Секрет попал в Dockerfile, разработчик спохватился и добавил RUN rm. Файла в контейнере больше нет — значит, всё в порядке? Нет: строку по-прежнему можно достать из образа, даже не запуская контейнер. Я собрала восемь образов, в каждом передала секрет своим способом и разобрала результат на слои и метаданные. Ниже — где именно оседает секрет в каждом случае и какие два способа действительно не оставляют следов.
Все эксперименты выполнены на Ubuntu 24.04.1 LTS, Docker Engine 29.1.3, BuildKit v0.26.2.
Эксперимент №1. COPY
Начнём с самого частого варианта — секретный файл кладут в образ инструкцией COPY.
Dockerfile:
FROM alpine:latest WORKDIR /app COPY secret.txt . CMD ["sh"]
В файл secret.txt кладём тестовую строку: SUPER_SECRET_KOTY_HULIGANY_2026
Собираем образ: docker build -t <ИМЯ_ОБРАЗА> . — и проверяем, остался ли файл внутри.
Сначала смотрим историю сборки: docker history --no-trunc <ИМЯ_ОБРАЗА>
В истории присутствует инструкция COPY и путь, по которому файл был добавлен в образ. При этом содержимое файла не отображается.
docker history позволяет увидеть, что файл участвовал в сборке, но не показывает его содержимое. Поэтому следующим шагом проверяем сами слои образа.
Сохраняем образ: docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
Распаковываем архив и находим слой с файлом: app/secret.txt
Извлекаем файл из слоя — содержимое сохранилось: SUPER_SECRET_KOTY_HULIGANY_2026

То, что docker history не показал содержимое файла, ещё не значит, что секрета в образе нет. Файл, добавленный через COPY, остается внутри одного из слоев и может быть извлечен без запуска контейнера.
Эксперимент №2. COPY и удаление файла
Обычно это чинят так — используют файл во время сборки, а потом удаляют. Проверяем, исчезает ли секрет из Docker-образа после удаления файла отдельной командой RUN.
Dockerfile:
FROM alpine:latest COPY secret.txt /secret.txt RUN rm /secret.txt CMD ["sh"]
Собираем образ: docker build -t <ИМЯ_ОБРАЗА> .
Проверяем контейнер: docker run --rm <ИМЯ_ОБРАЗА> cat /secret.txt
Результат:
cat: can't open '/secret.txt': No such file or directory
Но из образа секрет никуда не делся: значение осталось в предыдущем слое, и его можно восстановить.
Проверяем: docker history --no-trunc <ИМЯ_ОБРАЗА>
В истории остаются обе операции:
COPY secret.txt /secret.txt
RUN rm /secret.txt

Первый слой добавляет файл, второй — удаляет. Но удаление здесь означает лишь пометку в новом слое: данные первого слоя остаются нетронутыми.
Теперь сохраняем образ: docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
После распаковки проверяем слои.
Секрет находится в предыдущем слое:
SUPER_SECRET_KOTY_HULIGANY_2026
Команда rm удаляет файл из текущего состояния контейнера, но не удаляет данные из уже созданного слоя.
Эксперимент №3. ENV
В предыдущих экспериментах секрет был обычным файлом. С переменными окружения ситуация отличается. Файла внутри контейнера нет вообще — значение лежит в конфигурации образа.
Dockerfile:
FROM alpine:latest ENV SECRET=SUPER_SECRET_KOTY_HULIGANY_2026 CMD ["sh"]
Собираем образ: docker build -t <ИМЯ_ОБРАЗА> .
После сборки образ сохраняем: docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
Распаковываем архив и анализируем содержимое образа.
В конфигурационном JSON-файле образа находится секция config.Env, где сохраняется значение переменной:
SECRET=SUPER_SECRET_KOTY_HULIGANY_2026
В этом же файле в секции history присутствует инструкция ENV, использованная при сборке.

Дополнительно проверяем историю сборки:
docker history --no-trunc <ИМЯ_ОБРАЗА>
Команда показывает полную инструкцию ENV вместе со значением переменной.

В отличие от файлов, которые приходится искать по слоям, значение переменной лежит прямо в конфигурации образа — искать не нужно.
Вывод: ENV для секретов не подходит.
Эксперимент №4. ARG
ARG выбирают как раз потому, что значение живёт только во время сборки.
В отличие от ENV, переменная не должна появляться в окружении готового контейнера. Поэтому возникает вопрос: остается ли что-то от переданного значения после создания образа?
Dockerfile:
FROM alpine:latest ARG SECRET RUN echo "Build completed" CMD ["sh"]
Собираем образ, передавая значение через --build-arg:
docker build \ --build-arg SECRET=SUPER_SECRET_KOTY_HULIGANY_2026 \ -t <ИМЯ_ОБРАЗА> .
После сборки проверяем историю образа:
docker history --no-trunc <ИМЯ_ОБРАЗА>
В выводе команды значение переменной SECRET сохраняется в истории сборки и отображается в инструкции, которая использовалась при создании слоя.

Далее проверяем содержимое конфигурации образа:
docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar mkdir image tar -xf <ИМЯ_АРХИВА>.tar -C image cat <КОНФИГУРАЦИОННЫЙ_JSON_ФАЙЛ> | grep SECRET
В секции config.Env переменная SECRET отсутствует. При этом значение сохраняется в поле history.created_by внутри конфигурационного JSON образа.

Значение, переданное через --build-arg, в окружение контейнера не попало — зато осталось в истории сборки и в поле history.created_by. Для передачи секретов ARG не годится.
Эксперимент №5. BuildKit Secrets
До сих пор секрет оседал либо в слоях, либо в метаданных. Теперь проверим механизм, который Docker сделал специально для временных данных.
В этом случае секрет не копируется внутрь образа. Он монтируется только на время выполнения команды RUN.
Dockerfile:
# syntax=docker/dockerfile:1.7 FROM alpine:latest RUN --mount=type=secret,id=mysecret \ cat /run/secrets/mysecret CMD ["sh"]
Передаем секрет параметром --secret:
DOCKER_BUILDKIT=1 docker build \ --secret id=mysecret,src=<ПУТЬ_К_ФАЙЛУ_С_СЕКРЕТОМ> \ -t <ИМЯ_ОБРАЗА> .
Примечание: начиная с Docker 23.0 BuildKit включён по умолчанию. Здесь DOCKER_BUILDKIT=1 указан явно — чтобы было видно, какой билдер работает, и чтобы команды воспроизводились на любых версиях.
После сборки проверяем историю образа: docker history --no-trunc <ИМЯ_ОБРАЗА>
В истории остается только информация о том, что во время сборки использовался временный секрет:
RUN /bin/sh -c cat /run/secrets/mysecret # buildkit
Значение секрета при этом не отображается.

Далее экспортируем образ и распаковываем его:
docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar mkdir image tar -xf <ИМЯ_АРХИВА>.tar -C image
Проверяем содержимое файлов слоев:
zgrep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <ФАЙЛЫ_СЛОЕВ>

Команда не обнаружила значение секрета в содержимом слоев образа.
Дополнительно проверим конфигурационные JSON-файлы образа:
grep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <КОНФИГУРАЦИОННЫЕ_JSON_ФАЙЛЫ>

Значение секрета также отсутствует.
BuildKit Secrets не оставляет секрет в готовом Docker-образе. Проверка показала, что значение секрета отсутствует в истории сборки, не обнаруживается в содержимом слоев и не сохраняется в конфигурации образа.
Эксперимент №6. Multi-stage Build
Multi-stage build позволяет разделить процесс сборки на несколько этапов. Промежуточные этапы могут содержать временные файлы и инструменты, которые не должны попасть в финальный образ.
В этом эксперименте проверим, останется ли секрет в финальном образе, если он используется только на промежуточном этапе сборки.
Dockerfile:
FROM alpine:latest AS builder RUN echo "SUPER_SECRET_KOTY_HULIGANY_2026" > /tmp/secret.txt FROM alpine:latest CMD ["sh"]
На первом этапе (builder) создается файл:
/tmp/secret.txt
На втором этапе начинается новая сборка на основе чистого образа alpine. Файл с секретом из промежуточного этапа в финальный образ не копируется.
Собираем образ:
docker build -t <ИМЯ_ОБРАЗА> .
После сборки проверяем, присутствует ли файл в готовом контейнере:
docker run --rm <ИМЯ_ОБРАЗА> cat /tmp/secret.txt
Затем анализируем историю образа и его слои:
docker history --no-trunc <ИМЯ_ОБРАЗА>
docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
mkdir image tar -xf <ИМЯ_АРХИВА>.tar -C image grep -R -Fq "SUPER_SECRET_KOTY_HULIGANY_2026" image && echo "НАЙДЕН" || echo "НЕ НАЙДЕН"

На скриншоте видно, что:
образ
exp6-multistageуспешно собран;файл
/tmp/secret.txtотсутствует в готовом контейнере:
cat: can't open '/tmp/secret.txt': No such file or directory
в истории финального образа отсутствует команда, создавшая секрет на промежуточном этапе;
поиск по слоям образа завершился результатом
НЕ НАЙДЕН.
Дополнительно проверяем содержимое сжатых слоёв и конфигурационные JSON-файлы образа:
zgrep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <ФАЙЛЫ_СЖАТЫХ_СЛОЕВ>
grep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <КОНФИГУРАЦИОННЫЕ_JSON_ФАЙЛЫ>

