Files
eduplan-cpp-eltex/lessons/D6_docker.md
T

33 KiB
Raw Blame History

D6, часть 2. Docker минимум под сборку и тесты (урок)

Это не про эксплуатацию продакшн-кластеров, а про Docker как инструмент воспроизводимой сборки и тестового окружения для C++: одна и та же среда сборки — компилятор, тулчейн, библиотеки — у каждого разработчика и на CI, вместо «у меня собирается, у тебя нет».

1. Контейнер — это процесс, а не виртуальная машина

Контейнер — обычный процесс хостовой ОС, изолированный двумя независимыми механизмами ядра Linux:

  • namespaces — отдельное пространство имён для каждого вида ресурса: pid (свой список процессов, процесс внутри контейнера видит себя как PID 1), mnt (своя точка монтирования файловой системы), net (свой сетевой стек — интерфейсы, адреса, таблица маршрутизации), uts (свой hostname), ipc (изолированные механизмы межпроцессного взаимодействия — очереди сообщений, семафоры), user (отображение UID/GID контейнера на другие UID/GID хоста).
  • cgroups (control groups) — ограничение и учёт потребления ресурсов: сколько CPU, памяти, дискового ввода-вывода разрешено процессу и его потомкам.

Ключевое отличие от виртуальной машины: контейнеру не нужно собственное ядро — он использует ядро хоста напрямую, тогда как VM через гипервизор эмулирует виртуальное железо, поверх которого грузится отдельное ядро гостевой ОС. Отсюда напрямую следуют два практических эффекта: старт контейнера — это по сути fork/exec с применёнными namespaces, то есть доли секунды и накладные расходы, близкие к нулю (VM грузит собственное ядро — секунды и проценты CPU/памяти на гипервизор); и системные вызовы контейнера выполняет то же самое ядро хоста без эмуляции, то есть без потери производительности на виртуализацию — но и без изоляции на уровне ядра: уязвимость ядра или неверно настроенные capabilities способны дать выход из контейнера на хост, чего с отдельным ядром VM добиться сложнее.

Факты для карточек

  • base | Из каких двух механизмов ядра Linux состоит изоляция контейнера? — namespaces (изоляция видимости ресурсов) и cgroups (ограничение потребления ресурсов)
  • base | Перечисли namespaces, разбираемые в этом разделе? — pid, mnt, net, uts, ipc, user
  • core | Почему контейнер стартует за доли секунды, а VM — за секунды? — контейнер не грузит собственное ядро, старт — это fork/exec с применёнными namespaces; VM грузит через гипервизор целое гостевое ядро
  • core | Почему изоляция контейнера слабее, чем у VM, на уровне безопасности? — контейнер и хост используют одно и то же ядро; уязвимость ядра или неверные capabilities могут дать выход на хост, а у VM с отдельным ядром такой прямой путь отсутствует

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

2. Слои образа и кэш сборки

Образ Docker — неизменяемый набор слоёв: каждая инструкция Dockerfile (RUN, COPY) порождает отдельный слой — diff файловой системы относительно предыдущего слоя, слои read-only и кэшируются по хешу содержимого. При сборке Docker идёт по инструкциям сверху вниз и для каждой проверяет кэш: если инструкция и её входные данные не изменились с прошлой сборки (для COPY — содержимое копируемых файлов), слой берётся из кэша без выполнения; как только один слой не совпал с кэшем, все последующие слои пересобираются заново, даже если сами по себе не менялись — кэш линеен и рвётся в первой же точке расхождения.

Отсюда практическое правило порядка инструкций: сначала копировать и устанавливать зависимости (меняются редко), и только потом копировать исходный код (меняется на каждом коммите). Если сделать наоборот — любая правка одной строки кода инвалидирует кэш зависимостей, и сборка каждый раз заново качает пакеты из сети, что на CI ощутимо по времени.

Ловушки

  • COPY . . перед установкой зависимостей → любое изменение кода инвалидирует и слой с зависимостями → пересборка образа качает все пакеты заново на каждый коммит, видно по резко выросшему времени сборки в CI.

Факты для карточек

  • base | Что порождает каждая инструкция RUN/COPY в Dockerfile? — отдельный слой (diff файловой системы)
  • core | Что происходит с последующими слоями, если один слой не совпал с кэшем? — все последующие слои пересобираются заново, даже если сами по себе не менялись
  • core | Какой порядок инструкций Dockerfile правильный для скорости пересборки? — сначала зависимости (меняются редко), потом исходный код (меняется часто)

Почему дальше: применим это правило к конкретному Dockerfile для сборки C++-проекта.

