Secret Manager

Пароль базы данных не должен лежать ни в коде, ни в git, ни в переменных окружения на ноутбуке разработчика. Разбираемся, куда его класть в Google Cloud.

Secret Manager — управляемое хранилище секретов в GCP: пароли, API-ключи и токены лежат зашифрованными, доступ к каждому выдаётся через IAM, а каждое обращение попадает в аудит-лог.

История, которую видел каждый: пароль от продовой базы попал в config.py, файл ушёл в git, репозиторий стал публичным «на пять минут» — и через полчаса в базе майнер. Проблема не в невнимательности, а в том, что секрету физически негде жить, если для него нет специального места. Secret Manager — это такое место.

Зачем отдельный сервис

Кажется, что достаточно переменной окружения. Но у «просто env-переменной» есть неприятные свойства:

  • Значение всё равно откуда-то берётся — из .env, из скрипта деплоя, из настроек CI. И вот эти файлы и живут в git.
  • Сменить пароль нельзя без передеплоя всех, кто его знает — а кто именно знает, никто не помнит.
  • Нет ответа на вопрос «кто и когда читал этот пароль». А после инцидента это первый вопрос.

Secret Manager закрывает все три пункта: одно место хранения, версионирование, доступ по IAM и аудит. Цена символическая: платят за активную версию секрета в месяц (порядка $0,06) и за операции доступа (порядка $0,03 за 10 000 обращений). Есть и бесплатный уровень — несколько активных версий и десятки тысяч обращений в месяц. Для типичного приложения это единицы центов, но об этом ниже есть подвох.

Секрет и его версии

Это главная идея сервиса. Секрет — это контейнер с именем и настройками доступа. Значение лежит не в секрете, а в его версии. Версии нумеруются с единицы, они неизменяемы: поменять значение нельзя, можно только добавить новую версию.

# Не забудьте включить API — шаг, на котором спотыкаются все
gcloud services enable secretmanager.googleapis.com

# 1. Создаём контейнер секрета (значения в нём пока нет)
gcloud secrets create db-password --replication-policy=automatic

# 2. Кладём значение — это версия 1.
# ВНИМАНИЕ на -n у echo: без него в пароль попадёт символ перевода строки!
echo -n "S3cret-P@ss" | gcloud secrets versions add db-password --data-file=-

# 3. Читаем значение (нужна роль secretAccessor)
gcloud secrets versions access latest --secret=db-password

# 4. Ротация: добавили новую версию, старую отключили
echo -n "N3w-P@ss-2026" | gcloud secrets versions add db-password --data-file=-
gcloud secrets versions disable 1 --secret=db-password

gcloud secrets versions list db-password

Результат:

NAME  STATE     CREATED
2     enabled   2026-07-14T09:12:04
1     disabled  2026-07-11T18:40:55

Состояний у версии три: enabled (читается), disabled (временно закрыта — так проверяют, не сломается ли что-то после ротации) и destroyed (значение стёрто безвозвратно). Алиас latest всегда указывает на самую свежую включённую версию.

Про echo без -n стоит сказать отдельно, потому что на эти грабли наступают буквально все:

raw_with_newline = "S3cret-P@ss\n"   # так сохранит echo БЕЗ -n
raw_clean = "S3cret-P@ss"            # так сохранит echo -n

for name, value in [("echo без -n", raw_with_newline), ("echo -n     ", raw_clean)]:
    print(f"{name}: {value!r}, длина = {len(value)}")

print("Пароли совпадают?", raw_with_newline == raw_clean)

Результат:

echo без -n: 'S3cret-P@ss\n', длина = 12
echo -n     : 'S3cret-P@ss', длина = 11
Пароли совпадают? False

База ответит password authentication failed, а пароль в консоли будет выглядеть абсолютно правильным. Часы отладки на один пропущенный флаг.

Доступ из приложения

Секрет читает не «приложение», а сервис-аккаунт, от имени которого оно работает. Роль называется roles/secretmanager.secretAccessor, и выдавать её надо на конкретный секрет, а не на проект:

gcloud secrets add-iam-policy-binding db-password \
  --member="serviceAccount:api-runner@my-proj.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

Дальше есть два пути. Первый — декларативный, и он лучше: пусть секрет подставит сама платформа. Cloud Run умеет пробросить версию секрета как переменную окружения или как файл — код о Secret Manager вообще не знает:

# Секрет как переменная окружения DB_PASSWORD
gcloud run deploy api \
  --image=europe-docker.pkg.dev/my-proj/app/api:1.0 \
  --service-account=api-runner@my-proj.iam.gserviceaccount.com \
  --set-secrets=DB_PASSWORD=db-password:latest \
  --region=europe-west1

# Или как файл, смонтированный в контейнер (безопаснее: не утечёт в дамп env)
gcloud run deploy api \
  --image=europe-docker.pkg.dev/my-proj/app/api:1.0 \
  --set-secrets=/secrets/db=db-password:2 \
  --region=europe-west1

