Секреты: хранение, доставка, ротация
Документ доводит систему до состояния «ни одно секретное значение не лежит в репозитории и в образе; рядом с каждой частью системы лежит её файл значений; значение заменяется без простоя». Исходное состояние: репозиторий с кодом, части системы описаны файлами compose, сервер с Docker.
19 минутДокумент доводит систему до состояния «ни одно секретное значение не лежит в репозитории и в образе; рядом с каждой частью системы лежит её файл значений; значение заменяется без простоя». Исходное состояние: репозиторий с кодом, части системы описаны файлами compose, сервер с Docker.
Секрет — значение, знание которого даёт доступ: пароль базы, ключ подписи токенов, закрытый ключ, ключ внешнего API, строка подключения с учётными данными. Адрес сервиса, имя базы и номер порта секретом не являются; они попадают в тот же файл заодно, потому что описывают ту же установку.
git ls-files | grep -E '(^|/)\.env$|\.pem$' # ожидается: пустой вывод
docker compose version # ожидается: версия Compose v2Первая проверка обязательна: если файл со значениями уже отслеживается, значения уже в истории, и начинать надо не с настройки, а с их замены — раздел «Утечка».
Место в цепочке
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
структура частей системы задаёт каталог .secrets/ у каждой части; на сервере установлен Docker | что лежит в .secrets/, как значения попадают в контейнер и на сервер, как заменяются | сборка релиза и выкат: артефакт собирается без значений; резервное копирование, куда .secrets/ входит |
Что предыдущее звено обязано обеспечить: .gitignore заведён до первого коммита. Файл, попавший
в историю, оттуда не исчезает при последующем добавлении в игнор — он остаётся во всех прошлых
состояниях и во всех клонах.
Что этот документ оставляет следующему: на сервере рядом с каждой частью лежит заполненный
.secrets/.env. Поэтому образ не содержит значений, и один и тот же артефакт разворачивается в
любой среде — это условие воспроизводимого релиза.
Почему не в репозитории и не в образе
Репозиторий клонируется. У каждого, кто когда-либо его клонировал, есть полная история. Удаление файла следующим коммитом не убирает значение ни из истории, ни из уже сделанных клонов и форков.
Образ раздаётся. Слои читает любой, кто может его скачать; docker history показывает команды
сборки и аргументы (ARG); COPY . . без .dockerignore кладёт каталог со значениями прямо в
слой.
Отсюда рабочее правило: значение, попавшее в историю репозитория или в опубликованный слой, считается раскрытым. Его меняют, а не прячут — скрыть уже розданное невозможно.
Формат: каталог .secrets/ рядом с частью системы
Каждая часть системы — бэкенд, фронтенд, шлюз — хранит значения сама, в каталоге .secrets/ в
корне своей папки. Общего каталога на продукт нет: части разворачиваются по отдельности и на разные
машины, а общий файл пришлось бы копировать целиком туда, где нужна одна строка из него.
<часть>/
├── .secrets/
│ ├── .env # значения этой части — в git не попадает
│ ├── .env.example # шаблон с теми же ключами — попадает
│ └── signing_key.pem # ключевой материал — не попадает
├── container # образ
└── compose # запуск| Имя | Что внутри | В git |
|---|---|---|
.env | строки КЛЮЧ=значение, по одной на строку | нет |
.env.example | те же ключи с заглушками и комментариями | да |
*.pem, *.key, *.json | ключевой материал, файлы учётных данных провайдеров | нет |
Если на одной машине разворачивается несколько сред одной части, добавляется уровень:
.secrets/<среда>/.env. Общего файла у сред нет — различие сред должно быть видно как разные файлы,
а не как разные строки в одном.
Права и владелец:
| Путь | Права | Владелец |
|---|---|---|
.secrets/ | 700 | учётная запись, от имени которой запускается docker compose |
.secrets/.env | 600 | она же |
.secrets/*.pem (закрытый ключ) | 600 | она же |
.secrets/.env.example | 644 | она же; значений не содержит |
Владелец — именно эта учётная запись, а не root: файл окружения читается инструментом на хосте, и
файл, доступный только root, потребует выполнять весь выкат через sudo.
Права отделяют значения от прочих учётных записей на машине. От того, кто входит в группу docker,
они не защищают: через сокет демона монтируется любой каталог хоста (см. документ о Docker).
Схема рассчитана на установку, где серверы наперечёт и значения раздаются вручную. Централизованное хранилище секретов решает ту же задачу иначе и само требует эксплуатации; здесь оно не рассматривается.
Шаг 1. Закрыть .secrets/ от git и от сборки образа
В .gitignore в корне репозитория:
# Значения установки: содержимое .secrets/ в историю не попадает.
**/.secrets/*
# Шаблон — попадает: по нему видно, что нужно задать при развёртывании.
!**/.secrets/.env.exampleПравило написано как «игнорировать всё, кроме шаблона», а не перечислением имён (.env, *.pem).
При перечислении файл с новым именем — выгруженный провайдером credentials.json — не попадает ни
под одно правило и добавляется в коммит незаметно.
В .dockerignore рядом с описанием образа:
**/.secretsБез этой строки COPY . . копирует каталог со значениями в слой образа.
git check-ignore -v <часть>/.secrets/.env
# ожидается: строка вида «.gitignore:2:**/.secrets/* <часть>/.secrets/.env»
git status --porcelain --ignored <часть>/.secrets/
# ожидается: .env помечен «!!» (игнорируется), .env.example — обычный файл
git ls-files -- '**/.secrets/'
# ожидается: только строки с .env.exampleПустой вывод первой команды означает, что правило на этот путь не действует, — а не что файл безопасен.
Шаг 2. Написать шаблон
Шаблон — рабочий файл, по которому установка поднимается с нуля: те же ключи, что в боевом файле, заглушки вместо значений, комментарий о том, откуда значение взять и что будет, если оставить пустым.
# Шаблон. Скопировать в .secrets/.env и заполнить.
# .env и *.pem в git не попадают (см. .gitignore).
SERVICE_NAME=example-service
DB_SCHEMA=example
# Строка подключения. Пользователь и база создаются при первом запуске compose.
DB_URL=postgresql://app:CHANGE_ME@db:5432/app
# Ключ подписи токенов. Сгенерировать: openssl rand -hex 32
JWT_SIGNING_KEY=CHANGE_ME
JWT_ALGORITHM=HS256
ACCESS_TOKEN_TTL_MINUTES=30
# Почта. Пустой SMTP_HOST — письма не отправляются, код остаётся в кеше.
SMTP_HOST=
SMTP_PORT=465
SMTP_PASSWORD=
# Внешний провайдер. Пустая пара ключей — ручка отвечает «не настроено».
PROVIDER_CLIENT_ID=
PROVIDER_CLIENT_SECRET=Три требования к шаблону:
- Заглушка выглядит как заглушка (
CHANGE_ME). Правдоподобное значение видаchange_me_in_prodработает: система с ним запускается, ничего не ломается, и то, что ключ подписи известен всем, обнаруживается при разборе инцидента, а не при развёртывании. - Пустое значение и заглушка означают разное: пустое — «возможность выключена»,
CHANGE_ME— «задать обязательно». Различие описывается комментарием, иначе по шаблону не понять, что из незаполненного обязательно. - Шаблон — не копия боевого файла. Копия содержит значения; попав в git, она делает их раскрытыми.
Проверка — наборы ключей в файле и шаблоне совпадают:
diff <(grep -oE '^[A-Z0-9_]+' .secrets/.env | sort -u) \
<(grep -oE '^[A-Z0-9_]+' .secrets/.env.example | sort -u)
# ожидается: пустой вывод; сравниваются имена ключей, значения из файла не выходятШаг 3. Заполнить значения и выставить права
Значения генерируются, а не придумываются: придуманное человеком значение короче и предсказуемее, чем выглядит.
umask 077 # создаваемые файлы получат права 600
cp .secrets/.env.example .secrets/.env
openssl rand -hex 32 # ключ подписи: 32 байта
openssl genrsa -out .secrets/signing_key.pem 2048 # если нужен алгоритм с парой ключей
openssl rsa -in .secrets/signing_key.pem -pubout \
-out .secrets/signing_key.pub.pem
chmod 700 .secrets
chmod 600 .secrets/.env .secrets/signing_key.pemstat -c '%a %U %n' .secrets .secrets/.env .secrets/signing_key.pem
# ожидается:
# 700 <учётная запись> .secrets
# 600 <учётная запись> .secrets/.env
# 600 <учётная запись> .secrets/signing_key.pemШаг 4. Подключить значения к контейнеру
Способа два, и они не равнозначны.
| Способ | Как | Что даёт | Чего не даёт |
|---|---|---|---|
| окружение | env_file: ./.secrets/.env | работает с любым рантаймом, значение доступно процессу сразу | значение видно в docker inspect и в окружении процесса, наследуется дочерними процессами; меняется только пересозданием контейнера |
| файл | том ./.secrets:/app/.secrets:ro | значения нет в конфигурации контейнера; файл заменяется и перечитывается без пересоздания; действуют права | приложение должно уметь читать значение из файла |
Правило: короткие значения настроек — окружением; ключевой материал (закрытые ключи, сертификаты, файлы учётных данных провайдеров) — файлом. Файл выбирают и тогда, когда значение меняется чаще, чем выкатывается версия.
services:
app:
build: { context: ., dockerfile: container }
env_file:
- ./.secrets/.env # значения окружения
volumes:
- ./.secrets:/app/.secrets:ro # ключевой материал; том только на чтение
expose: ["8000"]Настройки приложения указывают тот же путь, что смонтирован (env_file=".secrets/.env" в описании
настроек), а не собирают значения из переменных по месту: тогда часть запускается и без контейнера,
при локальной отладке, тем же файлом.
Почему значение не пишут в compose по месту. Файл compose — часть кода: он лежит в репозитории и
попадает в историю. Кроме того, один и тот же файл описывает все среды, и вписанное значение
заставляет держать по копии файла на среду — копии расходятся. И третье: docker compose config
печатает описание с подставленными значениями, то есть обычная отладочная команда становится
способом их раскрыть.
Две подстановки, которые путают. env_file — это окружение контейнера; в тексте самого
compose эти значения не подставляются. Запись ${VAR} в compose — подстановка на стороне
инструмента, значения берутся из окружения оболочки и из файла .env рядом с файлом compose.
Второй механизм пригоден для параметров запуска (порт публикации, имя образа), но не для секретов:
подставленное значение видно в docker compose config.
docker compose up -d
docker compose ps # ожидается: состояние healthy
curl -sf http://127.0.0.1:8000/health # ожидается: ответ службы готовностиРучка готовности подтверждает не доставку файла, а пригодность значений: сервис не станет готовым, не подключившись к базе.
Шаг 5. Доставить значения на сервер
Файл переносится по тому же защищённому каналу, по которому идёт управление сервером:
ssh example.com 'install -d -m 700 /opt/app/<часть>/.secrets'
ssh example.com 'umask 077 && cat > /opt/app/<часть>/.secrets/.env' < .secrets/.envumask 077 в удалённой команде создаёт файл сразу с правами 600: он не существует ни секунды в
состоянии, доступном на чтение другим учётным записям. Подключение выполняется под той учётной
записью, от имени которой запускается docker compose, — иначе файл придётся передавать другому
владельцу отдельной командой.
Чего не делают: не пересылают значения сообщением, письмом или в тикет. Оттуда значение не удаляется — оно остаётся в переписке, в её резервных копиях и у каждого участника канала. Отправленное так значение считается раскрытым.
ssh example.com 'stat -c "%a %U %n" /opt/app/<часть>/.secrets /opt/app/<часть>/.secrets/.env'
# ожидается: 700 и 600, владелец — учётная запись выката
ssh example.com "grep -coE '^[A-Z0-9_]+' /opt/app/<часть>/.secrets/.env"
# ожидается: число ключей, равное числу ключей в шаблонеШаг 6. Убедиться, что значение не вытекает
Три места, куда секрет попадает без всякого злого умысла: конфигурация контейнера, журнал, ответ API.
# значение читается из файла, а не набирается: набранное осталось бы в истории оболочки
val=$(grep -m1 '^JWT_SIGNING_KEY=' .secrets/.env | cut -d= -f2-)
# 1. конфигурация контейнера
docker inspect "$(docker compose ps -q app)" --format '{{json .Config.Env}}' | grep -cF "$val"
# 2. журнал
docker compose logs app 2>&1 | grep -cF "$val"
# 3. ответы API, включая ответы об ошибке
curl -s http://127.0.0.1:8000/health | grep -cF "$val"Ожидается: 0 во всех трёх.
env_file от первой проверки сам по себе не спасает: инструмент читает файл и кладёт значения в
конфигурацию контейнера, поэтому всё переданное окружением видно в docker inspect любому, у кого
есть доступ к демону. Файл окружения решает другую задачу — не пускает значения в репозиторий и в
командную строку. Ключевой материал поэтому передают файлом (шаг 4).
Что обеспечивает нули во второй и третьей проверке:
- в журнал не пишутся ни настройки целиком при старте, ни тело запроса целиком; идентификатор ключа — можно, значение — нет (см. документ о наблюдении);
- непредвиденная ошибка отдаётся наружу обезличенным кодом, а не текстом исключения: строка подключения обычно утекает именно так;
- служебной ручки, возвращающей настройки, в боевой сборке нет, либо она отдаёт имена ключей без значений.
docker run --rm --entrypoint sh <образ> -c 'ls -a /app/.secrets 2>/dev/null || echo "каталога нет"'
# ожидается: «каталога нет»
docker history --no-trunc <образ> | grep -cF "$val" # ожидается: 0Аргументы сборки (ARG) остаются в образе и видны в docker history — секрет через них не
передают.
Фронтенд: значение, попавшее в клиентский бандл, перестаёт быть секретом — код фронта доступен пользователю целиком. Проверка та же: поиск значения по собранным файлам.
Ротация: плановая замена
Правило: в момент перехода у секрета два действующих значения. Если действующее значение одно, момент замены совпадает с моментом отказа для всех, кто ещё пользуется прежним, — а таких всегда больше нуля: выданные токены живут дольше выката, вторая копия приложения ещё работает на прежней версии.
Значение, которое проверяет наша же система (ключ подписи токенов):
- Научить проверяющую сторону принимать несколько значений: список ключей вместо одного, у каждого свой идентификатор. Выкатить. Проверка: ранее выданные токены по-прежнему принимаются.
- Добавить новый ключ в список и переключить на него выпуск. Выкатить. Проверка: выданный сейчас токен содержит новый идентификатор ключа; выданный до этого принимается.
- Выждать срок жизни самого долгоживущего значения, подписанного прежним ключом (обычно это срок токена обновления).
- Убрать прежний ключ из списка. Выкатить. Проверка: токен, подписанный прежним ключом, отвергается.
Значение, которое проверяет чужая сторона (пароль базы, ключ внешнего API):
- Завести второе действующее значение на проверяющей стороне: второй ключ в кабинете провайдера; вторая учётная запись базы с теми же правами. Прежнее продолжает работать.
- Записать новое значение в
.secrets/.envна сервере (шаг 5). - Поднять неактивную копию приложения — она стартует уже с новым значением. Проверить служебные ручки.
- Переключить вход на неё (см. документ о релизе и выкате).
- Отозвать прежнее значение на проверяющей стороне после того, как прежняя копия остановлена.
Порядок шагов 4 и 5 обязателен: пока прежнее значение действует, откат — это возврат указателя на прежнюю копию. Отзыв до подтверждения новой версии оставляет систему без пути назад.
Если проверяющая сторона второго значения не поддерживает (некоторые базы хранят один пароль на учётную запись), вторым действующим значением становится вторая учётная запись — заводится, права выдаются те же, прежняя удаляется на шаге 5.
Утечка
Признак утечки: значение оказалось в коммите, в журнале, в сообщении, в тикете, в снимке экрана, у человека, которому оно больше не положено. Порядок действий один и тот же, приоритет — отзыв.
-
Отозвать значение на проверяющей стороне. Не «сменить в файле», а сделать прежнее недействующим. До отзыва замена ничего не даёт: у того, кто получил значение, оно продолжает работать.
-
Выпустить новое, доставить, перезапустить — по порядку из раздела о ротации, но без выдержки: держать два действующих значения при утечке незачем.
-
Проверить историю репозитория. Если значение когда-либо было закоммичено, оно есть во всех клонах; удаление файла новым коммитом не помогает.
bash git log --all --oneline -S'<фрагмент значения>' # ожидается: пустой вывод git log --all --oneline --name-only --pretty=format: -- '**/.secrets/*' | sort -u # ожидается: только .env.exampleНепустой вывод означает, что чистка истории обязательна, а всех владельцев клонов надо предупредить. Отзыв при этом всё равно первичен: чистка не достаёт значение из чужих копий.
-
Проверить остальные места: журналы (в том числе собранные в хранилище), резервные копии, переписку, тикеты. Каждое такое место — самостоятельный источник, и каждое переживает замену файла на сервере.
-
Посмотреть, что успели сделать с раскрытым значением: журнал доступа на проверяющей стороне за период от вероятного момента утечки до отзыва.
Откат и снятие
Новое значение не подошло. Пока прежнее не отозвано, откат — это возврат прежнего значения:
ssh example.com 'umask 077 && cat > /opt/app/<часть>/.secrets/.env' < .secrets/.env.prev
ssh example.com 'cd /opt/app/<часть> && docker compose up -d --force-recreate app'
curl -sfI https://example.com/health # ожидается: 200Прежняя копия файла хранится до подтверждения новой — рядом, с теми же правами (600), и удаляется,
когда новое значение отработало. Если замена выполнялась через переключение копий приложения, откат
сводится к возврату указателя: прежняя копия работает с прежним значением, которое ещё действует.
Если прежнее значение уже отозвано, отката нет: назад пути не существует, потому что старое значение больше не принимается. Выход — выпустить ещё одно новое и пройти доставку заново. Отсюда и правило «отзывать последним».
Снятие секрета совсем (возможность отключается, часть выводится из эксплуатации):
- отозвать значение на проверяющей стороне;
- удалить файл и пересоздать контейнер:
rm .secrets/.env && docker compose up -d --force-recreate app; - убрать ключ из
.env.example, чтобы новая установка его не требовала.
Что при этом не удаляется само: значение остаётся в резервных копиях — там лежит архив
.secrets/, и он живёт до конца срока хранения. Поэтому снятие всегда начинается с отзыва: пока
значение действует, его копия в архиве — тоже действующий доступ.
Типичные отказы
| Признак | Причина | Что делать |
|---|---|---|
.env виден в git status как новый файл | правило не покрывает путь: игнорируется .env в корне, а файл лежит в .secrets/ | правило **/.secrets/*; проверить git check-ignore -v |
шаблон .env.example исчез из git вместе с .env | правило вида .env* захватило и шаблон | игнорировать содержимое каталога и вернуть шаблон строкой !**/.secrets/.env.example |
| файл в игноре, но продолжает отслеживаться | попал в историю раньше игнора | значение считать раскрытым: отозвать и заменить, затем убрать файл из индекса |
после правки .env контейнер работает со старым значением | окружение фиксируется при создании контейнера; docker compose restart перезапускает существующий | docker compose up -d --force-recreate <сервис> |
| приложение не видит переменную, файл на месте | путь в env_file разрешается относительно файла compose, а не текущего каталога | указать путь от файла compose; проверить docker inspect |
Permission denied на файле ключа внутри контейнера | файл 600 принадлежит одному пользователю, процесс в контейнере работает под другим | задать контейнеру того же владельца (user:) либо выдать доступ группе (640 и общая группа) |
| один образ, два сервера — разное поведение | значения в .env разошлись | сверять наборы ключей с шаблоном (grep -oE '^[A-Z0-9_]+'), расхождение по именам ищется без раскрытия значений |
| в журнале обнаружилось значение | логируются настройки при старте или тело запроса целиком | убрать поля из логирования, значение отозвать и заменить |
значение видно в docker inspect | оно передано окружением | для ключевого материала перейти на файл и том только на чтение |
| значение оказалось в опубликованном образе | нет .dockerignore, COPY . . забрал .secrets/ | добавить **/.secrets, пересобрать, значение отозвать и заменить |
| после замены часть пользователей получает отказ | заменили одним действием, без переходного периода | вернуть прежнее значение и пройти ротацию с двумя действующими |
| замену откатить не удаётся | прежнее значение отозвали до подтверждения нового | выпустить новое; на будущее — отзыв последним шагом |
права 644 на .env | файл создан редактором или скопирован без umask | chmod 600; при доставке использовать umask 077 |
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.