Главная/Docker Compose/Урок

9.5. Environment и secrets

Цели

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

  • объяснить, что делает файл .env и чего он не делает;
  • использовать все формы подстановки, включая различие ${VAR:-x} и ${VAR-x};
  • задать обязательную переменную так, чтобы её отсутствие останавливало запуск;
  • показать, где именно утекает пароль, переданный через переменную окружения;
  • подключить Compose secrets и прочитать их из приложения;
  • назвать, что нельзя хранить в compose.yaml и что вместо этого делать.

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

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

ТерминОбъяснение
interpolationПодстановка ${VAR} в текст compose.yaml
.envФайл переменных для подстановки, а не для container'а
env_fileФайл переменных для container'а
secretЗначение, попадающее в container файлом, а не переменной
/run/secrets/Каталог, куда Compose монтирует секреты

Теория

Два разных механизма, которые постоянно путают

.env в каталоге проектаenv_file: в сервисе
НазначениеПодстановка ${VAR} в compose.yamlПеременные внутри container'а
Попадает в containerНетДа
Читается автоматическиДаТолько если указан
Влияет на имя проекта, образы, портыДаНет
Задаётся флагом--env-fileКлючом env_file:

Это разные вещи с похожими названиями. Файл .env меняет текст конфигурации до её применения; env_file передаёт значения процессу внутри container'а.

Частая ошибка: положить DATABASE_URL в .env и ожидать, что приложение её увидит. Оно не увидит — если только в compose.yaml нет явной строки DATABASE_URL: ${DATABASE_URL}.

Формы подстановки

ФормаПоведение
${VAR}Значение или пустая строка
${VAR:-default}default, если переменная не задана или пуста
${VAR-default}default, только если переменная не задана
${VAR:?сообщение}Ошибка, если не задана или пуста
${VAR?сообщение}Ошибка, только если не задана
${VAR:+value}value, если переменная задана и непуста
$$Литеральный знак доллара

Различие :- и - практически важно. Пустая строка — законное значение, и иногда её задают намеренно:

yaml
LOG_PREFIX: "${PREFIX-по умолчанию}"     # PREFIX="" даст пустую строку
LOG_LEVEL: "${LEVEL:-INFO}"              # LEVEL="" даст INFO

Для обязательных переменных используйте :? — это единственный способ заставить Compose отказаться работать без значения:

yaml
DATABASE_PASSWORD: "${DB_PASSWORD:?задайте DB_PASSWORD в .env}"

Знак $$ нужен, когда доллар должен попасть в container как есть — например, в команде shell:

yaml
command: ["sh", "-c", "echo Домашний каталог: $$HOME"]

Без удвоения Compose попытается подставить ${HOME} из своего окружения.

env_file с параметрами

yaml
services:
  app:
    env_file:
      - .env.common
      - path: .env.local
        required: false          # не падать, если файла нет
      - path: .env.raw
        format: raw              # не интерпретировать кавычки и экранирование
ПараметрНазначение
pathПуть к файлу
requiredfalse — отсутствие файла не ошибка
format: rawЗначение берётся дословно

Формат файла:

text
# комментарий
KEY=value
QUOTED="значение с пробелами"
EMPTY=
MULTI=строка1\nстрока2

Подстановка ${OTHER} внутри env_file не выполняется — в отличие от compose.yaml. Значения берутся как есть.

Приоритет источников

От низшего к высшему:

ПриоритетИсточник
1ENV в образе
2env_file, в порядке перечисления
3environment в compose.yaml
4Окружение shell — для ключа без значения
5docker compose run -e

Отдельная линия: значения ${VAR} в самом compose.yaml берутся из .env и окружения shell, причём окружение shell побеждает .env.

Почему переменные окружения — плохое место для секретов

Пароль в переменной окружения виден в неожиданно многих местах:

Где виденКому
docker inspect <container>Любому с доступом к Docker
/proc/<pid>/environ внутри container'аЛюбому процессу с тем же UID
Дочерним процессамНаследуется автоматически
В отчётах об ошибкахМногие библиотеки дампят окружение
В логах CIПри отладочном выводе
docker compose configЛюбому, кто выполнит команду

Первая строка особенно неприятна: docker inspect не требует входа в container и работает для остановленных.

Compose secrets

yaml
services:
  app:
    image: myapp
    secrets:
      - db_password
      - source: api_key
        target: api-key.txt        # имя файла в /run/secrets/
        mode: 0400

secrets:
  db_password:
    file: ./secrets/db_password.txt

  api_key:
    environment: API_KEY           # из окружения, не из файла

Секрет попадает в container файлом по пути /run/secrets/<имя>:

python
password = Path("/run/secrets/db_password").read_text().strip()
СвойствоПеременная окруженияSecret
Виден в docker inspectДаНет
Виден в /proc/<pid>/environДаНет
Наследуется потомкамиДаНет
Требует поддержки в приложенииНетДа — нужно прочитать файл
Попадает в образНетНет
Ротация без пересозданияНетНет (файл монтируется при старте)

Ключевое ограничение: приложение должно уметь читать файл. Многие образы поддерживают это через соглашение <VAR>_FILE:

yaml
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

Официальные образы postgres, mysql, mariadb понимают суффикс _FILE. Для своего приложения такое соглашение стоит реализовать самому — это несколько строк.

Что нельзя класть в compose.yaml

НельзяПочемуЧем заменить
Пароли, токены, ключиФайл в репозитории.env вне репозитория или secrets
Абсолютные пути разработчикаНе переноситсяОтносительные пути
Адреса рабочих серверовУтечка топологииПеременные с умолчаниями
UID конкретного человекаЛомается у коллег${UID:-1000} (урок 7.5)

Рабочая схема:

text
compose.yaml        → в репозитории, без секретов
.env.example        → в репозитории, с описанием переменных
.env                → НЕ в репозитории (в .gitignore)
secrets/*.txt       → НЕ в репозитории

Внутренний механизм

Как Compose монтирует секреты

Вне Swarm секрет реализуется как bind mount файла в /run/secrets/<имя> с правами только для чтения. Никакой криптографии здесь нет: защита в том, что значение не попадает ни в метаданные container'а, ни в окружение процессов.

Отсюда следствие: на диске host секрет лежит обычным файлом. Права на этот файл — ваша ответственность.

Порядок обработки конфигурации

  1. Читается .env (или файл из --env-file).
  2. Переменные окружения shell накладываются поверх.
  3. Выполняется подстановка ${VAR} в тексте compose.yaml.
  4. Файлы сливаются, если их несколько (урок 9.6).
  5. Применяются env_file и environment — уже как содержимое container'ов.

Шаги 1–3 происходят до того, как Compose вообще понимает структуру файла. Поэтому подставлять можно что угодно — включая имена сервисов и ключи.


Команды и примеры

.env не передаётся в container

bash
mkdir -p /tmp/envsec && cd /tmp/envsec

cat > .env <<'EOF'
IMAGE_TAG=3.13-slim
SECRET_FROM_DOTENV=это-значение-из-dotenv
EOF

cat > compose.yaml <<'EOF'
name: envsec

services:
  app:
    image: "python:${IMAGE_TAG}"
    command:
      - python
      - -c
      - |
        import os
        for k in ("IMAGE_TAG", "SECRET_FROM_DOTENV", "EXPLICIT"):
            print(f"  {k:<20} = {os.environ.get(k, '(отсутствует)')}")
EOF

echo "═══ что видит container ═══"
docker compose run --rm -T app 2>/dev/null

echo "═══ а в конфигурацию подстановка прошла ═══"
docker compose config | grep -E 'image:' | sed 's/^/  /'

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

text
═══ что видит container ═══
  IMAGE_TAG            = (отсутствует)
  SECRET_FROM_DOTENV   = (отсутствует)
  EXPLICIT             = (отсутствует)
═══ а в конфигурацию подстановка прошла ═══
    image: python:3.13-slim

Обе переменные из .env в container не попали, хотя IMAGE_TAG явно повлиял на выбор образа.

Чтобы значение дошло до приложения, его нужно передать явно:

bash
cd /tmp/envsec
cat > compose.yaml <<'EOF'
name: envsec

services:
  app:
    image: "python:${IMAGE_TAG}"
    environment:
      SECRET_FROM_DOTENV: "${SECRET_FROM_DOTENV}"   # явная передача
      EXPLICIT: "задано прямо в файле"
    command:
      - python
      - -c
      - |
        import os
        for k in ("IMAGE_TAG", "SECRET_FROM_DOTENV", "EXPLICIT"):
            print(f"  {k:<20} = {os.environ.get(k, '(отсутствует)')}")
EOF

docker compose run --rm -T app 2>/dev/null

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

text
  IMAGE_TAG            = (отсутствует)
  SECRET_FROM_DOTENV   = это-значение-из-dotenv
  EXPLICIT             = задано прямо в файле

SECRET_FROM_DOTENV дошёл — потому что появилась строка, которая его передаёт. IMAGE_TAG по-прежнему невидим приложению, и это правильно: он нужен только Compose.

Формы подстановки

bash
cd /tmp/envsec
cat > compose.yaml <<'EOF'
name: envsec

services:
  app:
    image: python:3.13-slim
    environment:
      A_SIMPLE: "${VAR}"
      B_COLON_DASH: "${VAR:-умолчание}"
      C_DASH: "${VAR-умолчание}"
      D_COLON_PLUS: "${VAR:+задана-и-непуста}"
      E_LITERAL_DOLLAR: "цена 100$$"
    command:
      - python
      - -c
      - |
        import os
        for k in sorted(os.environ):
            if k[1] == "_" and k[0] in "ABCDE":
                print(f"  {k:<18} = {os.environ[k]!r}")
EOF

echo "═══ VAR не задана ═══"
docker compose run --rm -T app 2>/dev/null

echo "═══ VAR пустая строка ═══"
VAR= docker compose run --rm -T app 2>/dev/null

echo "═══ VAR='значение' ═══"
VAR=значение docker compose run --rm -T app 2>/dev/null

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

text
═══ VAR не задана ═══
  A_SIMPLE           = ''
  B_COLON_DASH       = 'умолчание'
  C_DASH             = 'умолчание'
  D_COLON_PLUS       = ''
  E_LITERAL_DOLLAR   = 'цена 100$'
═══ VAR пустая строка ═══
  A_SIMPLE           = ''
  B_COLON_DASH       = 'умолчание'
  C_DASH             = ''
  D_COLON_PLUS       = ''
  E_LITERAL_DOLLAR   = 'цена 100$'
═══ VAR='значение' ═══
  A_SIMPLE           = 'значение'
  B_COLON_DASH       = 'значение'
  C_DASH             = 'значение'
  D_COLON_PLUS       = 'задана-и-непуста'
  E_LITERAL_DOLLAR   = 'цена 100$'

Средний блок показывает всё различие: при VAR="" форма :- подставила умолчание, форма - оставила пустую строку.

Это существенно для параметров, где пустое значение осмысленно — например, префикс логов или дополнительные аргументы командной строки.

Обязательные переменные

bash
cd /tmp/envsec
cat > compose.yaml <<'EOF'
name: envsec

services:
  app:
    image: python:3.13-slim
    environment:
      DB_PASSWORD: "${DB_PASSWORD:?задайте DB_PASSWORD в .env или окружении}"
    command: ["python", "-c", "import os; print('  пароль получен, длина:', len(os.environ['DB_PASSWORD']))"]
EOF

echo "═══ без переменной ═══"
docker compose config 2>&1 | tail -1 | sed 's/^/  /'
echo "  код: $?"

echo "═══ с переменной ═══"
DB_PASSWORD=секрет123 docker compose run --rm -T app 2>/dev/null

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

text
═══ без переменной ═══
  error: required variable DB_PASSWORD is missing a value: задайте DB_PASSWORD в .env или окружении
  код: 0
═══ с переменной ═══
  пароль получен, длина: 9

Compose отказывается работать и печатает ваше сообщение. Это лучше, чем запуск с пустым паролем и падение приложения через десять секунд с невнятной ошибкой.

Обратите внимание на код: 0 — это код sed, а не config. Проверять нужно код самой команды:

bash
cd /tmp/envsec
docker compose config > /dev/null 2>&1
echo "  код docker compose config: $?"

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

text
  код docker compose config: 1

Где утекает пароль из переменной окружения

bash
cd /tmp/envsec
cat > compose.yaml <<'EOF'
name: envsec

services:
  leaky:
    image: python:3.13-slim
    environment:
      DB_PASSWORD: "супер-секретный-пароль"
    command: ["sleep", "300"]
EOF

docker compose up -d > /dev/null 2>&1
sleep 2
cid="$(docker compose ps -q leaky)"

echo "═══ 1. docker inspect ═══"
docker inspect "$cid" --format '{{range .Config.Env}}{{println "  " .}}{{end}}' | grep DB_PASSWORD

echo "═══ 2. /proc/1/environ внутри container ═══"
docker compose exec -T leaky sh -c "tr '\0' '\n' < /proc/1/environ | grep DB_PASSWORD" | sed 's/^/  /'

echo "═══ 3. docker compose config ═══"
docker compose config | grep DB_PASSWORD | sed 's/^/  /'

echo "═══ 4. наследование дочерним процессом ═══"
docker compose exec -T leaky python -c "
import os, subprocess
out = subprocess.run(['python', '-c', 'import os; print(os.environ.get(\"DB_PASSWORD\"))'],
                     capture_output=True, text=True)
print('  потомок видит:', out.stdout.strip())
"

echo "═══ 5. виден даже после остановки ═══"
docker compose stop leaky > /dev/null 2>&1
docker inspect "$cid" --format '{{range .Config.Env}}{{println "  " .}}{{end}}' | grep DB_PASSWORD
docker compose down > /dev/null 2>&1

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

text
═══ 1. docker inspect ═══
   DB_PASSWORD=супер-секретный-пароль
═══ 2. /proc/1/environ внутри container ═══
  DB_PASSWORD=супер-секретный-пароль
═══ 3. docker compose config ═══
        DB_PASSWORD: супер-секретный-пароль
═══ 4. наследование дочерним процессом ═══
  потомок видит: супер-секретный-пароль
═══ 5. виден даже после остановки ═══
   DB_PASSWORD=супер-секретный-пароль

Пять мест утечки, и ни для одного не требуется вход в работающий container. Пятое особенно неприятно: остановленный container хранит пароль в метаданных до удаления.

Secrets: то же значение, другой путь

bash
cd /tmp/envsec
mkdir -p secrets
echo -n "супер-секретный-пароль" > secrets/db_password.txt
chmod 600 secrets/db_password.txt

cat > compose.yaml <<'EOF'
name: envsec

services:
  safe:
    image: python:3.13-slim
    secrets:
      - db_password
      - source: api_key
        target: api-key
        mode: 0400
    command: ["sleep", "300"]

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    environment: API_KEY
EOF

API_KEY=ключ-из-окружения docker compose up -d > /dev/null 2>&1
sleep 2
cid="$(docker compose ps -q safe)"

echo "═══ 1. docker inspect ═══"
docker inspect "$cid" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -c 'db_password\|секретный' \
    | xargs printf '  упоминаний секрета в Env: %s\n'

echo "═══ 2. /proc/1/environ ═══"
docker compose exec -T safe sh -c "tr '\0' '\n' < /proc/1/environ | grep -c 'секретный'" \
    | xargs printf '  упоминаний в окружении процесса: %s\n'

echo "═══ 3. где секрет на самом деле ═══"
docker compose exec -T safe sh -c 'ls -l /run/secrets/' | sed 's/^/  /'

echo "═══ 4. чтение из приложения ═══"
docker compose exec -T safe python -c "
from pathlib import Path
pw = Path('/run/secrets/db_password').read_text()
key = Path('/run/secrets/api-key').read_text()
print(f'  пароль прочитан, длина: {len(pw)}')
print(f'  ключ прочитан:  {key}')
"

echo "═══ 5. docker compose config ═══"
API_KEY=ключ-из-окружения docker compose config | grep -A3 '^secrets:' | sed 's/^/  /'

docker compose down > /dev/null 2>&1

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

text
═══ 1. docker inspect ═══
  упоминаний секрета в Env: 0
═══ 2. /proc/1/environ ═══
  упоминаний в окружении процесса: 0
═══ 3. где секрет на самом деле ═══
  total 8
  -r--------    1 root     root            22 Jul 31 10:14 api-key
  -rw-------    1 root     root            22 Jul 31 10:14 db_password
═══ 4. чтение из приложения ═══
  пароль прочитан, длина: 22
  ключ прочитан:  ключ-из-окружения
═══ 5. docker compose config ═══
  secrets:
    api_key:
      environment: API_KEY
    db_password:
      file: /tmp/envsec/secrets/db_password.txt

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

Пятый блок показывает, что и config печатает только источник секрета, а не его содержимое. Сравните с предыдущим примером, где пароль был виден целиком.

Обратите внимание на права -r-------- у api-key: их задал параметр mode: 0400.

Соглашение _FILE в официальных образах

bash
cd /tmp/envsec
cat > compose.yaml <<'EOF'
name: envsec

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: appdb
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      retries: 20
      start_period: 10s

secrets:
  db_password:
    file: ./secrets/db_password.txt
EOF

docker compose up -d > /dev/null 2>&1
for _ in $(seq 40); do
    [ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
    sleep 1
done

echo "═══ база поднялась с паролем из файла ═══"
printf '  состояние: %s\n' "$(docker compose ps --format '{{.Health}}' db)"

echo "═══ пароля нет в переменных окружения ═══"
docker inspect "$(docker compose ps -q db)" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep -c 'секретный' | xargs printf '  упоминаний: %s\n'
docker inspect "$(docker compose ps -q db)" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep PASSWORD | sed 's/^/  /'

echo "═══ подключение с этим паролем работает ═══"
docker compose exec -T db sh -c '
    PGPASSWORD="$(cat /run/secrets/db_password)" psql -U postgres -d appdb -tAc "SELECT 1"
' | sed 's/^/  результат: /'

docker compose down -v > /dev/null 2>&1

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

text
═══ база поднялась с паролем из файла ═══
  состояние: healthy
═══ пароля нет в переменных окружения ═══
  упоминаний: 0
  POSTGRES_PASSWORD_FILE=/run/secrets/db_password
═══ подключение с этим паролем работает ═══
  результат: 1

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

Реализация _FILE в своём приложении

bash
cd /tmp/envsec
cat > config.py <<'PY'
"""Чтение конфигурации с поддержкой соглашения <VAR>_FILE."""
from __future__ import annotations

import os
from pathlib import Path


def get_secret(name: str, default: str | None = None) -> str | None:
    """Значение из <name>_FILE, затем из <name>, затем default.

    Приоритет у файла: если заданы оба, побеждает более безопасный источник.
    """
    file_var = f"{name}_FILE"
    path = os.environ.get(file_var)
    if path:
        try:
            return Path(path).read_text(encoding="utf-8").strip()
        except OSError as exc:
            raise RuntimeError(f"{file_var}={path}: не удалось прочитать ({exc})") from exc
    return os.environ.get(name, default)


if __name__ == "__main__":
    for var in ("DB_PASSWORD", "API_KEY", "MISSING"):
        value = get_secret(var)
        shown = f"{value[:3]}…({len(value)} симв.)" if value else "(нет)"
        source = "файл" if os.environ.get(f"{var}_FILE") else \
                 "переменная" if os.environ.get(var) else "—"
        print(f"  {var:<12} источник={source:<11} значение={shown}")
PY

cat > compose.yaml <<'EOF'
name: envsec

services:
  app:
    image: python:3.13-slim
    volumes:
      - ./config.py:/config.py:ro
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password     # безопасно
      API_KEY: "ключ-прямо-в-переменной"             # менее безопасно
    secrets:
      - db_password
    command: ["python", "/config.py"]

secrets:
  db_password:
    file: ./secrets/db_password.txt
EOF

docker compose run --rm -T app 2>/dev/null

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

text
  DB_PASSWORD  источник=файл        значение=суп…(22 симв.)
  API_KEY      источник=переменная  значение=клю…(23 симв.)
  MISSING      источник=—           значение=(нет)

Десять строк кода дают приложению ту же возможность, что есть у официальных образов. При этом переменная-путь остаётся видимой в docker inspect — но она не секретна.

Правильная организация файлов

bash
cd /tmp/envsec
cat > .env.example <<'EOF'
# Скопируйте в .env и заполните значения.
# Файл .env в репозиторий НЕ коммитится.

# Обязательные
DB_PASSWORD=

# С умолчаниями
LOG_LEVEL=INFO
WEB_CONCURRENCY=2
IMAGE_TAG=3.13-slim
EOF

cat > .gitignore <<'EOF'
.env
secrets/
*.key
*.pem
EOF

echo "═══ что в репозитории ═══"
ls -a | grep -vE '^\.\.?$' | while read -r f; do
    if grep -qxF "$f" .gitignore 2>/dev/null || grep -qxF "$f/" .gitignore 2>/dev/null; then
        printf '  %-22s ← игнорируется\n' "$f"
    else
        printf '  %-22s   в репозитории\n' "$f"
    fi
done

echo "═══ проверка: нет ли секретов в compose.yaml ═══"
if grep -inE '(password|secret|token|api_?key)\s*:\s*["'"'"']?[A-Za-zА-Яа-я0-9]{6,}' compose.yaml; then
    echo "  ✗ найдены подозрительные значения"
else
    echo "  ✓ значений секретов в файле нет"
fi

cd /tmp && rm -rf /tmp/envsec

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

text
═══ что в репозитории ═══
  .env                     ← игнорируется
  .env.example               в репозитории
  .gitignore                 в репозитории
  compose.yaml               в репозитории
  config.py                  в репозитории
  secrets                  ← игнорируется
═══ проверка: нет ли секретов в compose.yaml ═══
  ✓ значений секретов в файле нет

Файл .env.example играет ключевую роль: он документирует, какие переменные нужны, не раскрывая значений. Новый разработчик копирует его в .env и заполняет.

Последняя проверка — простой линтер, который стоит поставить в CI: он ловит пароль, случайно попавший в конфигурацию.


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

Задание. Настройте конфигурацию, в которой ни одно секретное значение не попадает в репозиторий и в метаданные container'а.

Требования:

  1. Обязательная переменная останавливает запуск с понятным сообщением при отсутствии.
  2. Пароль базы передаётся через Compose secret, а не переменную окружения.
  3. Приложение читает секрет по соглашению _FILE.
  4. Показано, что пароля нет в docker inspect и в /proc/1/environ.
  5. Для сравнения показан сервис с паролем в переменной — и место утечки.
  6. .env.example в репозитории, .env и secrets/ — нет; линтер это проверяет.

Подсказки

Подсказка 1

Для пункта 4 достаточно посчитать вхождения значения пароля в выводе docker inspect.

Подсказка 2

Пункт 5 нагляднее, если оба сервиса используют один и тот же пароль.

Подсказка 3

Линтер из пункта 6 должен проверять и .gitignore, и содержимое compose.yaml.

Решение

Показать решение
bash
mkdir -p /tmp/secfull/secrets && cd /tmp/secfull

cat > config.py <<'PY'
"""Конфигурация с поддержкой <VAR>_FILE и обязательными значениями."""
from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from pathlib import Path


def read_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)


@dataclass(frozen=True)
class Settings:
    db_password: str
    api_key: str
    log_level: str
    source_password: str
    source_api_key: str

    @classmethod
    def load(cls) -> "Settings":
        errors: list[str] = []

        pw = read_secret("DB_PASSWORD")
        if not pw:
            errors.append("DB_PASSWORD (или DB_PASSWORD_FILE) обязателен")

        key = read_secret("API_KEY", "")
        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)
            raise SystemExit(1)

        return cls(
            db_password=pw or "",
            api_key=key or "",
            log_level=level,
            source_password="файл" if os.environ.get("DB_PASSWORD_FILE") else "переменная",
            source_api_key="файл" if os.environ.get("API_KEY_FILE") else "переменная",
        )


def mask(value: str) -> str:
    return f"{value[:3]}…({len(value)} симв.)" if value else "(пусто)"


if __name__ == "__main__":
    s = Settings.load()
    print(f"  DB_PASSWORD  источник={s.source_password:<11} {mask(s.db_password)}")
    print(f"  API_KEY      источник={s.source_api_key:<11} {mask(s.api_key)}")
    print(f"  LOG_LEVEL    {s.log_level}")
PY

PASSWORD="пароль-который-не-должен-утечь"
printf '%s' "$PASSWORD" > secrets/db_password.txt
chmod 600 secrets/db_password.txt

cat > .env.example <<'EOF'
# Скопируйте в .env и заполните. Файл .env в репозиторий не коммитится.
API_KEY=
LOG_LEVEL=INFO
EOF

cat > .env <<'EOF'
API_KEY=ключ-приложения-12345
LOG_LEVEL=DEBUG
EOF

cat > .gitignore <<'EOF'
.env
secrets/
*.key
*.pem
EOF

cat > compose.yaml <<'EOF'
name: secfull

x-app-base: &app-base
  image: python:3.13-slim
  volumes:
    - ./config.py:/config.py:ro
  command: ["python", "/config.py"]

services:
  # Пункты 2–3: пароль через secret
  safe:
    <<: *app-base
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      API_KEY: "${API_KEY:?задайте API_KEY в .env}"
      LOG_LEVEL: "${LOG_LEVEL:-INFO}"
    secrets:
      - db_password

  # Пункт 5: тот же пароль в переменной — для сравнения
  leaky:
    <<: *app-base
    environment:
      DB_PASSWORD: "${DB_PASSWORD_PLAIN:?для демонстрации задайте DB_PASSWORD_PLAIN}"
      API_KEY: "${API_KEY:?задайте API_KEY в .env}"
      LOG_LEVEL: "${LOG_LEVEL:-INFO}"

  # Официальный образ с поддержкой _FILE
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: appdb
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      retries: 20
      start_period: 10s

secrets:
  db_password:
    file: ./secrets/db_password.txt
EOF

cat > lint-secrets.sh <<'SH'
#!/usr/bin/env bash
# Требование 6: линтер конфигурации. Ненулевой код при находке.
set -uo pipefail
fail=0

echo "  проверка .gitignore:"
for entry in ".env" "secrets/"; do
    if grep -qxF "$entry" .gitignore 2>/dev/null; then
        echo "    ✓ $entry игнорируется"
    else
        echo "    ✗ $entry НЕ в .gitignore"
        fail=1
    fi
done

echo "  проверка наличия .env.example:"
if [ -f .env.example ]; then
    echo "    ✓ .env.example есть"
    # Все обязательные переменные compose должны быть в примере
    for v in $(grep -oE '\$\{[A-Z_][A-Z0-9_]*' compose.yaml | tr -d '${' | sort -u); do
        case "$v" in
            DB_PASSWORD_PLAIN) continue ;;   # только для демонстрации
        esac
        if grep -q "^$v=" .env.example; then
            echo "    ✓ $v описан в .env.example"
        else
            echo "    ✗ $v используется, но не описан в .env.example"
            fail=1
        fi
    done
else
    echo "    ✗ .env.example отсутствует"
    fail=1
fi

echo "  проверка compose.yaml на литеральные секреты:"
if grep -inE '^[^#]*(password|secret|token|api_?key)[a-z_]*:[[:space:]]*["'"'"']?[^$"'"'"'[:space:]]{8,}' compose.yaml; then
    echo "    ✗ найдено значение, похожее на секрет"
    fail=1
else
    echo "    ✓ литеральных секретов нет — только \${...} и пути"
fi

exit "$fail"
SH
chmod +x lint-secrets.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: обязательная переменная ═══\n'
mv .env .env.hidden
out="$(docker compose config 2>&1)"; rc=$?
echo "$out" | tail -1 | sed 's/^/    /'
[ "$rc" != "0" ] && ok "запуск остановлен с понятным сообщением" || bad "ошибки не было"
mv .env.hidden .env

printf '\n═══ Требования 2–3: secret и соглашение _FILE ═══\n'
DB_PASSWORD_PLAIN="$PASSWORD" docker compose run --rm -T safe 2>/dev/null | sed 's/^/    /'
src="$(DB_PASSWORD_PLAIN=x docker compose run --rm -T safe 2>/dev/null | awk '/DB_PASSWORD/{print $3}')"
[ "$src" = "источник=файл" ] && ok "пароль прочитан из файла" || bad "источник: $src"

printf '\n═══ Требование 4: утечки нет ═══\n'
DB_PASSWORD_PLAIN="$PASSWORD" docker compose up -d safe > /dev/null 2>&1
sleep 2
cid="$(docker compose ps -aq safe)"
n_inspect="$(docker inspect "$cid" --format '{{json .Config.Env}}' | grep -c "$PASSWORD" || true)"
printf '    вхождений пароля в docker inspect: %s\n' "$n_inspect"
[ "$n_inspect" -eq 0 ] && ok "в метаданных пароля нет" || bad "пароль виден в inspect"

DB_PASSWORD_PLAIN="$PASSWORD" docker compose run --rm -d --name sec-probe safe sleep 60 > /dev/null 2>&1
sleep 2
n_environ="$(docker exec sec-probe sh -c "tr '\0' '\n' < /proc/1/environ" 2>/dev/null | grep -c "$PASSWORD" || true)"
printf '    вхождений в /proc/1/environ:       %s\n' "$n_environ"
[ "$n_environ" -eq 0 ] && ok "в окружении процесса пароля нет" || bad "пароль в окружении"
printf '    файл секрета внутри container:\n'
docker exec sec-probe ls -l /run/secrets/ 2>/dev/null | tail -1 | sed 's/^/      /'
docker rm -f sec-probe > /dev/null 2>&1

printf '\n═══ Требование 5: сервис с паролем в переменной ═══\n'
DB_PASSWORD_PLAIN="$PASSWORD" docker compose up -d leaky > /dev/null 2>&1
sleep 2
lcid="$(docker compose ps -aq leaky)"
n_leak="$(docker inspect "$lcid" --format '{{json .Config.Env}}' | grep -c "$PASSWORD" || true)"
printf '    вхождений пароля в docker inspect: %s\n' "$n_leak"
[ "$n_leak" -gt 0 ] && ok "утечка воспроизведена (для сравнения)" || bad "утечки не видно — проверка неинформативна"
docker inspect "$lcid" --format '{{range .Config.Env}}{{println .}}{{end}}' \
    | grep DB_PASSWORD | sed 's/^/      /'

printf '\n═══ Требование 6: линтер ═══\n'
./lint-secrets.sh
[ $? -eq 0 ] && ok "линтер не нашёл проблем" || bad "линтер сообщил о проблемах"

printf '\n═══ Проверка: официальный образ читает секрет ═══\n'
DB_PASSWORD_PLAIN="$PASSWORD" docker compose up -d db > /dev/null 2>&1
for _ in $(seq 40); do
    [ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
    sleep 1
done
printf '    состояние db: %s\n' "$(docker compose ps --format '{{.Health}}' db)"
res="$(docker compose exec -T db sh -c 'PGPASSWORD="$(cat /run/secrets/db_password)" psql -U postgres -d appdb -tAc "SELECT 42"' 2>/dev/null | tr -d ' \r')"
printf '    запрос с паролем из файла: %s\n' "${res:-ошибка}"
[ "$res" = "42" ] && ok "база приняла пароль из secret" || bad "подключение не удалось"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/secfull
exit "$fail"

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

text
═══ Требование 1: обязательная переменная ═══
    error: required variable API_KEY is missing a value: задайте API_KEY в .env
  ✓ запуск остановлен с понятным сообщением

═══ Требования 2–3: secret и соглашение _FILE ═══
      DB_PASSWORD  источник=файл        пар…(30 симв.)
      API_KEY      источник=переменная  клю…(23 симв.)
      LOG_LEVEL    DEBUG
  ✓ пароль прочитан из файла

═══ Требование 4: утечки нет ═══
    вхождений пароля в docker inspect: 0
  ✓ в метаданных пароля нет
    вхождений в /proc/1/environ:       0
  ✓ в окружении процесса пароля нет
    файл секрета внутри container:
      -rw-------    1 root     root            30 Jul 31 10:22 db_password

═══ Требование 5: сервис с паролем в переменной ═══
    вхождений пароля в docker inspect: 1
  ✓ утечка воспроизведена (для сравнения)
      DB_PASSWORD=пароль-который-не-должен-утечь

═══ Требование 6: линтер ═══
  проверка .gitignore:
    ✓ .env игнорируется
    ✓ secrets/ игнорируется
  проверка наличия .env.example:
    ✓ .env.example есть
    ✓ API_KEY описан в .env.example
    ✓ LOG_LEVEL описан в .env.example
  проверка compose.yaml на литеральные секреты:
    ✓ литеральных секретов нет — только ${...} и пути
  ✓ линтер не нашёл проблем

═══ Проверка: официальный образ читает секрет ═══
    состояние db: healthy
    запрос с паролем из файла: 42
  ✓ база приняла пароль из secret

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Три решения, определяющие качество.

Оба сервиса используют один и тот же пароль. Сравнение «0 вхождений» и «1 вхождение» осмысленно только если ищется одна и та же строка. Разные пароли оставили бы место сомнению: вдруг второй просто не попал в поиск по другой причине.

Требование 5 — обязательная часть, а не иллюстрация. Проверка «пароля нет в inspect» прошла бы и для сломанного скрипта, который ищет не то или не там. Сервис leaky служит положительным контролем: он доказывает, что метод поиска работает и способен обнаружить утечку.

Линтер проверяет .env.example на полноту, а не только на существование. Он извлекает все ${VAR} из compose.yaml и требует, чтобы каждая была описана. Без этого .env.example устареет после первой же новой переменной — и новый разработчик получит непонятную ошибку вместо подсказки.

Чего решение не делает. Compose secrets вне Swarm — это bind mount обычного файла: на диске host секрет лежит открытым текстом, и защищают его только права файловой системы. Реальное управление секретами требует внешнего хранилища (Vault, SOPS, средства облачного провайдера), которое выдаёт значения по запросу и умеет их ротировать. Не решается и ротация: файл монтируется при старте, и обновление секрета требует пересоздания container'а.

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

bash
mkdir -p /tmp/es/secrets && cd /tmp/es
echo -n "тайна" > secrets/s.txt
cat > compose.yaml <<'EOF'
name: es
services:
  a:
    image: alpine:3.21
    command: ["sh", "-c", "cat /run/secrets/s; echo"]
    secrets: [s]
secrets:
  s:
    file: ./secrets/s.txt
EOF
docker compose run --rm -T a
docker compose config | grep -A2 '^secrets:'
docker compose down
cd /tmp && rm -rf /tmp/es

Ожидается вывод тайна и в config — только путь к файлу, не содержимое.

Типичные ошибки

ОшибкаПричинаИсправление
Ждут переменные из .env в containerПохожие названия.env — для подстановки; нужен environment
${VAR:-x} там, где нужна пустая строкаНе различают :- и -:- подменяет и пустое значение
Пароль в compose.yamlБыстрее всегоФайл в репозитории; .env или secrets
Пароль в environmentПривычноВиден в inspect, environ, у потомков
.env закоммиченЗабыли .gitignoreДобавить; в репозитории только .env.example
.env.example не обновляютМеняли только compose.yamlЛинтер в CI
secrets без чтения файла в приложенииОжидают переменнуюСекрет приходит файлом
Забыли _FILE у официального образаНе знали о соглашенииPOSTGRES_PASSWORD_FILE вместо POSTGRES_PASSWORD
Считают secrets шифрованиемНазвание вводит в заблуждениеНа host это обычный файл
$HOME в command без удвоенияНе знали про подстановкуНужен $$HOME

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

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

  1. Чем .env отличается от env_file?
  2. В чём разница между ${VAR:-x} и ${VAR-x}?
  3. Назовите пять мест, где виден пароль из переменной окружения.
  4. Как секрет попадает в container и чем это лучше переменной?
  5. Что означает соглашение _FILE и какие образы его поддерживают?

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

  1. Как сделать переменную обязательной с понятным сообщением?
  2. Как передать пароль базе так, чтобы он не появился в docker inspect?
  3. Как реализовать поддержку _FILE в своём приложении?

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

  1. Переменная задана в .env, приложение её не видит. Причина?
  2. docker compose config печатает ошибку про переменную. Что произошло?

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

  1. .env подставляет значения в текст compose.yaml, но не передаёт их в container.
  2. env_file передаёт переменные процессу внутри container'а.
  3. Значение из .env доходит до приложения только через явную строку в environment.
  4. ${VAR:-x} подменяет и пустую строку, ${VAR-x} — только отсутствующую переменную.
  5. ${VAR:?сообщение} останавливает запуск и печатает ваше сообщение.
  6. $$ даёт литеральный доллар — нужен для переменных shell внутри command.
  7. Пароль в переменной окружения виден в inspect, environ, config и у потомков.
  8. Секрет приходит в container файлом /run/secrets/<имя> и в метаданных не появляется.
  9. Источником секрета может быть файл или переменная окружения Compose.
  10. Официальные образы баз читают пароль по соглашению <VAR>_FILE.
  11. Compose secrets вне Swarm — это bind mount: на host файл лежит открытым текстом.
  12. В репозитории — compose.yaml и .env.example; .env и secrets/ в .gitignore.

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

ИсточникСсылкаЧто подтверждает
Compose: environment variableshttps://docs.docker.com/compose/how-tos/environment-variables/.env, env_file, приоритет
Compose: variable interpolationhttps://docs.docker.com/reference/compose-file/interpolation/Все формы ${VAR...}
Compose: secrets top-levelhttps://docs.docker.com/reference/compose-file/secrets/file, environment, external
Compose: service secretshttps://docs.docker.com/reference/compose-file/services/#secretssource, target, mode
Compose: env_filehttps://docs.docker.com/reference/compose-file/services/#env_filerequired, format
Docker Hub: postgreshttps://hub.docker.com/_/postgresСоглашение POSTGRES_PASSWORD_FILE
Docker: secrets in Swarmhttps://docs.docker.com/engine/swarm/secrets/Отличия от режима Compose

Навигация

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

Markdown на GitHub ↗