Убогая архитектура Docker: как Docker хранит ваши удалённые секреты

Как можно было сделать систему сборки из неизменяемых слоёв, а потом назвать RUN rm удалением? Секрет попал в слой. Следующая команда сообщает, что файла больше нет. Сам слой с секретом никуда не делся. Он лежит внутри образа и ждёт человека, который знает, где посмотреть. Я решила проверить.

Взяла один тестовый секрет SUPER_SECRET_KOTY_HULIGANY_2026, собрала восемь образов и восемью способами передала его во время сборки. Потом разобрала результаты через слои образа, историю сборки и конфигурационные JSON.

Эксперименты проводились на Ubuntu 24.04.1 LTS, Docker Engine 29.1.3, BuildKit v0.26.2.

Первый способ COPY.

FROM alpine:latestWORKDIR /appCOPY secret.txt .CMD ["sh"]

COPY кладёт секрет в слой. docker history --no-trunc показывает, что файл участвовал в сборке и откуда его взяли, но содержимое не показывает. Значит, вроде бы секрет не светится. Сохраняем образ через docker save, распаковываем архив, находим слой с app/secret.txt, достаём файл.

SUPER_SECRET_KOTY_HULIGANY_2026

Вот и весь секрет. Никакого контейнера для этого даже не понадобилось. То, что docker history не показывает содержимое, не означает, что Docker его не хранит. Он просто хранит его в другом месте. Дичайшая архитектура для нормального человека.

Ладно. Раз COPY слишком прямолинеен, файл можно использовать при сборке, а потом удалить.

FROM alpine:latestCOPY secret.txt /secret.txtRUN rm /secret.txtCMD ["sh"]

Проверяем контейнер:

docker run --rm <ИМЯ_ОБРАЗА> cat /secret.txt

Получаем:

cat: can't open '/secret.txt': No such file or directory

Файла нет. Ну, наконец-то?

Потом смотрим историю:

COPY secret.txt /secret.txtRUN rm /secret.txt

Обе операции на месте. Сохраняем образ, распаковываем слои и достаём секрет из предыдущего слоя. Ну а как иначе!

SUPER_SECRET_KOTY_HULIGANY_2026

Команда rm отработала. Просто данные первого слоя она не трогает. Первый слой добавил файл, второй слой сообщает, что файла больше нет. Сам файл продолжает лежать в первом слое.

Вот это и есть архитектура Docker: чтобы удалить данные, сначала нужно понять, что удалять их уже поздно.

Можно вообще не создавать файл. Есть ENV.

FROM alpine:latestENV SECRET=SUPER_SECRET_KOTY_HULIGANY_2026CMD ["sh"]Пароль записывается в config.Env конфигурационного JSON готового образа.
В history остаётся инструкция ENV. docker history --no-trunc показывает её вместе со значением: SECRET=SUPER_SECRET_KOTY_HULIGANY_2026

Пароль лежит прямо в конфигурации образа. Искать по слоям не надо.

Зачем вообще делать отдельный механизм для секретов, если можно записать их в метаданные готового контейнера, а че нет то.

ENV для секретов не подходит.

Тогда ARG. Его используют именно потому, что значение нужно только во время сборки.

FROM alpine:latestARG SECRETRUN echo "Build completed"CMD ["sh"]

Передаём:

docker build \ --build-arg SECRET=SUPER_SECRET_KOTY_HULIGANY_2026 \ -t <ИМЯ_ОБРАЗА> .

Проверяем docker history --no-trunc.

Значение SECRET там есть. Оно сохраняется в инструкции, использованной при создании слоя, и в поле history.created_by конфигурационного JSON. Секрет не попал в окружение контейнера. Он просто остался в истории сборки.

Следующий вариант совсем на "верочку". Создать временный файл и удалить его внутри одного RUN.

FROM alpine:latestRUN echo "SUPER_SECRET_KOTY_HULIGANY_2026" > secret.txt \ && rm secret.txtCMD ["sh"]

Файл создаётся и тут же удаляется. В сжатых слоях значение секрета не находится, а в конфигурационном JSON находится.

RUN /bin/sh -c echo "SUPER_SECRET_KOTY_HULIGANY_2026" > secret.txt && rm secret.txt # buildkit

Секрет сидит в history.created_by.

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

Можно попробовать перезапись.

