🐳 Best Practices по Docker в 2025 году: Полный гид

1. Фундамент: Выбор базового образа

Первая команда FROM в Dockerfile определяет всё. Использование «толстых» образов (например, полной Ubuntu) добавляет сотни мегабайт мусора (утилиты типа vim, wget, systemd), которые занимают место, добавляют уязвимостей в контейнер и излишне нагружают систему.

Варианты решения:

  • Образы *-alpine (~5 МБ): Минималистичны, используют для компиляции библиотеку musl libc вместо стандартной glibc.
    • Нюансы: Приложения на Python или C++ могут работать медленнее или требовать пересборки зависимостей.
  • Slim-версии образов: Основаны на Debian, но без документации и лишних пакетов. Содержат привычную glibc, что делает их более совместимыми.
  • Distroless (от Google): Содержат только рантайм языка и приложение. Нет даже шелла (sh/bash) и менеджера пакетов.
    • Плюс: Хакеры не смогут выполнить ни одной команды в случае взлома.
    • Минус: Крайне сложно дебажить, т.к. у нас тоже нет доступа в shell (docker exec не сработает).

Вывод

Выбор между Alpine и Slim часто зависит от того, насколько ваше приложение завязано на системные вызовы. Если проект сложный и использует много нативных библиотек, Slim-версии сэкономят вам много часов на отладку проблем с musl.

2. Не использовать latest в production

Использование тега :latest в продакшене — это риск. Обновление базового образа может сломать сборку в любой момент.

3. Оптимизация контекста: .dockerignore

Docker Build отправляет всё содержимое текущей папки в docker. Папки типа .git, node_modules или локальные логи будут замедлять сборку и создавать риски безопасности. Поэтому всегда используйте файл .dockerignore, работает он по аналогии с .gitignore.

4. Эффективное кэширование слоев

Docker-образ состоит из слоев. Если один слой изменился, все последующие пересобираются с нуля. Правило: Отделяйте установку зависимостей от копирования кода.

  • Сначала: Копируем файлы манифестов (package.json, requirements.txt) и запускаем установку.
  • Затем: Копируем остальной код.
  • Результат: При изменении одной строки кода в любом из файлов(кроме файлов манифеста) зависимости возьмутся из кэша за секунды.

Пример:

# Так делать нужно!
 
# Слой 1, скопировали файлы манифеста
COPY pyproject.toml uv.lock* /app/
# Слой 2, установили зависимости
RUN uv sync
# Слой 3, скопировали остальные файлы
COPY . /app
 
# Как итог, докер возьмет слой 1 и 2 из кеша
# если файлы pyproject.toml uv.lock* не менялись
# После чего скопирует остальные файлы проекта
# Так делать НЕ нужно!
 
# Слой 1, скопировали все файлы
COPY . /app
# Слой 2, установили зависимости
RUN uv sync
 
# Как итог, докер будет пересобирать зависимости
# при изменении в любом из файлов проекта,
# вместо того, чтобы взять их из кеша

Дополнение:

Если включен buildkit мы можем эффективнее работать с зависимостями, не скачивая все пакеты заново, даже если изменился например requirements.txt и просто догрузить новые.

# Для pip
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
    
# Для uv
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project
    
# Для npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci

5. Атомарные слои и уменьшение веса

Каждая команда RUN создает новый неизменяемый слой.

Ошибка: Установка пакета в одной команде RUN и удаление мусора в другой не уменьшит размер образа — мусор останется в промежуточном слое.

Решение: Объединяйте связанные команды (обновление, установка, очистка) в один слой через && и используйте обратные слэши \ для переноса строк.

Пример:

