Container images: Dockerfile, multi-stage, OCI
Pod в Kubernetes ссылается на образ строкой image: nginx:1.27 — за этой простой строкой стоит целая инфраструктура: формат хранения (OCI image spec), реестр (registry), теги, дайджесты, layers, build-инструменты. CKAD не требует от вас глубоких знаний Docker BuildKit или buildx, но требует понимания: как образ собирается, как переопределить ENTRYPOINT из Pod-spec, чем latest опасен в продакшене и зачем нужны multi-stage builds. Этот урок — про image side контракта между Dockerfile и Kubernetes.
Multi-stage builds: builder + runtime image
Dockerfile: инструкции в CKAD scope
Dockerfile — это текстовый файл с инструкциями, как собрать образ. Каждая инструкция создаёт новый layer (слой). Минимальный набор, который нужно знать:
# базовый образ — родитель
FROM node:20-alpine
# рабочая директория внутри контейнера, создастся если нет
WORKDIR /app
# build-time переменная (можно переопределить --build-arg)
ARG NODE_ENV=production
# environment variable — будет в контейнере при запуске
ENV NODE_ENV=${NODE_ENV} \
PORT=8080
# копируем файлы из build context
COPY package*.json ./
# выполняем команду, результат сохраняется в новый layer
RUN npm ci --only=production
# копируем остальной код
COPY . .
# документация: указываем порт (НЕ открывает порт, см. ниже)
EXPOSE 8080
# непривилегированный пользователь — рекомендуется
USER 1000
# entrypoint: фиксированная команда
ENTRYPOINT ["node"]
# cmd: дефолтные аргументы для ENTRYPOINT
CMD ["server.js"]
Запомните различия:
| Инструкция | Что делает |
|---|---|
FROM | Указывает родительский образ. Должен быть первым (или после ARG). |
RUN | Выполняет команду на момент build. Результат — новый layer. |
COPY | Копирует файлы из build context в образ. Создаёт layer. |
ADD | Как COPY, но умеет распаковывать .tar и качать по URL. Best practice — использовать COPY, если не нужно extra. |
WORKDIR | Меняет cwd для последующих инструкций и финального процесса. |
ENV | Задаёт переменную окружения (доступна в RUN и в финальном процессе). |
ARG | Build-time переменная. В runtime недоступна, если не сохранена через ENV. |
EXPOSE | Документация о портах. Не публикует и не открывает. |
ENTRYPOINT | Команда, которая запускается. Фиксированная. |
CMD | Дефолтные аргументы для ENTRYPOINT. Легко переопределяемые. |
USER | UID/GID, под которым запускается процесс. |
VOLUME | Объявляет точку монтирования. В K8s обычно игнорируется в пользу spec.volumes. |
ENTRYPOINT vs CMD, command vs args
Это любимая путаница на собеседованиях. Правило:
ENTRYPOINT— фиксированная часть команды (исполняемый файл).CMD— дефолтные аргументы, которые легко переопределяются.
При запуске Docker берёт ENTRYPOINT + CMD и склеивает: получается одна командная строка.
ENTRYPOINT ["python"]
CMD ["app.py"]
→ контейнер запускает python app.py. При docker run myimage script.py получится python script.py (CMD заменён). При docker run --entrypoint sh myimage ENTRYPOINT тоже меняется.
В Kubernetes Pod-spec эти поля называются по-другому:
Pod, который запускает python script.py --verbose поверх образа с ENTRYPOINT ["python"]:
spec:
containers:
- name: app
image: myapp:1.0
args: ["script.py", "--verbose"] # переопределяет только CMD
Если хотим запустить полностью другую команду:
spec:
containers:
- name: app
image: myapp:1.0
command: ["/bin/sh"] # переопределяет ENTRYPOINT
args: ["-c", "echo hello; sleep 1000"] # и CMD
exec form vs shell form
Инструкции RUN, CMD, ENTRYPOINT поддерживают две формы:
exec form (JSON array):
ENTRYPOINT ["nginx", "-g", "daemon off;"]
shell form (строка):
ENTRYPOINT nginx -g 'daemon off;'
Разница огромная. Shell form под капотом превращается в /bin/sh -c "nginx -g 'daemon off;'". Это означает:
- PID 1 в контейнере — это /bin/sh, а не nginx.
- SIGTERM от kubelet идёт в sh, а не в nginx. sh не пробрасывает сигнал дочернему процессу.
- При
kubectl delete podконтейнер получит SIGTERM, sh его проигнорирует, через 30 секунд kubelet пошлёт SIGKILL → 137. Никакого graceful shutdown.
Используйте exec form (JSON array) для ENTRYPOINT и CMD, чтобы PID 1 был вашим процессом и получал сигналы напрямую. Shell form — частая причина «графsful shutdown не работает», особенно в legacy образах.
# хорошо
ENTRYPOINT ["node", "server.js"]
# плохо: PID 1 = sh, signals пропадут
ENTRYPOINT node server.js
Альтернатива — tini или dumb-init как PID 1, они корректно forward-ят сигналы:
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Multi-stage builds
Прямой Dockerfile для приложения с компилятором получится огромным: build tools, dev dependencies, исходники остаются в финальном образе. Решение — multi-stage build: несколько FROM в одном Dockerfile, каждый — отдельный stage. Финальный образ копирует только нужные артефакты из build stage.
# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # создаёт /app/dist
# Stage 2: runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package*.json ./
USER 1000
ENTRYPOINT ["node", "dist/server.js"]
Что получаем:
- Финальный образ — это
node:20-alpine(~120 MB) +distи productionnode_modules. Безnpm,git, build-tools. COPY --from=buildберёт файлы из stagebuild, ничего из его слоёв не утекает в финальный.- Можно делать ещё уже: использовать
distrolessилиscratchдля финального stage.
# Финал на distroless: ни shell, ни package manager, минимум surface
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
WORKDIR /app
USER nonroot
ENTRYPOINT ["dist/server.js"]
Multi-stage даёт две выгоды: (1) меньший образ — быстрее pull, меньше storage в registry; (2) меньшая attack surface — нет компилятора, shell, sudo. Для production-сервисов distroless и multi-stage — золотой стандарт.
Layer cache
Каждая инструкция Dockerfile создаёт layer (read-only). Docker и BuildKit кэшируют слои по хэшу инструкции и контента: если инструкция и input не изменились — слой переиспользуется. Это даёт быстрые re-builds.
Порядок инструкций важен. Худший вариант:
FROM node:20
COPY . . # любое изменение в коде → invalidate cache ниже
RUN npm ci # будет переустанавливать npm packages каждый билд
Правильно — копировать сначала dependency manifest, ставить зависимости, потом копировать код:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci # этот слой кэшируется, пока package.json не изменится
COPY . .
RUN npm run build
OCI image format
«Docker image» исторически — но с 2017 стандартом стал OCI (Open Container Initiative). containerd, CRI-O, podman, kubelet — все работают с OCI образами. Docker — лишь один из инструментов сборки.
OCI image — это:
Registry (Docker Hub, GHCR, ECR, GCR, Harbor, registry.k8s.io) — это HTTP-сервер с API для push/pull OCI-артефактов. Когда kubelet делает pull, он:
- Резолвит имя
registry.example.com/team/app:1.0→ URL. - Качает manifest по тегу или digest.
- Параллельно качает layers по их digest (с проверкой sha256).
- Распаковывает в content store containerd.
- На запуске контейнера overlayfs стакает layers в rootfs.
Tags и digest
nginx:1.27 — это tag. Тэги в OCI mutable — владелец registry может переписать 1.27 так, чтобы он указывал на другой digest. latest — самый известный анти-паттерн: указывает на «последний пуш», содержимое меняется без вашего ведома.
nginx@sha256:abc123...def — это digest. Digest — это sha256 от manifest, immutable по определению: если digest указывает на этот образ — это всегда тот же самый набор байт.
| Способ ссылки | Пример | Стабильность |
|---|---|---|
Просто имя (= :latest) | nginx | Опасно: latest меняется |
| Major tag | nginx:1 | Изменится с выходом 1.28, 1.29 |
| Minor tag | nginx:1.27 | Изменится с выходом 1.27.1 |
| Patch tag | nginx:1.27.3 | Обычно стабилен, но технически mutable |
| Digest | nginx@sha256:... | Полностью immutable |
latest — anti-pattern в продакшене. Сегодня pull скачивает версию X, завтра pull скачивает Y (новый push в registry), и ваш Pod внезапно начал работать по-другому. Используйте конкретные теги (:1.27.3) или, для критичных production-нагрузок, digest pinning (@sha256:...).
Killer момент: digest pinning в production
Для compliance-критичных систем (банки, healthcare, supply-chain security) идеал — image:tag@digest:
containers:
- name: app
image: registry.example.com/team/app:1.0.3@sha256:abc123...def
Это даёт:
- Человекочитаемую версию (тег);
- Криптографическую гарантию неизменности (digest);
- Защиту от подмены registry или взлома namespace репозитория;
- Возможность detection: если registry начнёт отдавать другой манифест по тегу — kubelet откажется деплоить.
Tools вроде crane, cosign, kustomize edit set image умеют автоматически разрешать tag в digest на момент CI.