FROM alpine:latestCOPY secret.txt /secret.txtRUN echo CLEAN > /secret.txtCMD ["sh"]

В контейнере:

CLEAN

Всё чисто.

Берём слой, созданный COPY, извлекаем из него secret.txt.

Ага! А там:

SUPER_SECRET_KOTY_HULIGANY_2026

Настоящий секрет никуда не делся. В контейнере уже CLEAN, а предыдущий слой хранит старое значение.

На этом этапе стало понятно, где вообще оседают данные: в слоях, в конфигурации и в истории сборки. Удаление файла после того, как он уже попал в слой, ничего не меняет с самим слоем. COPY оставляет секрет в слое, COPY + rm оставляет его в предыдущем слое, секрет в конфигурацию кладет ENV, ARG оставляет в истории сборки. Создание и удаление внутри одного RUN оставляет его в history.created_by. Перезапись файла оставляет исходное значение в предыдущем слое. Шесть способов. Секрет после сборки можно достать.

И тут появляется BuildKit Secrets. Наконец-то механизм, который не пытается удалить секрет после того, как уже записал его в образ. Секрет монтируется только на время выполнения RUN.

syntax=docker/dockerfile:1.7FROM alpine:latestRUN --mount=type=secret,id=mysecret \ cat /run/secrets/mysecretCMD ["sh"]

Передача:

DOCKER_BUILDKIT=1 docker build \ --secret id=mysecret,src=<ПУТЬ_К_ФАЙЛУ_С_СЕКРЕТОМ> \ -t <ИМЯ_ОБРАЗА> .

В истории остаётся только:

RUN /bin/sh -c cat /run/secrets/mysecret # buildkit

Значения секрета там нет.

Сохраняем образ. Распаковываем слои. Проверяем:

zgrep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <ФАЙЛЫ_СЛОЕВ>

Ничего.

Проверяем конфигурационные JSON:

grep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <КОНФИГУРАЦИОННЫЕ_JSON_ФАЙЛЫ>

Тоже ничего.

BuildKit Secrets действительно не оставляет значение в готовом Docker-образе: его нет в слоях, конфигурации и истории. Вот здесь секрет не пришлось удалять. Он просто не попал в образ.

Есть ещё multi-stage build.

FROM alpine:latest AS builderRUN echo "SUPER_SECRET_KOTY_HULIGANY_2026" > /tmp/secret.txtFROM alpine:latestCMD ["sh"]

На первом этапе создаём /tmp/secret.txt.

На втором начинается новая сборка на чистом alpine. Секретный файл туда не копируется.

Проверяем контейнер:

docker run --rm <ИМЯ_ОБРАЗА> cat /tmp/secret.txt

Файла нет.

Проверяем финальный образ:

docker history --no-trunc <ИМЯ_ОБРАЗА>docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tarmkdir imagetar -xf <ИМЯ_АРХИВА>.tar -C imagegrep -R -Fq "SUPER_SECRET_KOTY_HULIGANY_2026" image && echo "НАЙДЕН" || echo "НЕ НАЙДЕН"

НЕ НАЙДЕН.

Проверяем сжатые слои и конфигурацию. Секрета нет. Промежуточный этап мог содержать секрет. Финальный образ его не получил. Секрет нельзя переносить в финальный stage. Протащили его туда сами — он снова окажется в образе. Multi-stage спасает ровно до момента, когда секрет действительно остаётся промежуточным.

Итого восемь способов передачи одного секрета.

COPY — слой Docker-образа.

COPY + rm — предыдущий слой.

ENV — конфигурация образа.

ARG — история сборки и конфигурация.

BuildKit Secrets — не сохраняется.

Multi-stage Build — не сохраняется, если секрет не переносится в финальный образ.

RUN echo + rm — история сборки и конфигурация.

Перезапись файла — предыдущий слой.

Docker появился в 2013 году. В конце 2018 года появился BuildKit Secrets. Пять с половиной лет ушло на то, чтобы добавить временное монтирование секрета при сборке и перестать сохранять его в итоговом образе.

Пять с половиной лет...

До сих пор, а нынче 2026, нормальные люди — а я считаю нормальными людей, которые удаление понимают как, не поверите, удаление, а не прикрывание новым слоем — попадаются на убогую до идиотизма архитектуру Докера.

7
5
5
1