voidlearn
 

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

Сопоставишь контейнер с процессом Linux и виртуальную машину с отдельным ядром. Поймёшь, как слои образа, PID 1, сигналы и лимиты ресурсов влияют на Go-сервис.

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

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

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

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

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

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

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

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

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

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

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

WORKDIR /src

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вопросы

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

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

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

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

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

Гипервизоры

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

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

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

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

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

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

Механика

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

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

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

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

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

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

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

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

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

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

scratch vs distroless vs alpine

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

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

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

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

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

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

ENTRYPOINT и CMD

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

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

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

EXPOSE

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

USER

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

HEALTHCHECK

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

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

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

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

Хранилище

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

Сети

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

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

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

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

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

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

CPU

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

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

Память

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

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

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

Чем добить

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

Что стоит запомнить

  • Контейнер изолирует процесс через 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 под репрезентативной нагрузкой. Значения не стоит выводить из самого лимита без замера.