Перейти к содержанию

Docker и контейнеризация: квиz для самопроверки

Компаньон к мануалу 03 про Docker. Сначала попробуйте ответить на все вопросы самостоятельно, потом сверьтесь с разделом «Ответы». Не подглядывайте раньше времени — так проверка честнее.


Вопросы для самопроверки

  1. Где проходит граница ответственности между Docker и оркестратором? Что решает Docker сам по себе, а что остаётся за Kubernetes/Nomad/ECS?

  2. Контейнеризация держится на трёх механизмах ядра Linux. Назовите их и объясните своими словами, за что отвечает каждый (что процесс видит и что он потребляет).

  3. Объясните разницу между понятиями Dockerfile, Image, Container и Registry — и подберите к каждому бытовую аналогию (не обязательно ту, что в мануале). Что даёт стандартизация OCI и почему то, что Docker-образ спокойно запускается в containerd или Podman — не случайность?

  4. Сценарий. Вы разворачиваете мультитенантный SaaS: нужна жёсткая граница безопасности между клиентами, но также важны скорость деплоя и высокая плотность сервисов на хосте. Одни коллеги предлагают «просто контейнеры», другие — «просто ВМ на клиента». Какую архитектуру вы предложите и почему?

  5. Опишите путь запроса от docker run до работающего процесса в контейнере — какую роль играют dockerd, BuildKit, containerd и runc? Почему проброс /var/run/docker.sock внутрь контейнера считается серьёзным риском безопасности?

  6. Сценарий. В Dockerfile сначала идёт COPY . ., а потом RUN npm ci. Разработчики жалуются, что любая правка одной строчки кода вызывает 2-3-минутную пересборку, хотя package.json они не трогали. В чём причина и как переписать Dockerfile, чтобы правка кода пересобирала образ за секунды?

  7. Зачем нужен multi-stage build? Объясните, за счёт чего образ Go-сервиса на distroless получается порядка 15 МБ, если стадия сборки весит около 800 МБ.

  8. Почему alpine считается частой «ловушкой» при выборе базового образа для Python и Node.js-сервисов, несмотря на то что он в разы меньше debian-slim?

  9. В чём разница между тегом образа (myapp:1.4.2) и digest (myapp@sha256:...)? На что должен опираться деплой в production и почему тег latest в проде — это, по сути, гарантированная невоспроизводимость?

  10. Чем сеть bridge по умолчанию отличается от пользовательской (user-defined) bridge-сети? Что конкретно перестаёт работать, если несколько контейнеров многоконтейнерного стенда запущены в сети по умолчанию, и как Docker Compose решает эту проблему автоматически?

  11. Почему CMD/ENTRYPOINT нужно писать в exec-форме (CMD ["node", "main.js"]), а не в shell-форме (CMD node main.js)? Что произойдёт при docker stop, если инструкция написана в shell-форме?

  12. Сценарий. В вашем приложении есть три вида данных: (а) данные 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>).