Внедрение в готовый продукт
Как перевести на env-master сервис, который уже работает: без окна простоя, без «большого переключения» и с возможностью остановиться на любом шаге.
Эта страница описывает не идеальную схему, а ту, что проделана на реальном
сервисе — ota-service: монорепозиторий на bun, docker compose на одном хосте
за внешним Traefik, CI в Gitea Actions. Порядок шагов, ловушки и оговорки —
оттуда.
Куда вы придёте
Две identity с разными правами вместо одной кучи переменных:
| Кто | Как представляется | Что видит |
|---|---|---|
| Пайплайн | ENV_MASTER_CLIENT_ID + ENV_MASTER_CLIENT_SECRET в настройках CI | только ключи выкладки |
| Сам сервис на хосте | machine identity из файла на диске | только ключи рантайма |
В настройках CI остаются две строки — те, без которых нечем открыть env-master. Всё остальное живёт в окружении проекта.
На сервере не остаётся ни одного прикладного секрета: .env с паролями
исчезает, а единственный секретный файл на хосте — удостоверение машины.
Главное следствие, ради которого всё и делается: identity пайплайна не видит
прикладных ключей вовсе. Сборка, которую скомпрометировали, не отдаёт
JWT_SECRET, потому что не может его прочитать.
Порядок: шесть шагов, и каждый останавливаем отдельно
Порядок не косметический. Сначала выкладка перестаёт хранить конфигурацию у себя, потом её проверяют, и только потом её перестаёт видеть тот, кому она не нужна. Обратный порядок ломает деплой на середине.
1. Заведите проект, окружение и identity пайплайна
Имена берите те, что заведены в env-master, а не «как принято». Окружение
prod, а не production, если так вы его назвали. Слаг проекта — тот, что
в адресе, а не человеческое название. На этом спотыкаются первым делом:
пайплайн получает Secret reference is not accessible и выглядит как проблема
прав.
Identity пайплайна — режим universal (постоянные client_id и
client_secret): раннер поднимается заново на каждый прогон, и одноразовый
enrollment-токен ему не подходит.
В настройках CI:
ENV_MASTER_CLIENT_ID— переменной, не секретом. Тайны в идентификаторе нет, а видимый в интерфейсе идентификатор экономит время при разборе. В workflow читайте его как${{ vars.ENV_MASTER_CLIENT_ID || secrets.ENV_MASTER_CLIENT_ID }}— тогда переезд из секретов в переменные не ломает прогон.ENV_MASTER_CLIENT_SECRET— только секретом, без запасного варианта.
2. Перенесите ключи выкладки и уберите самодельный SSH
Первое, что уезжает в env-master, — конфигурация самой выкладки: адрес хоста, пользователь, порт, путь, отпечаток хоста, реестр образов, приватный ключ деплой-пользователя.
- name: Fetch deploy env from env-master
uses: aistastudio/env-action@v1
with:
base-url: https://env.example.com
project: my-project
environment: prod
client-id: ${{ vars.ENV_MASTER_CLIENT_ID || secrets.ENV_MASTER_CLIENT_ID }}
client-secret: ${{ secrets.ENV_MASTER_CLIENT_SECRET }}
Приватный ключ маскируется построчно. Раннер маскирует значение целиком, а многострочный PEM попадает в лог по строке за раз — и целиком видим в выводе. Берите версию действия, которая маскирует каждую строку отдельно; версия ниже этого не умеет, и ключ утечёт в журнал прогона.
Пустое значение — тоже отказ. Ключ может существовать и быть пустым;
проверяйте непустоту поимённо сразу после получения, чтобы ошибка называла
ключ, а не проявлялась через три шага падением ssh:
test -n "${REGISTRY_HOST:-}" || { echo '::error::env-master: ключ REGISTRY_HOST пустой'; exit 1; }
3. Положите контракт окружения рядом с кодом
Заведите в репозитории файл, который перечисляет каждый ключ: имя, вид
(var/secret), обязателен ли, кому он нужен и что он значит.
version: 1
keys:
- name: REGISTRY_HOST
kind: var
required: true
consumer: deploy
description: Домен реестра образов
- name: JWT_SECRET
kind: secret
required: true
consumer: runtime
description: >-
Подпись сессий. При переезде на другой сервер должен остаться
ПРЕЖНИМ: смена разлогинит всех.
Почему файл в репозитории, а не схема окружения на сервере. Это два разных
контракта, и расходиться им позволено: схема на сервере описывает, что
обязан держать оператор; файл описывает, что нужно этому коду. Файл
ревьюится в pull request и меняется тем же коммитом, что и compose-файл, —
а серверную схему пришлось бы синхронизировать вручную и однажды забыть.
Три правила, выведенные из практики:
consumer:обязателен, а ключ без него считается «нужен обоим». Забытая пометка должна ронять проверку громко, а не молча выводить ключ из-под неё.- Форматы не описывайте. Проверка формата роняет деплой, а ловит мало:
S3_ENDPOINTкакurlне пропуститminio:9000, число сломается на законном пустом значении. Что значение осмысленно — проверяет приложение на старте. Схема отвечает на вопрос «оно вообще есть». - Bootstrap-ключей в контракте нет. Адрес env-master, слаг проекта,
окружение и путь к удостоверению подставляет
compose; ключами env-master они не являются, и объявлять их значит завести вторую правду.
4. Проверяйте контракт до первого SSH
Отдельным шагом, до того как выкладка что-то тронет на хосте. Проверка, падающая на середине деплоя, оставляет сервис в промежуточном состоянии.
Проверяйте только свою половину — ключи с consumer: deploy и both.
Это не экономия: identity пайплайна сужена и ключей рантайма не видит.
Проверив их, вы получите «ключ не приехал из env-master» на ключ, который там
есть, — сообщение, уводящее в противоположную сторону. Ровно это и случилось
при первом сужении прав: JWT_SECRET честно лежал в окружении, а проверка
требовала его завести.
Ключи рантайма проверяет само приложение при старте — там, где они нужны, и той identity, которой они видны.
5. Переведите приложение на чтение из env-master
Здесь исчезает .env с сервера. compose передаёт контейнеру только четыре
значения — где искать, кто он и чем представиться:
environment:
ENVMASTER_URL: https://env.example.com
ENVMASTER_PROJECT: my-project
ENVMASTER_ENVIRONMENT: prod
ENVMASTER_CREDENTIALS_FILE: /run/envmaster/machine.json
volumes:
- /etc/envmaster/my-project:/run/envmaster
Остальное приложение читает само через SDK:
const client = await EnvMasterClient.connect({
baseUrl: process.env.ENVMASTER_URL!,
project: process.env.ENVMASTER_PROJECT!,
environment: process.env.ENVMASTER_ENVIRONMENT!,
credentialsFile: process.env.ENVMASTER_CREDENTIALS_FILE!,
});
await client.assertRequirements(["JWT_SECRET", "S3_ENDPOINT", "S3_ACCESS_KEY"]);
Список обязательных ключей — не «важные», а те, у которых умолчание не
падает, а тихо делает неправильное. Отсутствующий JWT_SECRET со значением
по умолчанию change-me-in-production поднимает сервис, который подписывает
токены, подделываемые кем угодно. Ключ, чьё умолчание честно работает,
требовать не надо — иначе оператор придумывает значения настройкам, которые
никому не нужны.
Проверка стоит до чтения конфигурации: отсутствующий ключ обязан остановить старт, назвав себя, а не превратиться в умолчание, которое смотрит на localhost в проде.
6. Уберите доступ у того, кому он больше не нужен
Последний шаг — сужение. Теперь, когда прикладные ключи читает только сервис,
у identity пайплайна их можно отобрать (allowedKeys), а лишние сервисы
выкинуть из стека вместе с их доступами.
Делайте это последним и отдельным изменением: если сузить права раньше, чем приложение научится читать само, деплой встанет, и причина будет выглядеть как ошибка прав, а не как порядок шагов.
Удостоверение машины на хосте
Единственный секретный файл на сервере. Заводится один раз:
sudo install -d -o root -g root -m 0700 /etc/envmaster/my-project
Владелец root, а не деплой-пользователь, и права 0700. Причина простая:
контейнер работает от root и каталог читает, а деплой-пользователь — то есть
всё, что приезжает по SSH из пайплайна, — секрет машины видеть не должен.
Монтируйте каталог целиком, а не файл. SDK пишет machine.json через
временный файл рядом и rename, а rename не работает через границу
монтирования одиночного файла.
Несколько проектов на одном хосте
Каталог на проект, и монтируется ровно один:
/etc/envmaster/
├── my-project/machine.json
├── other-project/machine.json
└── <проект>/machine.json
volumes:
- /etc/envmaster/my-project:/run/envmaster # так
# - /etc/envmaster:/run/envmaster # НЕ так
Разница не косметическая. Смонтировав родителя, вы отдаёте контейнеру удостоверения всех проектов хоста, а с ними — всё, к чему допущены их identity. И самое неприятное: в журнале доступа env-master это ничем не выделяется — чтения пойдут от законных identity, каждое выглядит обычным.
Внутри контейнера путь один и тот же, различаются только источники монтирования: образ остаётся одинаковым для всех проектов, а кто он такой, контейнер узнаёт из смонтированного каталога.
Шов между кодом и контрактом держите тестом
Контракт, который не проверяется, расходится с кодом на первой же правке.
Проверять надо не сам файл, а совпадение трёх списков — что читает код, что
объявлено в контракте, что подставляет compose:
- каждый ключ, который читает приложение, объявлен в контракте;
- всё, что приложение требует на старте, контракт тоже считает обязательным;
- каждый ключ, который подставляет
compose, помечен для пайплайна; - каждый ключ, который читает приложение, помечен для рантайма;
- bootstrap-ключей в контракте нет;
- имя проекта одно и то же в
compose, в деплое и в правилах на хосте; - файл учётных данных лежит внутри смонтированного каталога — не рядом с ним.
Последний пункт стоит теста отдельно: путь /etc/envmaster вместо
/etc/envmaster/my-project выглядит как опечатка, а стоит доступа ко всем
проектам хоста.
Что меняется в сборке, а что в рантайме
Значения, которые запекаются в образ на сборке (идентификаторы счётчиков, публичные адреса, всё, что фронтенд получает статически), в рантайме не меняются. Смена такого ключа требует пересборки, а не правки значения. Отмечайте это в описании ключа — иначе однажды будете полчаса искать, почему новое значение не подхватилось.
Ключ может быть объявлен обязательным для сборки и при этом иметь законное пустое значение: действие получает ключи поимённо и падает на отсутствующем. То есть «необязательный» в контракте и «может отсутствовать в env-master» — разные вещи.
Как сервис узнаёт о ротации
Секрет, прочитанный один раз в переменную при старте, о ротации не узнает никогда. Порядок, при котором ротация работает:
- значение читается через SDK, а не из переменной окружения;
- SDK держит записи в кэше — по умолчанию 15 минут;
refresh()сбрасывает кэш немедленно; вызов по вебхукуentry.rotatedпревращает ротацию в событие для сервиса, а не в ожидание.
Подробности — в ротации секретов.
Права на хосте: сузить и сказать правду про границу
Если выкладка ходит по SSH и запускает docker, дайте деплой-пользователю
sudo ровно на те команды, которые пайплайн действительно выполняет, и
проверьте это дважды: разрешённая команда проходит, заведомо лишняя —
отказывает. Второй отказ и есть доказательство, что правило не шире, чем
нужно.
И оговорка, которую стоит написать себе в runbook: такое правило не делает
деплой безопасным в полном смысле. compose-файл в каталог кладёт сам деплой,
а compose-файл описывает, что запустится с правами root. Граница проходит не
по «деплой не может стать root», а по «деплой не может выполнить произвольную
команду мимо того, что лежит в репозитории и прошло ревью».
Чего эта схема не делает
- Не защищает от того, кто может изменить код. Кто правит репозиторий, тот правит и то, что сервис прочитает.
- Не прячет значения от самого сервиса. Он их читает — на то и доступ. Разница в том, что читает он их в память процесса, а не получает файлом, который останется на диске и попадёт в бэкап.
- Не отменяет ротацию. Централизованное хранение делает ротацию выполнимой, но пока вы её не включили, секрет остаётся тем же, что и был.