Главная/Production-ready containers/Урок

11.5. Конфигурация и secrets

Цели

После этого материала вы сможете:

  • показать, где именно ARG с секретом остаётся в готовом образе;
  • использовать RUN --mount=type=secret и доказать, что секрет не попал ни в один слой;
  • выбрать способ доставки секрета в работающий container по критериям, а не по привычке;
  • организовать получение секретов из внешнего хранилища при старте;
  • спланировать ротацию и понять, почему она требует перезапуска;
  • разделить конфигурацию по окружениям без пересборки образа.

Предварительные знания

Механика Compose secrets разобрана в уроке 9.5. Здесь — секреты при сборке, внешние хранилища и ротация.

Ключевые термины

ТерминОбъяснение
build secretСекрет, доступный только во время выполнения одной инструкции RUN
secret mountМонтирование секрета в файловую систему сборки
layerСлой образа; его содержимое неизменяемо и доступно всем
ротацияПлановая замена секрета на новый
внешнее хранилищеVault, AWS Secrets Manager и аналоги

Теория

Два разных вопроса

ВопросГде решается
Как получить приватный пакет при сборке?RUN --mount=type=secret
Как передать пароль работающему приложению?Файл, Compose secret, внешнее хранилище

Их часто смешивают, и тогда токен для сборки оказывается в ENV готового образа — то есть доступен всем, кто этот образ скачает.

Почему ARG не годится для секретов

dockerfile
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

dockerfile
# 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
bash
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:

yaml
environment:
  POSTGRES_PASSWORD_FILE: /run/secrets/db_password

Своему приложению это добавляется десятком строк и снимает необходимость выбирать между удобством и безопасностью (урок 9.5).

Приоритет должен быть у файла: если заданы оба источника, побеждает более безопасный.

Внешние хранилища

text
приложение ──запрос──► хранилище (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 с секретом остаётся в образе

bash
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/^/  /'

Ожидаемый вывод:

text
═══ ищем токен в готовом образе ═══
  в 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 с секретом — то же самое, но хуже

bash
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

Ожидаемый вывод:

text
═══ где виден ═══
  docker image inspect (Config.Env): 1
  docker history:                    1
  в любом запущенном container:      1
  ═══ значение целиком ═══
   API_TOKEN=sk-live-SECRET-9876543210

ENV хуже ARG: секрет не только в истории, но и в конфигурации образа, и автоматически попадает в окружение каждого container'а из этого образа.

RUN --mount=type=secret: правильный способ

bash
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/^/  /'

Ожидаемый вывод:

text
═══ ищем токен в образе ═══
  в 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/...) не раскрывается при сохранении.

Последний блок подтверждает, что монтирование исчезло вместе с инструкцией: в готовом образе файла нет.

Ошибка, которая сводит защиту на нет

bash
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/^/  /'

Ожидаемый вывод:

text
═══ смешанный вариант ═══
  вхождений токена в history: 1
  а этот из ARG: glpat-SECRET-abcdef1234567890

Секрет из --mount не утёк, а тот же секрет через ARG — утёк. Наличие --mount=type=secret в файле не защищает остальные инструкции.

Правило: если секрет попал в ARG или ENV, он в образе. Проверять нужно поиском значения, а не наличием правильной конструкции.

Секрет из переменной окружения при сборке

bash
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)"

Ожидаемый вывод:

text
═══ результат ═══
  ключ получен, длина 29
  вхождений в history: 0

Форма env= берёт значение из окружения сборки и монтирует его как переменную внутри RUN — удобно в CI, где секреты приходят из хранилища системы сборки, а не из файлов.

Сравнение всех вариантов

bash
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

Ожидаемый вывод:

text
═══ сводка по образам ═══
  образ              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» везде нулевая — это подтверждает, что искать секрет в файлах бесполезно, утечка происходит через метаданные.

bash
docker rmi -f leak:arg leak:env leak:mixed safe:secret safe:envsecret > /dev/null 2>&1

Соглашение _FILE с приоритетом файла

bash
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/^/  /'

Ожидаемый вывод:

text
═══ только переменная ═══
  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).

Ротация требует перезапуска

bash
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

Ожидаемый вывод:

text
═══ исходное значение ═══
  старт, секрет: версия-1
═══ меняем файл на host ═══
  на host: версия-2-НОВАЯ
  в container: версия-2-НОВАЯ
═══ отправляем SIGHUP ═══
  [SIGHUP] секрет перечитан: версия-2-НОВАЯ

Файл, смонтированный bind mount'ом, обновился в container'е сразу — это свойство bind mount (урок 7.3). Но приложение узнало о новом значении только после SIGHUP.

Отсюда практический вывод: ротация без перезапуска требует поддержки в коде. Без неё приложение будет использовать старый секрет до пересоздания container'а.

