Безопасные контейнеры в разработке

Как сделать так, чтобы образ контейнера не таскал за собой лишние пакеты, секреты и права root — и чтобы вы могли доказать, что задеплоили именно его.

Образ контейнера — неизменяемый слоёный архив с приложением и его зависимостями. Всё, что попало в слой, остаётся в истории образа навсегда — включая случайно скопированный секрет.

Контейнеры ускоряют разработку, но переносят и старые проблемы безопасности: уязвимые библиотеки, root по умолчанию, секреты внутри артефакта. Хорошая новость — почти все эти риски закрываются на этапе сборки, в Dockerfile и в CI. Это территория разработчика, а не только «безопасников».

Зачем это знать защитнику

Образ — это поставляемый артефакт. Если в нём лежит уязвимая версия библиотеки или захардкоженный токен, вы тиражируете дыру на каждый запущенный под. А контейнер, работающий от root, при пробитии приложения даёт атакующему максимум возможностей внутри пода и шанс на побег к ноде. Минимизировать поверхность и привилегии нужно заранее.

Важно понимать, что контейнер — это не виртуальная машина и не полноценная граница безопасности сам по себе. Процессы внутри изолированы пространствами имён и cgroups, но делят одно ядро с хостом. Поэтому уязвимость в ядре или лишняя привилегия превращают «изоляцию» в фикцию, и роль защитника — не полагаться на контейнер как на песочницу, а уменьшать и поверхность образа, и права процесса. Ещё одна особенность: образы строятся слоями, и каждый слой кэшируется и переиспользуется. Это удобно для скорости, но означает, что уязвимая или секретосодержащая команда «застывает» в истории и тянется во все производные образы. Поэтому решения о безопасности принимают на этапе написания Dockerfile, а не «потом в рантайме».

Минимальные образы

Чем меньше пакетов в образе, тем меньше уязвимостей и инструментов для атакующего. Базируйтесь на slim, alpine или distroless и используйте multi-stage build: собираете в «толстом» образе, а в финальный кладёте только бинарь и рантайм.

# Этап сборки
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# Минимальный финальный образ (distroless, без shell и пакетного менеджера)
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

В distroless нет shell — атакующему не на чем «жить» внутри контейнера, а образ весит мегабайты вместо сотен. Логика прямая: каждый лишний пакет — это потенциальная CVE и готовый инструмент для постэксплуатации. Нет curl, wget, bash и компилятора — значит, даже получив выполнение кода, злоумышленнику нечем скачать второй этап и закрепиться. Дополнительный бонус минимальных образов — скорость: они быстрее тянутся в кластер и быстрее стартуют, что само по себе полезно для устойчивости сервиса.

Запуск от non-root

По умолчанию процесс в контейнере — root. Явно создайте непривилегированного пользователя и переключитесь на него; в манифесте продублируйте это в securityContext:

# Создаём пользователя и не запускаемся от root
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 appuser
WORKDIR /home/appuser/app
COPY --chown=appuser . .
RUN pip install --no-cache-dir -r requirements.txt
USER appuser
CMD ["python", "main.py"]

Секреты не кладут в образ

Самая частая ошибка — COPY .env или ENV API_TOKEN=... прямо в Dockerfile. Секрет остаётся в слое образа навсегда, и docker history его покажет, даже если вы «удалили» файл следующей командой. Правильно — передавать секреты в рантайме через переменные окружения из секрет-хранилища (Kubernetes Secret, Vault, облачный Secrets Manager) или через build-time секреты BuildKit, не оседающие в слоях.

# НЕБЕЗОПАСНО: секрет навсегда в истории образа
ENV DB_PASSWORD="super-secret"
COPY .env /app/.env

# Правильно: секрет приходит в рантайме, в образе его нет

