Docker-основы: образы, слои, Dockerfile

Уровень: beginner ~55 мин Практика: Vikunja (AGPL-3.0)

Что нужно знать перед уроком

  • M1.02 — Deployment и ReplicaSet — нужно понимать, что такое container image как единица деплоя в Pod

Что нужно знать перед уроком

M1 урок 1 — Pod запускает контейнеры из готовых образов; там мы использовали чужой готовый образ excalidraw/excalidraw. В этом уроке разбираемся, как такой образ появляется вообще — фундамент, без которого непонятен весь дальнейший werf.yaml (M3 уроки 2–7).

Теория

Образ — не файл, а слоёная файловая система

Container image — это не единый архив, а последовательность слоёв (layers), каждый — результат одной инструкции Dockerfile, наложенных друг на друга через union-файловую систему (overlayfs в современном containerd/Docker). Каждый слой — read-only после сборки; при запуске контейнера сверху добавляется единственный read-write слой (thin writable layer), в который пишутся все runtime-изменения. Именно поэтому:

  • удаление файла в новом слое не уменьшает физический размер образа — файл просто помечается «удалённым» (whiteout-файл), нижний слой остаётся на диске;
  • одинаковые слои между разными образами (например, общий базовый ubuntu:24.04) физически хранятся на диске один раз — это называется copy-on-write и лежит в основе экономии дискового пространства при множестве образов на одном узле;
  • порядок инструкций в Dockerfile определяет порядок слоёв и, соответственно, эффективность кэша слоёв: Docker/containerd переиспользует уже собранный слой, если инструкция и всё, от чего она зависит (предыдущие слои + контекст, влияющий именно на эту инструкцию), не изменились.

Базовый синтаксис Dockerfile

1
2
3
4
5
6
7
8
FROM node:24-alpine          # базовый слой — от какого образа наследуемся
WORKDIR /app                  # рабочая директория для всех следующих инструкций
COPY package*.json /app/      # копируем только манифесты зависимостей — для кэша!
RUN npm ci                    # установка зависимостей — свой слой, кэшируется отдельно
COPY . .                      # весь остальной код — после RUN npm ci, иначе кэш ломался бы при каждом изменении кода
CMD ["node", "server.js"]     # команда по умолчанию при старте контейнера
ENTRYPOINT ["node"]           # альтернатива/дополнение к CMD — неизменяемая часть команды запуска
EXPOSE 3000                   # документирует порт (не публикует его — это делает `docker run -p`/K8s Service)

Порядок COPY package*.jsonRUN npm ciCOPY . . — не случайность, а классический паттерн максимизации кэша: файлы зависимостей (package.json/package-lock.json) меняются гораздо реже исходного кода, поэтому дорогая операция установки зависимостей (npm ci) кэшируется отдельно от частых изменений кода. Если бы COPY . . стоял раньше RUN npm ci, любое изменение любого файла кода инвалидировало бы кэш зависимостей — пересборка занимала бы минуты вместо секунд.

CMD vs ENTRYPOINT: ENTRYPOINT — неизменяемая (без переопределения при запуске) часть команды, CMD — аргументы по умолчанию к ней, которые легко переопределить (docker run image другая-команда заменит CMD, но не ENTRYPOINT). Частый паттерн: ENTRYPOINT ["node"] + CMD ["server.js"] — по умолчанию запускается node server.js, но docker run image debug.js запустит node debug.js без переопределения самого интерпретатора.

Multi-stage builds — сборка и финальный образ разделены

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Стадия 1: сборка (образ с компилятором, весит сотни МБ)
FROM golang:1.26 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server .

# Стадия 2: финальный образ (минимальный, без компилятора)
FROM alpine:3.22
COPY --from=builder /app/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]

FROM ... AS builder именует промежуточную стадию; COPY --from=builder копирует только нужный артефакт (скомпилированный бинарник) в финальный образ, не копируя весь тяжёлый инструментарий сборки (компилятор Go, кэш модулей). Итоговый образ содержит только бинарник и минимальный рантайм — этот же принцип лежит в основе паттерна Stapel-импорта из M3 урока 5 и продакшен-минимизации через distroless из M8 урока 6.

docker build/docker run вручную — что происходит «под капотом» werf

Хотя весь курс использует werf как оболочку над сборкой, важно понимать, что делает сама Docker/containerd-инфраструктура под капотом:

1
2
3
4
docker build -t myapp:latest .        # читает Dockerfile из текущей директории (context), собирает слои
docker run -d -p 8080:80 myapp:latest # создаёт контейнер, добавляет writable-слой, публикует порт
docker images                          # список локальных образов и их размеров
docker history myapp:latest            # список слоёв конкретного образа с размером каждого

werf (со стартового M0 урока 1) — не замена Docker/containerd, а надстройка с двумя билдерами (Dockerfile-builder и Stapel-builder, разбираем в M3 уроке 2), решающая задачи, которые голый docker build не решает из коробки: детерминированное тегирование по содержимому, giterminism, встроенная очистка registry, линковка нескольких образов в одном файле конфигурации.

