open atlas
↑ К треку
Безопасность облака и инфраструктуры CLOUD · 02 · 01

Container image security

Образ — это тарбол чужого кода плюс твоего, а `:latest` — движущаяся цель, которую ты не контролируешь. Пинуй по дайджесту, бери минимальную базу, сканируй и пересобирай ради CVE, запускай не от root и проверяй подпись, чтобы запускать именно тот образ, что собрал.

CLOUD Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Деплой, который работал вчера, сегодня выкатывает криптомайнер — и код никто не менял. В Dockerfile стоит FROM node:18, CI зелёный, реестр говорит «pushed». Но node:18 — это плавающий тег: за ночь он разрезолвился в свежий апстрим-образ, тот подтянул транзитивно обновлённый базовый слой, и один из слоёв несёт новый критический CVE, который твой скан не видел, потому что сканировал неделю назад другой набор байтов. Или хуже: атакующий, добравшийся до токена реестра, перезапушил node:18 на отравленный манифест, а твой кластер, которому сказали лишь «тяни node:18», вытянул ровно то, что сказали. Контейнер выполнил твой код поверх чужой базы, и кто эта база — ты не решал. Безопасность образов — это дисциплина решать и доказывать, какие именно байты выполняются.

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

Образ — это унаследованные слои, а не твой код

Образ контейнера — это не твоё приложение. Это стопка read-only слоёв файловой системы — базовый слой ОС, рантайм языка, системные библиотеки, затем твой код и зависимости поверх — упакованная в тарбол с JSON-манифестом и адресуемая по хешу содержимого (дайджест, sha256:…). Когда ты пишешь FROM node:18, ты наследуешь сотни мегабайт чужих бинарей: userland Debian, OpenSSL, glibc, рантайм Node и всё, что они тянут. Твой COPY . . — это тонкий верхний слой. Значимая для безопасности правда в том, что большая часть поверхности атаки типового образа лежит ниже твоей черты — в слоях, которые ты не писал и редко аудируешь.

Это наследование — первая проблема, потому что уязвимости живут в этих нижних слоях. Обзоры публичных образцов регулярно находили от десятков до сотен известных CVE на образ, подавляющее большинство — в пакетах ОС и базовых слоях языка, а не в коде приложения. Критический CVE в OpenSSL или glibc оказывается в твоём образе в тот момент, когда ты собираешь поверх базы, содержащей уязвимую версию, — ты отгрузил его, не написав ни строки уязвимого кода. Так что «безопасен ли мой образ?» — это в основном «что я унаследовал и знаю ли я текущее состояние его CVE?».

Теги плавают; дайджесты пинят

Вторая проблема — идентичность. Тег вроде node:18 или myapp:latest — это изменяемый указатель: сегодня он именует манифест, но владелец реестра (апстрим или любой с правом push) может перенаправить его завтра. FROM node:18 — это не «вот этот конкретный образ», а «то, во что node:18 разрезолвится в момент pull». Две сборки с разницей в неделю могут дать существенно разные образы из идентичного Dockerfile — вот почему «неделю назад собиралось чисто» ничего не говорит о том, что ты запускаешь сейчас.

Фикс — адресовать образы так, как реестр их реально хранит: по дайджесту. FROM node:18@sha256:abc123… пинит точные контент-адресуемые байты; дайджест — это хеш манифеста, поэтому другой образ = другой дайджест, и точка. Пинуй базовые образы по дайджесту в Dockerfile и пинуй задеплоенный образ по дайджесту в манифесте Kubernetes (image: myapp@sha256:…, а не image: myapp:latest). Это делает деплои воспроизводимыми и закрывает атаку перезапуша: даже если кто-то перенаправит тег на отравленный манифест, твоя ссылка по дайджесту за ним не пойдёт. Компромисс реален — дайджесты непрозрачны и не обновляются сами, так что ты меняешь удобство на контроль и заводишь бота (Renovate/Dependabot) для осознанного бампа пинованного дайджеста — что и есть смысл: обновления становятся решениями, а не сюрпризами.

Почему это работает

Почему :latest — это не просто «самая новая версия»? Потому что latest — это лишь строка тега без особого смысла для реестра: это соглашение, а не гарантия. Ничто не заставляет latest указывать на самое новое, самое стабильное или вообще на что-то конкретное; он указывает туда, куда был запушен в последний раз. Деплой :latest означает, что твоя работающая версия — «то, что запушили в этот тег последним», что невоспроизводимо (нельзя сказать, какой образ живой), нельзя откатить (тег может больше не указывать на прежний хороший образ) и является движущейся целью для сканирования. Зрелый рефлекс: теги — для людей, дайджесты — для машин. Люди читают v2.4.1, кластер запускает @sha256:….

