Безопасные контейнеры в разработке
Как сделать так, чтобы образ контейнера не таскал за собой лишние пакеты, секреты и права 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 ограничивают ущерб при пробитии приложения.
- Секреты живут в рантайм-хранилищах, а не в слоях образа.
- Сканирование отвечает «безопасен ли образ», подпись — «тот ли это образ»; нужны оба.