Конфигурация и секреты: как разделять окружения и менять ключи без простоя
В предыдущих главах мы запустили API на сервере и открыли его через HTTPS-прокси. Приложение принимает запросы, обращается к PostgreSQL и Redis. Теперь к нему нужно подключить платёжный сервис.
Для подключения понадобятся адрес сервиса, время ожидания ответа и API-ключ. Разработчик добавляет их в код, проверяет интеграцию локально и отправляет изменения на сервер. После запуска выясняется, что тестовый API-ключ попал в production, а приложение обращается к учебному платёжному аккаунту. Исправление одного значения требует нового выпуска кода.
Здесь обнаруживаются две разные задачи. Конфигурация задаёт, с какими ресурсами и по каким правилам работает конкретный запуск приложения. Секреты являются частью конфигурации, но требуют особого обращения: знание значения даёт доступ к ресурсу или позволяет выдавать себя за доверенную сторону.
Разберём, как описать нужные приложению параметры, передать их в разные окружения и организовать смену секретов. Сквозной пример продолжит нашу схему: FastAPI за Caddy, PostgreSQL и Redis на сервере.
Где заканчивается код и начинается конфигурация
Представим один и тот же образ приложения, который запускается локально, на staging и в production. Логика обработки заказа во всех трёх случаях одинакова. Различаются адрес базы, платёжный аккаунт, публичный URL и некоторые ограничения.
Удобная проверка границы такая: если значение должно меняться между запусками без изменения логики программы, это кандидат в конфигурацию. Принцип отделения конфигурации от кода описан в The Twelve-Factor App.
| Параметр | Код или конфигурация? | Почему |
|---|---|---|
| Алгоритм расчёта стоимости заказа | Код | Меняет поведение предметной области |
| Адрес PostgreSQL | Конфигурация | Зависит от места запуска |
| Время ожидания платёжного API | Конфигурация | Может отличаться между окружениями |
| Пароль PostgreSQL | Секрет | Даёт доступ к базе |
| API-ключ платёжного сервиса | Секрет | Даёт право выполнять запросы от нашего имени |
Имя маршрута /orders/{id} |
Обычно код | Это часть интерфейса приложения |
Разделение важно не только для удобства. Когда адрес базы или ключ записан в исходном коде, каждое его изменение превращается в изменение программы. Кроме того, секрет может попасть в историю Git, собранный образ и копии репозитория.
При этом название окружения само по себе ничего не изолирует. Переменная APP_ENV=staging не помешает приложению обратиться к production-базе, если ему передали её адрес и пароль. Изоляцию создают отдельные ресурсы и права доступа.
Короткий вывод: код определяет, как приложение работает; конфигурация определяет, с чем и при каких настройках работает конкретный экземпляр. Секрет отличается тем, что его значение требует ограничения доступа и процедуры отзыва.
Как разделить local, staging и production
Для нашего API можно определить три окружения.
| Ресурс | Local | Staging | Production |
|---|---|---|---|
| PostgreSQL | Локальная база | Отдельная тестовая база | Рабочая база |
| Redis | Локальный экземпляр | Отдельный экземпляр | Рабочий экземпляр |
| Платёжный сервис | Песочница или имитатор | Песочница | Рабочий аккаунт |
| Публичный адрес | localhost |
Тестовый домен | Рабочий домен |
| Учётные данные | Локальные | Отдельные тестовые | Отдельные рабочие |
Staging нужен для проверки выпуска в условиях, похожих на рабочие. Он не должен получать доступ к production только потому, что разработчику так проще воспроизвести ошибку. Иначе ошибка в тестовом коде может изменить реальные заказы.
Полезно отдельно договориться о контракте конфигурации: какие параметры приложение ожидает, какой у каждого тип, обязателен ли он и что считается допустимым значением. Например:
| Параметр | Тип | Правило |
|---|---|---|
APP_ENV |
Строка | local, staging или production |
DB_HOST |
Строка | Непустой адрес базы |
DB_NAME |
Строка | Имя базы для данного окружения |
DB_USER |
Строка | Пользователь с нужными правами |
DB_PASSWORD_FILE |
Путь | Существующий читаемый файл с паролем |
PAYMENT_API_KEY_FILE |
Путь | Существующий читаемый файл с ключом |
PAYMENT_TIMEOUT_S |
Число | Больше нуля и не больше 30 секунд |
Проверять этот контракт лучше при запуске, до приёма пользовательских запросов. Тогда отсутствие ключа станет понятной причиной отказа запуска, а не случайной ошибкой при первом платеже.
Не стоит задавать удобное значение по умолчанию для критичного production-параметра. Если при отсутствии DB_HOST приложение молча подключается к localhost, оно может запуститься с неверной базой и выглядеть исправным до первого обращения к данным.
Почему одного большого .env недостаточно
Файл .env удобен для локальной разработки, но его назначение часто понимают неверно. В Docker Compose локальный .env прежде всего служит источником значений для подстановки в Compose-файл. Он не передаёт все свои переменные контейнеру автоматически: передача зависит от environment и env_file. Кроме того, при совпадении имён у источников есть порядок приоритета. Это подробно разобрано в документации Docker и в правилах приоритета переменных.
Для команды полезен файл .env.example с именами параметров и безопасными образцами, но без действующих ключей. Настоящий локальный .env исключают из Git. Это снижает вероятность случайного коммита, но не делает файл защищённым хранилищем: доступ к компьютеру, резервным копиям или журналам команд всё ещё имеет значение.
Короткий вывод: окружения разделяются ресурсами, учётными данными и правами. Файл с переменными только доставляет значения приложению; он не создаёт эту изоляцию.
Как передать настройки и секреты приложению
Для обычных параметров переменные окружения подходят хорошо: они просты, доступны почти в любой среде запуска и не требуют изменения образа приложения. С секретами выбор сложнее.
| Способ | Подходит для | Что учитывать |
|---|---|---|
| Переменная окружения | Простые настройки; иногда секреты в ограниченной среде | Значение может попасть в вывод диагностики, конфигурацию запуска и окружение процесса |
| Смонтированный файл | Секрет, который должен получить конкретный сервис | Нужны права доступа к файлу и понятный способ его обновления |
| Менеджер секретов | Несколько сервисов, аудит, версии, централизованная ротация | Нужны идентификация приложения, сеть, кеширование и план на случай недоступности менеджера |
Файл с секретом тоже не является магической защитой. Если процесс приложения скомпрометирован, он может прочитать то, что ему разрешено читать. Практическая цель состоит в другом: сократить число мест, где лежит секрет, ограничить круг получателей и иметь возможность заменить или отозвать его. Эти свойства лежат в основе рекомендаций OWASP по управлению секретами.
Передача файлов через Docker Compose
Продолжим пример с нашего сервера. Для API фрагмент Compose-конфигурации может выглядеть так:
services:
api:
image: registry.example.com/orders-api:${IMAGE_TAG:?IMAGE_TAG is required}
ports:
- "127.0.0.1:8000:8000"
environment:
APP_ENV: production
DB_HOST: db
DB_NAME: orders
DB_USER: orders_app
DB_PASSWORD_FILE: /run/secrets/db_password
PAYMENT_API_KEY_FILE: /run/secrets/payment_api_key
PAYMENT_TIMEOUT_S: "5"
secrets:
- db_password
- payment_api_key
secrets:
db_password:
file: /srv/orders/secrets/db_password
payment_api_key:
file: /srv/orders/secrets/payment_api_key
Это фрагмент конфигурации уже существующего проекта: сервис базы и остальные параметры запуска здесь опущены. Адрес образа и пути на сервере нужно заменить своими.
В контейнер передаются обычные значения и пути к двум секретам. Сам пароль базы и API-ключ не записаны в Compose-файле. Каждому сервису нужно отдельно предоставить доступ к требуемому секрету через secrets. В Docker Compose такие секреты доступны контейнеру как файлы в /run/secrets/; для источника file Compose использует монтирование файла. Описание механизма приведено в документации Docker.
Но исходные файлы /srv/orders/secrets/... по-прежнему лежат на сервере. Для них нужны ограниченные права, безопасная передача на сервер и продуманное резервное копирование. Compose не превращает обычный файл на хосте в зашифрованное хранилище с аудитом.
Ещё одна полезная граница: во время сборки образа приложение не должно получать рабочий API-ключ. Ключ нужен работающему сервису, а не слою образа. Иначе секрет может остаться в артефактах сборки и попасть во все копии образа.
Как приложение читает конфигурацию
Передать значение в контейнер мало. Приложение должно однозначно его прочитать, проверить и не вывести секрет при ошибке.
Ниже сокращённый пример загрузчика настроек на Python. Он рассчитан на текстовые секреты, содержащие одну строку.
from dataclasses import dataclass, field
from math import isfinite
from os import environ
from pathlib import Path
@dataclass(frozen=True)
class Settings:
app_env: str
db_host: str
db_name: str
db_user: str
db_password: str = field(repr=False)
payment_api_key: str = field(repr=False)
payment_timeout_s: float
def required(name: str) -> str:
value = environ.get(name)
if value is None or value == "":
raise RuntimeError(f"Required setting is missing: {name}")
return value
def read_secret(path_setting: str) -> str:
path = Path(required(path_setting))
value = path.read_text(encoding="utf-8")
# Убираем один перевод строки, добавленный при создании текстового файла.
if value.endswith("\n"):
value = value[:-1]
if not value:
raise RuntimeError(f"Secret file is empty: {path_setting}")
return value
def load_settings() -> Settings:
app_env = required("APP_ENV")
if app_env not in {"local", "staging", "production"}:
raise RuntimeError("APP_ENV has an unsupported value")
try:
timeout = float(required("PAYMENT_TIMEOUT_S"))
except ValueError as exc:
raise RuntimeError("PAYMENT_TIMEOUT_S must be a number") from exc
if not isfinite(timeout) or not 0 < timeout <= 30:
raise RuntimeError("PAYMENT_TIMEOUT_S must be within (0, 30]")
return Settings(
app_env=app_env,
db_host=required("DB_HOST"),
db_name=required("DB_NAME"),
db_user=required("DB_USER"),
db_password=read_secret("DB_PASSWORD_FILE"),
payment_api_key=read_secret("PAYMENT_API_KEY_FILE"),
payment_timeout_s=timeout,
)
Здесь есть несколько важных решений.
Проверка в начале работы. Если обязательного значения нет, запуск останавливается с именем отсутствующего параметра. Сообщение не содержит значение секрета.
Проверка типа и диапазона. Переменная окружения всегда приходит как строка. Строки "false" и "0" тоже остаются строками, пока приложение явно не преобразует их. Для таймаута мы проверяем число, конечность и допустимый диапазон.
Ограничение вывода объекта. repr=False скрывает пароль и ключ из стандартного строкового представления Settings. Это дополнительная защита от случайного вывода объекта, а не гарантия: секрет всё ещё находится в памяти процесса и может попасть в журнал, если явно вывести поле, заголовок запроса или объект клиента платёжного API.
Одно чтение при запуске. load_settings() возвращает снимок значений. Если файл с ключом позже изменится, этот объект не обновится сам. Это принципиально для ротации.
В production полезно записывать в журнал безопасные сведения о конфигурации: имя окружения, версию выпуска и идентификатор версии секрета. Значение пароля или ключа туда записывать нельзя.
Короткий вывод: хороший загрузчик настроек делает ошибку запуска явной и ранней. Он не должен превращать секрет в часть диагностического сообщения.
Что означает «сменить ключ»
Слово «ротация» часто скрывает несколько отдельных действий:
Создать новый ключ -> доставить его приложению -> убедиться, что приложение использует новый ключ -> прекратить использование старого -> отозвать старый ключ.
Если переставить шаги местами, сервис может остановиться. Если не выполнить последний шаг, старый ключ продолжит работать, а смысл ротации частично потеряется.
Способ перехода зависит от того, для чего используется секрет. Рассмотрим три случая.
Ключ для исходящих запросов к платёжному сервису
API-ключ позволяет нашему приложению обращаться к внешнему сервису. Здесь наиболее удобен провайдер, который допускает два одновременно действующих ключа.
Плановая смена выглядит так:
- Выпустить новый ключ с теми же необходимыми правами.
- Не отключая старый, доставить новый ключ приложению.
- Перезапустить или обновить экземпляры API так, чтобы новые запросы шли с новым ключом.
- Выполнить безопасную проверку интеграции и проследить за ошибками авторизации.
- Убедиться, что старые экземпляры и незавершённые повторные попытки больше не используют прежний ключ.
- Отозвать старый ключ.
Если провайдер разрешает только один активный ключ и не даёт периода перекрытия, обещать смену без ошибок нельзя. Нужна другая возможность со стороны провайдера, запасной канал интеграции или согласованное окно переключения.
Пароль PostgreSQL
У одной учётной записи обычно нет двух одновременно действующих паролей, из которых старый принимает старые экземпляры, а новый принимает новые. Поэтому простая последовательность «поменять пароль в базе, затем развернуть приложение» может оставить старые экземпляры без возможности создать новое соединение.
Один из вариантов перекрытия - две отдельные учётные записи приложения с одинаковыми необходимыми правами. Новый экземпляр использует новую запись, старый временно продолжает работать со старой. После переключения трафика старые соединения завершают работу, старую учётную запись отключают и удаляют по принятой процедуре. Такой переход требует аккуратного управления правами и пулами соединений. Для управляемых баз данных могут существовать собственные механизмы ротации; например, AWS Secrets Manager описывает смену значения и в хранилище секрета, и в самом целевом сервисе.
Главная мысль: обновить запись в менеджере секретов недостаточно. База тоже должна принимать новые данные, а работающие процессы должны их получить.
Ключ подписи токенов
Здесь роли разделяются иначе. Приложение выпускает новые токены одним ключом и проверяет ранее выпущенные токены. Для плановой смены можно начать подписывать новым ключом, но временно оставить старый в наборе ключей проверки.
Идентификатор kid в заголовке подписанного токена помогает выбрать проверочный ключ. Он является подсказкой для выбора ключа из заранее разрешённого набора, а не основанием загрузить произвольный ключ по адресу из токена. Назначение kid определено в RFC 7515.
Допустим, срок действия access-токена равен 15 минутам, а допустимое расхождение часов составляет 1 минуту. Последний токен со старой подписью выдан в 12:04. Старый проверочный ключ нужно сохранять как минимум до 12:20: 15 минут жизни токена и 1 минута допуска после последней выдачи, а не после начала развёртывания. Если есть дополнительные кеши, очереди или другие типы токенов, их сроки тоже учитывают. Поле времени окончания действия exp описано в RFC 7519.
При компрометации старого ключа ждать истечения всех токенов может быть недопустимо. Тогда приоритет меняется: ключ немедленно отзывают, а часть пользователей может потребоваться повторно авторизовать. Плановая ротация и реакция на утечку имеют разные допустимые последствия.
Короткий вывод: ротация требует периода совместимости либо заранее предусмотренного способа переключения. Его длина определяется жизнью старых клиентов, соединений, запросов и токенов.
Почему файл с новым секретом ещё не означает новую конфигурацию
Есть три разных состояния:
- Источник обновлён. Новый ключ записан в файл или менеджер секретов.
- Процесс увидел изменение. Файл перечитан либо приложение запросило новую версию.
- Все нужные компоненты используют новый ключ. Новые клиенты и соединения созданы, старые запросы завершились.
Наш Settings читается один раз при запуске. Замена файла изменит первое состояние, но не второе и третье. Даже если приложение перечитает файл, существующий клиент платёжного сервиса или пул соединений с базой может продолжать хранить старый ключ.
У смонтированных секретов поведение зависит и от платформы. Например, Kubernetes обновляет содержимое Secret-тома с задержкой, но само приложение всё равно должно перечитать файл; при монтировании через subPath автоматического обновления нет. Секрет, переданный в окружение уже запущенного процесса, также не превращается в новое значение его переменной. Эти ограничения отражены в документации Kubernetes.
Есть два основных способа применения новой версии:
- Перезапуск экземпляров по очереди. Проще проверить и откатить. Каждый новый экземпляр читает свежую конфигурацию при старте.
- Обновление внутри работающего процесса. Приложение обнаруживает новую версию, проверяет её, создаёт новый клиент или пул и переключает новые операции на него. Старые операции завершаются на прежнем объекте. Это требует отдельной реализации и проверки ошибок переключения.
Для нашего API разумно начать с последовательной замены экземпляров. Она проще, чем собственный механизм горячей перезагрузки секретов.
Когда возможна смена без простоя
Если сейчас работает один экземпляр API на 127.0.0.1:8000, его остановка и запуск с новым секретом могут дать короткую паузу. Ни хранение секрета в файле, ни команда перезапуска не отменяют этого ограничения.
Для непрерывного обслуживания можно временно или постоянно держать два экземпляра, например на локальных портах 8001 и 8002. Caddy способен распределять запросы между несколькими upstream и проверять их готовность; соответствующие параметры описаны в документации reverse_proxy.
Тогда плановый выпуск проходит так:
Старый экземпляр обслуживает запросы -> новый экземпляр запускается с новым секретом -> проверка готовности и интеграции проходит успешно -> прокси направляет запросы на новый экземпляр -> старый завершает текущие запросы -> старый секрет отзывается.
У этого сценария есть предпосылки:
- внешний сервис временно принимает старый и новый ключи;
- серверу хватает ресурсов на одновременную работу экземпляров;
- пользовательское состояние не хранится только в памяти одного процесса;
- изменения схемы базы совместимы со старой и новой версиями приложения на время перехода;
- проверка готовности отражает способность экземпляра обслуживать запросы;
- есть способ вернуть трафик на старую версию до отзыва прежнего ключа.
Проверка готовности не обязана выполнять настоящий платёж при каждом запросе прокси. Она должна подтверждать необходимые локальные и внутренние зависимости, а внешнюю интеграцию можно дополнительно проверить отдельным безопасным тестом при выпуске.
Так становится видно, что «без простоя» - свойство всего процесса переключения, а не выбранного формата хранения секрета.
Как проверить ротацию до production
Для ключа платёжного сервиса полезно провести репетицию на staging, где используются отдельные ключи песочницы. Проверяем не только успешный запрос с новым ключом, но и переходные состояния.
- Новый экземпляр не становится готовым, если секретный файл отсутствует или пуст.
- Новый ключ успешно проходит предусмотренный тест у провайдера.
- Пока работают старый и новый экземпляры, оба получают допустимые ответы.
- После выключения старого экземпляра в журналах и метриках не появляются запросы с прежней версией ключа.
- Ошибка нового ключа обнаруживается до отзыва старого.
- Откат возвращает рабочее состояние без восстановления ключа из исходного кода или переписки.
В наблюдении за ротацией полезны метрики ошибок авторизации у провайдера, ошибок подключения к базе, готовности экземпляров и числа запросов на каждый экземпляр. Версию секрета или kid можно использовать как диагностический идентификатор. Само значение секрета в метрики и журналы не помещают.
Для конфигурации в целом проверяют ещё один риск: не возник ли неожиданный приоритет значений. Например, переменная окружения процесса, запустившего Compose, способна повлиять на подстановку в файл. Проверка итоговой конфигурации полезна, но выполнять её следует осторожно: если вы подставили секрет непосредственно в environment, итоговый вывод может раскрыть его.
Что делать при утечке
Плановая ротация допускает управляемое перекрытие ключей. Подозрение на утечку требует прежде всего ограничить дальнейшее использование раскрытого секрета.
Практический порядок зависит от системы, но включает:
- Определить, какой секрет раскрыт, какие права он даёт и где мог оказаться: Git, журнал, артефакт сборки, резервная копия, сообщение или рабочая станция.
- Создать и доставить замену безопасным способом.
- Как можно быстрее отозвать скомпрометированный ключ или ограничить его права.
- Проверить обращения к ресурсу за период возможного раскрытия.
- Удалить секрет из доступных копий и устранить способ, которым он попал наружу.
Удаление строки из последнего коммита не отнимает права у уже раскрытого ключа и не стирает все копии истории. Приоритетом остаётся отзыв и оценка использования. Такой подход соответствует рекомендациям OWASP по реагированию на раскрытие секретов.
Отдельно стоит решить задачу доступа системы выпуска к секретам. Если CI/CD хранит долгоживущий ключ от облака, этот ключ сам становится целью защиты и ротации. Там, где платформа поддерживает федеративную идентификацию, процесс выпуска может получать краткоживущие полномочия через OIDC вместо постоянного облачного ключа. Такой вариант, например, описан в документации GitHub Actions.
Как собрать подход целиком
Для нашего API итоговая схема выглядит так:
Один образ приложения -> отдельные параметры и ресурсы каждого окружения -> обычные настройки через окружение -> секреты через ограниченный источник -> проверка при запуске -> последовательное обновление экземпляров -> отзыв старых ключей.
Начать можно с Compose и файлов секретов вне репозитория. По мере роста числа сервисов полезность централизованного менеджера секретов увеличивается: появляются права на отдельные секреты, версии, журнал доступа и автоматизация ротации. Но смена хранилища не заменяет работу приложения с новыми значениями и не решает сама по себе задачу доступности при переключении.
Главный вопрос при проектировании каждого секрета звучит так: кто его использует, когда считывает, сколько живут старые экземпляры и каким действием прежнее значение окончательно теряет силу? Если на эти вопросы есть проверенные ответы, конфигурацию можно менять предсказуемо, а ключи - ротировать без случайных утечек и незапланированных остановок.