Learning Platform
Урок 03.04 · 18 мин
Начальный
12-factor.envpydantic-settingsSecretsConfiguration
Управление конфигурацией production-пайплайна

Почему пароль не должен быть в коде

В прошлом уроке мы научили ruff ловить грязный код до коммита. Сейчас разберём вторую категорию того, что никогда не должно попадать в коммит — секреты. Пароли от БД, API-ключи, токены, connection strings с production-хостами. Это и тема безопасности (не отдать ключ от боевого Postgres в публичный репозиторий), и тема архитектуры (тот же код должен работать в локальной разработке, в staging, в production без изменений).

Решение этой задачи стандартизировано в манифесте под названием

Twelve-Factor App
. Конкретно — третий фактор, «Config».

Twelve-Factor: конфиг — в окружении

Цитата из 12-factor дословно:

Strict separation of config from code. Config varies substantially across deploys, code does not.

Иными словами: код одинаков везде, конфиг отличается. Локально вы ходите в localhost:5432, в production — в db.internal:5432. Логин и пароль в проде другие, чем у вас. Код, который читает из БД, должен быть один и тот же в обоих местах. Различия — в значениях

environment variables
.

Что хранить в env-переменных:

  • Креды: пароли, API-ключи, токены, секреты OAuth.
  • Hostnames: адреса БД, очередей, внешних API.
  • Флаги: DEBUG=true, ENABLE_FEATURE_X=false.
  • Параметры окружения: LOG_LEVEL=INFO, WORKERS=4.

Что не хранить:

  • Бизнес-логику (если выбор переходит из конфига в код — это constant, не config).
  • Стат-данные кода (имена таблиц, имена полей — это часть схемы).
  • Большие списки (отдельный YAML/JSON-файл лучше).
Что где хранится

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

Код (git)одинаков везде
src/etl.pyбизнес-логика
src/db.pyкак ходить в БД
settings.pyсхема конфига
Конфиг (env)разный по среде
DATABASE_URLразный
LOG_LEVELDEBUG/INFO
ENVdev/prod
Секретыникогда в git
DB_PASSWORDvault/secret-mgr
API_TOKENvault/secret-mgr
JWT_SECRETvault/secret-mgr

.env файлы: dev-конвенция

В production env-переменные приходят из системы оркестрации (Docker, Kubernetes, Airflow, systemd). В локальной разработке писать руками export DATABASE_URL=... перед каждым запуском — неудобно. Решение — файл .env в корне проекта:

# .env
DATABASE_URL=postgresql://user:secret@localhost:5432/etl_dev
API_TOKEN=test-token-12345
LOG_LEVEL=DEBUG
ENV=dev

Это просто текстовый файл с парами KEY=VALUE. Приложение читает его при старте, как если бы переменные пришли из окружения.

Критическое правило: .env никогда не коммитится в git. В .gitignore:

# .gitignore
.env
.env.local
.env.*.local

Вместо этого в репозиторий кладётся .env.example — шаблон без реальных значений:

# .env.example
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
API_TOKEN=your-token-here
LOG_LEVEL=DEBUG
ENV=dev

Когда коллега клонирует репо, он:

  1. Копирует cp .env.example .env.
  2. Заполняет реальными значениями (или берёт из 1Password / Vault команды).
  3. Запускает проект — конфиг подхватился.
WARNING

Если случайно закоммитили .env с реальным паролем — этот пароль нужно считать утёкшим, даже если вы тут же удалите файл. История git хранит всё. Меняйте пароль немедленно. И настройте

secret-scanner
в CI, чтобы это не повторилось.

python-dotenv: классика

Исторически первый и самый известный инструмент для работы с .env — библиотека python-dotenv. Использование тривиально:

uv add python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()  # читает .env в os.environ

database_url = os.environ["DATABASE_URL"]
log_level = os.environ.get("LOG_LEVEL", "INFO")

load_dotenv() ищет файл .env от текущей папки вверх и подгружает его переменные в os.environ. Дальше код работает через стандартное os.environ, как если бы переменные пришли извне.

Это простой подход, и он работает. Но у него есть два проблемы:

  1. Нет типизации. os.environ["PORT"] всегда возвращает строку. Преобразовать в int — ваша задача. Забыли — упало с TypeError через час работы.
  2. Нет валидации. Если в .env нет DATABASE_URL, os.environ["DATABASE_URL"] упадёт с KeyError только в момент чтения. Хочется, чтобы приложение упало сразу на старте с понятным сообщением.

В DE-проектах с десятками настроек это превращается в проблему. Поэтому для серьёзных проектов используют следующий инструмент.

pydantic-settings: типизированный конфиг

Pydantic
— это библиотека валидации, которую мы подробно разберём в модуле «Типы и валидация». Сейчас нам нужен её расширительный пакет — pydantic-settings, специально для конфигов:

uv add pydantic-settings

