2.1Как устроен контейнер
Сопоставишь контейнер с процессом Linux и виртуальную машину с отдельным ядром. Поймёшь, как слои образа, PID 1, сигналы и лимиты ресурсов влияют на Go-сервис.
Контейнер 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 секрет всё равно уезжает в реестр. Ответ на оба один, и держится он на устройстве слоёв.
Слой не «часть образа» в смысле «кусок». Слой хранит список изменений
файловой системы: какие файлы добавились, какие изменились, какие удалились по сравнению
с предыдущим состоянием. Git устроен наоборот: там коммит хранит снимок всего дерева,
а слой ближе к патчу между двумя коммитами. Каждая
инструкция Dockerfile, которая может что-то записать на диск (RUN, COPY,
ADD, WORKDIR), рождает один такой слой, даже если он выйдет пустым.
Драйвер ядра Linux OverlayFS умеет показать стопку таких слоёв как одно обычное дерево каталогов. Аналогия: стопка прозрачных плёнок, каждая с частью рисунка; смотришь сверху и видишь один цельный рисунок, хотя физически это несколько отдельных листов. Ломается аналогия на записи.
Технически образ состоит из манифеста, конфига и списка слоёв. Каждый слой лежит 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, а память лимитируют всегда: память
несжимаемый ресурс, и её перерасход убивает соседей по ноде.»
Что стоит запомнить
- Контейнер изолирует процесс через namespaces и cgroups, но использует ядро хоста.
- Каждая инструкция сборки может создать слой и повлиять на повторное использование кэша.
- PID 1 должен передавать сигналы и корректно завершать дочерние процессы.
Частые ошибки
- Контейнерная изоляция не равна границе виртуальной машины.
- GOMAXPROCS и GOMEMLIMIT стоит согласовать с лимитами контейнера.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи сигналы
Что произойдёт при docker stop, если исполняемым процессом контейнера стал shell-скрипт?
ENTRYPOINT ["/app/start.sh"]
# start.sh
/app/api
Ответ и разбор
Docker отправляет SIGTERM PID 1 контейнера, которым здесь является shell. Если скрипт запускает бинарь как обычную дочернюю команду и не делает exec, сигнал может не попасть приложению, а после таймаута Docker пошлёт SIGKILL. Заверши скрипт через exec /app/api, чтобы бинарь занял PID 1, либо используй init-процесс для управления несколькими детьми.
Код-ревью
2. Собери финальный слой без libc ошибки
Бинарь собирается с CGO и запускается в scratch. В чём ошибка при старте?
FROM golang:1.27 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=1 go build -o /api ./cmd/api
FROM scratch
COPY --from=build /api /api
ENTRYPOINT ["/api"]
Ответ и разбор
При CGO_ENABLED=1 бинарь может динамически зависеть от libc и других системных библиотек, которых в scratch нет. Если проект поддерживает чистую Go-сборку, пересобери с CGO_ENABLED=0; иначе добавь нужные shared libraries в финальный образ или выбери runtime-образ с ними. Отдельно проверь CA certificates и timezone data, если приложение от них зависит.
Применение
3. Выбери изоляцию
Команде нужен быстрый запуск сервиса и отдельное ядро для недоверенной нагрузки. Какой вариант выбрать и какую цену он несёт?
Ответ и разбор
Контейнеры подходят для быстрого запуска и плотного размещения, когда допустимо общее ядро. Для недоверенной нагрузки с требованием отдельного ядра выбирай виртуальную машину или среду с соответствующей аппаратной изоляцией. Контейнер экономит ресурсы и время старта, но уязвимость общего ядра затрагивает границу изоляции.
Применение
4. Сверь лимиты с Go
Контейнеру задали лимит памяти 256 MiB и CPU quota в одно ядро. Какие параметры Go и наблюдения проверишь до запуска нагрузочного теста?
Ответ и разбор
Проверь, видит ли версия Go cgroup-лимиты; задай или проверь согласованные GOMAXPROCS и GOMEMLIMIT, оставив запас под стеки, библиотеки и вне-heap память. Затем сравни throttling CPU, heap и RSS под репрезентативной нагрузкой. Значения не стоит выводить из самого лимита без замера.