Главная/Docker internals/Практика

Раздел 17. Практические задания

Раздел разбирает механизмы, часть которых доступна только с правами root. Задания построены так, чтобы основная часть выполнялась без привилегий — через API, через показания ядра изнутри container'а, через штатные команды.

Там, где привилегии всё же нужны, задание требует отметить шаг невыполненным, а не обойти его. Это то же правило, что в уроке 16.4: «не проверено» должно отличаться от «проверено и чисто».

Как проверять себя

ФормулировкаЧто требуется
«показать»Команда и её фактический вывод
«измерить»Число, полученное командой
«сверить»Два независимых источника, дающих один ответ
«отметить»Явное указание, что шаг не выполнялся, и почему

Задание 1. Клиент Engine API без CLI

Тип: обязательное
Материал: 17.1

Написать программу, работающую с Docker без установленного CLI.

Требования:

  1. Обратиться к API через Unix-сокет, получить версию и список container'ов.
  2. Создать и запустить container двумя запросами; показать Pid между ними.
  3. Прочитать логи, разобрав мультиплексированный поток по кадрам.
  4. Различить stdout и stderr по первому байту заголовка.
  5. Запустить программу в образе без Docker CLI и без сторонних пакетов.
  6. Сверить результат с выводом CLI.

Проверка

bash
curl -s --unix-socket /var/run/docker.sock http://localhost/version | python3 -m json.tool | head -4
python3 client.py
docker ps -aq | wc -l

Критерий

Число container'ов совпадает с выводом CLI. Кадры логов разобраны: stdout и stderr различены. Программа работает в образе, где CLI отсутствует.

Ориентир: формат кадра

Восемь байт заголовка перед каждым фрагментом:

text
байт 0    поток: 1 = stdout, 2 = stderr
байты 1-3 зарезервированы
байты 4-7 длина фрагмента, big-endian

Распаковка: struct.unpack(">BxxxI", data[:8]).

Отрезать первые 8 байт и считать задачу решённой недостаточно: на втором кадре появится второй заголовок.


Задание 2. Цепочка выполнения

Тип: обязательное
Материал: 17.2

Разобрать путь от docker run до процесса приложения.

Требования:

  1. Найти PID процесса на host и построить цепочку родителей.
  2. Показать, что процессов runc нет, и объяснить почему.
  3. Объяснить, кто хранит код выхода, и подтвердить это container'ом, процесса которого уже нет.
  4. Сверить состояние по трём источникам: docker inspect, ядро изнутри, config.json.
  5. Собрать OCI bundle вручную и разобрать его структуру.
  6. Отметить шаги, не выполненные из-за отсутствия прав.

Проверка

bash
pid=$(docker inspect ИМЯ --format '{{.State.Pid}}')
grep PPid /proc/$pid/status
pgrep -c runc; pgrep -c containerd-shim
docker inspect ИМЯ --format '{{.State.ExitCode}} {{.State.Pid}}'

Критерий

pgrep -c runc даёт ноль при работающих container'ах. Код выхода получен у container'а с Pid=0. Два доступных источника состояния согласуются.


Задание 3. Namespaces по inode

Тип: обязательное
Материал: 17.3

Определить, что именно изолирует Docker.

Требования:

  1. Сравнить все восемь namespaces container'а и host по значению readlink.
  2. Назвать, какие Docker не создаёт, и объяснить последствие каждого.
  3. Показать, что означает карта 0 0 4294967295, и подтвердить владельцем файла.
  4. Показать, что --network container: даёт тот же namespace, а не похожий.
  5. Создать изолированное окружение через unshare и перечислить, чего в нём нет.
  6. Объяснить, почему unshare --pid требует --fork.

Проверка

bash
pid=$(docker inspect ИМЯ --format '{{.State.Pid}}')
for n in mnt uts ipc pid net user cgroup time; do
  printf '%-8s host=%s cont=%s\n' "$n" \
    "$(readlink /proc/self/ns/$n)" "$(readlink /proc/$pid/ns/$n)"
done
docker exec ИМЯ cat /proc/self/uid_map

Критерий

Изолировано шесть namespaces; user и time совпадают с host. Совпадение inode у двух container'ов с общей сетью показано.

Ориентир для самопроверки

Сравнивать namespaces по поведению — по IP-адресу, по видимости процессов — недостаточно: это даёт вероятностный ответ. Совпадение строки net:[4026532574] означает тождество объекта ядра.

Признак хорошего решения по пункту 5: перечислено не менее пяти механизмов, которых нет в ручном окружении, — cgroups, pivot_root, seccomp, сеть, слои.


Задание 4. Чтение cgroup изнутри

Тип: обязательное
Материал: 17.4

Прочитать ограничения и статистику без прав root.