Пример настройки реального ETL-приложения:

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Конфигурация приложения, читается из env + .env файла."""

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",
    )

    # Обязательные — упадёт на старте, если не заданы
    database_url: str
    api_token: str

    # Опциональные — есть значения по умолчанию
    log_level: str = "INFO"
    env: str = "dev"
    batch_size: int = 1000
    enable_dry_run: bool = False

    # Сложнее: парсим список
    allowed_origins: list[str] = Field(default_factory=list)


settings = Settings()

Что здесь происходит при Settings():

  1. Pydantic
    читает переменные окружения и .env.
  2. Каждое поле приводится к указанному типу: batch_sizeint, enable_dry_runbool. Если в .env написано BATCH_SIZE=abc, упадёт с ясным ValidationError.
  3. Если обязательное поле без значения по умолчанию (database_url, api_token) не задано — приложение падает на старте.
  4. Имена полей мапятся в env-переменные без учёта регистра: database_urlDATABASE_URL, LOG_LEVEL или log_level.

После создания settings — это обычный объект с типизированными полями:

from settings import settings

print(settings.database_url)   # str
print(settings.batch_size)     # int — уже int, не "1000"
print(settings.enable_dry_run) # bool

if settings.env == "prod":
    ...

IDE и mypy знают типы, автокомплит работает, опечатки ловятся.

TIP

Создавайте settings = Settings() один раз — например, в модуле settings.py — и импортируйте этот объект везде. Не вызывайте Settings() в каждой функции: каждый вызов перечитывает .env, тратит время, и есть риск получить разные значения, если файл правился между вызовами.

Группировка через nested settings

В большом проекте сотни переменных. Их группируют по доменам:

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class DatabaseSettings(BaseModel):
    url: str
    pool_size: int = 10
    timeout: int = 30


class APISettings(BaseModel):
    base_url: str
    token: str
    retry_count: int = 3


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_nested_delimiter="__",
    )

    database: DatabaseSettings
    api: APISettings


settings = Settings()

В .env:

DATABASE__URL=postgresql://...
DATABASE__POOL_SIZE=20
API__BASE_URL=https://api.example.com
API__TOKEN=secret

env_nested_delimiter="__" говорит: двойное подчёркивание — это разделитель для вложенности. DATABASE__URL попадёт в settings.database.url.

Это спасает от хаоса в крупных проектах: 50 переменных, разбитых по 5 группам, читаются проще, чем 50 в одной плоской структуре.

Source priority в pydantic-settings

pydantic-settings читает значения из нескольких источников. Приоритет (от высшего к низшему):

  1. Аргументы при создании Settings(database_url="...") — для тестов.
  2. Переменные окружения процесса (os.environ).
  3. Содержимое .env файла.
  4. Значения по умолчанию из класса.

Логика: окружение перекрывает .env. То есть в production, когда env-переменные приходят от Kubernetes Secret, никакой .env файл их не перебьёт, даже если кто-то случайно положил его в Docker-образ.

Secrets management в проде — awareness

В локалке хватит .env. В production команды используют что-то посерьёзнее:

  • HashiCorp Vault
    — most popular, on-premise.
  • AWS Secrets Manager / GCP Secret Manager / Azure Key Vault — облачные, интегрированы с правами доступа cloud’а.
  • Kubernetes Secrets — простой вариант, секреты лежат в кластере, монтируются в pod как env-переменные.
  • Doppler / Infisical — SaaS-сервисы, веб-интерфейс + SDK.

Общая идея у всех одна: секрет лежит в защищённом сторадже, приложение запрашивает его на старте (по identity, не по паролю), получает env-переменные. Дальше код работает через тот же Settings() — он не различает, откуда переменные пришли.

В первый год работы вы будете встречать либо Vault (если команда зрелая), либо Kubernetes Secrets (если кубер уже стоит), либо просто .env, который SRE кладёт в /etc/myapp/.env (если проект простой). Принципы те же.

Чек-лист «правильно настроенного» проекта

Перед тем как мержить PR, проверьте:

  • В корне есть .env.example с примерами всех переменных, но без реальных значений.
  • В .gitignore есть .env.
  • В коде нет ни одного
    hardcoded
    URL/пароля/токена.
  • Settings объявлены через pydantic-settings, все поля типизированы.
  • Обязательные поля без default — приложение падает на старте, если их нет.
  • В README.md написано, как заполнить .env.

Упражнение

  1. В новом проекте settings-experiment установите pydantic-settings:
uv init --python 3.13
uv add pydantic-settings
  1. Создайте .env.example:
APP_NAME=my-etl
DATABASE_URL=postgresql://user:password@localhost:5432/etl
API_TOKEN=your-token-here
BATCH_SIZE=1000
DEBUG=false
  1. Скопируйте в .env и заполните реальными (тестовыми) значениями.

  2. Добавьте .env в .gitignore.

  3. Создайте settings.py:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)

    app_name: str
    database_url: str
    api_token: str
    batch_size: int = 1000
    debug: bool = False


settings = Settings()
  1. Создайте main.py:
from settings import settings

print(f"App: {settings.app_name}")
print(f"DB: {settings.database_url}")
print(f"Batch: {settings.batch_size} (type: {type(settings.batch_size).__name__})")
print(f"Debug: {settings.debug} (type: {type(settings.debug).__name__})")

Запустите uv run main.py.

Критерии приёмки:

  • Все четыре строки печатаются с реальными значениями из .env.
  • batch_size — это int, не str (видно в выводе type: int).
  • debug — это bool, не str.
  • Удалите API_TOKEN из .env и запустите ещё раз — должно упасть с ValidationError, в котором написано про api_token.

В следующем уроке — упаковка кода в Python-пакет, чтобы коллеги могли его установить и Airflow смог импортировать.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5