Безопасность образов и сканирование
Образ отгружает всю свою файловую систему как attack surface: минимизируй base image, переходи на non-root, пинни по digest и сканируй в CI и со временем — потому что CVE в базе, чистая на сборке, может стать критичной, пока образ в проде.
Отчёт по пентесту приходит с одной строкой в красном: удалённое выполнение кода, CVSS 9.8, в TLS-библиотеке, которую никто в команде никогда не импортировал. Она пришла с base image — node:18, скачанным чистым одиннадцать месяцев назад, отсканированным в зелёный на сборке, задеплоенным и больше ни разу не просмотренным. CVE опубликовали через четыре месяца после сборки. Образ не менялся; менялся ландшафт угроз. Ничто в пайплайне не следило за уже отгруженным образом, поэтому критичная дыра просидела в проде треть года с зелёной галочкой рядом.
К концу урока ты поймёшь, какие именно четыре решения закаляют образ, почему зелёный CI-скан не равен гарантии безопасности и что продовый пайплайн должен делать иначе, чем сборочный.
Attack surface — это то, что ты отгружаешь
Каждый байт в образе — это то, что атакующий сможет использовать, оказавшись внутри контейнера. Полная база node:18 или python:3.12 — это целый userland Debian или Ubuntu: шелл, apt, curl, bash, десятки системных библиотек, пакетный менеджер — сотни пакетов, каждый из которых — строка в CVE-базе (база известных уязвимостей, Common Vulnerabilities and Exposures). Ты ничего из этого не просил; оно пришло вместе с удобством FROM node:18. Поэтому первый и самый большой рычаг безопасности образа — не сканер и не политика, а нести меньше.
Три движения сжимают attack surface. Slim-варианты (node:22-slim) обрезают базовую ОС примерно до того, что нужно рантайму. Distroless-образы (gcr.io/distroless/*) идут дальше: только твоё приложение плюс его прямые runtime-библиотеки — без шелла, без пакетного менеджера, без apt. Multi-stage сборки гарантируют, что компиляторы, dev-зависимости и кэши apt, которыми ты собирал артефакт, вообще не попадут в финальный стейдж. Выигрыш прямой: меньше установленных пакетов — меньше CVE для триажа, меньше образ для pull и хранения, и куда меньше у атакующего, который получил шелл, — потому что в distroless шелла, в который можно попасть, попросту нет.
# syntax=docker/dockerfile:1
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Distroless финальный стейдж: приложение + runtime-библиотеки, без шелла, без apt
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Distroless поставляет nonroot-пользователя; не гоняй под root
USER nonroot
CMD ["dist/server.js"]Три варианта базы меняют attack surface на удобство:
| База | Типичный размер | Пакеты / CVE-поверхность | Шелл + пакетный менеджер? | Отлаживаемость |
|---|---|---|---|---|
node:22 (полная) | ~1.1GB | Сотни — большая | Да (bash, apt) | Легко — полный userland |
node:22-slim | ~250MB | Десятки — умеренная | Да (меньший набор) | Рабочая — есть шелл |
distroless/nodejs22 | ~120MB | Только приложение + runtime — минимальная | Нет | Сложно — логи + эфемерные debug-контейнеры |
Гоняй non-root и пинни то, что гоняешь
По умолчанию процесс контейнера работает под root внутри своего namespace. Этот root в чём-то ограничен, но это всё равно неправильный дефолт: если атакующий пробьётся в процесс — через баг приложения, RCE в зависимости, неправильно настроенный mount — он наследует root, а root плюс баг ядра, или беспечный флаг --privileged, или записываемый host bind mount — это путь к эскалации за пределы контейнера. Гайд самого Docker прямолинеен: если сервис может работать без привилегий, используй USER, чтобы перейти на non-root пользователя. Добавь явного непривилегированного пользователя и переключись на него до CMD. Соедини это с read-only корневой файловой системой (--read-only, с tmpfs для немногих путей, которым реально нужна запись), чтобы скомпрометированный процесс не смог положить бинарник или переписать твоё приложение на диске.
# Создай и переключись на непривилегированного пользователя до CMD
RUN addgroup --system app && adduser --system --ingroup app app
USER app
# В рантайме запри корневую файловую систему read-only:
# docker run --read-only --tmpfs /tmp myimageВторая дисциплина — пиннинг по digest, а не по тегу. Тег вроде node:22 — это подвижный указатель: издатель может, и делает это, перенаправить его на новый образ при каждой пересборке. Значит, FROM node:22 не воспроизводим: две сборки с разницей в неделю могут скачать два разных base image, а :latest — худший из всех, тихо съезжает под тобой. Digest (node:22@sha256:...) — это неизменяемый хэш содержимого одного точного образа; пиннишь на него — и каждая сборка, каждая машина, каждый CI-прогон получают байт-в-байт одинаковые байты, и у тебя есть фиксированная мишень для скана и аудита. Цена в том, что digest надо осознанно поднимать, чтобы получить upstream-патчи, — и именно поэтому важно сканирование со временем (ниже).
▸Почему это работает
«Запинь digest» и «получай патчи безопасности» будто конфликтуют: неизменяемый digest никогда не получит upstream-фикс. Не конфликтуют, если разделить две задачи. Digest даёт известный, воспроизводимый вход — ты всегда точно знаешь, что в проде. Непрерывное пересканирование даёт сигнал двигаться — когда CVE приходит в твой запинённый digest, сканер её флагует, и ты осознанно поднимаешь на новый, пропатченный digest через PR и CI, а не тег тихо мутирует под тобой. Dependabot/Renovate автоматизируют бамп. Ты получаешь и воспроизводимость, и патчи — просто не случайно.
Сканирование: на сборке и навсегда после
Сканер (Docker Scout, Trivy, Grype) строит software bill of materials — SBOM (реестр всего программного обеспечения образа с версиями), полную опись каждого OS-пакета и языковой зависимости в образе с версиями — и сопоставляет эту опись с непрерывно обновляемой базой уязвимостей, чтобы точно указать CVE в твоих слоях и пакетах. Встрой его в CI как гейт: роняй сборку на новых critical/high находках, чтобы уязвимый образ не отгрузился. SBOM — ещё и твой provenance и аудиторская запись: когда выйдет следующий громкий CVE, ты отвечаешь на «затронуты ли мы?» запросом к SBOM, а не гаданием.
# Опись + сводка уязвимостей (Docker Scout)
docker scout quickview my-app:1.4.0
docker scout cves my-app:1.4.0
# Сгенерировать SBOM для аудита / provenance
docker scout sbom my-app:1.4.0
# То же на Trivy, с гейтом CI по critical/high
trivy image --severity CRITICAL,HIGH --exit-code 1 my-app:1.4.0Сеньорский failure mode — считать скан на сборке всей работой. Зелёный скан — это снимок, а не гарантия. Новые CVE раскрывают ежедневно против пакетов, которые уже внутри твоего отгруженного образа; образ, чистый на сборке, через месяц может быть критичным без единого изменения кода — ровно как в хуке. Поэтому сканирование должно бежать по расписанию против задеплоенных образов, а не только в той сборке, что их породила, и алерт должен уходить к тому, кто может отгрузить пропатченный digest. Пересканируй образы в реестре ночами; считай «никто не пересканировал» настоящей уязвимостью.
Скомпилированный сервис должен отгружаться в прод закалённым, с минимально оправданной attack surface. Выбери базу финального стейджа.
Образ, чистый на сборке, через месяц находят с критичной CVE, при том что кода и образ между этим не менялись. Что произошло и что это предотвращает?
Почему для продового base image предпочесть FROM node:22@sha256:... вместо FROM node:22?
- 01Почему минимизация base image (slim/distroless + multi-stage) улучшает безопасность, а не только размер?
- 02Разбери, как CVE в base image может просидеть в проде месяцами с зелёной галочкой, и практики, которые это предотвращают.
Образ отгружает всю свою файловую систему как attack surface, поэтому первый и самый большой рычаг безопасности — нести меньше: выбери минимальную базу (slim или distroless без шелла и пакетного менеджера) и используй multi-stage сборки, чтобы компиляторы и dev-зависимости никогда не попадали в финальный образ, что напрямую сжимает, сколько CVE надо триажить и насколько может пивотить взломщик. Затем закали то, что осталось — добавь явного non-root USER до CMD, чтобы скомпрометированный процесс не наследовал root, и запри корневую ФС read-only с tmpfs для немногих записываемых путей. Пинни базу по digest (@sha256:…), а не по изменяемому тегу, чтобы сборки были воспроизводимы, аудируемы и представляли фиксированную мишень, принимая, что digest потом надо осознанно поднимать ради патчей. Наконец, сканируй инструментом, который строит SBOM и сопоставляет его с CVE-базой — ставь гейт CI на critical/high, чтобы уязвимые образы не отгружались, но и пересканируй задеплоенные образы по расписанию, потому что CVE в базе, чистая на сборке, может стать критичной, пока неизменный образ сидит в проде, а настоящая уязвимость — что никто больше не посмотрел. Теперь, когда видишь зелёную галочку на образе, который полгода живёт в проде, спроси: когда последний раз этот пайплайн проверял работающий образ, а не тот, что собирался?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.