Частые ошибки и подводные камни

  • Ставить COPY . . раньше RUN npm ci/RUN go mod download. Инвалидирует кэш зависимостей при каждом изменении любого файла кода — самая частая причина «почему у меня сборка так долго идёт».
  • Путать EXPOSE с публикацией порта. EXPOSE — это только документация/метаданные образа, реальная публикация происходит через docker run -p/K8s Service (M1 урок 3), без этого флага/ресурса EXPOSE не открывает никакого порта наружу.
  • Не использовать multi-stage и тащить в продакшен-образ весь инструментарий сборки. Финальный образ раздувается на сотни МБ компилятором/кэшем модулей, которые физически не нужны в рантайме и увеличивают площадь атаки.

Linux / Сети / Docker

Слои образа физически хранятся как обычные tar-архивы (плюс манифест config.json с метаданными) в специальном локальном хранилище (/var/lib/docker/overlay2/ для Docker, аналогично для containerd). Каждый слой идентифицируется хэшем своего содержимого (content-addressable storage) — именно на этом принципе позже будет построено content-based тегирование werf (M8 урок 3): тег образа werf вычисляется от хэша итогового содержимого, а не назначается вручную.

1
docker inspect myapp:latest --format='{{json .RootFS.Layers}}' | jq

Практика в кластере

Практики в кластере в этом уроке нет — материал полностью локальный (сборка на своей машине), первый деплой собранного образа Vikunja в кластер — с M3 урока 2 (после появления werf.yaml).

Практика разработки

🧩 Практика урока: Vikunja · лицензия AGPL-3.0

Vikunja — self-hosted менеджер задач с Go-бэкендом (компилируемый бинарник) и Vue-фронтендом (статические файлы после сборки) — двухкомпонентная структура делает его удобным сквозным примером для всего модуля M3 (несколько образов в одном werf.yaml, линковка между ними).

  1. Клонируйте репозиторий и посмотрите на структуру:
1
2
3
4
git clone --depth 1 https://github.com/go-vikunja/vikunja.git
cd vikunja
ls          # api/ (Go-бэкенд) и frontend/ (Vue)
cat api/Dockerfile 2>/dev/null || echo "у Vikunja сборка сложнее одного Dockerfile — разберём через werf в следующих уроках"
  1. Соберите минимальный пробный multi-stage Dockerfile для Go-бэкенда самостоятельно (упражнение, не финальное решение — финальное появится в M3 уроке 3–4):
1
2
3
4
5
6
7
8
FROM golang:1.26 AS builder
WORKDIR /src
COPY api/ .
RUN go build -o /app/vikunja .

FROM alpine:3.22
COPY --from=builder /app/vikunja /usr/local/bin/vikunja
ENTRYPOINT ["/usr/local/bin/vikunja"]
1
2
3
docker build -f Dockerfile.practice -t vikunja-api-practice:local .
docker images | grep vikunja-api-practice
docker history vikunja-api-practice:local
  1. Убедитесь, что размер финального образа заметно меньше, чем если бы вы оставили промежуточную стадию golang:1.26 как единственный FROM.

Шпаргалка команд урока

1
2
3
4
5
docker build -t <name>:<tag> [-f <dockerfile>] <context>
docker run [-d] [-p <host>:<container>] <image>
docker images
docker history <image>
docker inspect <image>

Вопросы для самопроверки

Почему удаление файла в новом слое Dockerfile не уменьшает размер итогового образа?

Слои — read-only после сборки; удаление в новом слое — это whiteout-маркер поверх, а не реальное удаление данных из нижних слоёв.

Источник: Рекомендуемая литература

Почему рекомендуется копировать package.json и запускать npm ci ДО копирования всего остального кода?

Порядок инструкций определяет порядок слоёв и, соответственно, границы кэша — разделение зависимостей и кода по разным слоям максимизирует переиспользование кэша.

Источник: Docker — документация

В чём разница между CMD и ENTRYPOINT?

ENTRYPOINT задаёт неизменяемый исполняемый файл, CMD — аргументы по умолчанию к нему, которые docker run легко переопределяет без изменения ENTRYPOINT.

Источник: Docker — документация

Зачем нужен multi-stage build (FROM ... AS builder + COPY --from=builder)?

Multi-stage разделяет сборочную среду и финальный рантайм-образ, оставляя в итоговом образе только необходимые артефакты — тот же принцип, что лежит в основе Stapel-импорта и distroless.

Источник: Docker — документация

Что делает инструкция EXPOSE в Dockerfile?

EXPOSE не публикует порт сам по себе — нужен явный -p при docker run или Service в Kubernetes; это чисто декларативная метка в образе.

Источник: Docker — документация

Рекомендуемая литература

Официальная документация

Статьи и блоги

Книги

  • Nigel Poulton. Docker Deep Dive. 2023. ISBN 978-1916585206.

Связанные материалы