Секрет попал в 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

Поиск и извлечение файла secret.txt из слоя Docker-образа
Поиск и извлечение файла secret.txt из слоя Docker-образа

То, что 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, использованная при сборке.

Секрет в конфигурации образа (config.Env) и инструкция ENV в истории сборки (history)
Секрет в конфигурации образа (config.Env) и инструкция ENV в истории сборки (history)

Дополнительно проверяем историю сборки:

docker history --no-trunc <ИМЯ_ОБРАЗА>

Команда показывает полную инструкцию ENV вместе со значением переменной.

Вывод команды docker history --no-trunc
Вывод команды docker history --no-trunc

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

Вывод: 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 history --no-trunc
Результат выполнения docker history --no-trunc

Далее проверяем содержимое конфигурации образа:

docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
mkdir image
tar -xf <ИМЯ_АРХИВА>.tar -C image
cat <КОНФИГУРАЦИОННЫЙ_JSON_ФАЙЛ> | grep SECRET

В секции config.Env переменная SECRET отсутствует. При этом значение сохраняется в поле history.created_by внутри конфигурационного JSON образа.

Значение секрета в history.created_by
Значение секрета в history.created_by

Значение, переданное через --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-образа
Проверка истории Docker-образа

Далее экспортируем образ и распаковываем его:

docker save <ИМЯ_ОБРАЗА> -o <ИМЯ_АРХИВА>.tar
mkdir image
tar -xf <ИМЯ_АРХИВА>.tar -C image

Проверяем содержимое файлов слоев:

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

Проверка содержимого слоев Docker-образа
Проверка содержимого слоев Docker-образа

Команда не обнаружила значение секрета в содержимом слоев образа.

Дополнительно проверим конфигурационные JSON-файлы образа:

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

Проверка конфигурации Docker-образа
Проверка конфигурации Docker-образа

Значение секрета также отсутствует.

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 "НЕ НАЙДЕН"
Проверка финального образа после multi-stage сборки
Проверка финального образа после multi-stage сборки

На скриншоте видно, что:

  • образ 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-образа
Проверка содержимого сжатых слоев и конфигурации Docker-образа

Обе команды не обнаружили значение секрета.

Секрет, использовавшийся только на промежуточном этапе сборки, в финальный 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_ФАЙЛЫ>
Проверка истории образа, содержимого слоев и конфигурационного JSON
Проверка истории образа, содержимого слоев и конфигурационного 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
Проверка содержимого контейнера и слоев Docker-образа после перезаписи секрета
Проверка содержимого контейнера и слоев Docker-образа после перезаписи секрета

На скриншоте видно, что в контейнере файл /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.

Положение об акции