Хранение паролей в Docker и Kubernetes: секреты, переменные окружения и Vault | AdminWiki

Хранение паролей в Docker и Kubernetes: секреты, переменные окружения и Vault

15 сентября 2026 14 мин. чтения

Стандартный 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 restartaudit logSelf-managed кластеры
Secrets с внешним KMSДа, ключ данных завёрнут в KMSРотация ключа KMS, секреты вручнуюaudit log плюс журнал облакаEKS, GKE, AKS, требования комплаенса
HashiCorp VaultДа, барьерное шифрованиеДинамические креды с TTLAudit 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.

Поделиться:
Сохранить гайд? В закладки браузера