Docker
Контейнер — не маленькая виртуальная машина, а процесс на общем ядре Linux, и большая часть ошибок растёт отсюда. Как устроены образ и его слои, как собрать Go-сервис быстро, воспроизводимо и без лишнего в образе, что делает кэш BuildKit и что хранит реестр. И что происходит с контейнером в работе: сеть и публикация портов, тома, compose, лимиты ресурсов, логи, сигналы и безопасность запуска.
Понимаешь ли ты Docker как надстройку над ядром Linux, а не как набор команд из шпаргалки.
Можно сказать «добавим ещё контейнеров», а можно объяснить, почему приложение за
shell-обёрткой не получает SIGTERM, почему Go-сервис со старой версией в
go.mod при лимите в одно ядро ставит GOMAXPROCS по числу ядер
хоста и почему смонтированный docker.sock равен root на хосте. На собеседовании
это два разных грейда.
2.1Как устроен контейнер
Здесь проверяют не знание флагов, а понимание двух вещей: образ собран из стопки неизменяемых слоёв с кэшем, а контейнер остаётся обычным процессом, у которого отобрали видимость и ограничили ресурсы. Остальное из этого и следует.
Контейнер vs виртуальная машина
Виртуальная машина эмулирует железо: у неё своё ядро, свой init, свой набор устройств. Изоляция получается максимальная (уязвимость в гостевом ядре не даёт доступа к хосту), но платить приходится сотнями мегабайт памяти на одну только ОС и десятками секунд на загрузку. Контейнер запускается процессом на общем ядре хоста: через namespaces ему обрезали видимость, через cgroups ограничили ресурсы. Стартует за десятки миллисекунд, весит немногим больше самого процесса, но ядро общее: эксплойт ядра пробивает изоляцию.
| Виртуальная машина | Контейнер | |
|---|---|---|
| Ядро | своё, гостевое | общее с хостом |
| Старт | десятки секунд | десятки миллисекунд |
| Накладные расходы | сотни МиБ на гостевую ОС | единицы МиБ на процесс containerd-shim |
| Плотность на хосте | единицы–десятки | сотни |
| Граница безопасности | сильная (аппаратная виртуализация) | слабее: общее ядро, нужны seccomp/AppArmor |
| Разные ОС на одном хосте | да (Windows на Linux) | нет — только то, что понимает ядро хоста |
| Образ | от сотен МиБ до десятков ГиБ | от 2 МиБ (scratch с Go-бинарём) |
Потому что там нет ядра 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 зависит только от
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
- Порядок COPY: сначала
go.mod/go.sumиgo mod download, потом весь остальной код. .dockerignore: исключить.git,vendor(если не используешь), артефакты,node_modules, локальные.env. Иначе весь контекст (иногда гигабайты) уезжает сборщику (BuildKit досылает только изменения, но чистый раннер CI получает всё заново), а кэшCOPY . .ломается при любом изменении в.git.- BuildKit cache mounts для
/go/pkg/modи/root/.cache/go-build: инкрементальная компиляция переживает пересборку слоя. - Общий кэш в CI:
--cache-from/--cache-to type=registry, чтобы кэш жил не на раннере, а в реестре. - Multi-stage, чтобы в финальный образ не уезжал тулчейн на сотни МиБ.
- Фиксированные теги базовых образов, лучше по digest. Иначе сборка невоспроизводима и кэш рвётся сам по себе.
Multi-stage build: статический бинарь в scratch
Идея простая: собирать в жирном образе с компилятором, а в финальный образ переносить только готовый бинарь. Go для этого идеален: без cgo линкуется статически и не тянет за собой рантайм.
# 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/zoneinfotime.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 популярен из-за размера, но 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 ENV | build-time vs run-time | ARG виден в истории образа — секреты туда класть нельзя |
Тома, сети, 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 мс на ровном месте.
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-лимиту и дальше следить за его изменениями. Других штатных способов «отменить» ручную установку нет.
Сборщик мусора 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Что физически происходит
Когда ты делаешь 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-shim | 5–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 контейнеров.
Механика
Образ складывается из слоёв, объединённых 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): секрет монтируется на время выполнения инструкции и в слои не попадает.
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
| База | Размер | Что внутри | Когда брать |
|---|---|---|---|
scratch | 0 | ничего вообще | максимум аскезы; 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 NPM_TOKEN виден в docker history и в метаданных образа
любому, кто его скачал. ENV тем более: он ещё и наследуется дочерними
процессами. Секреты в сборку передавай только через RUN --mount=type=secret,
секреты в рантайм — через Secret/переменные окружения кластера или внешний vault.
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 без внешнего окружения.
/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, сведениями о сборке.
COPY . . в стадии build, и пересобирается только эта ветка
и шаг финальной стадии, который берёт из неё бинарь.Почему CACHED и что сбрасывает кэш
В ключ RUN входят текст команды, окружение с объявленными к этому месту ARG,
монтирования, платформа и ключ родителя. Для COPY считается сумма содержимого файлов, время
изменения в неё не входит. Первые четыре строки таблицы сняты на Dockerfile выше, остальные — на
одностадийном, где версия уходит в go build -ldflags "-X main.version=${VERSION}":
| Что поменялось | go mod download | go build | Финальный COPY --from=build |
|---|---|---|---|
touch cmd/server/main.go | CACHED | CACHED | CACHED |
строка кода в orders.go | CACHED | заново, 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 build | CACHED | заново | — |
строка кода, 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 скачивается готовый слой с модулями.
| Чистый builder | mode=min | mode=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.
С 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 читает go (в go 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 для него не отмечает.
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/tzdata | 8,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-cache | created | Слой | Манифест | Index |
|---|---|---|---|---|
| без настроек | разный | разный | разный | разный |
--build-arg SOURCE_DATE_EPOCH=1767225600 | 2026-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 проверяет бинарь из образа без исходников.
Вопросы
4mode=max и подтягивают перед сборкой, а потом
ищут шаг, который первым получает новый ключ.Что искать в логе
С --progress=plain найди первую вершину без CACHED, зависимые шаги выполнятся за ней.
| Что в логе | Причина |
|---|---|
нет importing cache manifest | не передан --cache-from |
… not found в каждой сборке (в первой это нормально) | никто не пишет --cache-to или ref другой |
всё заново с WORKDIR | mode=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.
В кэш попадают слои сборочных стадий: токен, записанный шагом в /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.
$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без gccgo 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 эмулятор не нужен.
SOURCE_DATE_EPOCH и
rewrite-timestamp, index совпадёт только без provenance. Выкатывают по дайджесту, потому что тег переезжает при каждом push.Что меняется от сборки к сборке
| Источник | Куда попадает | Чем фиксируют |
|---|---|---|
| время сборки | created и история в конфиге | SOURCE_DATE_EPOCH |
| время файлов | tar слоя | rewrite-timestamp=true |
startedOn | attestation, 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 и открывает своё до
контейнера, поэтому приложение видит адрес моста, а не клиента.
В ВМ 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 порядок выстраивается сам:
Интерполяция ${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.
Вопросы
4nat. Он срабатывает
в 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».
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.
: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 туда без нужды не тащат.