Минимальная база: тебя нельзя проэксплуатировать через шелл, который ты не отгрузил

Третий контроль бьёт по унаследованной поверхности напрямую: бери минимальную базу, на которой приложение работает. Полная база ubuntu или debian несёт шелл, пакетный менеджер, curl/wget, coreutils и сотни библиотек — каждая потенциальный CVE и каждая удобство для пост-эксплуатации. Прогрессия: debian (≈120 МБ, полный userland) → debian-slim (≈75 МБ, урезанный) → alpine (≈5–8 МБ, musl libc + busybox) → distroless (образы Google: только твоё приложение, его рантайм-зависимости, CA-сертификаты и /etc/passwdни шелла, ни пакетного менеджера, ни busybox) → scratch (буквально пустой; для статического бинаря).

Выигрыш двойной. Во-первых, меньше пакетов — меньше CVE отслеживать и патчить: distroless или scratch может нести однозначное число или ноль CVE в ОС против десятков в полной базе, что прямо ужимает беговую дорожку скан-и-пересборки. Во-вторых, и это недооценивают: без шелла в образе атакующий, добившийся RCE, не сделает sh -c, не подтянет curl | sh второй стейдж и не воспользуется обычными living-off-the-land бинарями — он сведён к тому, что даёт его единственный примитив эксплойта. Цена операционная: нельзя kubectl exec дебаг-шелл в distroless под (вместо этого — эфемерные дебаг-контейнеры), а musl-based Alpine может выявить тонкие отличия libc (резолвинг DNS, некоторые glibc-only бинари). Для большинства сервисов компромисс в пользу минимума; тянись к более полной базе только когда упёрся в конкретную стену совместимости.

Скан и пересборка: образ без CVE гниёт на месте

Сканирование — контроль, который все знают, и тот, что чаще всего понимают неверно. Сканер (Trivy, Grype, Clair или встроенный в реестр) читает метаданные пакетов образа, сопоставляет установленные версии с базами уязвимостей (NVD, трекеры безопасности дистрибутивов, GitHub Advisory) и репортит CVE по серьёзности. Зрелое озарение двойное. Во-первых, сканируй на этапе сборки и роняй пайплайн на новые critical — скан, который только крутится на дашборде, куда никто не смотрит, это театр; ценность — в гейте, который вообще не даёт уязвимому образу запушиться. Во-вторых, тоньше: число CVE образа растёт со временем, хотя образ не меняется, потому что новые CVE раскрываются против пакетов, уже находящихся внутри. Образ, который ты сканировал чисто в январе, может нести три новых critical в марте без единого изменённого байта. Так что реальный контроль — не «сканируй однажды», а цикл: пересканируй задеплоенные образы по расписанию против текущей базы и пересобирай на свежей базе, чтобы впитать апстрим-патчи. Пересборка — это то, как фикс реально приходит: пропатченный OpenSSL входит в твой образ только когда ты пересобираешь на базе, его содержащей.

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

Запускай не от root и доказывай происхождение

Ещё два контроля замыкают цикл. Запускай от не-root пользователя: по умолчанию процесс контейнера работает от root (uid 0) внутри контейнера, и хотя это не root хоста, это ненужная эскалация — задай директиву USER в Dockerfile (или runAsNonRoot: true в поде), чтобы баг процесса попадал на непривилегированного пользователя, сужая радиус поражения чего угодно сломавшегося. Это бесплатно почти для любого приложения и требуется уровнем restricted Pod Security Standard. (Рантайм-харденинг — капабилити, seccomp, read-only корень — это следующий урок; здесь build-time срез лишь: не запекай root.)

Доказывай происхождение подписью. Пин по дайджесту гарантирует, что ты запускаешь конкретный образ; подпись гарантирует, что этот образ пришёл от тебя. С cosign из Sigstore ты подписываешь образ (keyless, через OIDC, так что нет долгоживущего ключа, который утечёт) и прикрепляешь аттестации — SBOM (software bill of materials: полную инвентаризацию зависимостей, чтобы когда выйдет следующий Log4Shell, ты ответил «затронуты ли мы?» за минуты, а не за дни) и заявление о SLSA-провенансе (кто собрал, из какого коммита исходника, на каком сборщике). Затем admission-контроллер в кластере (Kyverno, Sigstore Policy Controller, Connaisseur) проверяет подпись до того, как поду разрешат запуститься, и отклоняет всё неподписанное или подписанное не той личностью. Это звено, что бьёт по компрометации реестра: даже если атакующий запушит вредоносный образ, он не подписан твоей сборочной личностью, так что admission его не пустит.

