Docker Deep Dive: от механизмов ядра Linux до Senior SRE практик

Углубленный курс по контейнеризации, фокусирующийся на внутреннем устройстве Docker, оптимизации производительности и безопасности в production-средах. Вы пройдете путь от понимания системных вызовов ядра до проектирования отказоустойчивых систем.

Анатомия контейнеризации: namespaces, cgroups и архитектура Docker Engine

Анатомия контейнеризации: namespaces, cgroups и архитектура Docker Engine

Если вы запустите команду top внутри свежего контейнера Ubuntu, вы увидите всего пару процессов. Кажется, будто вы находитесь внутри крошечной, легковесной виртуальной машины со своей собственной операционной системой. Но это величайшая иллюзия в современной IT-индустрии. На самом деле никаких «контейнеров» на уровне ядра Linux не существует.

Для ядра Linux контейнер — это абсолютно обычный процесс, точно такой же, как ваш браузер или текстовый редактор. Разница лишь в том, что к этому процессу применены два механизма изоляции: Namespaces (пространства имён) и Control Groups (контрольные группы, или cgroups).

Чтобы научиться оптимизировать контейнеры и обеспечивать их безопасность на уровне Senior SRE, нам нужно разрушить магию Docker и посмотреть, как он дергает за ниточки операционной системы.

Иллюзия одиночества: Linux Namespaces

Представьте, что вы надели на человека очки виртуальной реальности, в которых он видит пустую комнату, хотя на самом деле находится на оживленном вокзале. Именно это механизм Namespaces делает с процессами. Он ограничивает то, что процесс может видеть.

Существует несколько типов пространств имён, но для понимания сути мы разберем самый наглядный — PID Namespace (пространство идентификаторов процессов).

В обычной Linux-системе каждому процессу выдается уникальный номер — PID (Process ID). Процесс инициализации системы всегда получает PID 1, следующий — PID 2, и так далее. Когда Docker запускает ваш контейнер, он просит ядро Linux создать для этого процесса новое пространство имён PID.

Внутри этого нового пространства процесс уверен, что он тут самый первый и главный, поэтому получает почетный PID 1. Но на уровне хост-системы (вашего сервера) ядро знает правду: для него это просто очередной процесс, которому выдан, скажем, PID 3452.

Контейнеризация — это не виртуализация железа. Это изоляция видимости. Процесс в контейнере использует то же самое ядро ОС, что и хост, просто ядро фильтрует для него системные вызовы, скрывая соседей.

Помимо PID, Docker использует и другие Namespaces:

  • Network Namespace: дает процессу собственный сетевой стек, свои IP-адреса и таблицы маршрутизации (поэтому у контейнера есть свой IP).
  • Mount Namespace: изолирует точки монтирования файловой системы (процесс видит только файлы образа, а не весь жесткий диск сервера).
  • UTS Namespace: позволяет контейнеру иметь собственное имя хоста (hostname).

Укрощение аппетита: Control Groups (cgroups)

Namespaces отлично справляются с тем, чтобы спрятать от процесса остальную систему. Но они никак не ограничивают его жадность. Если процесс с PID 1 внутри контейнера решит занять все 100% процессорного времени или выделить себе 64 ГБ оперативной памяти, он это сделает, и соседние сервисы на сервере упадут.

Здесь на сцену выходит второй столп контейнеризации — cgroups.

Если Namespaces ограничивают то, что процесс видит, то cgroups ограничивают то, что процесс может использовать. Это механизм ядра Linux, который позволяет объединять процессы в иерархические группы и жестко задавать им лимиты на:

  • CPU: сколько долей процессорного времени может утилизировать группа.
  • Memory: максимальный объем оперативной памяти.
  • Block I/O: скорость чтения и записи на диск.

Для SRE-инженера понимание cgroups критически важно из-за механизма OOM Killer (Out Of Memory Killer). Если процесс внутри контейнера попытается превысить лимит памяти, заданный через cgroups, ядро Linux безжалостно убьет этот процесс. В логах Docker вы увидите причину завершения: OOMKilled.

Когда вы пишете в docker run флаг --memory="512m", Docker не создает виртуальную планку RAM на 512 мегабайт. Он просто создает запись в файловой системе cgroups (обычно в /sys/fs/cgroup/memory/docker/<id_контейнера>), указывая ядру: «если сумма памяти процессов в этой группе превысит 512 МБ — убей их».

Архитектура Docker Engine: Кто всем управляет?

Мы выяснили, что контейнер — это процесс, обернутый в Namespaces и cgroups. Но настраивать всё это вручную через системные вызовы Linux невероятно сложно. Нужна программа, которая скачает образ, распакует его, подготовит сеть, настроит cgroups и запустит процесс.

Этим занимается Docker Engine. Со временем его архитектура эволюционировала и разбилась на несколько независимых компонентов, чтобы стать более надежной.

Давайте проследим путь команды docker run nginx сверху вниз:

  1. Docker Client (CLI): Это утилита docker, которую вы вызываете в терминале. Она не запускает контейнеры сама, а лишь отправляет REST API запрос к демону.
  2. Docker Daemon (dockerd): Мозг системы. Он принимает запрос от CLI, проверяет, есть ли образ nginx локально (если нет — качает из Registry), подготавливает слои файловой системы (об этом в следующей главе) и формирует конфигурацию для запуска. Но сам dockerd процессы не запускает! Он передает задачу ниже.
  3. containerd: Это менеджер жизненного цикла. Его единственная задача — управлять выполнением контейнеров на конкретном узле. Он независим от Docker (его использует и Kubernetes). containerd берет конфигурацию от dockerd и готовится к старту.
  4. runc (OCI Runtime): Это низкоуровневая утилита, которая делает грязную работу. containerd вызывает runc, передавая ему путь к файлам образа и конфиг. runc обращается к ядру Linux, создает те самые Namespaces и cgroups, запускает процесс nginx и... сразу же завершает свою работу.
  5. containerd-shim: Поскольку runc завершился, кто-то должен следить за запущенным nginx, собирать его логи (stdout/stderr) и сообщать containerd, если процесс упадет. Этим занимается крошечный процесс shim (прокладка). Для каждого контейнера висит свой shim.

Зачем архитектуру сделали такой сложной и добавили containerd-shim? Ради стабильности production-систем.

Благодаря тому, что контейнеры отвязаны от dockerd и держатся на shim, вы можете обновить Docker Daemon, перезапустить службу dockerd, и ваши запущенные контейнеры не упадут. Они продолжат работать, обслуживая трафик, а когда dockerd поднимется, он переподключится к containerd и восстановит контроль над ними.

Взгляд SRE: Контейнер изнутри хоста

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

Частая проблема: контейнер потребляет много ресурсов, но внутри него нет утилит вроде strace, htop или tcpdump (хороший образ должен быть минималистичным ради безопасности). Как его дебажить?

Очень просто: найти его реальный PID на хост-системе и использовать инструменты хоста!

Чтобы узнать, под каким PID ядро Linux видит главный процесс вашего контейнера, используйте команду:

docker inspect -f '{{.State.Pid}}' <id_или_имя_контейнера>

Допустим, команда вернула PID 4123. Теперь, находясь на хост-сервере с правами root, вы можете:

  • Посмотреть, какие файлы реально открыты процессом: ls -l /proc/4123/fd
  • Отследить системные вызовы, на которых тормозит приложение: strace -p 4123
  • Посмотреть потребление ресурсов именно этой cgroup через systemd-cgtop.

Вы вышли за пределы иллюзии. Вы больше не ограничены инструментами, зашитыми в Docker-образ разработчиком. Как SRE-инженер, вы работаете напрямую с ядром Linux, используя Docker лишь как удобный интерфейс для управления изоляцией.

В следующей главе мы разберем, как именно Docker формирует файловую систему для этого изолированного процесса, почему образы состоят из слоев и как работает магия UnionFS, позволяющая запускать сотни контейнеров из одного образа, не дублируя данные на диске.

Слоистые файловые системы и хранение данных: UnionFS, Overlay2 и стратегии работы с Volume

Слоистые файловые системы и хранение данных: UnionFS, Overlay2 и стратегии работы с Volume

Представьте ситуацию: вы скачали образ Ubuntu весом 70 МБ и запустили из него 100 независимых контейнеров. Сколько места на диске они займут? Логика подсказывает, что 70×100=700070 \times 100 = 7000 МБ. Но утилита df -h на хосте покажет, что занято всего около 70 МБ, плюс пара мегабайт на метаданные.

В прошлой главе мы выяснили, что контейнер — это обычный процесс, которому ядро Linux через Mount Namespace подменяет корневую директорию (/). Процесс уверен, что владеет целой файловой системой. Но откуда берется эта файловая система, если она не копируется для каждого контейнера физически? Ответ кроется в механизме каскадного монтирования — UnionFS.

Иллюзия единого диска: как работает Overlay2

Современный Docker по умолчанию использует драйвер хранилища overlay2. Это реализация концепции UnionFS на уровне ядра Linux. Ее суть проста: взять несколько разных директорий на хосте и «наложить» их друг на друга так, чтобы для наблюдателя они выглядели как одна сплошная директория.

В архитектуре overlay2 файловая система контейнера собирается из четырех компонентов:

  1. LowerDir (Нижние слои) — это слои самого Docker-образа. Они строго read-only (только для чтения). Если образ состоит из 5 слоев, все они выстраиваются в стопку LowerDir. Именно поэтому 100 контейнеров Ubuntu используют одни и те же физические файлы базового образа на диске хоста.
  2. UpperDir (Верхний слой) — это тонкий writable (доступный для записи) слой, который создается индивидуально для каждого запущенного контейнера. Все изменения, которые процесс делает внутри контейнера, физически сохраняются только здесь.
  3. WorkDir (Рабочая директория) — скрытая служебная директория, которую драйвер overlay2 использует для подготовки файлов перед их перемещением в UpperDir (обеспечивает атомарность операций).
  4. MergedDir (Объединенное представление) — это финальный результат. Именно эту директорию видит процесс внутри контейнера как свой корень /.

Посмотреть реальные пути этих директорий на хосте можно с помощью команды docker inspect. Найдите блок GraphDriver:

"GraphDriver": {
    "Data": {
        "LowerDir": "/var/lib/docker/overlay2/layer3...:/var/lib/docker/overlay2/layer2...:/var/lib/docker/overlay2/layer1...",
        "MergedDir": "/var/lib/docker/overlay2/container_id.../merged",
        "UpperDir": "/var/lib/docker/overlay2/container_id.../diff",
        "WorkDir": "/var/lib/docker/overlay2/container_id.../work"
    },
    "Name": "overlay2"
}

Налог на производительность: Copy-on-Write (CoW)

Разделение на read-only и writable слои порождает главную механику контейнерных файловых систем — Copy-on-Write (Копирование при записи).

Пока контейнер просто читает файл, overlay2 ищет его сверху вниз: сначала в UpperDir, затем по очереди в слоях LowerDir. Как только файл найден, он отдается процессу. Это работает быстро.

Но что происходит, когда процесс хочет изменить файл, который физически лежит в read-only слое LowerDir?

  1. Драйвер находит файл в нижнем слое.
  2. Драйвер копирует этот файл целиком из LowerDir в UpperDir.
  3. Процесс вносит изменения уже в скопированную версию внутри UpperDir.
  4. Теперь при чтении этого файла overlay2 всегда будет находить измененную версию в UpperDir, «перекрывая» оригинальный файл снизу.

А как работает удаление файла, если из LowerDir удалять ничего нельзя? Ядро создает в UpperDir специальный файл-маркер — whiteout (символически обозначается как .wh.filename). Когда процесс запрашивает список файлов в MergedDir, драйвер видит whiteout-маркер в верхнем слое и скрывает оригинальный файл из нижнего слоя. Для процесса внутри контейнера файл исчез.

SRE-инсайт: почему базы данных умирают в контейнерах

Механизм CoW — это катастрофа для I/O-интенсивных приложений, таких как PostgreSQL или MySQL. Представьте, что база данных хочет обновить одну строку размером 100 байт в файле данных размером 1 ГБ, который лежит в базовом слое. Из-за CoW ядро Linux будет вынуждено скопировать весь гигабайтный файл из LowerDir в UpperDir, прежде чем изменить эти 100 байт. Это вызывает колоссальные задержки (latency) и износ диска.

Именно поэтому любые данные, которые часто изменяются или должны пережить перезапуск контейнера, должны быть вынесены за пределы Overlay2.

Побег из матрицы: Volume, Bind Mount и tmpfs

Чтобы обойти ограничения overlay2, Docker предоставляет механизмы монтирования. Они позволяют пробросить директорию в обход UnionFS прямо в контейнер. С точки зрения ядра Linux (Mount Namespace), это обычная операция mount.

Существует три основные стратегии хранения данных:

Тип монтирования Где физически лежат данные Жизненный цикл Идеальный Use Case
Volume (Том) Внутри /var/lib/docker/volumes/ (управляется Docker) Переживает удаление контейнера. Удаляется только явно (docker volume rm). Базы данных (PostgreSQL, Redis), персистентные хранилища, шаринг данных между контейнерами.
Bind Mount (Связывание) Любой абсолютный путь на хосте (например, /opt/app/config) Зависит от хоста. Docker не управляет этими файлами. Проброс конфигурационных файлов (nginx.conf), проброс исходного кода при локальной разработке.
tmpfs В оперативной памяти (RAM) хоста Исчезает при остановке контейнера. Хранение секретов (ключи, токены), высокоскоростной временный кэш, куда нельзя писать на диск.

Разбор стратегий для Production

1. Volumes: Стандарт индустрии В production-средах Volumes — это выбор по умолчанию. Так как Docker сам управляет директорией, вам не нужно беспокоиться о правах доступа (UID/GID) на хост-системе. Кроме того, Volumes поддерживают драйверы сторонних разработчиков: вы можете создать Volume, который физически пишет данные в AWS EBS, NFS или Ceph, а контейнер даже не узнает об этом.

