9.5. Environment и secrets
Цели
После этого материала вы сможете:
- объяснить, что делает файл
.envи чего он не делает; - использовать все формы подстановки, включая различие
${VAR:-x}и${VAR-x}; - задать обязательную переменную так, чтобы её отсутствие останавливало запуск;
- показать, где именно утекает пароль, переданный через переменную окружения;
- подключить Compose secrets и прочитать их из приложения;
- назвать, что нельзя хранить в
compose.yamlи что вместо этого делать.
Предварительные знания
- 9.2. Справочник по services —
environmentиenv_file; - 6.4. Переменные окружения.
Ключевые термины
| Термин | Объяснение |
|---|---|
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, если переменная задана и непуста |
$$ | Литеральный знак доллара |
Различие :- и - практически важно. Пустая строка — законное значение, и иногда её задают намеренно:
LOG_PREFIX: "${PREFIX-по умолчанию}" # PREFIX="" даст пустую строку
LOG_LEVEL: "${LEVEL:-INFO}" # LEVEL="" даст INFO
Для обязательных переменных используйте :? — это единственный способ заставить Compose отказаться работать без значения:
DATABASE_PASSWORD: "${DB_PASSWORD:?задайте DB_PASSWORD в .env}"
Знак $$ нужен, когда доллар должен попасть в container как есть — например, в команде shell:
command: ["sh", "-c", "echo Домашний каталог: $$HOME"]
Без удвоения Compose попытается подставить ${HOME} из своего окружения.
env_file с параметрами
services:
app:
env_file:
- .env.common
- path: .env.local
required: false # не падать, если файла нет
- path: .env.raw
format: raw # не интерпретировать кавычки и экранирование
| Параметр | Назначение |
|---|---|
path | Путь к файлу |
required | false — отсутствие файла не ошибка |
format: raw | Значение берётся дословно |
Формат файла:
# комментарий
KEY=value
QUOTED="значение с пробелами"
EMPTY=
MULTI=строка1\nстрока2
Подстановка ${OTHER} внутри env_file не выполняется — в отличие от compose.yaml. Значения берутся как есть.
Приоритет источников
От низшего к высшему:
| Приоритет | Источник |
|---|---|
| 1 | ENV в образе |
| 2 | env_file, в порядке перечисления |
| 3 | environment в compose.yaml |
| 4 | Окружение shell — для ключа без значения |
| 5 | docker 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
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/<имя>:
password = Path("/run/secrets/db_password").read_text().strip()
| Свойство | Переменная окружения | Secret |
|---|---|---|
Виден в docker inspect | Да | Нет |
Виден в /proc/<pid>/environ | Да | Нет |
| Наследуется потомками | Да | Нет |
| Требует поддержки в приложении | Нет | Да — нужно прочитать файл |
| Попадает в образ | Нет | Нет |
| Ротация без пересоздания | Нет | Нет (файл монтируется при старте) |
Ключевое ограничение: приложение должно уметь читать файл. Многие образы поддерживают это через соглашение <VAR>_FILE:
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) |
Рабочая схема:
compose.yaml → в репозитории, без секретов
.env.example → в репозитории, с описанием переменных
.env → НЕ в репозитории (в .gitignore)
secrets/*.txt → НЕ в репозитории
Внутренний механизм
Как Compose монтирует секреты
Вне Swarm секрет реализуется как bind mount файла в /run/secrets/<имя> с правами только для чтения. Никакой криптографии здесь нет: защита в том, что значение не попадает ни в метаданные container'а, ни в окружение процессов.
Отсюда следствие: на диске host секрет лежит обычным файлом. Права на этот файл — ваша ответственность.
Порядок обработки конфигурации
- Читается
.env(или файл из--env-file). - Переменные окружения shell накладываются поверх.
- Выполняется подстановка
${VAR}в текстеcompose.yaml. - Файлы сливаются, если их несколько (урок 9.6).
- Применяются
env_fileиenvironment— уже как содержимое container'ов.
Шаги 1–3 происходят до того, как Compose вообще понимает структуру файла. Поэтому подставлять можно что угодно — включая имена сервисов и ключи.
Команды и примеры
.env не передаётся в container
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/^/ /'
Ожидаемый вывод:
═══ что видит container ═══
IMAGE_TAG = (отсутствует)
SECRET_FROM_DOTENV = (отсутствует)
EXPLICIT = (отсутствует)
═══ а в конфигурацию подстановка прошла ═══
image: python:3.13-slim
Обе переменные из .env в container не попали, хотя IMAGE_TAG явно повлиял на выбор образа.
Чтобы значение дошло до приложения, его нужно передать явно:
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
Ожидаемый вывод:
IMAGE_TAG = (отсутствует)
SECRET_FROM_DOTENV = это-значение-из-dotenv
EXPLICIT = задано прямо в файле
SECRET_FROM_DOTENV дошёл — потому что появилась строка, которая его передаёт. IMAGE_TAG по-прежнему невидим приложению, и это правильно: он нужен только Compose.
Формы подстановки
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
Ожидаемый вывод:
═══ 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="" форма :- подставила умолчание, форма - оставила пустую строку.
Это существенно для параметров, где пустое значение осмысленно — например, префикс логов или дополнительные аргументы командной строки.
Обязательные переменные
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
Ожидаемый вывод:
═══ без переменной ═══
error: required variable DB_PASSWORD is missing a value: задайте DB_PASSWORD в .env или окружении
код: 0
═══ с переменной ═══
пароль получен, длина: 9
Compose отказывается работать и печатает ваше сообщение. Это лучше, чем запуск с пустым паролем и падение приложения через десять секунд с невнятной ошибкой.
Обратите внимание на код: 0 — это код sed, а не config. Проверять нужно код самой команды:
cd /tmp/envsec
docker compose config > /dev/null 2>&1
echo " код docker compose config: $?"
Ожидаемый вывод:
код docker compose config: 1
Где утекает пароль из переменной окружения
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
Ожидаемый вывод:
═══ 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: то же значение, другой путь
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
Ожидаемый вывод:
═══ 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 в официальных образах
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
Ожидаемый вывод:
═══ база поднялась с паролем из файла ═══
состояние: healthy
═══ пароля нет в переменных окружения ═══
упоминаний: 0
POSTGRES_PASSWORD_FILE=/run/secrets/db_password
═══ подключение с этим паролем работает ═══
результат: 1
В переменной лежит путь, а не значение. База прочитала файл сама — благодаря поддержке суффикса _FILE в официальном образе.
Реализация _FILE в своём приложении
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
Ожидаемый вывод:
DB_PASSWORD источник=файл значение=суп…(22 симв.)
API_KEY источник=переменная значение=клю…(23 симв.)
MISSING источник=— значение=(нет)
Десять строк кода дают приложению ту же возможность, что есть у официальных образов. При этом переменная-путь остаётся видимой в docker inspect — но она не секретна.
Правильная организация файлов
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
Ожидаемый вывод:
═══ что в репозитории ═══
.env ← игнорируется
.env.example в репозитории
.gitignore в репозитории
compose.yaml в репозитории
config.py в репозитории
secrets ← игнорируется
═══ проверка: нет ли секретов в compose.yaml ═══
✓ значений секретов в файле нет
Файл .env.example играет ключевую роль: он документирует, какие переменные нужны, не раскрывая значений. Новый разработчик копирует его в .env и заполняет.
Последняя проверка — простой линтер, который стоит поставить в CI: он ловит пароль, случайно попавший в конфигурацию.
Практическое упражнение
Задание. Настройте конфигурацию, в которой ни одно секретное значение не попадает в репозиторий и в метаданные container'а.
Требования:
- Обязательная переменная останавливает запуск с понятным сообщением при отсутствии.
- Пароль базы передаётся через Compose secret, а не переменную окружения.
- Приложение читает секрет по соглашению
_FILE. - Показано, что пароля нет в
docker inspectи в/proc/1/environ. - Для сравнения показан сервис с паролем в переменной — и место утечки.
.env.exampleв репозитории,.envиsecrets/— нет; линтер это проверяет.
Подсказки
Подсказка 1
Для пункта 4 достаточно посчитать вхождения значения пароля в выводе docker inspect.
Подсказка 2
Пункт 5 нагляднее, если оба сервиса используют один и тот же пароль.
Подсказка 3
Линтер из пункта 6 должен проверять и .gitignore, и содержимое compose.yaml.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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'а.
Проверка результата
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 |
Контрольные вопросы
На понимание:
- Чем
.envотличается отenv_file? - В чём разница между
${VAR:-x}и${VAR-x}? - Назовите пять мест, где виден пароль из переменной окружения.
- Как секрет попадает в container и чем это лучше переменной?
- Что означает соглашение
_FILEи какие образы его поддерживают?
На применение:
- Как сделать переменную обязательной с понятным сообщением?
- Как передать пароль базе так, чтобы он не появился в
docker inspect? - Как реализовать поддержку
_FILEв своём приложении?
На диагностику:
- Переменная задана в
.env, приложение её не видит. Причина? docker compose configпечатает ошибку про переменную. Что произошло?
Краткое резюме
.envподставляет значения в текстcompose.yaml, но не передаёт их в container.env_fileпередаёт переменные процессу внутри container'а.- Значение из
.envдоходит до приложения только через явную строку вenvironment. ${VAR:-x}подменяет и пустую строку,${VAR-x}— только отсутствующую переменную.${VAR:?сообщение}останавливает запуск и печатает ваше сообщение.$$даёт литеральный доллар — нужен для переменных shell внутриcommand.- Пароль в переменной окружения виден в
inspect,environ,configи у потомков. - Секрет приходит в container файлом
/run/secrets/<имя>и в метаданных не появляется. - Источником секрета может быть файл или переменная окружения Compose.
- Официальные образы баз читают пароль по соглашению
<VAR>_FILE. - Compose secrets вне Swarm — это bind mount: на host файл лежит открытым текстом.
- В репозитории —
compose.yamlи.env.example;.envиsecrets/в.gitignore.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: environment variables | https://docs.docker.com/compose/how-tos/environment-variables/ | .env, env_file, приоритет |
| Compose: variable interpolation | https://docs.docker.com/reference/compose-file/interpolation/ | Все формы ${VAR...} |
| Compose: secrets top-level | https://docs.docker.com/reference/compose-file/secrets/ | file, environment, external |
| Compose: service secrets | https://docs.docker.com/reference/compose-file/services/#secrets | source, target, mode |
Compose: env_file | https://docs.docker.com/reference/compose-file/services/#env_file | required, format |
| Docker Hub: postgres | https://hub.docker.com/_/postgres | Соглашение POSTGRES_PASSWORD_FILE |
| Docker: secrets in Swarm | https://docs.docker.com/engine/swarm/secrets/ | Отличия от режима Compose |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Несколько файлов и profiles
Главное оглавление