Twelve-Factor App и cloud-native принципы
Все предыдущие статьи трека говорили про внутреннее устройство кода: имена, функции, связанность, тесты. Twelve-Factor — про другое. Это принципы границы между приложением и средой, в которой оно живёт. Как приложение получает настройки, где хранит состояние, как переживает убийство процесса, как пишет логи, как масштабируется.
Разница принципиальная. SOLID можно нарушать годами, и проект будет медленно гнить. Нарушение Twelve-Factor обнаруживается в тот момент, когда вы впервые пытаетесь запустить второй экземпляр приложения — и всё разваливается за пятнадцать минут.
1. Откуда это взялось и какую боль лечит
В 2011 году Адам Уиггинс, сооснователь Heroku, опубликовал манифест The Twelve-Factor App (есть русский перевод). Контекст важен: Heroku была PaaS-платформой, которая хостила десятки тысяч чужих приложений. Инженеры Heroku видели один и тот же набор патологий снова и снова:
- приложение читает настройки из
config/production.rb, который лежит в git — и ключи от боевой базы утекают вместе с репозиторием; - приложение пишет загруженные пользователем файлы в
./uploads— и при добавлении второго инстанса половина картинок «пропадает»; - приложение хранит сессии в памяти процесса — и любой деплой разлогинивает всех;
- приложение при старте прогревается сорок секунд — и автоскейлинг бесполезен;
- на машине разработчика SQLite, в проде PostgreSQL — и баги, которые невоспроизводимы.
Twelve-Factor — это список условий, при выполнении которых платформа может делать с вашим приложением всё что угодно: запускать в десяти копиях, убивать без предупреждения, переносить на другую машину, откатывать до вчерашней версии. Это контракт, и он двусторонний: вы соблюдаете двенадцать правил — платформа берёт на себя развёртывание, масштабирование и восстановление.
Полезная переформулировка: Twelve-Factor описывает не «хорошую архитектуру», а «утилизируемое приложение» — то, которое можно выбросить и пересоздать, ничего не потеряв.
Манифест старше Docker (2013), Kubernetes (2014) и самого термина «cloud native». Многое из
того, что в 2011 году было дисциплиной разработчика, сегодня навязывается инструментами:
контейнер физически не даст вам протащить состояние между запусками. Но понимать почему
эти ограничения существуют — по-прежнему нужно, иначе вы обойдёте их первым же
emptyDir-волюмом.
Карта факторов
Двенадцать пунктов в оригинале идут плоским списком, и это худшее, что в манифесте есть: запомнить порядок невозможно, а связи между пунктами не видны. Разложим их по смыслу.
Factor)) Что деплоим I. Кодовая база один репозиторий - много развёртываний II. Зависимости явные и изолированные V. Сборка, релиз, запуск три стадии, релиз неизменяем Что снаружи III. Конфигурация в переменных окружения IV. Прикреплённые ресурсы БД и очереди - подключаемые VII. Привязка к порту приложение само себе веб-сервер Как исполняем VI. Процессы stateless, ничего не разделяют VIII. Параллелизм масштаб через число процессов IX. Утилизируемость быстрый старт, корректная остановка Как эксплуатируем X. Паритет сред dev и prod максимально похожи XI. Логи поток событий в stdout XII. Задачи администрирования одноразовые процессы того же релиза
Дальше — по группам, с кодом и с честной пометкой там, где фактор устарел.
2. Группа «что деплоим»: кодовая база, зависимости, стадии
I. Кодовая база (Codebase)
Одна кодовая база, отслеживаемая в системе контроля версий, — множество развёртываний.
Соотношение строго один-к-одному: одно приложение — один репозиторий. Если два приложения делят код, это не «одна кодовая база», а общая библиотека, которую нужно вынести и подключать через менеджер зависимостей. Если одно приложение живёт в нескольких репозиториях — это распределённая система, а не приложение.
Развёртываний при этом сколько угодно: прод, стейджинг, ноутбук каждого разработчика. Они отличаются только версией кода (у разработчика могут быть незакоммиченные изменения) и конфигурацией — но никогда не «отдельной веткой для прода».
Частая путаница: монорепозиторий это не нарушение. В монорепе живут несколько кодовых баз, каждая со своим пайплайном сборки. Нарушение — когда сборка одного сервиса неотделима от сборки другого.
II. Зависимости (Dependencies)
Явно объявляйте и изолируйте зависимости.
Два требования, и второе забывают чаще.
Явное объявление: полный список зависимостей в манифесте (pyproject.toml,
go.mod, package.json, mix.exs, *.csproj) плюс lock-файл с точными версиями и хешами.
Без lock-файла сборка невоспроизводима: сегодня подтянется 2.4.1, завтра 2.5.0 с
поломанным поведением.
Изоляция: приложение не должно полагаться на то, что в системе что-то есть. Никаких
«у нас на сервере стоит ImageMagick» — если он нужен, он объявлен в Dockerfile. Классический
тест: свежая машина, git clone — и приложение собирается одной командой.
# Явные зависимости: версия базового образа зафиксирована по digest,
# системные пакеты — с фиксированными версиями, lock-файл копируется первым.
FROM python:3.12-slim@sha256:2be8daddbb82756f7d1f2c7ece706aadcb284bf6ab6d769ea695cc3ed6016743
# Системные зависимости объявлены, а не «предполагаются установленными»
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5=15.* \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Слой зависимостей отдельно от слоя кода — кеш переиспользуется между сборками
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev
COPY src/ ./src/
# Не root: требование не из 12factor, а из здравого смысла эксплуатации
RUN useradd --uid 10001 --no-create-home app
USER 10001
CMD ["uv", "run", "python", "-m", "src.web"]
Обратите внимание на --frozen: это не украшение. Флаг падает, если lock-файл не соответствует
манифесту, — то есть сборка отказывается молча разрешать версии сама.
V. Сборка, релиз, запуск (Build, release, run)
Строго разделяйте стадии сборки и выполнения.
Самый недооценённый фактор и, пожалуй, самый ценный. Три стадии:
- Сборка превращает код в исполняемый артефакт (образ, бинарник, jar). Зависит только от коммита. Детерминирована.
- Релиз = артефакт + конфигурация конкретной среды. Получает уникальный возрастающий идентификатор. Неизменяем.
- Запуск — исполнение релиза. Ничего не создаёт и не меняет.
Из неизменяемости релизов следует главное практическое свойство: откат — это запуск предыдущего релиза, а не обратная сборка. Если для отката нужно собирать код заново, вы теряете минуты в момент, когда прод лежит, и, что хуже, не гарантируете, что соберётся то же самое.
Отсюда же вытекает запрет на правку кода на боевом сервере — не из вредности, а потому что после такой правки состояние среды перестаёт быть выводимым из релиза. Следующий рестарт контейнера тихо откатит вашу «горячую заплатку», и никто не поймёт почему.
Практическое следствие для CI: один и тот же артефакт должен пройти все среды. Если стейджинг собирается отдельной командой сборки, вы тестируете не то, что поедет в прод.
# GitHub Actions: собираем ОДИН раз, продвигаем ОДИН И ТОТ ЖЕ образ по средам
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }} # адресуем артефакт по digest, не по тегу
steps:
- uses: actions/checkout@v4
- id: push
uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/app:${{ github.sha }}
deploy-staging:
needs: build
steps:
- run: ./deploy.sh staging registry.example.com/app@${{ needs.build.outputs.digest }}
deploy-production:
needs: [build, deploy-staging]
environment: production # ручное подтверждение
steps:
# тот же digest — байт в байт тот же артефакт, что проверен на стейджинге
- run: ./deploy.sh production registry.example.com/app@${{ needs.build.outputs.digest }}
Тег :latest в проде — прямое нарушение этого фактора: тег изменяем, поэтому «релиз» перестаёт
быть однозначно определён. Адресуйте образы по digest.
3. Группа «что снаружи»: конфиг, ресурсы, порт
III. Конфигурация (Config)
Храните конфигурацию в среде выполнения.
Определение конфигурации в манифесте точное и полезное: конфигурация — это всё, что
различается между развёртываниями. Строка подключения к БД — конфигурация. Ключ платёжного
провайдера — конфигурация. А маршруты приложения или схема inversion of control — нет, это код,
даже если лежит в файле с расширением .yaml.
Лакмусовая бумажка от авторов: можно ли прямо сейчас открыть репозиторий в open source, не скомпрометировав ни одного секрета? Если нет — конфигурация просочилась в код.
Почему именно переменные окружения, а не config.json? Три причины:
- Они универсальны: любой язык, любая ОС, любой оркестратор умеет их выставлять.
- Они не группируются в «окружения». Как только вы завели файлы
dev.yaml,staging.yaml,prod.yaml, добавление четвёртой среды становится копированием файла, и они начинают расходиться. - Их сложно случайно закоммитить.
Слабое место — секреты: переменные окружения видны в /proc/<pid>/environ, попадают в дампы
краша и в отладочные вывалы фреймворков. В современном проде их обычно монтируют файлом
(Kubernetes Secret как volume, Vault Agent, SOPS) и подставляют путь через переменную:
DATABASE_PASSWORD_FILE=/run/secrets/db. Это компромисс, а не нарушение духа фактора —
конфиг всё так же живёт вне артефакта.
Хороший приём — валидировать конфигурацию при старте и падать громко, а не ловить
None где-то в середине обработки запроса.
"""Конфигурация: одна типизированная точка входа, валидация на старте."""
from functools import lru_cache
from pathlib import Path
from pydantic import Field, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# env_file нужен только для локальной разработки; в проде переменные приходят из среды
model_config = SettingsConfigDict(env_file=".env", extra="forbid")
database_url: PostgresDsn # обязательна: без неё падаем сразу
redis_url: str = "redis://localhost:6379/0"
# Секрет читаем из файла, а не из переменной: путь — конфиг, содержимое — секрет
stripe_key_file: Path = Path("/run/secrets/stripe")
log_level: str = "INFO"
# Число воркеров задаёт платформа, а не код
web_concurrency: int = Field(default=2, ge=1, le=64)
shutdown_grace_seconds: float = 25.0
@field_validator("log_level")
@classmethod
def _known_level(cls, v: str) -> str:
allowed = {"DEBUG", "INFO", "WARNING", "ERROR"}
if v.upper() not in allowed:
raise ValueError(f"log_level={v!r}, ожидалось одно из {sorted(allowed)}")
return v.upper()
@property
def stripe_key(self) -> str:
return self.stripe_key_file.read_text().strip()
@lru_cache(maxsize=1)
def settings() -> Settings:
"""Читаем окружение ровно один раз — при первом обращении на старте процесса."""
return Settings() # ValidationError здесь = процесс не поднимется, и это правильно
extra="forbid" ловит опечатки в именах переменных: DATABAS_URL не пройдёт молча.
Антипаттерн, который стоит назвать явно: ветвление по имени среды.
# ПЛОХО: код знает про среды и ведёт себя по-разному
if os.getenv("ENV") == "production":
mailer = SMTPMailer(host="smtp.example.com")
else:
mailer = ConsoleMailer()
Здесь путь «отправить письмо по-настоящему» существует только в проде и потому никогда не
тестируется. Правильно — один код, разные значения: MAILER_URL=smtp://… в проде,
MAILER_URL=console:// локально. Различие переезжает из ветвлений в конфиг.
IV. Прикреплённые ресурсы (Backing services)
Считайте прикреплённые ресурсы подключаемыми ресурсами.
Локальный PostgreSQL, RDS у Amazon, база коллеги — для кода это одно и то же: URL из конфига. Замена ресурса не должна требовать изменения кода, только смены URL. То же самое для SMTP, S3, Redis, Kafka, платёжного шлюза.
Это ровно тот же принцип инверсии зависимостей, что мы разбирали в SOLID, просто на уровне процессов, а не классов. И та же логика низкой связанности из статьи о coupling и cohesion: изменение в инфраструктуре не должно распространяться в код.
QUEUE_URL, S3_ENDPOINT"]] h --> r h --> q cfg -.подставляет адрес.-> r cfg -.подставляет адрес.-> q r --> pg[("PostgreSQL
локальный / RDS / чужой")] q --> mq[["Kafka / SQS / RabbitMQ"]] h --> s3[("S3 / MinIO")] classDef ext stroke-dasharray: 5 4; class pg,mq,s3 ext;
Практический критерий соблюдения: вы можете переключить прод на реплику базы, поменяв одну переменную и перезапустив процессы. Если для этого нужен релиз кода — фактор нарушен.
VII. Привязка к порту (Port binding)
Экспортируйте сервисы через привязку к порту.
Приложение само поднимает HTTP-сервер и слушает порт, а не «разворачивается внутрь» Tomcat,
Apache или IIS. Оно самодостаточно: ./app — и сервис работает.
В 2011 году это было революцией (Rails жил в Passenger внутри Apache). Сегодня встроенный
сервер — норма во всех экосистемах: net/http в Go, Kestrel в ASP.NET Core, Cowboy в Elixir,
uvicorn в Python. Смотрите, как это устроено в
треке по Go или
по C# — там веб-сервер это библиотека, а не
контейнер приложений.
Два практических требования, которые до сих пор нарушают:
- порт берётся из окружения, а не хардкодится:
PORT=8080. Платформа решает, какой порт выдать; - слушать надо
0.0.0.0, а не127.0.0.1. Классические полчаса отладки в Docker: контейнер «работает», но снаружи не отвечает — потому что процесс привязался к loopback.
// Порт и адрес приходят из окружения — платформа управляет тем, где нас искать
addr := ":" + cmp.Or(os.Getenv("PORT"), "8080") // ":8080" слушает все интерфейсы
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // защита от slowloris; таймауты обязательны в проде
}
4. Группа «как исполняем»: процессы, параллелизм, утилизируемость
VI. Процессы (Processes)
Запускайте приложение как один или несколько процессов, не сохраняющих внутреннее состояние.
Ядро всего манифеста. Процесс не хранит ничего, что нужно пережить его смерть. Всё, что должно жить дольше одного запроса, отправляется в прикреплённый ресурс: данные — в БД, сессии — в Redis, файлы — в объектное хранилище.
Что считается нарушением, хотя выглядит невинно:
| Практика | Что ломается |
|---|---|
| Сессии в памяти процесса | Разлогин при каждом деплое; нужен sticky-роутинг |
Загрузки в ./uploads |
Файл виден одному инстансу из четырёх |
| In-memory кеш без внешнего слоя | Разный ответ на одинаковый запрос — «плавающие» баги |
| Счётчик rate limit в памяти | Реальный лимит умножается на число реплик |
| Локальный SQLite для очереди задач | Задачи теряются при пересоздании контейнера |
Важное уточнение, которое манифест делает нечётко: кеш в памяти сам по себе не запрещён. Запрещено полагаться на него как на источник истины. Локальный кеш, который можно потерять без последствий (и который переживёт потерю простым походом в БД), — совершенно нормальная оптимизация. Разница в вопросе: «если этот процесс сейчас умрёт, что-нибудь потеряется безвозвратно?»
VIII. Параллелизм (Concurrency)
Масштабируйте с помощью процессов.
Приложение делится на типы процессов (web, worker, clock), и каждый тип
масштабируется независимо изменением их количества. Внутри процесса можно использовать потоки,
горутины, event loop — это способ утилизации ядра, но единица масштабирования на уровне
системы всегда процесс.
Почему не «просто побольше потоков»? Потому что потоки упираются в границу одной машины, а процессы — нет. Горизонтальное масштабирование возможно только тогда, когда единица масштабирования не разделяет память.
Явное следствие: приложение не демонизируется само. Никаких nohup, PID-файлов и
самозапуска в фоне. Процессом управляет менеджер процессов — systemd, Kubernetes, Nomad,
supervisord. Ваша задача — быть обычным процессом переднего плана, писать в stdout и умирать по
сигналу.
# Procfile: декларация типов процессов. Каждый тип масштабируется отдельно.
web: uv run uvicorn src.web:app --host 0.0.0.0 --port $PORT --workers $WEB_CONCURRENCY
worker: uv run python -m src.worker
clock: uv run python -m src.scheduler
release: uv run alembic upgrade head
Отдельно про тип clock: планировщик почти всегда должен существовать в единственном
экземпляре, иначе задачи выполнятся дважды. Это тихое нарушение симметрии «все процессы
одинаковы» — и первая проблема, о которую спотыкаются при переезде на Kubernetes. Решения:
CronJob вместо постоянного процесса, распределённый лок (Redis/etcd) либо выборы лидера.
IX. Утилизируемость (Disposability)
Максимизируйте надёжность с помощью быстрого запуска и корректного завершения работы.
Процесс должен быть готов умереть в любую секунду и стартовать за секунды. Быстрый старт — это не только про удобство: он определяет, насколько быстро вы масштабируетесь под всплеск нагрузки и насколько быстро откатываетесь.
Корректное завершение — это конкретный протокол:
- Получили
SIGTERM. - Немедленно перестали принимать новые соединения и начали отвечать
503на liveness/readiness пробы готовности. - Дали доработать уже принятым запросам (обычно 10–30 секунд).
- Закрыли соединения с БД и очередями, зафиксировали или вернули в очередь незавершённые задачи.
- Вышли с кодом 0.
трафик всё ещё идёт C->>P: запрос (успел проскочить) P-->>C: 200 OK K->>P: SIGTERM P->>P: server.Shutdown(ctx): новые соединения не принимаются P-->>K: readiness = false Note over P: дорабатывает оставшиеся запросы P->>P: закрыть пул БД, вернуть задачи в очередь P-->>K: exit(0) Note over K: если через terminationGracePeriodSeconds
процесс жив — SIGKILL
Тонкость, которую пропускают почти все: между «pod помечен Terminating» и «балансировщик
перестал слать трафик» проходит заметное время. Если убить сервер сразу по SIGTERM, часть
запросов получит connection refused. Отсюда preStop-хук со sleep 5 — он выглядит грязно,
но это официально рекомендуемое решение проблемы гонки в
документации Kubernetes.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
// Контекст отменяется по SIGTERM (Kubernetes) или SIGINT (Ctrl+C локально)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("сервер упал", "err", err)
stop()
}
}()
slog.Info("слушаем", "addr", srv.Addr)
<-ctx.Done() // пришёл сигнал
slog.Info("получен сигнал завершения, перестаём принимать соединения")
// Даём меньше, чем terminationGracePeriodSeconds, чтобы успеть выйти самим,
// а не быть убитыми SIGKILL: 25s против 30s в манифесте.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
// Не уложились: часть запросов оборвётся — это надо видеть в метриках
slog.Error("грубое завершение: истёк таймаут", "err", err)
}
closeDBPool() // вернуть соединения, дождаться транзакций
flushTelemetry() // дослать трейсы и метрики, иначе последние минуты жизни невидимы
slog.Info("завершились корректно")
}
Второе следствие утилизируемости — идемпотентность фоновых задач. Раз воркер может умереть
в любой момент, задача может быть выполнена частично и запущена снова. Значит, обработчик обязан
переживать повторный запуск без побочных эффектов: INSERT ... ON CONFLICT DO NOTHING, ключи
идемпотентности у платёжных операций, проверка «уже обработано» перед отправкой письма.
Подробнее об этом — в статье
Обработка ошибок, контракты и отказоустойчивое мышление.
Жизненный цикл экземпляра целиком:
Различие livenessProbe и readinessProbe — прямое следствие этой диаграммы, и его постоянно
путают. Readiness отвечает на вопрос «слать ли мне трафик прямо сейчас»; liveness — «не
завис ли процесс насмерть, надо ли его убить». Если повесить liveness на проверку доступности
базы, то при недоступной базе Kubernetes начнёт перезапускать все реплики — и вы получите
каскадный отказ вместо временной деградации. Liveness проверяет только сам процесс.
# Kubernetes: пробы и корректное завершение
spec:
terminationGracePeriodSeconds: 30 # больше, чем таймаут Shutdown в коде (25s)
containers:
- name: app
image: registry.example.com/app@sha256:9f2c... # digest, не тег
lifecycle:
preStop:
exec:
command: ["sleep", "5"] # ждём, пока балансировщик уберёт нас из пула
startupProbe: # медленный старт не мешает liveness
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 2
readinessProbe: # ЗДЕСЬ проверяем зависимости
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
livenessProbe: # ЗДЕСЬ — только «процесс жив и отвечает»
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 3
env:
- name: PORT
value: "8080"
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: app-secrets, key: database-url }
5. Группа «как эксплуатируем»: паритет, логи, админ-задачи
X. Паритет разработки/продакшена (Dev/prod parity)
Держите окружения разработки, промежуточного развёртывания и продакшена максимально похожими.
Манифест выделяет три разрыва:
- временной — код неделями лежит между написанием и деплоем. Лечится частыми релизами;
- кадровый — пишет один, деплоит другой. Лечится тем, что автор выкатывает сам;
- инструментальный — SQLite локально, PostgreSQL в проде. Лечится одинаковыми прикреплёнными сервисами везде.
Третий пункт — тот самый, из-за которого фактор попал в манифест. Различия «эквивалентных» бэкендов вылезают в худший момент: SQLite не знает уровней изоляции PostgreSQL, локальная файловая система не знает eventual consistency объектного хранилища, in-memory очередь не доставляет сообщение дважды. Баг, которого нет на машине разработчика, — самый дорогой класс багов.
Сегодня решение стандартно: docker compose с теми же образами БД и брокера, что в проде, или
Testcontainers в тестах. Ценой чуть более медленного локального
запуска вы покупаете совпадение поведения — и это, как правило, отличная сделка. Связь с
принципами тестирования прямая: интеграционный тест
против настоящего PostgreSQL в контейнере ловит то, что не поймает ни один мок.
XI. Журналирование (Logs)
Рассматривайте журнал как поток событий.
Приложение не управляет файлами логов, не ротирует их и не знает, куда они попадут. Оно пишет
поток событий в stdout. Дальше платформа решает: собрать в файл, отправить в Loki, ELK или
Datadog.
Практика 2026 года добавляет к этому одно важное уточнение: структурированный JSON, а не
текст. Строка "Ошибка при обработке заказа 12345" требует regexp для поиска; объект с полями
order_id, trace_id, level — индексируется и агрегируется.
import logging, sys, json, time
from contextvars import ContextVar
# trace_id прокидывается через контекст — иначе логи одного запроса не собрать вместе
trace_id: ContextVar[str] = ContextVar("trace_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)),
"level": record.levelname,
"msg": record.getMessage(),
"logger": record.name,
"trace_id": trace_id.get(),
}
# Произвольные поля из logger.info("...", extra={"order_id": 42})
payload.update(getattr(record, "extra_fields", {}))
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
handler = logging.StreamHandler(sys.stdout) # только stdout, никаких файлов
handler.setFormatter(JsonFormatter())
logging.basicConfig(handlers=[handler], level="INFO", force=True)
Три ошибки, которые встречаются постоянно:
- Логи в файл внутри контейнера. Диск переполнится, а логи потеряются при пересоздании pod.
- Логи как замена метрик. «Посчитаем по логам, сколько было ошибок» работает, пока трафик мал. Для счётчиков есть метрики (Prometheus), для причинно-следственных связей — трассировка (OpenTelemetry), логи — для деталей конкретного события. Это три разных инструмента, и современный термин для их совокупности — observability, «наблюдаемость». Практическое введение — Google SRE Book, глава Monitoring Distributed Systems.
- Секреты в логах. Тело запроса с токеном, залогированное «для отладки», живёт в хранилище логов годами. Маскирование чувствительных полей должно быть в форматтере, а не в дисциплине разработчиков.
XII. Задачи администрирования (Admin processes)
Выполняйте задачи администрирования/управления с помощью разовых процессов.
Миграции, разовые скрипты починки данных, REPL — запускаются из того же релиза, тем же кодом, с той же конфигурацией. Не «зайду на сервер и выполню в psql», не «запущу скрипт со своего ноутбука по VPN».
Причина проста: скрипт, запущенный из другого кода, работает с другими предположениями о схеме данных. Классический инцидент — миграция, выполненная с ноутбука из ветки, которая не доехала до прода.
Практика в Kubernetes — Job с тем же образом:
apiVersion: batch/v1
kind: Job
metadata:
name: migrate-v134
spec:
backoffLimit: 0 # миграция не должна повторяться автоматически
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/app@sha256:9f2c... # ТОТ ЖЕ образ, что у web
command: ["uv", "run", "alembic", "upgrade", "head"]
envFrom:
- secretRef: { name: app-secrets } # ТА ЖЕ конфигурация
Смежное правило, которое манифест не проговаривает, но которое из него следует: миграции схемы должны быть обратно совместимы. Раз старые и новые процессы какое-то время работают одновременно (rolling update), схема обязана устраивать оба релиза. Отсюда паттерн expand/contract: сначала релиз, добавляющий колонку и пишущий в обе, потом релиз, читающий новую, и только потом удаление старой. Подробное описание — ParallelChange у Мартина Фаулера.
6. Критика: где манифест устарел и где он просто неправ
Twelve-Factor — документ 2011 года, написанный под конкретную платформу. Полезно знать его границы, иначе легко превратить в карго-культ ровно так, как описано в обзорной статье трека.
Фактор III и секреты. Переменные окружения — плохое место для секретов. Они наследуются
дочерними процессами, попадают в дампы, видны через /proc. Индустрия ушла к монтированию
секретов файлами и к динамическим кратковременным учётным данным (Vault, IRSA, Workload
Identity). Это не отменяет фактор — конфиг по-прежнему вне артефакта, — но буквальное «всё в
env» устарело.
Фактор VI и состояние. Тезис «процессы не имеют состояния» отлично работает для веб-бэкенда
и разваливается для того, что нужно этому бэкенду: сами базы данных, брокеры, поисковые движки
принципиально stateful. Twelve-Factor молча предполагает, что stateful-часть эксплуатирует
кто-то другой (Heroku). Когда вы эксплуатируете её сами, нужен другой набор инструментов:
StatefulSet, операторы, консенсусные протоколы (Raft). Манифест про это не говорит вообще
ничего.
Фактор VII и не-HTTP. Привязка к порту описана в мире «сервис = HTTP». Сегодня в проде есть gRPC с долгоживущими соединениями, WebSocket, потребители Kafka, которые вообще ничего не слушают, и serverless-функции, у которых порта нет в принципе. Дух фактора (самодостаточность) остаётся, буква — нет.
Фактор VIII и не только процессы. «Масштабируйте процессами» игнорирует то, что дешевле масштабируется внутри процесса. BEAM-машина Elixir/Erlang держит сотни тысяч лёгких процессов в одной ОС-задаче, и её модель конкурентности мощнее, чем «запусти ещё один инстанс» — см. трек по Elixir. Go с горутинами — та же история. Правильная формулировка: масштабируйтесь процессами на уровне системы, а внутри процесса используйте нативную модель конкурентности языка.
Чего в манифесте нет вовсе. Безопасность (нет ни слова про аутентификацию сервисов, mTLS, сканирование образов). Наблюдаемость дальше логов. API-контракты и их версионирование. Отказоустойчивость: ретраи, таймауты, circuit breaker, backpressure. Управление стоимостью. Всё это — обязательные части современного продакшена, и Twelve-Factor их не покрывает.
Кевин Хоффман в книге Beyond the Twelve-Factor App (O’Reilly, 2016) предложил пересобранный список из пятнадцати факторов, добавив: API-first (контракт проектируется раньше реализации), телеметрию (метрики и трассировки как первоклассный выход приложения), аутентификацию и авторизацию как встроенное требование, а также подняв «одну кодовую базу» до «одна кодовая база, одно приложение» и переосмыслив конфигурацию как «конфиг, учётные данные и код — три разные сущности с разными жизненными циклами».
Эволюция контекста
7. Cloud-native: что это добавляет сверху
CNCF даёт собственное определение: cloud-native технологии позволяют создавать и запускать масштабируемые приложения в динамических средах — публичных, приватных и гибридных облаках. Ключевые механизмы: контейнеры, сервисные сетки, микросервисы, неизменяемая инфраструктура и декларативные API. Формулировка расплывчата (как всякое определение, написанное консорциумом), поэтому полезнее смотреть на конкретные добавления к Twelve-Factor.
Неизменяемая инфраструктура. Twelve-Factor требует неизменяемых релизов приложения; cloud-native распространяет это на сервер целиком. Машина не патчится — она пересоздаётся из образа. Отсюда GitOps: желаемое состояние системы лежит в git, контроллер приводит реальность в соответствие. Разница с «запустить деплой-скрипт» в том, что дрейф конфигурации самоисправляется, а не накапливается.
Декларативность вместо императивности. Вы не пишете «запусти три копии», вы объявляете «копий должно быть три», а контроллер непрерывно сводит факт к желаемому. Это тот же переход от «как» к «что», что в SQL или функциональном программировании — см. трек по парадигмам.
Отказ как штатное событие. Twelve-Factor говорит «будьте готовы к смерти процесса». Cloud-native идёт дальше: отказ узла, зоны доступности, целого региона — тоже норма, а не авария. Отсюда бюджеты ошибок, chaos engineering, деплой в несколько зон.
Наблюдаемость как обязательный выход. Приложение экспортирует не только логи, но и метрики
(/metrics в формате Prometheus) и трассировки (OpenTelemetry). Без trace_id, сквозного через
все сервисы, отладка распределённой системы превращается в археологию.
Автомасштабирование как замыкание петли. Метрика (RPS, длина очереди, задержка) управляет числом реплик. Работает это ровно настолько, насколько соблюдены факторы VI, VIII и IX: если процесс стартует три минуты или хранит сессии в памяти, автоскейлинг бесполезен или вреден. Здесь Twelve-Factor из «хорошей практики» становится техническим предусловием.
8. Типичные ошибки и как их ловить
| Ошибка | Симптом в проде | Как проверить заранее |
|---|---|---|
| Конфиг зашит в код или в git | Секрет утёк; смена БД требует релиза | git grep -iE '(password|secret|api[_-]?key)\s*=' -- '*.py' '*.go' |
Ветвление if env == "prod" |
Путь кода не тестируется до прода | Грепом по имени переменной среды в бизнес-логике |
| Сессии/кеш в памяти | Разлогин при деплое, «плавающие» ответы | Запустить локально 2 реплики за nginx и покликать |
| Игнорирование SIGTERM | 502 при каждом деплое | docker stop под нагрузкой: смотреть на код ответа |
| Liveness проверяет БД | Каскадный перезапуск всех реплик при сбое БД | Ревью манифеста: в /healthz никаких внешних вызовов |
| Медленный старт (>30 с) | Автоскейлинг не успевает за нагрузкой | Метрика времени от запуска до readiness |
Тег :latest в проде |
«У меня работало» — неясно, какой код запущен | Политика: только digest в манифестах |
| Логи в файл | Логи теряются, диск переполняется | lsof внутри контейнера; проверка на *.log |
| Миграции с ноутбука | Схема разъезжается с релизом | Миграции только как Job в CI |
| Неидемпотентный воркер | Двойные списания при рестарте | Тест: выполнить обработчик дважды на одном входе |
| SQLite локально, Postgres в проде | Баги, невоспроизводимые локально | Testcontainers в интеграционных тестах |
Чеклист готовности к развёртыванию (полезен как часть Definition of Done — см. Код-ревью, командные стандарты и Definition of Done):
- Приложение стартует из чистого клона одной командой.
- Все настройки читаются из среды, валидируются на старте, падение при отсутствии обязательных.
- Репозиторий можно открыть публично, ничего не скомпрометировав.
- Две реплики за балансировщиком работают корректно, включая логин и загрузку файлов.
-
SIGTERMзавершает процесс корректно, без потери запросов, укладываясь в grace period. - Один и тот же артефакт проходит стейджинг и прод; откат — переключение на прошлый релиз.
- Логи структурированы, идут в stdout, содержат
trace_id, не содержат секретов. - Миграции запускаются из образа релиза и обратно совместимы.
- Фоновые задачи идемпотентны.
- Локальные зависимости совпадают по версиям с продовыми.
9. Мини-итог
Twelve-Factor — это не архитектурный стиль и не «правильный способ писать код». Это набор предусловий, при которых платформа может управлять вашим приложением автоматически. Каждый фактор снимает с вас конкретную операционную обязанность и передаёт её платформе.
Что осталось верным спустя пятнадцать лет: разделение сборки, релиза и запуска; конфигурация вне артефакта; отсутствие состояния в процессе; корректная реакция на сигналы; логи как поток; паритет сред. Это фундамент, и он не устарел ни на грамм.
Что требует поправки: буквальное «секреты в env»; молчание про stateful-нагрузки; HTTP-центризм; полное отсутствие безопасности, наблюдаемости и отказоустойчивости в списке.
И главный практический совет: не сдавайте отчёт по двенадцати пунктам — задайте один вопрос. Что произойдёт, если прямо сейчас убить любой процесс приложения и запустить три новых на другой машине? Если ответ «ничего страшного», факторы соблюдены по сути, даже если формально вы чему-то не следуете. Если ответ содержит слова «ну, кроме…» — вы нашли, где именно нарушен контракт.
Источники
- The Twelve-Factor App — Adam Wiggins, 2011. Оригинал, короткий, читается за час. Есть перевод на русский.
- Kevin Hoffman. Beyond the Twelve-Factor App — O’Reilly, 2016. Пятнадцать факторов, критика и дополнения.
- CNCF Cloud Native Definition — что индустрия понимает под cloud native.
- Betsy Beyer et al. Site Reliability Engineering — Google, доступна бесплатно. Главы про мониторинг, релизы и обработку перегрузки.
- Kubernetes: Pod Lifecycle и Container Lifecycle Hooks — как на практике реализуется утилизируемость.
- OpenTelemetry Documentation — современный стандарт наблюдаемости.
- Martin Fowler. ParallelChange — обратно совместимые изменения схемы и API.
- Nicole Forsgren, Jez Humble, Gene Kim. Accelerate (IT Revolution, 2018) — эмпирические данные о том, что практики непрерывной доставки коррелируют с результатами бизнеса.
Что дальше
Twelve-Factor описывает, как приложение должно себя вести, когда платформа убивает и пересоздаёт процессы. Но отказы бывают не только такие: падает база, отвечает таймаутом соседний сервис, приходит некорректный ответ от внешнего API. Как проектировать код, который переживает это осмысленно, — в следующей статье: