Проект подрастает, контейнер с приложением уезжает в продакшен, и почти всегда там оказывается та же сборка, что стояла на ноутбуке разработчика. Универсальный базовый образ на гигабайт, права администратора внутри, ни одной проверки на уязвимости в конвейере. Пока графики зелёные, кажется, что так и надо. Первая серьёзная проверка защиты разворачивает картину наизнанку: устаревший пакет с известной уязвимостью и в системе, и в зависимостях, лишние возможности ядра, файлы приложения, открытые на запись кому угодно внутри. Ниже разобрано, как выглядит зрелая работа с этой стороной релизов и какие шаги дают максимум эффекта на вложенный час.

Запуск от root по умолчанию оставляет дверь приоткрытой

Сборка, в которой нет директивы USER, стартует приложение от имени пользователя с номером ноль. Внутри namespaces это кажется условностью, ведь контейнер изолирован, но именно с этой условности начинается большинство историй о вырвавшихся наружу нагрузках. Процесс с полными правами внутри может менять собственные файлы, ставить пакеты, поднимать слушающие сокеты на привилегированных портах и пользоваться файлами с расширенными битами. Уязвимости механизма запуска контейнеров уже не раз превращали эти мелочи в выход за границы изоляции: нашумевшая история 2019 года с раннером позволяла процессу из контейнера перезаписать исполняемый файл утилиты на хосте и получить своё выполнение уже снаружи при следующем запуске.

Отдельный рецепт беды это смонтированный сокет Docker внутри контейнера. Отдавать его приложению означает отдать управление всем хостом, потому что через сокет можно запустить новый контейнер с корнем файловой системы хозяина. Мониторинговые агенты и системы сборки, работающие внутри кластера, частенько получают такой доступ ради удобства, и это удобство потом фигурирует в отчётах о проникновении первой строкой. Расхожее оправдание прав root звучит как потребность слушать порт восемьдесят, и оно рассыпается при первом знакомстве с темой: слушающий порт отдаётся через проброс, а если процессу всё-таки нужен низкий порт, для этого существует единственная возможность ядра, добавляемая точечно.

Современные оркестраторы давят на это административно: профиль ограниченного режима в Kubernetes отклоняет поды, в которых не объявлен запуск от непривилегированного пользователя. Значит, привычка ставить USER в образ перекладывается и на миграцию в кластер, а не только на эстетику Dockerfile.

Корни привычки понятны. Под нулевым номером отладка проще: можно поставить недостающую утилиту прямо в живой контейнер, поправить права, потрогать всё подряд. Только продакшен это не среда отладки, и каждая мелочь, доступная под полными правами, расширяет то, что получит злоумышленник после единственной логической ошибки в приложении. Разница между нулем и тысячей это разница между взломом изоляции и испорченным каталогом с временными файлами.

Базовый образ определяет поверхность атаки задолго до кода

Полный образ с официальной сборкой Node.js двадцатой версии занимает около гигабайта, и почти всё это величина не приложения, а Debian с компиляторами, утилитами и библиотеками, приложению не нужными. Каждый лишний пакет это чужой код внутри периметра и будущие исправления, ради которых придётся пересобирать образ. Alpine-вариант той же среды весит на порядок меньше, варианты distroless строже: в них нет ни оболочки, ни пакетного менеджера, только среда исполнения, а значит атакующему, попавшему внутрь, нечем пользоваться. Самые аскетичные сервисы уезжают в образ scratch со статически слинкованным бинарником. Минимальность Alpine изредка напоминает о себе отличиями в стандартной библиотеке C, поэтому причудливые нативные зависимости стоит проверять заранее, а не в ночь релиза.

Многоэтапная сборка отделяет кухню от результата: на первом этапе ставятся зависимости и компилируется код, на второй переносятся только артефакты и runtime. Разница между промежуточным и финальным образом получается в разы, и это разница не только в скорости загрузки, но и в количестве потенциальных уязвимостей.

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]