КонтрольКакую угрозу закрываетГде живётКонкретно
Пин по дайджестуПлавающий тег / перезапуш на отравленный манифестFROM Dockerfile + image: k8s@sha256:abc123…, не :latest
Минимальная базаУнаследованные CVE + инструменты пост-эксплуатацииВыбор базового образаdistroless / scratch, без шелла
Скан + пересборкаИзвестные CVE, включая свежераскрытыеCI-гейт + плановый пересканTrivy/Grype, падать на critical, пересобрать
Запуск не от rootНенужный root в контейнере / радиус пораженияUSER Dockerfile / pod specUSER 10001, runAsNonRoot: true
Подпись + проверкаКомпрометация реестра / недоверенное происхождениеcosign + admission-контроллерkeyless-подпись, SBOM + SLSA, проверка на admit
Выбери лучший вариант

Команда деплоит с `image: myapp:latest` из Dockerfile с `FROM node:18`, без сканирования, от root. В новостях только что всплыл CVE в зависимости. Выбери изменение, которое сильнее всего снижает шанс, что ты отгружаешь (и переотгружаешь) уязвимый, непроверенный образ.

Викторина

Почему деплой `FROM node:18` и `image: myapp:latest` — проблема безопасности, даже если образ сканировался чисто на момент сборки?

Викторина

Атакующий компрометирует твой реестр и пушит вредоносный образ под тегом твоего приложения. Какой контроль реально не даёт кластеру его запустить?

Расставь шаги по порядку

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

  1. 1 Унаследовать минимальную базу, пинованную по дайджесту в Dockerfile
  2. 2 Собрать твой код поверх от не-root пользователя
  3. 3 Просканировать образ и уронить пайплайн на новые critical CVE
  4. 4 Подписать образ и прикрепить SBOM + SLSA-провенанс через cosign
  5. 5 Кластер тянет по дайджесту; admission проверяет подпись до запуска пода
Вспомните перед уходом
  1. 01
    Почему образ контейнера лучше понимать как «унаследованные слои» и почему это делает `:latest` и плавающие теги проблемой безопасности?
  2. 02
    Пройди по пяти build-time контролям образа и что каждый останавливает: минимальная база, скан-и-пересборка, не-root, пин по дайджесту, подпись.
Итог

Образ контейнера — не твоё приложение, а стопка унаследованных read-only слоёв (базовая ОС, рантайм, системные библиотеки) с твоим тонким слоем кода поверх, адресуемая по дайджесту содержимого. Это наследование и есть причина, по которой безопасность образа — это безопасность цепочки поставки: большая часть твоей поверхности атаки живёт в пакетах, которые ты не писал, а уязвимый OpenSSL или glibc оказывается в образе в тот момент, когда ты собираешь на базе, его содержащей. Пять контролей превращают «запускай то, на что указывает тег» в «запускай ровно тот артефакт, что я собрал и за который поручился». Пинуй по дайджесту — @sha256:… и в FROM Dockerfile, и в image: Kubernetes, никогда :latest, — чтобы запускаемые байты были воспроизводимыми, а перезапуш тега (в том числе атакующим) не утягивался. Собирай на минимальной работающей базе — distroless или scratch, — чтобы ужать унаследованное число CVE и убрать шелл, которым атакующий воспользовался бы после RCE. Сканируй на гейте сборки и падай на новые critical, но также пересканируй и пересобирай по расписанию, потому что чистый образ гниёт на месте по мере раскрытия новых CVE, а патч приходит только при пересборке. Запекай не-root USER, чтобы баг процесса стартовал непривилегированным. И доказывай происхождение: подписывай через cosign (keyless), прикрепляй SBOM и SLSA-провенанс и проверяй подпись на admission, чтобы кластер отказывал любому образу, не подписанному твоей сборочной личностью, — контроль, переживающий компрометацию реестра. Теперь, читая Dockerfile и манифест деплоя, твои первые вопросы: пинована ли база по дайджесту, минимальна ли она, есть ли гейт сканирования, запускается ли не от root и отклонил бы кластер образ, который ты не подписывал?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
углубляется в

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.