11.5. Конфигурация и secrets
Цели
После этого материала вы сможете:
- показать, где именно
ARGс секретом остаётся в готовом образе; - использовать
RUN --mount=type=secretи доказать, что секрет не попал ни в один слой; - выбрать способ доставки секрета в работающий container по критериям, а не по привычке;
- организовать получение секретов из внешнего хранилища при старте;
- спланировать ротацию и понять, почему она требует перезапуска;
- разделить конфигурацию по окружениям без пересборки образа.
Предварительные знания
- 6.4. Переменные окружения;
- 9.5. Environment и secrets — Compose secrets;
- 5.6. BuildKit.
Механика Compose secrets разобрана в уроке 9.5. Здесь — секреты при сборке, внешние хранилища и ротация.
Ключевые термины
| Термин | Объяснение |
|---|---|
build secret | Секрет, доступный только во время выполнения одной инструкции RUN |
secret mount | Монтирование секрета в файловую систему сборки |
layer | Слой образа; его содержимое неизменяемо и доступно всем |
ротация | Плановая замена секрета на новый |
внешнее хранилище | Vault, AWS Secrets Manager и аналоги |
Теория
Два разных вопроса
| Вопрос | Где решается |
|---|---|
| Как получить приватный пакет при сборке? | RUN --mount=type=secret |
| Как передать пароль работающему приложению? | Файл, Compose secret, внешнее хранилище |
Их часто смешивают, и тогда токен для сборки оказывается в ENV готового образа — то есть доступен всем, кто этот образ скачает.
Почему ARG не годится для секретов
ARG PIP_INDEX_TOKEN # НЕ ДЕЛАЙТЕ ТАК
RUN pip install --index-url https://user:${PIP_INDEX_TOKEN}@pypi.example.com/simple mypackage
Значение попадает в историю слоёв: инструкция RUN сохраняется целиком, с подставленным значением.
| Где виден секрет | Команда |
|---|---|
| История слоёв | docker history --no-trunc |
| Метаданные образа | docker image inspect |
| Экспортированный образ | docker save плюс распаковка |
| В реестре | Любому, у кого есть доступ на чтение |
Удалить его нельзя: слои неизменяемы. Единственный выход после утечки — отозвать секрет.
Тот же дефект у ENV: значение остаётся в конфигурации образа.
RUN --mount=type=secret
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=pip_token \
PIP_TOKEN="$(cat /run/secrets/pip_token)" \
pip install --index-url "https://user:${PIP_TOKEN}@pypi.example.com/simple" mypackage
docker build --secret id=pip_token,src=./token.txt -t app .
| Свойство | Значение |
|---|---|
| Доступен во время | Только этой инструкции RUN |
| Попадает в слой | Нет |
Виден в docker history | Нет |
| Путь по умолчанию | /run/secrets/<id> |
| Источник | Файл (src=) или переменная (env=) |
| Требует BuildKit | Да, он по умолчанию с Engine 23 |
Ключевое: секрет монтируется как tmpfs на время выполнения инструкции и исчезает вместе с ней. Слой содержит результат установки, но не сам секрет.
Важная деталь: значение не должно попасть в строку команды в открытом виде, иначе оно окажется в истории через переменную. Пример выше читает файл внутри RUN — значение не выходит за пределы шага.
Способы доставки в работающий container
| Способ | Виден в inspect | Ротация без пересборки | Сложность |
|---|---|---|---|
ENV в Dockerfile | Да, и в образе | Нет | Низкая |
-e при запуске | Да | Перезапуск | Низкая |
| Файл через bind mount | Нет | Перезапуск | Низкая |
| Compose secret | Нет | Перезапуск | Низкая |
| Внешнее хранилище | Нет | Возможна на лету | Высокая |
| Sidecar-агент | Нет | Возможна на лету | Высокая |
Первая строка — единственная категорически неприемлемая: секрет попадает в образ и распространяется вместе с ним.
Вторая приемлема с оговорками: значение видно в docker inspect и наследуется потомками (урок 9.5), но хотя бы не в образе.
Соглашение _FILE
Официальные образы баз данных читают секрет из файла, если переменная оканчивается на _FILE:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
Своему приложению это добавляется десятком строк и снимает необходимость выбирать между удобством и безопасностью (урок 9.5).
Приоритет должен быть у файла: если заданы оба источника, побеждает более безопасный.
Внешние хранилища
приложение ──запрос──► хранилище (Vault, Secrets Manager)
│ │
│◄──── секрет ─────────┘
│ TTL 1 час
▼
использует, перезапрашивает до истечения
| Что даёт | Цена |
|---|---|
| Секрет не хранится на узле | Зависимость от доступности хранилища |
| Ротация без перезапуска | Код должен уметь перечитывать |
| Аудит доступа | Настройка политик |
| Короткоживущие учётные данные | Сложность |
Ключевая проблема — начальная аутентификация: чтобы получить секреты, нужен секрет. Решения: токен из окружения платформы, идентичность экземпляра в облаке, Kubernetes ServiceAccount.
Для одного узла с Compose внешнее хранилище обычно избыточно. Оно окупается при десятках сервисов и требованиях аудита.
Ротация
| Способ доставки | Что нужно для смены секрета |
|---|---|
ENV в образе | Пересборка и передеплой |
-e или Compose secret | Перезапуск container'а |
| Файл через bind mount | Перезапуск (файл читается при старте) |
| Файл плюс перечитывание по сигналу | Отправить SIGHUP |
| Внешнее хранилище с TTL | Ничего, само |
Compose secret монтируется при создании container'а: изменение файла на host не попадает внутрь до пересоздания.
Приложение, читающее секрет один раз при старте, ротацию без перезапуска не переживёт — независимо от способа доставки.
Конфигурация против секретов
| Конфигурация | Секрет | |
|---|---|---|
| Пример | LOG_LEVEL, DB_HOST, число worker'ов | Пароль, токен, приватный ключ |
| В репозитории | Да | Нет |
В docker inspect | Приемлемо | Нет |
| В логах | Приемлемо | Нет |
| Разное по окружениям | Да | Да |
Разделение важно: попытка защищать всю конфигурацию как секреты делает систему неудобной, и в итоге защищают плохо и то и другое.
Валидация при старте
Конфигурация проверяется до начала работы, а все ошибки сообщаются сразу (урок 6.4):
| Что проверять | Пример |
|---|---|
| Наличие обязательных значений | Пароль задан |
| Тип и диапазон | Порт — число от 1 до 65535 |
| Согласованность | WEB_CONCURRENCY не больше лимита памяти |
| Достижимость | Хост базы разрешается в адрес |
Последний пункт спорный: проверка достижимости при старте делает приложение зависимым от порядка запуска. Разумный компромисс — проверять формат, а связность оставить readiness-пробе (урок 11.3).
Внутренний механизм
Что именно сохраняется в истории слоёв
Каждая инструкция Dockerfile порождает запись с полем CreatedBy — текстом команды после подстановки ARG. Запись хранится в конфигурации образа и передаётся вместе с ним.
Инструкции RUN --mount=type=secret тоже сохраняются, но монтирование секрета в тексте не отражается — сохраняется только команда, а значение остаётся снаружи.
Где лежит смонтированный секрет
Во время сборки — tmpfs, доступная только процессу этой инструкции. Во время работы Compose secret — bind mount файла с host в /run/secrets/.
Отсюда следствие: на host секрет лежит обычным файлом. Compose secret не шифрует и не защищает — он лишь не даёт значению попасть в метаданные (урок 9.5).
Команды и примеры
ARG с секретом остаётся в образе
mkdir -p /tmp/secrets && cd /tmp/secrets
cat > Dockerfile.arg <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ARG BUILD_TOKEN
# Секрет подставляется в команду — она сохранится в истории целиком
RUN echo "используем токен: ${BUILD_TOKEN}" > /tmp/build.log \
&& rm -f /tmp/build.log
CMD ["python", "-c", "print('готово')"]
EOF
TOKEN="glpat-SECRET-abcdef1234567890"
docker build -q -f Dockerfile.arg --build-arg BUILD_TOKEN="$TOKEN" -t leak:arg . > /dev/null
echo "═══ ищем токен в готовом образе ═══"
printf ' в docker history: %s\n' \
"$(docker history --no-trunc leak:arg 2>/dev/null | grep -c "$TOKEN" || echo 0)"
printf ' в image inspect: %s\n' \
"$(docker image inspect leak:arg 2>/dev/null | grep -c "$TOKEN" || echo 0)"
echo "═══ где именно ═══"
docker history --no-trunc leak:arg 2>/dev/null | grep -o "используем токен: [^ ]*" | head -1 | sed 's/^/ /'
echo "═══ файл при этом удалён ═══"
docker run --rm leak:arg sh -c 'ls /tmp/build.log 2>&1 | tail -1' | sed 's/^/ /'
Ожидаемый вывод:
═══ ищем токен в готовом образе ═══
в docker history: 1
в image inspect: 0
═══ где именно ═══
используем токен: glpat-SECRET-abcdef1234567890
═══ файл при этом удалён ═══
ls: cannot access '/tmp/build.log': No such file or directory
Файл удалён, а токен остался — он в тексте инструкции, а не в содержимом слоя. Удаление файла ничего не меняет.
Любой, кто скачает образ, выполнит docker history --no-trunc и получит токен.
ENV с секретом — то же самое, но хуже
cd /tmp/secrets
cat > Dockerfile.env <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV API_TOKEN=sk-live-SECRET-9876543210
CMD ["python", "-c", "import os; print('токен есть:', bool(os.environ.get('API_TOKEN')))"]
EOF
docker build -q -f Dockerfile.env -t leak:env . > /dev/null
echo "═══ где виден ═══"
printf ' docker image inspect (Config.Env): %s\n' \
"$(docker image inspect leak:env --format '{{json .Config.Env}}' | grep -c 'sk-live-SECRET' || echo 0)"
printf ' docker history: %s\n' \
"$(docker history --no-trunc leak:env | grep -c 'sk-live-SECRET' || echo 0)"
printf ' в любом запущенном container: %s\n' \
"$(docker run --rm leak:env sh -c 'env | grep -c API_TOKEN')"
echo "═══ значение целиком ═══"
docker image inspect leak:env --format '{{range .Config.Env}}{{println " " .}}{{end}}' | grep API_TOKEN
Ожидаемый вывод:
═══ где виден ═══
docker image inspect (Config.Env): 1
docker history: 1
в любом запущенном container: 1
═══ значение целиком ═══
API_TOKEN=sk-live-SECRET-9876543210
ENV хуже ARG: секрет не только в истории, но и в конфигурации образа, и автоматически попадает в окружение каждого container'а из этого образа.
RUN --mount=type=secret: правильный способ
cd /tmp/secrets
echo -n "glpat-SECRET-abcdef1234567890" > token.txt
chmod 600 token.txt
cat > Dockerfile.secret <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
# Секрет доступен только внутри этой инструкции и не попадает в слой.
# Значение читается ВНУТРИ RUN: если подставить его в команду, оно
# окажется в истории через переменную.
RUN --mount=type=secret,id=build_token \
TOKEN="$(cat /run/secrets/build_token)" \
&& test -n "$TOKEN" \
&& echo "длина токена: ${#TOKEN}" \
&& echo "пакет установлен" > /opt/result.txt
CMD ["cat", "/opt/result.txt"]
EOF
docker build -q -f Dockerfile.secret --secret id=build_token,src=./token.txt \
-t safe:secret . > /dev/null
echo "═══ ищем токен в образе ═══"
TOKEN="$(cat token.txt)"
printf ' в docker history: %s\n' \
"$(docker history --no-trunc safe:secret 2>/dev/null | grep -c "$TOKEN" || echo 0)"
printf ' в image inspect: %s\n' \
"$(docker image inspect safe:secret 2>/dev/null | grep -c "$TOKEN" || echo 0)"
printf ' в файловой системе: %s\n' \
"$(docker run --rm safe:secret sh -c "grep -rl '$TOKEN' / 2>/dev/null | wc -l" 2>/dev/null || echo 0)"
echo "═══ что сохранилось в истории ═══"
docker history --no-trunc safe:secret 2>/dev/null | grep -o 'RUN.*build_token.*' | head -1 | cut -c1-100 | sed 's/^/ /'
echo "═══ результат сборки на месте ═══"
docker run --rm safe:secret | sed 's/^/ /'
echo "═══ секрет доступен ли внутри готового образа ═══"
docker run --rm safe:secret sh -c 'cat /run/secrets/build_token 2>&1 | tail -1' | sed 's/^/ /'
Ожидаемый вывод:
═══ ищем токен в образе ═══
в docker history: 0
в image inspect: 0
в файловой системе: 0
═══ что сохранилось в истории ═══
RUN /bin/sh -c TOKEN="$(cat /run/secrets/build_token)" && test -n "$TOKEN" && echo
═══ результат сборки на месте ═══
пакет установлен
═══ секрет доступен ли внутри готового образа ═══
cat: can't open '/run/secrets/build_token': No such file or directory
Ноль вхождений во всех трёх местах. В истории сохранилась команда, но не значение: $(cat /run/secrets/...) не раскрывается при сохранении.
Последний блок подтверждает, что монтирование исчезло вместе с инструкцией: в готовом образе файла нет.
Ошибка, которая сводит защиту на нет
cd /tmp/secrets
cat > Dockerfile.wrong <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
# ОШИБКА: значение подставляется в ARG, который сохраняется в истории
ARG TOKEN_ARG
RUN --mount=type=secret,id=build_token \
echo "секрет из mount: $(cat /run/secrets/build_token)" > /dev/null \
&& echo "а этот из ARG: ${TOKEN_ARG}" > /dev/null
CMD ["true"]
EOF
docker build -q -f Dockerfile.wrong \
--secret id=build_token,src=./token.txt \
--build-arg TOKEN_ARG="$(cat token.txt)" \
-t leak:mixed . > /dev/null
TOKEN="$(cat token.txt)"
echo "═══ смешанный вариант ═══"
printf ' вхождений токена в history: %s\n' \
"$(docker history --no-trunc leak:mixed | grep -c "$TOKEN" || echo 0)"
docker history --no-trunc leak:mixed | grep -o "а этот из ARG: [^\"]*" | head -1 | sed 's/^/ /'
Ожидаемый вывод:
═══ смешанный вариант ═══
вхождений токена в history: 1
а этот из ARG: glpat-SECRET-abcdef1234567890
Секрет из --mount не утёк, а тот же секрет через ARG — утёк. Наличие --mount=type=secret в файле не защищает остальные инструкции.
Правило: если секрет попал в ARG или ENV, он в образе. Проверять нужно поиском значения, а не наличием правильной конструкции.
Секрет из переменной окружения при сборке
cd /tmp/secrets
cat > Dockerfile.envsecret <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
RUN --mount=type=secret,id=api_key,env=API_KEY \
test -n "$API_KEY" && echo "ключ получен, длина ${#API_KEY}" > /opt/result.txt
CMD ["cat", "/opt/result.txt"]
EOF
API_KEY="ak-SECRET-from-env-0987654321" \
docker build -q -f Dockerfile.envsecret --secret id=api_key,env=API_KEY \
-t safe:envsecret . > /dev/null
echo "═══ результат ═══"
docker run --rm safe:envsecret | sed 's/^/ /'
printf ' вхождений в history: %s\n' \
"$(docker history --no-trunc safe:envsecret | grep -c 'ak-SECRET-from-env' || echo 0)"
Ожидаемый вывод:
═══ результат ═══
ключ получен, длина 29
вхождений в history: 0
Форма env= берёт значение из окружения сборки и монтирует его как переменную внутри RUN — удобно в CI, где секреты приходят из хранилища системы сборки, а не из файлов.
Сравнение всех вариантов
cd /tmp/secrets
echo "═══ сводка по образам ═══"
printf ' %-18s %-10s %-10s %-10s\n' "образ" "history" "inspect" "в FS"
for img in leak:arg leak:env leak:mixed safe:secret safe:envsecret; do
case "$img" in
*envsecret) needle="ak-SECRET-from-env" ;;
leak:env) needle="sk-live-SECRET" ;;
*) needle="glpat-SECRET" ;;
esac
h="$(docker history --no-trunc "$img" 2>/dev/null | grep -c "$needle" || echo 0)"
i="$(docker image inspect "$img" 2>/dev/null | grep -c "$needle" || echo 0)"
f="$(docker run --rm --entrypoint sh "$img" -c "grep -rl '$needle' /etc /opt /tmp 2>/dev/null | wc -l" 2>/dev/null || echo 0)"
printf ' %-18s %-10s %-10s %-10s\n' "$img" "$h" "$i" "$f"
done
Ожидаемый вывод:
═══ сводка по образам ═══
образ history inspect в FS
leak:arg 1 0 0
leak:env 1 1 0
leak:mixed 1 0 0
safe:secret 0 0 0
safe:envsecret 0 0 0
Таблица подводит итог: три способа утекают, два — нет. Колонка «в FS» везде нулевая — это подтверждает, что искать секрет в файлах бесполезно, утечка происходит через метаданные.
docker rmi -f leak:arg leak:env leak:mixed safe:secret safe:envsecret > /dev/null 2>&1
Соглашение _FILE с приоритетом файла
cd /tmp/secrets
cat > config.py <<'PY'
"""Конфигурация: файл имеет приоритет над переменной."""
from __future__ import annotations
import os
import sys
from pathlib import Path
def get_secret(name: str, default: str | None = None) -> str | None:
"""<name>_FILE → <name> → default.
Файл побеждает: если заданы оба, используется более безопасный источник.
"""
path = os.environ.get(f"{name}_FILE")
if path:
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError as exc:
raise RuntimeError(f"{name}_FILE={path}: {exc}") from exc
return os.environ.get(name, default)
def source_of(name: str) -> str:
if os.environ.get(f"{name}_FILE"):
return "файл"
if os.environ.get(name):
return "переменная"
return "умолчание"
def validate() -> int:
"""Все ошибки сразу: иначе исправление требует нескольких перезапусков."""
errors: list[str] = []
password = get_secret("DB_PASSWORD")
if not password:
errors.append("DB_PASSWORD или DB_PASSWORD_FILE обязателен")
raw_port = os.environ.get("DB_PORT", "5432")
try:
port = int(raw_port)
if not 1 <= port <= 65535:
errors.append(f"DB_PORT вне диапазона: {port}")
except ValueError:
errors.append(f"DB_PORT должен быть числом: {raw_port!r}")
level = os.environ.get("LOG_LEVEL", "INFO").upper()
if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
errors.append(f"LOG_LEVEL недопустим: {level}")
if errors:
print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
return 1
masked = f"{password[:3]}…({len(password)} симв.)" if password else "(нет)"
print(f" DB_PASSWORD источник={source_of('DB_PASSWORD'):<11} {masked}")
print(f" DB_PORT {port}")
print(f" LOG_LEVEL {level}")
return 0
if __name__ == "__main__":
sys.exit(validate())
PY
mkdir -p secret-files
echo -n "пароль-из-файла-длинный" > secret-files/db_password
chmod 600 secret-files/db_password
echo "═══ только переменная ═══"
docker run --rm -v "$PWD/config.py:/c.py:ro" \
-e DB_PASSWORD="пароль-из-переменной" python:3.13-slim python /c.py
echo "═══ только файл ═══"
docker run --rm -v "$PWD/config.py:/c.py:ro" \
-v "$PWD/secret-files/db_password:/run/secrets/db_password:ro" \
-e DB_PASSWORD_FILE=/run/secrets/db_password python:3.13-slim python /c.py
echo "═══ заданы оба — побеждает файл ═══"
docker run --rm -v "$PWD/config.py:/c.py:ro" \
-v "$PWD/secret-files/db_password:/run/secrets/db_password:ro" \
-e DB_PASSWORD="пароль-из-переменной" \
-e DB_PASSWORD_FILE=/run/secrets/db_password python:3.13-slim python /c.py
echo "═══ валидация: несколько ошибок сразу ═══"
docker run --rm -v "$PWD/config.py:/c.py:ro" \
-e DB_PORT=не-число -e LOG_LEVEL=ПОДРОБНО python:3.13-slim python /c.py 2>&1 | sed 's/^/ /'
Ожидаемый вывод:
═══ только переменная ═══
DB_PASSWORD источник=переменная пар…(20 симв.)
DB_PORT 5432
LOG_LEVEL INFO
═══ только файл ═══
DB_PASSWORD источник=файл пар…(23 симв.)
DB_PORT 5432
LOG_LEVEL INFO
═══ заданы оба — побеждает файл ═══
DB_PASSWORD источник=файл пар…(23 симв.)
DB_PORT 5432
LOG_LEVEL INFO
═══ валидация: несколько ошибок сразу ═══
ОШИБКА КОНФИГУРАЦИИ:
- DB_PASSWORD или DB_PASSWORD_FILE обязателен
- DB_PORT должен быть числом: 'не-число'
- LOG_LEVEL недопустим: ПОДРОБНО
Третий блок — ключевой: длина 23 символа соответствует значению из файла, а не из переменной. Приоритет отдан более безопасному источнику.
Четвёртый показывает три ошибки за один запуск: исправить их можно одной правкой конфигурации, а не тремя перезапусками (урок 6.4).
Ротация требует перезапуска
cd /tmp/secrets
cat > watcher.py <<'PY'
"""Приложение, читающее секрет один раз и по сигналу."""
import os
import signal
import sys
import time
from pathlib import Path
PATH = Path(os.environ.get("SECRET_PATH", "/run/secrets/token"))
_value = PATH.read_text().strip() if PATH.exists() else "(нет)"
def reload_secret(signum, _frame):
"""SIGHUP: перечитать секрет без перезапуска процесса."""
global _value
try:
_value = PATH.read_text().strip()
print(f"[SIGHUP] секрет перечитан: {_value}", flush=True)
except OSError as exc:
print(f"[SIGHUP] ошибка: {exc}", flush=True)
signal.signal(signal.SIGHUP, reload_secret)
print(f"старт, секрет: {_value}", flush=True)
while True:
time.sleep(1)
PY
mkdir -p rot
echo -n "версия-1" > rot/token
docker run -d --name rotate \
-v "$PWD/watcher.py:/w.py:ro" \
-v "$PWD/rot/token:/run/secrets/token:ro" \
python:3.13-slim python -u /w.py > /dev/null
sleep 2
echo "═══ исходное значение ═══"
docker logs rotate 2>&1 | tail -1 | sed 's/^/ /'
echo "═══ меняем файл на host ═══"
echo -n "версия-2-НОВАЯ" > rot/token
printf ' на host: %s\n' "$(cat rot/token)"
printf ' в container: %s\n' "$(docker exec rotate cat /run/secrets/token)"
echo "═══ отправляем SIGHUP ═══"
docker exec rotate kill -HUP 1
sleep 1
docker logs rotate 2>&1 | tail -1 | sed 's/^/ /'
docker rm -f rotate > /dev/null
Ожидаемый вывод:
═══ исходное значение ═══
старт, секрет: версия-1
═══ меняем файл на host ═══
на host: версия-2-НОВАЯ
в container: версия-2-НОВАЯ
═══ отправляем SIGHUP ═══
[SIGHUP] секрет перечитан: версия-2-НОВАЯ
Файл, смонтированный bind mount'ом, обновился в container'е сразу — это свойство bind mount (урок 7.3). Но приложение узнало о новом значении только после SIGHUP.
Отсюда практический вывод: ротация без перезапуска требует поддержки в коде. Без неё приложение будет использовать старый секрет до пересоздания container'а.
Проверим, что с Compose secret файл не обновляется:
cd /tmp/secrets
cat > compose.rot.yaml <<'EOF'
name: rot
services:
app:
image: python:3.13-slim
volumes:
- ./watcher.py:/w.py:ro
command: ["python", "-u", "/w.py"]
secrets:
- token
secrets:
token:
file: ./rot/token
EOF
echo -n "compose-версия-1" > rot/token
docker compose -f compose.rot.yaml up -d > /dev/null 2>&1
sleep 2
printf ' при старте: %s\n' "$(docker compose -f compose.rot.yaml exec -T app cat /run/secrets/token)"
echo -n "compose-версия-2" > rot/token
sleep 1
printf ' после правки на host: %s\n' "$(docker compose -f compose.rot.yaml exec -T app cat /run/secrets/token)"
docker compose -f compose.rot.yaml up -d --force-recreate > /dev/null 2>&1
sleep 2
printf ' после пересоздания: %s\n' "$(docker compose -f compose.rot.yaml exec -T app cat /run/secrets/token)"
docker compose -f compose.rot.yaml down > /dev/null 2>&1
Ожидаемый вывод:
при старте: compose-версия-1
после правки на host: compose-версия-1
после пересоздания: compose-версия-2
Compose secret монтируется при создании container'а. Изменение исходного файла не доходит до работающего container'а — нужно пересоздание.
Это отличается от bind mount и объясняет, почему SIGHUP в предыдущем примере сработал, а здесь не сработал бы.
Получение секретов из внешнего хранилища
cd /tmp/secrets
cat > vault-stub.py <<'PY'
"""Имитация внешнего хранилища секретов с TTL."""
import json
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
TTL = 5
_version = 1
_issued_at = time.monotonic()
VALID_TOKEN = "role-token-abc"
class H(BaseHTTPRequestHandler):
def do_GET(self):
global _version, _issued_at
# Начальная аутентификация: без токена доступа нет
auth = self.headers.get("X-Auth-Token", "")
if auth != VALID_TOKEN:
self._json(403, {"error": "не аутентифицирован"})
return
# Секрет протухает: хранилище выдаёт новую версию
if time.monotonic() - _issued_at > TTL:
_version += 1
_issued_at = time.monotonic()
self._json(200, {
"value": f"db-password-v{_version}",
"ttl_seconds": TTL,
"version": _version,
})
def _json(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
print("хранилище запущено", flush=True)
HTTPServer(("0.0.0.0", 8000), H).serve_forever()
PY
cat > client.py <<'PY'
"""Клиент: получает секрет при старте и обновляет до истечения TTL."""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.error
import urllib.request
VAULT = os.environ.get("VAULT_URL", "http://vault:8000/secret")
# Начальная аутентификация: единственный секрет, который приходит извне
AUTH = os.environ.get("VAULT_TOKEN", "")
_secret = {"value": None, "version": 0}
_lock = threading.Lock()
def fetch() -> dict[str, object] | None:
req = urllib.request.Request(VAULT, headers={"X-Auth-Token": AUTH})
try:
with urllib.request.urlopen(req, timeout=3) as r:
return json.load(r)
except urllib.error.HTTPError as exc:
print(f" хранилище отказало: HTTP {exc.code}", flush=True)
except OSError as exc:
print(f" хранилище недоступно: {type(exc).__name__}", flush=True)
return None
def refresher() -> None:
"""Обновление до истечения TTL — ротация без перезапуска."""
while True:
data = fetch()
if data:
with _lock:
if data["version"] != _secret["version"]:
print(f" получена версия {data['version']}: {data['value']}", flush=True)
_secret.update(data)
time.sleep(max(1, float(data["ttl_seconds"]) * 0.6))
else:
time.sleep(2)
if __name__ == "__main__":
print(f"клиент запущен, токен доступа задан: {bool(AUTH)}", flush=True)
threading.Thread(target=refresher, daemon=True).start()
for _ in range(14):
time.sleep(1)
print("завершение", flush=True)
PY
cat > compose.vault.yaml <<'EOF'
name: vaultdemo
services:
vault:
image: python:3.13-slim
volumes:
- ./vault-stub.py:/v.py:ro
command: ["python", "-u", "/v.py"]
app:
image: python:3.13-slim
volumes:
- ./client.py:/c.py:ro
environment:
VAULT_URL: "http://vault:8000/secret"
VAULT_TOKEN: "role-token-abc"
command: ["python", "-u", "/c.py"]
depends_on: [vault]
app-no-auth:
image: python:3.13-slim
volumes:
- ./client.py:/c.py:ro
environment:
VAULT_URL: "http://vault:8000/secret"
VAULT_TOKEN: ""
command: ["python", "-u", "/c.py"]
depends_on: [vault]
EOF
echo "═══ клиент с токеном доступа ═══"
docker compose -f compose.vault.yaml up --abort-on-container-exit --exit-code-from app app vault \
> /tmp/secrets/vault.log 2>&1 || true
grep -E 'клиент|версия' /tmp/secrets/vault.log | sed 's/^/ /' | head -6
docker compose -f compose.vault.yaml down > /dev/null 2>&1
echo "═══ клиент без токена ═══"
docker compose -f compose.vault.yaml up --abort-on-container-exit --exit-code-from app-no-auth \
app-no-auth vault > /tmp/secrets/vault2.log 2>&1 || true
grep -E 'клиент|отказало' /tmp/secrets/vault2.log | head -3 | sed 's/^/ /'
docker compose -f compose.vault.yaml down > /dev/null 2>&1
Ожидаемый вывод:
═══ клиент с токеном доступа ═══
app-1 | клиент запущен, токен доступа задан: True
app-1 | получена версия 1: db-password-v1
app-1 | получена версия 2: db-password-v2
app-1 | получена версия 3: db-password-v3
═══ клиент без токена ═══
app-no-auth-1 | клиент запущен, токен доступа задан: False
app-no-auth-1 | хранилище отказало: HTTP 403
Первый блок показывает ротацию без перезапуска: три версии секрета за четырнадцать секунд, процесс тот же.
Второй иллюстрирует проблему начальной аутентификации: чтобы получить секреты, нужен секрет. VAULT_TOKEN приходит извне — от платформы, из окружения экземпляра или через ServiceAccount. Он остаётся тем единственным значением, которое приходится доставлять обычным способом.
cd /tmp && rm -rf /tmp/secrets
Практическое упражнение
Задание. Постройте сборку и запуск, не оставляющие секретов ни в одном слое, и докажите это.
Требования:
- Приватный пакет устанавливается при сборке с использованием токена.
- Токен не найден ни в
docker history, ни вimage inspect, ни в файловой системе образа. - Показан контрольный вариант с
ARG— токен найден; проверка информативна. - Пароль передаётся работающему приложению файлом; в
docker inspectего нет. - Приложение читает секрет по соглашению
_FILEс приоритетом файла над переменной. - Конфигурация валидируется при старте; все ошибки сообщаются сразу.
- Показано, что ротация Compose secret требует пересоздания container'а.
Подсказки
Подсказка 1
Для пункта 2 ищите значение токена, а не наличие конструкции --mount=type=secret.
Подсказка 2
Значение секрета не должно попасть в текст команды: читайте файл внутри RUN.
Подсказка 3
Пункт 7 проверяется правкой файла на host и сравнением до и после --force-recreate.
Решение
Показать решение
mkdir -p /tmp/secfull/{app,secrets,pkg} && cd /tmp/secfull
# ── Имитация приватного пакета, требующего токен ──
cat > pkg/install.sh <<'SH'
#!/bin/sh
# Имитирует установку приватного пакета: проверяет токен и создаёт артефакт
set -e
if [ -z "${TOKEN:-}" ]; then
echo "ОШИБКА: токен не задан" >&2
exit 1
fi
if [ "${#TOKEN}" -lt 10 ]; then
echo "ОШИБКА: токен слишком короткий" >&2
exit 1
fi
mkdir -p /opt/private
printf 'приватный пакет установлен (токен длиной %s)\n' "${#TOKEN}" > /opt/private/VERSION
SH
chmod +x pkg/install.sh
cat > app/__init__.py <<'PY'
"""Приложение практикума."""
PY
cat > app/config.py <<'PY'
"""Конфигурация: соглашение _FILE, валидация при старте."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
VALID_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"})
def get_secret(name: str, default: str | None = None) -> str | None:
"""<name>_FILE → <name> → default. Файл имеет приоритет."""
path = os.environ.get(f"{name}_FILE")
if path:
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError as exc:
raise RuntimeError(f"{name}_FILE={path}: {exc}") from exc
return os.environ.get(name, default)
def source_of(name: str) -> str:
if os.environ.get(f"{name}_FILE"):
return "файл"
if os.environ.get(name):
return "переменная"
return "умолчание"
@dataclass(frozen=True)
class Settings:
db_password: str
db_port: int
log_level: str
password_source: str
@classmethod
def load(cls) -> "Settings":
errors: list[str] = []
password = get_secret("DB_PASSWORD")
if not password:
errors.append("DB_PASSWORD или DB_PASSWORD_FILE обязателен")
raw_port = os.environ.get("DB_PORT", "5432")
port = 5432
try:
port = int(raw_port)
if not 1 <= port <= 65535:
errors.append(f"DB_PORT вне диапазона 1-65535: {port}")
except ValueError:
errors.append(f"DB_PORT должен быть числом, получено {raw_port!r}")
level = os.environ.get("LOG_LEVEL", "INFO").upper()
if level not in VALID_LEVELS:
errors.append(f"LOG_LEVEL должен быть из {sorted(VALID_LEVELS)}, получено {level}")
# Все ошибки сразу (требование 6)
if errors:
print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
raise SystemExit(1)
return cls(password or "", port, level, source_of("DB_PASSWORD"))
PY
cat > app/main.py <<'PY'
"""Точка входа: печатает конфигурацию с маскированным секретом."""
from __future__ import annotations
import sys
from pathlib import Path
from .config import Settings
def main() -> int:
s = Settings.load()
masked = f"{s.db_password[:3]}…({len(s.db_password)} симв.)"
version = Path("/opt/private/VERSION")
print(f" приватный пакет: {version.read_text().strip() if version.exists() else 'отсутствует'}")
print(f" DB_PASSWORD: источник={s.password_source} {masked}")
print(f" DB_PORT: {s.db_port}")
print(f" LOG_LEVEL: {s.log_level}")
return 0
if __name__ == "__main__":
sys.exit(main())
PY
# ── Правильный Dockerfile ──
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/app
WORKDIR /app
FROM base AS builder
COPY pkg/install.sh /tmp/install.sh
# Требование 1-2: секрет доступен только внутри RUN и не попадает в слой.
# Значение читается ВНУТРИ команды: подстановка в текст оставила бы след.
RUN --mount=type=secret,id=pkg_token \
TOKEN="$(cat /run/secrets/pkg_token)" \
&& export TOKEN \
&& sh /tmp/install.sh \
&& rm -f /tmp/install.sh
FROM base AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --from=builder --chown=10001:10001 /opt/private /opt/private
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "-m", "app.main"]
EOF
# ── Контрольный Dockerfile с ARG (требование 3) ──
cat > Dockerfile.arg <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ARG PKG_TOKEN
COPY pkg/install.sh /tmp/install.sh
RUN TOKEN="${PKG_TOKEN}" sh /tmp/install.sh && rm -f /tmp/install.sh
CMD ["cat", "/opt/private/VERSION"]
EOF
TOKEN_VALUE="tok-SECRET-9f3a2c1e8b7d6045"
printf '%s' "$TOKEN_VALUE" > secrets/pkg_token
printf '%s' "пароль-базы-версия-один" > secrets/db_password
chmod 600 secrets/*
cat > compose.yaml <<'EOF'
name: secfull
services:
app:
build:
context: .
target: runtime
image: secfull:app
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
DB_PORT: "5432"
LOG_LEVEL: "INFO"
secrets:
- db_password
command: ["python", "-m", "app.main"]
secrets:
db_password:
file: ./secrets/db_password
EOF
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
find_in() { # find_in <образ> <строка> → число вхождений
local h i
h="$(docker history --no-trunc "$1" 2>/dev/null | grep -c "$2" || true)"
i="$(docker image inspect "$1" 2>/dev/null | grep -c "$2" || true)"
echo "$((h + i))"
}
printf '\n═══ Требование 1: сборка с секретом ═══\n'
docker build -q --secret id=pkg_token,src=./secrets/pkg_token \
--target runtime -t secfull:app . > /dev/null 2>&1 \
&& ok "образ собран, приватный пакет установлен" || { bad "сборка"; exit 1; }
docker run --rm --entrypoint cat secfull:app /opt/private/VERSION | sed 's/^/ /'
printf '\n═══ Требование 2: токена нет в образе ═══\n'
h="$(docker history --no-trunc secfull:app 2>/dev/null | grep -c "$TOKEN_VALUE" || true)"
i="$(docker image inspect secfull:app 2>/dev/null | grep -c "$TOKEN_VALUE" || true)"
f="$(docker run --rm --entrypoint sh secfull:app -c \
"grep -rl '$TOKEN_VALUE' /opt /etc /tmp /app 2>/dev/null | wc -l" 2>/dev/null || echo 0)"
printf ' docker history: %s, image inspect: %s, файловая система: %s\n' "$h" "$i" "$f"
[ "$h" = "0" ] && [ "$i" = "0" ] && [ "$f" = "0" ] \
&& ok "токен не найден нигде" || bad "утечка: history=$h inspect=$i fs=$f"
printf ' что сохранилось в истории:\n'
docker history --no-trunc secfull:app 2>/dev/null | grep -o 'TOKEN="[^"]*"' | head -1 | sed 's/^/ /'
printf '\n═══ Требование 3: контрольный вариант с ARG ═══\n'
docker build -q -f Dockerfile.arg --build-arg PKG_TOKEN="$TOKEN_VALUE" \
-t secfull:leaky . > /dev/null 2>&1
leaked="$(find_in secfull:leaky "$TOKEN_VALUE")"
printf ' вхождений токена: %s\n' "$leaked"
docker history --no-trunc secfull:leaky 2>/dev/null | grep -o "TOKEN=\"$TOKEN_VALUE\"" | head -1 | sed 's/^/ /'
[ "$leaked" -gt 0 ] && ok "ARG утекает — проверка информативна" \
|| bad "утечка не воспроизвелась, проверка ненадёжна"
printf '\n═══ Требования 4-5: секрет работающему приложению ═══\n'
docker compose up -d > /dev/null 2>&1
sleep 2
docker compose logs app --no-log-prefix 2>/dev/null | tail -4 | sed 's/^/ /'
cid="$(docker compose ps -aq app)"
pw="$(cat secrets/db_password)"
env_leak="$(docker inspect "$cid" --format '{{json .Config.Env}}' 2>/dev/null | grep -c "$pw" || true)"
printf ' вхождений пароля в docker inspect: %s\n' "$env_leak"
[ "$env_leak" = "0" ] && ok "пароля нет в метаданных container" || bad "пароль виден"
docker compose logs app --no-log-prefix 2>/dev/null | grep -q 'источник=файл' \
&& ok "секрет прочитан из файла (требование 5)" || bad "источник не файл"
printf '\n═══ Требование 5: приоритет файла над переменной ═══\n'
both="$(docker run --rm \
-v "$PWD/secrets/db_password:/run/secrets/db_password:ro" \
-e DB_PASSWORD="короткий" \
-e DB_PASSWORD_FILE=/run/secrets/db_password \
secfull:app 2>/dev/null | grep DB_PASSWORD)"
printf ' заданы оба источника: %s\n' "$(echo "$both" | tr -s ' ')"
echo "$both" | grep -q 'источник=файл' && ok "победил файл, а не переменная" \
|| bad "приоритет неверен"
printf '\n═══ Требование 6: валидация конфигурации ═══\n'
out="$(docker run --rm secfull:app 2>&1 || true)"
echo "$out" | head -5 | sed 's/^/ /'
n_errors="$(echo "$out" | grep -c '^ - ' || true)"
printf ' ошибок сообщено сразу: %s\n' "$n_errors"
[ "$n_errors" -ge 1 ] && ok "отсутствие пароля остановило запуск" || bad "запустился без пароля"
out2="$(docker run --rm \
-v "$PWD/secrets/db_password:/run/secrets/db_password:ro" \
-e DB_PASSWORD_FILE=/run/secrets/db_password \
-e DB_PORT=не-число -e LOG_LEVEL=ПОДРОБНО secfull:app 2>&1 || true)"
echo "$out2" | sed 's/^/ /'
n2="$(echo "$out2" | grep -c '^ - ' || true)"
[ "$n2" -ge 2 ] && ok "все ошибки сообщены за один запуск" || bad "сообщено только $n2"
printf '\n═══ Требование 7: ротация Compose secret ═══\n'
printf ' в container сейчас: %s\n' \
"$(docker compose exec -T app cat /run/secrets/db_password 2>/dev/null || \
docker compose run --rm -T app cat /run/secrets/db_password 2>/dev/null)"
printf '%s' "пароль-базы-версия-ДВА" > secrets/db_password
printf ' на host после правки: %s\n' "$(cat secrets/db_password)"
in_container="$(docker compose run --rm -T --no-deps app cat /run/secrets/db_password 2>/dev/null)"
printf ' в НОВОМ container: %s\n' "$in_container"
docker compose up -d --force-recreate > /dev/null 2>&1
sleep 2
after="$(docker compose exec -T app cat /run/secrets/db_password 2>/dev/null || \
docker compose run --rm -T app cat /run/secrets/db_password 2>/dev/null)"
printf ' после пересоздания: %s\n' "$after"
echo "$after" | grep -q 'ДВА' && ok "новое значение доставлено пересозданием" \
|| bad "значение не обновилось"
printf ' вывод: Compose secret монтируется при создании container,\n'
printf ' правка файла на host не доходит до работающего экземпляра\n'
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все семь требований выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down > /dev/null 2>&1
docker rmi -f secfull:app secfull:leaky > /dev/null 2>&1
cd /tmp && rm -rf /tmp/secfull
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: сборка с секретом ═══
✓ образ собран, приватный пакет установлен
приватный пакет установлен (токен длиной 24)
═══ Требование 2: токена нет в образе ═══
docker history: 0, image inspect: 0, файловая система: 0
✓ токен не найден нигде
что сохранилось в истории:
TOKEN="$(cat /run/secrets/pkg_token)"
═══ Требование 3: контрольный вариант с ARG ═══
вхождений токена: 2
TOKEN="tok-SECRET-9f3a2c1e8b7d6045"
✓ ARG утекает — проверка информативна
═══ Требования 4-5: секрет работающему приложению ═══
приватный пакет: приватный пакет установлен (токен длиной 24)
DB_PASSWORD: источник=файл пар…(23 симв.)
DB_PORT: 5432
LOG_LEVEL: INFO
вхождений пароля в docker inspect: 0
✓ пароля нет в метаданных container
✓ секрет прочитан из файла (требование 5)
═══ Требование 5: приоритет файла над переменной ═══
заданы оба источника: DB_PASSWORD: источник=файл пар…(23 симв.)
✓ победил файл, а не переменная
═══ Требование 6: валидация конфигурации ═══
ОШИБКА КОНФИГУРАЦИИ:
- DB_PASSWORD или DB_PASSWORD_FILE обязателен
ошибок сообщено сразу: 1
✓ отсутствие пароля остановило запуск
ОШИБКА КОНФИГУРАЦИИ:
- DB_PORT должен быть числом, получено 'не-число'
- LOG_LEVEL должен быть из ['DEBUG', 'ERROR', 'INFO', 'WARNING'], получено ПОДРОБНО
✓ все ошибки сообщены за один запуск
═══ Требование 7: ротация Compose secret ═══
в container сейчас: пароль-базы-версия-один
на host после правки: пароль-базы-версия-ДВА
в НОВОМ container: пароль-базы-версия-ДВА
после пересоздания: пароль-базы-версия-ДВА
✓ новое значение доставлено пересозданием
вывод: Compose secret монтируется при создании container,
правка файла на host не доходит до работающего экземпляра
═══ ИТОГ ═══
все семь требований выполнены
Все семь требований выполнены.
Обратите внимание на строку в требовании 2: в истории сохранилось TOKEN="$(cat /run/secrets/pkg_token)" — команда с невыполненной подстановкой. Сравните со строкой в требовании 3, где значение подставлено целиком.
Три решения, определяющие качество.
Проверка ищет значение токена, а не наличие --mount=type=secret. Конструкция может присутствовать и при этом не защищать — как в примере со смешанным вариантом, где тот же секрет параллельно пришёл через ARG. Единственная надёжная проверка — поиск строки во всех метаданных и файлах.
Требование 3 обязательно, а не иллюстративно. Ноль вхождений в требовании 2 доказывает отсутствие утечки только если метод способен утечку обнаружить. Контрольный образ с ARG даёт положительный результат — и лишь после этого ноль в первой проверке что-то значит.
Секрет читается внутри RUN через $(cat ...), а не подставляется в текст команды. Разница неочевидна: RUN --mount=type=secret,id=t echo "$SOME_ARG" защищает монтирование, но не защищает SOME_ARG. Значение не должно покидать пределы шага сборки — это и есть суть механизма.
Чего решение не делает. Compose secret на host — обычный файл: его защищают только права файловой системы. Настоящее управление секретами требует внешнего хранилища с аудитом, TTL и ротацией. Не проверяется и утечка через логи приложения: маскирование в выводе реализовано, но никакая проверка не гарантирует, что секрет не попадёт в трассировку исключения или в отладочный вывод библиотеки.
Проверка результата
mkdir -p /tmp/sc && cd /tmp/sc
echo -n "секрет-12345" > s.txt
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN --mount=type=secret,id=s \
test -n "$(cat /run/secrets/s)" && echo ok > /r.txt
CMD ["cat", "/r.txt"]
EOF
docker build -q --secret id=s,src=./s.txt -t sc:test . > /dev/null
docker run --rm sc:test
docker history --no-trunc sc:test | grep -c "секрет-12345" || echo "0 — секрет не в истории"
docker rmi -f sc:test > /dev/null; cd /tmp && rm -rf /tmp/sc
Ожидается ok и 0 — секрет не в истории.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Токен через --build-arg | Привычный механизм | Остаётся в docker history навсегда |
ENV с секретом | Кажется удобным | В образе и в каждом container'е из него |
Удаляют файл с секретом после RUN | Кажется достаточным | Секрет в тексте инструкции, а не в файле |
Подстановка значения в команду при --mount | Не поняли механизм | Значение попадает в историю через переменную |
Проверяют наличие --mount, а не отсутствие секрета | Логично предположить | Другие инструкции могут утекать |
| Ждут обновления Compose secret на лету | По аналогии с bind mount | Монтируется при создании container'а |
| Приложение читает секрет один раз | Проще | Ротация требует перезапуска |
| Считают Compose secret шифрованием | Название вводит в заблуждение | На host это обычный файл |
| Защищают всю конфигурацию как секреты | Осторожность | Неудобно; в итоге защищают плохо |
| Валидация сообщает первую ошибку | Проще написать | Несколько перезапусков на исправление |
Контрольные вопросы
На понимание:
- Где именно остаётся
ARGс секретом и почему его нельзя удалить? - Чем
ENVс секретом хужеARG? - Что происходит с секретом при
RUN --mount=type=secretпосле завершения инструкции? - Почему Compose secret не обновляется при правке файла на host?
- В чём проблема начальной аутентификации при работе с внешним хранилищем?
На применение:
- Как установить приватный пакет при сборке, не оставив токен в образе?
- Как проверить, что секрет действительно не попал в образ?
- Как реализовать ротацию без перезапуска процесса?
На диагностику:
- Токен утёк, хотя использовался
--mount=type=secret. Где искать? - Приложение использует старый пароль после ротации. Причина?
Краткое резюме
- Секреты при сборке и секреты при работе — разные задачи с разными механизмами.
ARGс секретом сохраняется вdocker historyвместе с текстом инструкции.ENVс секретом попадает и в историю, и в конфигурацию образа, и в каждый container.- Удаление файла после
RUNне помогает: секрет в тексте команды, а не в слое. RUN --mount=type=secretмонтирует значение только на время инструкции.- Значение нужно читать внутри
RUN; подстановка в текст команды сводит защиту на нет. - Форма
env=берёт секрет из окружения сборки — удобно в CI. - Проверять нужно поиском значения, а не наличием правильной конструкции.
- Соглашение
_FILEдаёт приоритет файлу над переменной окружения. - Compose secret монтируется при создании container'а и не обновляется на лету.
- Ротация без перезапуска требует поддержки в коде: перечитывание по сигналу или по TTL.
- Внешнее хранилище снимает большинство проблем, но требует решить начальную аутентификацию.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: build secrets | https://docs.docker.com/build/building/secrets/ | --mount=type=secret, src=, env= |
| Dockerfile: RUN --mount | https://docs.docker.com/reference/dockerfile/#run---mounttypesecret | Синтаксис и поведение |
Docker: docker history | https://docs.docker.com/reference/cli/docker/image/history/ | Что сохраняется в истории слоёв |
| Compose: secrets | https://docs.docker.com/reference/compose-file/secrets/ | Источники, монтирование |
| Docker: BuildKit | https://docs.docker.com/build/buildkit/ | Требования к версии |
| Docker Hub: postgres | https://hub.docker.com/_/postgres | Соглашение _FILE |
| OWASP: Secrets Management | https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html | Практики хранения и ротации |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Supply chain
Главное оглавление