3. Dockerfile для C++ сборки

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential cmake git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /src
COPY CMakeLists.txt .
COPY src/ src/
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j

Установку пакетов делают одной инструкцией RUN (apt-get update && apt-get install ... && rm -rf /var/lib/apt/lists/*), а не отдельными командами — потому что слой фиксирует файловую систему на момент завершения именно этой инструкции. Если rm -rf /var/lib/apt/lists/* вынести в отдельный RUN после установки, скачанный кеш списков пакетов уже необратимо запечён в предыдущем слое и продолжает занимать место в итоговом образе — слой нельзя «похудеть» задним числом последующим слоем, можно только скрыть файл в новом слое поверх старого.

WORKDIR задаёт и фиксирует рабочую директорию для всех последующих инструкций; COPY переносит файлы с хоста в образ отдельным слоем — именно поэтому в примере сначала копируется CMakeLists.txt, а исходники — вторым COPY: правка кода не трогает слой с конфигурацией сборки. Каталог build/, уже собранный на хосте разработчика, копировать в образ нельзя: он собран под окружение хоста (другая версия компилятора, другие пути, возможно другая архитектура) и не гарантированно совместим с окружением внутри контейнера — сборка должна проходить внутри самого образа, чтобы результат был воспроизводим одинаково у всех.

Ловушки

  • apt-get install и rm -rf /var/lib/apt/lists/* в разных RUN → кеш списков пакетов необратимо остаётся в промежуточном слое → итоговый образ ощутимо больше, чем при объединении в одну инструкцию, видно по docker history.
  • Копирование готового build/ с хоста в образ вместо сборки внутри контейнера → бинарник собран под окружение хоста, а не образа → несовместимость версий библиотек или архитектуры, «works on my machine» переносится прямо в контейнер.

Факты для карточек

  • base | Почему apt-get install и rm -rf /var/lib/apt/lists/* объединяют в одну инструкцию RUN? — слой фиксирует файловую систему на момент завершения инструкции; в отдельном RUN кеш пакетов уже необратимо запечён в предыдущем слое
  • core | Почему нельзя копировать в образ каталог build/, собранный на хосте? — он собран под окружение хоста (версия компилятора, пути, архитектура), не гарантированно совместим с окружением контейнера; сборка должна идти внутри образа

Почему дальше: если собирать всё в одном образе, финальный образ несёт в себе весь тулчейн сборки (компилятор, cmake, заголовки) — для рантайма это лишний вес и лишняя поверхность атаки. Разберём, как это разделить.

4. Multi-stage build

FROM debian:bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential cmake \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j

FROM debian:bookworm-slim
COPY --from=builder /src/build/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]

Первый FROM ... AS builder — образ со всем тулчейном сборки; второй FROM начинает новый, независимый образ, в который командой COPY --from=builder переносится только готовый результат — скомпилированный бинарник. Слои со всем тулчейном сборки в финальный образ не попадают вовсе. Итоговый рантайм-образ для этого выбирают минимальным: debian:bookworm-slim (урезанный Debian с базовыми библиотеками) — если бинарник собран динамически и нужна libc; либо scratch (полностью пустой образ, без единого файла) — подходит только для полностью статически слинкованного бинарника, которому вообще не нужна никакая библиотека окружения.

Факты для карточек

  • base | Что переносит COPY --from=builder во второй FROM? — только указанные готовые файлы (например бинарник) из первого этапа, без слоёв тулчейна
  • core | Когда для финального этапа multi-stage можно использовать FROM scratch? — когда бинарник собран полностью статически и не нуждается ни в одной библиотеке окружения
  • core | Чем debian:bookworm-slim в качестве финального образа лучше полного debian:bookworm для рантайма? — не несёт тулчейн сборки и лишние пакеты, меньше размер и меньше поверхность атаки

Почему дальше: образ — это шаблон файловой системы, но данные, которые должны пережить пересоздание контейнера (например, база данных теста), нельзя держать в самом read-write-слое контейнера — для этого есть volume и bind mount.

5. Volume и bind mount

Оба механизма монтируют в контейнер хранилище, которое живёт отдельно от read-write-слоя контейнера (тот слой стирается командой docker rm).

  • Volume — область, которой управляет сам Docker, физически хранится в его служебной директории на хосте, не привязана к конкретному пути на диске разработчика — переносима между машинами, подходит для персистентных данных вроде базы данных теста.
  • Bind mount — прямое монтирование конкретного каталога хоста внутрь контейнера по заданному пути: контейнер и хост видят один и тот же каталог одновременно и правки на хосте сразу видны внутри без пересборки образа — используется при разработке, когда исходники редактируются на хосте, а собираются/тестируются внутри контейнера.

Ловушки

  • Держать данные в обычном read-write-слое контейнера без volume → docker rm или пересоздание контейнера безвозвратно стирает данные → «тесты вчера прошли, база сегодня пустая» без единой ошибки при удалении.

Факты для карточек

  • base | Кто физически управляет расположением volume на диске? — сам Docker, служебная директория, не путь, выбранный вручную
  • core | Почему bind mount, а не volume, используют для разработки с редактированием кода на хосте? — bind mount даёт прямой одновременный доступ к каталогу хоста, правки на хосте сразу видны в контейнере без пересборки образа

Почему дальше: то, как контейнер запускается и как к нему обращаются после старта, задаётся флагами docker run и парой соседних команд — разберём их.

6. docker run, docker exec, docker logs

Частые флаги docker run:

  • --rm — автоматически удалить контейнер (его read-write-слой) сразу после завершения процесса; без него остановленные контейнеры копятся и занимают место на диске.
  • -v host_path:container_path (или -v volume_name:container_path) — bind mount или named volume (раздел 5).
  • -p host_port:container_port — пробросить порт с хоста на порт внутри контейнера (контейнер по умолчанию в изолированной bridge-сети не виден снаружи без явного проброса).
  • --network=host — контейнер использует сетевой стек хоста напрямую, без собственного сетевого namespace и без проброса портов, но и без сетевой изоляции.

docker exec <container> <cmd> запускает дополнительную команду внутри уже работающего контейнера — типичный случай: открыть интерактивный shell (docker exec -it <container> bash) для отладки живого процесса без его остановки. docker logs <container> показывает вывод stdout/stderr, который написал процесс с PID 1 внутри контейнера, — то, что видно в терминале при docker run без -d, доступно так же и после отсоединения.

Факты для карточек

  • base | Что делает флаг --rm у docker run? — автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса
  • base | Какая команда даёт интерактивный shell в уже запущенном контейнере? — docker exec -it <container> bash
  • core | Чем --network=host отличается от обычного режима с -p? — контейнер напрямую использует сетевой стек хоста без собственного network namespace и без проброса портов, но и без сетевой изоляции

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

7. docker compose

docker-compose.yml — декларативное описание нескольких связанных контейнеров как одного приложения: сервисы с указанием образа или пути к Dockerfile, портов, volume, переменных окружения и зависимостей между сервисами. docker compose up -d поднимает все сервисы разом и автоматически создаёт для них общую сеть, где сервисы видят друг друга по имени из YAML как по hostname — не нужно вручную создавать сеть и связывать контейнеры по IP-адресам, которые могут меняться при пересоздании.

docker compose down без флага -v останавливает и удаляет контейнеры, но сохраняет именованные volume; -v удаляет и их. Типичная ошибка — предположить, что обычный down стирает данные тестовой базы (нет, если она в именованном volume), либо наоборот случайно потерять данные, добавив -v не задумавшись.

Факты для карточек

  • base | Какая команда поднимает все сервисы из docker-compose.yml разом? — docker compose up -d
  • core | Что удаляет docker compose down -v, чего не удаляет docker compose down без флага? — именованные volume и данные в них

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

8. Реестры и теги

Образ идентифицируется именем и тегом (myapp:1.4.0); реестр (например Docker Hub или приватный registry) хранит образы, docker pull/docker push их скачивают/загружают. Тег :latest — не «самая новая версия» в смысле гарантии, а обычный мутируемый тег, который каждый docker push без явного тега перезаписывает: myapp:latest, скачанный сегодня и через месяц, может указывать на совершенно разное содержимое образа. Это ломает воспроизводимость сборки и тестов — CI, зафиксировавший myapp:latest, через месяц может неожиданно тянуть другой код без единой изменённой строчки в собственном конфиге. Практика — фиксировать конкретную версию тега или дайджест образа (myapp@sha256:...), который неизменяем по определению хеша.

Факты для карточек

  • base | Что физически происходит с тегом :latest при каждом docker push без явного тега? — он перезаписывается на новый образ, становится мутируемым указателем, а не фиксированной версией
  • core | Почему фиксация myapp@sha256:... вместо тега :latest важна для воспроизводимости CI? — дайджест неизменяем по определению хеша, а тег :latest может незаметно указывать на другое содержимое образа в разное время

Почему дальше: помимо версии образа, есть ещё вопрос — от чьего имени процесс исполняется внутри контейнера, и почему это не всё равно.

9. Права: --user и почему root в контейнере опасен

По умолчанию процесс внутри контейнера, если не указано иное, запускается от root (UID 0) — того же UID 0, что и root на хосте, если не настроен user namespace с ремаппингом UID. Раз у контейнера нет отдельного ядра (раздел 1), эскалация из контейнерного root до root на хосте — через уязвимость ядра, неверно выданные capabilities или смонтированный внутрь docker.sock — гораздо ближе и реальнее, чем аналогичный побег из виртуальной машины с отдельным ядром.

Флаг --user uid:gid запускает процесс контейнера с непривилегированным UID/GID вместо root — снижает ущерб от компрометации процесса внутри контейнера: даже получив контроль над процессом, атакующий не имеет привилегий root ни внутри контейнера, ни тем более на хосте.

Факты для карточек

  • base | От какого пользователя запускается процесс в контейнере по умолчанию, если не указано иное? — root (UID 0)
  • core | Почему root в контейнере опаснее, чем root в отдельной VM? — контейнер не имеет отдельного ядра; эскалация до root хоста возможна через уязвимость общего ядра, неверные capabilities или смонтированный docker.sock, чего с отдельным ядром VM добиться сложнее
  • core | Что делает флаг --user uid:gid? — запускает процесс контейнера от непривилегированного UID/GID вместо root

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

10. Ограничения ресурсов: --memory, --cpus

  • --memory=512m — жёсткий лимит памяти через cgroups; при превышении лимита OOM-killer убивает процесс контейнера сигналом SIGKILL — тот же механизм и тот же код завершения 137 = 128 + 9 (128 — соглашение shell/wait о сигнальном завершении, 9 — номер SIGKILL), что и при обычном kill -9 вне контейнера, но здесь его вызывает превышение cgroup-лимита, а не человек.
  • --cpus=1.5 — ограничение через CPU-контроллер cgroups: контейнер не может использовать больше эквивалента 1.5 ядра процессорного времени, даже если на хосте простаивают дополнительные ядра.

Ограничения задаются той же cgroups-инфраструктурой, что обеспечивает саму изоляцию контейнера (раздел 1) — это не отдельный механизм Docker, а применение уже существующего механизма ядра с конкретными числами.

Факты для карточек

  • base | Каким сигналом и с каким кодом завершения убивает процесс превышение лимита --memory? — SIGKILL, код завершения 137 (128 + 9)
  • core | Что ограничивает --cpus=1.5 технически? — квоту CPU-контроллера cgroups, эквивалент 1.5 ядра процессорного времени вне зависимости от простаивающих ядер хоста

Ссылки на задачи этого набора: tasks/06_threads — типичный кандидат на тестирование внутри контейнера с ограничением --cpus, чтобы гонки данных проявлялись стабильнее под реальным давлением на планировщик; tasks/09_gdb — отладка бинарника внутри контейнера требует флага --cap-add=SYS_PTRACE (по умолчанию Docker урезает capabilities, и ptrace, на котором работает gdb/strace, без этого флага запрещён).

Проверь себя
  1. В Dockerfile сначала COPY . . копирует весь исходный код, а уже потом идёт установка зависимостей через apt-get. Что произойдёт со временем пересборки при правке одной строки кода и почему?

    ОтветСлой с `COPY . .` изменится при любой правке кода, и по правилу линейного кэша все последующие слои — включая установку зависимостей — пересоберутся заново, заново скачивая пакеты из сети. Правильный порядок — сначала зависимости (меняются редко), потом код.
  2. Финальный образ после multi-stage build весит существенно меньше, чем промежуточный builder-образ, хотя бинарник тот же самый. За счёт чего?

    ОтветВторой `FROM` начинает независимый образ, в который `COPY --from=builder` переносит только сам готовый бинарник — весь тулчейн сборки (компилятор, cmake, заголовки и их слои) остаётся только в промежуточном `builder`-образе и в финальный не попадает.
  3. Тестовый контейнер убит с кодом завершения 137 после превышения лимита --memory=256m. Что произошло механически и с чем ещё встречается тот же код завершения вне контейнеров?

    Ответcgroups-контроллер памяти зафиксировал превышение лимита и OOM-killer прислал процессу `SIGKILL` (сигнал 9); shell/wait-конвенция кодирует завершение по сигналу как 128 + номер сигнала = 137. Тот же код 137 виден при обычном `kill -9` процесса вне всякого контейнера — механизм кодирования тот же.
  4. Почему root внутри контейнера — больший риск, чем root внутри виртуальной машины, изолирующей тот же процесс?

    ОтветКонтейнер не имеет собственного ядра — он использует ядро хоста напрямую, поэтому уязвимость в этом общем ядре, неверно выданные capabilities или смонтированный `docker.sock` могут дать выход из контейнерного root прямо в root хоста. VM с отдельным гостевым ядром не даёт такого прямого пути.

Материалы