Тема 02

Docker

Контейнер — не маленькая виртуальная машина, а процесс на общем ядре Linux, и большая часть ошибок растёт отсюда. Как устроены образ и его слои, как собрать Go-сервис быстро, воспроизводимо и без лишнего в образе, что делает кэш BuildKit и что хранит реестр. И что происходит с контейнером в работе: сеть и публикация портов, тома, compose, лимиты ресурсов, логи, сигналы и безопасность запуска.

Что реально проверяют этой секцией

Понимаешь ли ты Docker как надстройку над ядром Linux, а не как набор команд из шпаргалки. Можно сказать «добавим ещё контейнеров», а можно объяснить, почему приложение за shell-обёрткой не получает SIGTERM, почему Go-сервис со старой версией в go.mod при лимите в одно ядро ставит GOMAXPROCS по числу ядер хоста и почему смонтированный docker.sock равен root на хосте. На собеседовании это два разных грейда.

2.1Как устроен контейнер

Здесь проверяют не знание флагов, а понимание двух вещей: образ собран из стопки неизменяемых слоёв с кэшем, а контейнер остаётся обычным процессом, у которого отобрали видимость и ограничили ресурсы. Остальное из этого и следует.

Контейнер vs виртуальная машина

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

Виртуальная машина Приложение A Приложение B Библиотеки, ФС Библиотеки, ФС Гостевая ОС + ядро Гостевая ОС + ядро Гипервизор Хост-ОС (только для type 2) Железо Контейнеры Прил. A Прил. B Прил. C ФС образа ФС образа ФС образа своего ядра нет namespaces + cgroups + seccomp + capabilities Ядро Linux — одно на всех Железо Гипервизоры: type 1 (bare-metal) — ESXi, Xen, Hyper-V, KVM: исполняются прямо на железе, это прод и облака. type 2 (hosted) — VirtualBox, VMware Workstation, Parallels: поверх обычной ОС, для десктопа и тестов. KVM формально модуль ядра Linux, но по свойствам это type 1: ядро само превращается в гипервизор.
Два способа изоляции. ВМ дублирует ядро — отсюда вес и надёжность границы. Контейнер переиспользует ядро хоста: быстрее стартует, но граница тоньше.
Виртуальная машинаКонтейнер
Ядросвоё, гостевоеобщее с хостом
Стартдесятки секунддесятки миллисекунд
Накладные расходысотни МиБ на гостевую ОСединицы МиБ на процесс containerd-shim
Плотность на хостеединицы–десяткисотни
Граница безопасностисильная (аппаратная виртуализация)слабее: общее ядро, нужны seccomp/AppArmor
Разные ОС на одном хостеда (Windows на Linux)нет — только то, что понимает ядро хоста
Образот сотен МиБ до десятков ГиБот 2 МиБ (scratch с Go-бинарём)
Почему Docker на macOS и Windows «медленный»

Потому что там нет ядра Linux. Docker Desktop поднимает виртуальную машину с Linux (на macOS — Apple Virtualization framework или Docker VMM, на Windows — WSL 2 или Hyper-V), и все контейнеры живут внутри неё. Отсюда и тормоза bind-mount'ов: файловая система пробрасывается через границу ВМ. Хорошая добивка к ответу: видно, что линуксовую природу контейнеров ты понимаешь.

Образ, слои и OverlayFS

Два практических вопроса: почему сборка идёт то 10 секунд, то 10 минут и почему удалённый в Dockerfile секрет всё равно уезжает в реестр. Ответ на оба один, и держится он на устройстве слоёв.

Слой (layer) не «часть образа» в смысле «кусок». Слой хранит список изменений файловой системы: какие файлы добавились, какие изменились, какие удалились по сравнению с предыдущим состоянием. Git устроен наоборот: там коммит хранит снимок всего дерева, а слой ближе к патчу между двумя коммитами. Каждая инструкция Dockerfile, которая может что-то записать на диск (RUN, COPY, ADD, WORKDIR), рождает один такой слой, даже если он выйдет пустым.

Драйвер ядра Linux OverlayFS (overlay filesystem, «наложенная файловая система») умеет показать стопку таких слоёв как одно обычное дерево каталогов. Аналогия: стопка прозрачных плёнок, каждая с частью рисунка; смотришь сверху и видишь один цельный рисунок, хотя физически это несколько отдельных листов. Ломается аналогия на записи — там начинается своя механика, про неё сразу ниже.

Технически образ состоит из манифеста, конфига и списка слоёв. Каждый слой лежит tar-архивом с разницей файловой системы относительно предыдущего, адресуемый по SHA-256 своего содержимого. Слои неизменяемы и переиспользуются между образами: если у трёх твоих сервисов одинаковый базовый слой, на диске и в реестре он лежит один раз.

При запуске контейнера драйвер OverlayFS склеивает слои в одно дерево: нижние слои образа монтируются как lowerdir (только чтение), сверху добавляется тонкий upperdir, записываемый слой контейнера. Чтение идёт сверху вниз по стопке. При записи в файл из нижнего слоя срабатывает copy-up: файл целиком копируется в верхний слой и правится уже там. Удаление файла из нижнего слоя оформляется whiteout-записью. Отсюда два практических следствия: правка большого файла в контейнере стоит его полного копирования, а «удаление» секрета в следующем слое Dockerfile не удаляет его из образа — он остаётся в предыдущем слое и достаётся оттуда любым, кто скачал образ.

Кэш слоёв устроен просто: для каждой инструкции Dockerfile считается ключ. Для RUN это текст команды и значения объявленных выше ARG, для COPY/ADD контрольная сумма копируемых файлов (содержимое и права, но не время изменения). Совпали ключ и родительский слой — берём из кэша. Не совпали, пересобираем эту инструкцию и все последующие. Правило из этого одно: сначала то, что меняется редко, а исходный код в самом конце.

Плохой порядок: правка одной строки кода пересобирает всё, включая go mod download FROM golang:1.27-alpine кэш COPY . . инвалидация RUN go mod download заново, 6 с RUN go build -o /app ./cmd/api заново, 11 с итог: 22 секунды на каждый коммит Хороший порядок: зависимости отдельным слоем меняется только код — пересобирается только сборка FROM golang:1.27-alpine кэш COPY go.mod go.sum ./ кэш RUN go mod download кэш, 0 с COPY . . инвалидация RUN go build -o /app ./cmd/api заново, 12 с итог: 17 секунд, с cache mount — 2 секунды Кэш инвалидируется каскадом: не совпал ключ одной инструкции — пересобираются все, что ниже неё. Ключ для RUN — текст команды и значения ARG, для COPY — контрольная сумма файлов. Время — замер, 95 модулей.
Порядок инструкций решает всё. go mod download зависит только от go.mod/go.sum, значит, копировать в этот слой надо только их.
# посмотреть, из чего собран образ и сколько весит каждый слой
$ docker history myapp:latest --no-trunc
$ docker image inspect myapp:latest --format '{{json .RootFS.Layers}}'
$ dive myapp:latest            # интерактивно: что реально попало в каждый слой

# BuildKit: с кэш-маунтами модули и кэш компилятора переживают пересборку слоя
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /out/server ./cmd/api
Чек-лист «как ускорить сборку Go-образа»
  1. Порядок COPY: сначала go.mod/go.sum и go mod download, потом весь остальной код.
  2. .dockerignore: исключить .git, vendor (если не используешь), артефакты, node_modules, локальные .env. Иначе весь контекст (иногда гигабайты) уезжает сборщику (BuildKit досылает только изменения, но чистый раннер CI получает всё заново), а кэш COPY . . ломается при любом изменении в .git.
  3. BuildKit cache mounts для /go/pkg/mod и /root/.cache/go-build: инкрементальная компиляция переживает пересборку слоя.
  4. Общий кэш в CI: --cache-from/--cache-to type=registry, чтобы кэш жил не на раннере, а в реестре.
  5. Multi-stage, чтобы в финальный образ не уезжал тулчейн на сотни МиБ.
  6. Фиксированные теги базовых образов, лучше по digest. Иначе сборка невоспроизводима и кэш рвётся сам по себе.

Multi-stage build: статический бинарь в scratch

Идея простая: собирать в жирном образе с компилятором, а в финальный образ переносить только готовый бинарь. Go для этого идеален: без cgo линкуется статически и не тянет за собой рантайм.

Стадия builder FROM golang:1.27 AS builder компилятор, линкер, git — 950 МиБ модули в /go/pkg/mod — сотни МиБ исходники, тесты, .git /out/server — 17 МиБ COPY --from=builder Финальный образ FROM gcr.io/distroless/static-debian13:nonroot ca-certificates — для HTTPS /etc/passwd — чтобы был nonroot tzdata — если нужны часовые пояса /server — 17 МиБ Итог: 950 МиБ против 20 МиБ. Меньше вес — быстрее pull при скейлинге, меньше поверхность атаки: в distroless/scratch нет ни shell, ни пакетного менеджера, ни curl — эксплуатировать нечего.
Multi-stage. Всё, что нужно для сборки, остаётся в первой стадии и в финальный образ не попадает, включая исходники, токены и историю git.
# syntax=docker/dockerfile:1
# ---------- стадия сборки ----------
FROM golang:1.27-alpine AS builder

WORKDIR /src

# 1. Слой зависимостей: меняется только при правке go.mod/go.sum
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

# 2. Слой кода
COPY . .

# 3. Сборка. CGO_ENABLED=0 даёт полностью статический бинарь.
#    -trimpath убирает пути сборки, -s -w выкидывают отладочные символы (минус ~30% веса).
#    Версию зашиваем через ldflags, чтобы приложение знало, что оно такое.
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/server ./cmd/api

# ---------- финальный образ ----------
FROM gcr.io/distroless/static-debian13:nonroot

# сертификаты и часовые пояса уже есть в distroless; для scratch их надо копировать:
# COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/server /server

USER nonroot:nonroot
EXPOSE 8080
# exec-форма обязательна: бинарь становится PID 1 и получает SIGTERM напрямую
ENTRYPOINT ["/server"]
CMD ["--config=/etc/app/config.yaml"]
Что ломается при CGO_ENABLED=0
  • DNS. На Linux Go и с cgo по умолчанию резолвит сам, но уходит в системный резолвер glibc, если /etc/nsswitch.conf требует того, чего он не умеет (LDAP, mDNS, NSS-модули). Без cgo уходить некуда: чистый Go-резолвер читает /etc/resolv.conf напрямую и умеет только файлы и DNS. Обычно это даже лучше (не блокирует потоки), но экзотические корпоративные схемы разрешения имён отвалятся.
  • Сертификаты. В scratch нет /etc/ssl/certs — любой исходящий HTTPS упадёт с x509: certificate signed by unknown authority. Копируй ca-certificates.crt из builder или бери distroless.
  • Часовые пояса. Без /usr/share/zoneinfo time.LoadLocation("Europe/Moscow") вернёт ошибку. Лечится импортом _ "time/tzdata" (встраивает базу в бинарь, около +400 КиБ) или копированием zoneinfo.
  • os/user. user.Current() без cgo читает /etc/passwd, а без него держится только на переменных USER и HOME; в scratch нет ни файла, ни USER, и вызов вернёт ошибку.
  • Библиотеки на C (go-sqlite3, некоторые крипто-обёртки, старые драйверы) не соберутся или, как go-sqlite3, соберутся заглушкой и упадут при первом запросе. Тогда либо чистые Go-аналоги (modernc.org/sqlite), либо cgo и базовый образ с libc.
Alpine и musl

alpine популярен из-за размера, но libc у него другая, musl. Если ты собираешь с CGO_ENABLED=1 на glibc-образе и запускаешь в alpine, бинарь не стартует («no such file or directory» на существующий файл — это как раз про отсутствующий динамический линкер). Плюс исторические грабли: DNS-резолвер musl, который до версии 1.2.4 не умел TCP и терял большие ответы, и просевшая производительность из-за другого аллокатора. Для Go правильный ответ почти всегда один: статический бинарь в distroless/static или scratch, а alpine брать только когда реально нужен shell внутри.

Dockerfile: ENTRYPOINT, CMD и проблема PID 1

ИнструкцияСмыслЧто происходит на практике
ENTRYPOINTчто запускать — сам исполняемый файлне перекрывается аргументами docker run (только --entrypoint)
CMDаргументы по умолчаниюполностью заменяется тем, что дописали в docker run image ...
exec-форма ["/app"]прямой execveправильно: процесс становится PID 1 и получает сигналы
shell-форма /appоборачивается в /bin/sh -cловушка: PID 1 — это sh, он не пересылает SIGTERM (dash в Debian и Ubuntu; busybox в alpine одиночную команду запускает через exec)
EXPOSE 8080документациясам ничего не публикует; порт открывает -p (-P публикует все EXPOSE на случайные порты) или Service в k8s
USER nonrootот кого работает процесспо умолчанию root — а это root и на хосте при пробое
HEALTHCHECKпроверка для докераработает в docker/compose; Kubernetes её игнорирует, там probes
ARG vs ENVbuild-time vs run-timeARG виден в истории образа — секреты туда класть нельзя
Shell-форма: ENTRYPOINT /server docker stop SIGTERM PID 1: /bin/sh -c сигнал не пересылает PID 7: /server ничего не получил через 10 с SIGKILL запросы оборваны Exec-форма: ENTRYPOINT ["/server"] docker stop SIGTERM PID 1: /server signal.NotifyContext srv.Shutdown(ctx) доигрывает запросы exit 0 за 3 с ни одного 5xx У PID 1 без обработчика сигнал теряется (так живёт sh). Go ставит обработчики сам и выходит с 143, обрывая запросы. Поэтому: exec-форма всегда; если нужен shell-обвяз — ставь init (docker run --init, tini) и не забывай про exec.
PID 1 и сигналы. Самая частая причина «наш сервис теряет запросы при деплое» находится не в коде, а в одной строке Dockerfile.

Тома, сети, docker-compose

  • Named volume (-v pgdata:/var/lib/postgresql/data) живёт под управлением докера в /var/lib/docker/volumes и переживает пересоздание контейнера. Правильный выбор для данных БД.
  • Bind mount (-v $(pwd):/src) кладёт каталог хоста внутрь. Для разработки; в проде опасен (зависимость от хоста, проблемы с правами и SELinux).
  • tmpfs (--tmpfs /tmp) держит данные в памяти, на диск не пишет. Хорош вместе с read_only: true для контейнера.
  • Сети: bridge по умолчанию; user-defined bridge добавляет встроенный DNS, и контейнеры видят друг друга по имени контейнера или алиасу, а compose добавляет алиасом имя сервиса. host снимает сетевую изоляцию (быстро, но порты конфликтуют), none оставляет только loopback.
  • -p 8080:8080 публикует порт наружу через iptables DNAT. Внутри одной сети публиковать ничего не нужно.
services:
  api:
    build:
      context: .
      args: { VERSION: dev }
    environment:
      DB_DSN: postgres://app:secret@db:5432/app?sslmode=disable   # имя сервиса db резолвит DNS докера
      GOMEMLIMIT: 400MiB
    ports: ["8080:8080"]
    depends_on:
      db: { condition: service_healthy }   # ждать не запуска, а готовности
      redis: { condition: service_started }
    read_only: true
    tmpfs: ["/tmp"]

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
      POSTGRES_USER: app
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]

volumes:
  pgdata:
Классика: depends_on без healthcheck

Голый depends_on: [db] гарантирует только порядок запуска контейнеров, а не готовность БД принимать соединения. Postgres поднимается несколько секунд, и приложение падает на старте с «connection refused». Правильно ставить condition: service_healthy плюс ретраи подключения в самом приложении: в k8s никакого depends_on нет вообще, и сервис обязан переживать недоступность зависимостей на старте.

GOMAXPROCS и GOMEMLIMIT: почему это обязательный пункт

Контейнерные лимиты задаются через cgroups, а namespaces не изолируют /proc/cpuinfo и /proc/meminfo. Значит, рантайм Go, если ничего не предпринять, видит хост целиком (CPU-лимит он сам учитывает только с Go 1.25, память — до сих пор нет) — и решения принимает по неверным числам.

CPU: квота CFS и троттлинг

Лимит CPU в контейнере задаёт не «медленные ядра», а квоту: cpu.max = 100000 100000 значит «100 мс процессорного времени на каждые 100 мс реального». Планировщик считает суммарное время всех потоков cgroup. Если GOMAXPROCS равен 64 (число ядер ноды), Go запускает 64 горутины параллельно, они выжигают всю квоту за первые 1.5 мс периода — и все потоки группы замораживаются до конца стомиллисекундного окна. Средняя утилизация при этом выглядит скромно (1 ядро из 64), а p99 задержки взлетает в разы: любой запрос, попавший в окно троттлинга, ждёт до 100 мс на ровном месте.

Лимит 1 CPU (cpu.max = 100000 100000), нода на 64 ядра GOMAXPROCS=64 заморожены до конца периода заморожены квота сожжена за 1.5 мс шестьюдесятью четырьмя потоками сразу GOMAXPROCS=1 работа ровно, квота расходуется равномерно работа ровно троттлинга нет, p99 стабилен 0 мс 100 мс 200 мс Проверить: container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total. Больше 1–2 % — уже больно.
Троттлинг CFS. Не «не хватило CPU», а «выбрали квоту слишком быстро». Лечат не лимит, а GOMAXPROCS: его приводят к лимиту.
// Вариант 1 (актуально только для Go до 1.25): библиотека Uber
// читает cgroup и ставит GOMAXPROCS на старте.
import _ "go.uber.org/automaxprocs"     // на Go 1.25+ не нужна и мешает: выключает пересчёт

// Вариант 2: руками из переменной окружения, которую подставляет k8s
// env:
//   - name: GOMAXPROCS
//     valueFrom: { resourceFieldRef: { resource: limits.cpu } }

// Вариант 3 (Go 1.25+ и go 1.25+ в go.mod), он же способ по умолчанию: рантайм сам читает cpu.max
// своей cgroup и, более того, пересчитывает GOMAXPROCS на лету, если лимит поменяли.
// Ничего писать не надо, это поведение из коробки.
// Отключается через GODEBUG=containermaxprocs=0,updatemaxprocs=0

// Go 1.25+: вернуться в автоматический режим после ручной установки
runtime.GOMAXPROCS(2)          // выключили автоматику: значение зафиксировано
runtime.SetDefaultGOMAXPROCS() // снова «как решит рантайм», по cgroup-лимиту,
                               // и снова с пересчётом при изменении лимита

// Память: GOMEMLIMIT задаёт мягкий предел, у границы GC работает чаще
// debug.SetMemoryLimit(400 << 20)  // или env GOMEMLIMIT=400MiB
// Практика: 80–90 % от limits.memory: предел мягкий, а cgroup кроме памяти
// рантайма (куча, стеки, метаданные) считает бинарь, C-код и память ядра.
Версии: что здесь изменилось и что теперь лишнее
  • Go 1.25 научил рантайм читать cgroup-лимит CPU на Linux сам, если в go.mod указана версия 1.25 или новее (для модуля со старой версией в go.mod всё по-старому). Если лимит ниже числа логических ядер, GOMAXPROCS берётся из него: дробный лимит округляется вверх, а ниже двух рантайм не опускается, пока ядер и маски affinity хватает на два. Вдобавок значение периодически пересматривается на всех ОС, если доступность CPU изменилась.
  • Следствие для собеса: внешний go.uber.org/automaxprocs больше не нужен. Годами это был обязательный импорт в любом Go-сервисе под Kubernetes — теперь лишняя зависимость, и даже вредная: она ставит значение вручную и этим выключает пересчёт. Скажешь это вслух, сразу видно, что ты следишь за версиями.
  • Ручной вызов runtime.GOMAXPROCS(n) по-прежнему выключает автоматику целиком: значение фиксируется и больше не пересматривается. Вернуть автоматический режим позволяет новая функция runtime.SetDefaultGOMAXPROCS() (тоже с Go 1.25): она возвращает рантайму право самому выбирать значение по cgroup-лимиту и дальше следить за его изменениями. Других штатных способов «отменить» ручную установку нет.
Почему GOMEMLIMIT спасает от OOMKilled

Сборщик мусора Go по умолчанию управляется только GOGC (100): «запусти сборку, когда живая куча удвоится с прошлого раза». Он не знает про memory.max контейнера. Если после сборки живых объектов 300 МиБ, GC спокойно даст куче дорасти до 600 МиБ — а лимит 512 МиБ, и cgroup-OOM убивает процесс с кодом 137. GOMEMLIMIT ставит мягкий предел: при приближении к нему GC начинает работать чаще и агрессивнее возвращает память ядру. «Мягкий» значит, что если живых данных реально больше лимита, процесс не упадёт, а будет молотить GC постоянно (GC death spiral), и это видно по go_gc_duration_seconds и по проценту CPU в GC. Комбинация GOGC=off + GOMEMLIMIT хорошо работает для сервисов с предсказуемым потолком памяти.

Вопросы

6
Суть: ВМ виртуализирует железо и тащит собственное ядро, контейнер — это обычный процесс хостового ядра, которому namespaces подменили видимость мира, а cgroups ограничили ресурсы. Отсюда всё: старт за доли секунды против десятков секунд, накладные расходы почти нулевые (единицы МиБ на containerd-shim) против процентов, изоляция слабее.

Что физически происходит

Когда ты делаешь docker run, никакой «машины» не создаётся. Демон разворачивает слои образа в overlay-файловую систему, создаёт набор namespace-ов (mnt, pid, net, uts, ipc, cgroup, с Docker 29.5 ещё и time; user-namespace по умолчанию не создаётся), кладёт процесс в cgroup с лимитами, делает pivot_root в подготовленный корень — и запускает твой бинарь обычным execve. В ps aux на хосте этот процесс прекрасно виден. Ядро одно на всех, системные вызовы идут напрямую, без прослойки.

Виртуальная машина эмулирует железо: свой BIOS/UEFI, свой загрузчик, своё ядро, свои драйверы, своя память, размеченная гипервизором. Гость не знает, что он гость (или знает и берёт паравиртуальные драйверы virtio, чтобы не платить за эмуляцию сетевой карты).

КонтейнерВиртуальная машина
Ядрообщее с хостомсвоё
Старт50–500 мс10–60 с
Накладные расходыединицы процентов, в основном сеть и overlayfs, плюс единицы МиБ на containerd-shim5–15 % CPU/памяти на гостевое ядро
Размердесятки МиБ (для Go — 10–20)гигабайты
Изоляциялогическая: пробой ядра = пробой всего хостааппаратная: VT-x/EPT, граница гораздо крепче
Разные ОСнет: Linux-контейнер только на Linux-ядреда: Windows-гость на Linux-хосте
Плотностьсотни на нодудесятки

Гипервизоры

  • Тип 1 (bare metal) работает прямо на железе и сам служит ядром: VMware ESXi, Microsoft Hyper-V, Xen, KVM (формально модуль Linux-ядра, но по факту превращает ядро в гипервизор первого типа). На этом стоят все облака.
  • Тип 2 (hosted) идёт приложением поверх обычной ОС: VirtualBox, VMware Workstation, Parallels, QEMU без KVM. Удобно на ноутбуке, медленнее.
  • Обе разновидности опираются на аппаратную виртуализацию (Intel VT-x / AMD-V, EPT/NPT для таблиц страниц), поэтому граница между «типами» сегодня во многом терминологическая.

Почему на Mac и Windows Docker всё-таки виртуалка

Контейнеру нужны Linux-namespaces, а их нет ни в XNU, ни в NT. Docker Desktop поднимает лёгкую Linux-ВМ (на macOS через Apple Virtualization framework или Docker VMM, раньше HyperKit; на Windows через WSL 2 или Hyper-V) и запускает контейнеры уже внутри неё. Отсюда и тормоза bind-mount-ов на маке: файлы ходят через границу ВМ.

Гибриды, если спросят «а как в облаке изолируют чужой код»

Между двумя мирами живут микро-ВМ и песочницы: AWS Firecracker (микро-ВМ на KVM, стартует за ~125 мс, на нём работают Lambda и Fargate), Kata Containers (контейнер с собственным ядром в лёгкой ВМ, но с обычным OCI-интерфейсом), gVisor (перехват системных вызовов в user-space-ядре на Go). Все они решают одну задачу: дать аппаратную границу безопасности, сохранив скорость и API контейнеров.

Суть: каждая инструкция Dockerfile, которая может писать на диск (RUN, COPY, ADD, WORKDIR), даёт read-only слой; слой берётся из кэша, только если совпала инструкция и все предыдущие слои. Значит: сначала то, что меняется редко (зависимости), потом то, что меняется каждый коммит (исходники).

Механика

Образ складывается из слоёв, объединённых overlayfs: нижние read-only, сверху тонкий writable-слой контейнера. Запись в существующий файл вызывает copy-up: файл целиком копируется в верхний слой. Отсюда правило «не пиши много в контейнер, монтируй том»: перезапись гигабайтного файла в overlay сначала его скопирует.

Кэш сборки работает цепочкой. У каждого слоя есть ключ: инструкция плюс идентификатор родителя, для RUN ещё и значения объявленных выше ARG, а для COPY/ADD — контрольная сумма содержимого и прав копируемых файлов (время изменения не в счёт). Промах в одном слое инвалидирует все последующие. Поэтому COPY . . в начале Dockerfile убивает кэш полностью: любая правка README заставляет заново качать модули.

# Плохо: кэш ломается от любой правки
FROM golang:1.27
WORKDIR /src
COPY . .
RUN go mod download
RUN go build -o /out/app ./cmd/app
# Хорошо: зависимости отдельным слоем
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/app ./cmd/app

Чем ускорить дальше

  • BuildKit cache mounts кэшируют каталоги между сборками, в образ это не попадает: RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build ./.... Второй маунт держит кэш компиляции Go и даёт основной выигрыш на инкрементальной сборке.
  • .dockerignore. Без него в контекст уезжают .git, vendor, бинари и локальные артефакты: и контекст тяжёлый, и хеш COPY . . меняется от мусора.
  • Пины версий. FROM golang:1.27 вместо latest, в идеале по digest — иначе воспроизводимости нет.
  • Объединять RUN с apt-get update && apt-get install в одну команду, иначе закэшированный update отдаёт устаревшие индексы (сам этот приём документация Docker называет cache busting).
  • В CI кэш слоёв не переживает раннер, нужен внешний бэкенд: --cache-from type=registry,ref=..., GitHub Actions cache, локальный type=local.
Секрет, «удалённый» следующим слоем, никуда не делся

COPY id_rsa /root/ ... RUN rm /root/id_rsa не удаляет ничего: файл остался в нижнем слое, и его достанет любой, кто скачал образ. То же с ARG TOKEN — он виден в docker history. Правильно брать RUN --mount=type=secret (BuildKit): секрет монтируется на время выполнения инструкции и в слои не попадает.

Суть: собираем статический бинарь в жирном образе с тулчейном, в финальный образ копируем только его — получается 10–20 МиБ без компилятора, shell и пакетного менеджера. CGO_ENABLED=0 убирает зависимость от libc и вместе с ней системный резолвер, а пустая база — ещё и сертификаты и tzdata из ОС.

Канонический Dockerfile

FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
  • -trimpath убирает абсолютные пути сборки и делает сборку воспроизводимой.
  • -ldflags="-s -w" выкидывает таблицу символов и DWARF: минус треть размера. Платишь тем, что delve к такому бинарю толком не прицепится; стек-трейсы не пострадают: имена функций, файлы и строки живут в отдельной таблице и не выкидываются.
  • -X main.version зашивает версию внутрь бинаря; можно и наоборот, читать runtime/debug.ReadBuildInfo(), куда Go сам пишет VCS-ревизию, если каталог .git попал в контекст сборки.

scratch vs distroless vs alpine

БазаРазмерЧто внутриКогда брать
scratch0ничего вообщемаксимум аскезы; CA-сертификаты и /etc/passwd копировать руками
distroless/static~3 МиБCA-сертификаты, tzdata, /etc/passwd, nonroot-пользовательдефолт для Go
alpine~8 МиБmusl, busybox, apkкогда реально нужен shell внутри
debian-slim~75 МиБполноценная glibc-системаесли без cgo не обойтись

Что ломается без cgo

Коротко: DNS остаётся только на чистом Go-резолвере (он и с cgo работает по умолчанию, но ради LDAP, mDNS и прочих NSS-модулей из nsswitch.conf уходил в glibc, а теперь уходить некуда), а библиотеки на C (тот же mattn/go-sqlite3) собираются в заглушку и падают при первом обращении. Остальное ломает не сам cgo, а пустая база вроде scratch: исчезают корневые сертификаты (любой HTTPS падает с x509: certificate signed by unknown authority), time.LoadLocation не находит зону без _ "time/tzdata", user.Current() без /etc/passwd держится только на переменных USER и HOME, а их в scratch нет.

Как это звучит на собесе

«Multi-stage нужен не ради красоты, а ради двух вещей: размера и площади атаки. В финальном образе нет ни компилятора, ни curl, ни shell — злоумышленнику, который получил RCE, буквально нечем воспользоваться и нечего скачать. Побочный эффект: kubectl exec в такой под не даст оболочки, отлаживаться придётся через ephemeral containers (kubectl debug) или distroless-вариант с тегом :debug, где busybox есть.»

Суть: ENTRYPOINT — что запускать, CMD — аргументы по умолчанию; exec-форма обязательна, иначе PID 1 может стать sh и потерять SIGTERM. EXPOSE — просто документация, USER — главная строка, отделяющая тебя от root на хосте, HEALTHCHECK в Kubernetes игнорируется.

ENTRYPOINT и CMD

Итоговая команда склеивается так: ENTRYPOINT + CMD. Аргументы docker run image --foo заменяют только CMD. Отсюда идиома: ENTRYPOINT ["/server"], CMD ["--config=/etc/app/config.yaml"]. Конфиг переопределяется одной строкой запуска, а бинарь остаётся тем же. Полностью подменить точку входа можно только флагом --entrypoint.

PID 1 — главный подвох

Shell-форма ENTRYPOINT /server разворачивается в /bin/sh -c "/server". PID 1 достаётся sh (так в Debian и Ubuntu, где sh — это dash; busybox в Alpine и bash сами делают exec, и там PID 1 — уже сервер); он не пересылает сигналы потомкам, а ядро для PID 1 не применяет действия по умолчанию — сигнал без обработчика просто пропадает. Итог: docker stop ждёт 10 секунд (в Docker Desktop — 3: он прописывает демону default-stop-timeout) и присылает SIGKILL, все in-flight запросы обрываются, а в k8s это выглядит как «при каждом деплое ловим 502». Exec-форма ["/server"] снимает вопрос. Если shell-обвязка нужна, добавляй exec перед командой и запускай с --init (tini), который ещё и зомби пожинает.

EXPOSE

Не открывает и не публикует ничего. Это метаданные: подсказка человеку и источник для docker run -P (публикация всех EXPOSE на случайные порты). Реально порт наружу отдаёт -p 8080:8080 (правило DNAT в iptables), а в кластере за это отвечает Service.

USER

По умолчанию процесс в контейнере работает от root, и это root того же ядра: без user-namespace uid 0 внутри равен uid 0 снаружи. Любой пробой рантайма или неудачный bind-mount — и ты хозяин ноды. Правильно: непривилегированный пользователь в образе (distroless :nonroot даёт uid 65532), а в k8s продублировать это в securityContext: runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: drop: [ALL]. Порт ниже 1024 root не требует: Docker с 20.10 и containerd 2.0 под Kubernetes и так пускают на него непривилегированный процесс (sysctl ip_unprivileged_port_start=0), а где это не так, бери порт выше 1024 (правильный ответ) или capability NET_BIND_SERVICE.

HEALTHCHECK

Демон периодически выполняет команду внутри контейнера и метит его healthy/unhealthy. Полезно в docker compose (depends_on: condition: service_healthy), но сам докер контейнер не перезапустит, это умеет swarm. Kubernetes инструкцию HEALTHCHECK игнорирует целиком: там свои liveness/readiness/startup probes. И ещё: в distroless нет curl, так что healthcheck придётся делать самим бинарём (/server healthcheck подкомандой).

ARG не место для секретов

ARG NPM_TOKEN виден в docker history и в метаданных образа любому, кто его скачал. ENV тем более: он ещё и наследуется дочерними процессами. Секреты в сборку передавай только через RUN --mount=type=secret, секреты в рантайм — через Secret/переменные окружения кластера или внешний vault.

Суть: named volume — для данных, bind mount — для разработки, tmpfs — для временного; сети: user-defined bridge даёт DNS по имени сервиса, и именно поэтому в compose приложение ходит в БД по адресу db:5432.

Хранилище

  • Writable layer контейнера умирает вместе с контейнером и работает через copy-up в overlayfs. Для интенсивной записи не годится.
  • Named volume (-v pgdata:/var/lib/postgresql/data; образ postgres 18+ ждёт том уровнем выше, в /var/lib/postgresql) докер держит у себя в /var/lib/docker/volumes. Переживает пересоздание, нормальная производительность, работает драйверами (можно NFS). Выбор для БД.
  • Bind mount (-v $(pwd):/src) кладёт каталог хоста внутрь. Для hot-reload при разработке. В проде плохо: жёсткая привязка к хосту, конфликты uid/gid и SELinux, на macOS ещё и медленно (проброс через ВМ).
  • tmpfs живёт в RAM, на диск ничего не пишется. Хорош в паре с read_only: true: контейнер с неизменяемым корнем, но рабочим /tmp.

Сети

  • bridge по умолчанию (docker0): у контейнера свой net-namespace, veth-пара в мост, выход наружу через SNAT.
  • user-defined bridge (её и создаёт compose) добавляет к этому встроенный DNS на 127.0.0.11: контейнеры находят друг друга по имени контейнера или алиасу, а compose добавляет алиасом имя сервиса. На дефолтном мосте этого нет — там были устаревшие --link.
  • host выключает сетевую изоляцию совсем, порты контейнера = порты хоста. Быстро (нет NAT), но конфликтует и ломает переносимость.
  • none оставляет loopback.
  • -p 8080:8080 нужен, только чтобы попасть внутрь с хоста. Внутри одной сети контейнеры общаются напрямую, публикация не требуется — и не надо публиковать порт БД наружу «чтобы посмотреть», это дыра.

Compose: что ценят на собесе

Не знание синтаксиса, а понимание, что depends_on без condition: service_healthy гарантирует лишь порядок старта, но не готовность: Postgres поднимается несколько секунд, и приложение падает с «connection refused». Здоровая практика: healthcheck у зависимостей и ретраи подключения в самом приложении, потому что в Kubernetes никакого depends_on нет вообще и сервис обязан переживать недоступность соседей на старте.

Локальная разработка

Хороший набор: compose поднимает Postgres, Redis, Kafka и брокер-эмуляторы, приложение запускается на хосте обычным go run (быстрее итерации, работает отладчик). Для интеграционных тестов бери testcontainers-go: те же контейнеры поднимаются из кода теста, с уникальными портами и автоматической уборкой, и тесты становятся воспроизводимыми в CI без внешнего окружения.

Суть: лимиты задаются через cgroups, а namespaces не изолируют /proc/cpuinfo и /proc/meminfo — рантайм Go видит хост целиком (CPU-лимит он сам учитывает только с Go 1.25). Результат: GOMAXPROCS по числу ядер ноды даёт CFS-троттлинг и рваный p99, а GC без GOMEMLIMIT не знает про memory.max и приводит к OOMKilled.

CPU

limits.cpu: 1 задаёт не «одно медленное ядро», а квоту cpu.max = 100000 100000: 100 мс процессорного времени суммарно по всем потокам за каждые 100 мс реального. Если GOMAXPROCS равен 64, рантайм запускает 64 потока, они выжигают квоту за первые полторы миллисекунды — и вся cgroup замирает до конца периода. Средняя утилизация выглядит скромно, а p99 растёт на десятки миллисекунд из ниоткуда. Метрика-улика: container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total; если больше 1–2 %, уже больно.

До Go 1.25 лечили библиотекой go.uber.org/automaxprocs или пробросом GOMAXPROCS через resourceFieldRef: limits.cpu. Начиная с Go 1.25 (если та же версия или новее указана в go.mod) рантайм сам читает cpu.max своей cgroup, учитывает и дробные лимиты, и меняет GOMAXPROCS на лету при изменении лимита (отключается через GODEBUG=containermaxprocs=0,updatemaxprocs=0).

Память

GC по умолчанию управляется только GOGC=100: «собери, когда живая куча удвоится». Про memory.max он не знает. Живых 300 МиБ, лимит 512 МиБ, а GC спокойно даст куче дорасти до 600, cgroup-OOM убивает процесс, kubelet показывает OOMKilled и код выхода 137. GOMEMLIMIT вводит мягкий предел: при приближении GC работает чаще и активнее возвращает память ядру. Берут 80–90 % от limits.memory, потому что предел мягкий, а cgroup считает не только память рантайма (куча, стеки и метаданные в пределе уже учтены), но и сам бинарь, не-Go аллокации и память ядра под процесс.

Почему предел «мягкий» и чем это опасно

Если живых данных реально больше лимита, процесс не упадёт, а войдёт в GC death spiral: сборка запускается почти непрерывно, CPU уходит в GC, пропускная способность падает в разы, но под жив и readiness зелёный. Видно по go_gc_duration_seconds, доле CPU в GC и по go_memstats_heap_inuse_bytes, прилипшему к потолку. Поэтому GOMEMLIMIT не заменяет правильный лимит, а страхует его. Комбинация GOGC=off + GOMEMLIMIT хорошо работает для сервисов с предсказуемым потолком памяти: GC запускается только у границы.

Чем добить

«В k8s важна ещё разница между requests и limits: requests.cpu превращается в cpu.weight (доля при конкуренции), а limits.cpu становится жёсткой квотой. Многие команды сознательно не ставят limits.cpu вообще, чтобы избежать троттлинга, оставляют только requests, а память лимитируют всегда: память несжимаемый ресурс, и её перерасход убивает соседей по ноде.»

2.2Сборка образа и реестр

BuildKit читает Dockerfile как граф: независимые стадии идут параллельно, а для каждой вершины он считает ключ и решает, выполнять её или взять готовый результат. От этого зависят скорость сборки в CI, утечка токена через ARG и дайджест, который меняется без единой правки кода.

Что сможешь объяснить после этой главы
  • Почему шаг помечен CACHED и какая правка заставит заново выполнить go mod download.
  • Чем cache mount отличается от кэша слоёв и почему в CI он не выручает.
  • Куда утекает токен из ARG, даже когда docker history финального образа чист.
  • Как собрать образ под amd64 и arm64 без эмуляции и что лежит в реестре под одним тегом.
  • Что ломается в scratch и как отлаживать контейнер без shell.
  • Почему два билда одного коммита дают разные дайджесты и как добиться одинаковых.

Примеры сняты на Docker Desktop 4.91: Docker Engine 29.8.0 со встроенным BuildKit v0.33.0, buildx 0.37.0, containerd image store, ВМ на arm64, Go 1.27.1. HTTP-сервис orders построен на chi и prometheus/client_golang, в сборке десять модулей. Дайджесты, размеры и секунды у тебя будут свои.

Граф вместо списка команд

Dockerfile сначала превращается в LLB (low-level build): граф операций вроде «скачать базу», «выполнить команду», «скопировать файлы», где каждая вершина адресуется хешем своего описания. BuildKit идёт от цели (последней стадии или --target) к источникам, выполняет только то, от чего цель зависит, и запускает одновременно всё независимое.

