6.4. Environment variables
Цели
После этого материала вы сможете:
- объяснить, что делает каждая из переменных
PYTHON*, и когда она нужна; - объяснить механизм буферизации вывода и два независимых способа её отключить;
- обосновать выбор между
PYTHONDONTWRITEBYTECODEи предварительной компиляцией; - передавать конфигурацию приложения через окружение, понимая границы подхода;
- объяснить, почему переменные из entrypoint-скрипта не видны в
docker exec; - валидировать конфигурацию при старте, а не в момент первого запроса.
Предварительные знания
- 5.2. Базовые инструкции —
ARGпротивENV; - 6.3. Python Dockerfile;
- 4.3. Exec, logs, inspect — окружение процессов.
Ключевые термины
| Термин | Объяснение |
|---|---|
буферизация | Накопление вывода в памяти перед фактической записью |
line buffering | Сброс буфера после каждой строки |
block buffering | Сброс буфера при заполнении (обычно 4 или 8 KB) |
bytecode | Скомпилированное представление Python-кода, файлы .pyc |
Twelve-Factor | Методология, предписывающая хранить конфигурацию в окружении |
Теория
Переменные, влияющие на поведение Python
| Переменная | Что делает | Нужна в container |
|---|---|---|
PYTHONUNBUFFERED | Отключает буферизацию stdout и stderr | Всегда |
PYTHONDONTWRITEBYTECODE | Запрещает создание файлов .pyc | Обычно да |
PYTHONFAULTHANDLER | Печатает трассировку при фатальных сигналах | Полезна |
PYTHONHASHSEED | Фиксирует seed хэширования | Для воспроизводимости тестов |
PYTHONPATH | Дополнительные пути импорта | Редко; лучше устанавливать пакет |
PYTHONWARNINGS | Управление предупреждениями | По необходимости |
PIP_NO_CACHE_DIR | Отключает кэш pip | При сборке без cache mount |
PIP_DISABLE_PIP_VERSION_CHECK | Убирает проверку версии pip | Косметика в логах сборки |
Механизм буферизации
Python выбирает режим буферизации в зависимости от того, куда направлен вывод:
stdout подключён к терминалу ──► line buffering
(сброс после каждой строки)
stdout перенаправлен в канал ──► block buffering
или файл (сброс при заполнении ~8 KB)
В container stdout подключён не к терминалу, а к каналу, который читает Docker. Значит, действует блочная буферизация.
Практическое следствие:
приложение печатает "старт" ──► 4 байта в буфере
приложение работает 10 минут ──► буфер не заполнен, ничего не выведено
docker logs ──► пусто
приложение падает ──► буфер потерян, логов нет
Это одна из двух самых частых причин «логов нет, хотя приложение работает». Вторая — запись логов в файл внутри container (урок 6.6).
Три способа отключить буферизацию
| Способ | Область действия | Когда применять |
|---|---|---|
ENV PYTHONUNBUFFERED=1 | Весь процесс | Основной для container |
python -u | Весь процесс | Эквивалент, если нельзя задать переменную |
print(..., flush=True) | Один вызов | В своём коде; не влияет на библиотеки |
sys.stdout.reconfigure(line_buffering=True) | Весь процесс, программно | Когда переменную задать нельзя |
Ключевое различие: flush=True действует только на ваши вызовы print. Сторонняя библиотека, пишущая в stdout, останется буферизованной.
Поэтому PYTHONUNBUFFERED=1 обязателен даже в приложении, где везде проставлен flush=True.
Стоимость отключения буферизации
Небуферизованный вывод означает системный вызов write() на каждую запись. При интенсивном логировании это заметно: тысячи вызовов в секунду вместо десятков.
На практике для типичного сервиса разница несущественна, а потеря логов при падении — существенна. Но если приложение пишет десятки тысяч строк в секунду, стоит использовать не print, а модуль logging с настроенной буферизацией на уровне обработчика (урок 6.6).
PYTHONDONTWRITEBYTECODE
При импорте модуля Python компилирует его в байт-код и сохраняет рядом в каталоге __pycache__. Это ускоряет последующие импорты.
В container это обычно не нужно:
| Аргумент | Обоснование |
|---|---|
Файлы .pyc увеличивают образ | Немного, но бесполезно |
| Пишутся при первом запуске | Замедляют старт, а не ускоряют |
| Засоряют примонтированные каталоги | При bind mount появляются на host |
| Не переживают пересоздание container | Writable layer удаляется |
Отсюда PYTHONDONTWRITEBYTECODE=1 как значение по умолчанию.
Но есть лучший вариант. Скомпилировать байт-код при сборке, а не при запуске:
RUN python -m compileall -q /opt/venv/lib
Тогда .pyc попадают в слой образа, первый импорт быстрый, и ничего не пишется в runtime. Для uv это делает переменная UV_COMPILE_BYTECODE=1.
Комбинация: компилируем при сборке и запрещаем запись в runtime — оптимально для production.
Для воспроизводимости сборки полезен режим инвалидации по хэшу вместо метки времени:
RUN python -m compileall -q --invalidation-mode checked-hash /opt/venv/lib
Иначе .pyc зависят от времени изменения исходников и ломают побитовую воспроизводимость (урок 5.9).
PYTHONFAULTHANDLER
Включает обработчик фатальных сигналов: SIGSEGV, SIGFPE, SIGABRT, SIGBUS. При их получении Python печатает трассировку Python-уровня перед завершением.
Без него сегфолт в C-расширении даёт только код возврата 139 и никакой информации о том, где это произошло.
Стоимость — нулевая: обработчик срабатывает только при аварии. Рекомендуется включать всегда.
Конфигурация через окружение
Методология Twelve-Factor предписывает хранить конфигурацию в переменных окружения. Для контейнеризованных приложений это работает хорошо, но с ограничениями.
Что подходит:
| Тип | Пример |
|---|---|
| Адреса сервисов | DATABASE_URL, REDIS_URL |
| Режимы работы | LOG_LEVEL, DEBUG |
| Числовые параметры | WEB_CONCURRENCY, TIMEOUT |
| Признаки окружения | ENVIRONMENT=production |
Что не подходит:
| Тип | Почему | Альтернатива |
|---|---|---|
| Секреты | Видны в docker inspect, в /proc/<pid>/environ, в логах оркестратора | Файлы, secret mounts |
| Многострочные значения | Экранирование хрупко | Файл конфигурации |
| Большие структуры | Неудобно и нечитаемо | Файл YAML или TOML |
| Значения, меняющиеся часто | Требуют перезапуска | Внешнее хранилище конфигурации |
Строка про секреты важна: переменная окружения доступна любому, кто может выполнить docker inspect, и попадает в дампы состояния. Подробно — в разделе 11.
Валидация при старте
Приложение должно падать при некорректной конфигурации сразу, а не в момент первого запроса.
без валидации:
старт → healthcheck healthy → трафик пошёл → первый запрос → падение
с валидацией:
старт → падение с понятным сообщением → оркестратор не пускает трафик
Второй вариант заметно лучше: отказ происходит до того, как пользователи его увидят.
Инструменты: pydantic-settings для сложных случаев, обычный dataclass с проверками — для простых.
Внутренний механизм
Почему переменные entrypoint не видны в exec
Разбиралось в уроке 4.3, но здесь важен Python-специфичный аспект.
Окружение — свойство процесса, а не container. Команда export VAR=value в entrypoint-скрипте изменяет окружение этого скрипта и наследуется процессами, которые он порождает. Процесс, запущенный через docker exec, порождается извне и получает окружение из конфигурации образа.
Практическое следствие для отладки: чтобы увидеть фактическое окружение работающего приложения, читайте /proc/1/environ:
docker exec <container> sh -c "tr '\0' '\n' < /proc/1/environ"
Как Python определяет режим буферизации
При старте интерпретатор проверяет isatty() для файловых дескрипторов 1 и 2. Терминал — линейная буферизация, иначе — блочная.
Переменная PYTHONUNBUFFERED устанавливается до этой проверки и переводит потоки в небуферизованный режим для бинарного слоя и линейный для текстового.
Начиная с Python 3.9 доступен программный эквивалент:
sys.stdout.reconfigure(line_buffering=True)
Он полезен, когда переменную задать невозможно — например, в библиотеке.
Команды и примеры
Подготовка
mkdir -p /tmp/pyenv && cd /tmp/pyenv
Демонстрация буферизации
cat > buffered.py <<'PY'
import time
print("первая строка — появится сразу?")
time.sleep(5)
print("вторая строка")
PY
cat > Dockerfile.buffered <<'EOF'
FROM python:3.13-slim
COPY buffered.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.buffered -t env:buffered . > /dev/null
docker run -d --name buf env:buffered > /dev/null
sleep 2
echo "логи через 2 секунды после старта:"
docker logs buf 2>&1 | sed 's/^/ /' || echo " (пусто)"
echo " строк: $(docker logs buf 2>&1 | wc -l)"
sleep 5
echo "логи после завершения:"
docker logs buf 2>&1 | sed 's/^/ /'
docker rm -f buf > /dev/null
логи через 2 секунды после старта:
строк: 0
логи после завершения:
первая строка — появится сразу?
вторая строка
Первая строка была напечатана сразу, но появилась в логах только через 5 секунд — при завершении процесса, когда буфер сбросился.
В сервисе, работающем сутками, эта задержка была бы бесконечной.
Три способа исправить
# способ 1: переменная окружения
cat > Dockerfile.unbuffered <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY buffered.py /app.py
CMD ["python", "/app.py"]
EOF
# способ 2: флаг -u
cat > Dockerfile.flag <<'EOF'
FROM python:3.13-slim
COPY buffered.py /app.py
CMD ["python", "-u", "/app.py"]
EOF
for v in unbuffered flag; do
docker build -q -f "Dockerfile.$v" -t "env:$v" . > /dev/null
docker run -d --name "t-$v" "env:$v" > /dev/null
sleep 2
printf '%-12s строк в логах через 2 c: %s\n' "$v" "$(docker logs "t-$v" 2>&1 | wc -l)"
docker rm -f "t-$v" > /dev/null
done
unbuffered строк в логах через 2 c: 1
flag строк в логах через 2 c: 1
Оба способа эквивалентны. Переменная предпочтительнее: она действует независимо от того, как запущено приложение, включая процессы, порождённые через subprocess.
Почему flush=True недостаточно
cat > with_library.py <<'PY'
"""Демонстрация: flush=True в своём коде не влияет на библиотеки."""
import subprocess
import sys
import time
print("моя строка с flush", flush=True)
# Имитация сторонней библиотеки, которая пишет в stdout без flush
code = "import time; print('строка из библиотеки'); time.sleep(4)"
subprocess.run([sys.executable, "-c", code], check=False)
PY
cat > Dockerfile.lib <<'EOF'
FROM python:3.13-slim
COPY with_library.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.lib -t env:lib . > /dev/null
docker run -d --name lib-test env:lib > /dev/null
sleep 2
echo "через 2 секунды видно:"
docker logs lib-test 2>&1 | sed 's/^/ /'
docker rm -f lib-test > /dev/null
через 2 секунды видно:
моя строка с flush
Строка из «библиотеки» не видна: её вывод буферизован. С PYTHONUNBUFFERED=1 дочерний процесс унаследовал бы переменную, и обе строки появились бы сразу.
docker run --rm -d --name lib-fixed -e PYTHONUNBUFFERED=1 env:lib > /dev/null
sleep 2
echo "с PYTHONUNBUFFERED=1:"
docker logs lib-fixed 2>&1 | sed 's/^/ /'
docker rm -f lib-fixed > /dev/null 2>&1 || true
с PYTHONUNBUFFERED=1:
моя строка с flush
строка из библиотеки
Это и есть аргумент за переменную: она действует на всё дерево процессов.
Байт-код: запись против предварительной компиляции
cat > Dockerfile.pyc-default <<'EOF'
FROM python:3.13-slim
RUN pip install --no-cache-dir fastapi==0.141.1
CMD ["python", "-c", "import fastapi; print('импортирован')"]
EOF
cat > Dockerfile.pyc-off <<'EOF'
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
RUN pip install --no-cache-dir fastapi==0.141.1
CMD ["python", "-c", "import fastapi; print('импортирован')"]
EOF
cat > Dockerfile.pyc-precompiled <<'EOF'
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
RUN pip install --no-cache-dir fastapi==0.141.1 \
&& python -m compileall -q --invalidation-mode checked-hash \
/usr/local/lib/python3.13/site-packages
CMD ["python", "-c", "import fastapi; print('импортирован')"]
EOF
for v in pyc-default pyc-off pyc-precompiled; do
docker build -q -f "Dockerfile.$v" -t "env:$v" . > /dev/null
count="$(docker run --rm --entrypoint sh "env:$v" -c \
'find /usr/local/lib/python3.13/site-packages -name "*.pyc" | wc -l')"
size="$(docker images "env:$v" --format '{{.Size}}')"
printf '%-18s .pyc: %-6s размер: %s\n' "$v" "$count" "$size"
done
pyc-default .pyc: 1834 размер: 251MB
pyc-off .pyc: 1834 размер: 251MB
pyc-precompiled .pyc: 1834 размер: 251MB
Все три одинаковы — потому что pip компилирует байт-код при установке независимо от PYTHONDONTWRITEBYTECODE. Переменная влияет только на компиляцию при импорте в runtime.
Проверим, где разница проявляется:
cat > mymodule.py <<'PY'
def hello():
return "привет"
PY
cat > Dockerfile.runtime-pyc <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY mymodule.py .
CMD ["sh", "-c", "python -c 'import mymodule; print(mymodule.hello())'; find /app -name '*.pyc' | wc -l"]
EOF
cat > Dockerfile.runtime-nopyc <<'EOF'
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY mymodule.py .
CMD ["sh", "-c", "python -c 'import mymodule; print(mymodule.hello())'; find /app -name '*.pyc' | wc -l"]
EOF
for v in runtime-pyc runtime-nopyc; do
docker build -q -f "Dockerfile.$v" -t "env:$v" . > /dev/null
printf '%-16s ' "$v"
docker run --rm "env:$v" | tr '\n' ' '
echo
done
runtime-pyc привет 1
runtime-nopyc привет 0
Вот где переменная действует: на ваш код, импортируемый в runtime. Без неё создаётся __pycache__ в writable layer — бесполезный, потому что исчезнет с container.
Особенно заметно при bind mount:
docker run --rm -v "$PWD:/app" python:3.13-slim \
python -c "import sys; sys.path.insert(0,'/app'); import mymodule"
ls -d __pycache__ 2>/dev/null && echo " __pycache__ появился на host (принадлежит root)" || echo " чисто"
sudo rm -rf __pycache__ 2>/dev/null || rm -rf __pycache__ 2>/dev/null
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$PWD:/app" python:3.13-slim \
python -c "import sys; sys.path.insert(0,'/app'); import mymodule"
ls -d __pycache__ 2>/dev/null && echo " __pycache__ появился" || echo " с PYTHONDONTWRITEBYTECODE=1: чисто"
__pycache__ появился на host (принадлежит root)
с PYTHONDONTWRITEBYTECODE=1: чисто
Каталог, созданный от root внутри container, появляется на host и не удаляется обычным пользователем — проблема из раздела 07.
PYTHONFAULTHANDLER
cat > crash.py <<'PY'
"""Провоцирует сегфолт через ctypes."""
import ctypes
print("сейчас будет сегфолт", flush=True)
ctypes.string_at(0) # разыменование нулевого указателя
PY
cat > Dockerfile.nofault <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY crash.py /app.py
CMD ["python", "/app.py"]
EOF
cat > Dockerfile.fault <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1
COPY crash.py /app.py
CMD ["python", "/app.py"]
EOF
for v in nofault fault; do
docker build -q -f "Dockerfile.$v" -t "env:$v" . > /dev/null
echo "── $v ──"
docker run --rm "env:$v" 2>&1 | tail -5 | sed 's/^/ /'
echo " код возврата: $(docker run --rm "env:$v" > /dev/null 2>&1; echo $?)"
done
── nofault ──
сейчас будет сегфолт
код возврата: 139
── fault ──
сейчас будет сегфолт
Fatal Python error: Segmentation fault
Current thread 0x00007f8a2c1d5740 (most recent call first):
File "/app.py", line 6 in <module>
код возврата: 139
Без PYTHONFAULTHANDLER есть только код 139. С ним видна строка, где произошёл сбой. Для отладки падений в C-расширениях это разница между «непонятно» и «понятно».
Конфигурация через окружение
cat > config_app.py <<'PY'
"""Валидация конфигурации при старте."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
database_url: str
log_level: str
workers: int
timeout: float
@classmethod
def from_env(cls) -> Settings:
errors: list[str] = []
database_url = os.environ.get("DATABASE_URL", "")
if not database_url:
errors.append("DATABASE_URL обязателен")
elif not database_url.startswith(("postgresql://", "postgres://")):
errors.append(f"DATABASE_URL должен начинаться с postgresql://, получено: {database_url[:20]}")
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
errors.append(f"недопустимый LOG_LEVEL: {log_level}")
raw_workers = os.environ.get("WEB_CONCURRENCY", "2")
try:
workers = int(raw_workers)
if workers < 1:
errors.append("WEB_CONCURRENCY должен быть >= 1")
except ValueError:
errors.append(f"WEB_CONCURRENCY должен быть числом, получено: {raw_workers!r}")
workers = 0
raw_timeout = os.environ.get("TIMEOUT", "30")
try:
timeout = float(raw_timeout)
except ValueError:
errors.append(f"TIMEOUT должен быть числом, получено: {raw_timeout!r}")
timeout = 0.0
if errors:
print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
raise SystemExit(1)
return cls(database_url, log_level, workers, timeout)
def main() -> int:
settings = Settings.from_env()
print("конфигурация корректна:")
print(f" log_level = {settings.log_level}")
print(f" workers = {settings.workers}")
print(f" timeout = {settings.timeout}")
# секрет не печатаем целиком
print(f" database = {settings.database_url.split('@')[-1]}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
PY
cat > Dockerfile.config <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LOG_LEVEL=INFO \
WEB_CONCURRENCY=2 \
TIMEOUT=30
COPY config_app.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.config -t env:config . > /dev/null
echo "=== корректная конфигурация ==="
docker run --rm -e DATABASE_URL='postgresql://user:pass@db:5432/app' env:config
echo
echo "=== отсутствует обязательная переменная ==="
docker run --rm env:config 2>&1 | sed 's/^/ /'
echo " код возврата: $(docker run --rm env:config > /dev/null 2>&1; echo $?)"
echo
echo "=== некорректные значения ==="
docker run --rm \
-e DATABASE_URL='mysql://db/app' \
-e LOG_LEVEL=VERBOSE \
-e WEB_CONCURRENCY=abc \
env:config 2>&1 | sed 's/^/ /'
=== корректная конфигурация ===
конфигурация корректна:
log_level = INFO
workers = 2
timeout = 30.0
database = db:5432/app
=== отсутствует обязательная переменная ===
ОШИБКА КОНФИГУРАЦИИ:
- DATABASE_URL обязателен
код возврата: 1
=== некорректные значения ===
ОШИБКА КОНФИГУРАЦИИ:
- DATABASE_URL должен начинаться с postgresql://, получено: mysql://db/app
- недопустимый LOG_LEVEL: VERBOSE
- WEB_CONCURRENCY должен быть числом, получено: 'abc'
Все ошибки собраны и показаны сразу, а не по одной при каждом перезапуске. Приложение падает при старте — оркестратор не пустит на него трафик.
Секреты в переменных окружения
docker run -d --name secret-env \
-e DATABASE_URL='postgresql://admin:SuperSecret123@db:5432/app' \
env:config sleep 60 > /dev/null 2>&1 || \
docker run -d --name secret-env --entrypoint sleep \
-e DATABASE_URL='postgresql://admin:SuperSecret123@db:5432/app' \
env:config 60 > /dev/null
echo "=== где виден пароль ==="
echo "1. docker inspect:"
docker inspect secret-env --format '{{range .Config.Env}}{{println " " .}}{{end}}' | grep -i database
echo "2. /proc/1/environ внутри container:"
docker exec secret-env sh -c "tr '\0' '\n' < /proc/1/environ | grep DATABASE" | sed 's/^/ /'
echo "3. окружение процесса на host:"
CPID="$(docker inspect secret-env --format '{{.State.Pid}}')"
sudo tr '\0' '\n' < "/proc/$CPID/environ" 2>/dev/null | grep DATABASE | sed 's/^/ /'
docker rm -f secret-env > /dev/null
=== где виден пароль ===
1. docker inspect:
DATABASE_URL=postgresql://admin:SuperSecret123@db:5432/app
2. /proc/1/environ внутри container:
DATABASE_URL=postgresql://admin:SuperSecret123@db:5432/app
3. окружение процесса на host:
DATABASE_URL=postgresql://admin:SuperSecret123@db:5432/app
Пароль виден в трёх местах, и docker inspect доступен любому члену группы docker. Для секретов нужны файлы — разбирается в разделе 11.
Вариант с файлом:
cat > file_secret.py <<'PY'
import os
from pathlib import Path
def read_secret(name: str) -> str:
"""Читает секрет из файла; путь задаётся переменной с суффиксом _FILE."""
file_var = f"{name}_FILE"
if path := os.environ.get(file_var):
return Path(path).read_text(encoding="utf-8").strip()
return os.environ.get(name, "")
password = read_secret("DB_PASSWORD")
print(f"пароль прочитан, длина: {len(password)}")
print(f"в окружении DB_PASSWORD: {'есть' if os.environ.get('DB_PASSWORD') else 'НЕТ'}")
PY
echo -n 'SuperSecret123' > db_password.txt
cat > Dockerfile.filesecret <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY file_secret.py /app.py
CMD ["python", "/app.py"]
EOF
docker build -q -f Dockerfile.filesecret -t env:filesecret . > /dev/null
docker run --rm \
-v "$PWD/db_password.txt:/run/secrets/db_password:ro" \
-e DB_PASSWORD_FILE=/run/secrets/db_password \
env:filesecret
пароль прочитан, длина: 14
в окружении DB_PASSWORD: НЕТ
Соглашение с суффиксом _FILE широко распространено — его поддерживают официальные образы PostgreSQL, MySQL и другие.
Полный набор для production
cat > Dockerfile.production <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONFAULTHANDLER=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY config_app.py .
CMD ["python", "config_app.py"]
EOF
docker build -q -f Dockerfile.production -t env:prod . > /dev/null
docker image inspect env:prod --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -E 'PYTHON|PIP'
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
PYTHONFAULTHANDLER=1
PIP_DISABLE_PIP_VERSION_CHECK=1
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=env:*') 2>/dev/null || true
rm -rf /tmp/pyenv
Практическое упражнение
Задание. Напишите модуль конфигурации settings.py, который:
- Читает параметры из переменных окружения с разумными значениями по умолчанию.
- Поддерживает соглашение
_FILEдля секретов. - Валидирует все параметры и выводит все ошибки сразу, а не первую.
- Не печатает секреты в логах — маскирует их.
- Падает с кодом
1при некорректной конфигурации.
Обязательные параметры: DATABASE_URL (обязателен, проверка схемы), LOG_LEVEL (из допустимого набора), WEB_CONCURRENCY (целое ≥ 1), API_KEY (секрет, поддерживает _FILE).
Напишите тесты, проверяющие каждый случай.
Подсказки
Подсказка 1
Собирайте ошибки в список и проверяйте его в конце — тогда пользователь увидит все проблемы за один запуск.
Подсказка 2
Для маскирования достаточно показывать первые и последние два символа: Su********23.
Подсказка 3
Тестировать удобно через monkeypatch.setenv и pytest.raises(SystemExit).
Решение
Сначала выполните задание самостоятельно.
Показать решение
mkdir -p /tmp/settings-ex && cd /tmp/settings-ex
cat > settings.py <<'PY'
"""Конфигурация приложения из переменных окружения.
Принципы:
- все ошибки собираются и показываются сразу;
- секреты поддерживают соглашение _FILE;
- секреты маскируются при выводе;
- некорректная конфигурация останавливает приложение при старте.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
VALID_DB_SCHEMES = ("postgresql://", "postgres://")
def _read_secret(name: str) -> str:
"""Читает секрет: сначала из файла по <NAME>_FILE, затем из <NAME>."""
if path := os.environ.get(f"{name}_FILE"):
try:
return Path(path).read_text(encoding="utf-8").strip()
except OSError as exc:
raise ValueError(f"не удалось прочитать {name}_FILE={path}: {exc}") from exc
return os.environ.get(name, "")
def mask(value: str, visible: int = 2) -> str:
"""Маскирует секрет, оставляя видимыми края."""
if not value:
return "(пусто)"
if len(value) <= visible * 2:
return "*" * len(value)
return f"{value[:visible]}{'*' * (len(value) - visible * 2)}{value[-visible:]}"
@dataclass(frozen=True)
class Settings:
database_url: str
log_level: str
web_concurrency: int
api_key: str = field(repr=False)
@classmethod
def from_env(cls) -> Settings:
errors: list[str] = []
# DATABASE_URL — обязателен, проверяем схему
database_url = os.environ.get("DATABASE_URL", "").strip()
if not database_url:
errors.append("DATABASE_URL обязателен")
elif not database_url.startswith(VALID_DB_SCHEMES):
errors.append(
f"DATABASE_URL должен начинаться с одного из {VALID_DB_SCHEMES}, "
f"получено: {database_url.split('://')[0]}://"
)
# LOG_LEVEL — из допустимого набора
log_level = os.environ.get("LOG_LEVEL", "INFO").strip().upper()
if log_level not in VALID_LOG_LEVELS:
errors.append(
f"LOG_LEVEL должен быть одним из {sorted(VALID_LOG_LEVELS)}, "
f"получено: {log_level}"
)
# WEB_CONCURRENCY — целое >= 1
raw = os.environ.get("WEB_CONCURRENCY", "2").strip()
web_concurrency = 0
try:
web_concurrency = int(raw)
if web_concurrency < 1:
errors.append(f"WEB_CONCURRENCY должен быть >= 1, получено: {web_concurrency}")
except ValueError:
errors.append(f"WEB_CONCURRENCY должен быть целым числом, получено: {raw!r}")
# API_KEY — секрет, поддерживает _FILE
api_key = ""
try:
api_key = _read_secret("API_KEY")
except ValueError as exc:
errors.append(str(exc))
if not api_key:
errors.append("API_KEY обязателен (переменная или API_KEY_FILE)")
elif len(api_key) < 8:
errors.append(f"API_KEY слишком короткий: {len(api_key)} символов, нужно >= 8")
if errors:
print("ОШИБКА КОНФИГУРАЦИИ:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
print(file=sys.stderr)
print("Приложение не запущено. Исправьте конфигурацию.", file=sys.stderr)
raise SystemExit(1)
return cls(
database_url=database_url,
log_level=log_level,
web_concurrency=web_concurrency,
api_key=api_key,
)
def describe(self) -> str:
"""Безопасное для логов описание: секреты маскированы."""
host = self.database_url.split("@")[-1] if "@" in self.database_url else self.database_url
return "\n".join([
"конфигурация:",
f" database = {host}",
f" log_level = {self.log_level}",
f" web_concurrency = {self.web_concurrency}",
f" api_key = {mask(self.api_key)}",
])
if __name__ == "__main__":
print(Settings.from_env().describe())
PY
cat > test_settings.py <<'PY'
"""Тесты конфигурации."""
import pytest
from settings import Settings, mask
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for var in ("DATABASE_URL", "LOG_LEVEL", "WEB_CONCURRENCY", "API_KEY", "API_KEY_FILE"):
monkeypatch.delenv(var, raising=False)
def set_valid(monkeypatch, **overrides):
env = {
"DATABASE_URL": "postgresql://u:p@db:5432/app",
"LOG_LEVEL": "INFO",
"WEB_CONCURRENCY": "4",
"API_KEY": "supersecretkey",
}
env.update(overrides)
for k, v in env.items():
if v is None:
monkeypatch.delenv(k, raising=False)
else:
monkeypatch.setenv(k, v)
def test_valid_config(monkeypatch):
set_valid(monkeypatch)
s = Settings.from_env()
assert s.web_concurrency == 4
assert s.log_level == "INFO"
def test_defaults_applied(monkeypatch):
set_valid(monkeypatch, LOG_LEVEL=None, WEB_CONCURRENCY=None)
s = Settings.from_env()
assert s.log_level == "INFO"
assert s.web_concurrency == 2
def test_missing_database_url(monkeypatch, capsys):
set_valid(monkeypatch, DATABASE_URL=None)
with pytest.raises(SystemExit) as exc:
Settings.from_env()
assert exc.value.code == 1
assert "DATABASE_URL обязателен" in capsys.readouterr().err
def test_wrong_db_scheme(monkeypatch, capsys):
set_valid(monkeypatch, DATABASE_URL="mysql://db/app")
with pytest.raises(SystemExit):
Settings.from_env()
assert "должен начинаться" in capsys.readouterr().err
def test_invalid_log_level(monkeypatch, capsys):
set_valid(monkeypatch, LOG_LEVEL="VERBOSE")
with pytest.raises(SystemExit):
Settings.from_env()
assert "LOG_LEVEL" in capsys.readouterr().err
def test_non_numeric_concurrency(monkeypatch, capsys):
set_valid(monkeypatch, WEB_CONCURRENCY="abc")
with pytest.raises(SystemExit):
Settings.from_env()
assert "целым числом" in capsys.readouterr().err
def test_zero_concurrency(monkeypatch, capsys):
set_valid(monkeypatch, WEB_CONCURRENCY="0")
with pytest.raises(SystemExit):
Settings.from_env()
assert ">= 1" in capsys.readouterr().err
def test_short_api_key(monkeypatch, capsys):
set_valid(monkeypatch, API_KEY="short")
with pytest.raises(SystemExit):
Settings.from_env()
assert "слишком короткий" in capsys.readouterr().err
def test_api_key_from_file(monkeypatch, tmp_path):
secret = tmp_path / "key.txt"
secret.write_text("filesecretkey\n")
set_valid(monkeypatch, API_KEY=None)
monkeypatch.setenv("API_KEY_FILE", str(secret))
s = Settings.from_env()
assert s.api_key == "filesecretkey"
def test_all_errors_reported_at_once(monkeypatch, capsys):
set_valid(monkeypatch, DATABASE_URL=None, LOG_LEVEL="X", WEB_CONCURRENCY="abc", API_KEY=None)
with pytest.raises(SystemExit):
Settings.from_env()
err = capsys.readouterr().err
# Все четыре ошибки должны быть в одном выводе
assert err.count(" - ") == 4
def test_secret_not_in_repr(monkeypatch):
set_valid(monkeypatch)
s = Settings.from_env()
assert "supersecretkey" not in repr(s)
def test_describe_masks_secret(monkeypatch):
set_valid(monkeypatch)
out = Settings.from_env().describe()
assert "supersecretkey" not in out
assert "su" in out and "ey" in out
@pytest.mark.parametrize(
("value", "expected"),
[("", "(пусто)"), ("abc", "***"), ("abcdef", "ab**ef")],
)
def test_mask(value, expected):
assert mask(value) == expected
PY
python3 -m pytest -q test_settings.py 2>&1 | tail -3
Ожидаемый вывод:
............. [100%]
13 passed in 0.05s
Проверка вручную:
echo "=== все ошибки сразу ==="
python3 settings.py 2>&1 | head -8
echo
echo "=== корректная конфигурация ==="
DATABASE_URL='postgresql://user:pass@db:5432/app' \
API_KEY='mysupersecretkey' \
WEB_CONCURRENCY=4 \
python3 settings.py
echo
echo "=== секрет из файла ==="
echo -n 'filebasedsecret' > /tmp/api.key
DATABASE_URL='postgresql://user:pass@db:5432/app' \
API_KEY_FILE=/tmp/api.key \
python3 settings.py
rm -f /tmp/api.key
=== все ошибки сразу ===
ОШИБКА КОНФИГУРАЦИИ:
- DATABASE_URL обязателен
- API_KEY обязателен (переменная или API_KEY_FILE)
Приложение не запущено. Исправьте конфигурацию.
=== корректная конфигурация ===
конфигурация:
database = db:5432/app
log_level = INFO
web_concurrency = 4
api_key = my************ey
=== секрет из файла ===
конфигурация:
database = db:5432/app
log_level = INFO
web_concurrency = 2
api_key = fi***********et
Три решения, которые делают модуль практичным.
Сбор всех ошибок. Наивная реализация проверяет параметры по очереди и падает на первом. Тогда исправление трёх ошибок требует трёх запусков. Здесь ошибки накапливаются в списке — тест test_all_errors_reported_at_once это фиксирует.
Маскирование в двух местах. Поле api_key объявлено с field(repr=False) — оно не попадёт в автоматический repr dataclass, который легко утекает в логи при отладке. Метод describe() дополнительно маскирует значение. Два теста проверяют оба канала.
Соглашение _FILE. Приоритет отдан файлу: если задана API_KEY_FILE, переменная API_KEY игнорируется. Это позволяет одному образу работать и в разработке (переменная), и в production (файл из secret mount), без изменения кода.
Что осталось за рамками. Для сложной конфигурации с вложенными структурами удобнее pydantic-settings: он даёт типизацию, автоматическую валидацию и поддержку .env из коробки. Ручная реализация показана здесь, чтобы механизм был виден целиком.
Проверка результата
docker run --rm python:3.13-slim python -c "
import sys
print('буферизация stdout:', 'нет' if not sys.stdout.line_buffering else 'построчная')
"
docker run --rm -e PYTHONUNBUFFERED=1 python:3.13-slim python -c "
import sys
print('с PYTHONUNBUFFERED, line_buffering:', sys.stdout.line_buffering)
"
Убедитесь, что различие видно, и объясните, почему это критично для docker logs.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Нет PYTHONUNBUFFERED | Не знают о буферизации | Логи не появляются; при падении теряются |
Полагаться на flush=True | Работает в своём коде | Сторонние библиотеки его не используют |
PYTHONDONTWRITEBYTECODE считают влияющим на pip | Название вводит в заблуждение | pip компилирует независимо; переменная влияет на runtime |
__pycache__ на host при bind mount | Не задана переменная | Каталоги от root не удаляются обычным пользователем |
| Секреты в переменных окружения | Простой способ | Видны в docker inspect и /proc/<pid>/environ |
| Валидация конфигурации при первом запросе | Не задумывались | Отказ виден пользователю, а не при старте |
| Первая ошибка вместо всех | Простая реализация | Три ошибки требуют трёх перезапусков |
Секрет в repr dataclass | Автоматический repr | Утекает при отладочном логировании; field(repr=False) |
PYTHONPATH вместо установки пакета | Кажется проще | Хрупко; установить пакет через pip install . |
Нет PYTHONFAULTHANDLER | Не знают о переменной | При сегфолте только код 139, без трассировки |
Контрольные вопросы
На понимание:
- Почему в container Python использует блочную буферизацию, а в терминале — построчную?
- Почему
flush=Trueв коде не заменяетPYTHONUNBUFFERED? - На что влияет
PYTHONDONTWRITEBYTECODE, а на что не влияет? - Почему предварительная компиляция байт-кода лучше, чем его создание в runtime?
- В каких трёх местах виден секрет, переданный через переменную окружения?
На применение:
- Как отключить буферизацию, если изменить
Dockerfileнельзя? - Как реализовать поддержку секретов из файлов, не меняя интерфейс приложения?
- Как получить трассировку при сегфолте в C-расширении?
На диагностику:
docker logsпуст, хотя приложение работает и пишет в stdout. Назовите две независимые причины.- После bind mount исходников на host появились каталоги
__pycache__, принадлежащиеroot. Причина и исправление?
Краткое резюме
- В container stdout подключён к каналу, поэтому Python использует блочную буферизацию.
PYTHONUNBUFFERED=1обязателен: без него логи не появляются и теряются при падении.flush=Trueдействует только на свой код; переменная — на всё дерево процессов.PYTHONDONTWRITEBYTECODEвлияет на компиляцию при импорте в runtime, а не наpip.- Оптимально: компилировать байт-код при сборке и запретить запись в runtime.
PYTHONFAULTHANDLERдаёт трассировку при сегфолте — стоимость нулевая.- Конфигурация через окружение подходит для параметров, но не для секретов.
- Секрет в переменной виден в
docker inspect, в/proc/1/environи в окружении процесса на host. - Соглашение
_FILEпозволяет одному образу работать и с переменной, и с файлом. - Валидация конфигурации выполняется при старте и сообщает все ошибки сразу.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Python: command line and environment | https://docs.python.org/3/using/cmdline.html | PYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE, PYTHONFAULTHANDLER, PYTHONHASHSEED, флаг -u |
Python: sys.stdout | https://docs.python.org/3/library/sys.html#sys.stdout | Режимы буферизации, reconfigure |
Python: compileall | https://docs.python.org/3/library/compileall.html | Предварительная компиляция, --invalidation-mode |
Python: faulthandler | https://docs.python.org/3/library/faulthandler.html | Трассировка при фатальных сигналах |
| Dockerfile reference: ENV | https://docs.docker.com/reference/dockerfile/#env | Синтаксис и попадание в конфигурацию образа |
| docker run reference | https://docs.docker.com/reference/cli/docker/container/run/ | Флаги -e и --env-file |
| The Twelve-Factor App: Config | https://12factor.net/config | Хранение конфигурации в окружении |
| Compose: environment variables | https://docs.docker.com/compose/how-tos/environment-variables/ | Приоритет источников переменных |
| pydantic-settings | https://docs.pydantic.dev/latest/concepts/pydantic_settings/ | Типизированная конфигурация из окружения |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Сигналы и PID 1 в Python
Главное оглавление