# Так делать нужно, сразу установили нужные пакеты и очистили мусор.
# Как итог мусор не остался ни на одном из слоев.
RUN apt-get update \
	&& apt-get install -y --no-install-recommends \
		curl \
		ca-certificates \
		gcc \
		libpq-dev \
		postgresql-client \
	&& rm -rf /var/lib/apt/lists/*
# Так делать НЕ нужно
 
# Слой 1
RUN apt-get update 
# Слой 2
RUN apt-get install -y --no-install-recommends \
		curl \
		ca-certificates \
		gcc \
		libpq-dev \
		postgresql-client
# Весь мусор который остался после выполнения 1 и 2 шага
# Удаляется в слое 3, но всеравно занимает место в докере, в слое 1 и слое 2
 
# Слой 3
RUN rm -rf /var/lib/apt/lists/*

6. COPY против ADD

  • ADD — антипаттерн: Он непредсказуем (может распаковать архив или скачать файл по URL) и плохо работает с кэшем.
  • COPY: Простая и понятная команда для локальных файлов.

Исключение

Используйте ADD только для распаковки локальных архивов. Для скачивания файлов лучше использовать RUN curl или wget в рамках одного слоя с последующим удалением архива.

7. Multi-stage Builds - Многоэтапная сборка

Для компилируемых языков (Go, Rust, Java) инструменты сборки (компиляторы, SDK) не нужны в рантайме.

  • Механика: В одном Dockerfile описывается этап сборки (FROM … AS builder) и этап финального образа. Готовый бинарник копируется из первого образа во второй, а гигабайты мусора отбрасываются.
  • Результат: Образ весом в 15 МБ вместо 1 ГБ.

Зачем? Если мы берем образ под условный golang, то в куче с ним идут: SDK, компилятор, мененджер зависимостей, заголовочные файлы… Это все не пригодится в production версии, на проде нужен лишь итоговый скомпилированный файл. При переходе на новый этап, docker “забывает” все что связанно с контейнером предыдущего. После чего в новый этап просто копирует нужные файлы из предыдущего шага. А в результате, у нас вместо тяжелой системы, со всем мусором, работает крошечный образ, на котором только наша скомпилированная программа. И так же при таком подходе итоговый образ получаемый из Dockerfile весит не почти 1ГБ, а условно 10-15мб, около 5мб alpine и еще 10 наша программа.

На выходе получаем: более легкий, более быстрый и более безопасный образ.

Пример:

# Так делать нужно.
 
# --- ЭТАП 1: Сборка (Builder) ---
 
# Дали имя этапа "builder", чтобы можно было на него ссылаться
FROM golang:1.21-alpine AS builder
...
RUN go build -o myapp main.go
 
# --- ЭТАП 2: Запуск (Production) ---
 
# Новый FROM все обнуляет, мы начинаем с чистого листа.
FROM alpine:latest
WORKDIR /app
# Копируем скомпилированную версию не из локальной машины, а из этапа builder
COPY --from=builder /app/myapp
# Запускаем
CMD ["./myapp"]
# Так делать НЕ нужно.
 
# --- ЭТАП 1: Сборка (Builder) ---
 
# Дали имя этапа "builder", чтобы можно было на него ссылаться
FROM golang:1.21-alpine AS builder
...
RUN go build -o myapp main.go
# Запускаем
CMD ["./myapp"]

8. Использование BuildKit

BuildKit — современная версия движка сборки, который строит граф зависимостей и может запускать независимые этапы (например, сборку фронтенда и бэкенда) параллельно.

  • Поддерживает mount cache для кэширования папок между сборками.
  • В старых системах (CI/CD) включается переменной DOCKER_BUILDKIT=1.

Если версия docker desktop/docker engine >=23.0 включать вручную не нужно, в противном случае вы можете включить BuildKit, установив переменную среды или сделав BuildKit настройкой по умолчанию в конфигурации демона.

Чтобы задать переменную среды BuildKit при выполнении команды docker build, выполните следующую команду:

 DOCKER_BUILDKIT=1 docker build .

Или отредактируйте файл /etc/docker/daemon.json:

{
  "features": {
    "buildkit": true
  }
}

9. Безопасность: Non-root пользователь

По умолчанию Docker запускает всё от имени root. Если хакер взломает приложение и вырвется из контейнера, он получит root и на хост-машине.

Всегда создавайте непривилегированного пользователя через команду USER в Dockerfile.

WORKDIR /app
 
# appgroup и appuser имя группы и пользователя соотв.
RUN addgroup -S appgroup && \
	adduser -S appuser -G appgroup \
	chown appuser:appgroup /app
...
# Далее при копировании файлов сразу присваиваем их пользователю:
COPY --chown=appuser:appgroup . .
# Меняем пользователя
USER appuser

Итог

В случае если хакер взломает наше приложение в контейнере и вырвется из контейнера, то на хосте(vds) хакер будет никем, бесправным пользователем в бесправной группе.

10. Работа с секретами

Никогда не передавайте токены или ключи через ARG или ENV — они сохраняются в истории образа и видны через команду docker history.

Решение: Используйте механизм Build Secrets (требует включенного BuildKit). Секрет монтируется как временная «флешка» только на время выполнения команды и не попадает в итоговый образ.

RUN --mount=type=secret,id=mytoken \
	export TOKEN=$(cat /run/secrets/mytoken) && \
	команда_требующая_секретный_ключ, чтобы подставить его используем $TOKEN

Запускается такой образ следующим образом:

docker build --secret id=mytoken,src=./local_secret.txt .

Или через docker compose

services:
  myapp:
    build:
      context: .
      dockerfile: Dockerfile
      # Указываем, какие секреты использовать при сборке
      secrets:
        - mytoken
    # Если секрет нужен и при запуске (runtime), его также можно прокинуть здесь:
    # secrets:
    #   - mytoken
 
secrets:
  mytoken:
    file: ./local_secret.txt  # Путь к файлу с токеном на вашем компьютере

Так же важно: Если мы хотим использовать этот TOKEN только в команде мы в Dockerfile пишем:

RUN --mount=type=secret,id=mytoken \
	TOKEN=$(cat /run/secrets/mytoken) && \
	команда_требующая_секретный_ключ, чтобы подставить его используем $TOKEN

Если же нам необходимо передать это в приложение(например в python, чтобы получить через os.getenv), то пишем:

RUN --mount=type=secret,id=mytoken \
	export TOKEN=$(cat /run/secrets/mytoken) && \
	запуск приложения

Примечание

Это сработает только если скрипт запускается во время сборки образа (например, для генерации статики или миграции БД на этапе билда). Если же вы хотите, чтобы приложение видело этот токен после того, как контейнер запустится (команда CMD или ENTRYPOINT), то секрет нужно прокидывать через Runtime Secrets в Docker Compose и в приложении его нужно будет читать напрямую из файла /run/secrets/mytoken.

11. Обработка сигналов

Допустим вы выкатили новую версию приложения, которое работает на kubernetes. И для обновления, kuber должен сначала остановить старый код. Он посылает контейнеру сигнал SIGTERM, если по простому - вежливую просьбу остановить приложение(аналог команды kill), нормальной реакцией на этот сигнал является: корректное завершение работы приложения(завершение запросов, сохранение данных и закрытие). Но зачастую приложение продолжает работу, игнорируя этот сигнал. Kuber ждет 30 секунд, понимает что контейнер не остановился и посылает сигнал SIGKILL, принудительное завершение процесса (аналог команды kill -9), что мгновенно убивает процесс. Итог: ошибки у клиентов, возможны ошибки в данных, все запросы оборваны.

Почему так? Если ваше приложение запускается через shell-скрипт(bash start.sh или sh start.sh), то процесс с PID=1 это сама оболочка(bash/sh), а наше приложение становится дочерним процессом. В свою очередь оболочка не умеет посылать SIGTERM дочерним процессам и наше приложение не знает, что его пытаются остановить. Как итог kuber убивает процесс по таймауту (отправляя SIGKILL), что ведет к потере данных.

Варианты решения:

1. Использовать команду exec в скриптах для замещения процесса.

#!/bin/sh
# Тут мы что-то делаем
echo "Запуск приложения"
exec node index.js

Команда exec вместо создания нового процесса замещает текущий, как итог процесс с PID=1 не оболочка, а наше приложение.

2. Использовать Tini — крошечный инит-процесс, встроенный в Docker, который корректно проксирует сигналы

# Устанавливаем tini
 
# Для alpine:
RUN apk add --no-cache tini
 
# Для debian:
RUN apt-get update && \
	apt-get install -y --no-install-recommends tini && \
	rm -rf /var/lib/apt/lists/*
 
# Используем его как точку входа(для alpine)
ENTRYPOINT ["/sbin/tini","--"]
 
# Используем его как точку входа(для debian)
ENTRYPOINT ["/usr/bin/tini","--"]
 
# Запускаем приложение как аргумент для tini
CMD ["node", "index.js"]

Примечание

Tini не только правильно проксирует сигналы к приложению, но и убирает зомби-процессы(процессы которые завершили свою работу, но не удалились из таблицы процессов, в случае слишком большого количества таких процессов система не сможет запустить новый)

12. Healthchecks

Докер считает контейнер здоровым, пока жив процесс, но приложение может зависнуть (deadlock), потерять подключение или уйти в бесконечный цикл. Процесс будет висеть, контейнер будет считать что с ним все впорядке, но на запросы приложение отвечать не будет.

Для решения этой проблемы существует инструкция HEALTHCHECK. Она позволяет Docker периодически проверять реальное состояние приложения. Если проверка не проходит, статус меняется на unhealthy, и оркестратор может перезапустить контейнер.

Пример

Dockerfile

# Спустя 5 секунд со старта приложение.
# Каждые 30 секунд запускаем проверки.
# Если ответа нет 3 секунды - фейл.
# Так же есть флаг --retries (по умолчанию = 3)
# Указывает сколько раз проверка должна быть провалена для статуса unhhealthy
 
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
	CMD curl -f http://localhost:8080/health || exit 1
	
# || exit = 1, выполнится если проверка провалилась и скажет докеру о ошибке 

Docker compose

services:
  myapp:
    ...
    healthcheck:
      # Команда может быть списком или строкой
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 5s
    # Полезная фишка: зависимые сервисы
    depends_on:
      db:
        condition: service_healthy
 
  db:
    ...
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

13. Статический анализ (Hadolint)

Для проверки Dockerfile стоит использовать Hadolint. Это линтер, который проверяет файл на соответствие этим критериям и указывает на ошибки (например, использование sudo или отсутствие очистки кэша пакетов).