FROM golang:1.27.1-alpine AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

FROM deps AS build
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

FROM deps AS test
COPY . .
RUN go vet ./... && go test ./...

FROM alpine:3.24 AS certs
RUN apk add --no-cache ca-certificates-bundle

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
$ docker build --no-cache --progress=plain -t orders .
…
#10 [deps 4/4] RUN go mod download
#10 ...

#11 [certs 2/2] RUN apk add --no-cache ca-certificates-bundle
#11 0.958 OK: 8433 KiB in 16 packages
#11 DONE 1.0s

#12 [stage-4 1/2] COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
#12 DONE 0.0s

#10 [deps 4/4] RUN go mod download
#10 DONE 2.6s
…
#16 exporting manifest sha256:95b12a08142978e00a89873f834bc7da47107aab0da853771df91da0471287d8 done
#16 exporting attestation manifest sha256:751dab29dae9dae6e924ad724cf12f96286f3567d8a75c97fde47174e6302bbe done
#16 exporting manifest list sha256:44b0b4800674dd141cf1e6ae8b76f2e7341137305bfd04f7ee5e406226f0dd3a done

#N нумерует вершины по мере появления в выводе и со строками Dockerfile не связан; безымянная пятая стадия зовётся stage-4, счёт с нуля. #10 ... значит, что вывод прервали: пока качались модули, certs выполнила apk add, а финальная стадия скопировала сертификаты. Стадии test в выводе нет, цели она не нужна. В хвосте index (manifest list): манифест образа и attestation с provenance, сведениями о сборке.

Пересборка после правки internal/orders/orders.go certs 1/2 FROM alpine:3.24 certs 2/2 RUN apk add ca-certificates-bundle deps 1/4 FROM golang: 1.27.1-alpine deps 2–4/4 COPY go.mod go.sum ./ RUN go mod download build 1–2/2 COPY . . RUN go build -trimpath контекст сборки load build context изменился orders.go test FROM deps AS test цели не нужна, не строится stage-4 1/2 COPY --from=certs stage-4 2/2 COPY --from=build новый бинарь exporting to image manifest, config, attestation, index CACHED: ключ совпал выполняется заново не входит в граф цели Ключ RUN складывается из команды, окружения с ARG и ключа родителя, ключ COPY — из содержимого файлов. Ключ сменился — вершина выполняется, а за ней все, кто получает её результат, пока содержимое не совпадёт.
Граф сборки. Сертификаты и модули готовят разные стадии параллельно. Правка одного Go-файла меняет ключ COPY . . в стадии build, и пересобирается только эта ветка и шаг финальной стадии, который берёт из неё бинарь.

Почему CACHED и что сбрасывает кэш

В ключ RUN входят текст команды, окружение с объявленными к этому месту ARG, монтирования, платформа и ключ родителя. Для COPY считается сумма содержимого файлов, время изменения в неё не входит. Первые четыре строки таблицы сняты на Dockerfile выше, остальные — на одностадийном, где версия уходит в go build -ldflags "-X main.version=${VERSION}":

Что поменялосьgo mod downloadgo buildФинальный COPY --from=build
touch cmd/server/main.goCACHEDCACHEDCACHED
строка кода в orders.goCACHEDзаново, 5,6 сзаново
комментарий в orders.go без сдвига строкCACHEDзаново, 5,5 сCACHED
go get github.com/go-chi/chi/v5@v5.3.1заново, 2,9 сзановозаново
новый --build-arg VERSION, ARG VERSION сразу после FROMзановозаново
новый --build-arg VERSION, ARG VERSION перед go buildCACHEDзаново
строка кода, COPY . . стоит до go mod downloadзаново, 2,5 сзаново

Строка с комментарием показывает, где каскад останавливается: go build выполнился, но выдал прежний бинарь байт в байт (номера строк есть в бинаре, а они не сдвинулись), ключ COPY --from=build считается по содержимому, и обе сборки вывели один и тот же exporting manifest sha256:ec2ba98c…, а index новый из-за нового attestation.

Номер коммита в начале стадии

--build-arg VERSION=$CI_COMMIT_SHA при ARG VERSION в начале стадии даёт каждому коммиту новый ключ для всех RUN ниже, и слой с модулями не переиспользуется ни при каком внешнем кэше, хотя go mod download переменную не читает. Объявляй ARG прямо перед шагом, который её читает. Это касается и TARGETOS, TARGETARCH: они видны глобально, но внутри стадии их тоже объявляют.

Контекст сборки и .dockerignore

docker build . отдаёт BuildKit каталог целиком, это контекст сборки. В опыте в каталоге лежат дамп tmp/dump.sql на 300 МиБ, собранный bin/server и .git на 313 МиБ, в историю которого этот дамп однажды закоммитили.

$ docker build --progress=plain -t orders .
…
#7 transferring context: 654.51MB 4.0s done
…
$ echo "more notes" >> README.md
$ docker build --progress=plain -t orders .
…
#7 transferring context: 3.61kB done
…
#12 [build 2/2] RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server
#12 DONE 7.6s

Вторая передача весит 3,61 кБ: BuildKit помнит, что прислали из этого каталога, и досылает изменённое. Свежему раннеру CI помнить нечего. А правка README.md сменила сумму COPY . ., и сервис скомпилировался заново. Копия каталога с .dockerignore из строк .git, bin/, tmp/, *.md передала при первой сборке 7,20 кБ, и после правки README.md шаг COPY . . остался CACHED. Без .git в контексте Go не вошьёт в бинарь ревизию, об этом ниже.

Cache mounts: кэш компилятора вне слоёв

После правки кода RUN go build выполняется поверх нового слоя COPY . ., а кэш компилятора прошлой сборки остался в слое, который новому шагу не родитель. Стандартная библиотека и зависимости компилируются заново. Cache mount (кэш-монтирование) подключает к одному RUN каталог из хранилища BuildKit. Каталог переживает сборки, в слой не попадает, а его содержимое на ключ шага не влияет.

RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server

Пять раз правим строку в orders.go и повторяем сборку. Без cache mount шаг go build шёл 4,9–5,0 с, а вся docker build 5,5–5,6 с; с ним шаг занял 0,3 с, сборка 0,9 с. Стадия build обоих вариантов, собранная через --target build:

$ docker run --rm orders-build-layers du -sh /root/.cache/go-build /go/pkg/mod
132.8M	/root/.cache/go-build
38.5M	/go/pkg/mod
$ docker run --rm orders-build-cachemount du -sh /root/.cache/go-build /go/pkg/mod
du: /root/.cache/go-build: No such file or directory
du: /go/pkg/mod: No such file or directory

Содержимое лежит в builder: docker buildx du --verbose --filter type=exec.cachemount показал две записи, 40,39 и 141,1 МБ, с id "//go/pkg/mod" и id "//root/.cache/go-build". Идентификатор по умолчанию равен пути target, так что все сборки на этом builder делят один каталог, даже из разных проектов. Go это выдерживает: по go help cache кэш безопасен при одновременных запусках; для apt ставят sharing=locked. Кэш может и пропасть: первое правило сборщика мусора в docker buildx inspect относится к exec.cachemount (здесь 48 часов и 2,764 ГиБ), и сборка обязана работать с пустым каталогом.

Внешний кэш для CI

Builder на одноразовом раннере стартует пустым, поэтому кэш выносят наружу: --cache-to выгружает его после сборки, --cache-from подтягивает перед ней, обычно из отдельного тега в реестре. Драйвер docker выгружает кэш, только если включён containerd image store (в Docker Engine 29 он по умолчанию на новых установках), иначе нужен builder в контейнере. У того своя версия BuildKit (здесь v0.32.2) и настройки в buildkitd.toml, а образ из него попадает в Docker с --load.

$ docker buildx create --name ci --driver docker-container \
    --driver-opt network=host --buildkitd-config buildkitd.toml
$ docker buildx build --builder ci \
    --cache-from type=registry,ref=localhost:5052/orders:cache-max \
    --cache-to type=registry,ref=localhost:5052/orders:cache-max,mode=max \
    -t localhost:5052/orders:v2 --push .
…
#8 [build 3/6] COPY go.mod go.sum ./
#8 CACHED

#9 [build 4/6] RUN go mod download
#9 sha256:6dbc4f0eb2c8a5ae31323c4da6712279adc20265cf57a6ad0b33e12569713105 9.45MB / 9.45MB 0.0s done
…
#11 [build 6/6] RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server
#11 DONE 4.9s

registry:3 работает в контейнере; network=host даёт builder доступ к нему на localhost, buildkitd.toml разрешает HTTP. Перед каждой сборкой кэш builder очищался, как на новом раннере. go mod download не помечен CACHED и не выполнялся: вместо вывода Go скачивается готовый слой с модулями.

Чистый buildermode=minmode=max
что выгружено1 слой, 8,5 МБ: слой финального образа11 слоёв, 130,4 МБ: база golang, модули, слои стадии build
тот же коммитвсё CACHEDвсё CACHED
правка строки Goвсё заново: база, модули 4,7 с, go build 5,0 смодули из кэша, заново COPY . . и go build 4,9 с

В min попадают только слои итогового образа (здесь один, с бинарём), и после любой правки промежуточные результаты взять негде, поэтому в CI нужен max — ценой места: в кэш уехали даже 72 МБ базового golang. Builder вместе с томом состояния удаляет docker buildx rm ci.

Cache mount в экспорт кэша не входит

С Dockerfile из прошлого раздела модули лежат в cache mount, и слой go mod download их не содержит. На чистом раннере каталог пуст, и go build снова печатает go: downloading github.com/go-chi/chi/v5 v5.3.2 и ещё девять строк: 7,2 с вместо 4,9. Документация Docker пишет то же про кэш GitHub Actions и отсылает к обходному решению buildkit-cache-dance. Для Go в CI проще держать go mod download обычным слоем, а cache mount оставить локальной сборке.

Секреты сборки

go mod download тянет приватный модуль, нужен токен. Через ARG сборка пройдёт с предупреждением SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "GIT_TOKEN").

