Контейнеры в доставке: Docker, сборка образов, реестры, безопасность и размер
В предыдущей статье — https://courses.digitable.life/post/devops/04-cd-and-release-strategies/ — мы разбирали, как выкатывать новую версию: blue-green, canary, откаты. Но всё это опирается на одно молчаливое допущение: у нас есть артефакт, который можно запустить где угодно и который на всех средах ведёт себя одинаково. Этот артефакт сегодня почти всегда — контейнерный образ.
Эта статья про то, что происходит между «код собрался» и «Kubernetes скачал образ». Это участок, который команды чаще всего оставляют «как получилось»: один раз написали Dockerfile по первому найденному примеру, и через год сборка идёт девять минут, образ весит 1.4 ГБ, в нём лежит приватный ключ, а реестр стоит дороже, чем прод.
Зачем вообще контейнер: проблема, которую он решает
Возьмём первопричину. Программа зависит от окружения: версии libc, наличия libpq.so.5, кодировки локали, переменных окружения, содержимого /etc/ssl/certs. До контейнеров эту зависимость решали двумя способами: либо «настроим все машины одинаково» (Ansible, Puppet — см. https://courses.digitable.life/post/devops/10-configuration-management/), либо «положим всю ОС в образ» (виртуалка, Packer).
Первый способ хрупок: конфигурация дрейфует, потому что состояние машины — это накопленная история всех команд, которые на ней выполнялись. Второй тяжёл: образ виртуалки — это гигабайты, минуты загрузки и отдельное ядро на каждый экземпляр.
Контейнер — компромисс: упаковываем всё пользовательское пространство, но переиспользуем ядро хоста. Технически контейнер — это обычный процесс Linux, которому:
namespacesподменили представление о мире (свой PID 1, своя сеть, своя файловая система, свои пользователи);cgroupsограничили ресурсы (память, CPU, IO);pivot_root/chrootподставили корень из распакованного образа;- capabilities, seccomp и LSM (AppArmor/SELinux) урезали права.
Никакой «виртуализации» здесь нет — есть изоляция. Отсюда сразу два практических следствия: контейнер стартует за миллисекунды (это просто fork+exec), и контейнер не является границей безопасности того же класса, что виртуалка — ядро общее, и уязвимость в ядре пробивает изоляцию. Это ключевой факт для мультитенантных сценариев: там берут gVisor, Kata Containers или отдельные ноды.
Про namespaces и cgroups подробнее — в треке ОС: https://courses.digitable.life/post/operating-systems/00-overview/.
Анатомия OCI-образа
«Docker-образ» — исторически неточное название. Формат стандартизован в OCI Image Specification, и его поддерживают все: containerd, CRI-O, Podman, Buildah, Kaniko.
Образ — это контентно-адресуемый граф. Каждый узел идентифицируется sha256-дайджестом своего содержимого.
изменяемый указатель"] --> IDX IDX["Image Index / manifest list
sha256:9f3c...
мультиархитектурный список"] IDX -->|linux/amd64| M1["Manifest
sha256:aa71..."] IDX -->|linux/arm64| M2["Manifest
sha256:bc09..."] M1 --> CFG["Config JSON
ENV, CMD, USER, WORKDIR,
history, diff_ids"] M1 --> L1["Layer 1: базовая ФС
tar.gz, 28 МБ"] M1 --> L2["Layer 2: зависимости
tar.gz, 140 МБ"] M1 --> L3["Layer 3: приложение
tar.gz, 6 МБ"] M1 -.-> ATT["Аттестации: SBOM,
provenance, подпись"] style TAG fill:#e76f51,color:#fff style IDX fill:#3d8bcd,color:#fff style CFG fill:#5c6b7a,color:#fff style ATT fill:#2a9d8f,color:#fff
Что здесь важно на практике:
- Слой — это tar-архив с diff файловой системы, не «команда». Удаление файла записывается как whiteout-файл (
.wh.<имя>). Отсюда классическая ошибка:RUN rm secret.pemв отдельном слое не удаляет секрет — он остаётся в предыдущем слое и извлекается командойdocker saveза десять секунд. - Тег — изменяемый указатель, дайджест — нет.
myapp:1.4.2сегодня и через месяц могут быть разными образами. В проде разворачивайте по дайджесту (myapp@sha256:...), а тег держите как человекочитаемую метку. - Слои дедуплицируются глобально. Если 40 сервисов используют одну базу — она хранится и качается один раз на ноду. Это аргумент за стандартизацию базовых образов внутри компании.
Посмотреть это руками можно без запуска Docker-демона — утилитами crane и skopeo:
# Дайджест тега, не скачивая образ
$ crane digest nginx:1.27-alpine
sha256:3d69bd9dcbdfa6dbde54c96b04a7bd1e4a0f01b47a51c8a5f8f5dbaf9fd0b71b
# Манифест: что внутри
$ crane manifest nginx:1.27-alpine | jq '.layers[] | .size'
3622036
1234567
...
# Полная история слоёв и их авторов
$ crane config nginx:1.27-alpine | jq '.history[] | .created_by' | head -3
"/bin/sh -c #(nop) ADD file:... in / "
"/bin/sh -c #(nop) CMD [\"/bin/sh\"]"
"/bin/sh -c #(nop) ENV NGINX_VERSION=1.27.3"
Отдельно рекомендую dive — TUI, который показывает, какой слой сколько добавил и какие файлы в нём мусорные:
$ dive myapp:1.4.2 --ci --lowestEfficiency=0.95
Image efficiency: 88 %
Wasted space: 122 MB
Сборка: кэш решает всё
Скорость сборки определяется почти исключительно попаданием в кэш слоёв. Правило одно: инструкции упорядочены от редко меняющихся к часто меняющимся.
Кэш BuildKit инвалидируется, если изменилась сама инструкция или контент, который она копирует. Поскольку слои — это стопка, инвалидация одного слоя убивает все слои выше. Отсюда — канонический шаблон «сначала манифест зависимостей, потом код».
Плохой и хороший Dockerfile: Node.js
# ❌ ПЛОХО: любое изменение кода запускает npm ci заново
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "src/index.js"]
Проблемы: базовый образ на 1.1 ГБ, npm install вместо npm ci (недетерминированно), кэш не работает, процесс идёт от root, dev-зависимости в проде, PID 1 не обрабатывает SIGTERM.
# ✅ ХОРОШО
# syntax=docker/dockerfile:1.9
# --- Стадия сборки -------------------------------------------------
FROM node:22-slim AS build
WORKDIR /app
# Сначала только манифесты — этот слой переживёт правки кода
COPY package.json package-lock.json ./
# Кэш-маунт: ~/.npm сохраняется между сборками, но НЕ попадает в слой
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Отбрасываем dev-зависимости уже после сборки
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# --- Финальная стадия ----------------------------------------------
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Непривилегированный пользователь: в node-образах он уже есть (uid 1000)
USER node
# --link позволяет переиспользовать слой независимо от предыдущих
COPY --link --chown=node:node --from=build /app/node_modules ./node_modules
COPY --link --chown=node:node --from=build /app/dist ./dist
EXPOSE 3000
# --init подставит tini снаружи; либо явный exec-form без shell
CMD ["node", "dist/index.js"]
Ключевые приёмы:
--mount=type=cache— персистентный кэш пакетного менеджера, который не становится слоем образа. Работает для npm, pip, apt, go mod, cargo, maven. Это самая недооценённая фича BuildKit.COPY --linkсоздаёт слой, не зависящий от состояния предыдущих слоёв: если изменилась только база, слой приложения переиспользуется как есть.- Многостадийность: инструменты сборки (компилятор TypeScript, gcc, заголовочные файлы) остаются в стадии
buildи в финальный образ не попадают.
.dockerignore — обязателен
Без него в контекст сборки уезжают .git (часто сотни мегабайт), node_modules, локальные .env. Контекст передаётся демону целиком, до выполнения первой инструкции.
.git
.github
node_modules
dist
coverage
*.log
.env*
Dockerfile*
docker-compose*.yml
**/__pycache__
Секреты при сборке
Три типичных способа передать токен приватного репозитория — и только один правильный.
# ❌ ARG остаётся в истории образа навсегда: crane config покажет его
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci
# ❌ Копирование файла: он в слое, даже если потом rm
COPY .npmrc .
RUN npm ci && rm .npmrc
# ✅ Секрет монтируется только на время выполнения RUN, в слой не попадает
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm ci
$ docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp:dev .
Проверить, что вы ничего не забыли, стоит автоматически — например, прогнать trufflehog по собранному образу в пайплайне. Подробнее про управление секретами — в https://courses.digitable.life/post/devops/17-security-in-pipeline/.
Размер образа: сколько это стоит на самом деле
Размер — не эстетика. Он платится трижды: временем docker pull при каждом холодном старте пода, местом на диске нод, деньгами за трафик и хранение в реестре. Плюс — каждый лишний пакет в образе это CVE, который придётся объяснять в отчёте сканера.
Выбор базового образа
| База | Размер (сжат.) | Пакетный менеджер | Shell | Кому подходит |
|---|---|---|---|---|
ubuntu:24.04 |
~29 МБ | apt (glibc) | да | legacy, много системных зависимостей |
debian:12-slim |
~29 МБ | apt (glibc) | да | дефолт для «нужен нормальный Linux» |
alpine:3.20 |
~3.4 МБ | apk (musl) | да | Go, Rust, статические бинарники |
gcr.io/distroless/base-debian12 |
~8 МБ | нет | нет | glibc-приложения, максимум безопасности |
gcr.io/distroless/static-debian12 |
~2 МБ | нет | нет | статические бинарники (Go, Rust) |
cgr.dev/chainguard/static |
~2 МБ | нет | нет | то же + политика «0 известных CVE» |
scratch |
0 | нет | нет | полностью статические, без TLS-корней |
Про Alpine и musl — честно. Alpine соблазнительно мал, но использует musl вместо glibc, и это регулярно кусает:
- Python-колёса собираются под
manylinux(glibc). На Alpine pip не найдёт бинарное колесо и начнёт компилироватьnumpy/pandasиз исходников — сборка растягивается с 40 секунд до 12 минут, а образ раздувается из-за build-зависимостей. Итог: Alpine-образ Python-сервиса нередко больше debian-slim. - Аллокатор musl заметно медленнее glibc на многопоточных нагрузках; известны деградации в 2–5 раз на аллокационно-интенсивных приложениях (наиболее известный разбор — «Python on Alpine» от Martin Heinz и обсуждение в issue-трекерах CPython).
- Резолвер musl исторически иначе обрабатывал
search-домены и TCP-фолбэк DNS, что даёт странные таймауты в Kubernetes.
Практическое правило: Go и Rust — Alpine или distroless/static; Python, Ruby, Node, JVM — -slim на glibc или distroless/base.
Multi-stage для Go: эталон
# syntax=docker/dockerfile:1.9
FROM golang:1.23-bookworm AS build
WORKDIR /src
# Кэш модулей и кэш компилятора — оба через cache-mount
COPY go.mod go.sum ./
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 GOOS=linux \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/app ./cmd/api
# distroless/static: только корневые сертификаты, /etc/passwd и tzdata
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
-trimpath убирает пути сборки (детерминизм), -ldflags="-s -w" выбрасывает таблицу символов и DWARF — минус 25–30 % от бинарника. CGO_ENABLED=0 даёт статическую линковку, без которой static-debian12 не заработает.
Про Go-специфику сборки — https://courses.digitable.life/post/golang/00-overview/.
Python: где обычно теряют сотни мегабайт
# syntax=docker/dockerfile:1.9
FROM python:3.12-slim AS build
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONDONTWRITEBYTECODE=1
# Собираем в venv, чтобы потом перенести одной директорией
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Build-зависимости нужны только здесь и в финал не попадут
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
FROM python:3.12-slim AS runtime
# Только runtime-библиотека, без -dev и без компилятора
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -u 10001 -r -s /usr/sbin/nologin app
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY --chown=app:app src ./src
USER 10001
ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8000", "src.wsgi:app"]
Типичная экономия против наивного варианта: 1.1 ГБ → 190 МБ. build-essential в одиночку весит ~180 МБ.
Чем собирать: сравнение сборщиков
Docker-демон — не единственный и часто не лучший вариант, особенно если сборка идёт внутри Kubernetes-кластера, где давать доступ к /var/run/docker.sock означает раздать root на ноде.
| Инструмент | Нужен демон/root | Dockerfile | Сильная сторона | Слабая сторона |
|---|---|---|---|---|
docker build (BuildKit) |
да, демон | да | стандарт, всё работает | демон = привилегии на ноде |
docker buildx |
да (или remote driver) | да | мультиарх, распределённые билдеры, экспорт кэша | больше движущихся частей |
| Buildah / Podman | нет (rootless) | да | rootless, скриптуемая сборка без Dockerfile | экосистема беднее вокруг |
| Kaniko | нет (но в контейнере) | да | сборка внутри k8s-пода | медленнее, кэш капризный; проект малоактивен |
| Cloud Native Buildpacks | зависит | нет | нет Dockerfile вообще, авто-обновление базы | «магия», сложно отладить, образы крупнее |
ko (Go) |
нет | нет | сборка Go-образа за 2 секунды, SBOM из коробки | только Go |
| Jib (JVM) | нет | нет | слои JVM-приложения по-умному, без демона | только JVM |
| apko / Nix | нет | нет | побайтово воспроизводимые образы | высокий порог входа |
Как выбирать по масштабу. Команда до 20 человек с GitHub Actions: docker buildx в раннере, кэш в GHA или в реестре — этого хватит на годы. Компания с сотней репозиториев и собственным кластером: выделенные build-ноды с buildx remote driver (arm64-нода + amd64-нода, нативная мультиарх без эмуляции) плюс общий registry-кэш. Kaniko берут, когда политика безопасности запрещает привилегированные поды, а поднимать отдельный парк билдеров нельзя.
Отдельно про мультиархитектуру. Эмуляция через QEMU (docker/setup-qemu-action) работает, но компиляция под arm64 на amd64-хосте медленнее нативной в 5–20 раз. Для Go/Rust дешевле кросс-компиляция (GOARCH=arm64), для интерпретируемых языков — нативные раннеры.
# Мультиарх-сборка с кросс-компиляцией: одна нода, два образа, один индекс
$ docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILDPLATFORM \
--cache-from type=registry,ref=ghcr.io/acme/api:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/api:buildcache,mode=max \
--provenance=true --sbom=true \
-t ghcr.io/acme/api:1.4.2 --push .
[+] Building 74.3s (28/28) FINISHED
=> exporting manifest list sha256:9f3c1a... 1.2s
=> pushing layers 18.4s
mode=max экспортирует кэш всех промежуточных стадий, а не только финальной — для multi-stage это принципиально.
Кэш в CI: рабочий пример
Самая частая жалоба — «локально собирается 20 секунд, в CI восемь минут». Причина: раннер эфемерный, локального кэша слоёв нет. Кэш нужно экспортировать наружу.
# .github/workflows/build.yml
name: build-and-push
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
packages: write # push в GHCR
id-token: write # OIDC-токен для keyless-подписи cosign
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Генерируем теги: semver из git-тега + sha + latest только для релизов
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=sha,format=long
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
provenance: mode=max
sbom: true
# Кэш в самом реестре — переживает смерть раннера и общий для всех веток
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
# Сканируем ДО того, как образ кто-то задеплоит
- uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true # не валим сборку на CVE без патча
exit-code: "1"
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: trivy.sarif }
# Keyless-подпись: ключей нет, личность берётся из OIDC GitHub
- uses: sigstore/cosign-installer@v3
- run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
Три сорта кэша и когда какой:
| Тип | Где живёт | Плюсы | Минусы |
|---|---|---|---|
type=gha |
GitHub Actions Cache | просто, бесплатно | лимит 10 ГБ на репо, вытеснение по LRU, только GHA |
type=registry |
ваш реестр | общий для всех раннеров и платформ, mode=max |
занимает место и трафик в реестре |
type=local |
диск раннера | быстрее всего | бесполезен на эфемерных раннерах |
| self-hosted раннер с диском | локальный BuildKit | максимально быстро | нужно чистить docker system prune, иначе диск кончится |
Про устройство самих пайплайнов и матрицы сборок — https://courses.digitable.life/post/devops/01-ci-fundamentals/ и https://courses.digitable.life/post/devops/03-modern-ci-platforms/.
Реестры: где хранить и сколько это стоит
Реестр — это HTTP-сервис, реализующий OCI Distribution Spec: GET /v2/<name>/manifests/<ref>, GET /v2/<name>/blobs/<digest> и т. д. Всё остальное — UI, права, сканирование, репликация — это надстройки, и именно за них берут деньги.
| Реестр | Модель цены (порядок) | Порог входа | Эксплуатация | Когда брать |
|---|---|---|---|---|
| Docker Hub | бесплатно для public, платные планы для приватного и снятия лимитов | нулевой | нет | публичные образы, OSS |
| GHCR (GitHub) | входит в GitHub, public бесплатно | нулевой при GH Actions | нет | если CI уже в GitHub |
| GitLab Registry | входит в GitLab | нулевой при GitLab CI | нет | если CI в GitLab |
| AWS ECR | ~0.10 $ за ГБ-мес + egress | низкий (IAM) | нет | нагрузка в AWS, см. https://courses.digitable.life/post/devops/11-aws/ |
| Google Artifact Registry | ~0.10 $ за ГБ-мес + egress | низкий | нет | нагрузка в GCP |
| Azure ACR | тарифы Basic/Standard/Premium, фикс. цена + квота | низкий | нет | нагрузка в Azure, см. https://courses.digitable.life/post/devops/12-azure/ |
| Harbor (self-hosted) | только своё железо | высокий | ваша: БД, S3, апгрейды, TLS | нужны квоты, репликация, RBAC, air-gap |
| zot (self-hosted) | только своё железо | средний | ваша, но проще Harbor | минималистичный OCI-реестр |
| Nexus / Artifactory | лицензия, часто дорого | средний | ваша или SaaS | единое хранилище всех типов артефактов |
Цены — порядок величин на момент написания; смотрите актуальные прайс-листы, они меняются.
Скрытая статья расходов — egress. Хранение обычно копейки: 200 образов по 200 МБ — это 40 ГБ, около 4 $/мес. А вот трафик наружу может быть в разы дороже. Считаем: 30 сервисов × 10 деплоев в день × 20 нод, которые тянут образ по 250 МБ = 1.5 ТБ/день исходящего трафика. При 0.09 $/ГБ это ~135 $ в день, то есть 4000 $ в месяц — только за скачивание собственных образов.
Что с этим делают:
- Держат реестр в том же регионе/VPC, что и кластер, и используют VPC-эндпоинты (в AWS трафик через VPC Endpoint для ECR не тарифицируется как интернет-egress).
- Ставят pull-through cache — сквозной кэширующий прокси (ECR pull-through cache, Harbor proxy cache,
registry:2в режимеproxy). Он же лечит лимиты Docker Hub. - Уменьшают образ. 250 МБ → 60 МБ это минус 76 % счёта за трафик, без единой архитектурной правки.
- Настраивают retention. Без политики очистки реестр растёт линейно вечно: каждый коммит в main — новый образ.
// ECR lifecycle policy: хранить 10 релизных тегов, чистить sha-теги через 14 дней
{
"rules": [
{
"rulePriority": 1,
"description": "Держим последние 10 релизов",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["v"],
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
},
{
"rulePriority": 2,
"description": "Промежуточные сборки живут 14 дней",
"selection": {
"tagStatus": "any",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": { "type": "expire" }
}
]
}
Важная тонкость: удаление манифеста не освобождает место сразу — блобы удаляются сборкой мусора. В Harbor и registry:2 GC запускается отдельно и требует read-only режима (в старых версиях) — планируйте окно.
Про лимиты Docker Hub. Анонимные и бесплатные пулы ограничены по количеству за интервал, и лимиты за последние годы менялись несколько раз в сторону ужесточения. Симптом — toomanyrequests: You have reached your pull rate limit в проде посреди инцидента, когда нужно срочно перезапустить поды. Лечение — не тянуть базовые образы из Hub напрямую в рантайме: зеркалируйте нужные в свой реестр и правьте FROM на зеркало. Это пятнадцать минут работы, которые однажды спасут ночь.
Безопасность образа: слои защиты
Разберём слои по порядку.
1. Минимальная поверхность атаки
Каждый пакет в образе — потенциальный CVE. Distroless-образ Go-сервиса даёт обычно 0–2 находки сканера, тот же сервис на ubuntu:24.04 — 40–150 (в основном не эксплуатируемых, но их придётся разбирать). Отсутствие shell в distroless дополнительно ломает целый класс атак: если злоумышленник получил RCE, ему не на чем закрепиться — нет sh, curl, wget, apt.
2. Не root
RUN useradd -u 10001 -r -g app -s /usr/sbin/nologin app
USER 10001
Указывайте числовой UID, а не имя: Kubernetes runAsNonRoot проверяет именно UID из конфига образа, и с именем пользователя проверка не срабатывает. Соответствующий фрагмент манифеста пода (подробнее — в https://courses.digitable.life/post/devops/06-kubernetes/):
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
readOnlyRootFilesystem: true ломает приложения, пишущие во временные файлы, — подмонтируйте emptyDir в /tmp. Это тридцать секунд работы и очень серьёзное усиление защиты.
3. Сканирование
# Trivy: уязвимости ОС и языковых зависимостей + секреты + мисконфиги
$ trivy image --severity HIGH,CRITICAL --ignore-unfixed ghcr.io/acme/api:1.4.2
ghcr.io/acme/api:1.4.2 (debian 12.7)
Total: 2 (HIGH: 2, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬───────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Ver │ Fixed Ver │
├──────────────┼────────────────┼──────────┼───────────────┼───────────────┤
│ libssl3 │ CVE-2024-XXXXX │ HIGH │ 3.0.14-1 │ 3.0.15-1 │
└──────────────┴────────────────┴──────────┴───────────────┴───────────────┘
Практика, которая работает: --ignore-unfixed и блокировка только на CRITICAL с доступным фиксом. Если валить сборку на всём подряд, команда за неделю добавит || true и сканер перестанет что-либо значить. Отдельно ведите .trivyignore с обязательным сроком истечения и комментарием «почему».
Альтернативы: Grype (Anchore), Snyk, Docker Scout, встроенное сканирование ECR/ACR/Harbor. Все они читают одни и те же базы (NVD, GitHub Advisory, дистрибутивные), различия — в качестве матчинга и удобстве.
4. SBOM и происхождение
SBOM (Software Bill of Materials) — машиночитаемый список всего, что попало в образ. Нужен, когда завтра выйдет очередной Log4Shell и нужно за десять минут ответить «а где у нас эта библиотека».
# Сгенерировать SBOM в формате SPDX и прикрепить к образу как аттестацию
$ syft ghcr.io/acme/api:1.4.2 -o spdx-json > sbom.spdx.json
$ cosign attest --predicate sbom.spdx.json --type spdxjson \
ghcr.io/acme/api@sha256:9f3c1a...
# Найти все образы, где есть уязвимая версия
$ grype sbom:./sbom.spdx.json --only-fixed
Provenance по SLSA отвечает на другой вопрос — «кто и из какого коммита это собрал». BuildKit генерирует её флагом --provenance=mode=max, и она включает git-ref, digest исходников, параметры сборки.
5. Подпись и проверка на входе
Подпись без проверки бессмысленна. Полная цепочка: CI подписывает → admission controller в кластере проверяет и не пускает неподписанное. Пример политики Kyverno:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: check-cosign-signature
match:
any:
- resources: { kinds: [Pod] }
verifyImages:
- imageReferences: ["ghcr.io/acme/*"]
attestors:
- entries:
- keyless:
# Только образы, собранные ЭТИМ workflow в ЭТОМ репозитории
subject: "https://github.com/acme/api/.github/workflows/build.yml@refs/tags/*"
issuer: "https://token.actions.githubusercontent.com"
Побочный эффект, который часто и есть главная ценность: mutateDigest: true в Kyverno заменяет теги на дайджесты, и вы автоматически получаете иммутабельные деплои.
Жизненный цикл образа
Два момента из этой схемы, которые часто нарушают.
Promote, а не rebuild. Если для staging и prod вы собираете образ заново, вы тестировали не то, что задеплоили: изменились версии пакетов в базе, timestamp’ы, транзитивные зависимости. Собирайте один раз, дальше двигайте дайджест по средам. Это же условие воспроизводимого отката.
Пересканирование. Образ, чистый в момент сборки, через месяц может иметь три CRITICAL: изменился не образ, а база уязвимостей. Поэтому реестр должен пересканировать хранящиеся образы по расписанию, а на базовые образы нужен автоматический бамп (Renovate, Dependabot) с пересборкой.
Типичные ошибки
latestв проде. Непонятно, что развёрнуто;imagePullPolicy: Alwaysбьёт по лимитам реестра; откат невозможен.- Секрет через
ARG. Остаётся вconfig.historyнавсегда. Проверяется командойcrane config <image> | grep -i token. ADDвместоCOPY.ADDумеет скачивать по URL и автоматически распаковывать архивы — сюрпризы в сборке и лишний вектор атаки. ИспользуйтеCOPY, а для скачивания — явныйRUN curlс проверкой контрольной суммы.apt-get updateотдельным слоем. Кэшируется и устаревает;update,installи очистка/var/lib/apt/listsдолжны быть однимRUN.- Приложение как PID 1 без обработки сигналов. Контейнер не реагирует на SIGTERM, Kubernetes ждёт
terminationGracePeriodSecondsи убивает SIGKILL — обрываются активные запросы. Лечится exec-формойCMDиtini/--init, либо обработкой сигнала в коде. - Shell-форма
CMD.CMD node index.jsзапускает/bin/sh -c, который сигналы дочернему процессу не пробрасывает. Всегда exec-форма:CMD ["node", "index.js"]. - Конфигурация вшита в образ. Один образ должен работать во всех средах, различаясь только переменными окружения и смонтированными секретами. Иначе
promoteневозможен. - Логи в файл внутри контейнера. Пишите в stdout/stderr — этого ждёт вся экосистема сбора логов (https://courses.digitable.life/post/devops/16-observability-and-oncall/).
VOLUMEв Dockerfile. Создаёт анонимные тома, которые накапливаются и мешают в Kubernetes. Объявляйте тома в манифесте пода.- Отсутствие
HEALTHCHECK/проб. В Kubernetes используйтеreadinessProbe/livenessProbe, они полезнее докеровскогоHEALTHCHECK. - Гигантский build-контекст. Забытый
.dockerignore— и.gitна 800 МБ отправляется демону перед каждой сборкой.
Практика продакшена: чек-лист
- Multi-stage, финальная стадия без компиляторов и пакетных менеджеров
- Базовый образ — distroless/slim, зафиксирован по дайджесту, а не по тегу
-
.dockerignoreесть и покрывает.git, зависимости, артефакты - Кэш-маунты BuildKit для пакетного менеджера
- Экспорт кэша в реестр (
mode=max) для CI -
USERс числовым UID ≥ 10000,readOnlyRootFilesystemв манифесте - Секреты — только
--mount=type=secret, никогдаARG/COPY - OCI-лейблы:
org.opencontainers.image.source,.revision,.created - Сканирование в CI с блокировкой на CRITICAL+fixed, пересканирование по расписанию
- SBOM и provenance генерируются и прикрепляются к дайджесту
-
cosign signв CI + admission-политика, проверяющая подпись - Деплой по дайджесту, promote одного артефакта между средами
- Lifecycle-политика в реестре и мониторинг его размера
- Зеркала базовых образов, чтобы не зависеть от лимитов внешних хабов
- Автообновление базовых образов (Renovate) с автоматической пересборкой
Полезный минимум OCI-лейблов, который потом окупается при расследованиях:
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.source="https://github.com/acme/api" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.licenses="Apache-2.0"
GHCR по image.source автоматически связывает пакет с репозиторием — мелочь, но экономит время каждому, кто найдёт образ и захочет понять, откуда он.
Мини-итог
- Образ — контентно-адресуемый граф слоёв; тег изменяем, дайджест — нет. В проде живите дайджестами.
- Скорость сборки — это на 90 % попадание в кэш: порядок инструкций плюс
--mount=type=cacheи экспорт кэша в реестр. - Размер образа платится трафиком, временем старта пода и объёмом отчёта сканера. Multi-stage + distroless дают порядок величины.
- Alpine хорош для статических бинарников и плох для Python/JVM — musl не бесплатен.
- Реестр стоит не столько за хранение, сколько за egress: считайте трафик, ставьте pull-through cache, настраивайте retention.
- Безопасность — цепочка: минимальная база → non-root → сканирование → SBOM → подпись → проверка подписи на входе в кластер. Любое разорванное звено обнуляет остальные.
Источники
- OCI Image Specification и Distribution Specification — первоисточник по формату и протоколу.
- Docker: Best practices for building images — официальный чек-лист.
- BuildKit / Dockerfile reference —
--mount,COPY --link, heredoc-синтаксис. - Google distroless — минимальные базовые образы.
- Chainguard Images / Wolfi — дистрибутив, спроектированный под «0 CVE».
- Sigstore / cosign — keyless-подписи и прозрачный лог Rekor.
- SLSA framework — уровни зрелости цепочки поставок.
- Trivy, Grype, Syft — сканирование и SBOM.
- NIST SP 800-190, Application Container Security Guide — систематизация рисков контейнеров.
- Kubernetes: Pod Security Standards — какие требования к образу проверяются на входе.
Что дальше
Образ собран, подписан и лежит в реестре. Теперь его нужно где-то запустить, дать сеть, диски, автоскейлинг и пережить падение половины нод. Об этом — следующая статья: Kubernetes: архитектура, объекты, сеть, хранилища, автоскейлинг и эксплуатация.