Школа

← К программе

Что такое контейнер: namespaces, cgroups и отличие от VM

Урок курса «Курс подготовки 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. Проверка усвоения

  1. Контейнер запущен командой sleep 300. Как найти его процесс на хозяине и какой у процесса номер там и внутри?
  2. Debian и Alpine в контейнерах показывают одну и ту же версию ядра. Почему, и что из этого следует для приложения, которому нужна возможность нового ядра?
  3. Перечислите пространства имён, которые Docker создаёт по умолчанию. Какого в этом списке нет и что это меняет?
  4. free -m в контейнере показывает 3914 МБ, а лимит задан в 256 МБ. Почему так и откуда читать правильное значение?
  5. Приложение само выбирает число рабочих процессов. Что оно выберет в контейнере с одним разрешённым ядром и почему это плохо?
  6. cpu.max содержит 50000 100000. Сколько процессорного времени разрешено контейнеру?
  7. Как определить, принадлежит ли параметр ядра контейнеру или всему хозяину? Почему сравнение значений не годится?
  8. Приложению нужен vm.max_map_count больше стандартного. Что вы напишете в документации на развёртывание?
  9. Процесс в контейнере работает от root, но mount -t tmpfs none /mnt отвечает permission denied. Как это объяснить и чем --privileged отличается от --cap-add?
  10. Назовите три задачи, для которых контейнер не подходит принципиально, а нужна виртуальная машина.
  11. Почему облачные сервисы контейнеров запускают их внутри виртуальных машин?

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 и реестры →

Материалы для практики

Скрипты, шпаргалки и заготовки к заданиям урока. Текст урока — на этой странице.