Стандартный Kubernetes Secret хранит пароль в base64 и отдаёт его любому, у кого есть право get на секреты в неймспейсе. Контейнер Docker с флагом -e DB_PASSWORD=... показывает тот же пароль в выводе docker inspect и в /proc/1/environ. В обоих случаях это передача значения в открытом виде, а не защита.
Рабочая схема выглядит так: включить шифрование etcd через EncryptionConfiguration с провайдером aescbc или внешним KMS, для чувствительных сред поднять HashiCorp Vault с Kubernetes Auth и Vault Agent Injector, а обновление паролей выполнять через rolling update или динамические учётные данные с TTL. Ниже команды, манифесты и проверки для каждого варианта.
Проверить, где именно пароль лежит в открытом виде в вашей инфраструктуре, можно за 10 минут: команды для etcd, docker inspect и kube-apiserver приведены в первых трёх разделах. Общая логика везде одна: сначала закрыть доступ к хранилищу, затем включить шифрование на диске, и только потом автоматизировать выдачу и ротацию.
Почему base64 в Kubernetes Secrets не даёт шифрования
Secret в Kubernetes - это объект API, где каждое значение лежит в поле data и закодировано base64. Кодирование обратимо одной командой:
kubectl get secret db-secret -o jsonpath='{.data.password}' | base64 -d
Тот же результат даёт просмотр манифеста: значения в data декодируются глазами, если знать, что смотреть. base64 нужен только для того, чтобы произвольные байты (TLS-сертификаты, бинарные ключи) умещались в текстовый YAML или JSON. Ключа здесь нет, алгоритм общеизвестен, поэтому защита нулевая.
Kubelet монтирует секреты в под как tmpfs, то есть на диск ноды файлы не попадают. Проблема в другом месте: kube-apiserver по умолчанию пишет объекты в etcd без шифрования, и в записи /registry/secrets// пароль лежит читаемым текстом внутри protobuf. Доступ к etcd равносилен доступу ко всем секретам всех неймспейсов сразу.
Проверьте, кто в кластере имеет право читать секреты:
kubectl auth can-i get secrets --as=system:serviceaccount:default:default -n prod kubectl get clusterrolebindings -o json | grep -i secret
Роль по умолчанию не должна давать get или list на secrets вне целевого неймспейса. Для подов, которым секреты не нужны, отключайте автомонтирование токена: в манифесте Deployment ставится automountServiceAccountToken: false. Подробный разбор типов секретов, работы с kubectl, YAML и генераторами есть в полном практическом руководстве по Kubernetes Secrets.
Как проверить, зашифрован ли etcd
Самый быстрый способ - посмотреть, передан ли kube-apiserver флаг --encryption-provider-config. На self-managed кластере манифест static pod лежит на control plane:
grep -i encryption /etc/kubernetes/manifests/kube-apiserver.yaml
Если строки нет, шифрования нет. Вторая проверка - чтение записи напрямую из etcd. Команда выполняется на control plane с сертификатами etcd:
ETCDCTL_API=3 etcdctl \ --endpoints=https://127.0.0.1:2379 \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ get /registry/secrets/prod/db-secret
Читаемый фрагмент вида k8s:protobuf:...password означает, что шифрование выключено. Если вывод похож на бинарный мусор без узнаваемых строк, шифрование работает. Учтите: для проверки нужны сертификаты etcd, а на managed-кластерах доступа к etcd у вас нет по определению.
В облаках шифрование включается отдельным флагом, и его состояние проверяется через API провайдера:
aws eks describe-cluster --name my-cluster --query "cluster.encryptionConfig" gcloud container clusters describe my-cluster --format="value(databaseEncryption)" az aks show --resource-group rg --name my-cluster --query "securityProfile"
В EKS envelope encryption с ключом KMS включается при создании или обновлении кластера и покрывает только секреты. В GKE это application-layer secrets encryption с Cloud KMS, в AKS - KMS-шифрование etcd. Значение null или пустой массив в выводе означает, что зашифрованы только диски нод, а сами секреты в etcd лежат открыто.
Переменные окружения в Docker: удобно, но небезопасно
Переменные окружения - самый распространённый способ передачи пароля в контейнер, и самый уязвимый. Значение видно всем, у кого есть доступ к Docker API или к процессам внутри контейнера:
docker run -d --name api -e DB_PASSWORD=S3cretPass myapp:1.0
docker inspect api --format '{{json .Config.Env}}'
docker exec api env | grep DB_PASSWORD
docker exec api cat /proc/1/environ | tr '\0' '\n' | grep DB_PASSWORD
Доступ к сокету /var/run/docker.sock и так равносилен root на хосте, поэтому docker inspect не добавляет новых рисков сам по себе. Опаснее другое: переменные наследуются дочерними процессами, попадают в дампы памяти, в отчёты мониторинга и в debug-логи приложений. Агент APM может отправить окружение целиком на внешний сервер. Флаг ENV в Dockerfile запекает пароль в слой образа, и он остаётся в docker history даже после смены значения:
docker history --no-trunc myapp:1.0
Правило простое: переменные окружения годятся для несекретных параметров (уровень логирования, имя хоста БД, режим работы). Пароли, токены и ключи передавайте файлом с правами 0400 или через механизм секретов.
Docker Secrets в Swarm: пошаговый пример
В режиме Swarm секрет создаётся в Raft-хранилище менеджера и передаётся только на те ноды, где запущена задача сервиса. Создание и подключение:
printf 'S3cretPass' | docker secret create db_password - docker secret ls docker service create --name api --secret db_password myapp:1.0
В compose-файле для Swarm секрет объявляется на верхнем уровне и подключается к сервису:
services:
api:
image: myapp:1.0
secrets:
- db_password
secrets:
db_password:
external: true
Внутри контейнера файл появляется по пути /run/secrets/db_password в tmpfs с правами 0444, и увидеть его может только процесс задачи. Приложение читает пароль при старте из файла. Передача между менеджерами и воркерами идёт по TLS, а в Raft-логе секреты лежат в открытом виде, пока не включён autolock:
docker swarm update --autolock=true
Autolock требует ввода ключа после перезапуска менеджера, поэтому ключ храните вне ноды. Важное ограничение: механизм работает только в Swarm. Если вы запускаете docker-compose на одной машине без swarm init, директива external для секрета не сработает.
Альтернативы для docker-compose без Swarm
Docker Compose поддерживает секреты из файла: значение монтируется в /run/secrets, но не шифруется, а просто читается с диска хоста:
services:
api:
image: myapp:1.0
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password
Файл готовится заранее и закрывается правами:
install -m 0400 -o 1000 -g 1000 /dev/null ./secrets/db_password printf 'S3cretPass' > ./secrets/db_password chmod 0400 ./secrets/db_password
Владелец файла должен совпадать с UID пользователя внутри контейнера, иначе приложение не прочитает секрет. На хостах с SELinux добавьте суффикс :Z к монтированию, чтобы контейнер получил доступ к файлу через контекст. Эквивалентный вариант - bind-mount каталога только для чтения:
volumes: - ./secrets:/run/secrets:ro
Файлы .env удобны, но опасны: docker compose config печатает подставленные значения, а сам файл часто попадает в репозиторий. Держите .env в .gitignore и храните в нём только несекретные значения по умолчанию. Пароли - в отдельном каталоге secrets, который также исключён из репозитория.
Шифрование etcd и внешние KMS в Kubernetes
Шифрование секретов на уровне API включается конфигурационным файлом, который передаётся kube-apiserver флагом --encryption-provider-config. Порядок провайдеров в списке определяет, чем пишутся новые объекты: шифрует первый провайдер в списке, а читать apiserver умеет всеми перечисленными. Провайдер identity в конце списка позволяет читать старые незашифрованные записи.
Ключ шифрования хранится вне etcd, на файловой системе control plane, с правами 0600 и владельцем root. Копию ключа нужно хранить в менеджере секретов или в KMS: без него зашифрованные секреты не восстановить, а бэкап etcd без ключа бесполезен. Схема резервного копирования и шифрования бэкапов разобрана в материале про шифрование данных при передаче и хранении.
Пример EncryptionConfiguration для aescbc
Ключ генерируется на control plane и кодируется в base64. Длина ключа для aescbc - ровно 32 байта:
head -c 32 /dev/urandom | base64
Полученную строку подставьте в secret. Файл /etc/kubernetes/enc/encryption-config.yaml:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
- configmaps
providers:
- aescbc:
keys:
- name: key1
secret:
- identity: {}
Дальше файл монтируется в static pod kube-apiserver как hostPath, а в командную строку добавляется флаг:
--encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml
После перезапуска apiserver новые и обновлённые секреты пишутся в etcd зашифрованными, но старые записи остаются в открытом виде. Их нужно перезаписать:
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
Выбор алгоритма: aescbc работает стабильно, но не даёт аутентификации шифротекста. aegcm быстрее, однако требует ротации ключа каждые 200 000 записей, а при превышении лимита запись падает с ошибкой. secretbox (XSalsa20-Poly1305) не имеет этого ограничения и подходит для кластеров с высокой частотой обновления секретов. Ротация ключа делается добавлением нового ключа в начало списка keys: тогда новые записи шифруются им, а старые читаются предыдущим.
Подключение внешнего KMS: пример для AWS KMS
Внешний провайдер убирает главный недостаток локального ключа - необходимость хранить его на диске control plane. Kube-apiserver общается с KMS-плагином по unix-сокету через gRPC, а плагин, в свою очередь, вызывает API облачного сервиса:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
name: aws-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
timeout: 3s
cachesize: 1000
- identity: {}
Плагин запускается как отдельный процесс systemd на control plane и должен быть доступен до старта apiserver. Ключ KMS создаётся в облаке, а Kubernetes хранит только завёрнутый ключ шифрования данных. Для self-managed кластеров на AWS это aws-encryption-provider, для AKS используется KMS-плагин Azure, для GKE - плагин Cloud KMS. В EKS envelope encryption настраивается на уровне кластера, и сокет с конфигурацией вручную не редактируется.
Два практических ограничения. Первое: расшифровка при холодном кэше идёт в облако, поэтому таймаут и задержки при старте подов возможны, ставьте timeout 3s и не отключайте кэш. Второе: если KMS недоступен и кэш пуст, apiserver не сможет прочитать секреты, и часть workloads не поднимется. Ротация ключей KMS в AWS выполняется автоматически раз в год и не требует перешифрования данных, но конфигурацию с идентификатором ключа менять нужно осознанно.
HashiCorp Vault: централизованное управление секретами
Vault хранит секреты в зашифрованном виде, где ключ шифрования защищён барьерным ключом, ведёт аудит каждого обращения и умеет выдавать динамические учётные данные с ограниченным сроком жизни. Кластер после старта запечатан, и для распечатывания нужны ключи unseal либо настроенный auto-unseal через облачный KMS.
Для Kubernetes есть два рабочих способа интеграции: Vault Agent Injector, который добавляет в под sidecar-контейнер, и Secrets Store CSI Driver с провайдером Vault, который монтирует секреты как тома. Первый вариант гибче в шаблонах, второй ближе к нативному поведению Kubernetes. Сравнение Vault с AWS Secrets Manager и Azure Key Vault по критериям безопасности, RBAC и ротации приведено в отдельном разборе внешних хранилищ.
Настройка Vault для Kubernetes Auth
Сначала включается метод аутентификации и указывается адрес API кластера. Vault проверяет JWT пода через TokenReview, поэтому ему нужно право на serviceaccounts/token в целевых неймспейсах:
vault auth enable kubernetes vault write auth/kubernetes/config \ kubernetes_host="https://kubernetes.default.svc"
Затем описывается политика доступа. Файл myapp-policy.hcl:
path "secret/data/myapp/db" {
capabilities = ["read"]
}
vault policy write myapp-policy myapp-policy.hcl
Роль связывает ServiceAccount пода с политикой и задаёт срок жизни токена:
vault write auth/kubernetes/role/myapp \ bound_service_account_names=myapp \ bound_service_account_namespaces=prod \ policies=myapp-policy \ ttl=24h
Если Vault развёрнут вне кластера, добавьте в конфиг kubernetes_ca_cert с сертификатом кластера и disable_local_ca_jwt=true. Ограничение bound_service_account_namespaces и bound_service_account_names критично: без них роль примет токен любого пода.
Пример интеграции с приложением через Vault Agent
Injector работает как mutating webhook и добавляет в под контейнер vault-agent и init-контейнер. Под получает секрет через аннотации:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "myapp"
vault.hashicorp.com/agent-inject-secret-db: "secret/data/myapp/db"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "secret/data/myapp/db" -}}
{{ .Data.data.password }}
{{- end }}
spec:
serviceAccountName: myapp
containers:
- name: app
image: myapp:1.0
Файл появляется по пути /vault/secrets/db в tmpfs, приложение читает его при старте и не хранит пароль ни в манифесте, ни в образе. Агент продлевает токен и перерисовывает файл при изменении значения в Vault. Чтобы приложение перечитало конфигурацию без перезапуска, добавьте команду после обновления:
vault.hashicorp.com/agent-inject-command-db: "kill -HUP 1"
Динамические учётные данные для базы настраиваются через database secrets engine. Vault создаёт пользователя в СУБД на время TTL и удаляет его после истечения срока:
vault secrets enable database
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
allowed_roles="app" \
connection_url="postgresql://{{username}}:{{password}}@postgres:5432/appdb?sslmode=disable" \
username="vault_admin" password="vault_admin_pass"
vault write database/roles/app \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
default_ttl=1h max_ttl=24h
Статичного пароля в конфигурации приложения при такой схеме нет вообще: логин и пароль генерируются на лету, а отзыв происходит автоматически. Приложение обязано уметь переподключаться с новыми кредами, иначе после истечения TTL соединения начнут падать.
Ротация секретов без простоя
Смена пароля ломает работающий сервис, если о новом значении знает только часть реплик. Порядок действий зависит от того, как приложение читает секрет: из переменных окружения, из смонтированного файла или из внешнего хранилища по запросу.
Ротация Kubernetes Secret с rolling update
Секрет обновляется идемпотентно, без ручного редактирования YAML в репозитории:
kubectl create secret generic db-secret \ --from-literal=password='NewS3cretPass' \ --dry-run=client -o yaml | kubectl apply -f - kubectl rollout restart deployment/myapp kubectl rollout status deployment/myapp
Rolling update гарантирует, что новые поды поднимутся и пройдут readiness-пробу до остановки старых. Для этого в Deployment нужны strategy: RollingUpdate, maxUnavailable: 0, maxSurge: 1, минимум две реплики и корректная проба готовности. Если секрет объявлен с immutable: true, обновить его нельзя - придётся удалить объект и создать заново, поэтому для ротируемых значений immutable не подходит.
Важный нюанс: смонтированный через volume секрет kubelet обновляет сам, но с задержкой до примерно минуты, а переменные окружения, полученные через envFrom или valueFrom, не обновляются никогда. Если приложение читает только переменные окружения, перезапуск пода обязателен. Готовые примеры подключения Secret к поду и различия между ConfigMap и Secret разобраны в руководстве по централизованной конфигурации.
Для баз данных применяйте двухпользовательскую схему: новый пароль создаётся заранее, оба пользователя какое-то время работают параллельно, после обновления всех реплик старый пароль отзывается. Такой порядок исключает окно, в котором часть подов не может подключиться.
Динамические секреты в Vault: ротация без перезапуска
При TTL в один час и max_ttl в сутки пароль меняется автоматически, и ручной rollout не нужен. Схема работы: Vault Agent перерисовывает файл /vault/secrets/db, приложение ловит изменение файла и открывает новый пул соединений, а старые кредиты Vault отзывает по истечении lease.
Требования к приложению при такой схеме: переподключение без падения процесса, обработка ошибки аутентификации как временной и запрет кэшировать пароль дольше TTL. Для приложений, которые читают конфигурацию один раз при старте, используйте сигнал через vault.hashicorp.com/agent-inject-command-db или отдельный sidecar-контейнер, который следит за файлом и вызывает reload. Подход хорошо ложится на сервисы в managed-кластере, где инфраструктурные задачи закрывает провайдер, например управляемый Kubernetes в Timeweb Cloud.
Сравнение подходов: что выбрать для вашего сценария
| Подход | Шифрование на диске | Ротация | Аудит | Когда подходит |
|---|---|---|---|---|
| Переменные окружения (Docker, Pod) | Нет | Только с перезапуском контейнера | Нет | Локальная разработка, несекретные параметры |
| Kubernetes Secrets без шифрования etcd | Нет, только base64 | Обновление плюс rollout restart | Только audit log apiserver | Внутренние кластеры с жёстко ограниченным доступом к etcd |
| Secrets с aescbc | Да, ключ в файле на control plane | Обновление плюс rollout restart | audit log | Self-managed кластеры |
| Secrets с внешним KMS | Да, ключ данных завёрнут в KMS | Ротация ключа KMS, секреты вручную | audit log плюс журнал облака | EKS, GKE, AKS, требования комплаенса |
| HashiCorp Vault | Да, барьерное шифрование | Динамические креды с TTL | Audit devices по каждому запросу | Мультикластерные и мультиоблачные среды |
| External Secrets Operator или Secrets Store CSI | Наследует шифрование бэкенда | Автообновление по интервалу синхронизации | Зависит от бэкенда | GitOps, единый источник истины в облаке |
Практические рекомендации по размеру инфраструктуры. Для одного небольшого кластера достаточно Secrets с включённым шифрованием etcd на aescbc или secretbox и ограниченным RBAC: это закрывает основную часть рисков без дополнительных сервисов. Для нескольких кластеров и команд с разными правами берите Vault или External Secrets Operator с облачным бэкендом, чтобы не раздавать доступ к etcd и не дублировать секреты в каждом неймспейсе. Для облачных сред с требованием комплаенса включайте KMS-провайдер и храните ключи там, где их ротация автоматизирована. Планирование таких кластеров целиком, включая HA control plane, RBAC и storage, описано в руководстве по production-кластерам.
Типичные ошибки и как их избежать
Большинство утечек паролей в контейнерных средах связано с шестью повторяющимися ошибками. Ниже каждая с конкретным решением.
- Пароль в переменной окружения. Виден в docker inspect, /proc/1/environ, дампах и логах. Решение: файл с правами 0400 или Docker Secrets в Swarm, для Kubernetes - volume с Secret и чтением из файла.
- base64 как защита. Декодируется одной командой. Решение: включить шифрование etcd, а для долгоживущих паролей перейти на Vault с динамическими кредами.
- Секреты в Git. .env, values.yaml с паролями и docker-compose с литералами попадают в историю коммитов. Решение: .gitignore для каталога secrets, сканирование репозитория через gitleaks или trufflehog в pre-commit и CI, ротация всего, что уже было закоммичено.
- Избыточные права ServiceAccount. Под с правами на list secrets в неймспейсе читает чужие секреты. Решение: отдельный ServiceAccount на приложение, automountServiceAccountToken: false там, где токен не нужен, точечные Role с resourceNames.
- Пароль в аргументах командной строки. Значения из command и args видны в ps внутри контейнера и в описании пода. Решение: передавать секрет через файл или переменную, а не через флаг командной строки.
- Отсутствие ротации и аудита. Пароль живёт годами, об утечке узнают по инциденту. Решение: TTL для динамических кредов, плановая ротация статических, audit log apiserver и audit device в Vault с отправкой журналов во внешнее хранилище.
Отдельно про ключи к внешним API. Токены к десяткам сервисов удобнее не размножать по .env-файлам и CI-переменным, а держать в одном шлюзе с раздельными ключами и лимитами. Так устроен агрегатор моделей AiTunnel: единый интерфейс и управление ключами и бюджетами снижают количество мест, где вообще лежит долгоживущий секрет.
Проверьте прямо сейчас три вещи в своём кластере: есть ли флаг --encryption-provider-config у kube-apiserver, кто имеет право get на secrets, и что покажет kubectl get secret -o jsonpath по любому рабочему паролю. Если пароль читается без дополнительных шагов, начните с шифрования etcd и перезаписи существующих секретов, а затем переводите чувствительные сервисы на Vault с ротацией по TTL.