Проверим, что с Compose secret файл не обновляется:

bash
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

Ожидаемый вывод:

text
  при старте:  compose-версия-1
  после правки на host: compose-версия-1
  после пересоздания:   compose-версия-2

Compose secret монтируется при создании container'а. Изменение исходного файла не доходит до работающего container'а — нужно пересоздание.

Это отличается от bind mount и объясняет, почему SIGHUP в предыдущем примере сработал, а здесь не сработал бы.

Получение секретов из внешнего хранилища

bash
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

Ожидаемый вывод:

text
═══ клиент с токеном доступа ═══
  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. Он остаётся тем единственным значением, которое приходится доставлять обычным способом.

bash
cd /tmp && rm -rf /tmp/secrets

Практическое упражнение

Задание. Постройте сборку и запуск, не оставляющие секретов ни в одном слое, и докажите это.

Требования:

  1. Приватный пакет устанавливается при сборке с использованием токена.
  2. Токен не найден ни в docker history, ни в image inspect, ни в файловой системе образа.
  3. Показан контрольный вариант с ARG — токен найден; проверка информативна.
  4. Пароль передаётся работающему приложению файлом; в docker inspect его нет.
  5. Приложение читает секрет по соглашению _FILE с приоритетом файла над переменной.
  6. Конфигурация валидируется при старте; все ошибки сообщаются сразу.
  7. Показано, что ротация Compose secret требует пересоздания container'а.

Подсказки

Подсказка 1

Для пункта 2 ищите значение токена, а не наличие конструкции --mount=type=secret.

Подсказка 2

Значение секрета не должно попасть в текст команды: читайте файл внутри RUN.

Подсказка 3

Пункт 7 проверяется правкой файла на host и сравнением до и после --force-recreate.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требование 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 и ротацией. Не проверяется и утечка через логи приложения: маскирование в выводе реализовано, но никакая проверка не гарантирует, что секрет не попадёт в трассировку исключения или в отладочный вывод библиотеки.

Проверка результата

bash
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 это обычный файл
Защищают всю конфигурацию как секретыОсторожностьНеудобно; в итоге защищают плохо
Валидация сообщает первую ошибкуПроще написатьНесколько перезапусков на исправление

Контрольные вопросы

На понимание:

  1. Где именно остаётся ARG с секретом и почему его нельзя удалить?
  2. Чем ENV с секретом хуже ARG?
  3. Что происходит с секретом при RUN --mount=type=secret после завершения инструкции?
  4. Почему Compose secret не обновляется при правке файла на host?
  5. В чём проблема начальной аутентификации при работе с внешним хранилищем?

На применение:

  1. Как установить приватный пакет при сборке, не оставив токен в образе?
  2. Как проверить, что секрет действительно не попал в образ?
  3. Как реализовать ротацию без перезапуска процесса?

На диагностику:

  1. Токен утёк, хотя использовался --mount=type=secret. Где искать?
  2. Приложение использует старый пароль после ротации. Причина?

Краткое резюме

  1. Секреты при сборке и секреты при работе — разные задачи с разными механизмами.
  2. ARG с секретом сохраняется в docker history вместе с текстом инструкции.
  3. ENV с секретом попадает и в историю, и в конфигурацию образа, и в каждый container.
  4. Удаление файла после RUN не помогает: секрет в тексте команды, а не в слое.
  5. RUN --mount=type=secret монтирует значение только на время инструкции.
  6. Значение нужно читать внутри RUN; подстановка в текст команды сводит защиту на нет.
  7. Форма env= берёт секрет из окружения сборки — удобно в CI.
  8. Проверять нужно поиском значения, а не наличием правильной конструкции.
  9. Соглашение _FILE даёт приоритет файлу над переменной окружения.
  10. Compose secret монтируется при создании container'а и не обновляется на лету.
  11. Ротация без перезапуска требует поддержки в коде: перечитывание по сигналу или по TTL.
  12. Внешнее хранилище снимает большинство проблем, но требует решить начальную аутентификацию.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: build secretshttps://docs.docker.com/build/building/secrets/--mount=type=secret, src=, env=
Dockerfile: RUN --mounthttps://docs.docker.com/reference/dockerfile/#run---mounttypesecretСинтаксис и поведение
Docker: docker historyhttps://docs.docker.com/reference/cli/docker/image/history/Что сохраняется в истории слоёв
Compose: secretshttps://docs.docker.com/reference/compose-file/secrets/Источники, монтирование
Docker: BuildKithttps://docs.docker.com/build/buildkit/Требования к версии
Docker Hub: postgreshttps://hub.docker.com/_/postgresСоглашение _FILE
OWASP: Secrets Managementhttps://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.htmlПрактики хранения и ротации

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Supply chain
Главное оглавление

Markdown на GitHub ↗