Что такое контейнер: namespaces, cgroups и отличие от VM
Lesson from the course “Курс подготовки DevOps-инженеров”
Хронометраж: занятие 60 минут — теория 30 минут, практика 30 минут. Домашняя работа: 1–2 часа.
Кратко о чём урок: Контейнер как изолированный процесс, а не «маленькая виртуалка»; какие ресурсы изолируются и какие нет; общее ядро и его следствия; где VM всё ещё нужна.
1. Что изучаем
- Объяснять, что контейнер — это процесс с ограничениями, и подтверждать это, найдя его на хозяине по номеру.
- Различать, какие пространства имён у контейнера свои, а какие общие, и проверять это командой.
- Читать фактические ограничения из cgroup, а не из
freeиnproc, и объяснять, почему привычные команды показывают не то. - Определять, какой параметр ядра принадлежит контейнеру, а какой всему хозяину.
- Отвечать, где выбор между контейнером и виртуальной машиной есть, а где его нет.
2. Зачем это нужно и где применяется
Дальше весь блок про то, как собирать образы и запускать контейнеры. Но почти все дорогие ошибки в этой области растут из одного неверного представления: что контейнер — это маленькая виртуальная машина. Он ей не является, и разница не философская.
Из этого представления следуют конкретные заблуждения, каждое из которых стоит времени.
«В контейнере своя система, значит можно поставить другое ядро» — нельзя, ядро одно и оно
хозяйское. «Контейнер изолирован, значит внутри можно запускать что угодно» — изоляция есть,
но она не такая полная, как у ВМ, и root внутри по умолчанию равен root снаружи.
«Я задал контейнеру лимит памяти, значит приложение его увидит» — не увидит: free покажет
память всей машины, и приложение, подбирающее размеры по ней, будет убито.
Третье заблуждение — самое дорогое, потому что проявляется не сразу и не там, где причина. Приложение работает на стенде, где лимит велик, а в продакшене с лимитом падает по нехватке памяти без внятной ошибки. Причина в том, что оно прочитало не тот источник.
Урок построен на измерениях: находим процесс контейнера на хозяине, сравниваем пространства
имён, смотрим, что показывает free против того, что разрешено, и различаем свои параметры ядра
от общих. Механизмы изоляции по отдельности разбирались в уроке 02.13 — здесь они собираются
в ответ на вопрос «что такое контейнер».
Важная оговорка про стенд. На macOS и Windows Docker Desktop запускает Linux в виртуальной
машине, и всё, что ниже называется «хозяином», — это она. Проверено: uname -r и в контейнере,
и на хозяине даёт 6.10.14-linuxkit, а памяти у этой машины 3914 МБ, а не столько, сколько
у ноутбука. На Linux хозяином будет сама машина; числа изменятся, механика — нет.
3. Теория (30 минут)
Контейнер — это процесс
Самый короткий путь к пониманию — найти процесс контейнера на хозяине. Запускаем контейнер
с sleep 300 и смотрим с двух сторон:
номер процесса НА ХОЗЯИНЕ: 3759
его команда внутри контейнера: sleep 300
его номер внутри контейнера: 1
его команда с точки зрения хозяина: sleep 300
процессов видит хозяин: 127
процессов видит контейнер: 4
Это один и тот же процесс с двумя номерами. На хозяине он 3759, внутри — 1. Никакого «внутри» физически нет: процесс запущен обычным образом, просто он не видит остальных 123 процессов, потому что у него своё пространство имён процессов.
Отсюда сразу несколько практических следствий. Контейнер можно найти и убить с хозяина обычным
kill. Его память и процессорное время видны в обычных инструментах хозяина. И он не «загружает
систему» как ВМ: работающий контейнер с sleep занимает меньше мегабайта (замеры разных
прогонов: 540–604 КиБ), потому
что своей системы у него нет.
Одно ядро на всех
Второй опыт, который отделяет контейнер от ВМ окончательно:
контейнер debian:13: 6.10.14-linuxkit
контейнер alpine:3: 6.10.14-linuxkit
хозяин: 6.10.14-linuxkit
Два разных дистрибутива — Debian и Alpine, с разными наборами библиотек, разной системой инициализации, разным менеджером пакетов, — и одно ядро. То, что образ называется «debian», означает набор файлов в пользовательском пространстве, а не ядро.
Отсюда три следствия, каждое из которых однажды попадается.
Версию ядра образом не выбрать. Если приложению нужна возможность, появившаяся в ядре 6.12, а на хозяине 6.10, никакой образ не поможет: нужно обновлять хозяин.
Архитектура должна совпадать. Образ, собранный под amd64, не запустится на arm64 без
эмуляции, и наоборот. На машинах с процессорами Apple это встречается ежедневно, и ошибка
выглядит невнятно — «exec format error».
Уязвимость в ядре касается всех контейнеров сразу. У ВМ своё ядро, и её изоляция глубже. Это то, из-за чего недоверенный код в контейнерах не запускают, а берут микро-ВМ или прослойки вроде gVisor.
Какие границы у контейнера есть, а каких нет
Изоляция контейнера складывается из пространств имён — механизма ядра, который даёт процессу свой экземпляр какого-то ресурса. Их разбор был в уроке 02.13; здесь важно, какие из них Docker создаёт по умолчанию. Проверено сравнением с процессом 1 хозяина:
| Пространство | У контейнера своё? | Что это значит |
|---|---|---|
| pid | да | свои номера процессов, соседей не видно |
| mnt | да | своя файловая система |
| net | да | свои интерфейсы, адреса, порты |
| uts | да | своё имя узла |
| ipc | да | своя разделяемая память и очереди |
| cgroup | да | свой вид на дерево ограничений |
| user | НЕТ — общее | root внутри = root снаружи |
Последняя строка — то, что стоит запомнить из всего урока. По умолчанию Docker пространство
пользователей не создаёт, и идентификатор совпадает буквально: 4026531837 и внутри,
и на хозяине. Пользователь с номером 0 в контейнере — тот же пользователь с номером 0 на
хозяине.
Практическое следствие: процесс в контейнере, работающий от root, при получении доступа
к чему-то хозяйскому (смонтированный каталог, устройство, сокет демона Docker) действует
на хозяине с правами root. Именно поэтому --privileged опасен, а не потому что «так
написано в статьях». Отдельно этому посвящён урок 08.10; включение пространства пользователей
(userns-remap) — настройка демона, а не образа.
free и nproc в контейнере лгут
Самая дорогая ловушка, и она измерима одним прогоном. Запускаем контейнер с ограничением в 256 МБ памяти и одно ядро:
free и /proc/meminfo говорят: 3914 МБ
nproc говорит: 4 ядер
а разрешено cgroup:
memory.max: 256 МБ
cpu.max: 100000 100000 (доля ядра = первое/второе)
занято сейчас: 1 МБ
Расхождение в пятнадцать раз по памяти и в четыре — по процессорам. Причина простая: free
и nproc читают /proc, а /proc показывает ядро хозяина. Ограничения живут не там,
а в cgroup, и привычные команды о них не знают.
Практическое следствие — то, из-за чего эту ловушку стоит знать наизусть. Многие среды исполнения подбирают внутренние размеры по объёму памяти: размер области для объектов, число рабочих потоков, размеры буферов. Прочитав память хозяина, такая среда выберет размеры под четыре гигабайта, займёт больше лимита и будет убита ядром по превышению. В журнале приложения при этом не будет ничего — процесс не падает с ошибкой, его убивают снаружи.
Правильный источник — файлы cgroup: /sys/fs/cgroup/memory.max, /sys/fs/cgroup/cpu.max,
/sys/fs/cgroup/memory.current. Современные среды исполнения умеют их читать, но это надо
проверить, а не предположить: в карточке урока под это отведён отдельный вопрос.
Про cpu.max полезно понимать запись: 100000 100000 означает «100000 микросекунд времени
за период 100000 микросекунд», то есть ровно одно ядро. --cpus 0.5 дало бы 50000 100000.
Это не привязка к конкретному ядру, а доля процессорного времени.
Root в контейнере — это root с урезанными правами
Предыдущий вывод — «пространство пользователей общее, root внутри равен root снаружи» — верен, но неполон, и без второй половины он пугает сильнее, чем следует.
Права процесса в Linux определяются не только идентификатором пользователя, но и набором
возможностей (capabilities): отдельных разрешений вроде «менять владельца файла»,
«привязываться к порту ниже 1024», «монтировать файловые системы». У обычного root на хозяине
они все. Docker по умолчанию оставляет контейнеру часть. Проверено:
обычный контейнер: возможностей 14
cap_chown, cap_dac_override, cap_fowner, cap_fsetid, cap_kill, cap_setgid,
cap_setuid, cap_setpcap, cap_net_bind_service, cap_net_raw, cap_sys_chroot,
cap_mknod, cap_audit_write, cap_setfcap
cap_sys_admin в списке: НЕТ
cap_sys_module в списке: НЕТ
с --privileged: возможностей ВСЕ (набор «=ep»)
Четырнадцать из примерно сорока. И самой мощной — cap_sys_admin, которая открывает
монтирование, изменение пространств имён и многое другое, — в списке нет. Видно это не по
описанию, а по попытке:
монтирование tmpfs в обычном контейнере: mount: /mnt: permission denied.
то же с --privileged: прошло
Отсюда точная формулировка, которую стоит запомнить вместо обеих крайностей: root
в контейнере — это root с урезанным набором возможностей. Не «безопасный пользователь» и не
«полный root на хозяине». А флаг --privileged возвращает весь набор, и именно поэтому он
опасен: не потому, что «так говорят», а потому что снимает разом все ограничения, тогда как
задаче обычно нужна одна возможность.
Правильный приём — выдавать конкретное: --cap-add=NET_ADMIN вместо --privileged, если нужны
настройки сети; --device вместо --privileged, если нужно устройство. Подробно про это —
урок 08.10, но привычка складывается здесь.
Какие параметры ядра принадлежат контейнеру
Продолжение той же темы, и здесь есть тонкость в самой методике проверки. Часть параметров ядра неймспейсена — у каждого контейнера своё значение; часть общая — она одна на всю машину.
Различить чтением нельзя: одинаковое значение может означать и «параметр общий», и «параметр свой, но со тем же значением по умолчанию». Проверено — четыре параметра дали одинаковые числа в контейнере и на хозяине, и по этому выводу нельзя было сказать ничего.
Различает попытка задать. Docker отказывается устанавливать параметр, который не принадлежит контейнеру:
net.core.somaxconn=1024 свой у контейнера
net.ipv4.ip_forward=0 свой у контейнера
vm.max_map_count=100000 ОБЩИЙ с хозяином (Docker отказал)
kernel.pid_max=50000 ОБЩИЙ с хозяином (Docker отказал)
И проверка, что заданное действительно осталось внутри:
в контейнере somaxconn: 1024
у хозяина somaxconn: 4096
Отказ Docker здесь не ограничение инструмента, а защита: изменение общего параметра из
контейнера задело бы весь хозяин и все остальные контейнеры. Если такой параметр всё же нужен
(например, vm.max_map_count для поисковых движков), его меняют на хозяине, и это
становится требованием к машине, а не свойством образа. Формулировка в документации на
развёртывание должна выглядеть именно так: «на узле требуется vm.max_map_count не меньше
262144».
Где выбор между контейнером и ВМ есть, а где его нет
Сравнение по памяти и скорости обычно приводят первым, и оно верное, но не самое важное. Числа со стенда: работающий контейнер занимает меньше мегабайта — семь прогонов дали 540–604 КиБ, — а в уроке 02.02 под учебную виртуальную машину требовалось выделить 1–2 ГБ памяти и 10 ГБ диска. Сравнение не совсем честное — выделенное против фактически занятого, — но порядок разницы виден.
Время запуска на этом стенде измерять бессмысленно: пять прогонов дали 1020–1491 мс, и почти всё это — обращение клиента к демону через виртуальную машину Docker Desktop, а не запуск контейнера. Числа приведены, чтобы вы не приняли их за характеристику контейнеров.
Гораздо полезнее список того, где выбора нет.
Только виртуальная машина: другая версия ядра; загрузка модуля ядра; операционная система, отличная от Linux; изменение общего параметра ядра только для себя; изоляция недоверенного кода.
Первые два пункта стоит увидеть, а не принять на слово, потому что оба упираются в один и тот же факт — ядро общее. Список загруженных модулей внутри контейнера читается, но он не свой:
/proc/modules в контейнере: 8 строк
/proc/modules на хозяине: 8 строк
Один и тот же список, потому что модули принадлежат ядру, а ядро одно. Загрузить свой модуль
контейнер не может даже попытаться: в образе нет ни modprobe (код=127), ни каталога
/lib/modules. И это не недоработка образа — модуль всё равно попал бы в ядро хозяина,
то есть подействовал бы на все контейнеры машины и на саму машину. Отсюда и правило: нужна
своя версия ядра или свой модуль — нужна виртуальная машина, обсуждать тут нечего.
Только контейнер (практически): сотни экземпляров одного сервиса на машине; выкат новой версии за секунды; одинаковое окружение от разработки до продакшена одним артефактом.
Оба подходят, и решает удобство: один долгоживущий сервис на выделенной машине.
И то, что на практике встречается чаще всего: контейнеры внутри виртуальных машин. Именно так устроены все облачные сервисы контейнеров и, кстати, сам Docker Desktop. Это не компромисс от нерешительности, а разумное разделение: ВМ даёт границу между клиентами и защиту на уровне ядра, контейнеры — плотность и скорость выката внутри этой границы.
3.1. Примеры и варианты реализации
Одна задача — три среды. Если вариант в этом уроке неприменим, это указывается явно с объяснением причины.
| Среда | Как решается | Что нужно иметь |
|---|---|---|
| Локальный стенд (Docker / VM / kind) | опыты через флаги, снимающие изоляцию: --pid=host, --privileged, --sysctl |
Docker, образ course-probe:13 из примеров блока |
| VPS / bare-metal | то же, но хозяин — сама машина: числа реальны, nsenter не нужен |
Linux с Docker, доступ sudo |
| Yandex Cloud | контейнеры в Managed Service for Kubernetes или Serverless Containers; доступа к хозяину нет | кластер или сервис контейнеров |
Локальный стенд. Все измерения урока получены здесь. Главное ограничение уже названо:
на macOS и Windows «хозяин» — это виртуальная машина Docker Desktop, поэтому версия ядра
(linuxkit) и объём памяти (3914 МБ) специфичны для неё. И ещё одно: пространство имён
пользователей проверено в состоянии по умолчанию, то есть общим с хозяином; включение
userns-remap картину меняет, но это настройка демона, и на общем стенде её не трогаем.
VPS / bare-metal. Тот же урок, но честнее: хозяин — настоящая машина, числа памяти и ядер
реальные, а процессы контейнеров видны в обычном ps без всяких флагов. Появляется и то, чего
на стенде нет: возможность изменить общий параметр ядра и увидеть, как это подействовало
на все контейнеры сразу. Делать это стоит на учебной машине, а не на рабочей.
Yandex Cloud. Здесь доступа к хозяину нет вовсе, и это принципиально: половину опытов урока воспроизвести нельзя. Зато становятся видны следствия. Ограничения задаются описанием ресурсов, и приложение обязано читать их из cgroup, потому что никакого «посмотреть на хозяине» уже не будет. В Serverless Containers нет даже понятия узла — только лимиты. Это тот случай, когда понимание механики важнее доступа к ней: диагностировать «убит по памяти» приходится по косвенным признакам.
Что выбрать. Разбираться — на локальном стенде, а если есть Linux-машина, то на ней: там не надо мысленно вычитать прослойку Docker Desktop. Для рабочих задач вопрос ставится иначе, и правильный порядок такой: сначала посмотреть в список «где выбора нет», и только если задача там не нашлась — выбирать по удобству и стоимости.
4. Практика на занятии (30 минут)
Курс проходят самостоятельно, поэтому каждый шаг говорит, что ввести, зачем, что должно получиться и что делать при неудаче.
Что делаем. Семь опытов, каждый показывает границу контейнера с двух сторон — изнутри и с хозяина. Все числа теории получены этими командами.
Файлы для работы: blocks/08-containers/examples/08.01-what-is-container/
Стенд — образ course-probe:13, описанный в
blocks/08-containers/examples/docker/. Особенность блока 08:
изучаемый предмет — сам контейнер, поэтому часть опытов использует флаги, снимающие изоляцию
(--pid=host, --privileged). В рабочей эксплуатации это почти всегда ошибка; здесь они
инструмент наблюдения, и в README стенда расписано, какой флаг зачем.
cd blocks/08-containers/examples/docker
docker build -t course-probe:13 -f Dockerfile.probe .
cd ../08.01-what-is-container
./lab-container.sh # список режимов
Шаг 1 (3 минуты). Одно ядро на всех.
-
Что ввести:
./lab-container.sh kernel -
Зачем: убедиться, что контейнер и хозяин делят одно ядро, — это первое, что отделяет контейнер от виртуальной машины.
-
Ожидаемый результат: три строки с одной и той же версией ядра (на этом стенде
6.10.14-linuxkitуdebian:13,alpine:3и хозяина). -
Если не так: команда падает — образ
course-probe:13не собран, вернитесь к сборке выше. На Linux-хозяине версия будет иной, но одинаковой во всех трёх строках — это норма, а не ошибка. -
Ответьте: что из общего ядра следует для выбора версии ядра и для архитектуры образа?
Шаг 2 (6 минут). Какие границы есть. Главный опыт занятия.
-
Что ввести:
./lab-container.sh namespaces -
Зачем: увидеть, какие пространства имён Docker создаёт контейнеру, а какое оставляет общим с хозяином.
-
Ожидаемый результат: pid, mnt, net, uts, ipc, cgroup у контейнера свои, а пространство пользователей совпадает с хозяином — один и тот же идентификатор (на стенде
4026531837). -
Если не так: если идентификатор пользователя тоже различается — на демоне включён userns-remap; на общем стенде его не трогаем.
-
Ответьте: найдите в выводе одинаковый идентификатор и объясните, что это значит для процесса, работающего от
root.
Шаг 3 (3 минуты). Процесс с двух сторон.
-
Что ввести:
./lab-container.sh process -
Зачем: подтвердить, что контейнер — это один процесс с двумя номерами, а не «внутренняя система».
-
Ожидаемый результат: один и тот же процесс виден и на хозяине (например, номер 3759), и внутри (номер 1); хозяин видит 127 процессов, контейнер — 4.
-
Если не так: если номера процессов совпали с обеих сторон — не сработал флаг
--pid=host; проверьте, что запускаете именно режимprocess. -
Ответьте: сравните число процессов, которое видит хозяин и которое видит контейнер.
Шаг 4 (6 минут). Что видно и что разрешено. Самая дорогая ловушка.
-
Что ввести:
./lab-container.sh limits -
Зачем: показать, что
freeиnprocчитают хозяина, а настоящий лимит лежит в cgroup. -
Ожидаемый результат:
freeи/proc/meminfoпоказывают 3914 МБ,nproc— 4 ядра, аmemory.maxравен 256 МБ иcpu.max—100000 100000(ровно одно ядро). -
Если не так: если числа не расходятся — лимиты не заданы; проверьте, что режим
limitsзапускает контейнер с--memoryи--cpus. -
Ответьте: что произойдёт с приложением, которое выбирает размеры по
free?
Шаг 5 (4 минуты). Свои и общие параметры ядра.
-
Что ввести:
./lab-container.sh sysctl -
Зачем: научиться различать неймспейсенный параметр от общего — и увидеть, что чтением значения это не определить.
-
Ожидаемый результат: Docker принимает
net.core.somaxconnиnet.ipv4.ip_forward(свои у контейнера) и отвергаетvm.max_map_countиkernel.pid_max(общие с хозяином); внутриsomaxconnравен 1024 при 4096 у хозяина. -
Если не так: чтением различить нельзя — значения по умолчанию совпадают; смотрите не на значение, а на то, принял Docker параметр или отказал.
-
Ответьте: почему совпадение значений в контейнере и на хозяине ещё не доказывает, что параметр общий?
Шаг 6 (5 минут). Что на самом деле может root в контейнере.
-
Что ввести:
./lab-container.sh caps -
Зачем: заменить обе крайности точной формулировкой — root в контейнере это root с урезанным набором возможностей.
-
Ожидаемый результат: у обычного контейнера четырнадцать возможностей,
cap_sys_adminсреди них нет,mount -t tmpfsотвечаетpermission denied; с--privilegedнабор полный (=ep) и монтирование проходит. -
Если не так: если насчитали ноль или странное число — не считайте
grep -c cap_, берите строкуCurrent:, а вывод подтверждайте поведением (прошло монтирование или нет). -
Ответьте: почему
--privilegedопаснее точечного--cap-add?
Шаг 7 (3 минуты). Сколько стоит контейнер.
-
Что ввести:
./lab-container.sh cost -
Зачем: сравнить контейнер с виртуальной машиной по цене и понять, какое число здесь надёжно.
-
Ожидаемый результат: память работающего контейнера — меньше мегабайта (540–604 КиБ); время запуска 1020–1491 мс, и почти всё это накладные расходы обращения клиента к демону через виртуальную машину Docker Desktop.
-
Если не так: большое время старта на Docker Desktop — норма, а не свойство контейнера; сравнивайте по памяти, а не по времени.
-
Ответьте: какое из двух чисел — память или время старта — годится для сравнения с ВМ на этом стенде и почему?
Проверка результата. Вы можете показать процесс контейнера на хозяине по номеру, назвать
пространство имён, которое Docker по умолчанию не создаёт, и объяснить, почему free в
контейнере показывает не то. Уберите за собой: docker ps -a не должен показывать контейнеров
с именами lab71-* — скрипт снимает их сам.
5. Домашняя работа (1–2 часа)
Задание. Три части: описать, сравнить, найти риск в своём.
Часть 1. Карточка контейнера. Заполните
container-card.md по любому работающему
контейнеру — своему рабочему или из стендов блока 07 (там их много: приложение, база, кэш,
брокер). В разделе 4 обязательно заполните оба столбца — «что показывают привычные команды»
и «что разрешено» — и скажите, совпадают ли они.
Часть 2. Сравнение с виртуальной машиной. Заполните
vm-vs-container.md. Числа для
контейнера измерьте сами; для ВМ возьмите из урока 02.02 или измерьте на своей, если она ещё
жива. Для каждого числа отмечайте, выделено оно или фактически занято, — это разные
величины.
Часть 3. Найти риск в своём приложении. Возьмите приложение из стендов блока 07 (или своё)
и ответьте на вопрос: читает ли оно объём памяти или число процессоров самостоятельно?
Проверять надо не по памяти, а поиском по коду и настройкам: обращения к os.cpu_count(),
multiprocessing.cpu_count(), nproc, /proc/meminfo, автоматический подбор числа рабочих
процессов.
Если такие места есть — покажите замером, что они видят при заданном лимите, и предложите исправление (читать cgroup либо задавать число явно переменной окружения). Если таких мест нет — покажите, чем вы это подтвердили.
Критерии приёмки.
- В карточке заполнены все шесть строк раздела 1; совпадение версий ядра подтверждено командой.
- Таблица пространств имён заполнена целиком, включая строку
user; сказано, что означает её результат. - Указаны оба номера процесса контейнера — на хозяине и внутри — и подтверждены выводом.
- В разделе 4 карточки заполнены оба столбца, и дан ответ, совпадают ли они.
- Проверены минимум два параметра ядра: один свой, один общий; различены попыткой задать, а не чтением.
- В сравнении с ВМ все числа для контейнера измерены самостоятельно.
- Для каждого числа указано, выделено оно или занято.
- Таблица «что можно, а что нельзя» заполнена целиком.
- В разделе «где выбора нет» у каждой строки есть объяснение «почему».
- По третьей части: приведён результат поиска по коду — либо найденные места, либо подтверждение, что их нет.
- Если места найдены: показано замером, что они видят при лимите, и предложено исправление.
- Раздел 6 карточки заполнен: приведена строка
Current:, число возможностей и ответ проcap_sys_admin. - Обе поведенческие попытки из раздела 6 выполнены, результат записан фактическим выводом.
- Контейнеры опытов убраны:
docker ps -aчист от временных.
6. Частые ошибки и подводные камни
«Контейнер — это маленькая виртуальная машина». Симптом: попытки поставить в образ другое ядро, загрузить модуль, ждать от контейнера изоляции уровня ВМ. Причина: неверная модель. Что делать: помнить измерение — Debian, Alpine и хозяин показывают одну версию ядра.
Приложение читает память через free или /proc/meminfo. Симптом: в продакшене с лимитом
процесс убивают по памяти, в журнале приложения ничего нет. Проверено: free показал 3914 МБ
при лимите 256 МБ. Что делать: читать /sys/fs/cgroup/memory.max либо задавать размеры явно
переменной окружения.
Число рабочих процессов подбирается по nproc. Симптом: приложение с лимитом в одно ядро
запускает четыре процесса и работает медленнее, чем один. Проверено: nproc показал 4 при
cpu.max, равном одному ядру. Что делать: читать cpu.max либо задавать число явно.
Ожидание, что root в контейнере безобиден. Симптом: процесс в контейнере изменил файлы
хозяина в смонтированном каталоге от root. Причина: пространство имён пользователей по
умолчанию не создаётся, и root внутри равен root снаружи — проверено, идентификатор
совпадает. Что делать: запускать от непривилегированного пользователя (урок 08.04), не давать
--privileged без причины (08.10).
Обратная крайность: «root в контейнере — это полный root на хозяине». Симптом: отказ
запускать что-либо от root даже там, где это безопасно, и вера, что контейнер не даёт вообще
ничего. Причина: верное наблюдение достроено до неверного вывода. Проверено: у контейнерного
root четырнадцать возможностей, cap_sys_admin среди них нет, и mount -t tmpfs отвечает
permission denied. Что делать: держать точную формулировку — root с урезанным набором
возможностей, — и оценивать риск по набору, а не по имени пользователя.
Определение набора прав через grep -c cap_. Симптом: посчитанное число не сходится или
получается ноль. Причина: capsh --print печатает несколько строк с cap_, а полный набор
записывается коротко — =ep, вообще без имён. Что делать: брать строку Current: и разбирать
её отдельно, а вывод проверять поведением: прошло монтирование или нет. Эта ошибка сделана при
подготовке урока — первый замер сообщил ноль возможностей у --privileged.
--privileged «чтобы заработало». Симптом: работает, но контейнер получает почти все права
на хозяине. Причина: флагом снимают ограничение, не разобравшись, какое именно нужно.
Что делать: выдавать конкретную возможность (--cap-add) или конкретное устройство
(--device), а не всё сразу.
Попытка поменять общий параметр ядра из контейнера. Симптом: docker run --sysctl отвергает
параметр. Причина: параметр не принадлежит контейнеру. Что делать: менять на хозяине и записать
это как требование к узлу в документации на развёртывание.
Определение «свой или общий» чтением значения. Симптом: вывод «параметр общий», потому что значения совпали, — и он неверен. Причина: у неймспейсенного параметра то же значение по умолчанию. Что делать: проверять попыткой задать.
Образ под другую архитектуру. Симптом: exec format error при запуске. Причина: образ
собран под amd64, машина arm64 (или наоборот). Что делать: смотреть Architecture
у образа; собирать под нужную платформу — подробно в уроке 08.02.
Вывод о скорости контейнеров по замеру на Docker Desktop. Симптом: «контейнер запускается секунду» и построенные на этом рассуждения. Причина: замер включает обращение клиента к демону через виртуальную машину. Проверено: 1020–1491 мс, и почти всё это накладные расходы. Что делать: сравнивать по памяти (меньше мегабайта у контейнера), а не по времени старта на этой платформе.
Уверенность, что контейнер защищает от недоверенного кода. Симптом: чужой код запускают в контейнере и считают вопрос решённым. Причина: ядро общее, и его уязвимость касается всех контейнеров сразу. Что делать: для недоверенного кода — виртуальная машина, микро-ВМ или прослойка вроде gVisor.
7. Проверка усвоения
- Контейнер запущен командой
sleep 300. Как найти его процесс на хозяине и какой у процесса номер там и внутри? - Debian и Alpine в контейнерах показывают одну и ту же версию ядра. Почему, и что из этого следует для приложения, которому нужна возможность нового ядра?
- Перечислите пространства имён, которые Docker создаёт по умолчанию. Какого в этом списке нет и что это меняет?
free -mв контейнере показывает 3914 МБ, а лимит задан в 256 МБ. Почему так и откуда читать правильное значение?- Приложение само выбирает число рабочих процессов. Что оно выберет в контейнере с одним разрешённым ядром и почему это плохо?
cpu.maxсодержит50000 100000. Сколько процессорного времени разрешено контейнеру?- Как определить, принадлежит ли параметр ядра контейнеру или всему хозяину? Почему сравнение значений не годится?
- Приложению нужен
vm.max_map_countбольше стандартного. Что вы напишете в документации на развёртывание? - Процесс в контейнере работает от
root, ноmount -t tmpfs none /mntотвечаетpermission denied. Как это объяснить и чем--privilegedотличается от--cap-add? - Назовите три задачи, для которых контейнер не подходит принципиально, а нужна виртуальная машина.
- Почему облачные сервисы контейнеров запускают их внутри виртуальных машин?
8. Материалы
namespaces(7)— первоисточник по пространствам имён: полный список, что именно изолирует каждое, как смотреть через/proc/<pid>/ns.user_namespaces(7)— отдельно про пространство пользователей и отображение идентификаторов; объясняет, почему без негоrootвнутри равенrootснаружи.capabilities(7)— полный список возможностей и что каждая разрешает; здесь же описан формат строкиCurrent:, включая короткую запись=ep.cgroups(7)— обзор механизма ограничений, различие версий 1 и 2.- Документация ядра: cgroup v2 —
описание файлов
memory.max,cpu.max,memory.current; те самые, откуда надо читать лимиты. clone(2)— системный вызов, которым создаются пространства имён; полезно посмотреть на флагиCLONE_NEW*, чтобы увидеть, что «создать контейнер» — это набор флагов при создании процесса.- Docker: ограничение ресурсов —
--memory,--cpusи что именно они задают в cgroup. - Docker:
--sysctl— список параметров, которые можно задать контейнеру; всё остальное принадлежит хозяину. - Docker: пространство имён пользователей (userns-remap) — как включить отображение идентификаторов и какие ограничения при этом появляются.
- OCI Runtime Specification — стандарт, описывающий контейнер как набор настроек: пространства имён, cgroup, возможности. Полезно прочесть раздел про Linux-специфичную часть: там видно, что контейнер — это описание, а не сущность.
← Предыдущий: 07.10. Бэкапы данных: логические, физические, проверка восстановления · Блок 08 · Следующий: 08.02. Образы, слои, OCI и реестры →
Practice materials
Scripts, cheat sheets and starter files for the lesson tasks. The lesson text is on this page.
-
Lesson materials 08.01 «Что такое контейнер namespaces, cgroups и отличие от VM».zip