CI/CD, инфраструктура и облака Контейнеры в доставке: Docker, сборка образов, реестры, безопасность и размер
0%

Контейнеры в доставке: Docker, сборка образов, реестры, безопасность и размер

Контейнеры в доставке: 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-дайджестом своего содержимого.

Что здесь важно на практике:

  1. Слой — это tar-архив с diff файловой системы, не «команда». Удаление файла записывается как whiteout-файл (.wh.<имя>). Отсюда классическая ошибка: RUN rm secret.pem в отдельном слое не удаляет секрет — он остаётся в предыдущем слое и извлекается командой docker save за десять секунд.
  2. Тег — изменяемый указатель, дайджест — нет. myapp:1.4.2 сегодня и через месяц могут быть разными образами. В проде разворачивайте по дайджесту (myapp@sha256:...), а тег держите как человекочитаемую метку.
  3. Слои дедуплицируются глобально. Если 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

Сборка: кэш решает всё

Скорость сборки определяется почти исключительно попаданием в кэш слоёв. Правило одно: инструкции упорядочены от редко меняющихся к часто меняющимся.

Порядок инструкций в Dockerfile и инвалидация кэша слоёв

Кэш 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, который придётся объяснять в отчёте сканера.

Размер образа Go-сервиса при разных базах

Выбор базового образа

База Размер (сжат.) Пакетный менеджер 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 $ в месяц — только за скачивание собственных образов.

Что с этим делают:

  1. Держат реестр в том же регионе/VPC, что и кластер, и используют VPC-эндпоинты (в AWS трафик через VPC Endpoint для ECR не тарифицируется как интернет-egress).
  2. Ставят pull-through cache — сквозной кэширующий прокси (ECR pull-through cache, Harbor proxy cache, registry:2 в режиме proxy). Он же лечит лимиты Docker Hub.
  3. Уменьшают образ. 250 МБ → 60 МБ это минус 76 % счёта за трафик, без единой архитектурной правки.
  4. Настраивают 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) с пересборкой.

Типичные ошибки

  1. latest в проде. Непонятно, что развёрнуто; imagePullPolicy: Always бьёт по лимитам реестра; откат невозможен.
  2. Секрет через ARG. Остаётся в config.history навсегда. Проверяется командой crane config <image> | grep -i token.
  3. ADD вместо COPY. ADD умеет скачивать по URL и автоматически распаковывать архивы — сюрпризы в сборке и лишний вектор атаки. Используйте COPY, а для скачивания — явный RUN curl с проверкой контрольной суммы.
  4. apt-get update отдельным слоем. Кэшируется и устаревает; update, install и очистка /var/lib/apt/lists должны быть одним RUN.
  5. Приложение как PID 1 без обработки сигналов. Контейнер не реагирует на SIGTERM, Kubernetes ждёт terminationGracePeriodSeconds и убивает SIGKILL — обрываются активные запросы. Лечится exec-формой CMD и tini/--init, либо обработкой сигнала в коде.
  6. Shell-форма CMD. CMD node index.js запускает /bin/sh -c, который сигналы дочернему процессу не пробрасывает. Всегда exec-форма: CMD ["node", "index.js"].
  7. Конфигурация вшита в образ. Один образ должен работать во всех средах, различаясь только переменными окружения и смонтированными секретами. Иначе promote невозможен.
  8. Логи в файл внутри контейнера. Пишите в stdout/stderr — этого ждёт вся экосистема сбора логов (https://courses.digitable.life/post/devops/16-observability-and-oncall/).
  9. VOLUME в Dockerfile. Создаёт анонимные тома, которые накапливаются и мешают в Kubernetes. Объявляйте тома в манифесте пода.
  10. Отсутствие HEALTHCHECK/проб. В Kubernetes используйте readinessProbe/livenessProbe, они полезнее докеровского HEALTHCHECK.
  11. Гигантский 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 → подпись → проверка подписи на входе в кластер. Любое разорванное звено обнуляет остальные.

Источники

Что дальше

Образ собран, подписан и лежит в реестре. Теперь его нужно где-то запустить, дать сеть, диски, автоскейлинг и пережить падение половины нод. Об этом — следующая статья: Kubernetes: архитектура, объекты, сеть, хранилища, автоскейлинг и эксплуатация.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов