Главная/Python внутри Container/Урок

6.4. Environment variables

Цели

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

  • объяснить, что делает каждая из переменных PYTHON*, и когда она нужна;
  • объяснить механизм буферизации вывода и два независимых способа её отключить;
  • обосновать выбор между PYTHONDONTWRITEBYTECODE и предварительной компиляцией;
  • передавать конфигурацию приложения через окружение, понимая границы подхода;
  • объяснить, почему переменные из entrypoint-скрипта не видны в docker exec;
  • валидировать конфигурацию при старте, а не в момент первого запроса.

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

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

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

text
   stdout подключён к терминалу  ──►  line buffering
                                       (сброс после каждой строки)

   stdout перенаправлен в канал  ──►  block buffering
   или файл                            (сброс при заполнении ~8 KB)

В container stdout подключён не к терминалу, а к каналу, который читает Docker. Значит, действует блочная буферизация.

Практическое следствие:

text
   приложение печатает "старт"      ──►  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
Не переживают пересоздание containerWritable layer удаляется

Отсюда PYTHONDONTWRITEBYTECODE=1 как значение по умолчанию.

Но есть лучший вариант. Скомпилировать байт-код при сборке, а не при запуске:

dockerfile
RUN python -m compileall -q /opt/venv/lib

Тогда .pyc попадают в слой образа, первый импорт быстрый, и ничего не пишется в runtime. Для uv это делает переменная UV_COMPILE_BYTECODE=1.

Комбинация: компилируем при сборке и запрещаем запись в runtime — оптимально для production.

Для воспроизводимости сборки полезен режим инвалидации по хэшу вместо метки времени:

dockerfile
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.

Валидация при старте

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

text
   без валидации:
   старт → healthcheck healthy → трафик пошёл → первый запрос → падение

   с валидацией:
   старт → падение с понятным сообщением → оркестратор не пускает трафик

Второй вариант заметно лучше: отказ происходит до того, как пользователи его увидят.

Инструменты: pydantic-settings для сложных случаев, обычный dataclass с проверками — для простых.


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

Почему переменные entrypoint не видны в exec

Разбиралось в уроке 4.3, но здесь важен Python-специфичный аспект.

Окружение — свойство процесса, а не container. Команда export VAR=value в entrypoint-скрипте изменяет окружение этого скрипта и наследуется процессами, которые он порождает. Процесс, запущенный через docker exec, порождается извне и получает окружение из конфигурации образа.

Практическое следствие для отладки: чтобы увидеть фактическое окружение работающего приложения, читайте /proc/1/environ:

bash
docker exec <container> sh -c "tr '\0' '\n' < /proc/1/environ"

Как Python определяет режим буферизации

При старте интерпретатор проверяет isatty() для файловых дескрипторов 1 и 2. Терминал — линейная буферизация, иначе — блочная.

Переменная PYTHONUNBUFFERED устанавливается до этой проверки и переводит потоки в небуферизованный режим для бинарного слоя и линейный для текстового.

Начиная с Python 3.9 доступен программный эквивалент:

python
sys.stdout.reconfigure(line_buffering=True)

Он полезен, когда переменную задать невозможно — например, в библиотеке.


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

Подготовка

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

Демонстрация буферизации

bash
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
text
логи через 2 секунды после старта:
  строк: 0
логи после завершения:
  первая строка — появится сразу?
  вторая строка

Первая строка была напечатана сразу, но появилась в логах только через 5 секунд — при завершении процесса, когда буфер сбросился.

В сервисе, работающем сутками, эта задержка была бы бесконечной.

Три способа исправить

bash
# способ 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
text
unbuffered   строк в логах через 2 c: 1
flag         строк в логах через 2 c: 1

Оба способа эквивалентны. Переменная предпочтительнее: она действует независимо от того, как запущено приложение, включая процессы, порождённые через subprocess.

Почему flush=True недостаточно

bash
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
text
через 2 секунды видно:
  моя строка с flush

Строка из «библиотеки» не видна: её вывод буферизован. С PYTHONUNBUFFERED=1 дочерний процесс унаследовал бы переменную, и обе строки появились бы сразу.

bash
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
text
с PYTHONUNBUFFERED=1:
  моя строка с flush
  строка из библиотеки

Это и есть аргумент за переменную: она действует на всё дерево процессов.

Байт-код: запись против предварительной компиляции

bash
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
text
pyc-default        .pyc: 1834   размер: 251MB
pyc-off            .pyc: 1834   размер: 251MB
pyc-precompiled    .pyc: 1834   размер: 251MB

Все три одинаковы — потому что pip компилирует байт-код при установке независимо от PYTHONDONTWRITEBYTECODE. Переменная влияет только на компиляцию при импорте в runtime.

Проверим, где разница проявляется:

bash
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
text
runtime-pyc      привет 1
runtime-nopyc    привет 0

Вот где переменная действует: на ваш код, импортируемый в runtime. Без неё создаётся __pycache__ в writable layer — бесполезный, потому что исчезнет с container.

Особенно заметно при bind mount:

bash
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: чисто"
text
  __pycache__ появился на host (принадлежит root)
  с PYTHONDONTWRITEBYTECODE=1: чисто

Каталог, созданный от root внутри container, появляется на host и не удаляется обычным пользователем — проблема из раздела 07.

PYTHONFAULTHANDLER

bash
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
text
── 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-расширениях это разница между «непонятно» и «понятно».

Конфигурация через окружение

bash
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/^/  /'
text
=== корректная конфигурация ===
конфигурация корректна:
  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'

Все ошибки собраны и показаны сразу, а не по одной при каждом перезапуске. Приложение падает при старте — оркестратор не пустит на него трафик.

Секреты в переменных окружения

bash
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
text
=== где виден пароль ===
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.

Вариант с файлом:

bash
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
text
пароль прочитан, длина: 14
в окружении DB_PASSWORD: НЕТ

Соглашение с суффиксом _FILE широко распространено — его поддерживают официальные образы PostgreSQL, MySQL и другие.

Полный набор для production

bash
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'
text
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
PYTHONFAULTHANDLER=1
PIP_DISABLE_PIP_VERSION_CHECK=1

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=env:*') 2>/dev/null || true
rm -rf /tmp/pyenv

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

Задание. Напишите модуль конфигурации settings.py, который:

  1. Читает параметры из переменных окружения с разумными значениями по умолчанию.
  2. Поддерживает соглашение _FILE для секретов.
  3. Валидирует все параметры и выводит все ошибки сразу, а не первую.
  4. Не печатает секреты в логах — маскирует их.
  5. Падает с кодом 1 при некорректной конфигурации.

Обязательные параметры: DATABASE_URL (обязателен, проверка схемы), LOG_LEVEL (из допустимого набора), WEB_CONCURRENCY (целое ≥ 1), API_KEY (секрет, поддерживает _FILE).

Напишите тесты, проверяющие каждый случай.

Подсказки

Подсказка 1

Собирайте ошибки в список и проверяйте его в конце — тогда пользователь увидит все проблемы за один запуск.

Подсказка 2

Для маскирования достаточно показывать первые и последние два символа: Su********23.

Подсказка 3

Тестировать удобно через monkeypatch.setenv и pytest.raises(SystemExit).

Решение

Сначала выполните задание самостоятельно.

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

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

text
.............                                                            [100%]
13 passed in 0.05s

Проверка вручную:

bash
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
text
=== все ошибки сразу ===
ОШИБКА КОНФИГУРАЦИИ:
  - 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 из коробки. Ручная реализация показана здесь, чтобы механизм был виден целиком.

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

bash
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, без трассировки

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

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

  1. Почему в container Python использует блочную буферизацию, а в терминале — построчную?
  2. Почему flush=True в коде не заменяет PYTHONUNBUFFERED?
  3. На что влияет PYTHONDONTWRITEBYTECODE, а на что не влияет?
  4. Почему предварительная компиляция байт-кода лучше, чем его создание в runtime?
  5. В каких трёх местах виден секрет, переданный через переменную окружения?

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

  1. Как отключить буферизацию, если изменить Dockerfile нельзя?
  2. Как реализовать поддержку секретов из файлов, не меняя интерфейс приложения?
  3. Как получить трассировку при сегфолте в C-расширении?

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

  1. docker logs пуст, хотя приложение работает и пишет в stdout. Назовите две независимые причины.
  2. После bind mount исходников на host появились каталоги __pycache__, принадлежащие root. Причина и исправление?

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

  1. В container stdout подключён к каналу, поэтому Python использует блочную буферизацию.
  2. PYTHONUNBUFFERED=1 обязателен: без него логи не появляются и теряются при падении.
  3. flush=True действует только на свой код; переменная — на всё дерево процессов.
  4. PYTHONDONTWRITEBYTECODE влияет на компиляцию при импорте в runtime, а не на pip.
  5. Оптимально: компилировать байт-код при сборке и запретить запись в runtime.
  6. PYTHONFAULTHANDLER даёт трассировку при сегфолте — стоимость нулевая.
  7. Конфигурация через окружение подходит для параметров, но не для секретов.
  8. Секрет в переменной виден в docker inspect, в /proc/1/environ и в окружении процесса на host.
  9. Соглашение _FILE позволяет одному образу работать и с переменной, и с файлом.
  10. Валидация конфигурации выполняется при старте и сообщает все ошибки сразу.

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

ИсточникСсылкаЧто подтверждает
Python: command line and environmenthttps://docs.python.org/3/using/cmdline.htmlPYTHONUNBUFFERED, PYTHONDONTWRITEBYTECODE, PYTHONFAULTHANDLER, PYTHONHASHSEED, флаг -u
Python: sys.stdouthttps://docs.python.org/3/library/sys.html#sys.stdoutРежимы буферизации, reconfigure
Python: compileallhttps://docs.python.org/3/library/compileall.htmlПредварительная компиляция, --invalidation-mode
Python: faulthandlerhttps://docs.python.org/3/library/faulthandler.htmlТрассировка при фатальных сигналах
Dockerfile reference: ENVhttps://docs.docker.com/reference/dockerfile/#envСинтаксис и попадание в конфигурацию образа
docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/Флаги -e и --env-file
The Twelve-Factor App: Confighttps://12factor.net/configХранение конфигурации в окружении
Compose: environment variableshttps://docs.docker.com/compose/how-tos/environment-variables/Приоритет источников переменных
pydantic-settingshttps://docs.pydantic.dev/latest/concepts/pydantic_settings/Типизированная конфигурация из окружения

Навигация

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

Markdown на GitHub ↗