Убрать секреты из CI — значит оставить там ровно две строки
Когда работающий сервис переводят на внешнее хранилище секретов, ожидание обычно одно: «в настройках CI станет пусто». Не станет. Там остаются ровно две строки — и понимание, почему именно эти две, важнее самого списка.
Ниже — разбор реального переезда: сервис доставки обновлений, монорепозиторий на bun, docker compose на одном хосте, CI в Gitea Actions. Переезд шёл без окна простоя и без «большого переключения». Пошаговая инструкция — в документации по внедрению; здесь — почему порядок шагов именно такой и что ломается, если его нарушить.
Что остаётся в настройках CI
Две строки, и только они:
ENV_MASTER_CLIENT_ID— переменной, а не секретом. Тайны в идентификаторе нет, а видимый в интерфейсе идентификатор экономит время при разборе инцидента. В workflow он читается какvars.ENV_MASTER_CLIENT_ID || secrets.ENV_MASTER_CLIENT_ID— тогда переезд из секретов в переменные не ломает прогон.ENV_MASTER_CLIENT_SECRET— только секретом, без запасного варианта.
Всё остальное — адрес хоста, пользователь, порт, путь выкладки, отпечаток хоста, реестр образов, приватный ключ деплой-пользователя — это уже не настройки CI, а ключи окружения проекта в env-master.
«Убрать секреты из CI» не значит «убрать все». Эти две строки — не секреты приложения, а пропуск к хранилищу. И важнее всего то, что этот пропуск открывает не всё.
Две identity, и это главное в конструкции
| Кто | Как представляется | Что видит |
|---|---|---|
| Пайплайн | ENV_MASTER_CLIENT_ID + ENV_MASTER_CLIENT_SECRET, universal-режим | только ключи выкладки |
| Сервис на хосте | machine identity из файла на диске | только ключи рантайма |
Universal-режим — постоянные client_id и client_secret: раннер поднимается
заново на каждый прогон, и одноразовый enrollment-токен ему не подходит.
У identity пайплайна список разрешённых ключей сужен (allowedKeys) до тех,
что нужны выкладке.
Следствие, ради которого всё и делается: identity пайплайна не видит
прикладных ключей вовсе. Скомпрометированная сборка не отдаёт JWT_SECRET —
не потому что её «хорошо настроили», а потому что прочитать его ей нечем.
Это действующее свойство, из которого вытекает половина остальных решений —
включая историю с проверкой ниже.
Порядок, который нельзя развернуть
Переезд — это три движения, и порядок у них не косметический:
- Выкладка перестаёт хранить конфигурацию у себя. Ключи уезжают в env-master, самодельный SSH заменяется чтением из хранилища.
- Конфигурацию проверяют. Отдельным шагом, до первого SSH — до того, как выкладка что-то тронет на хосте. Проверка, падающая на середине деплоя, оставляет сервис в промежуточном состоянии.
- Конфигурацию перестаёт видеть тот, кому она не нужна. Права identity сужаются, лишние сервисы выкидываются из стека вместе с их доступами.
Разверните порядок — и деплой встанет на середине. Сузьте права раньше, чем приложение научится читать конфигурацию само, и пайплайн потеряет ключи, которые ещё нужны. Причина при этом будет выглядеть как ошибка прав, а не как порядок шагов — и разбор уйдёт не туда. Поэтому сужение — всегда последний и отдельный шаг.
Проверка, которая увела в противоположную сторону
Это случилось на самом деле. При первом сужении прав пайплайн упал с
требованием завести JWT_SECRET и PUBLIC_API_URL — ключи, которые честно
лежали в окружении. Причина: проверка сверяла полный список, а identity
пайплайна уже видела только свою половину. Для невидимого ключа она выдавала
«ключ не приехал из env-master» — и человек шёл заводить ключ, который уже
есть, вместо того чтобы чинить проверку.
Отсюда правило: пайплайн проверяет только свою половину — ключи,
помеченные для выкладки (deploy и both в контракте окружения). Это не
экономия: чужую половину он проверить не способен, а попытка даёт сообщение,
уводящее в противоположную сторону. Ключи рантайма проверяет само приложение
при старте — там, где они нужны, и той identity, которой они видны. У каждого
ключа в контракте сказано, кому он нужен (consumer), а ключ без пометки
считается «нужен обоим»: забытая пометка должна ронять проверку громко, а не
молча выводить ключ из-под неё.
Монтирование, которого не видно в журнале
Единственный секретный файл на сервере после переезда — удостоверение машины.
Оно лежит в каталоге проекта: /etc/envmaster/<проект>, владелец root,
права 0700. Владелец именно root, а не деплой-пользователь: контейнер
работает от root и каталог читает, а деплой-пользователь — то есть всё, что
приезжает по SSH из пайплайна, — секрет машины видеть не должен. В контейнер
монтируется каталог целиком, а не один файл: SDK пишет machine.json через
временный файл рядом и rename, а rename не работает через границу
монтирования одиночного файла.
На хосте с несколькими проектами — каталог на проект, и монтируется ровно один:
volumes:
- /etc/envmaster/my-project:/run/envmaster # так
# - /etc/envmaster:/run/envmaster # НЕ так
Разница — одна строка, и выглядит она как опечатка. А стоит доступа ко всем проектам хоста: смонтировав родителя, вы отдаёте контейнеру удостоверения каждого проекта, и одна скомпрометированная сборка становится доступом ко всем сразу. Самое неприятное — в журнале доступа это ничем не выделяется: чтения пойдут от законных identity, и каждое выглядит обычным. Журнал фиксирует, кто читал; он не может знать, что процесс вообще не должен был получить этот файл. Поэтому совпадение путей держится тестом, а не вниманием при ревью.
PEM уходит в лог по строке за раз
Приватный ключ деплоя лежит в env-master, как и всё остальное. Но раннер CI маскирует секрет только целиком — а многострочный PEM попадает в вывод построчно, и маскировка целого значения его не ловит: с ранней версией действия ключ оказался бы в журнале прогона в открытом виде. Поэтому версия, которой пайплайн получает ключи, маскирует PEM построчно — каждую строку, кроме заголовка. Это исправление реальной ловушки, а не украшение: утечка по строке за раз неотличима от обычного вывода, пока её не увидишь.
Что в итоге
- В настройках CI — две строки: идентификатор-переменная и секрет. Больше там искать нечего, и это ответ на вопрос «а что там останется?».
- На сервере — один секретный файл: удостоверение машины. Прикладных ключей
на диске нет:
.envисчез, сервис читает значения сам, в память процесса. - Скомпрометированная сборка отдаёт максимум ключи выкладки —
JWT_SECRETей недоступен конструктивно. - Порядок шагов — часть конструкции: сначала конфигурация уезжает, потом проверяется, и только потом прячется от тех, кому не нужна.
Полный порядок — шесть шагов, каждый останавливаем отдельно, — в документации по внедрению в готовый продукт. Про то, как сервис доказывает, что он — это он, — в статье про машинную identity, а про журнал, о котором шла речь выше, — в статье про аудит доступа к секретам.