Здесь использован готовый пользователь node из официального образа, его номер тысяча, и это уже меньше прав, чем ноль. Ещё лучше зафиксировать базовый образ не тегом, а дайджестом с хэшем, тогда сборка воспроизводится байт в байт, а обновления базы становятся осознанным действием, а не лотереей.

Размер и состав образа проверяются на месте двумя командами: docker images покажет вес, docker history разложит содержимое по слоям и подскажет, какой шаг в Dockerfile оказался расточительным. Расточительность слоя это не только стиль: чем больше в нём чужого кода, тем чаще сканер находит в нём что-то тяжёлое и тем чаще выходит обязательная пересборка.

Сканирование уязвимостей в конвейере сборки ловит старые пакеты

Минимальный образ не гарантирует отсутствия дыр, он лишь сокращает их приток. Проверку должен выполнять сканер, и свободный Trivy стал здесь стандартом де-факто: он разбирает слои образа, сверяет пакеты системы и зависимости приложения с базами уязвимостей, попутно ищет секреты и ошибки конфигурации.

trivy image registry.local/orders:1.4
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.local/orders:1.4

Флаг ignore-unfixed убирает шум от находок, для которых исправление ещё не вышло, severity задаёт порог интереса. Для конвейера важен exit-code: команда возвращает не ноль при находках, и сборка падает до того, как уязвимый образ уедет в реестр. Исключения с обоснованием складываются в файл trivyignore, чтобы легитимные отложенные находки не мешали остальным. Альтернативы есть на любой вкус: Grype от Anchore, встроенный Docker Scout, облачные сканеры у реестров.

Сканер ловит и то, чего в образе быть не должно: ключи доступа, забытые в слоях вместе с командой COPY, токены из файла с настройками, пароли, попавшие в переменные сборки. Пара строк в файле .dockerignore, исключающая каталоги с секретами и тестами из контекста, экономит и вес, и нервы. Базы уязвимостей обновляются ежедневно, поэтому один и тот же образ вчерашней и сегодняшней сборки может дать разные отчёты, и это норма, а не сбой.

Живая ситуация из практики: сканер находит критическую уязвимость в OpenSSL внутри базового слоя, приложению она не принадлежит. Правильный ответ занимает минуты: базовый образ обновляется, пересборка подхватывает исправленный слой, в репозиторий уезжает тот же код приложения внутри здоровой оболочки. Без сканера об этом узнавали бы месяцами позже из чужого отчёта.

Политика зрелых команд обычно двухуровневая: тяжёлые находки с публичным эксплойтом блокируют сборку немедленно, остальным выделяется окно на исправление, и оба порога прописаны в конфигурации конвейера, а не в головах инженеров.

Понижение прав в Dockerfile от владельца к гостю

Если образ не готов дать непривилегированного пользователя, он заводится вручную, и делается это тремя строками. Для Alpine годится adduser с явным номером, для Debian то же самое через useradd, главное не оставить безымянного пользователя без номера, потому что оркестраторы проверяют именно номер при запуске.

FROM alpine:3.20
RUN addgroup -g 10001 orders && adduser -D -u 10001 -G orders orders
WORKDIR /app
COPY --chown=orders:orders . .
USER 10001
CMD ["/app/orders"]

Пользователь с номером десять тысяч один не может трогать чужие файлы, переустанавливать пакеты и слушать порты ниже тысячи двадцати четырёх, и приложению это почти никогда не нужно: порт наружу всё равно публикуется через проброс, а не напрямую из процесса. Файлы, которые сервис пишет, заранее выделяются в отдельные тома с корректным владельцем.

Бывают сервисы, которым на старте нужны права на поправку владельца в томе, и для них существует выверенный приём: стартовый скрипт под нулевым номером делает несколько chown, после чего утилита вроде gosu перезапускает приложение уже от обычного пользователя. Главное завершить понижение прав и не тянуть их дальше в основной процесс.

Флаги запуска, которые добивают лишние возможности

Ничтожный остаток прав снимается уже на этапе запуска, и перечисление выглядит скучно ровно до первого инцидента.