Требования:

  1. Найти cgroup container'а обоими способами: по пути на host и изнутри.
  2. Сопоставить не менее шести флагов docker run с файлами cgroup.
  3. Показать, что Docker не задаёт memory.high, и объяснить последствие.
  4. Прочитать memory.events и объяснить сочетание max > 0 при oom_kill = 0.
  5. Отличить рост кэша от настоящей нехватки, сравнив memory.current и anon.
  6. Измерить throttling двумя показателями: cpu.stat и cpu.pressure.

Проверка

bash
docker exec ИМЯ cat /proc/self/cgroup
docker exec ИМЯ sh -c 'cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.high /sys/fs/cgroup/cpu.max'
docker exec ИМЯ cat /sys/fs/cgroup/memory.events
docker exec ИМЯ grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat

Критерий

/proc/self/cgroup содержит 0::/. Соответствие шести флагов показано. Объяснено, что memory.high = max означает убийство вместо замедления.


Задание 5. Copy-up и слои

Тип: обязательное
Материал: 17.5

Измерить стоимость записи в файл образа.

Требования:

  1. Собрать образ с файлом на 128 МиБ.
  2. Измерить: запись байта в него, chmod на нём, создание нового файла того же размера, создание маленького файла.
  3. Показать размер writable layer после записи одного байта.
  4. Показать, что файл, удалённый инструкцией RUN rm, остаётся в слое с содержимым.
  5. Объяснить, почему RUN rm отдельной инструкцией не уменьшает образ.
  6. Определить, какое хранилище образов используется, двумя независимыми признаками.

Проверка

bash
docker run --rm ОБРАЗ sh -c 'time (echo x >> /big.bin)'
docker ps -s
docker diff ИМЯ
docker save ОБРАЗ | tar -t | head
docker info --format '{{.Driver}}'

Критерий

Запись байта заметно дороже создания маленького файла. Writable layer вырос на размер файла. Содержимое удалённого файла прочитано из слоя.


Задание 6. Изолированное окружение вручную

Тип: дополнительное
Материал: 17.3, 17.5

Воспроизвести часть работы Docker без Docker.

Требования:

  1. Получить корневую файловую систему через docker export.
  2. Создать namespaces командой unshare и показать, какие получились.
  3. Собрать overlay из двух нижних слоёв и одного верхнего — или отметить отсутствие прав.
  4. Показать перекрытие файлов и порядок старшинства слоёв.
  5. Перечислить, что Docker делает сверх этого, — не менее шести механизмов.
  6. Объяснить, почему это не делает Docker «тонкой обёрткой».

Проверка

bash
docker export $(docker create alpine:3.21 true) | tar -x -C rootfs
unshare --user --map-root-user --pid --fork --mount --uts id -u
sudo mount -t overlay overlay -o lowerdir=l2:l1,upperdir=u,workdir=w merged

Критерий

Окружение создано, namespaces перечислены. Список отсутствующих механизмов содержит не менее шести пунктов с пояснением каждого.


Задание 7. runc напрямую

Тип: дополнительное
Материал: 17.2

Запустить container без Docker и containerd.

Требования:

  1. Собрать OCI bundle: rootfs и config.json.
  2. Задать в спецификации: пользователя, пустые capabilities, лимиты памяти и CPU, набор namespaces.
  3. Сопоставить каждое поле с флагом docker run.
  4. Запустить через runc run — или отметить, что инструмент недоступен.
  5. Показать, что после запуска процесса runc не остаётся.
  6. Объяснить, что это означает для взаимозаменяемости сред выполнения.

Проверка

bash
cd bundle && runc spec
python3 -c "import json; print(json.load(open('config.json'))['linux']['namespaces'])"
sudo runc run demo
pgrep -c runc

Критерий

Bundle собран и валиден как JSON. Соответствие полей флагам показано не менее чем для шести пунктов.

Ориентир: минимальный `config.json`

Обязательны: ociVersion, processargs, cwd, user), rootpath), mounts для /proc.

Флаги docker run в полях:

ФлагПоле
--userprocess.user.uid, gid
--cap-drop=ALLprocess.capabilities.* — пустые списки
--read-onlyroot.readonly
--memorylinux.resources.memory.limit
--cpuslinux.resources.cpu.quota и period
--pids-limitlinux.resources.pids.limit

runc spec создаёт заготовку, которую остаётся отредактировать.


Задание 8. Диагностика: лимит задан, но не работает

Тип: диагностическое
Материал: весь раздел

docker inspect показывает Memory: 268435456, приложение потребляет больше и не убивается. Найти причину.

Требования:

  1. Назвать пять механизмов, дающих такое расхождение.
  2. Для каждого предложить проверку.
  3. Воспроизвести хотя бы один.
  4. Объяснить, почему docker inspect недостаточно для проверки.
  5. Предложить последовательность проверок от дешёвой к дорогой.

