env-master
ENСоздать аккаунт
Разделы документации

Внедрение в готовый продукт

Как перевести на 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» — разные вещи.

Как сервис узнаёт о ротации

Секрет, прочитанный один раз в переменную при старте, о ротации не узнает никогда. Порядок, при котором ротация работает:

  1. значение читается через SDK, а не из переменной окружения;
  2. SDK держит записи в кэше — по умолчанию 15 минут;
  3. refresh() сбрасывает кэш немедленно; вызов по вебхуку entry.rotated превращает ротацию в событие для сервиса, а не в ожидание.

Подробности — в ротации секретов.

Права на хосте: сузить и сказать правду про границу

Если выкладка ходит по SSH и запускает docker, дайте деплой-пользователю sudo ровно на те команды, которые пайплайн действительно выполняет, и проверьте это дважды: разрешённая команда проходит, заведомо лишняя — отказывает. Второй отказ и есть доказательство, что правило не шире, чем нужно.

И оговорка, которую стоит написать себе в runbook: такое правило не делает деплой безопасным в полном смысле. compose-файл в каталог кладёт сам деплой, а compose-файл описывает, что запустится с правами root. Граница проходит не по «деплой не может стать root», а по «деплой не может выполнить произвольную команду мимо того, что лежит в репозитории и прошло ревью».

Чего эта схема не делает

  • Не защищает от того, кто может изменить код. Кто правит репозиторий, тот правит и то, что сервис прочитает.
  • Не прячет значения от самого сервиса. Он их читает — на то и доступ. Разница в том, что читает он их в память процесса, а не получает файлом, который останется на диске и попадёт в бэкап.
  • Не отменяет ротацию. Централизованное хранение делает ротацию выполнимой, но пока вы её не включили, секрет остаётся тем же, что и был.