Пример запуска: docker run -d -v my_db_data:/var/lib/postgresql/data postgres:15

2. Bind Mounts: Удобно, но опасно Bind Mounts жестко привязывают контейнер к структуре директорий конкретного сервера. Это нарушает принцип переносимости: контейнер может не запуститься на другом сервере, если там нет нужной папки. С точки зрения безопасности Bind Mount — это риск. Если по ошибке смонтировать корень хоста (-v /:/host), процесс внутри контейнера получит доступ ко всем файлам сервера. А проброс сокета Docker (-v /var/run/docker.sock:/var/run/docker.sock) дает контейнеру права root на хосте, так как позволяет запускать новые контейнеры в привилегированном режиме.

Пример запуска: docker run -d -v /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro nginx (флаг ro делает монтирование read-only, защищая файл на хосте от изменения).

3. Проблема «Призрачных данных» Частая ошибка начинающих инженеров:

  1. В Dockerfile есть инструкция RUN wget data.tar.gz -O /app/data/huge_file.dat. Файл сохраняется в слое образа.
  2. При запуске контейнера поверх этой директории монтируется пустой Volume: docker run -v my_vol:/app/data ....

Что произойдет? Volume полностью «перекроет» содержимое директории /app/data. Контейнер увидит пустую папку. Но huge_file.dat никуда не исчезнет — он останется лежать в LowerDir образа, занимая место на диске хоста, хотя получить к нему доступ из контейнера уже невозможно.

Понимание того, как overlay2 взаимодействует с механизмами монтирования — это ключ к созданию легковесных, быстрых и безопасных контейнеров. В следующей главе мы применим эти знания на практике, разбирая этапы сборки образа и методы радикального уменьшения его размера через Multi-stage builds.

Жизненный цикл образа: глубокая оптимизация сборки и Multi-stage builds

Жизненный цикл образа: глубокая оптимизация сборки и Multi-stage builds

Представьте типичную ситуацию: разработчик пишет микросервис на Go, который в скомпилированном виде весит 15 мегабайт. Однако итоговый Docker-образ, который уезжает в production, занимает 850 мегабайт, а его сборка в CI/CD пайплайне длится 8 минут. Каждый деплой превращается в ожидание, а служба безопасности регулярно присылает отчеты о десятках уязвимостей в системных библиотеках контейнера, которые самому приложению даже не нужны.

В прошлой главе мы разобрали, как overlay2 эффективно переиспользует слои на уровне файловой системы хоста. Теперь мы спустимся на этап раньше: как эти слои формируются, почему они раздуваются и как Senior SRE инженеры создают минималистичные, безопасные и молниеносно собирающиеся образы.

Анатомия docker build и ловушка Build Context

Сборка образа начинается с команды docker build -t myapp .. Для новичка точка в конце кажется просто указанием на текущую директорию. На деле это определение Build Context (контекста сборки).

Архитектура Docker разделена на клиент (CLI) и сервер (демон dockerd). Они могут находиться на разных машинах. Когда вы запускаете сборку, первое, что делает Docker CLI — архивирует всё содержимое директории, указанной в качестве контекста, и отправляет этот архив демону dockerd через REST API.

Если в вашей директории лежит папка .git на 2 ГБ, локальная база данных или тяжелая директория node_modules, Docker CLI послушно упакует их и отправит демону. Сборка зависнет на этапе «Sending build context to Docker daemon», потребляя CPU и забивая I/O диска, хотя эти файлы могут даже не использоваться в самом Dockerfile.

Именно поэтому первый шаг любой оптимизации — создание файла .dockerignore. Он работает аналогично .gitignore, указывая клиенту, какие файлы нужно исключить из контекста до отправки демону.

Формирование слоев и механика кэширования

Каждая инструкция в Dockerfile (FROM, RUN, COPY, ADD) создает новый слой. Под капотом dockerd запускает временный контейнер из предыдущего слоя, выполняет вашу инструкцию, фиксирует изменения файловой системы (делает commit) как новый read-only слой, и удаляет временный контейнер.

Чтобы не выполнять долгие команды (например, загрузку пакетов) при каждой сборке, Docker использует кэширование. Для каждой инструкции вычисляется хэш. Кэш считается валидным (инструкция пропускается, берется готовый слой), если совпадают три условия:

  1. Родительский слой не изменился.
  2. Строка самой инструкции в Dockerfile не изменилась.
  3. Для инструкций COPY и ADD — не изменились контрольные суммы копируемых файлов.

Как только Docker находит инструкцию, для которой кэш не совпал (произошла инвалидация кэша), он выполняет её заново. И здесь кроется главное правило: инвалидация одного слоя автоматически инвалидирует кэш для всех последующих слоев.

Именно поэтому профессиональные Dockerfile строятся по принципу: от наименее изменяемого к наиболее изменяемому.

Рассмотрим классический антипаттерн в Node.js:

COPY . /app
RUN npm install

При любом изменении кода (даже в README.md) инвалидируется слой COPY . /app. Следовательно, RUN npm install будет выполняться заново, скачивая сотни мегабайт зависимостей.

Правильный SRE-подход разделяет копирование манифеста и кода:

# Копируем только манифест зависимостей
COPY package.json package-lock.json /app/
# Устанавливаем зависимости (кэшируется, пока не изменится package.json)
RUN npm install
# Копируем исходный код (меняется часто)
COPY . /app/

Анатомия раздувания: почему rm -rf на новой строке не работает

Вспомним механику драйвера overlay2 из прошлой главы. Слои иммутабельны (read-only). Если файл попал в слой, его невозможно удалить физически — можно лишь создать скрытый whiteout-файл в слое выше, который скажет драйверу «спрячь этот файл».

Рассмотрим частую ошибку:

RUN apt-get update && apt-get install -y build-essential curl
RUN curl -O https://example.com/big-source-code.tar.gz
RUN make install
RUN rm -rf /var/lib/apt/lists/* big-source-code.tar.gz

Разработчик честно попытался очистить за собой мусор в последней строке. Но каждая инструкция RUN создает свой слой.

  • Слой 1 добавил компиляторы (300 МБ).
  • Слой 2 добавил исходники (500 МБ).
  • Слой 3 добавил бинарник (20 МБ).
  • Слой 4 добавил whiteout-файлы, скрывающие исходники и кэш apt (0 МБ).

Итоговый размер образа увеличился на 820 МБ. Файлы физически остались в слоях 1 и 2, занимая место на диске хоста и увеличивая время скачивания образа из Registry.

Чтобы файлы действительно не попали в итоговый образ, их загрузка, использование и удаление должны происходить в рамках одной инструкции RUN (в одном слое), объединенные логическим &&:

RUN apt-get update \
    && apt-get install -y build-essential curl \
    && curl -O https://example.com/big-source-code.tar.gz \
    && make install \
    && rm -rf /var/lib/apt/lists/* big-source-code.tar.gz \
    && apt-get purge -y build-essential curl \
    && apt-get autoremove -y

Multi-stage builds: хирургическое разделение сред

Даже если мы идеально склеим все команды RUN, в образе останется базовая операционная система (например, Ubuntu или Debian) со всеми её системными утилитами. Для компилируемых языков (Go, Rust, C++) или приложений, собираемых через бандлеры (React, Angular), нам нужны тяжелые инструменты сборки. Но для работы готового приложения в production нужен только сам бинарный файл или статика.

Оставлять инструменты сборки в production-образе — это грубое нарушение безопасности. Чем больше утилит (shell, curl, пакетные менеджеры) есть в контейнере, тем шире вектор атаки (attack surface), если злоумышленник найдет уязвимость в вашем приложении и получит RCE (Remote Code Execution).

Для решения этой проблемы в Docker внедрили Multi-stage builds (многоэтапные сборки).

Суть метода в том, что в одном Dockerfile можно использовать несколько инструкций FROM. Каждая инструкция FROM начинает новую стадию сборки с чистой файловой системой. Самая магия заключается в инструкции COPY --from=..., которая позволяет скопировать конкретные файлы из предыдущей стадии в текущую, оставляя весь остальной мусор позади.

Под капотом dockerd собирает первую стадию как обычный образ. Затем он начинает собирать вторую стадию. Когда встречается COPY --from, демон просто монтирует файловую систему первой стадии и копирует нужные файлы. По завершении сборки, слои промежуточных стадий остаются в системе как безымянные (dangling) образы, а финальным тегированным образом становится только результат последней стадии.

Практика: создание SRE-оптимизированного образа

Вернемся к нашему микросервису на Go из начала статьи. Превратим его 850-мегабайтный образ в идеальный production-ready артефакт.

# СТАДИЯ 1: Сборка (Builder)
# Берем тяжелый образ с установленным компилятором Go (около 800 МБ)
FROM golang:1.21-bullseye AS builder

WORKDIR /src

# Сначала копируем манифесты и скачиваем зависимости (оптимизация кэша!)
COPY go.mod go.sum ./
RUN go mod download

# Затем копируем исходный код
COPY . .

# Компилируем бинарник.
# Флаги CGO_ENABLED=0 делают его статически слинкованным (не зависящим от системных библиотек)
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/microservice ./cmd/main.go

# СТАДИЯ 2: Production
# Берем абсолютно пустой образ scratch (0 МБ)
FROM scratch

# Копируем ТОЛЬКО готовый бинарник из стадии builder
COPY --from=builder /app/microservice /microservice

# Указываем команду запуска
CMD ["/microservice"]

Результат: финальный образ весит ровно 15 мегабайт (размер самого бинарника). В нем нет ни операционной системы, ни оболочки sh, ни пакетных менеджеров. Даже если злоумышленник найдет уязвимость в коде и попытается выполнить системную команду, ему будет нечем её выполнять — в контейнере буквально нет файлов, кроме одного исполняемого.

Для языков, требующих среды выполнения (Python, Node.js, Java), вместо scratch на второй стадии используют минималистичные образы alpine (около 5 МБ) или специальные образы Distroless от Google, которые содержат только рантайм языка и базовые SSL-сертификаты, но лишены shell-оболочки.

Используя правильный порядок кэширования и Multi-stage сборку, мы решаем сразу три SRE-задачи: ускоряем CI/CD пайплайны, экономим дисковое пространство на серверах и радикально снижаем риски информационной безопасности.

Сетевой стек Docker: от виртуальных мостов до низкоуровневой маршрутизации трафика

Сетевой стек Docker: от виртуальных мостов до низкоуровневой маршрутизации трафика

Вы запускаете контейнер с Nginx, и Docker присваивает ему IP-адрес, например 172.17.0.2. Если выполнить curl 172.17.0.2 с хост-машины, вы получите стартовую страницу веб-сервера. Но если попытаться пинговать этот адрес с соседнего сервера в локальной сети — пакеты уйдут в никуда. Этот IP-адрес — иллюзия, существующая исключительно внутри ядра вашего Linux-хоста.

Ранее мы разобрали, что Network Namespace изолирует сетевой стек: у процесса появляются свои собственные сетевые интерфейсы, таблица маршрутизации и правила фаервола. По умолчанию этот изолированный стек абсолютно пуст (содержит только интерфейс loopback). Чтобы контейнер мог общаться с внешним миром, Docker должен построить виртуальную инфраструктуру из кабелей, коммутаторов и маршрутизаторов прямо в оперативной памяти.

Виртуальная физика: veth-пары и коммутатор docker0

Как соединить изолированную «комнату» (Network Namespace контейнера) с основной системой (Root Network Namespace)? Ядро Linux предоставляет для этого механизм veth (Virtual Ethernet).

veth-пара — это виртуальный сетевой кабель с двумя концами. Все, что отправляется в один конец, немедленно появляется из другого. При старте контейнера в режиме по умолчанию (--network bridge), Docker делает следующее:

  1. Создает veth-пару.
  2. Один конец помещает внутрь Network Namespace контейнера и переименовывает его в eth0. Именно ему назначается IP-адрес 172.17.0.2.
  3. Второй конец оставляет в корневом пространстве хоста, давая ему случайно сгенерированное имя (например, veth9a2b3c).

Если запустить десять контейнеров, на хосте появится десять veth-интерфейсов. Чтобы они могли общаться друг с другом и выходить в интернет, их нужно объединить. Для этого Docker при установке создает Linux bridge — виртуальный коммутатор с именем docker0.

Все хостовые концы veth-пар подключаются к docker0. Сам docker0 имеет IP-адрес (обычно 172.17.0.1) и выступает шлюзом по умолчанию (Default Gateway) для всех контейнеров в этой сети.

Linux bridge работает на канальном уровне (L2), как настоящий аппаратный свитч: он изучает MAC-адреса контейнеров и пересылает Ethernet-кадры между соответствующими veth-портами.

Выход в город: SNAT и маскарадинг

Контейнер может достучаться до docker0, но как ему скачать пакет из интернета? Внешние маршрутизаторы ничего не знают о подсети 172.17.0.0/16. Если пакет с таким обратным адресом уйдет в сеть провайдера, ответ никогда не вернется.

Здесь в игру вступает подсистема Netfilter/iptables. Демон Docker автоматически прописывает правила в цепочку POSTROUTING таблицы nat.

Когда пакет от контейнера идет в интернет, ядро Linux применяет SNAT (Source Network Address Translation) в режиме маскарадинга. Оно подменяет внутренний IP-адрес контейнера (172.17.0.2) на реальный IP-адрес хоста (например, 192.168.1.100), запоминая это соединение. Когда приходит ответ от внешнего сервера, ядро делает обратную замену и перенаправляет пакет в docker0, а оттуда — в нужный контейнер.

Входящий трафик: магия проброса портов (-p)

Самый частый вопрос на SRE-собеседованиях: как именно работает флаг -p 80:8080? Многие думают, что Docker запускает некий прокси-процесс, который слушает порт 80 и пересылает данные на порт 8080. На самом деле, основную работу делает ядро Linux.

При использовании -p Docker добавляет правила DNAT (Destination Network Address Translation) в цепочку PREROUTING таблицы nat.

Поток выглядит так:

  1. Внешний пакет приходит на физический интерфейс хоста (eth0) на порт 80.
  2. До того как пакет попадет в локальные процессы хоста, правило PREROUTING перехватывает его.
  3. IP назначения меняется с адреса хоста на IP контейнера (172.17.0.2), а порт назначения — с 80 на 8080.
  4. Пакет маршрутизируется на интерфейс docker0, проходит по veth-кабелю и попадает в контейнер.

Примечание SRE: Docker всё же запускает легковесный userland-процесс docker-proxy для каждого проброшенного порта. Но он нужен только для обработки краевых случаев (например, маршрутизации трафика от процессов самого хоста через localhost, где iptables PREROUTING может не сработать). 99% внешнего трафика обрабатывается ядром через iptables без участия docker-proxy.

Альтернативные сетевые режимы

Понимание bridge закрывает большинство сценариев, но иногда требуются другие подходы:

  • Host Network (--network host) В этом режиме Docker вообще не создает для контейнера Network Namespace. Процесс контейнера живет в сетевом пространстве хоста. Если приложение внутри слушает порт 80, оно займет порт 80 прямо на хост-машине. Плюсы: Максимальная производительность (нет накладных расходов на NAT и veth-пары). Минусы: Отсутствие изоляции, риск конфликта портов. Не работает на Docker Desktop (macOS/Windows), так как там хостом выступает скрытая виртуальная машина, а не ваша реальная ОС.
  • None (--network none) Контейнер получает свой Network Namespace, но Docker не добавляет в него никаких интерфейсов (кроме loopback). Идеально для параноидально изолированных задач: криптографических вычислений или обработки секретных данных, где гарантия отсутствия сети важнее всего.

User-Defined Bridges и встроенный DNS

Вы наверняка замечали: если запустить два контейнера в сети по умолчанию (bridge), они не могут обратиться друг к другу по именам (например, ping db). Приходится использовать их нестабильные IP-адреса. Однако, если создать свою сеть (docker network create my-net) и поместить контейнеры туда, разрешение имен начинает работать. Почему?

Сеть bridge оставлена в Docker исключительно для обратной совместимости со старыми версиями. В пользовательских сетях (User-Defined Bridges) Docker активирует встроенный DNS-сервер.

Внутри каждого контейнера в пользовательской сети файл /etc/resolv.conf указывает на адрес 127.0.0.11. Это специальный адрес, который перехватывается демоном Docker. Когда контейнер пытается разрешить имя db, запрос летит на 127.0.0.11, Docker проверяет свою внутреннюю базу данных запущенных контейнеров в этой сети и возвращает нужный IP-адрес. Это избавляет от необходимости жестко прописывать IP-адреса в конфигурациях.

Практика SRE: низкоуровневая отладка сети

Частая проблема в production: контейнер запущен, порт проброшен, но соединения отваливаются по таймауту. Как посмотреть трафик внутри контейнера, если в образе (особенно distroless) нет утилит tcpdump или netstat?

Используйте утилиту nsenter, которая позволяет выполнить команду из хост-системы внутри Namespace контейнера.

  1. Узнаем реальный PID контейнера на хосте: docker inspect -f '{{.State.Pid}}' my-container (допустим, PID 4512)
  2. Запускаем tcpdump из хоста, но в сетевом контексте контейнера: sudo nsenter -t 4512 -n tcpdump -i eth0 port 8080

Флаг -n указывает nsenter войти только в Network Namespace. Вы используете бинарник tcpdump, установленный на хосте, но он «видит» сетевые интерфейсы и трафик контейнера. Это мощнейший инструмент отладки, не требующий модификации production-образов.

Оркестрация локальных сред с Docker Compose и управление конфигурациями

Оркестрация локальных сред с Docker Compose и управление конфигурациями

Представьте, что вы разворачиваете типичный микросервис: API на Go, база данных PostgreSQL, кэш Redis и агент Prometheus для метрик. Чтобы запустить это локально через docker run, вам потребуется написать bash-скрипт строк на пятьдесят. В нём придётся вручную создать сеть, запустить базу, подождать её инициализации, пробросить нужные порты и связать контейнеры по IP. Если один из контейнеров упадёт, скрипт об этом не узнает. Управление инфраструктурой через императивные команды быстро превращается в хаос.

На уровне Senior SRE инфраструктура должна быть декларативной: мы описываем желаемое состояние системы, а инструмент сам решает, как его достичь. В экосистеме Docker эту задачу для локальных сред и CI/CD решает Docker Compose.

Compose как транслятор API

Docker Compose сам по себе не запускает контейнеры и не управляет их изоляцией. Современный Compose (v2) — это плагин, написанный на Go, который работает как умный транслятор.

Он читает декларативный файл compose.yaml, строит в памяти граф зависимостей и переводит его в серию REST/gRPC запросов к демону dockerd.

Когда вы выполняете docker compose up, Compose вводит понятие Проекта (Project). По умолчанию именем проекта становится название директории, в которой лежит файл. Это имя используется как префикс для всех создаваемых ресурсов, чтобы изолировать разные проекты друг от друга на одном хосте.

Compose гарантирует идемпотентность: если вы изменили конфигурацию только одного сервиса в YAML и снова выполнили up, Compose сравнит хеши конфигураций, оставит неизменные контейнеры работать, а пересоздаст только тот, чья конфигурация изменилась.

ENTRYPOINT и CMD: контроль точки входа

Прежде чем оркестрировать взаимодействие сервисов, нужно кристально ясно понимать, как именно стартует процесс внутри контейнера. В прошлой главе мы отложили этот вопрос, но в Compose переопределение команд — ежедневная рутина.

В Dockerfile есть две инструкции, определяющие запуск: ENTRYPOINT (точка входа) и CMD (команда). Их взаимодействие — самая частая причина путаницы у инженеров.

Правило работает так: итоговая команда запуска контейнера — это конкатенация ENTRYPOINT и CMD.

  1. ENTRYPOINT — это неизменяемая часть команды, сам исполняемый файл.
  2. CMD — это аргументы по умолчанию, которые передаются в ENTRYPOINT.

Shell-форма против Exec-формы

Обе инструкции можно написать двумя способами, и это критически влияет на работу процесса:

  • Shell-форма: CMD echo "Hello"
  • Exec-форма (JSON массив): CMD ["echo", "Hello"]

Если вы используете Shell-форму, Docker подставляет перед вашей командой /bin/sh -c. То есть ENTRYPOINT node app.js превращается в запуск /bin/sh -c "node app.js".

В контексте SRE это катастрофа. Процесс /bin/sh занимает PID 1 внутри Namespaces контейнера. Когда вы попытаетесь остановить контейнер (docker stop), демон пошлёт сигнал SIGTERM процессу с PID 1. Оболочка sh проигнорирует этот сигнал и не передаст его дочернему процессу node. Контейнер зависнет на 10 секунд, после чего ядро убьёт его принудительно через SIGKILL. Вы потеряете возможность graceful shutdown (корректного завершения с закрытием соединений к БД).

Best practice: Всегда используйте Exec-форму (массив JSON).

В Docker Compose эти инструкции переопределяются ключами entrypoint: и command:. Если вы задаёте command в Compose, он полностью перезаписывает CMD из Dockerfile, но ENTRYPOINT остаётся прежним (если вы не переопределили и его).

Сетевая топология Compose

В предыдущей главе мы разбирали, как работает виртуальный коммутатор docker0 и встроенный DNS-сервер (127.0.0.11). Compose использует эти механизмы на 100%.

Если в compose.yaml не указано иное, при запуске проекта Compose неявно создаёт новую пользовательскую сеть (user-defined bridge) с именем <project_name>_default. Все сервисы из файла подключаются к этой сети.

Главная магия заключается в том, что Compose автоматически регистрирует имена сервисов во встроенном DNS демона Docker.

services:
  web:
    image: nginx
  database:
    image: postgres

В этом примере процесс внутри контейнера web может сделать запрос ping database или подключиться к postgres://database:5432. Демон Docker перехватит DNS-запрос к 127.0.0.11 и вернёт внутренний IP-адрес контейнера database.

Вам не нужно пробрасывать порты (ports:) базы данных на хост, чтобы web мог к ней подключиться. Проброс портов нужен только для доступа снаружи (с вашего ноутбука). Внутри сети <project_name>_default контейнеры видят друг друга по всем портам напрямую через veth-интерфейсы.

Порядок запуска и состояние гонки (Race Condition)

Запуск нескольких контейнеров порождает проблему синхронизации. Допустим, у нас есть API и база данных.

Если мы используем директиву depends_on:

services:
  api:
    image: my-api
    depends_on:
      - db
  db:
    image: postgres

Compose гарантирует, что контейнер db стартует раньше, чем api. Но есть нюанс: Compose считает контейнер запущенным в тот момент, когда процесс с PID 1 внутри него начал работу.

СУБД PostgreSQL при первом запуске требует времени на выделение памяти, создание системных таблиц и запуск воркеров. Контейнер db уже имеет статус "running", Compose тут же запускает api. Приложение api пытается открыть TCP-соединение с БД, получает отказ (Connection refused) и падает. Это классическое состояние гонки.

Чтобы решить эту проблему, SRE используют механизм Healthchecks (проверок жизнеспособности). Мы должны научить Docker понимать, когда процесс не просто запущен, а реально готов принимать трафик.

services:
  db:
    image: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    image: my-api
    depends_on:
      db:
        condition: service_healthy

Теперь Compose выполнит запуск db, затем будет каждые 5 секунд выполнять внутри него команду pg_isready. Только когда эта команда вернёт код возврата 0 (успех), статус контейнера изменится на healthy, и Compose запустит контейнер api.

Управление конфигурациями: от локальной среды до CI

Один и тот же compose.yaml часто используется и разработчиком на ноутбуке, и в CI-пайплайне для прогона интеграционных тестов. Хардкодить пароли или пути к файлам в YAML — плохая практика.

Подстановка переменных (Interpolation)

Compose умеет читать переменные окружения с хост-машины или из файла .env, лежащего в той же директории. Внутри YAML вы используете синтаксис ${VAR_NAME}.

services:
  db:
    image: postgres:${PG_VERSION:-15} # 15 будет использовано как значение по умолчанию
    environment:
      POSTGRES_USER: ${DB_USER}

Важно различать два понятия:

  1. Подстановка в YAML: Переменная ${DB_USER} нужна самому Compose в момент парсинга файла.
  2. Передача в контейнер: Директива environment: или env_file: указывает демону Docker создать эти переменные окружения внутри Namespaces самого контейнера.

Переопределение файлов (Overrides)

Для адаптации под разные среды используется каскадное чтение файлов. По умолчанию, если в директории есть compose.yaml и compose.override.yaml, Compose читает оба и сливает их воедино (merge).

  • compose.yaml: Базовая конфигурация (образы, сети, healthchecks).
  • compose.override.yaml: Локальные удобства разработчика (проброс портов на хост, монтирование исходного кода через Bind Mount). Этот файл обычно добавляют в .gitignore.

В CI-пайплайне override-файла не будет, и база данных останется безопасно закрытой внутри виртуальной сети, не выставляя порты наружу.

Профили (Profiles)

Иногда проект разрастается до десятков сервисов. Разработчику фронтенда не нужно запускать тяжелые воркеры машинного обучения или брокеры сообщений. Для этого существуют профили.

services:
  frontend:
    image: my-front

  ml-worker:
    image: heavy-worker
    profiles:
      - data-science

Команда docker compose up запустит только frontend. Чтобы запустить ресурсоёмкий воркер, нужно явно указать профиль: docker compose --profile data-science up.

Docker Compose превращает набор разрозненных контейнеров в единую логическую систему. Однако он ограничен одним физическим хостом и не умеет автоматически перезапускать контейнеры на других серверах при сбоях оборудования. Именно там, где заканчиваются возможности Compose, начинается территория распределённых оркестраторов, таких как Kubernetes. Но для локальной отладки и тестирования Compose остаётся безальтернативным стандартом.

Безопасность и изоляция: Capabilities, Seccomp, AppArmor и Rootless режим

Безопасность и изоляция: Capabilities, Seccomp, AppArmor и Rootless режим

Запустите контейнер командой docker run -u root ubuntu bash. Внутри вы — суперпользователь. Вы можете устанавливать пакеты, менять конфигурации. Но задумывались ли вы, кем этот процесс является для ядра хост-системы? Если вы найдете реальный PID этого bash на хосте и проверите его владельца, вы увидите root. Контейнер — это просто процесс. И если этот процесс найдет уязвимость, позволяющую выйти за пределы Mount или PID Namespace, он получит полный контроль над сервером, потому что для ядра он — легитимный суперпользователь.

Чтобы предотвратить захват хоста при компрометации контейнера, Docker выстраивает эшелонированную оборону (Defense in Depth). Она состоит из механизмов ядра Linux, которые ограничивают процесс, даже если он запущен от имени root.

Linux Capabilities: дробление абсолютной власти

Исторически в UNIX существовало два типа процессов: привилегированные (UID 0, root) и непривилегированные (все остальные). Ядро не проверяло права при системных вызовах от root. Это нарушало принцип наименьших привилегий: чтобы программа могла просто открыть порт 80, ей приходилось давать права на перезагрузку сервера, форматирование дисков и загрузку модулей ядра.

Linux Capabilities разбивают монолитные права root на ~40 независимых привилегий.

По умолчанию Docker отбирает у root внутри контейнера большинство привилегий, оставляя лишь 14 базовых. Например, процесс в контейнере сохраняет CAP_CHOWN (смена владельца файла) и CAP_NET_BIND_SERVICE (прослушивание портов < 1024), но лишается CAP_SYS_TIME (изменение системного времени) и CAP_SYS_MODULE (загрузка модулей ядра).

Практика SRE: управление Capabilities

Частая ошибка при переезде в Docker — падение утилит, требующих специфических прав. Например, если вы собираете минималистичный образ для сетевой диагностики и запускаете там ping, он может завершиться с ошибкой: ping: socket: Operation not permitted

Утилита ping использует сырые сокеты (raw sockets) для отправки ICMP-пакетов. Для этого ядро требует наличие capability CAP_NET_RAW. Если администратор кластера параноидально урезал права контейнера, ping работать не будет.

Управление осуществляется флагами --cap-add и --cap-drop:

# Идеальный с точки зрения безопасности запуск:
# Отбираем всё, выдаем только право слушать 80 порт
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

В Docker Compose это выглядит так:

services:
  web:
    image: nginx
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Важно: Никогда не используйте флаг --privileged. Он не просто добавляет все Capabilities, но и отключает изоляцию устройств (cgroup device whitelist) и снимает профили Seccomp/AppArmor. Контейнер с --privileged — это открытая дверь к ядру хоста.

Seccomp: фаервол для системных вызовов

Capabilities решают часть проблемы, но они всё ещё слишком широкие. Например, CAP_SYS_ADMIN — это «новая свалка root», включающая в себя десятки различных прав, от монтирования файловых систем до управления пространствами имен.

Здесь на сцену выходит Seccomp (Secure Computing Mode). Если Capabilities проверяют права на действие, то Seccomp фильтрует сами системные вызовы (syscalls) от процесса к ядру.

Seccomp работает на базе BPF (Berkeley Packet Filter) — механизма ядра, который позволяет загружать микропрограммы для проверки каждого системного вызова на лету. Docker при запуске контейнера применяет профиль Seccomp по умолчанию (файл JSON), который блокирует около 44 из 300+ существующих системных вызовов Linux.

Например, системный вызов unshare (создание новых Namespaces) заблокирован по умолчанию. Даже если злоумышленник получит нужные Capabilities, ядро прервет выполнение вызова на этапе фильтрации Seccomp, вернув ошибку EPERM.

Чтобы применить кастомный профиль, используется флаг --security-opt: docker run --security-opt seccomp=/path/to/profile.json ubuntu

AppArmor: мандатное управление доступом (MAC)

Даже если у процесса есть нужные Capabilities и системный вызов разрешен в Seccomp, нам нужно ограничить, к каким именно объектам применяется это действие.

AppArmor (как и SELinux) реализует Mandatory Access Control. Он привязывает политики безопасности к путям файловой системы. Профиль AppArmor docker-default, который применяется ко всем контейнерам, запрещает запись в критичные директории ядра, проброшенные в контейнер (например, /proc/sys или /sys).

Если процесс попытается выполнить echo 1 > /proc/sys/kernel/panic, ядро проверит:

  1. Есть ли права на файл? (Да, внутри контейнера процесс root).
  2. Есть ли Capability? (Да).
  3. Разрешен ли syscall write? (Да, Seccomp пропускает).
  4. Разрешает ли AppArmor запись по этому пути? (Нет, отказ).

User Namespaces: сдвиг реальности

Все описанные выше механизмы работают как фильтры. Но самая надежная защита — сделать так, чтобы root в контейнере вообще не был root на хосте. Эту задачу решает User Namespace.

Механизм User Namespaces позволяет создать маппинг (сопоставление) идентификаторов пользователей (UID) и групп (GID) между контейнером и хостом. Мы можем сказать ядру: «Пусть процесс думает, что у него UID 0, но на самом деле на хосте это UID 100000».

Если процесс с таким маппингом вырвется из контейнера, на хосте он окажется непривилегированным пользователем 100000, у которого нет прав даже на чтение /etc/shadow или запись в системные директории.

Для включения User Namespaces на уровне всего демона Docker, необходимо настроить файлы /etc/subuid и /etc/subgid на хосте, а затем добавить конфигурацию в /etc/docker/daemon.json:

{
  "userns-remap": "default"
}

Rootless Docker: защита самого демона

User Namespaces защищают от процессов внутри контейнера. Но сам процесс dockerd (демон Docker) по умолчанию работает от root. Если злоумышленник получит доступ к сокету Docker (/var/run/docker.sock), он сможет запустить любой контейнер с любыми правами, скомпрометировав хост.

Rootless режим — это архитектурный паттерн, при котором сам демон dockerd и все его компоненты (containerd, runc) запускаются от имени обычного пользователя хоста, вообще без привилегий суперпользователя.

В этом режиме:

  • Демон работает в пространстве пользователя (например, UID 1000).
  • Внутри демона запускаются контейнеры, где root (UID 0) мапится на поддиапазон пользователя (например, UID 100000+).
  • Уязвимости в самом Docker Engine больше не ведут к получению root-доступа к серверу.

Ограничения Rootless режима: Так как демон не имеет прав root на хосте, он не может использовать физические возможности ядра, требующие привилегий.

  1. Порты: Нельзя пробросить порты ниже 1024 (например, 80 или 443). Придется использовать высокие порты (например, 8080) или настраивать setcap для бинарника rootlesskit.
  2. Сеть: Недоступен режим host network в его истинном виде, так как демон не может управлять сетевыми интерфейсами хоста.
  3. Cgroups: Для управления лимитами ресурсов (CPU/RAM) требуется поддержка cgroup v2 и делегирование прав через systemd.

Изоляция контейнеров — это не одна волшебная кнопка, а набор слоев. В production-средах Senior SRE применяют правило «запрещено всё, что не разрешено явно»: отбрасывают все Capabilities, оставляя 1-2 нужных, используют Read-Only корневые файловые системы и, при возможности, переводят кластеры на Rootless-архитектуру.

Production-эксплуатация: лимиты ресурсов, логирование и стратегии отладки (Troubleshooting)

Production-эксплуатация: лимиты ресурсов, логирование и стратегии отладки (Troubleshooting)

Представьте классический ночной инцидент: на сервере с 64 ГБ оперативной памяти запущен десяток микросервисов. Внезапно мониторинг начинает «краснеть», SSH-подключение к хосту зависает, а затем сервер уходит в жесткую перезагрузку. Причина? Один из контейнеров с утечкой памяти потребил все 64 ГБ, спровоцировав панику ядра и активацию системного OOM Killer, который в попытке спасти ОС убил процесс dockerd.

Почему это произошло? Потому что по умолчанию Docker запускает контейнеры без каких-либо ограничений. Контейнер может утилизировать 100% CPU и всю доступную RAM хоста. В production-среде это недопустимо.

В этой главе мы разберем, как превратить анархию в предсказуемую систему: как ядро Linux на самом деле режет ресурсы, куда исчезают логи и как отлаживать контейнеры, в которых нет даже оболочки sh.

Лимиты ресурсов: Анатомия cgroups в бою

В первой главе мы выяснили, что за ограничение ресурсов отвечают Control Groups (cgroups). Теперь посмотрим, как Docker транслирует флаги CLI в параметры ядра.

Оперативная память (RAM)

Управление памятью в Docker задается двумя основными флагами:

  • --memory (или -m) — жесткий лимит (hard limit). Если процесс превышает его, ядро Linux немедленно отправляет ему сигнал SIGKILL (OOMKilled).
  • --memory-reservation — мягкий лимит (soft limit). Контейнеру разрешено превышать этот объем, но если на хосте начинается нехватка памяти, ядро начнет агрессивно вытеснять кэши именно этого контейнера.

Ловушка «Container-Aware» сред: Долгое время рантаймы языков программирования (JVM, Node.js V8, Go) не умели читать ограничения cgroups. Процесс внутри контейнера с лимитом --memory=512m вызывал системный вызов для проверки доступной памяти, видел 64 ГБ хоста и устанавливал размер Heap (кучи) пропорционально хосту. Итог: мгновенный OOMKilled при попытке сборщика мусора выделить память.

Решение: Современные версии рантаймов стали cgroup-aware. Например, в Node.js флаг --max-old-space-size больше не обязателен, если версия старше 12.x, а в Java 11+ флаг -XX:+UseContainerSupport включен по умолчанию.

Процессорное время (CPU)

С ограничением CPU всё сложнее. Ядро Linux использует планировщик CFS (Completely Fair Scheduler). Docker предлагает два принципиально разных подхода к ограничению CPU.

1. Относительный вес (CPU Shares) Флаг --cpu-shares задает приоритет (по умолчанию 1024). Это мягкое ограничение. Если на хосте работает один контейнер с весом 512, а CPU простаивает, контейнер получит 100% процессора. Лимит включается только в момент конкуренции. Если два контейнера (с весами 1024 и 512) претендуют на 1 ядро, первый получит ~66% времени, второй — ~33%.

2. Жесткая квота (CPU Quota) Флаг --cpus (например, --cpus=1.5) жестко ограничивает потребление. Под капотом Docker транслирует этот флаг в два параметра CFS:

  • cpu.cfs_period_us — период планирования (по умолчанию 100000 микросекунд, или 100 мс).
  • cpu.cfs_quota_us — сколько микросекунд из периода контейнер может выполняться.

Формула расчета квоты ядром: Quota=Period×CPUsQuota = Period \times CPUs

Если вы задали --cpus=1.5, ядро установит квоту в 150000 мс на каждые 100 мс реального времени. Это означает, что процесс может полностью загрузить одно ядро (100000 мс) и наполовину второе (50000 мс) за один период. Если процесс исчерпает квоту раньше конца периода, планировщик приостановит его (CPU Throttling) до начала следующего окна.

Высокий уровень троттлинга часто становится причиной необъяснимых задержек (latency) в микросервисах, даже когда мониторинг показывает, что утилизация CPU хоста составляет всего 20%.

Жизненный цикл: Graceful Shutdown

В главе про Docker Compose мы упоминали проблему PID 1 и обработку сигналов. В production это становится критичным при выкатке новых версий (Zero-Downtime Deployment).

Когда оркестратор обновляет контейнер, он отправляет процессу с PID 1 сигнал SIGTERM. Это вежливая просьба завершить работу. Если приложение игнорирует SIGTERM (или сигнал застрял в sh -c из-за использования Shell-формы в Dockerfile), Docker ждет stop-timeout (по умолчанию 10 секунд). После этого ядро безжалостно убивает процесс сигналом SIGKILL.

Для базы данных это означает повреждение индексов. Для веб-сервера — разрыв активных клиентских соединений (HTTP 502 Bad Gateway).

Production Best Practice: Приложение обязано перехватывать SIGTERM. Получив его, сервис должен:

  1. Перестать принимать новые запросы (например, убрать себя из Service Mesh / Load Balancer).
  2. Завершить обработку текущих запросов.
  3. Корректно закрыть соединения с базами данных.
  4. Завершиться с кодом 0 (exit 0).

Укрощение логов: Куда утекает диск

По умолчанию Docker использует log-драйвер json-file. Он перехватывает стандартные потоки вывода (stdout и stderr) процесса и пишет их в JSON-файлы на диске хоста (/var/lib/docker/containers/<id>/...).

Проблема в том, что по умолчанию ротация логов отключена. Если ваш сервис пишет много логов, файл будет расти бесконечно, пока на сервере не закончится место (ошибка No space left on device), что приведет к падению всех сервисов на хосте.

Решение 1: Локальная ротация Настройте ротацию глобально для демона через файл /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Это гарантирует, что логи одного контейнера никогда не превысят 30 МБ.

Решение 2: Централизованное логирование (SRE подход) В серьезных инфраструктурах логи не хранят на узлах. Драйвер меняют на journald (для интеграции с системными логами Linux), fluentd или syslog. В этом случае Docker вообще не пишет логи на диск, а сразу отправляет их по сети в агрегатор (ELK/EFK стек, Datadog, Splunk).

Стратегии отладки (Troubleshooting)

Представьте ситуацию: ваш контейнер падает в production. Вы хотите зайти внутрь, чтобы проверить конфигурацию. Вы пишете docker exec -it <id> sh и получаете ошибку: executable file not found in $PATH.

Вы вспоминаете главу про оптимизацию образов: контейнер собран на базе scratch или distroless. В нем нет ни sh, ни ls, ни curl. Только ваш скомпилированный бинарник. Как это отлаживать?

Паттерн: Debug Sidecar (Shared Namespaces)

Поскольку контейнер — это просто набор Namespaces (изолированных пространств), мы можем запустить новый контейнер, в котором есть все утилиты отладки, и попросить Docker поместить его в те же Namespaces, что и сломанный контейнер!

Для этого SRE-инженеры используют специальные образы-швейцарские ножи (например, nicolaka/netshoot).

Запуск отладочного контейнера в сети и пространстве процессов проблемного контейнера:

docker run -it --rm \
  --pid=container:<target_container_id> \
  --net=container:<target_container_id> \
  nicolaka/netshoot bash

Что произошло:

  1. Мы запустили новый контейнер из образа netshoot с оболочкой bash.
  2. Флаг --net=container:... приказал не создавать новый Network Namespace, а подключиться к существующему. Теперь команда netstat или tcpdump внутри netshoot покажет трафик целевого сервиса.
  3. Флаг --pid=container:... объединил PID Namespace. Теперь команда top или strace увидит процессы целевого приложения.

Это элегантный SRE-паттерн: мы сохраняем production-образы минималистичными и безопасными (без shell и утилит), но при этом сохраняем полную возможность глубокой отладки с помощью инструментов хоста и sidecar-контейнеров.

На этом мы завершаем глубокое погружение в архитектуру и эксплуатацию Docker. Понимание того, как абстракции контейнеров опираются на фундаментальные механизмы ядра Linux (namespaces, cgroups, overlayfs, iptables), позволяет не просто запускать команды по инструкции, а осознанно проектировать отказоустойчивые, безопасные и оптимизированные системы уровня Senior SRE.