Введение
В предыдущих статьях мы рассмотрели SLI и SLO, управление инцидентами, наблюдаемость (observability), хаос-инжиниринг, планирование ёмкости и GitOps. Мы заложили прочную основу для эксплуатации надёжных сервисов в Kubernetes, но есть одна тема, которую мы ещё не затрагивали, — и она способна либо укрепить, либо полностью разрушить вашу систему безопасности: управление секретами (secrets management).
Если вы когда-нибудь коммитили пароль от базы данных в Git-репозиторий, прописывали API-ключ прямо в манифесте деплоя или полагались на Kubernetes Secrets, считая их «зашифрованными» — вы знаете, чем это чревато. Секреты встречаются повсюду в современной инфраструктуре, и небрежное обращение с ними — один из самых быстрых способов оказаться в новостях по самым нежелательным причинам.
В этой статье мы разберём, почему Kubernetes Secrets сами по себе недостаточны, а затем пройдёмся по инструментам и стратегиям, которые действительно решают проблему: Sealed Secrets, External Secrets Operator, HashiCorp Vault, ротация секретов, SOPS, политики RBAC и аудит-логирование. К концу у вас сложится чёткое понимание того, какой подход подходит именно вам и как его реализовать.
Приступим.
Проблема с Kubernetes Secrets
В Kubernetes есть встроенный ресурс Secret, который на первый взгляд кажется готовым решением. Вы создаёте Secret, ссылаетесь на него в спецификации Pod, и приложение получает значение в виде переменной окружения или примонтированного файла. Всё просто.
Но есть подвох. Kubernetes Secrets кодируются в base64, а не шифруются. Base64 — это обратимое кодирование, а не механизм защиты. Любой, у кого есть доступ к манифесту или к API-серверу, может мгновенно декодировать ваши секреты:
# Создание "секрета" в Kubernetes
apiVersion: v1
kind: Secret
metadata:
name: my-app-secrets
namespace: default
type: Opaque
data:
# Это просто base64, НЕ шифрование
database-password: cGFzc3dvcmQxMjM=
api-key: c3VwZXItc2VjcmV0LWtleQ==
# Любой может декодировать это мгновенно
$ echo "cGFzc3dvcmQxMjM=" | base64 -d
password123
$ echo "c3VwZXItc2VjcmV0LWtleQ==" | base64 -d
super-secret-key
Проблемы на этом не заканчиваются:
-
Хранение в etcd: по умолчанию секреты хранятся в etcd в открытом виде. Любой, кто получит доступ к хранилищу etcd, сможет прочитать все секреты кластера.
-
Пробелы в RBAC: конфигурация RBAC по умолчанию во многих кластерах слишком разрешительна. Если сервисный аккаунт может перечислять секреты в пространстве имён (namespace), он может прочитать их все.
-
Утечка через Git: коммитить манифесты Secret в Git без раскрытия значений невозможно, что ломает GitOps-процессы.
-
Отсутствие журнала аудита: по умолчанию Kubernetes не логирует, кто обращался к значению секрета — только кто его перечислял или отслеживал.
-
Отсутствие ротации: встроенного механизма ротации секретов нет. Вы меняете значение, перезапускаете поды — и надеетесь, что ничего не сломается.
-
Отсутствие шифрования в состоянии покоя: если явно не настроить шифрование данных etcd в состоянии покоя (encryption at rest), секреты лежат в открытом тексте.
Шифрование в состоянии покоя можно включить в API-сервере с помощью EncryptionConfiguration:
# /etc/kubernetes/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {}
Это защищает данные в etcd в состоянии покоя, но не решает ни проблему с Git, ни проблему с ротацией, ни проблему с аудитом. Для этого нужны специализированные инструменты.
Sealed Secrets
Bitnami Sealed Secrets — одно из самых простых решений для задачи «хочу хранить секреты в Git». Идея элегантна: вы шифруете секреты открытым ключом, расшифровать который способен только контроллер в кластере. Зашифрованная версия (SealedSecret) безопасна для коммита в Git, потому что только контроллер, работающий в вашем кластере, владеет закрытым ключом для расшифровки.
Для начала установите контроллер Sealed Secrets в кластер:
# Установка контроллера
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm repo update
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system \
--set-string fullnameOverride=sealed-secrets-controller
Затем установите CLI kubeseal на рабочую станцию:
# Установка kubeseal
brew install kubeseal
# Или скачать напрямую
wget https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.3/kubeseal-0.27.3-linux-amd64.tar.gz
tar -xvf kubeseal-0.27.3-linux-amd64.tar.gz
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
Рабочий процесс выглядит так: вы создаёте обычный Kubernetes Secret, а затем запечатываете его:
# Создать обычный секрет (НЕ коммитить этот файл)
kubectl create secret generic my-app-secrets \
--namespace default \
--from-literal=database-password=password123 \
--from-literal=api-key=super-secret-key \
--dry-run=client -o yaml > my-secret.yaml
# Запечатать с помощью открытого ключа кластера
kubeseal --format yaml < my-secret.yaml > my-sealed-secret.yaml
# Удалить незашифрованную версию
rm my-secret.yaml
Полученный SealedSecret можно безопасно коммитить:
# my-sealed-secret.yaml - этот файл можно коммитить в Git
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: my-app-secrets
namespace: default
spec:
encryptedData:
database-password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
api-key: AgCtr8HZFBOGZ9Nk+HrKPHRf7A6WkXN0...
template:
metadata:
name: my-app-secrets
namespace: default
type: Opaque
Когда контроллер Sealed Secrets обнаруживает этот ресурс в кластере, он расшифровывает его и создаёт обычный Kubernetes Secret, которым поды могут пользоваться в штатном режиме.
Несколько важных моментов о Sealed Secrets:
-
Область действия (Scope): по умолчанию SealedSecret привязан к конкретному имени и пространству имён. Чтобы изменить имя или namespace, нужно перезапечатать секрет.
-
Ротация ключей: по умолчанию контроллер ротирует ключи шифрования каждые 30 дней. Старые ключи сохраняются, чтобы существующие SealedSecrets оставались расшифруемыми.
-
Резервное копирование ключей: если закрытый ключ контроллера будет утерян (например, при удалении namespace без резервной копии), расшифровать все SealedSecrets станет невозможно. Делайте резервные копии ключей.
-
Повторное шифрование: после ротации ключей существующие SealedSecrets продолжают работать, но используют старый ключ. Периодически их стоит перезапечатывать новым ключом.
Вот как выполнить резервное копирование и восстановление ключей контроллера:
# Резервное копирование ключей запечатывания
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
-o yaml > sealed-secrets-keys-backup.yaml
# Храните резервную копию в безопасном месте (не в Git!)
# Используйте менеджер паролей, облачный KMS или сейф
# Восстановить ключи в новый кластер
kubectl apply -f sealed-secrets-keys-backup.yaml
# Перезапустить контроллер, чтобы он подхватил восстановленные ключи
kubectl rollout restart deployment/sealed-secrets-controller -n kube-system
Sealed Secrets хорошо подходит, когда нужно простое автономное решение без зависимости от внешних сервисов. Оно отлично вписывается в GitOps, поскольку зашифрованные манифесты живут прямо в репозитории. Главный недостаток в том, что оно решает только задачу «секреты в Git» и не помогает с ротацией, централизованным управлением или динамическими секретами.
External Secrets Operator
External Secrets Operator (ESO) придерживается иного подхода. Вместо шифрования секретов и хранения их в Git, он синхронизирует секреты из внешнего хранилища (например, AWS Secrets Manager, HashiCorp Vault, Google Secret Manager или Azure Key Vault) в Kubernetes Secrets. В Git-репозитории хранятся только ссылки на секреты, а не сами значения.
Установите ESO через Helm:
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace \
--set installCRDs=true
Архитектура состоит из трёх основных компонентов:
-
SecretStore / ClusterSecretStore: настраивает подключение к внешнему провайдеру секретов.
-
ExternalSecret: объявляет, какие секреты нужно получить и как их отобразить в Kubernetes Secrets.
-
Оператор: следит за ресурсами ExternalSecret и создаёт/обновляет Kubernetes Secrets.
Вот пример с AWS Secrets Manager в качестве бэкенда. Сначала настройте SecretStore:
# cluster-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
namespace: external-secrets
Затем создайте ExternalSecret, который ссылается на секрет в AWS:
# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-app-secrets
namespace: default
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-app-secrets
creationPolicy: Owner
template:
type: Opaque
data:
database-password: "{{ .database_password }}"
api-key: "{{ .api_key }}"
data:
- secretKey: database_password
remoteRef:
key: production/my-app
property: database_password
- secretKey: api_key
remoteRef:
key: production/my-app
property: api_key
Этот манифест ExternalSecret полностью безопасен для коммита в Git, поскольку содержит только ссылки, а не значения. Оператор сам получает фактические значения из AWS Secrets Manager и создаёт Kubernetes Secret.
ESO можно также использовать с HashiCorp Vault в качестве бэкенда:
# vault-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: external-secrets-sa
namespace: external-secrets
# vault-external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-app-vault-secrets
namespace: default
spec:
refreshInterval: 15m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: my-app-secrets
creationPolicy: Owner
data:
- secretKey: database-password
remoteRef:
key: secret/data/production/my-app
property: database_password
- secretKey: api-key
remoteRef:
key: secret/data/production/my-app
property: api_key
Параметр refreshInterval — одна из ключевых возможностей ESO. Оператор периодически проверяет внешнее хранилище и обновляет Kubernetes Secret, если значение изменилось. Именно это является основой для автоматической ротации секретов, о которой мы поговорим далее.
ESO — хороший выбор, когда у вас уже есть централизованное хранилище секретов и вы хотите перенести их в Kubernetes без ручных шагов. Он хорошо вписывается в GitOps, поскольку в Git хранятся только ссылки, и поддерживает практически все крупные облачные провайдеры и инструменты управления секретами.
Интеграция HashiCorp Vault
HashiCorp Vault — тяжеловес в мире управления секретами. Он обеспечивает централизованное хранение секретов, генерацию динамических секретов, шифрование как сервис и детальное аудит-логирование. Хотя ESO умеет синхронизировать секреты из Vault в Kubernetes, Vault также предлагает нативную интеграцию через Vault Agent Injector и CSI-провайдер.
Vault Agent Injector
Vault Agent Injector использует мутирующий вебхук (mutating webhook) для внедрения сайдкар-контейнера Vault Agent в ваши поды. Агент берёт на себя аутентификацию, получает секреты из Vault и записывает их в общий том (volume), из которого приложение может их читать.
Установите Helm-чарт Vault с включённым инжектором:
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update
helm install vault hashicorp/vault \
--namespace vault \
--create-namespace \
--set "injector.enabled=true" \
--set "server.dev.enabled=false" \
--set "server.ha.enabled=true" \
--set "server.ha.replicas=3"
Настройте метод аутентификации Kubernetes в Vault, чтобы поды могли авторизоваться:
# Включить Kubernetes auth в Vault
vault auth enable kubernetes
# Настроить работу с Kubernetes API
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443" \
token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# Создать политику для приложения
vault policy write my-app-policy - <<EOF
path "secret/data/production/my-app" {
capabilities = ["read"]
}
EOF
# Создать роль, которая привязывает политику к сервисному аккаунту Kubernetes
vault write auth/kubernetes/role/my-app \
bound_service_account_names=my-app-sa \
bound_service_account_namespaces=default \
policies=my-app-policy \
ttl=1h
Теперь добавьте аннотации к вашему деплою, чтобы использовать инжектор:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "my-app"
vault.hashicorp.com/agent-inject-secret-db-password: "secret/data/production/my-app"
vault.hashicorp.com/agent-inject-template-db-password: |
{{- with secret "secret/data/production/my-app" -}}
{{ .Data.data.database_password }}
{{- end -}}
vault.hashicorp.com/agent-inject-secret-api-key: "secret/data/production/my-app"
vault.hashicorp.com/agent-inject-template-api-key: |
{{- with secret "secret/data/production/my-app" -}}
{{ .Data.data.api_key }}
{{- end -}}
spec:
serviceAccountName: my-app-sa
containers:
- name: my-app
image: my-app:latest
# Секреты доступны по путям /vault/secrets/db-password и /vault/secrets/api-key
Vault CSI Provider
CSI-провайдер (Container Storage Interface) монтирует секреты как тома с использованием драйвера Secrets Store CSI. Этот подход легче, чем Agent Injector, поскольку не требует сайдкара:
# Установить драйвер Secrets Store CSI
helm install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \
--namespace kube-system
# Установить Vault CSI Provider
helm install vault hashicorp/vault \
--namespace vault \
--set "injector.enabled=false" \
--set "csi.enabled=true"
# secret-provider-class.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: vault-my-app
namespace: default
spec:
provider: vault
parameters:
vaultAddress: "https://vault.vault.svc:8200"
roleName: "my-app"
objects: |
- objectName: "database-password"
secretPath: "secret/data/production/my-app"
secretKey: "database_password"
- objectName: "api-key"
secretPath: "secret/data/production/my-app"
secretKey: "api_key"
# При необходимости — синхронизировать также в Kubernetes Secret
secretObjects:
- secretName: my-app-secrets
type: Opaque
data:
- objectName: database-password
key: database-password
- objectName: api-key
key: api-key
# pod-with-csi.yaml
apiVersion: v1
kind: Pod
metadata:
name: my-app
namespace: default
spec:
serviceAccountName: my-app-sa
containers:
- name: my-app
image: my-app:latest
volumeMounts:
- name: secrets
mountPath: "/mnt/secrets"
readOnly: true
volumes:
- name: secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: "vault-my-app"
Vault — правильный выбор, когда вам нужны динамические секреты (например, учётные данные для базы данных, которые генерируются на лету и автоматически истекают), детализированные политики доступа, исчерпывающий аудит или шифрование как сервис. Компромисс — сложность. Vault — это распределённая система, которую нужно развернуть, обслуживать, распечатывать (unseal) и регулярно создавать резервные копии. Для небольших команд ESO с облачным хранилищем секретов может оказаться более разумным выбором.
Стратегии ротации секретов
Статичные секреты — это уязвимость. Чем дольше секрет существует без изменений, тем больше у злоумышленника времени, чтобы его обнаружить и использовать. Ротация секретов — это практика регулярной замены секретов новыми значениями, и она способна существенно улучшить вашу защищённость.
Зачем ротировать секреты?
-
Ограничить радиус поражения (blast radius): если секрет скомпрометирован, ротация ограничивает время, в течение которого злоумышленник может им воспользоваться.
-
Соответствие нормативам (Compliance): многие стандарты (SOC2, PCI-DSS, HIPAA) требуют регулярной ротации секретов.
-
Устранение устаревшего доступа: когда сотрудники покидают команду или сервисы выводятся из эксплуатации, их учётные данные должны перестать работать.
-
Эшелонированная защита (Defense in depth): даже если другие средства защиты дадут сбой, ротация ограничивает окно ущерба.
Автоматическая ротация с External Secrets Operator
Параметр refreshInterval в ESO — простейший способ реализовать ротацию. Если вы обновите секрет во внешнем хранилище, ESO подхватит новое значение при следующем цикле обновления:
# external-secret-with-rotation.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: rotating-secret
namespace: default
spec:
# Проверять новые значения каждые 15 минут
refreshInterval: 15m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: rotating-secret
creationPolicy: Owner
data:
- secretKey: database-password
remoteRef:
key: production/my-app/database
property: password
На стороне AWS автоматическую ротацию можно настроить с помощью Lambda-функции:
# Terraform для ротации в AWS Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
name = "production/my-app/database"
}
resource "aws_secretsmanager_secret_rotation" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
rotation_lambda_arn = aws_lambda_function.secret_rotation.arn
rotation_rules {
automatically_after_days = 30
}
}
resource "aws_lambda_function" "secret_rotation" {
function_name = "secret-rotation-db"
handler = "rotation.handler"
runtime = "python3.12"
filename = "rotation-lambda.zip"
environment {
variables = {
DB_HOST = "mydb.cluster-xyz.us-east-1.rds.amazonaws.com"
}
}
}
Динамические секреты с Vault
Vault идёт дальше обычной ротации, предлагая динамические секреты. Вместо смены статической учётной записи Vault генерирует уникальные короткоживущие учётные данные при каждом запросе. По истечении срока аренды (lease) Vault автоматически их отзывает:
# Включить движок секретов для баз данных
vault secrets enable database
# Настроить подключение к PostgreSQL
vault write database/config/my-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="my-app-role" \
connection_url="postgresql://{{username}}:{{password}}@postgres.default.svc:5432/mydb?sslmode=disable" \
username="vault_admin" \
password="admin_password"
# Создать роль, которая генерирует учётные данные с TTL 1 час
vault write database/roles/my-app-role \
db_name=my-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# Каждый запрос к этому пути генерирует свежие учётные данные
$ vault read database/creds/my-app-role
Key Value
--- -----
lease_id database/creds/my-app-role/abcd1234
lease_duration 1h
lease_renewable true
password A1B2-C3D4-E5F6-G7H8
username v-my-app-role-xyz123
При использовании динамических секретов ротировать в традиционном смысле нечего. Каждый под получает собственные уникальные учётные данные, которые истекают автоматически. Если учётные данные будут скомпрометированы, они будут работать лишь ограниченное время и дают доступ только к тому, что разрешено конкретной ролью.
Главная сложность при ротации — как традиционной, так и динамической — состоит в том, чтобы приложение корректно обрабатывало смену учётных данных. Ваше приложение должно либо периодически перечитывать файл с секретом, либо переподключаться с новыми учётными данными при отзыве старых, либо использовать пул соединений, который прозрачно обрабатывает ротацию.
SOPS с age/GPG
Mozilla SOPS (Secrets OPerationS) предлагает ещё один подход. Вместо отдельного контроллера или оператора SOPS шифрует конкретные значения в файлах YAML или JSON, оставляя структуру и ключи в открытом виде. Это значит, что вы можете видеть, какие секреты содержит файл, не имея возможности прочитать сами значения, — что очень удобно при ревью кода и просмотре диффов.
Установите SOPS и age (современный инструмент шифрования, проще GPG):
# Установить sops
brew install sops
# Установить age
brew install age
# Сгенерировать пару ключей age
age-keygen -o keys.txt
# Вывод: public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
Создайте файл конфигурации .sops.yaml в корне репозитория:
# .sops.yaml
creation_rules:
# Шифровать секреты в директории production
- path_regex: secrets/production/.*\.yaml$
encrypted_regex: "^(data|stringData)$"
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Шифровать секреты в директории staging другим ключом
- path_regex: secrets/staging/.*\.yaml$
encrypted_regex: "^(data|stringData)$"
age: age1wrg9q5p84t03edh09vqnqv60xfmxqxfaslfcm2yln95jwzxqntrse2x8fq
# Можно также использовать AWS KMS, GCP KMS или Azure Key Vault
- path_regex: secrets/production-aws/.*\.yaml$
encrypted_regex: "^(data|stringData)$"
kms: "arn:aws:kms:us-east-1:123456789:key/abcd-1234-efgh-5678"
Теперь создайте файл с секретом и зашифруйте его:
# secrets/production/my-app.yaml (до шифрования)
apiVersion: v1
kind: Secret
metadata:
name: my-app-secrets
namespace: default
type: Opaque
stringData:
database-password: password123
api-key: super-secret-key
# Зашифровать файл на месте
sops --encrypt --in-place secrets/production/my-app.yaml
После шифрования файл выглядит так:
# secrets/production/my-app.yaml (после шифрования)
apiVersion: v1
kind: Secret
metadata:
name: my-app-secrets
namespace: default
type: Opaque
stringData:
database-password: ENC[AES256_GCM,data:kJH7x9mN...,iv:abc...,tag:xyz...,type:str]
api-key: ENC[AES256_GCM,data:pQR8y0oP...,iv:def...,tag:uvw...,type:str]
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-03-07T10:30:00Z"
version: 3.9.0
Обратите внимание: ключи и структура видны, а значения зашифрованы. Это идеально для ревью кода — можно увидеть, что кто-то изменил database-password, не зная самого значения.
Для расшифровки и применения:
# Расшифровать и применить в кластере
sops --decrypt secrets/production/my-app.yaml | kubectl apply -f -
# Или редактировать зашифрованный файл напрямую (расшифруется в редакторе, зашифруется при сохранении)
sops secrets/production/my-app.yaml
Интеграция SOPS с ArgoCD
ArgoCD поддерживает SOPS через плагины. Можно использовать argocd-vault-plugin или встроенную поддержку SOPS в Kustomize:
# argocd-repo-server с поддержкой SOPS
apiVersion: apps/v1
kind: Deployment
metadata:
name: argocd-repo-server
namespace: argocd
spec:
template:
spec:
containers:
- name: argocd-repo-server
env:
# Закрытый ключ age для расшифровки
- name: SOPS_AGE_KEY_FILE
value: /sops/age/keys.txt
volumeMounts:
- name: sops-age
mountPath: /sops/age
volumes:
- name: sops-age
secret:
secretName: sops-age-key
# Использование kustomize-sops с ArgoCD
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
generators:
- secret-generator.yaml
# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: my-app-secrets
files:
- secrets/production/my-app.yaml
SOPS хорошо подходит, когда вы хотите держать всё в Git (настоящий GitOps), у вас относительно небольшое количество секретов и нет необходимости в динамических секретах или сложной ротации. Он хорошо работает для команд, которые уже комфортно используют Git-процессы и хотят минимальной дополнительной инфраструктуры.
RBAC для секретов
Какой бы инструмент вы ни использовали для управления секретами, уровень RBAC в Kubernetes — это ваш последний рубеж обороны. Если RBAC слишком разрешителен, злоумышленник, скомпрометировавший любой сервисный аккаунт, сможет прочитать все секреты в пространстве имён или даже во всём кластере.
Ключевые принципы:
-
Минимальные привилегии (Least privilege): предоставляйте доступ только к конкретным секретам, которые нужны сервису.
-
Изоляция по namespace: используйте отдельные пространства имён для разных окружений и команд.
-
Никаких шаблонных прав: избегайте
resources: ["*"]в RBAC-правилах для секретов. -
Разделение чтения и записи: большинство сервисов нуждается только в чтении секретов, а не в их создании или изменении.
Вот ограничительная Role, которая разрешает чтение только одного конкретного секрета:
# role-secret-reader.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: my-app-secret-reader
namespace: default
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["my-app-secrets"] # Только этот конкретный секрет
verbs: ["get"] # Только get, не list и не watch
# rolebinding-secret-reader.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-app-secret-reader
namespace: default
subjects:
- kind: ServiceAccount
name: my-app-sa
namespace: default
roleRef:
kind: Role
name: my-app-secret-reader
apiGroup: rbac.authorization.k8s.io
Для изоляции пространств имён создайте NetworkPolicy, запрещающую подам в одном namespace общаться с подами в других, в сочетании с RBAC, ограничивающим сервисные аккаунты рамками их namespace:
# namespace-isolation.yaml
apiVersion: v1
kind: Namespace
metadata:
name: team-payments
labels:
team: payments
environment: production
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-cross-namespace
namespace: team-payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {} # Разрешать трафик только из того же namespace
egress:
- to:
- podSelector: {} # Разрешать трафик только в тот же namespace
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP # Разрешить DNS-разрешение
Стоит также ограничить, кто может создавать или изменять Roles и RoleBindings, поскольку злоумышленник с такой возможностью может выдать себе доступ к любому секрету:
# restrict-rbac-management.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rbac-manager
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
# Привязывать только к администраторам кластера, но не к обычным сервисным аккаунтам
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: rbac-manager-binding
subjects:
- kind: Group
name: cluster-admins
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rbac-manager
apiGroup: rbac.authorization.k8s.io
Распространённая ошибка — назначать ClusterRole edit или admin сервисным аккаунтам или разработчикам. Эти встроенные роли включают возможность читать все секреты в namespace. Вместо этого создавайте собственные роли с только теми разрешениями, которые действительно необходимы.
Аудит доступа к секретам
Даже при строгом RBAC нужно знать, кто и когда обращается к вашим секретам. Аудит-логирование Kubernetes обеспечивает такую видимость, но его нужно настраивать явно, поскольку в большинстве дистрибутивов оно отключено по умолчанию.
Политика аудита определяет, какие события и на каком уровне детализации фиксировать:
# audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Логировать весь доступ к секретам на уровне RequestResponse
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Логировать запросы токенов (токены сервисных аккаунтов)
- level: Metadata
resources:
- group: ""
resources: ["serviceaccounts/token"]
verbs: ["create"]
# Логировать изменения RBAC
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
verbs: ["create", "update", "patch", "delete"]
# Всё остальное — на уровне метаданных
- level: Metadata
omitStages:
- "RequestReceived"
Настройте API-сервер на использование этой политики:
# Флаги kube-apiserver
--audit-policy-file=/etc/kubernetes/audit-policy.yaml
--audit-log-path=/var/log/kubernetes/audit.log
--audit-log-maxage=30
--audit-log-maxbackup=10
--audit-log-maxsize=100
# Или отправлять аудит-логи через вебхук (например, в Elasticsearch или Loki)
--audit-webhook-config-file=/etc/kubernetes/audit-webhook.yaml
Запись аудит-лога при обращении к секрету выглядит так:
{
"kind": "Event",
"apiVersion": "audit.k8s.io/v1",
"level": "RequestResponse",
"auditID": "abc-123-def-456",
"stage": "ResponseComplete",
"requestURI": "/api/v1/namespaces/default/secrets/my-app-secrets",
"verb": "get",
"user": {
"username": "system:serviceaccount:default:my-app-sa",
"groups": ["system:serviceaccounts", "system:serviceaccounts:default"]
},
"sourceIPs": ["10.244.0.15"],
"objectRef": {
"resource": "secrets",
"namespace": "default",
"name": "my-app-secrets",
"apiVersion": "v1"
},
"responseStatus": {
"metadata": {},
"code": 200
},
"requestReceivedTimestamp": "2026-03-07T10:30:00.000000Z",
"stageTimestamp": "2026-03-07T10:30:00.005000Z"
}
На основе аудит-логов можно настроить алерты для обнаружения подозрительной активности:
# Правило Falco для обнаружения доступа к секретам с неожиданных сервисных аккаунтов
- rule: Unexpected Secret Access
desc: Обнаружение обращения к секрету сервисного аккаунта, которого нет в списке разрешённых
condition: >
ka.verb in (get, list) and
ka.target.resource = secrets and
not ka.user.name in (allowed_secret_readers)
output: >
Unexpected secret access
(user=%ka.user.name verb=%ka.verb
secret=%ka.target.name ns=%ka.target.namespace
source=%ka.sourceips)
priority: WARNING
source: k8s_audit
tags: [security, secrets]
# Правило алертинга Prometheus на основе метрик аудит-логов
# (требует экспортёра метрик аудит-логов)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: secret-access-alerts
namespace: monitoring
spec:
groups:
- name: secret.access
rules:
- alert: UnusualSecretAccessRate
expr: |
sum(rate(apiserver_audit_event_total{
resource="secrets",
verb="get"
}[5m])) by (user) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Необычная частота обращений к секретам от {{ $labels.user }}"
description: "Сервисный аккаунт {{ $labels.user }} обращается к секретам с аномально высокой частотой"
Сочетание аудит-логирования с алертингом позволяет обнаруживать несанкционированный доступ к секретам и реагировать на него практически в режиме реального времени. Это критически важно как для соответствия нормативным требованиям, так и для своевременного обнаружения скомпрометированных сервисных аккаунтов.
Как всё объединить
При таком многообразии инструментов и подходов как понять, что выбрать? Вот матрица решений, основанная на потребностях и уровне зрелости вашей команды:
-
Только начинаете, небольшая команда: используйте Sealed Secrets. Проще всего в настройке, не требует внешней инфраструктуры и решает главную проблему (секреты в Git). Добавьте ограничения RBAC и базовое аудит-логирование.
-
Растущая команда, cloud-native подход: используйте External Secrets Operator с хранилищем секретов вашего облачного провайдера (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault). Это даёт централизованное управление, автоматическую ротацию средствами провайдера и чистый GitOps-процесс.
-
Крупная организация, строгие требования к соответствию: используйте HashiCorp Vault с Agent Injector или CSI-провайдером. Vault обеспечивает динамические секреты, детальное аудит-логирование, политики как код и интеграции со всем. Можно комбинировать с ESO для гибридного подхода.
-
Приверженцы GitOps: используйте SOPS с age или KMS. Всё хранится в Git, зашифровано на уровне значений, с понятными диффами в pull request’ах.
-
Максимальная безопасность: комбинируйте Vault для хранения секретов и динамических учётных данных, ESO для интеграции с Kubernetes, RBAC с политиками минимальных привилегий, аудит-логирование с алертингом и автоматическую ротацию с короткими TTL.
Модель зрелости для ориентира:
-
Уровень 0: секреты захардкожены в коде или коммитятся в Git в открытом виде. Остановите всё и исправьте это в первую очередь.
-
Уровень 1: Kubernetes Secrets с включённым шифрованием в состоянии покоя в etcd. Лучше, но секреты всё ещё в манифестах и не аудируются.
-
Уровень 2: Sealed Secrets или SOPS для зашифрованных секретов в Git. RBAC ограничен по принципу минимальных привилегий. Это хорошая базовая линия.
-
Уровень 3: External Secrets Operator с централизованным хранилищем секретов. Автоматическая ротация. Аудит-логирование включено.
-
Уровень 4: Vault с динамическими секретами, короткоживущими учётными данными и исчерпывающим аудит-логированием. Алерты на доступ к секретам. Регулярная ротация. Инструменты соответствия нормативным требованиям на месте.
Большинство команд обнаружит, что Уровень 2 или Уровень 3 покрывает их потребности. Уровень 4 — для организаций со строгими требованиями к соответствию или с высокоценными целями для атак. Главное — честно оценить, где вы находитесь, и двигаться вперёд постепенно.
Заключение
Управление секретами — одна из тех тем, которые кажутся простыми на поверхности, но быстро усложняются. Хорошая новость в том, что экосистема Kubernetes предлагает зрелые, проверенные в бою инструменты для любого уровня сложности: от Sealed Secrets для небольших команд до Vault для корпоративных динамических секретов.
Главный вывод: base64 — это не шифрование, а Kubernetes Secrets сами по себе недостаточны. Выберите инструмент, который соответствует размеру и потребностям вашей команды, соблюдайте RBAC с минимальными привилегиями, включите аудит-логирование и регулярно ротируйте секреты. Не нужно внедрять всё сразу — но нужно знать, на каком уровне зрелости вы находитесь, и иметь план по его повышению.
Надеемся, статья оказалась полезной и интересной — до следующего раза!
Замечания
Если вы заметили ошибку или хотите что-то предложить, напишите, и мы это исправим.
Исходный код и историю изменений можно посмотреть в репозитории с исходниками.