Критерий

Названы пять механизмов с проверкой. Один воспроизведён. Объяснено различие «запрошено» и «применено».

Ориентир: пять механизмов
МеханизмПроверка
Контроллер memory не включён в родительской cgroupcat /sys/fs/cgroup/cgroup.subtree_control
Считается page cache, а не только anon — потребление кажется большимgrep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
Подкачка не ограничена: --memory без --memory-swap даёт вдвоеcat /sys/fs/cgroup/memory.swap.max
Измеряется не тот процесс: ps на host показывает RSS, а не cgroupcat /sys/fs/cgroup/memory.current
cgroup v1 на этой машине: другие файлы и другая семантикаstat -fc %T /sys/fs/cgroup

Порядок проверок от дешёвой к дорогой:

  1. docker exec ИМЯ cat /sys/fs/cgroup/memory.max — применился ли лимит вообще
  2. memory.stat: anon против file — что именно занято
  3. memory.events — было ли давление
  4. memory.swap.max — не уходит ли в подкачку
  5. cgroup.subtree_control на host — включён ли контроллер

Почему docker inspect недостаточно: он показывает запрошенное при создании. Показания ядра изнутри container'а отвечают на вопрос, применилось ли оно.


Задание 9. Сверка трёх источников ★

Тип: итоговое
Материал: весь раздел

Написать инструмент, сверяющий состояние container'а по трём независимым источникам.

Требования:

  1. Источник 1 — Engine API: что запрошено при создании.
  2. Источник 2 — показания ядра изнутри container'а: /proc, /sys/fs/cgroup.
  3. Источник 3 — config.json из bundle containerd, если доступен.
  4. Сверить не менее восьми свойств: пользователь, capabilities, память, CPU, PID, корень только для чтения, namespaces, seccomp.
  5. Отметить расхождения и объяснить возможную причину каждого.
  6. Отличить «источник недоступен» от «значения различаются».
  7. Работать без прав root, используя первые два источника.

Проверка

Инструмент должен:

  • давать один результат на согласованном container'е;
  • находить расхождение на container'е, запущенном с изменёнными настройками;
  • отличать недоступный источник от расхождения;
  • возвращать разные коды: согласовано, расхождение, источник недоступен.

Критерий

Восемь свойств сверены. Расхождение воспроизведено намеренно и обнаружено. Недоступность третьего источника отмечена, а не засчитана как согласие.

Ориентир: почему три источника
ИсточникОтвечает на вопросТребует root
Engine APIЧто запрошеноНет
Ядро изнутриЧто применилосьНет
config.jsonЧто применил runcДа

Расхождение первого и второго означает: запрос не был выполнен. Причины — отключённый контроллер cgroup, несовместимая версия ядра, отсутствующая возможность.

Расхождение второго и третьего означало бы, что состояние изменилось после запуска, — редкий, но возможный случай.

Практическая ценность: инструмент отвечает на вопрос «действительно ли ограничения работают», на который docker inspect в одиночку ответить не может.


Сводная таблица

ЗаданиеТипОсновной материал
1Клиент Engine API без CLIобяз.17.1
2Цепочка выполненияобяз.17.2
3Namespaces по inodeобяз.17.3
4Чтение cgroup изнутриобяз.17.4
5Copy-up и слоиобяз.17.5
6Изолированное окружение вручнуюдоп.17.3
7runc напрямуюдоп.17.2
8Лимит задан, но не работаетдиаг.весь раздел
9Сверка трёх источниковвесь раздел

Что должно получиться

После заданий 1–5 у вас есть практическое подтверждение пяти утверждений раздела:

  1. CLI — преобразователь аргументов в HTTP-запрос; всё делает daemon.
  2. runc завершается после настройки; родитель процесса — shim.
  3. Docker создаёт шесть своих namespaces из восьми. user — хостовый; time на Docker 29.7.1 не хостовый, но одинаковый у всех container'ов.
  4. Ограничения читаются изнутри container'а без прав root.
  5. Запись одного байта в файл образа копирует его целиком.

Задания 6–7 воспроизводят часть работы Docker вручную и показывают, чего в ручном варианте нет.

Задание 8 отрабатывает главное различение раздела: запрошено против применено. Задание 9 превращает это различение в инструмент.

Что этот раздел меняет в работе

До него docker inspect был единственным источником истины о container'е. После — понятно, что он показывает намерение, а показания ядра изнутри container'а показывают результат, и эти два ответа могут не совпадать.

Это же объясняет приёмы из предыдущих разделов: почему /sys/fs/cgroup читают изнутри (урок 6.13), почему nsenter -n работает для образов без утилит (урок 13.5), почему секрет извлекается из слоя после RUN rm (урок 12.6).

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий раздел: Docker и Kubernetes →
Главное оглавление

Markdown на GitHub ↗