Обе команды не обнаружили значение секрета.
Секрет, использовавшийся только на промежуточном этапе сборки, в финальный Docker-образ не попал. Он отсутствует в контейнере, не отображается в истории финального образа, не обнаруживается в содержимом слоев и отсутствует в конфигурации образа.
Этот способ действительно позволяет исключить секреты из итогового образа, если они остаются только на промежуточных этапах сборки и не копируются в финальный stage. Способ безопасен ровно до тех пор, пока это условие выполняется.
Эксперимент №7. Удаление секрета в пределах одного RUN
Еще один распространенный способ, который используют разработчики, — создать временный файл с секретом во время сборки, выполнить необходимые действия, а затем удалить этот файл в рамках одной команды RUN.
Dockerfile:
FROM alpine:latest RUN echo "SUPER_SECRET_KOTY_HULIGANY_2026" > secret.txt \ && rm secret.txt CMD ["sh"]
Файл создается и удаляется внутри одной инструкции:
echo "SUPER_SECRET_KOTY_HULIGANY_2026" > secret.txt && rm secret.txt
Собираем образ: docker build -t <ИМЯ_ОБРАЗА> .
После сборки проверяем историю образа: docker history --no-trunc <ИМЯ_ОБРАЗА>
Затем сохраняем образ, распаковываем его и проверяем содержимое сжатых файлов слоев и конфигурационные JSON-файлы образа:
docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar mkdir image tar -xf <ИМЯ_АРХИВА>.tar -C image zgrep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <ФАЙЛЫ_СЖАТЫХ_СЛОЕВ> grep -a "SUPER_SECRET_KOTY_HULIGANY_2026" <КОНФИГУРАЦИОННЫЕ_JSON_ФАЙЛЫ>

На скриншоте видно, что команда создания и удаления файла сохранилась в истории образа:
RUN /bin/sh -c echo "SUPER_SECRET_KOTY_HULIGANY_2026" > secret.txt && rm secret.txt # buildkit
Поиск в содержимом сжатых файлов слоев не обнаружил значение секрета. Проверка конфигурационного JSON показала, что значение секрета сохраняется в поле history.created_by. В результате удаление файла в пределах одной инструкции RUN не защищает секрет от попадания в Docker-образ. Файл действительно удаляется во время сборки, но значение остаётся доступным. Это подтверждает история образа.
Эксперимент №8. Перезапись секрета
Останется ли секрет в образе, если перезаписать файл? Добавляем файл, а следующей инструкцией заменяем содержимое на безобидную строку.
Dockerfile:
FROM alpine:latest COPY secret.txt /secret.txt RUN echo CLEAN > /secret.txt CMD ["sh"]
Во время сборки команда COPY добавляет файл в образ, а следующая инструкция RUN заменяет его содержимое на строку CLEAN.
Собираем образ: docker build -t <ИМЯ_ОБРАЗА> .
После сборки проверяем содержимое файла в готовом контейнере: docker run --rm <ИМЯ_ОБРАЗА> cat /secret.txt
Затем проверяем историю образа: docker history --no-trunc <ИМЯ_ОБРАЗА>
После этого сохраняем образ, распаковываем его, определяем слой, созданный инструкцией COPY, и извлекаем из него файл secret.txt:
docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar mkdir image tar -xf <ИМЯ_АРХИВА>.tar -C image cat image/manifest.json tar -xf <ПУТЬ_К_СЛОЮ_COPY> -O secret.txt

На скриншоте видно, что в контейнере файл /secret.txt содержит значение CLEAN. История образа включает отдельные инструкции COPY и RUN, а при извлечении слоя, созданного командой COPY, отображается исходное содержимое файла.
Перезапись файла не спасает. После выполнения инструкции RUN в контейнере действительно остается только новое значение, однако слой, созданный командой COPY, сохраняет исходное содержимое файла.\
Что показали эксперименты
Секрет оседает в трёх местах: в слоях образа, в его конфигурации и в истории сборки. Удалить или перезаписать файл после того, как он попал в слой, не поможет — слои неизменяемы. Из рассмотренных способов только BuildKit Secrets и multi-stage build (без переноса секрета в финальный stage) не оставляют секрет в итоговом Docker-образе.
Способ | Где остается секрет | Можно извлечь после сборки | Использовать для секретов |
|---|---|---|---|
COPY | слой Docker-образа | Да | Нет |
COPY + rm | предыдущий слой | Да | Нет |
ENV | конфигурация образа | Да | Нет |
ARG | история сборки, конфигурация образа | Да | Нет |
BuildKit Secrets | не сохраняется в образе | Нет | Да |
Multi-stage Build | зависит от финального образа | Только если секрет попал в финальный образ | Только без переноса секрета |
RUN echo + rm | история сборки, конфигурация образа | Да | Нет |
Перезапись файла | предыдущий слой | Да | Нет |
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

