Деплой и инфра
Секреты при деплое: где они входят и где утекают
Коллега коммитит secret.yaml в GitOps-репозиторий. Выглядит безопасно — поле пароля это cGFzc3dvcmQxMjM=, непонятный блоб. Через полгода аудит безопасности запускает base64 -d и читает password123 меньше чем за секунду. Репозиторий приватный, но в нём 40 контрибьюторов, три форка и полная git-история, которую не вычистит никакой force-push. «Зашифрованный» секрет никогда не был зашифрован. base64 — это кодирование, а не замок.
Главное правило: никогда не запекай секреты в образ
Первый провал случается ещё до деплоя. Dockerfile с ENV API_KEY=sk-live-... или COPY .env . запекает секрет в слой образа. Этот слой адресуется по содержимому, кэшируется и пушится в реестр — а docker history плюс docker save воспроизведут содержимое каждого слоя. Удаление секрета в более позднем слое его не убирает; ранний слой всё ещё существует и доступен на pull любому с доступом на чтение реестра. То же с build-аргументами: --build-arg TOKEN=... оседает в метаданных образа.
Правило, которое сеньор не нарушает: секреты входят при деплое или рантайме, а не на этапе сборки. Образ — это почти публичный артефакт, относись к нему так, будто он утечёт. Конфигурация, меняющаяся от окружения к окружению (dev, staging, prod), и всё секретное инъектируется при старте контейнера, снаружи образа. Поэтому же один и тот же образ чисто продвигается из staging в prod: он не несёт окружение внутри себя.
Ловушка Kubernetes Secret: base64 — это не шифрование
Kubernetes Secret хранит значения, закодированные в base64. Новые инженеры читают непонятную строку и думают, что она защищена. Это не так. base64 — обратимое транспортное кодирование: echo cGFzcw== | base64 -d разворачивает его за секунду без всякого ключа. Любой, кто может прочитать объект Secret или получит копию манифеста, читает открытый текст. Модель угроз широкая: утёкший YAML в тикете, бэкап, неправильно настроенная RBAC-роль или git-история GitOps-репозитория.
Хуже того, по умолчанию значение лежит нешифрованным в etcd, key-value хранилище кластера. Любой с доступом к etcd — компрометация ноды, украденный бэкап — читает каждый секрет в кластере как открытый текст. Шифрование at rest по умолчанию выключено; нужно явно настроить EncryptionConfiguration, чтобы API-сервер шифровал перед записью в etcd. И даже тогда есть ловушка: включение не перешифровывает существующие секреты. Надо переписать каждый Secret (например, kubectl get secrets -A -o json | kubectl replace -f -) после включения, иначе старые значения остаются открытыми. На проде шифрование подкрепляют KMS-провайдером (AWS KMS, GCP KMS), чтобы ключ жил вне кластера.
| Убеждение | Реальность | Последствие |
|---|---|---|
| «base64 прячет значение» | Обратимо за <1с, без ключа | Утёкший манифест = утёкший пароль |
| «etcd шифрует мои Secret’ы» | По умолчанию открытый текст | Кража бэкапа etcd = полный дамп секретов |
| «Я включил шифрование» | Старые Secret’ы не перешифрованы | Уже существующие значения остаются открытыми |
| «Env-переменные нормально» | Утечка через crash dump и /proc | Секреты индексируются в трекерах ошибок |
Env-переменные vs файловые маунты: стиль инъекции важен
Когда секрет уже снаружи образа, инъектировать его можно двумя способами, и выбор имеет реальный вес для безопасности. Переменные окружения просты — process.env.DB_PASSWORD — но они утекают. Их наследует каждый дочерний процесс, который ты порождаешь, их можно прочитать в /proc/<pid>/environ всем с нужным доступом, и убийственное: crash dump захватывает всё окружение целиком. Observability-SDK (Sentry, Datadog) рутинно сериализуют состояние процесса при ошибке, так что стектрейс оказывается с твоим живым API-ключом, проиндексированным открытым текстом в стороннем лог-хранилище. Многие фреймворки ещё и печатают окружение на странице фатальной ошибки.
Файловые маунты — безопаснее по умолчанию. Секрет ложится файлом (например, /run/secrets/db-password, режим 0400, на tmpfs, который никогда не попадает на диск). Его нет в окружении, его не наследуют дочерние процессы, его нет в /proc/.../environ. Решающий операционный выигрыш: файловые маунты обновляются на месте при ротации секрета, тогда как env-переменные заморожены на старте контейнера и для нового значения нужен рестарт пода. Для креденшела, который ротируешь каждые 30–90 дней, эта разница — между rolling restart и тихой подменой на месте.
Почему это работает
Env-переменные кажутся безопасными, потому что невидимы в коде. Но «невидимо мне» — не «невидимо атакующему». Ровно те свойства, что делают env-переменные удобными — глобальность для процесса, наследование детьми, дамп при краше — это и есть свойства, которые их сливают. Файл, который ты читаешь один раз и больше не экспортируешь, имеет куда меньший радиус взрыва.
Реальный инструментарий: тяни из менеджера, шифруй до коммита
Ручное управление Secret’ами не масштабируется. Продакшен-ответ — держать источник истины в выделенном менеджере секретов (HashiCorp Vault, AWS/GCP Secrets Manager) и тянуть из него при деплое/рантайме:
- External Secrets Operator (ESO) — контроллер, который следит за внешним менеджером и синхронизирует значения в нативные Kubernetes Secret по
refreshInterval. Манифесты ссылаются наExternalSecret, а не на сырое значение. - Secrets Store CSI Driver — монтирует секреты из менеджера прямо в под файлами, минуя etcd целиком; значение живёт только в tmpfs пода.
- Sealed Secrets — для GitOps, где надо что-то закоммитить: он асимметрично шифрует секрет так, что расшифровать может только контроллер внутри кластера. Теперь блоб в git реально безопасно коммитить, в отличие от base64-Secret.
- Vault dynamic secrets — Vault генерирует короткоживущий per-app креденшел по запросу (юзер БД, который существует час, потом авто-отзывается). Нечему долгоживущему утекать; украденный креденшел истекает раньше, чем станет полезен.
GitOps-команде надо закоммитить секретный конфиг в приватный репозиторий, чтобы Argo CD его применил. Выбери подход.
В поле data у Kubernetes Secret видно cGFzc3dvcmQ=. Насколько защищено значение?
Почему файловые маунты секретов часто предпочитают переменным окружения на проде?
Расставь решения по работе с секретами, которые принимает сеньор, от самого безопасного:
- 1 Никогда не запекай секрет в образ — ни ENV, ни COPY .env, ни --build-arg
- 2 Держи источник истины в менеджере секретов (Vault / облачный Secrets Manager)
- 3 Тяни при деплое/рантайме через ESO или CSI-драйвер (или sealed для GitOps)
- 4 Предпочитай файловые маунты env-переменным, чтобы сжать поверхность утечки
- 5 Ротируй по расписанию; динамические короткоживущие креды убирают долгоживущие секреты вовсе
- 01Коллега говорит: «наши Kubernetes Secret безопасны, потому что значения закодированы в base64». Поправь его точно.
- 02Почему многие команды инъектируют секреты файловыми маунтами, а не переменными окружения, и что это даёт?
Безопасно завести секреты в задеплоенное приложение начинается с одного правила: они входят при деплое или рантайме, а не запекаются в образ, потому что слои образа кэшируются, пушатся в реестр и воспроизводятся через docker history — секрет, удалённый в позднем слое, всё ещё живёт в раннем. Ловушка Kubernetes Secret в том, что base64 — это кодирование, а не шифрование: утёкший манифест это утёкший пароль, и по умолчанию значение лежит в etcd открытым текстом, так что надо явно включить шифрование at rest (на KMS) и потом перешифровать существующие Secret’ы. Стиль инъекции тоже важен — переменные окружения утекают через дочерние процессы, /proc и crash dump, которые индексируются трекерами ошибок, тогда как файловые маунты сжимают эту поверхность и, что критично, ротируются на месте без рестарта. На масштабе ответ — реальный инструментарий: держи источник истины в Vault или облачном Secrets Manager, тяни в рантайме через External Secrets Operator или CSI-драйвер, шифруй-до-коммита через Sealed Secrets для GitOps и предпочитай короткоживущие динамические креденшелы, чтобы украденный секрет истёк раньше, чем станет полезен.