Второй путь — читать секрет из кода через клиентскую библиотеку. Нужен, когда секрет надо перечитывать без передеплоя (например, после ротации) или когда секретов много и они выбираются динамически:

import os
from functools import lru_cache
from google.cloud import secretmanager

PROJECT_ID = os.environ["GOOGLE_CLOUD_PROJECT"]

@lru_cache(maxsize=32)   # кэшируем: каждый access — платная операция и лишние 50 мс
def get_secret(name: str, version: str = "latest") -> str:
    client = secretmanager.SecretManagerServiceClient()
    path = f"projects/{PROJECT_ID}/secrets/{name}/versions/{version}"
    response = client.access_secret_version(request={"name": path})
    return response.payload.data.decode("UTF-8")

db_password = get_secret("db-password")
print("Пароль получен, длина:", len(db_password))   # само значение НИКОГДА не логируем

Аутентификация тут снова через ADC: сервис-аккаунт привязан к Cloud Run, токен приезжает из метаданных, ключей нет. На ноутбуке то же самое заработает после gcloud auth application-default login.

Как это работает

Значение версии шифруется на стороне Google (по умолчанию — ключами Google; при желании можно подключить свои через Cloud KMS) и реплицируется. Политика репликации automatic раскладывает секрет по регионам сама — это разумный выбор по умолчанию; user-managed нужна, когда по требованиям регулятора данные не должны покидать конкретные регионы.

Каждое обращение access_secret_version — это вызов API: проверка IAM, расшифровка, ответ. Отсюда два следствия:

  • Каждый вызов стоит денег и времени. Читать секрет на каждый HTTP-запрос — плохая идея: получите лишние миллисекунды задержки и счёт за миллионы операций. Читайте один раз при старте (или кэшируйте, как в примере выше).
  • Каждый вызов виден в аудите. В Cloud Logging остаётся запись: кто, когда и какую версию читал. Это и есть та самая ценность «кто трогал пароль», ради которой всё затевалось.

Частые ошибки

  • echo без -n. Лишний \n в конце пароля. Симптом — «пароль правильный, но не подходит». Лечится -n или чтением из файла.
  • Роль secretAccessor выдана на весь проект. Тогда сервис-аккаунт фронтенда читает и ключ платёжной системы, и пароль от прода. Права — на конкретный секрет.
  • Секрет попал в лог. print(db_password) или подробный вывод ошибки — и секрет уже в Cloud Logging, откуда его видит любой, у кого есть logging.viewer. Логируйте факт получения, но не значение.
  • Секрет в Docker-образе или в build-аргументах. Слои образа хранят всё, включая удалённые файлы. Секрет должен появляться в контейнере во время выполнения, а не при сборке.
  • Терраформ-стейт с секретами. Если создавать версию секрета через Terraform, значение окажется в открытом виде в terraform.tfstate. Кладите значение отдельно (через gcloud или консоль), а в IaC описывайте только сам контейнер секрета и права на него.
  • Уничтожили версию, которая ещё используется. destroy необратим. Правильный порядок ротации: добавить новую версию → передеплоить/перезапустить потребителей → disable старую → подождать → только потом destroy.
  • Забыли включить API. PERMISSION_DENIED: Secret Manager API has not been used in project ... — это не про IAM, это про gcloud services enable secretmanager.googleapis.com.
  • Ошибка, стоящая денег: тысяча секретов «на всякий случай» и чтение секрета на каждый запрос в нагруженном сервисе. Хранение копеечное, но платные операции доступа при 10 млн запросов в месяц превращаются во вполне заметную строку счёта.

Итоги

  • Секрет — это контейнер, значение живёт в неизменяемой версии; изменить значение = добавить новую версию.
  • Доступ выдаётся ролью roles/secretmanager.secretAccessor на конкретный секрет и конкретному сервис-аккаунту.
  • Лучший способ доставить секрет в Cloud Run — --set-secrets: платформа подставит его сама, код о Secret Manager не знает.
  • В коде читайте секрет один раз при старте и кэшируйте: каждый access — платная операция и лишняя задержка.
  • Ротация без простоя: новая версия → перезапуск потребителей → disable старой → и только потом destroy.
  • echo -n. Всегда -n.
Проверьте себя
1. Вы добавили новую версию секрета и хотите безопасно вывести старую из обращения. Какой порядок действий правильный?
AСразу destroy старой версии — новая уже есть
BПерезапустить потребителей на новую версию, затем disable старой, и только потом destroy
CУдалить сам секрет и создать его заново с новым значением
DНичего не делать: старая версия отключается автоматически
2. Почему опасно читать секрет через access_secret_version на каждый входящий HTTP-запрос?
ASecret Manager вернёт ошибку после второго обращения к одной версии
BКаждое обращение — платная операция доступа плюс лишняя задержка; секрет читают при старте и кэшируют
CЗначение секрета меняется при каждом чтении
DТак секрет автоматически попадает в переменные окружения