Docker и контейнеризация: квиz для самопроверки¶
Компаньон к мануалу 03 про Docker. Сначала попробуйте ответить на все вопросы самостоятельно, потом сверьтесь с разделом «Ответы». Не подглядывайте раньше времени — так проверка честнее.
Вопросы для самопроверки¶
-
Где проходит граница ответственности между Docker и оркестратором? Что решает Docker сам по себе, а что остаётся за Kubernetes/Nomad/ECS?
-
Контейнеризация держится на трёх механизмах ядра Linux. Назовите их и объясните своими словами, за что отвечает каждый (что процесс видит и что он потребляет).
-
Объясните разницу между понятиями Dockerfile, Image, Container и Registry — и подберите к каждому бытовую аналогию (не обязательно ту, что в мануале). Что даёт стандартизация OCI и почему то, что Docker-образ спокойно запускается в containerd или Podman — не случайность?
-
Сценарий. Вы разворачиваете мультитенантный SaaS: нужна жёсткая граница безопасности между клиентами, но также важны скорость деплоя и высокая плотность сервисов на хосте. Одни коллеги предлагают «просто контейнеры», другие — «просто ВМ на клиента». Какую архитектуру вы предложите и почему?
-
Опишите путь запроса от
docker runдо работающего процесса в контейнере — какую роль играютdockerd, BuildKit,containerdиrunc? Почему проброс/var/run/docker.sockвнутрь контейнера считается серьёзным риском безопасности? -
Сценарий. В Dockerfile сначала идёт
COPY . ., а потомRUN npm ci. Разработчики жалуются, что любая правка одной строчки кода вызывает 2-3-минутную пересборку, хотяpackage.jsonони не трогали. В чём причина и как переписать Dockerfile, чтобы правка кода пересобирала образ за секунды? -
Зачем нужен multi-stage build? Объясните, за счёт чего образ Go-сервиса на
distrolessполучается порядка 15 МБ, если стадия сборки весит около 800 МБ. -
Почему
alpineсчитается частой «ловушкой» при выборе базового образа для Python и Node.js-сервисов, несмотря на то что он в разы меньшеdebian-slim? -
В чём разница между тегом образа (
myapp:1.4.2) и digest (myapp@sha256:...)? На что должен опираться деплой в production и почему тегlatestв проде — это, по сути, гарантированная невоспроизводимость? -
Чем сеть
bridgeпо умолчанию отличается от пользовательской (user-defined) bridge-сети? Что конкретно перестаёт работать, если несколько контейнеров многоконтейнерного стенда запущены в сети по умолчанию, и как Docker Compose решает эту проблему автоматически? -
Почему
CMD/ENTRYPOINTнужно писать в exec-форме (CMD ["node", "main.js"]), а не в shell-форме (CMD node main.js)? Что произойдёт приdocker stop, если инструкция написана в shell-форме? -
Сценарий. В вашем приложении есть три вида данных: (а) данные Postgres, которые обязаны пережить пересоздание контейнера; (б) исходный код, проброшенный для hot-reload при локальной разработке; (в) временный секрет, который ни в коем случае не должен осесть на диске. Какой механизм хранения (named volume / bind mount / tmpfs) подходит для каждого случая и почему?
Ответы (открыть после самопроверки)
1. Docker решает задачу упаковки и запуска одного или нескольких контейнеров **на одной машине**: сборка образа, локальный запуск, сеть/тома на хосте, `docker compose` для небольшого одно-хостового стенда. Вопросы «раскатить на N узлов, пережить падение узла, автомасштабирование, rolling update с health-гейтами, централизованные секреты» — зона ответственности оркестратора (Kubernetes, Nomad, ECS). Docker — фундамент, на котором стоит оркестрация, но не заменяет её. 2. **Namespaces** изолируют то, что процесс *видит* (свой список процессов `pid`, свой сетевой стек `net`, свою файловую систему `mnt` и т.д.). **cgroups** ограничивают то, что процесс *потребляет* (лимиты CPU, памяти). **Union/overlay FS** (OverlayFS, Copy-on-Write) собирает файловую систему контейнера из read-only слоёв плюс тонкий writable-слой поверх них. 3. Dockerfile — текстовый рецепт сборки (аналог исходного кода). Image — неизменяемый набор слоёв с метаданными запуска (аналог скомпилированного бинарника). Container — запущенный экземпляр образа с собственным writable-слоем (аналог процесса). Registry — версионированное хранилище образов (аналог артефактория/npm-registry). Один образ порождает сколько угодно контейнеров, как один бинарник — сколько угодно процессов. OCI (Open Container Initiative) стандартизирует формат образа, спецификацию runtime и протокол реестра, поэтому образ, собранный Docker, без проблем запускается в containerd, CRI-O или Podman — это не совпадение, а прямое следствие общего стандарта (именно поэтому Kubernetes с 1.24, убрав dockershim, продолжает прекрасно работать с Docker-образами). 4. Стандартная продакшен-схема — **контейнеры внутри ВМ**, а не «или-или». ВМ даёт жёсткую, аппаратную границу безопасности между тенантами; контейнеры внутри неё дают скорость запуска, удобство упаковки и плотность. Изоляция контейнеров сама по себе мягче (общее ядро с хостом, при эксплуатации уязвимости ядра выход из контейнера принципиально возможен), поэтому для жёсткой межтенантной границы полагаться только на контейнеры рискованно. 5. `docker` CLI — тонкий клиент, отправляющий REST-запросы демону `dockerd` через Unix-сокет `/var/run/docker.sock`. `dockerd` — владелец всего состояния (образы, контейнеры, сети, тома), работает от root и делегирует сборку **BuildKit**, а жизненный цикл контейнеров — **containerd** (отдельный CNCF-проект, которым Kubernetes пользуется напрямую). containerd, в свою очередь, вызывает **runc** — эталонную реализацию OCI Runtime Spec, которая непосредственно разворачивает namespaces и cgroups и запускает процесс. Доступ к `docker.sock` эквивалентен root-доступу к хосту именно потому, что `dockerd` работает от root: получив сокет внутрь контейнера, процесс может попросить демона запустить новый привилегированный контейнер с примонтированным корнем хоста — это прямой побег из изоляции. 6. BuildKit кэширует результат каждой инструкции, и кэш инвалидируется, как только меняются входные данные инструкции (для `COPY` — контрольная сумма копируемых файлов); при этом рвётся кэш **этого и всех последующих** слоёв. `COPY . .` перед `RUN npm ci` означает, что любая правка любого файла в репозитории (в том числе не связанного с зависимостями) инвалидирует установку зависимостей. Исправление: сначала `COPY package*.json ./`, затем `RUN npm ci` (или `--mount=type=cache` для кэша npm), и только потом `COPY . .` — тогда правка кода ломает кэш только последних, дешёвых слоёв. 7. Компиляторы, dev-зависимости и тулчейны нужны только на этапе сборки и не должны попадать в продакшен-образ — они раздувают его и расширяют поверхность атаки. Multi-stage build решает это несколькими `FROM` в одном Dockerfile: тяжёлая стадия `build` (например, `golang:1.26`, ~800 МБ) компилирует статический бинарник, а в финальную стадию (`distroless/static`, пара МБ) копируется через `COPY --from=build` только сам бинарник — без компилятора, исходников и системных пакетов сборочного образа. Итог — образ порядка 15 МБ. 8. Alpine использует musl вместо glibc. Для Python и Node это часто ломает бинарные колёса (wheels/native-модули, собранные под glibc) и заметно просаживает производительность некоторых аллокаций/биндингов. Выигрыш в 20-30 МБ размера образа обычно не стоит этих рисков — для Python/Node разумнее взять `*-slim` (glibc) и переходить на distroless/Wolfi, когда важнее минимизировать CVE, чем удобство отладки. 9. Тег — изменяемая ссылка: сегодня `myapp:1.4.2` указывает на один образ, а завтра его можно перевесить на другой. Digest — неизменяемый адрес по содержимому (хэш SHA256), который всегда указывает ровно на один и тот же набор байт. Деплой в production должен опираться на digest (или как минимум на тег с SHA коммита), а не на плавающий тег вроде `latest`/`main`. Тег `latest` в проде опасен тем, что два хоста, сделавшие `pull` в разное время, могут оказаться на разных версиях образа — воспроизводимость теряется, а откат становится ненадёжным. 10. Во встроенной сети `bridge` (по умолчанию) контейнеры друг друга по имени **не видят** — есть только IP внутри общего моста, без DNS. В пользовательской (user-defined) bridge-сети Docker поднимает встроенный DNS, и контейнеры резолвятся по имени сервиса/контейнера (`postgres://db:5432`). Поэтому для многоконтейнерных сборок нужно создавать свою сеть — Docker Compose делает это автоматически для каждого стенда, поэтому в `compose.yaml` сервисы обращаются друг к другу просто по имени (`db`, `cache`), без ручной настройки сети. 11. В exec-форме процесс приложения становится PID 1 и сигналы (`SIGTERM`) приходят ему напрямую, поэтому оно успевает корректно закрыть соединения и завершиться. В shell-форме PID 1 занимает `/bin/sh`, который не пробрасывает сигналы дочернему процессу — при `docker stop` `dockerd` шлёт `SIGTERM`, приложение его не получает, ничего не происходит до истечения таймаута (по умолчанию 10 секунд), после чего контейнер убивается `SIGKILL` — соединения обрываются жёстко, а не закрываются штатно. 12. (а) Данные Postgres — **named volume**: управляется Docker, переносим, предназначен именно для данных, которые должны пережить пересоздание/удаление контейнера. (б) Исходники для hot-reload в dev — **bind mount**: удобно пробрасывать произвольный путь с хоста, но в проде это опасно (привязка к структуре каталогов хоста, риск утечки доступа к чужим файлам), поэтому используется только локально. (в) Временный секрет — **tmpfs**: живёт только в оперативной памяти и не попадает на диск, что и требуется для секретов и другой чувствительной временной информации.Практическое задание¶
Задание 1. Почините Dockerfile¶
Стартовая точка. Вам достался такой Dockerfile для Node.js-сервиса:
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD node dist/main.js
Цель. Переписать его с учётом best practices из мануала:
- зафиксировать версию базового образа;
- использовать multi-stage build (стадия сборки отдельно от рантайма);
- сначала копировать манифесты зависимостей и ставить их, потом копировать остальной код (правильный порядок для кэша сборки);
- не запускать процесс от root (
USER); - запускать команду в exec-форме, а не shell-форме;
- добавить
.dockerignore, чтобы.gitиnode_modulesне уезжали в контекст сборки.
Что значит «готово». У вас есть Dockerfile и .dockerignore в одной
папке; docker build -t myapp:test . успешно собирается; повторная сборка
после правки одной строки в исходном коде (без изменения package.json)
занимает секунды, а не минуты (проверьте это на практике — измените
какой-нибудь файл и пересоберите); docker run --rm myapp:test whoami
показывает не root; docker history myapp:test показывает несколько
стадий, из которых в финальном образе остались только рантайм-слои.
Задание 2. Соберите локальный стенд через Compose¶
Стартовая точка. У вас есть образ из задания 1 (или любой простой HTTP-сервис) и потребность поднять его локально вместе с Postgres и Redis для разработки — так, как описано в разделе «Docker Compose» и в примере 3 мануала.
Цель. Написать compose.yaml с тремя сервисами: api (собирается из
локального Dockerfile), db (postgres) и cache (redis). Требования:
version:в файле быть не должно;dbне публикует порт наружу —apiдолжен обращаться к нему по внутренней сети через имя сервиса (db:5432);- у
dbнастроенhealthcheck(pg_isready), аapiстартует только после того, какdbперешёл вhealthy(depends_onсcondition: service_healthy); - данные Postgres хранятся в named volume, а не в writable-слое контейнера.
Что значит «готово». docker compose up -d --build поднимает все три
сервиса; docker compose ps показывает db в статусе healthy; api может
успешно достучаться до базы по имени сервиса без указания IP; после
docker compose down (без -v) и повторного up данные в Postgres не
теряются — теряются только после docker compose down -v.
Задание 3. Диагностируйте инцидент (по желанию, третье задание)¶
Стартовая точка. Вам сообщают, что при каждом деплое в проде контейнер
с приложением всегда завершается ровно через 10 секунд после команды
остановки, и в это время часть активных HTTP-запросов обрывается с
ошибкой на стороне клиента — команда жалуется на «грубые» рестарты. В
Dockerfile сервиса последняя строчка выглядит так: CMD node dist/main.js.
Цель. Опираясь на раздел мануала про CLI-workflow и завершение
процессов (SIGTERM/SIGKILL, exec-форма), сформулируйте письменно (или в
виде issue/комментария к PR): что именно происходит на уровне PID 1 и
сигналов, почему это приводит к обрыву запросов ровно через 10 секунд, и
какое однострочное изменение в Dockerfile это исправит.
Что значит «готово». У вас есть краткое объяснение причины (2-4
предложения) и исправленная строка CMD в exec-форме; при желании — можно
подтвердить решение на практике: собрать образ с обоими вариантами CMD и
сравнить время между docker stop и фактическим завершением контейнера
(docker events или time docker stop <container>).