Начну с ситуации, которую видел не раз. Разработчик пишет Dockerfile, добавляет секрет для настройки чего-либо, затем удаляет файл несколькими строками ниже и пушит образ. С точки зрения контейнера файл исчез. Но секрет по-прежнему находится в образе — навсегда — и любой, у кого есть доступ к образу, может его извлечь с помощью tar и нескольких команд оболочки.
В этой статье мы воспроизведём именно такой сценарий с нуля, изучим внутреннее устройство образа и самостоятельно извлечём секрет. К концу вы поймёте, почему это происходит и как этого избежать.
Что потребуется
Перед началом убедитесь, что у вас есть:
-
Установленный и запущенный Docker
-
Базовое знакомство с Dockerfile
-
Утилита
jq(apt install jq/brew install jq) -
Терминал
Больше ничего не нужно.
Настройка демонстрации
Создадим простой Dockerfile, воспроизводящий типичную ошибку. Мы записываем секрет в файл, а затем удаляем его на следующем шаге — точно так же, как это делается при запуске скрипта установки, которому нужны учётные данные:
FROM alpine
RUN echo "supersecret_api_key_123" > secret.txt
ENV DB_PASSWORD=Str0ngP@ssw0rd
RUN rm secret.txt
ENTRYPOINT ["sh", "-c", "sleep 60"]
Собираем образ:
$ docker build -t myapp:1.0 .
Запускаем контейнер и проверяем, есть ли там файл с секретом:
$ docker run --rm myapp:1.0 cat secret.txt
cat: can't open 'secret.txt': No such file or directory
Отлично — изнутри контейнера файл исчез. Всё выглядит чисто.
Но давайте покопаемся глубже.
Что показывает docker history
Первое, что стоит сделать с любым образом, — запустить docker history. Эта команда показывает каждую инструкцию, использованную при сборке:
$ docker history myapp:1.0
IMAGE CREATED CREATED BY SIZE
a3f9d2c81b4e 2 min ago ENTRYPOINT ["sh" "-c" "sleep 60"] 0B
<missing> 2 min ago RUN /bin/sh -c rm secret.txt # buildkit 0B
<missing> 2 min ago ENV DB_PASSWORD=Str0ngP@ssw0rd 0B
<missing> 2 min ago RUN /bin/sh -c echo "supersecret..." > secre… 28B
<missing> 5 weeks ago CMD ["/bin/sh"] 0B
<missing> 5 weeks ago ADD alpine-minirootfs-...tar.gz / # buildkit 8.45MB
Внимательно посмотрите на столбец SIZE.
Слой RUN rm secret.txt занимает 0 байт — он ничего не добавил в файловую систему, а лишь создал маркер удаления. Слой RUN echo выше него занимает 28 байт — это и есть наш файл с секретом, лежащий ровно там, где мы его оставили.
Строка ENV DB_PASSWORD тоже видна открытым текстом прямо в выводе истории.
Как устроены слои Docker
Чтобы понять, почему файл никуда не делся, нужно разобраться, как Docker хранит слои.
Каждая инструкция в Dockerfile, изменяющая файловую систему, создаёт новый неизменяемый слой (read-only layer). Docker складывает эти слои друг на друга с помощью объединённой файловой системы (union filesystem; overlayfs в Linux). Когда контейнер запускается, все слои объединяются и представляются как единая файловая система.
Главное, что нужно понять: слои неизменяемы и только добавляют данные (immutable and additive). Когда вы удаляете файл в следующем слое, Docker не трогает предыдущий. Вместо этого в новый слой добавляется специальный файл-маркер — вайтаут (whiteout), файл с именем .wh.<имя_исходного_файла>. Объединённая файловая система видит этот маркер и скрывает исходный файл от работающего контейнера.
Исходный файл по-прежнему находится в своём слое — полностью нетронутым.
Извлечение секрета
Теперь давайте на практике вытащим секрет. Никаких специальных инструментов не нужно — только docker save и tar.
Шаг 1 — Экспортируем образ в tar-архив:
$ docker save myapp:1.0 -o myapp.tar
$ mkdir myapp-extracted && tar xf myapp.tar -C myapp-extracted/
$ ls myapp-extracted/
blobs/ index.json manifest.json oci-layout repositories
Шаг 2 — Читаем манифест, чтобы увидеть список слоёв:
$ cat myapp-extracted/manifest.json
[{
"Config": "blobs/sha256/a3f9d2c81b4e...",
"RepoTags": ["myapp:1.0"],
"Layers": [
"blobs/sha256/29df493baa13...",
"blobs/sha256/779700a0de6f...",
"blobs/sha256/05093f0d1704..."
]
}]
Массив Layers перечисляет слои в порядке их создания — том же порядке, в каком следуют инструкции Dockerfile. Слой 0 — это alpine, слой 1 — наш RUN echo, слой 2 — наш RUN rm.
Шаг 3 — Просматриваем содержимое каждого слоя файловой системы:
$ tar tf blobs/sha256/779700a0de6f...
secret.txt
$ tar tf blobs/sha256/05093f0d1704...
.wh.secret.txt
Флаг t выводит содержимое архива без извлечения файлов, а f указывает tar читать из файла. Вот оно — слой 1 содержит secret.txt, слой 2 содержит .wh.secret.txt. Префикс .wh. — это маркер вайтаута, скрывающий файл во время выполнения. Оригинал так и не был затронут.
Шаг 4 — Читаем секрет:
$ tar xOf blobs/sha256/779700a0de6f... secret.txt
supersecret_api_key_123
Флаг x извлекает файл, O (заглавная буква O) выводит результат в stdout вместо записи на диск, f читает из архива. Секрет — вот он. Извлечён менее чем за минуту, без специальных инструментов и повышенных привилегий.
Переменная ENV извлекается ещё проще
Чтобы найти DB_PASSWORD, нам даже не пришлось лезть в слои. Переменные среды, заданные через ENV, хранятся в конфигурации образа — их можно прочитать напрямую через docker inspect, без всякой распаковки архивов:
$ docker inspect myapp:1.0 --format '{{json .Config.Env}}' | jq .
[
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"DB_PASSWORD=Str0ngP@ssw0rd"
]
Если вы работаете с экспортированным tar-архивом (например, кто-то передал вам файл образа без доступа к Docker), конфигурационный блоб внутри архива — это файл, указанный в поле "Config" в manifest.json. В некоторых версиях Docker он экспортируется как gzip-сжатый бинарный файл, а не как обычный JSON, поэтому cat может выдавать ошибки кодировки. Надёжный способ — доверить разбор jq:
$ jq '.config.Env' blobs/sha256/a3f9d2c81b4e...
В любом случае — извлекать слои не нужно. Любой, у кого есть доступ к образу, может прочитать все значения ENV за секунды.
Кто может это сделать?
Любой, у кого есть доступ на чтение к образу. На практике это означает:
-
Каждый разработчик, способный выполнить
docker pullиз вашего реестра -
Ваша CI/CD-система, если она хранит образы как артефакты сборки
-
Любой, кто получил
.tar-экспорт образа -
Все без исключения, если образ опубликован на Docker Hub как публичный
Правильный способ работы с секретами
Теперь, когда мы разобрались с проблемой, поговорим о том, как её решить.
1. Используйте монтирование секретов BuildKit на этапе сборки
Если секрет нужен во время сборки — например, для аутентификации в приватном реестре пакетов — используйте --mount=type=secret из BuildKit. Секрет становится доступен как временный файл только внутри этого единственного шага сборки и никогда не записывается ни в один слой:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_key \
cat /run/secrets/api_key | ./setup.sh
$ docker build --secret id=api_key,src=./api_key.txt -t myapp:1.0 .
2. Передавайте секреты во время выполнения, а не во время сборки
Самый чистый подход — вообще не помещать секреты в образ. Передавайте их при запуске контейнера:
$ docker run -e DB_PASSWORD=$(vault kv get -field=password secret/db) myapp:1.0
Либо используйте Docker Secrets (режим Swarm) или Kubernetes Secrets, монтируемые в под как файлы.
3. Проверяйте уже существующие образы
Если вы не уверены, содержат ли ваши текущие образы секреты, такие инструменты, как Trivy и Trufflehog, умеют сканировать слои образов на наличие паттернов, характерных для учётных данных. Стоит прогнать их по своему реестру, прежде чем считать всё чистым.
4. Объединение слоёв как крайняя мера
Squash-сборка объединяет все слои в один, удаляя промежуточную историю:
$ docker build --squash -t myapp:1.0 .
Это смягчение проблемы, а не её решение. Секрет всё равно существовал в реальном слое в момент сборки и мог быть перехвачен тогда. Используйте этот подход только тогда, когда изменить процесс сборки невозможно.
Итог
Правило простое: если данные когда-либо были записаны в слой, их можно прочитать обратно. Единственный надёжный подход — не записывать секреты туда вообще.