Чтобы случайные секреты не утекали, добавьте .dockerignore (исключите .env, .git, ключи) и сканируйте репозиторий детектором секретов (gitleaks, trufflehog) в CI. Отдельно держите в голове build-context: команда docker build отправляет демону всю папку целиком, и без .dockerignore в контекст (а порой и в слой через COPY .) попадает то, чего вы там не ждали — приватные ключи, история git, локальные дампы. И помните золотое правило реагирования: если секрет всё же попал в образ или коммит, его недостаточно удалить — нужно считать скомпрометированным и немедленно ротировать, потому что старые слои и история git всё ещё хранят его.

Сканирование образов

Перед публикацией прогоняйте образ через сканер уязвимостей — он сверяет установленные пакеты с базами CVE. В своей среде и в CI это выглядит так:

# Сканирование собранного образа на известные CVE (учебная среда)
trivy image myapp:latest
# Альтернатива
grype myapp:latest

Настройте политику: сборка падает при наличии HIGH/CRITICAL с доступным фиксом. Заодно генерируйте SBOM (Software Bill of Materials) — список всех компонентов образа, чтобы при новой CVE мгновенно понять, затронуты ли вы.

Подпись образов

Сканирование отвечает на вопрос «безопасен ли образ», подпись — на вопрос «тот ли это образ». Подпись (Cosign из экосистемы Sigstore) криптографически связывает образ с автором, а admission-контроллер кластера отказывается запускать неподписанные образы — это защита от подмены в реестре и от supply chain атак.

# Подпись и проверка образа (Cosign, учебная среда)
cosign sign   registry.example.com/myapp@sha256:...
cosign verify registry.example.com/myapp@sha256:...

Как это работает под капотом

Образ — это набор слоёв и манифест с их digest-хешами (sha256:...). Любое изменение слоя меняет хеш, поэтому ссылка на образ по digest неизменяема. Сканер разбирает слои, извлекает метаданные пакетов и матчит версии с фидами уязвимостей. Cosign формирует подпись над digest манифеста и кладёт её рядом в реестр (или в прозрачный лог Rekor); проверка сравнивает подпись с публичным ключом/identity. Так из «доверяй имени тега» получается «доверяй конкретному проверенному содержимому».

Как защититься

  • Бери минимальную базу (slim/alpine/distroless) и multi-stage build.
  • Запускай контейнер от non-root, дублируй runAsNonRoot и drop capabilities в манифесте.
  • Никогда не помещай секреты в образ; передавай их в рантайме, держи .dockerignore.
  • Сканируй образы (Trivy/Grype) в CI и блокируй сборку на HIGH/CRITICAL с фиксом; генерируй SBOM.
  • Подписывай образы (Cosign) и требуй проверку подписи на admission в кластере.
  • Ссылайся на образы по digest, а не по плавающему тегу latest.

Юридическое напоминание: сканировать и подписывать можно свои образы и образы, на которые есть права. Распространение вредоносного ПО наказуемо (УК РФ ст. 273).

Итоги

  • Минимальный образ = меньше CVE и меньше инструментов у атакующего.
  • Non-root и drop capabilities ограничивают ущерб при пробитии приложения.
  • Секреты живут в рантайм-хранилищах, а не в слоях образа.
  • Сканирование отвечает «безопасен ли образ», подпись — «тот ли это образ»; нужны оба.
Проверьте себя
1. Почему нельзя класть секрет через ENV или COPY .env прямо в Dockerfile?
AЭто замедляет сборку образа
BСекрет навсегда сохраняется в слое образа и виден через docker history, даже если файл удалить позже
CDocker не поддерживает переменные окружения
DСекреты можно класть в образ, это безопасно
2. Зачем использовать минимальный (distroless/alpine) образ и multi-stage build?
AЧтобы образ занимал больше места
BЧтобы добавить shell для отладки
CЧтобы сократить число пакетов — меньше уязвимостей и меньше инструментов для атакующего внутри контейнера
DЧтобы запускаться от root по умолчанию
3. Какую задачу решает подпись образа (Cosign), которую НЕ решает сканер уязвимостей?
AНаходит CVE в библиотеках
BПодтверждает целостность и происхождение образа — что запускается именно тот, проверенный образ, а не подменённый
CУменьшает размер образа
DСоздаёт SBOM