Files

326 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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++ сборки
```dockerfile
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
```dockerfile
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`, без этого флага запрещён).
<details>
<summary>Проверь себя</summary>
1. В Dockerfile сначала `COPY . .` копирует весь исходный код, а уже потом идёт установка
зависимостей через `apt-get`. Что произойдёт со временем пересборки при правке одной
строки кода и почему?
<details><summary>Ответ</summary>Слой с `COPY . .` изменится при любой правке кода, и по
правилу линейного кэша все последующие слои — включая установку зависимостей —
пересоберутся заново, заново скачивая пакеты из сети. Правильный порядок — сначала
зависимости (меняются редко), потом код.</details>
2. Финальный образ после multi-stage build весит существенно меньше, чем промежуточный
`builder`-образ, хотя бинарник тот же самый. За счёт чего?
<details><summary>Ответ</summary>Второй `FROM` начинает независимый образ, в который
`COPY --from=builder` переносит только сам готовый бинарник — весь тулчейн сборки
(компилятор, cmake, заголовки и их слои) остаётся только в промежуточном `builder`-образе
и в финальный не попадает.</details>
3. Тестовый контейнер убит с кодом завершения 137 после превышения лимита `--memory=256m`.
Что произошло механически и с чем ещё встречается тот же код завершения вне контейнеров?
<details><summary>Ответ</summary>cgroups-контроллер памяти зафиксировал превышение лимита
и OOM-killer прислал процессу `SIGKILL` (сигнал 9); shell/wait-конвенция кодирует
завершение по сигналу как 128 + номер сигнала = 137. Тот же код 137 виден при обычном
`kill -9` процесса вне всякого контейнера — механизм кодирования тот же.</details>
4. Почему root внутри контейнера — больший риск, чем root внутри виртуальной машины,
изолирующей тот же процесс?
<details><summary>Ответ</summary>Контейнер не имеет собственного ядра — он использует ядро
хоста напрямую, поэтому уязвимость в этом общем ядре, неверно выданные capabilities или
смонтированный `docker.sock` могут дать выход из контейнерного root прямо в root хоста.
VM с отдельным гостевым ядром не даёт такого прямого пути.</details>
</details>
## Материалы
- docs.docker.com, Dockerfile reference — https://docs.docker.com/engine/reference/builder/
- docs.docker.com, Multi-stage builds — https://docs.docker.com/build/building/multi-stage/
- docs.docker.com, `docker run` CLI reference — https://docs.docker.com/engine/reference/commandline/run/
- docs.docker.com, Volumes — https://docs.docker.com/storage/volumes/
- docs.docker.com, Compose file reference — https://docs.docker.com/compose/compose-file/
- man7.org, `namespaces(7)` — https://man7.org/linux/man-pages/man7/namespaces.7.html
- man7.org, `cgroups(7)` — https://man7.org/linux/man-pages/man7/cgroups.7.html