Убогая архитектура 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, нормальные люди — а я считаю нормальными людей, которые удаление понимают как, не поверите, удаление, а не прикрывание новым слоем — попадаются на убогую до идиотизма архитектуру Докера.