ARG GIT_TOKEN
ENV GOPRIVATE=git.example.com/*
COPY go.mod go.sum ./
RUN echo "machine git.example.com login ci password ${GIT_TOKEN}" > /root/.netrc \
 && go mod download \
 && rm /root/.netrc
$ docker build --build-arg GIT_TOKEN=glpat-DEMO-arg-123 -t orders-arg .
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-arg
RUN |1 GIT_TOKEN=glpat-DEMO-arg-123 /bin/sh -c echo "machine git.example.com login ci password ${GIT_TOKEN}" > /root/.netrc  && go mod download  && rm /root/.netrc # buildkit
COPY go.mod go.sum ./ # buildkit
ENV GOPRIVATE=git.example.com/*
ARG GIT_TOKEN=glpat-DEMO-arg-123
…

Файла в слое нет, токен дважды в истории. ENV добавил бы его в .Config.Env и в окружение каждого контейнера. Если ARG живёт в сборочной стадии, а финальная собрана FROM scratch, история образа чистая. Но токен виден в записи сборки (docker buildx history inspect, таблица BUILD ARG), в attestation при --provenance=mode=max (в режиме по умолчанию min аргументов нет) и в экспорте кэша mode=max, если шаг записал его в файл: строка из git config --global url…insteadOf нашлась в root/.gitconfig одного из блобов.

Для секретов есть RUN --mount=type=secret: значение передаётся по сессии сборки и видно одному шагу, по умолчанию файлом /run/secrets/<id>.

RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    ls -l /root/.netrc && go mod download
$ docker build --progress=plain --secret id=netrc,src=../netrc -t orders-secret .
…
#8 0.066 -r--------    1 root     root            64 Sep 16 22:37 /root/.netrc
…
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-secret | head -1
RUN /bin/sh -c ls -l /root/.netrc && go mod download # buildkit
$ docker run --rm orders-secret ls -l /root/.netrc
ls: /root/.netrc: No such file or directory

.netrc читает gogo help goauth способ по умолчанию называется netrc) и git, если модуль берётся из репозитория напрямую: на локальном HTTP-сервере git ls-remote после ответа 401 повторил запрос с логином и паролем из ~/.netrc. Для переменной окружения есть --mount=type=secret,id=git_token,env=GIT_TOKEN (Dockerfile 1.10+) в паре с --secret id=git_token,env=GIT_TOKEN.

Новый токен не сбрасывает кэш

Содержимое секрета в ключ шага не входит: после замены токена сборка вывела CACHED на всех шагах. Если результат зависит от прав токена, кэш этого не заметит.

Кросс-сборка и multi-arch образ

Образ, собранный на Mac с чипом M, по умолчанию linux/arm64, и на узле amd64 он не запустится. На этой arm64-машине видно обратное:

$ docker run --rm orders-amd64 -selfcheck
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
exec /server: exec format error

Чужой машинный код ядро запустит через эмулятор из binfmt_misc. В этой ВМ его нет, и RUN под amd64 на драйвере docker падает с exec /bin/sh: exec format error; у builder в контейнере свои эмуляторы, buildkit-qemu-x86_64 и другие. Go эмуляция не нужна: компилятор собирает под любую поддерживаемую пару GOOS/GOARCH.

FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
$ docker buildx build --builder ci --platform linux/amd64,linux/arm64 \
    -t localhost:5052/orders:cross --push .
…
#12 [linux/arm64 build 4/6] RUN go mod download
#12 DONE 2.5s
…
#15 [linux/arm64->amd64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64     go build -trimpath -o /out/server ./cmd/server
#15 DONE 9.2s
…
#14 [linux/arm64 build 6/6] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64     go build -trimpath -o /out/server ./cmd/server
#14 DONE 9.3s

linux/arm64->amd64 читается «выполняется на arm64, собирает под amd64». Модули скачаны один раз: до ARG TARGETOS TARGETARCH вершины обеих платформ совпадают. Финальной стадии без RUN эмулировать нечего. С FROM golang:1.27.1-alpine без --platform сборочная стадия идёт дважды, под amd64 внутри QEMU: go build для amd64 занял 92,8 с против 9,7 с у arm64, вся сборка 103,2 с против 22,3 с. Оба замера после очистки кэша builder.

$ docker buildx imagetools inspect localhost:5052/orders:cross
Name:      localhost:5052/orders:cross
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:855e7de9fbdf810ba74e51eeb1ecb322f9cda7ceb5acb154a6a749d34756bcd7

Manifests:
  Name:        localhost:5052/orders:cross@sha256:c4a44cbb0e9312050c497a9595e0ec276d311e214a6ecded2b0a678ec7a1f65e
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
…
  Name:        localhost:5052/orders:cross@sha256:45250bb28203536a8109c7ffa6aa362433fe5eb61a8fafdecb1e596eb47908af
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown
  Annotations:
    vnd.docker.reference.digest: sha256:c4a44cbb0e9312050c497a9595e0ec276d311e214a6ecded2b0a678ec7a1f65e
    vnd.docker.reference.type:   attestation-manifest
…

Тег указывает на index (у Docker — manifest list): манифесты linux/amd64 и linux/arm64 и два attestation с платформой unknown/unknown, каждый ссылается на свой образ. При pull клиент берёт из index манифест своей архитектуры. Драйвер docker с containerd image store такой образ тоже собрал (docker image ls --tree показал обе платформы), хотя таблица возможностей драйверов в документации multi-arch для него не отмечает.

сборка на $BUILDPLATFORM = linux/arm64 TARGETPLATFORM в реестре FROM --platform= $BUILDPLATFORM golang RUN go mod download одна вершина на обе go build GOARCH=amd64 go build GOARCH=arm64 distroless linux/amd64 distroless linux/arm64 index orders:cross manifest amd64 manifest arm64 attestation attestation 855e7de9… Кросс-компиляция: 22,3 с на обе платформы, go build 9,2 и 9,3 с идут параллельно на родной архитектуре. Через QEMU (FROM golang без --platform): сборочная стадия дважды, go build под amd64 92,8 с, всего 103,2 с. CGO_ENABLED=0 задаётся явно: при кросс-сборке cgo выключен сам, при нативной с gcc в образе включается.
Multi-arch без эмуляции. Сборочная стадия выполняется на архитектуре builder и расходится на две компиляции только там, где появляется TARGETARCH. Финальные стадии берут базу нужной платформы, реестр получает два манифеста под одним index.

База: scratch, distroless, alpine

Один и тот же бинарь с флагом -selfcheck: он вызывает user.Current(), time.LoadLocation("Europe/Moscow") и делает HTTPS-запрос. Везде USER 65532:65532; размер по CONTENT SIZE для linux/arm64 из docker image ls --tree, это сжатые слои, которые скачивает pull.

БазаРазмерuser.CurrentЧасовой поясHTTPS
scratch + бинарь8,51 МБошибкаошибкаошибка x509
scratch + ca-certificates.crt + time/tzdata8,72 МБошибкадада
distroless/static-debian13:nonroot (база 862 кБ)9,37 МБnonrootдада
alpine:3.24 (база 4,18 МБ)12,7 МБошибкаошибкада
$ docker run --rm orders-scratch -selfcheck
version: dev
uid: 65532
user.Current: user: Current requires cgo or $USER set in environment
time.LoadLocation: unknown time zone Europe/Moscow
http.Get: Get "https://proxy.golang.org/": tls: failed to verify certificate: x509: certificate signed by unknown authority

Ядру нужен только номер, и USER 65532:65532 работает без /etc/passwd. Имя ищет Go: без cgo он читает /etc/passwd, а не найдя записи, собирает пользователя из $USER и домашнего каталога. В alpine файл есть, но записи для 65532 нет; distroless nonroot держит строку nonroot:x:65532:65532. Базы часовых поясов нет ни в scratch, ни в alpine. import _ "time/tzdata" (или -tags timetzdata) встраивает её в бинарь, здесь плюс 398 КБ, документация пакета говорит «около 450 KB». Сертификаты в alpine входят в базу, в scratch файл копируют из сборочной стадии.

Отлаживать такой контейнер нечем: docker exec orders-app sh отвечает exec: "sh": executable file not found in $PATH. Плагин Docker Desktop docker debug приносит свой shell с инструментами, не меняя образ. Без Desktop рядом запускают контейнер в пространствах имён сервиса:

$ docker run --rm --pid=container:orders-app --network=container:orders-app busybox:1.37 \
    sh -c 'ps; wget -qO- http://127.0.0.1:8080/orders/42; ls -l /proc/1/root/'
PID   USER     TIME  COMMAND
    1 65532     0:00 /server
   28 root      0:00 sh -c ps; wget -qO- http://127.0.0.1:8080/orders/42; ls -l /proc/1/root/
   33 root      0:00 ps
{"id":"42","status":"new"}
ls: /proc/1/root/: Permission denied

Файлы чужого процесса видны через /proc/1/root, но доступ ядро проверяет как для ptrace: root контейнера получил Permission denied к процессу пользователя 65532, а с --cap-add SYS_PTRACE прочитал его /etc/passwd. У distroless есть и теги debug с busybox; в Kubernetes для этого служит kubectl debug, глава 3.4.

Воспроизводимость

Две сборки docker build --no-cache подряд из одного кода дали манифесты 0bf05b6d5d6b и 28c413e49795, хотя SHA-256 файла /server в обоих 48d5207b68f2. В docker image inspect у них разные .Created (22:45:11 и 22:45:20) и разные слои: в tar-архиве записано время изменения /server, то есть момент сборки. Другой слой даёт другой diff_id в конфиге, другой конфиг — другой манифест.

Две сборки --no-cachecreatedСлойМанифестIndex
без настроекразныйразныйразныйразный
--build-arg SOURCE_DATE_EPOCH=17672256002026-01-01T00:00:00Zразныйразныйразный
плюс rewrite-timestamp=true в --outputтот жетот же10ff9a6faf54 в обеихразный
плюс --provenance=falseтот жетот жетот жеindex нет

SOURCE_DATE_EPOCH (BuildKit v0.11+) задаёт время в конфиге и истории, время файлов в слоях переписывает rewrite-timestamp=true (v0.13+). Index и после этого разный: в provenance записаны startedOn, finishedOn и invocationId. Значение для CI даёт git log -1 --pretty=%ct, время коммита в секундах. На драйвере docker сборка с этой опцией падает с exporter option "rewrite-timestamp" conflicts with "unpack", если не указать push=true,unpack=false; таблица снята на builder в контейнере с push=true.

В Go к одинаковому бинарю ведёт -trimpath: без него сборка из /src и из /app дала SHA-256 8076b22d19f4 и 2538ed4b4ff3, с ним оба раза 48d5207b68f2. -buildvcs по умолчанию auto: в git-репозитории Go вшивает ревизию и время коммита. В golang:1.27.1-alpine git нет. Если .git попал в контекст, auto пропускает эти сведения (mod example.com/orders (devel) в go version -m), а -buildvcs=true падает с error obtaining VCS status: exec: "git": executable file not found in $PATH; без .git молчат оба.

Остаётся база: builder в контейнере разрешает тег в реестре при каждой сборке (драйвер docker без --pull берёт локальную копию), и обновлённый на Docker Hub образ тихо сменит базу. Дайджест фиксирует базу, а тег рядом с ним BuildKit не проверяет:

$ docker buildx imagetools inspect golang:1.27.1-alpine --format '{{.Manifest.Digest}}'
sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125
$ cat Dockerfile.digest
FROM golang:1.26-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125
RUN go version
$ docker build --no-cache --progress=plain -f Dockerfile.digest .
…
#5 0.060 go version go1.27.1 linux/arm64

Тег, дайджест и метки

В реестре всё адресовано по содержимому: слой и конфиг лежат блобами, манифест — JSON со ссылками на них, index ссылается на манифесты, дайджест каждого — хеш его байтов. Изменяемое звено одно, тег. Опыт: образ с метками OCI дважды пушим под тегом 1.4, между сборками коммит.

FROM gcr.io/distroless/static-debian13:nonroot
ARG VERSION=dev REVISION CREATED
LABEL org.opencontainers.image.title="orders" \
      org.opencontainers.image.source="https://git.example.com/shop/orders" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}"
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
$ docker buildx build --builder ci --build-arg VERSION=1.4.0 \
    --build-arg REVISION=$(git rev-parse HEAD) \
    --build-arg CREATED=$(git log -1 --format=%cI) \
    -t localhost:5052/orders:1.4 --push .
$ docker buildx imagetools inspect localhost:5052/orders:1.4 --format '{{.Manifest.Digest}}'
sha256:cc5e19eb246eb9ce4d03b6ff3b82764081c226c8ef29a454e4b8034cb86425a1
$ git commit -qam "orders: new status"          # и та же сборка с VERSION=1.4.1
$ docker buildx imagetools inspect localhost:5052/orders:1.4 --format '{{.Manifest.Digest}}'
sha256:0f83b6a48a19b43e48d973bbf08e96c1389353d59691094447f736324e4f8832
$ docker run --rm localhost:5052/orders@sha256:cc5e19eb246eb9ce4d03b6ff3b82764081c226c8ef29a454e4b8034cb86425a1 -selfcheck
version: 1.4.0
…
$ docker pull localhost:5052/orders
Using default tag: latest
Error response from daemon: failed to resolve reference "localhost:5052/orders:latest": localhost:5052/orders:latest: not found

Тег 1.4 переехал, прошлый образ доступен по дайджесту. Тег latest подставляется, когда тег не указан; сам он не двигается и может не существовать. В манифест выкатки пишут дайджест, для multi-arch — дайджест index. Продвижение образа между окружениями, сканирование, SBOM и подпись разобраны в статье «CI/CD», глава 4.3. Метки видны в docker buildx imagetools inspect --format '{{json .Image.Config.Labels}}'; они лежат в конфиге и входят в дайджест, поэтому created берут из времени коммита: значение из date сломало бы воспроизводимость.

$ docker cp -q $(docker create localhost:5052/orders:1.4):/server ./server
$ go version -m ./server
./server: go1.27.1
	path	example.com/orders/cmd/server
	mod	example.com/orders	v0.0.0-20260916230120-a59789e5fa4d
	…
	dep	github.com/go-chi/chi/v5	v5.3.2	h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
	…
	build	-trimpath=true
	build	CGO_ENABLED=0
	build	GOARCH=arm64
	…
	build	vcs.revision=a59789e5fa4d1727a4b6ac676c67689428e22031
	build	vcs.time=2026-09-16T23:01:20Z
	build	vcs.modified=false

Это Go вшивает сам. Строки vcs.* и версия главного модуля из коммита (с Go 1.24) появились, потому что в сборочную стадию поставлен git, а .git попал в контекст. Версия Go, зависимости с хешами из go.sum и флаги есть в любом бинаре; по ним и по символам govulncheck -mode=binary проверяет бинарь из образа без исходников.

Вопросы

4
Суть: на одноразовом раннере builder стартует пустым, кэш слоёв и cache mounts пропадают вместе с ним. Кэш выгружают в реестр в mode=max и подтягивают перед сборкой, а потом ищут шаг, который первым получает новый ключ.

Что искать в логе

С --progress=plain найди первую вершину без CACHED, зависимые шаги выполнятся за ней.

Что в логеПричина
нет importing cache manifestне передан --cache-from
… not found в каждой сборке (в первой это нормально)никто не пишет --cache-to или ref другой
всё заново с WORKDIRmode=min у multi-stage или новая база под старым тегом
COPY go.mod в кэше, go mod download выполняетсявыше объявлен ARG с меняющимся значением
go: downloading … внутри go buildмодули в cache mount, он в экспорт не входит
COPY . . выполняется без правок кодав контексте .git или артефакты джобов

Ветка без своего кэша

$ docker buildx build --builder ci \
    --cache-from type=registry,ref=localhost:5052/orders:cache-feature-x \
    --cache-from type=registry,ref=localhost:5052/orders:cache-main \
    --cache-to type=registry,ref=localhost:5052/orders:cache-feature-x,mode=max \
    -t localhost:5052/orders:feature-x --push .
…
#7 importing cache manifest from localhost:5052/orders:cache-feature-x
#7 ERROR: failed to configure registry cache importer: localhost:5052/orders:cache-feature-x: not found

#6 importing cache manifest from localhost:5052/orders:cache-main
#6 inferred cache manifest type: application/vnd.oci.image.manifest.v1+json done
#6 DONE 0.6s
…
#9 [build 3/6] COPY go.mod go.sum ./
#9 CACHED

Кэша ветки ещё нет, и BuildKit взял слои из кэша main. Cache mounts так не переносятся: либо go mod download обычным слоем, либо долгоживущий builder на постоянном раннере, либо перенос каталога отдельными шагами CI, как в buildkit-cache-dance.

В mode=max уезжает и то, что писать было нельзя

В кэш попадают слои сборочных стадий: токен, записанный шагом в /root/.gitconfig, нашёлся в блобе кэша при финальном образе из scratch. Доступ к тегу кэша равен доступу к таким файлам.

Суть: через RUN --mount=type=secret: токен виден одному шагу и не пишется ни в слой, ни в историю. ARG оставляет его в docker history, в записи сборки и в provenance mode=max, а записанный в файл — в кэше mode=max.

Как сделать

FROM golang:1.27.1-alpine AS build
WORKDIR /src
ENV GOPRIVATE=git.example.com/*
COPY go.mod go.sum ./
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
    go mod download
$ docker build --secret id=netrc,src=../netrc -t orders-secret .
$ docker history --no-trunc --format '{{.CreatedBy}}' orders-secret | head -1
RUN /bin/sh -c go mod download # buildkit

GOPRIVATE уводит эти модули мимо публичного зеркала и базы контрольных сумм (go help private). .netrc читают go и git; если модуль берётся из репозитория напрямую, в стадию нужен apk add git, в golang:1.27.1-alpine его нет. Токен из переменной CI передают как --secret id=git_token,env=GIT_TOKEN и монтируют --mount=type=secret,id=git_token,env=GIT_TOKEN.

Как не допустить ARG

Директива в первой строке Dockerfile превращает предупреждение в ошибку:

$ cat Dockerfile
# check=error=true
FROM alpine:3.24
ARG GIT_TOKEN
RUN echo building
$ docker build --build-arg GIT_TOKEN=x .
…
ERROR: failed to build: failed to solve: lint violation found for rules: SecretsUsedInArgOrEnv
Отозванный токен кэш не проверит

Секрет не входит в ключ шага: после замены значения всё было CACHED. Ошибка доступа всплывёт, когда шаг выполнится по другой причине, например после правки go.mod.

Суть: образ собран под linux/arm64, узел amd64, и ядро не исполняет бинарь чужой архитектуры. Нужен multi-arch index: сборочная стадия на $BUILDPLATFORM, Go компилирует под TARGETOS/TARGETARCH, финальные стадии берут базу своей платформы.

Как убедиться

docker image inspect -f '{{.Os}}/{{.Architecture}}' локально или docker buildx imagetools inspect для тега в реестре. Если под тегом одна платформа, причина найдена.

Как собирать

  • FROM --platform=$BUILDPLATFORM только у сборочной стадии.
  • ARG TARGETOS TARGETARCH после go mod download, тогда модули качаются один раз.
  • CGO_ENABLED=0 явно. В golang:1.27.1-alpine без gcc go env CGO_ENABLED даёт 0, после apk add gcc musl-dev — 1, а для GOARCH=amd64 на arm64 снова 0: в базе с gcc родная платформа собралась бы с cgo, чужая без.
  • docker buildx build --platform linux/amd64,linux/arm64 … --push. Здесь это 22,3 с против 103,2 с через QEMU.

Если нужен cgo

$ CGO_ENABLED=1 GOARCH=amd64 go build -o /tmp/hello .
# runtime/cgo
gcc: error: unrecognized command-line option '-m64'

Это golang:1.27.1-alpine на arm64 с gcc: родной компилятор C под amd64 не собирает. Нужен кросс-компилятор C под цель (его задают в CC) или сборка под эмуляцией.

Список платформ не гарантирует эмуляцию

docker buildx inspect default здесь перечисляет linux/amd64, а RUN под amd64 падает с exec /bin/sh: exec format error. Builder в контейнере приносит свой QEMU, и одна команда на разных builder ведёт себя по-разному. Кросс-компиляции Go эмулятор не нужен.

Суть: дайджест манифеста зависит от конфига со временем сборки и от слоёв со временем файлов, дайджест index — ещё и от provenance. Манифест фиксируют SOURCE_DATE_EPOCH и rewrite-timestamp, index совпадёт только без provenance. Выкатывают по дайджесту, потому что тег переезжает при каждом push.

Что меняется от сборки к сборке

ИсточникКуда попадаетЧем фиксируют
время сборкиcreated и история в конфигеSOURCE_DATE_EPOCH
время файловtar слояrewrite-timestamp=true
startedOnattestation, index--provenance=false или сравнение манифестов платформ
путь исходниковбинарь-trimpath
обновлённая база под тем же тегомслои базыFROM …@sha256:

Тег или дайджест

После второго push localhost:5052/orders:1.4 запускал 1.4.1, а 1.4.0 по-прежнему запускался по @sha256:cc5e19eb…. Реплики, которые скачают образ по тегу в разное время, могут получить разный код. В манифест выкатки пишут дайджест, для multi-arch — дайджест index. Продвижение одного дайджеста между окружениями разобрано в статье «CI/CD», глава 4.3.

Воспроизводимость и неизменность — разные свойства

Воспроизводимость отвечает на вопрос «собран ли образ из этого коммита»: пересобери и сравни манифест. Выкатке нужно, чтобы записанный дайджест не менялся. latest опасен тем, что переезжает, и воспроизводимая сборка этого не лечит.

2.3Контейнер в работе

Образ собран, и контейнер начинает жить: говорит по сети, пишет данные, упирается в лимиты и однажды получает сигнал на остановку. На каждом из этих шагов Docker местами ведёт себя не так, как в шпаргалке.

Что сможешь объяснить после этой главы
  • Почему в дефолтной сети контейнеры не находят друг друга по имени, а в своей находят, и кто такой 127.0.0.11.
  • Что происходит с пакетом при -p 8080:8080, зачем нужен docker-proxy и почему опубликованный порт проходит мимо файрвола хоста.
  • Чем named volume отличается от bind mount и tmpfs и что с правами, когда USER 65532 пишет в каталог хоста.
  • Какие файлы cgroup пишут --memory, --cpus, --pids-limit и как отличить OOM от обычной остановки.
  • Почему docker stop здесь ждёт три секунды, а не десять, и как настроить graceful shutdown.
  • Что даёт --cap-drop ALL, --read-only, no-new-privileges и почему монтированный docker.sock равен root на хосте.

Прогоны сняты на Docker Desktop 4.91 (Docker Engine 29.8.0, containerd 2.3.4, ядро ВМ 7.0.12-linuxkit, cgroup v2, снапшоттер overlayfs), сервис написан на Go 1.27.1 и собран в distroless. Все контейнеры работают на ядре общей ВМ Docker Desktop, поэтому часть поведения (числа в /proc, docker-proxy, тайм-аут остановки) на сервере другая. Где это важно, рядом стоит прогон в docker:29.8.0-dind: полноценный dockerd 29.8 без настроек изображает Linux-сервер.

Дефолтный мост против своей сети

У контейнера свой сетевой namespace с интерфейсами, маршрутами и netfilter. Наружу он выходит через veth-пару, виртуальный кабель с двумя концами: eth0 внутри контейнера, второй конец воткнут в мост (bridge) на хосте. Контейнер без --network попадает в сеть bridge по умолчанию, то есть в мост docker0:

$ docker run -d --name dv-c3d-a dv-c3d-tools sleep infinity     # сеть bridge по умолчанию
$ docker exec dv-c3d-a ip -br link show eth0
eth0@if4498      UP             e6:6c:7f:5f:eb:20 <BROADCAST,MULTICAST,UP,LOWER_UP>

Суффикс @if4498 называет индекс пары. Второй конец лежит в namespace ВМ, и заглянуть туда можно только на чтение, через nsenter в namespace процесса 1:

# в сетевом namespace ВМ, только чтение
$ ip -o link show master docker0 | grep '^4498:'
4498: vethb328d3f@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 65535 qdisc noqueue master docker0 ...

Под индексом 4498 висит vethb328d3f, подключённый к docker0, а его @if11 указывает обратно на eth0 контейнера. Все контейнеры сети сидят на одном мосту и через него общаются.

По имени контейнеры дефолтной сети друг друга не находят: встроенного DNS там нет, запрос уходит резолверу хоста, а тот имён контейнеров не знает.

$ docker run --rm dv-c3d-tools getent hosts dv-c3d-a; echo "exit=$?"
exit=2
$ docker run --rm dv-c3d-tools cat /etc/resolv.conf
…
nameserver 192.168.65.7
…
# Based on host file: '/etc/resolv.conf' (legacy)
…

В пользовательской сети (её создаёт docker network create, такую же compose заводит под каждый проект) появляется встроенный DNS Docker на 127.0.0.11, и имена работают:

$ docker network create dv-c3d-net
$ docker run -d --name dv-c3d-b --network dv-c3d-net dv-c3d-tools sleep infinity
$ docker run --rm --network dv-c3d-net dv-c3d-tools cat /etc/resolv.conf
…
nameserver 127.0.0.11
options ndots:0
…
# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [host(192.168.65.7)]
…
$ docker run --rm --network dv-c3d-net dv-c3d-tools getent hosts dv-c3d-b
172.23.0.2      dv-c3d-b

На 127.0.0.11:53 слушает резолвер Docker, открытый в namespace контейнера. Свои имена он разрешает сам, остальное пересылает серверам из ExtServers. Дефолтный мост так не умеет (там были устаревшие --link), и документация Docker называет его наследием.

Контейнеры «не видят» друг друга

Два сервиса, запущенные docker run без общей сети, попадают в дефолтный bridge, и приложение не находит соседа по имени. Лечится общей пользовательской сетью или compose, а не адресом. Разные сети между собой не связаны: чтобы контейнер достучался в чужую сеть, его подключают и к ней (docker network connect).

Публикация порта: DNAT и docker-proxy

Внутри одной сети контейнеры общаются напрямую, а -p нужен, чтобы попасть в контейнер снаружи. Docker добавляет в таблицу nat правило DNAT, которое подменяет адрес назначения: порт хоста превращается в адрес контейнера (сам механизм разобран в главе 1.3 статьи «Linux и ОС»). На чистом Linux-сервере (dind, dockerd 29.8 без настроек) правила такие:

$ docker run -d -p 18080:8080          app     # наружу на всех адресах
$ docker run -d -p 127.0.0.1:18081:8080 app    # только на localhost
$ iptables -t nat -S | grep DNAT
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.18.0.2:8080
-A DOCKER -d 127.0.0.1/32 ! -i docker0 -p tcp -m tcp --dport 18081 -j DNAT --to-destination 172.18.0.3:8080

Пакет на 18080 получает в PREROUTING адрес 172.18.0.2:8080 и через FORWARD уходит к контейнеру. Правило для -p 127.0.0.1:18081:8080 добавляет условие -d 127.0.0.1/32: такой порт открыт только с самого хоста, а -p 8080:8080 выставляет сервис на все адреса сервера.

Рядом с DNAT работает docker-proxy, по процессу на каждый опубликованный порт и адрес: у -p 18080:8080 их два, на 0.0.0.0 и на ::. Он нужен соединениям хоста с самим собой: в цепочке OUTPUT правило стоит с условием ! -d 127.0.0.0/8, и loopback через DNAT не идёт. Такое соединение принимает docker-proxy и открывает своё до контейнера, поэтому приложение видит адрес моста, а не клиента.

Linux-хост, dockerd 29.8 с настройками по умолчанию: -p 18080:8080 сеть eth0 SYN от клиента dst хост:18080 PREROUTING, nat цепочка DOCKER: DNAT → 172.18.0.2:8080 FORWARD, filter DOCKER-USER, потом DOCKER ACCEPT tcp dpt:8080 INPUT правила хоста, ufw: адрес назначения уже не свой, пакет с DNAT сюда не попадает процесс на самом хосте localhost:18080 OUTPUT, nat ! -d 127.0.0.0/8 loopback без DNAT docker-proxy слушает 0.0.0.0:18080 и открывает соединение docker0 мост 172.18.0.1 veth-пара контейнер 172.18.0.2:8080 видит адрес клиента видит адрес 172.18.0.1 клиент не виден Таблицу nat проходит только первый пакет соединения, дальше адреса подменяет conntrack. Docker Desktop 4.91: userland-proxy выключен, поэтому и loopback внутри ВМ идёт через DNAT, а docker-proxy нет. Порт на самом Mac слушает com.docker.backend и пересылает соединение в ВМ. -p 127.0.0.1:18080:8080 добавляет к DNAT условие -d 127.0.0.1: из сети этот порт недоступен.
Опубликованный порт. Соединение из сети получает новый адрес назначения ещё до выбора маршрута и идёт через FORWARD, мимо INPUT с правилами файрвола. Соединение с localhost самого хоста принимает docker-proxy, и приложение видит адрес моста вместо клиента.
Почему на Docker Desktop нет docker-proxy

В ВМ Docker Desktop процессов docker-proxy нет: в её daemon.json стоит "userland-proxy": false. Правило в OUTPUT там без исключения 127.0.0.0/8, на docker0 включён route_localnet, и loopback внутри ВМ тоже идёт через DNAT. Порт на Mac слушает процесс com.docker.backend. На Linux-сервере userland-proxy по умолчанию включён.

localhost, host.docker.internal и сеть host

localhost в контейнере ведёт в его собственный loopback, а не на хост. До сервиса на хосте достают по имени host.docker.internal, и Docker Desktop разрешает его сам:

# на Mac поднят сервер на 127.0.0.1:18090
$ docker run --rm dv-c3d-tools sh -c 'curl -sS -m2 localhost:18090/; curl -sS host.docker.internal:18090/'
curl: (7) Failed to connect to localhost port 18090 after 0 ms: Could not connect to server
hello from the mac                                       # а через host.docker.internal есть

Голый dockerd на Linux-сервере этого имени не знает. Его добавляют флагом --add-host=host.docker.internal:host-gateway (в compose extra_hosts), а host-gateway превращается в адрес моста по умолчанию docker0, в какой бы сети ни был контейнер. Через этот адрес видны сервисы хоста на 0.0.0.0, а на 127.0.0.1 нет:

# Linux-сервер: сервис хоста слушает 0.0.0.0:18091 и 127.0.0.1:18090
$ docker run --rm --add-host=host.docker.internal:host-gateway alpine sh -c '\
    wget -qO- -T2 host.docker.internal:18091/; wget -qO- -T2 host.docker.internal:18090/'
hello from linux-host, remote 172.18.0.4:43880                        # 0.0.0.0 виден
wget: can't connect to remote host (172.18.0.1): Connection refused   # 127.0.0.1 нет

Сеть host (--network host) снимает сетевую изоляцию: контейнер живёт в сетевом namespace хоста, NAT и публикация не нужны. На Docker Desktop хостом для контейнера служит ВМ, а не Mac:

$ docker run --rm --network host dv-c3d-tools ip -br addr | grep -E '^(eth0|lo) '
lo   UNKNOWN   127.0.0.1/8 ::1/128
eth0 UP        192.168.65.3/24 ...          # адрес ВМ, не Mac

С Mac по localhost порт такого контейнера не виден: настройка host networking в Desktop выключена по умолчанию. По документации её включают в настройках с версии 4.34, работает она только на L4, а опубликованные порты в этом режиме отбрасываются. На Linux host означает сеть хоста без оговорок; про hostNetwork рассказано в статье «Kubernetes».

Тома, bind mount и tmpfs

Записываемый слой умирает вместе с контейнером (о нём ниже), поэтому данные, которые должны пережить пересоздание или пишутся интенсивно, выносят из него. Три варианта сравнивались в главе 2.1:

  • Named volume (-v pgdata:/var/lib/postgresql) лежит в /var/lib/docker/volumes/<имя>/_data и переживает пересоздание. Пустой том при первом монтировании Docker заполняет тем, что лежало в этом каталоге образа; bind mount и tmpfs так не делают.
  • Bind mount (-v $(pwd):/src) кладёт внутрь каталог хоста.
  • tmpfs (--tmpfs /tmp) держит данные в памяти, пока жив контейнер.

Тип монтирования и опции видны в docker inspect, а изнутри через findmnt:

$ docker run -d --name store -v "$PWD/data:/data:ro" \
    --tmpfs /cache:size=16m --mount type=tmpfs,dst=/scratch,tmpfs-size=8m dv-c3d-tools sleep infinity
$ docker exec store findmnt -lo TARGET,FSTYPE,OPTIONS | grep -E '/(cache|scratch|data) '
/cache              tmpfs    rw,nosuid,nodev,noexec,relatime,size=16384k
/data               virtiofs ro,nosuid,nodev,relatime,...   # bind mount проброшен через VirtioFS
/scratch            tmpfs    rw,nosuid,nodev,noexec,relatime,size=8192k

С :ro запись падает с «Read-only file system»: так дёшево отдать контейнеру конфиг, не давая его испортить. tmpfs по умолчанию монтируется с nosuid,nodev,noexec, и бинарь из него не запустится (Permission denied), пока не добавишь опцию exec. size задаёт потолок, а не резерв: данные tmpfs лежат в памяти и засчитываются в лимит cgroup.

Больнее всего на bind mount права и uid. Процесс пишет под своим uid, а каталог на хосте принадлежит хостовому пользователю. На Linux процесс с USER 65532 (distroless nonroot) не пишет в каталог root, пока каталог не отдан этому uid:

# Linux-сервер, /srv/data принадлежит root
$ docker run --rm --user 65532 -v /srv/data:/data alpine sh -c 'echo hi > /data/x'
sh: can't create /data/x: Permission denied
$ chown 65532:65532 /srv/data      # теперь тот же запуск пишет

На Docker Desktop тот же процесс пишет без вопросов: общий доступ к файлам через VirtioFS подменяет владельца. Внутри каталог показан как root, а на Mac файлы достаются твоему пользователю (uid 501). Для разработки удобно, но на сервере тот же compose упадёт на правах. Чинят это chown каталога под нужный uid или named volume.

# Docker Desktop, VirtioFS
$ docker run --rm --user 65532 -v "$PWD/data:/data" dv-c3d-tools sh -c 'echo hi > /data/f; ls -ln /data'
-rw-r--r-- 1 65532 0 3 ... f          # внутри владелец 65532
$ ls -ln data
-rw-r--r--  1 501  20  3 ... f          # на Mac владелец твой пользователь

Слой записи контейнера

Всё, что контейнер пишет мимо томов и tmpfs, попадает в его записываемый слой, тонкий верхний слой поверх образа (OverlayFS и copy-up разобраны в главе 2.1). С Engine 29.0 новые установки по умолчанию хранят образы в containerd image store, и слой виден в docker inspect как снапшот overlayfs, а изнутри как upperdir:

$ docker inspect -f '{{json .GraphDriver}} {{json .Storage}}' store
null {"RootFS":{"Snapshot":{"Name":"overlayfs"}}}
$ docker exec store sh -c 'findmnt -no OPTIONS / | tr , "\n" | grep upperdir'
upperdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/8445/fs

Что в него попало, показывает docker diff: A добавлено, C изменено, D удалено (удаление файла нижнего слоя записывается whiteout-файлом). Размер слоя виден в docker ps -s, а virtual прибавляет к нему образ:

$ docker exec store sh -c 'mkdir -p /var/log/app; head -c 50000000 /dev/urandom > /var/log/app/app.log; rm /etc/debian_version'
$ docker diff store
…
C /var/log
A /var/log/app
A /var/log/app/app.log
C /etc
D /etc/debian_version
…
$ docker ps -s --filter name=store --format '{{.Names}} {{.Size}}'
store 50MB (virtual 295MB)

Данные и логи в этом слое держать плохо: он умирает вместе с контейнером (пересоздали под при деплое, и данных нет), правка большого файла через copy-up копирует его целиком, а контейнер молча распухает. Логи, которые Docker забирает со stdout, лежат не в слое, и docker ps -s их не считает:

# контейнер написал 300000 строк в stdout; du запускается в ВМ
$ docker ps -s --filter name=chatty --format '{{.Names}} {{.Size}}'
chatty 0B (virtual 20.8MB)                    # слой пуст
$ du -h $(docker inspect -f '{{.LogPath}}' chatty)
27.3M   /var/lib/docker/containers/<id>/<id>-json.log   # а лог на диске растёт

Compose глубже

Основы compose, включая depends_on с service_healthy, есть в главе 2.1. Возьмём стек из базы, миграции и сервиса: база должна стать готовой, миграция отработать до конца, и только потом стартует сервис.

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: ${PG_PASSWORD:?set PG_PASSWORD in .env}
      POSTGRES_DB: app
    tmpfs: [/var/lib/postgresql]          # данные в памяти, тома не заводим
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 2s
      retries: 15

  migrate:
    build: ../app
    command: ["migrate"]
    environment: { DB_DSN: "postgres://postgres:${PG_PASSWORD}@db:5432/app" }
    depends_on:
      db: { condition: service_healthy }

  api:
    build: ../app
    env_file: app.env                     # переменные внутрь контейнера
    ports: ["127.0.0.1:${API_PORT:-18082}:8080"]
    depends_on:
      db:      { condition: service_healthy }
      migrate: { condition: service_completed_successfully }
    stop_grace_period: 20s

У depends_on три условия. service_started ждёт только запуска контейнера, service_healthy ждёт успешного healthcheck (у Postgres это pg_isready), а service_completed_successfully ждёт выхода с кодом 0 и подходит разовым задачам вроде миграции. На up -d порядок выстраивается сам:

docker compose up -d: условия depends_on db migrate api Created Started health: starting pg_isready каждые 2 с healthy Created ждёт db: condition: service_healthy Started Exited (0) Created ждёт db healthy и migrate: service_completed_successfully Started migrate: Exited (1) → api остаётся Created, compose up завершается с кодом 1: service "migrate" didn't complete successfully: exit 1
depends_on с условиями. Compose создаёт все контейнеры сразу, но запускает зависимый, только когда у зависимости выполнено условие: база отвечает на healthcheck, миграция вышла с нулевым кодом. Если миграция упала, api так и не стартует.

Интерполяция ${VAR} подставляет значения в сам compose-файл до запуска: ${API_PORT:-18082} берёт переменную или значение по умолчанию, ${PG_PASSWORD:?...} валит запуск, если переменная пуста. Значения приходят из окружения shell и файла .env. А env_file кладёт переменные внутрь контейнера, и в интерполяции их не видно: .env настраивает compose, env_file настраивает процесс. Итог подстановки и слияния печатает docker compose config, и с пустым PG_PASSWORD он падает ещё до запуска (required variable PG_PASSWORD is missing a value).

Слияние файлов. Без -f Compose читает compose.yaml и поверх него compose.override.yaml, если тот есть; несколько -f сливаются в порядке перечисления. image и command заменяются целиком, ports дополняются, environment сливается по имени переменной. Сервис с profiles: [tools] обычный up не запускает, нужен --profile tools: так прячут разовые и отладочные контейнеры.

compose down не трогает сервисы из профилей

Если поднят сервис из профиля, обычный docker compose down его оставит, а сеть не удалит: «Network ... Resource is still in use». Сносить надо с тем же профилем, docker compose --profile tools down. Контейнеры compose run ведут себя так же.

docker compose watch нужен для разработки в контейнере. Правила задаются в блоке develop.watch: action: sync копирует изменённый файл в работающий контейнер, action: rebuild пересобирает образ, когда без сборки не обойтись (исходник, go.mod). В distroless удаление через sync не работает: compose зовёт внутри rm, которого там нет (exec: "rm": executable file not found in $PATH), а добавление и правка проходят.

Лимиты ресурсов в cgroup v2

Флаги ресурсов docker run превращаются в файлы cgroup v2, и контейнер видит их в /sys/fs/cgroup:

$ docker run --rm --memory 256m --memory-swap 256m --cpus 1.5 --pids-limit 64 dv-c3d-tools \
    sh -c 'cd /sys/fs/cgroup && grep -H . memory.max memory.swap.max cpu.max pids.max; nproc'
memory.max:268435456          # --memory 256m
memory.swap.max:0             # --memory-swap 256m: своп сверх памяти = 0
cpu.max:150000 100000         # --cpus 1.5: 150 мс на каждые 100 мс
pids.max:64                   # --pids-limit 64
4                             # ядер по-прежнему видно 4

Квота 150000 100000 в cpu.max означает не «полтора ядра», а 150 мс процессорного времени на каждые 100 мс реального (как на это отвечает рантайм Go, разобрано в главе 2.1). --memory-swap задаёт память плюс своп: равный --memory своп запрещает, а без него Docker разрешает свопа ещё столько же, и OOM наступает позже. --pids-limit ограничивает число процессов и потоков. Go-сервису его дают с запасом: с --pids-limit 4 рантайм на 4 ядрах падает на старте (fatal error: newosproc, errno 11).

Лимиты меняются на живом контейнере: docker update --cpus 3 --memory 512m --memory-swap 512m перезаписывает те же файлы, а без --memory-swap команда откажет, если своп уже задан меньше новой памяти. Потребление показывает docker stats. Когда процесс упирается в memory.max и освободить нечего, его убивает OOM killer cgroup: docker inspect ставит OOMKilled, код выхода 137 (128 + 9, SIGKILL). Кроме кучи, в лимит идут page cache и tmpfs: файл на 200 МиБ в --tmpfs съедает 200 МиБ из memory.max. Подробнее в вопросе 3.

Остановка и перезапуск

docker stop шлёт контейнеру SIGTERM, ждёт тайм-аут и добивает SIGKILL, если тот не вышел. Приложение ловит SIGTERM и доигрывает текущие запросы (в Go signal.NotifyContext и srv.Shutdown, статья «Веб-сервисы» раздела Go). Ловить должен PID 1: к нему ядро не применяет действие сигнала по умолчанию, и без обработчика SIGTERM просто теряется (глава 2.1). Go-бинарь без signal.NotifyContext сигнал не теряет: рантайм ставит обработчик сам, и процесс сразу выходит с кодом 143, обрывая запросы.

По документации Docker тайм-аут по умолчанию 10 секунд, но на этой машине без -t он равен трём:

$ docker run -d -e SHUTDOWN_DELAY=5s app; docker stop <id>   # приложение тянет 5 с
# ... вернулось через 3.1 с, exit 137: не успел, добит SIGKILL

Приложение и пользовательский ~/.docker/daemon.json тут ни при чём. В Docker Engine 29.7 появилась опция демона default-stop-timeout, и Docker Desktop прописывает её в daemon.json своей ВМ. Конфиг видно через namespace процесса dockerd:

# в ВМ, только чтение конфига dockerd
$ grep stop-timeout /proc/$(pgrep dockerd)/root/run/config/docker/daemon.json
  "default-stop-timeout": 3,

Три секунды выставил Docker Desktop. На чистом Linux-сервере (dind без настроек) тайм-аут десять секунд, и тот же контейнер успевает слить запросы и выйти с кодом 0.

Не полагайся на дефолтный тайм-аут, задай его сам

Тайм-аут по умолчанию зависит от окружения: 3 с на Docker Desktop, 10 с на голом Linux-сервере, 30 с в Kubernetes. Задавай его явно, с запасом над временем слива: --stop-timeout, stop_grace_period в compose, terminationGracePeriodSeconds в поде. С --stop-timeout 10 тот же сервис на Desktop укладывается в свои 5 секунд и выходит с кодом 0.

Сигнал остановки задаёт STOPSIGNAL или --stop-signal: обычно это SIGTERM, а Postgres 18 просит SIGINT. Процессу без обработчиков помогает --init: первым встаёт крошечный init (tini), который пересылает сигнал потомку и пожинает зомби. Shell-обёртку он не чинит, сигнал достанется shell, а не приложению. Разница на процессе без обработчика:

$ docker run -d --name a1        dv-c3d-tools sleep infinity; docker stop a1   # 3.1 с, exit 137 (SIGKILL)
$ docker run -d --name b1 --init dv-c3d-tools sleep infinity; docker stop b1   # 0.1 с, exit 143 (SIGTERM)

Restart policies решают, что делать с вышедшим контейнером: no (по умолчанию), on-failure[:N] (при ненулевом коде, до N раз), always, unless-stopped (всегда, кроме ручной остановки). При повторных падениях задержка перезапуска удваивается со 100 мс (0,16 → 0,26 → 0,46 → 0,85 → 1,67 → 3,25 с в прогоне) до потолка в минуту и сбрасывается, если контейнер прожил 10 секунд. После перезапуска демона поднимаются always и упавшие on-failure, а unless-stopped только если его не останавливали руками.

Логи контейнера

Приложение в контейнере пишет логи в stdout и stderr, а не в файл, и Docker подхватывает их драйвером логов. Такой контракт: контейнер эфемерный, его файловая система пропадёт, а stdout забирает платформа. По умолчанию и на Docker Desktop, и на голом Linux стоит драйвер json-file:

$ docker info --format '{{.LoggingDriver}}'
json-file
$ docker inspect -f '{{.LogPath}}' logs
/var/lib/docker/containers/<id>/<id>-json.log
$ cat $(docker inspect -f '{{.LogPath}}' logs)
{"log":"log line 1 of 2\n","stream":"stdout","time":"2026-09-16T23:07:51.728511884Z"}

В каждой строке JSON с текстом, потоком и временем; этот файл читает docker logs, в том числе с --since и --timestamps. У json-file по умолчанию нет ротации (max-size не задан), и журнал растёт без предела, как тот лог на 27 МиБ из раздела про слой записи. Ротацию включают опциями драйвера:

$ docker run -d --name rot --log-opt max-size=1k --log-opt max-file=3 app
# ... контейнер насыпал логов
$ ls $(dirname $(docker inspect -f '{{.LogPath}}' rot)) | grep json
<id>-json.log
<id>-json.log.1
<id>-json.log.2                              # три файла, старые выбрасываются

max-size ограничивает размер файла, max-file число файлов; ставить их лучше глобально в daemon.json. Встроенный драйвер local ротирует из коробки (100 МиБ: пять файлов по 20 МиБ, со сжатием), и документация Docker рекомендует его, а json-file оставляет дефолтом ради обратной совместимости. В Kubernetes логами и ротацией занимается kubelet, статья «Kubernetes».

Безопасность запуска

По умолчанию контейнер стартует от root, но не всемогущего: права root ядро делит на capabilities (глава 1.2 статьи «Linux и ОС»), и Docker оставляет из них небольшой набор:

$ docker run --rm dv-c3d-tools sh -c 'grep CapEff /proc/self/status; capsh --decode=00000000a80425fb'
CapEff: 00000000a80425fb
0x...=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,
      cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

Четырнадцать capabilities из 41, которые знает ядро 7.0. Набор урезают дальше: снимают всё и возвращают нужное, например NET_BIND_SERVICE (порт ниже 1024 в Docker, впрочем, доступен и без неё, глава 2.1):

$ docker run --rm --cap-drop ALL --cap-add NET_BIND_SERVICE dv-c3d-tools sh -c 'grep CapEff /proc/self/status; chown nobody /tmp'
CapEff: 0000000000000400                      # одна capability, бит 10
chown: changing ownership of '/tmp': Operation not permitted   # CAP_CHOWN уже нет

С непривилегированным пользователем (--user 65532) действующий набор (CapEff) и вовсе пуст. Следующий шаг: неизменяемый корень и tmpfs для записи:

$ docker run --rm --read-only --tmpfs /tmp dv-c3d-tools sh -c 'touch /etc/x; touch /tmp/x && echo tmp-ok'
touch: cannot touch '/etc/x': Read-only file system
tmp-ok                                        # /tmp пишется, остальное нет

Флаг no-new-privileges запрещает набирать привилегии сверх имеющихся, и setuid-бит перестаёт поднимать euid. Тестовый setuid-бинарь без флага получает euid 0, с флагом остаётся при своём:

$ docker run --rm --user 65534 dv-c3d-tools euid
uid=65534 euid=0                              # setuid сработал
$ docker run --rm --user 65534 --security-opt no-new-privileges dv-c3d-tools euid
uid=65534 euid=65534                          # euid не поднялся

Ещё один рубеж, seccomp. Профиль Docker по умолчанию устроен как белый список: вызов вне списка получает ошибку EPERM. unshare в профиле разрешён только при CAP_SYS_ADMIN, поэтому под дефолтным профилем создать namespace нельзя, а без профиля можно:

$ docker run --rm dv-c3d-tools sh -c 'grep Seccomp: /proc/self/status; unshare --user --map-root-user id'
Seccomp: 2                                    # фильтр включён
unshare: unshare failed: Operation not permitted
$ docker run --rm --security-opt seccomp=unconfined dv-c3d-tools unshare --user --map-root-user id
uid=0(root) gid=0(root) groups=0(root)        # без профиля прошло

Всё это включается флагами, приложение переписывать не нужно. Дальше идёт rootless-режим: демон и контейнеры работают в user namespace от обычного пользователя.

Монтированный docker.sock равен root на хосте

С -v /var/run/docker.sock:/var/run/docker.sock контейнер получает API демона, который работает от root. Через сокет он запускает соседа с --privileged и -v /:/host и пишет в корень хоста, так что урезанные права исходного контейнера ничего не значат, и :ro на сокете не спасает. Разбор в вопросе 4.

Вопросы

4
Суть: публикация порта держится на DNAT в таблице nat. Он срабатывает в PREROUTING, и пакет к контейнеру идёт транзитом через FORWARD, мимо INPUT с правилами ufw, поэтому ufw deny его не видит. Фильтровать такой трафик надо в цепочке DOCKER-USER.

Почему ufw не работает

ufw пишет правила в INPUT и OUTPUT, а до них пакет не доходит. Закроем вход политикой DROP, как это делает ufw: сервис в сети host закрылся, а опубликованный порт по-прежнему открыт из сети:

$ iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ iptables -A INPUT -i lo -j ACCEPT
$ iptables -P INPUT DROP                        # default deny, как ufw
$ curl -sS -m2 <server>:18091/     # сервис в сети host: его ловит INPUT
curl: (28) Connection timed out after 2001 milliseconds
$ curl -sS -m2 <server>:15432/     # порт контейнера с -p идёт мимо INPUT
hello from <container>

Где фильтровать правильно

Для своих правил Docker держит в FORWARD цепочку DOCKER-USER, и пакет проходит её до правил Docker. Порт там сверяют через модуль conntrack, потому что DNAT уже переписал адрес:

$ iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 15432 -j DROP
$ curl -sS -m2 <server>:15432/                  # из сети закрыт
curl: (28) Connection timed out after 2004 milliseconds
$ wget -qO- -T2 localhost:15432/                # с самого хоста работает
hello from <container>
Не публикуй лишнего

Лучше вовсе не открывать порт наружу: внутри одной сети контейнеры видят друг друга, и порт базы «чтобы посмотреть» публиковать незачем. Нужен локально, вешай на loopback (-p 127.0.0.1:5432:5432). DOCKER-USER остаётся вторым рубежом.

Суть: проверь три вещи. Приложение ловит SIGTERM и делает graceful shutdown; оно работает как PID 1 (exec-форма ENTRYPOINT), иначе сигнал теряется; тайм-аут остановки больше времени слива и задан явно, потому что дефолт в каждом окружении свой.

Три места, где рвётся

Приложение должно поймать SIGTERM и доиграть текущие запросы (в Go signal.NotifyContext плюс srv.Shutdown(ctx)). При shell-форме ENTRYPOINT PID 1 достаётся /bin/sh, который сигнал не пересылает (глава 2.1), поэтому нужна exec-форма ENTRYPOINT ["/app"]; --init тут не спасёт, сигнал получит shell. А тайм-аут зависит от окружения: документация обещает 10 секунд, но Docker Desktop ставит в своей ВМ default-stop-timeout: 3, и без -t контейнер добивают через три:

$ docker run -d --name drain -e SHUTDOWN_DELAY=5s app; docker stop drain   # сливает 5 с
# ... вернулось через 3.1 с, exit 137: не успел, SIGKILL

На голом Linux тот же сервис успевает за свои 5 с (там тайм-аут 10 с), в Kubernetes дают 30 с. Задавай тайм-аут явно и с запасом: --stop-timeout, stop_grace_period в compose, terminationGracePeriodSeconds в поде; с --stop-timeout 10 сервис выходит с кодом 0. Сигнал задаёт STOPSIGNAL (Postgres просит SIGINT). Как по шагам останавливается под, разобрано в статье «Kubernetes».

Суть: 137 означает 128 + 9, то есть SIGKILL, а его шлёт и OOM killer, и docker stop по тайм-ауту. Различают их по флагу OOMKilled и событию oom. В лимит памяти, кроме кучи, входят page cache файлов и tmpfs.

137 бывает от двух причин

Код 137 значит «процесс убит сигналом 9». Его посылает OOM killer cgroup, когда память упёрлась в memory.max, и тот же сигнал прилетает от docker stop по тайм-ауту. Отличает их флаг:

# добит по тайм-ауту stop
$ docker inspect -f 'exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' drain
exit=137 OOMKilled=false
# убит OOM killer
$ docker inspect -f 'exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' hog
exit=137 OOMKilled=true

Надёжнее поток событий: у OOM перед die стоит oom, а у обычной остановки kill с сигналом 15, потом с 9.

$ docker events --filter container=hog --format '{{.Action}}'
oom
die

Почему heap маленький, а OOM есть

Лимит cgroup считает не одну кучу Go. В memory.current входит page cache файлов, которые процесс пишет (грязные страницы ядро не выбросит, пока не сбросит на диск), и tmpfs, который целиком лежит в памяти. Сервис, который пишет большой файл или держит данные в --tmpfs, получает OOMKilled при скромной куче. Механизм разобран в статье «Linux и ОС», главы 1.2 и 1.4, про GOMEMLIMIT в главе 2.1. Большие данные держи вне контейнера и не складывай в tmpfs.

Суть: через сокет доступен весь API демона, а демон работает от root. Кто пишет в сокет, тот запускает привилегированный контейнер с корнем хоста внутри; урезанные права и :ro не помогают. Если Docker всё же нужен, ставят socket-proxy с белым списком или rootless-демон.

Почему это полный доступ

Через API контейнер запускает соседа с --privileged и корнем хоста (-v /:/host), делает chroot и пишет в файловую систему хоста от root. Права исходного контейнера тут ни при чём: права нового задаёт тот, кто дёргает API. В песочнице (внутри dind) не-root контейнер так дописал файл в /root «хоста»:

$ docker run --rm --user 65532 --group-add <docker-gid> -v /var/run/docker.sock:/var/run/docker.sock ... \
    sh -c 'id; docker run --rm --privileged -v /:/host alpine chroot /host sh -c "id; echo pwned > /root/x"'
uid=65532 ...        # исходный не root
uid=0(root) ...      # новый: root на хосте

:ro на сокете ничего не меняет: read-only касается файла-сокета, а запросы к API идут через него как раньше.

Что делать вместо этого

  • Не пробрасывать сокет. Обычно Docker из контейнера не нужен: CI-раннеру хватает сборщика без демона (статья «CI/CD»), приложению он ни к чему.
  • Socket-proxy. Если доступ нужен (мониторинг, автообновление), перед сокетом ставят прокси, который пропускает только нужные запросы и режет POST на создание контейнеров.
  • Rootless-демон. Демон и контейнеры работают от обычного пользователя, и пробой через сокет упирается в его права.
Сокет в разработке ≠ сокет в проде

Локально смонтировать docker.sock в контейнер с инструментом нормально, а в проде на общем хосте тот же приём отдаёт root любому, кто попал в контейнер. В Kubernetes у пода по умолчанию нет доступа к сокету рантайма, и docker-in-docker туда без нужды не тащат.