docker run -d --read-only \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  --user 10001 \
  --tmpfs /tmp:rw,size=64m \
  registry.local/orders:1.4

Файловая система только для чтения делает образ неизменяемым, запись уезжает в тома и tmpfs, и внедрённый код перестаёт сохраняться между перезапусками. Отказ от всех возможностей ядра урезает то, что даже пользователь с малым номером смог бы делать, а при потребности в привязке к порту восемьдесят обратно добавляется единственная возможность NET_BIND_SERVICE. Запрет повышения привилегий закрывает путь через файлы с расширенными битами. Все эти флаги переносятся в Kubernetes почти дословно: securityContext с полями runAsNonRoot, readOnlyRootFilesystem и cap-drop существует ровно для этого.

Есть и более глубокий рубеж. Сам механизм Docker умеет работать без прав администратора на хосте в режиме rootless, а сопоставление номеров пользователей сдвигает внутренний ноль на произвольный внешний номер, так что даже уязвимость выхода из контейнера приземляется на бесправного гостя хоста. Профиль seccomp по умолчанию уже отсекает экзотические системные вызовы, и его стоит рассматривать как обязательный фон, а не опцию.

Порядок переноса релизов на безопасный фундамент

Разрозненные меры складываются в план, который проходит через ревью за один заход:

  1. Выбрать минимальный базовый образ под среду и зафиксировать его дайджестом;
  2. Разделить сборку и исполнение на два этапа, чтобы в финальный образ не попали компиляторы и кэш пакетного менеджера;
  3. Завести пользователя с явным номером и добавить USER в Dockerfile;
  4. Прогнать сканер по существующим образам и составить список критических находок;
  5. Встроить сканер в конвейер с блокировкой сборки по тяжёлым уязвимостям и окном исправления для остальных;
  6. Добавить флаги запуска, файловую систему только для чтения и отказ от лишних возможностей ядра.

Сопротивление обычно не техническое, а привычное: образ и так работает. Аргумент один и весомый, каждому пункту соответствует свой час работы, а вместе они снимают целый класс находок из отчётов аудита.

Типовая хронология внедрения на среднем сервисе выглядит так: полчаса на выбор образа и дайджест, час на многоэтапную сборку, двадцать минут на пользователя, полчаса на первый прогон сканера и разбор находок, ещё час на флаги запуска и проверку в тестовом окружении. Итого один рабочий день, и очередная проверка защиты встречает не список замечаний, а готовый отчёт сканера с чистым выводом.

Что даёт связка минимального образа и пониженных прав

Вывод из практики такой: права root по умолчанию это не один риск, а целая лестница, и почти каждая ступень бесплатна для устранения. Минимальный базовый образ уменьшает приток уязвимостей и вес сборки, сканер в конвейере превращает безопасность в обычный пункт проверки перед слиянием, пользователь с малым номером и флаги запуска добивают то, что осталось. Ни один из шагов не требует переписывания приложения, максимум стоит часа на сервис.

Частый вопрос от команд, только начинающих эту работу: не сломает ли минимальный образ отладку? Сломает ровно в том месте, где ей и место. Отладка уходит в локальные сборки и отдельные образы с инструментами, а продакшен перестаёт быть кладовой утилит. Оболочка внутри контейнера это одновременно и удобство, и готовый инструмент для чужих рук, и компромисс здесь давно известен: инструменты живут в отдельном образе, который запускается лишь в диагностических целях.

И обратная сторона честности: сканер не спасёт от логической уязвимости в коде, а USER не спасёт от утечки секретов из переменных окружения. Поэтому описанный фундамент не замена архитектурной работе, а её нижний этаж, на котором уже спокойно строятся сетевые политики, управление секретами и наблюдаемость. Начинать с образа и прав стоит хотя бы потому, что это самая дешёвая часть истории, а эффект от неё виден в первом же отчёте сканера: из сотен находок остаются единицы, и все они уже понятного происхождения.