Kubernetes для самых маленьких. Часть 12: Как работают ConfigMap и Secret?
Все части серии «Kubernetes для самых маленьких»Образ из части 5 один для всех окружений: тот же ghcr.io/example/hello:1.0 работает и на стенде, и в эксплуатации. Значит, всё, что между окружениями различается — адреса, уровни логирования, флаги функций, пароли и ключи, — должно приходить в контейнер снаружи, при запуске. В части 9 мы уже взяли пароль базы для дампа из объекта Secret, не объясняя, что это такое. Сегодня разберём два объекта для конфигурации — ConfigMap и Secret: как передать их в под, что происходит, когда они меняются, почему Secret — это не шифрование, кто может его прочитать, как зашифровать секреты там, где они на самом деле лежат, — в etcd, — и как держать их вне кластера. Все примеры — в неймспейсе demo из прошлой части.
ConfigMap
ConfigMap — объект неймспейса, который хранит пары «ключ — значение»: короткие строки или целые файлы.
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-config
namespace: demo
labels:
app.kubernetes.io/name: hello
data:
LOG_LEVEL: info
GREETING: hello
app.yaml: |
listen: ":8080"
upstream:
url: http://backend.demo.svc.cluster.local:8080
timeout: 5s
binaryData:
logo.png: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=
data — строки в UTF-8, binaryData — двоичные данные в base64 (здесь — картинка PNG размером в один пиксель). Ключи — имена из букв, цифр, -, _ и .: ими они станут, если ConfigMap смонтировать файлами. Размер данных объекта ограничен 1 МиБ (1 048 576 байт): ConfigMap хранится в etcd целиком и рассылается всем, кто за ним следит, поэтому большие файлы и данные приложения в нём не держат.
ConfigMap создают и командой — из литералов, файлов, каталога или файла переменных KEY=value:
$ kubectl create configmap hello-config --from-literal=LOG_LEVEL=info --from-file=app.yaml
configmap/hello-config created
$ kubectl create configmap nginx-conf --from-file=./conf.d/
configmap/nginx-conf created
$ kubectl get configmap nginx-conf -o yaml | head -7
apiVersion: v1
data:
default.conf: |
server { listen 80; }
gzip.conf: |
gzip on;
kind: ConfigMap
Из каталога каждый файл становится ключом с именем файла. Дальше hello-config будет создан манифестом, поэтому после опыта эти два объекта удаляем: kubectl delete configmap hello-config nginx-conf. --from-env-file разбирает файл построчно: строки KEY=value становятся ключами, комментарии пропускаются, значение берётся как есть, вместе с пробелами. Ключ с недопустимым символом kubectl отвергает сам, а превышение предела — API-сервер:
$ kubectl create configmap badkey --from-literal='bad key=1'
error: "bad key" is not a valid key name for a ConfigMap: a valid config key must consist of alphanumeric characters, '-', '_' or '.' (e.g. 'key.name', or 'KEY_NAME', or 'key-name', regex used for validation is '[-._a-zA-Z0-9]+')
$ head -c 1100000 /dev/zero | tr '\0' a > big.txt
$ kubectl create configmap big --from-file=big.txt
error: failed to create configmap: ConfigMap "big" is invalid: []: Too long: may not be more than 1048576 bytes
Сам по себе ConfigMap ничего не делает: это данные в API. Работать они начинают, когда kubelet подставит их в под.
Как передать конфигурацию в под
Есть четыре способа, и они ведут себя по-разному при изменениях.
Отдельная переменная из ключа:
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: hello-config
key: LOG_LEVEL
Все ключи сразу переменными (envFrom), при желании с префиксом:
envFrom:
- configMapRef:
name: hello-config
prefix: HELLO_ # HELLO_LOG_LEVEL, HELLO_GREETING
С 1.34 имя переменной может состоять из любых печатных символов ASCII, кроме =, поэтому envFrom превратит в переменную и ключ app.yaml — HELLO_app.yaml вместе со всем содержимым файла. Любой допустимый ключ ConfigMap теперь годится в имя переменной: даже 1badkey, который документация приводит как пример пропущенного ключа с событием InvalidEnvironmentVariableNames, на кластере 1.36 становится переменной без всяких событий. Ключи из binaryData в переменные не попадают вовсе — logo.png из нашего ConfigMap молча пропущен. Надёжнее держать переменные и файлы в разных ConfigMap.
Том — каждый ключ становится файлом:
volumes:
- name: config
configMap:
name: hello-config
items: # необязательно: только эти ключи
- key: app.yaml
path: app.yaml
containers:
- name: hello
volumeMounts:
- name: config
mountPath: /etc/hello
readOnly: true
Один файл в существующий каталог через subPath:
volumeMounts:
- name: config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
subPath удобен, когда в каталоге образа уже лежат нужные файлы и заменить надо только один: обычный том закрыл бы весь каталог. Но у него есть серьёзный недостаток — о нём ниже.
Ещё два вида томов собирают данные из нескольких источников: projected объединяет в одном каталоге ключи из нескольких ConfigMap, Secret, сведения Downward API (данные пода о самом себе — имя, метки, ресурсы) и токен ServiceAccount; именно так kubelet монтирует в каждый под токен для доступа к API, о чём подробно в части 29.
Ссылка на несуществующий ConfigMap или ключ останавливает запуск пода, пока объект не появится. Как именно — зависит от способа: переменная из отсутствующего ключа даёт CreateContainerConfigError, том из отсутствующего ConfigMap держит под в ContainerCreating. Если отсутствие допустимо, ссылку помечают optional: true — тогда переменная пуста, а каталог тома пуст:
$ kubectl get pods missing-key missing-cm missing-optional
NAME READY STATUS RESTARTS AGE
missing-key 0/1 CreateContainerConfigError 0 26s
missing-cm 0/1 ContainerCreating 0 26s
missing-optional 1/1 Running 0 25s
$ kubectl get events --field-selector involvedObject.name=missing-key -o custom-columns=REASON:.reason,MSG:.message | tail -1
Failed Error: couldn't find key DB_URL in ConfigMap demo/hello-config
$ kubectl get events --field-selector involvedObject.name=missing-cm -o custom-columns=REASON:.reason,MSG:.message | tail -1
FailedMount MountVolume.SetUp failed for volume "extra" : configmap "extra-config" not found
Что происходит при изменении: тома
Изменили ConfigMap — что увидит работающий под? Зависит от способа.
Тома обновляются. Kubelet следит за ConfigMap и Secret, которые используют поды его узла, и при изменении переписывает файлы. Делает он это так, чтобы приложение никогда не увидело половину старых файлов и половину новых. В каталоге тома лежат не сами файлы, а символические ссылки:
/etc/hello/
├─ app.yaml → ..data/app.yaml
├─ GREETING → ..data/GREETING
├─ ..data → ..2026_10_28_15_07_41.507995263
└─ ..2026_10_28_15_07_41.507995263/
├─ app.yaml
└─ GREETING
При изменении kubelet создаёт новый каталог с меткой времени, пишет туда все файлы, затем создаёт ссылку ..data_tmp на новый каталог и переименовывает её в ..data. Переименование в Linux атомарно: в любой момент ..data указывает либо на старый набор целиком, либо на новый. Старый каталог потом удаляется.
Отсюда практические следствия:
- приложение, которое следит за файлом через inotify (механизм ядра для уведомлений об изменениях файлов), должно следить за каталогом или за
..data: самapp.yaml— ссылка, которая не меняется, меняется то, куда указывает..data; - приложение, которое держит файл открытым, продолжит читать старую версию из удалённого каталога; перечитывать конфигурацию нужно, открывая файл заново;
- задержка — около минуты и больше. Kubelet перепроверяет тома пода раз в
syncFrequency(по умолчанию минута), и к этому добавляется задержка доставки изменения в его кэш; по умолчанию kubelet подписывается на изменения (стратегияWatch), и эта добавка мала, но документация предупреждает, что в сумме может набежать до двух минут. На нашем стенде файл обновился через 77 секунд послеkubectl patch. Рассчитывать на мгновенное применение нельзя.
subPath не обновляется никогда. Файл, смонтированный через subPath, — это привязка (bind mount) к конкретному файлу в конкретном каталоге с меткой времени, а не к ссылке ..data. Kubelet создаёт новый каталог, переключает ..data и удаляет старый, а subPath-монтирование продолжает показывать старый, уже удалённый файл до перезапуска контейнера. Если нужна и замена одного файла в каталоге образа, и обновление на лету, лучше смонтировать том в отдельный каталог и указать приложению путь к нему.
Переменные не обновляются
Переменные окружения процесса задаются один раз — при его запуске, системным вызовом execve. Ни kubelet, ни ядро не могут поменять окружение работающего процесса. Поэтому значения из env и envFrom фиксируются при старте контейнера и не меняются, пока контейнер не перезапустится. Изменили LOG_LEVEL в ConfigMap — поды продолжают работать со старым значением, а новые поды, созданные позже (при масштабировании или пересоздании после сбоя), получат новое. Получается смесь версий, и это хуже, чем любая из них.
Чтобы изменение дошло до всех, поды нужно перезапустить. Вручную:
$ kubectl rollout restart deployment/hello
deployment.apps/hello restarted
rollout restart ничего не перезапускает напрямую: он добавляет в шаблон пода аннотацию kubectl.kubernetes.io/restartedAt с текущим временем. Шаблон изменился — Deployment выкатывает новый ReplicaSet обычным rolling update из части 7, с maxSurge, maxUnavailable и пробами. Тот же приём лежит в основе автоматизации: изменить что-нибудь в шаблоне, когда меняется конфигурация. В чартах менеджера пакетов Helm (часть 27) для этого принято класть в аннотацию шаблона хеш содержимого ConfigMap, Kustomize (часть 26) добавляет хеш к имени ConfigMap, так что новая конфигурация — это новый объект и новая ссылка в шаблоне. А для объектов, применённых напрямую, есть Reloader.
Reloader
Reloader — небольшой контроллер, который следит за ConfigMap и Secret и при их изменении запускает выкатку тех Deployment, StatefulSet и DaemonSet, которые их используют. Он не перезапускает поды сам: он меняет шаблон, а дальше работает обычный механизм выкатки со всеми его гарантиями.
Ставится он официальным Helm-чартом одной командой (подробно Helm — в части 27). У проекта есть и готовый манифест, но он создаёт всё в неймспейсе default, а контроллеру с правом читать секреты всего кластера место в отдельном неймспейсе:
$ helm repo add stakater https://stakater.github.io/stakater-charts
$ helm install reloader stakater/reloader --version 2.2.19 \
--namespace reloader --create-namespace
Чарт 2.2.19 ставит Reloader v1.4.23 — Deployment reloader-reloader с одной репликой, ServiceAccount и ClusterRole с правами get, list, watch на ConfigMap и Secret всего кластера. По умолчанию он следит за всеми неймспейсами и реагирует только на объекты с аннотациями:
| Аннотация на рабочей нагрузке | Что делает |
|---|---|
reloader.stakater.com/auto: "true" |
перезапуск при изменении любого ConfigMap или Secret, на который ссылается шаблон |
configmap.reloader.stakater.com/auto: "true" |
то же, только для ConfigMap |
secret.reloader.stakater.com/auto: "true" |
то же, только для Secret |
configmap.reloader.stakater.com/reload: "hello-config" |
перезапуск при изменении именно этих ConfigMap (через запятую), даже если шаблон на них не ссылается |
secret.reloader.stakater.com/reload: "db-credentials" |
то же для Secret |
reloader.stakater.com/search: "true" |
перезапуск только при изменении объектов, помеченных на самих ConfigMap/Secret аннотацией reloader.stakater.com/match: "true" |
Аннотация ставится в metadata.annotations самого Deployment, а не шаблона пода:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
namespace: demo
annotations:
reloader.stakater.com/auto: "true"
Как Reloader меняет шаблон, задаёт стратегия. По умолчанию (env-vars) он добавляет в контейнеры, использующие изменённый объект, переменную вида STAKATER_HELLO_CONFIG_CONFIGMAP со значением хеша содержимого: шаблон изменился — пошла выкатка. Стратегия annotations вместо этого пишет аннотацию reloader.stakater.com/last-reloaded-from в шаблон пода; её предпочитают при доставке из Git (часть 41), чтобы инструмент синхронизации не видел «чужую» переменную как расхождение с репозиторием.
Reloader удобен, но помнить надо о двух вещах. Ошибка в конфигурации теперь выкатывается автоматически — и так же автоматически ломает все поды; хорошие readiness-пробы и maxUnavailable из части 7 остановят выкатку на первом сломанном поде. И ConfigMap не версионируется: откатить Deployment на прошлую ревизию — не значит откатить конфигурацию. Подход Kustomize с хешем в имени решает и это: старая ревизия ссылается на старый ConfigMap.
Неизменяемые ConfigMap и Secret
Поле immutable: true (стабильно с 1.21) делает ConfigMap или Secret неизменяемым:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-config-v3
immutable: true
data:
LOG_LEVEL: info
Изменить data у такого объекта нельзя, а снять immutable — тоже нельзя: только удалить и создать заново. Метаданные — метки и аннотации — меняются как обычно:
$ kubectl patch configmap hello-config-v3 --type=merge -p '{"data":{"LOG_LEVEL":"debug"}}'
The ConfigMap "hello-config-v3" is invalid: data: Forbidden: field is immutable when `immutable` is set
$ kubectl patch configmap hello-config-v3 --type=merge -p '{"immutable":false}'
The ConfigMap "hello-config-v3" is invalid: immutable: Forbidden: field is immutable when `immutable` is set
$ kubectl label configmap hello-config-v3 version=v3
configmap/hello-config-v3 labeled
Зачем это нужно:
- защита от случайной правки — конфигурация, на которую опираются работающие поды, не изменится у них под ногами;
- нагрузка на API-сервер — kubelet перестаёт следить за неизменяемыми объектами. В кластере с десятками тысяч подов и смонтированных ConfigMap это заметно разгружает API-сервер.
Вместе с версией в имени (hello-config-v3) это и есть правильная схема: новая конфигурация — новый объект, Deployment переключается на него обычной выкаткой, старая ревизия остаётся рабочей для отката. Старые версии потом удаляют.
Secret
Secret устроен почти так же, как ConfigMap: пары «ключ — значение» в неймспейсе, до 1 МиБ, передаются в под переменными или томом. Отличия — в том, как с ним обращаются:
- значения в
dataзаписываются в base64; для удобства есть полеstringData, куда можно писать открытым текстом, — API-сервер сам перекодирует его вdataпри записи и в ответахstringDataне возвращает; - у Secret есть тип, по которому API проверяет состав ключей;
- kubelet кладёт Secret-тома в tmpfs — файловую систему в памяти, — чтобы секрет не попадал на диск узла;
- kubelet получает Secret только тех подов, которые назначены на его узел (это обеспечивает режим авторизации Node в API-сервере), а не все секреты кластера;
kubectl describeпоказывает у Secret только размеры значений, а не сами значения.
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: demo
type: Opaque
stringData:
username: app
password: Zq3vN8rT1wX6pL0s
С stringData есть ловушка. kubectl apply сохраняет применённый манифест целиком в аннотации kubectl.kubernetes.io/last-applied-configuration — и для Secret тоже, вместе с открытым stringData:
$ kubectl get secret db-credentials -o yaml
apiVersion: v1
data:
password: WnEzdk44clQxd1g2cEwwcw==
username: YXBw
kind: Secret
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{},"name":"db-credentials","namespace":"demo"},"stringData":{"password":"Zq3vN8rT1wX6pL0s","username":"app"},"type":"Opaque"}
creationTimestamp: "2026-10-28T15:07:41Z"
name: db-credentials
namespace: demo
resourceVersion: "774947"
uid: 1bee8f75-9deb-460d-b5c0-50e5027b79b8
type: Opaque
kubectl describe secret эту аннотацию не показывает (Annotations: <none>), но она есть в API и в etcd. Secret, созданный kubectl create secret или kubectl apply --server-side, такой аннотации не получает.
Типы:
| Тип | Для чего | Обязательные ключи |
|---|---|---|
Opaque |
произвольные данные (по умолчанию) | — |
kubernetes.io/tls |
сертификат и ключ TLS | tls.crt, tls.key |
kubernetes.io/dockerconfigjson |
доступ к реестру образов (imagePullSecrets из части 5) |
.dockerconfigjson |
kubernetes.io/dockercfg |
то же в старом формате | .dockercfg |
kubernetes.io/basic-auth |
логин и пароль | username или password (хотя бы один) |
kubernetes.io/ssh-auth |
закрытый ключ SSH | ssh-privatekey |
kubernetes.io/service-account-token |
долгоживущий токен ServiceAccount | заполняется контроллером |
bootstrap.kubernetes.io/token |
токен присоединения узла, который kubeadm использовал в части 4 | заполняется kubeadm |
Долгоживущие токены ServiceAccount в Secret — наследие: с 1.24 они не создаются автоматически, а неиспользуемые старые удаляются. Поды получают короткоживущие токены через projected-том (часть 29). Создавать Secret этого типа вручную стоит только для внешних систем, которым по-другому никак.
Передаётся Secret в под так же, как ConfigMap: secretKeyRef для одной переменной, envFrom с secretRef, том с secret.secretName. Для томов полезно сузить права на файлы: по умолчанию они создаются с режимом 0644, то есть читаемы любым пользователем в контейнере. defaultMode: 0440 вместе с fsGroup пода (часть 30) оставляет чтение только процессу приложения.
Файл лучше переменной. Переменные окружения видны в /proc/<pid>/environ процесса и его потомков, наследуются всеми дочерними процессами, нередко попадают в отчёты об ошибках и в логи при отладке («выведем окружение»). Файл в tmpfs с узкими правами виден только тем, кто его открыл. Ещё одно преимущество файлов — обновление на лету: сменённый пароль появится в файле, а приложение, умеющее его перечитать, сменит его без перезапуска. Переменные — допустимый компромисс для программ, которые иначе не умеют, но не выбор по умолчанию.
base64 — не шифрование
Значения Secret в API выглядят непонятно, и это создаёт ложное чувство защищённости:
$ kubectl get secret db-credentials -o jsonpath='{.data.password}'
WnEzdk44clQxd1g2cEwwcw==
$ kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
Zq3vN8rT1wX6pL0s
base64 — способ записать произвольные байты печатными символами, чтобы двоичные данные (ключи, сертификаты в DER) поместились в JSON и YAML. Он обратим без всякого ключа. Secret защищён ровно настолько, насколько защищены:
- доступ к API — кто может читать Secret через
kubectl; - хранилище — etcd и его резервные копии;
- узлы — куда kubelet кладёт секреты для подов;
- манифесты — где лежит YAML, из которого Secret создан.
Отсюда две частые ошибки. Первая — коммитить манифест Secret в Git, «он же в base64». Это пароль открытым текстом в истории репозитория навсегда. Вторая — класть пароли в ConfigMap: у ConfigMap нет ни tmpfs, ни особых правил доступа, его часто дают читать всем, и он попадает в вывод describe. Секретное — только в Secret, и Secret — только из защищённого источника (о нём в конце).
Кто может читать Secret
Доступ к API разграничивает RBAC — правила вида «субъект может выполнять такие-то действия над такими-то ресурсами»; подробно — в части 29. Для Secret важно понимать, какие права на самом деле означают «может прочитать секрет»:
getнаsecrets— читать конкретный Secret по имени;listиwatchнаsecrets— получить все Secret неймспейса со значениями. Право «посмотреть список» здесь равносильно чтению всех секретов;createнаpods(или на Deployment, Job и любые другие объекты, создающие поды) в неймспейсе — косвенный доступ ко всем Secret этого неймспейса: можно создать под, который смонтирует любой из них, и прочитать его изнутри. То же сpods/execв под, у которого секрет уже смонтирован;- права на уровне кластера (ClusterRole с
secretsчерез ClusterRoleBinding) — доступ к секретам всех неймспейсов, включаяkube-system.
Проверить, кто что может, позволяет kubectl auth can-i. ServiceAccount default в demo секретов не видит, а Reloader из раздела выше видит их во всём кластере — такова цена его работы:
$ kubectl auth can-i list secrets -n demo --as=system:serviceaccount:demo:default
no
$ kubectl auth can-i list secrets -n kube-system --as=system:serviceaccount:reloader:reloader-reloader
yes
Правило: права на secrets давать только тем, кому они действительно нужны, и помнить, что право деплоить в неймспейс — это право читать его секреты.
Шифрование секретов в etcd
API-сервер хранит объекты в etcd в двоичном формате protobuf — и по умолчанию без шифрования. Значение Secret лежит в etcd, в снимках etcd и во всех резервных копиях открытым текстом. Любой, кто получил файл снимка или доступ к диску мастера, получил все секреты кластера.
Это исправляет шифрование при хранении (encryption at rest): API-сервер шифрует выбранные типы объектов перед записью в etcd и расшифровывает при чтении. Для клиентов API ничего не меняется. Настраивается файлом EncryptionConfiguration и флагом --encryption-provider-config у kube-apiserver:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- secretbox:
keys:
- name: key1
secret: <32 случайных байта в base64>
- identity: {}
Список провайдеров упорядочен: первый используется для записи, все — для чтения (по префиксу, записанному в начале значения). identity — «без шифрования»: в конце списка он нужен, чтобы читать то, что было записано до включения шифрования.
Провайдеры:
| Провайдер | Алгоритм | Рекомендация документации |
|---|---|---|
identity |
нет | шифрования нет вообще |
aescbc |
AES-CBC с дополнением PKCS#7 | не рекомендуется: уязвим к атакам padding oracle (подбор открытого текста по ответам о корректности дополнения) |
aesgcm |
AES-GCM со случайным nonce | только с автоматической ротацией ключа: ключ нужно менять каждые 200 000 записей |
secretbox |
XSalsa20 и Poly1305 | сильный и быстрый; ключ, как и у остальных локальных провайдеров, лежит на мастере |
kms v2 |
конвертное шифрование через внешнюю службу ключей | самый сильный вариант, стабилен с 1.29 |
kms v1 |
то же, старая версия | устарел с 1.28 и выключен по умолчанию с 1.29 |
Принципиальная разница между локальными провайдерами и KMS — где ключ. У secretbox, aescbc, aesgcm ключ лежит в файле конфигурации на мастере, рядом с etcd. Это защищает от утечки снимков etcd и резервных копий, но не от того, кто получил root на мастере: он прочитает и ключ. KMS (Key Management Service — внешняя служба управления ключами: облачная, аппаратный модуль HSM или HashiCorp Vault — хранилище секретов и ключей) использует конвертное шифрование: данные шифруются ключом данных (DEK) алгоритмом AES-GCM, а сам DEK — главным ключом (KEK), который никогда не покидает KMS. В etcd лежит зашифрованное значение и зашифрованный DEK; чтобы прочитать секрет, нужен и etcd, и доступ к KMS. У KMS v2 API-сервер генерирует DEK из секретного начального значения, которое меняется при смене KEK, поэтому обращаться к KMS на каждую запись не нужно, и это быстро. API-сервер говорит с KMS через плагин — отдельный процесс на мастере, слушающий Unix-сокет.
Практический выбор: при наличии KMS — kms v2; для учебного или небольшого кластера без внешней службы ключей — secretbox; aescbc в новых установках не использовать.
Порядок включения на кластере kubeadm:
сгенерировать ключ (
head -c 32 /dev/urandom | base64) и положить конфигурацию в/etc/kubernetes/enc/enc.yamlс правами0600для root — это ключ от всех секретов;добавить API-серверу флаг и том. В статическом поде
/etc/kubernetes/manifests/kube-apiserver.yamlэто сработает сразу, ноkubeadm upgradeперегенерирует манифест, поэтому то же нужно записать вClusterConfigurationkubeadm:apiServer: extraArgs: - name: encryption-provider-config value: /etc/kubernetes/enc/enc.yaml extraVolumes: - name: enc hostPath: /etc/kubernetes/enc mountPath: /etc/kubernetes/enc readOnly: true pathType: DirectoryOrCreateдождаться перезапуска API-сервера: kubelet следит за каталогом
manifestsи пересоздаёт статический под при изменении файла;перезаписать существующие секреты: шифрование применяется только при записи, а старые значения так и лежат открытыми. Команда из документации читает все Secret и записывает их обратно без изменений:
kubectl get secrets -A -o json | kubectl replace -f -.
При нескольких мастерах (как в серии про отказоустойчивый кластер) файл должен быть одинаковым на всех, иначе один API-сервер не прочитает то, что записал другой.
Смена ключа — в три шага, чтобы ни в какой момент данные не оказались нечитаемыми: добавить новый ключ вторым и перезапустить все API-серверы (теперь его все умеют читать); переставить его первым и снова перезапустить (теперь им пишут); перезаписать все секреты и убрать старый ключ. Флаг --encryption-provider-config-automatic-reload=true позволяет API-серверу подхватывать изменения файла без перезапуска: он перечитывает файл раз в минуту.
И последнее, о чём забывают: ключ шифрования нужно хранить в резервной копии отдельно от снимков etcd. Снимок с зашифрованными секретами без ключа невосстановим, а снимок вместе с ключом в одном месте теряет смысл шифрования. Снимки и восстановление etcd — в части 34.
Секреты вне кластера
Шифрование в etcd защищает секреты в кластере. Остаётся вопрос, откуда они туда попадают: манифест Secret нельзя хранить в Git открытым, а создавать секреты руками командой kubectl create secret — значит не иметь ни истории, ни воспроизводимости. Для этого есть несколько устоявшихся инструментов.
- External Secrets Operator — контроллер, который берёт значения из внешнего хранилища секретов (HashiCorp Vault, облачные менеджеры секретов) и создаёт из них обычные Secret в кластере. В Git лежит только объект
ExternalSecretсо ссылкой «какой ключ из хранилища положить в какой Secret»; сам секрет хранится и ротируется во внешней системе, а контроллер периодически синхронизирует его. - Sealed Secrets — контроллер в кластере держит пару ключей; утилита
kubesealшифрует Secret открытым ключом контроллера в объектSealedSecret, который можно коммитить в Git. Расшифровать его может только контроллер этого кластера, и он создаёт из него обычный Secret. - SOPS — утилита, которая шифрует значения в YAML- и JSON-файлах, оставляя ключи читаемыми, — ключами age (простой современной утилиты шифрования), OpenPGP или облачного KMS. Зашифрованный манифест хранится в Git, а расшифровывается при развёртывании — плагином инструмента доставки (часть 41) или на рабочей станции.
- Secrets Store CSI Driver — драйвер томов, который монтирует секреты из внешнего хранилища прямо в под файлами, вообще минуя объект Secret.
Выбор зависит от того, есть ли у вас внешнее хранилище секретов. Есть — External Secrets Operator: секрет живёт в одном месте, и его ротация не требует коммита. Нет — Sealed Secrets или SOPS: секреты в Git, но зашифрованные. Как это вписывается в доставку из Git — в части 41.
Смотрим руками
Создадим ConfigMap и Secret из примеров выше и учебный под, который их использует всеми способами. Образ hello минимален и без оболочки, поэтому смотреть будем из busybox:
apiVersion: v1
kind: Pod
metadata:
name: cfg-demo
namespace: demo
spec:
securityContext:
fsGroup: 2000
containers:
- name: shell
image: busybox:1.37
command: ["sleep", "infinity"]
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef: {name: hello-config, key: LOG_LEVEL}
volumeMounts:
- {name: config, mountPath: /etc/hello, readOnly: true}
- {name: config, mountPath: /etc/single/app.yaml, subPath: app.yaml, readOnly: true}
- {name: creds, mountPath: /run/secrets/db, readOnly: true}
volumes:
- name: config
configMap: {name: hello-config}
- name: creds
secret: {secretName: db-credentials, defaultMode: 0440}
$ kubectl apply -f hello-config.yaml -f db-credentials.yaml -f cfg-demo.yaml
configmap/hello-config created
secret/db-credentials created
pod/cfg-demo created
$ kubectl exec cfg-demo -- ls -la /etc/hello
total 12
drwxrwsrwx 3 root 2000 4096 Oct 28 15:07 .
drwxr-xr-x 1 root root 4096 Oct 28 15:07 ..
drwxr-sr-x 2 root 2000 4096 Oct 28 15:07 ..2026_10_28_15_07_41.507995263
lrwxrwxrwx 1 root 2000 31 Oct 28 15:07 ..data -> ..2026_10_28_15_07_41.507995263
lrwxrwxrwx 1 root 2000 15 Oct 28 15:07 GREETING -> ..data/GREETING
lrwxrwxrwx 1 root 2000 16 Oct 28 15:07 LOG_LEVEL -> ..data/LOG_LEVEL
lrwxrwxrwx 1 root 2000 15 Oct 28 15:07 app.yaml -> ..data/app.yaml
lrwxrwxrwx 1 root 2000 15 Oct 28 15:07 logo.png -> ..data/logo.png
$ kubectl exec cfg-demo -- ls -l /run/secrets/db/..data/
total 8
-r--r----- 1 root 2000 16 Oct 28 15:07 password
-r--r----- 1 root 2000 3 Oct 28 15:07 username
Ссылки и каталог с меткой времени — как в схеме; время внутри busybox — UTC, а метка времени в имени каталога — момент записи. Файлы секрета с режимом 0440 и группой из fsGroup. Теперь изменим ConfigMap и будем раз в секунду читать файл, пока он не обновится:
$ kubectl patch configmap hello-config --type=merge -p '{"data":{"LOG_LEVEL":"debug","app.yaml":"listen: \":8080\"\nupstream:\n url: http://backend.demo.svc.cluster.local:8080\n timeout: 10s\n"}}'
configmap/hello-config patched
$ s=$(date +%s); for i in $(seq 1 120); do [ "$(kubectl exec cfg-demo -- cat /etc/hello/LOG_LEVEL)" = debug ] && break; sleep 1; done; echo "updated after $(( $(date +%s)-s )) s"
updated after 77 s
$ kubectl exec cfg-demo -- sh -c 'echo "env: $LOG_LEVEL"; echo "file: $(cat /etc/hello/LOG_LEVEL)"; grep timeout /etc/hello/app.yaml /etc/single/app.yaml; readlink /etc/hello/..data'
env: info
file: debug
/etc/hello/app.yaml: timeout: 10s
/etc/single/app.yaml: timeout: 5s
..2026_10_28_15_09_14.3053548814
Три поведения сразу: переменная осталась старой, файл в томе обновился (..data теперь указывает на новый каталог), файл через subPath — старый.
Где это лежит на узле. Под на k8s-node-2, его UID — из kubectl get pod cfg-demo -o jsonpath='{.metadata.uid}':
user@k8s-node-2:~$ sudo ls /var/lib/kubelet/pods/f8b9521e-5015-4691-beb0-9587f57f21ac/volumes/
kubernetes.io~configmap
kubernetes.io~projected
kubernetes.io~secret
user@k8s-node-2:~$ findmnt -t tmpfs | grep f8b9521e
/var/lib/kubelet/pods/f8b9521e-5015-4691-beb0-9587f57f21ac/volumes/kubernetes.io~secret/creds tmpfs tmpfs rw,relatime,size=131072k,inode64,noswap
/var/lib/kubelet/pods/f8b9521e-5015-4691-beb0-9587f57f21ac/volumes/kubernetes.io~projected/kube-api-access-mz542 tmpfs tmpfs rw,relatime,size=131072k,inode64,noswap
user@k8s-node-2:~$ findmnt | grep f8b9521e | grep -v tmpfs
└─/var/lib/kubelet/pods/f8b9521e-5015-4691-beb0-9587f57f21ac/volume-subpaths/config/shell/1 /dev/sda2[/var/lib/kubelet/pods/f8b9521e-5015-4691-beb0-9587f57f21ac/volumes/kubernetes.io~configmap/config/..2026_10_28_15_07_41.507995263/app.yaml//deleted] ext4 rw,relatime
Secret-том и projected-том с токеном ServiceAccount — в tmpfs (noswap, размер — 128 МиБ, лимит памяти пода из LimitRange неймспейса demo), ConfigMap — на обычном диске в /var/lib/kubelet. Секреты на диск узла не попадают. А последняя строка — это и есть subPath: kubelet привязал файл app.yaml из каталога первой версии, тот каталог уже удалён (//deleted), а контейнер продолжает видеть старый файл.
Другие способы создать Secret — командой kubectl create secret с подкомандами generic, docker-registry и tls. Для tls нужен сертификат; для опыта сгодится самоподписанный, выпущенный openssl:
$ kubectl create secret generic api-token --from-literal=token=s3cr3t-t0ken
secret/api-token created
$ kubectl create secret generic db-env --from-env-file=db.env
secret/db-env created
$ kubectl create secret docker-registry regcred --docker-server=ghcr.io \
--docker-username=example --docker-password=ghp_example --dry-run=client -o yaml
apiVersion: v1
data:
.dockerconfigjson: eyJhdXRocyI6eyJnaGNyLmlvIjp7InVzZXJuYW1lIjoiZXhhbXBsZSIsInBhc3N3b3JkIjoiZ2hwX2V4YW1wbGUiLCJhdXRoIjoiWlhoaGJYQnNaVHBuYUhCZlpYaGhiWEJzWlE9PSJ9fX0=
kind: Secret
metadata:
name: regcred
type: kubernetes.io/dockerconfigjson
$ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout tls.key -out tls.crt -days 30 -subj "/CN=hello.example.com"
$ kubectl create secret tls hello-tls --cert=tls.crt --key=tls.key
secret/hello-tls created
$ kubectl get secrets
NAME TYPE DATA AGE
api-token Opaque 1 1s
db-credentials Opaque 2 4m32s
db-env Opaque 2 1s
ghcr-pull kubernetes.io/dockerconfigjson 1 6m41s
hello-tls kubernetes.io/tls 2 0s
$ kubectl describe secret hello-tls | sed -n '/^Type/,$p'
Type: kubernetes.io/tls
Data
====
tls.crt: 599 bytes
tls.key: 241 bytes
docker-registry собирает тот же JSON, что docker login пишет в ~/.docker/config.json: под base64 — {"auths":{"ghcr.io":{"username":"example","password":"ghp_example","auth":"…"}}}. Тип проверяет API-сервер, а kubectl create secret tls ещё и сам разбирает PEM:
$ kubectl create secret generic bad-tls --type=kubernetes.io/tls --from-file=tls.crt
error: failed to create secret Secret "bad-tls" is invalid: data[tls.key]: Required value
$ kubectl create secret generic bad-basic --type=kubernetes.io/basic-auth --from-literal=user=app
error: failed to create secret Secret "bad-basic" is invalid: [data[username]: Required value, data[password]: Required value]
$ kubectl create secret tls bad-pair --cert=tls.crt --key=db.env
error: tls: failed to find any PEM data in key input
Reloader. Поставим его, повесим аннотацию на hello из прошлой части, переведём LOG_LEVEL на ConfigMap и изменим ConfigMap ещё раз:
$ kubectl annotate deployment hello reloader.stakater.com/auto=true
deployment.apps/hello annotated
$ kubectl set env deployment/hello --from=configmap/hello-config --keys=LOG_LEVEL
deployment.apps/hello env updated
$ kubectl rollout status deployment/hello
Waiting for deployment spec update to be observed...
…
deployment "hello" successfully rolled out
$ kubectl patch configmap hello-config --type=merge -p '{"data":{"LOG_LEVEL":"warn"}}'
configmap/hello-config patched
$ kubectl get deployment hello -o jsonpath='{range .spec.template.spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}'
STAKATER_HELLO_CONFIG_CONFIGMAP=92f99426d763ed6778fb609e85204f8a50781b25
LOG_LEVEL=
$ kubectl -n reloader logs deploy/reloader-reloader | tail -1
time="2026-10-28T15:10:21Z" level=info msg="Changes detected in 'hello-config' of type 'CONFIGMAP' in namespace 'demo'; updated 'hello' of type 'Deployment' in namespace 'demo'"
$ kubectl get rs -l app.kubernetes.io/name=hello
NAME DESIRED CURRENT READY AGE
hello-6b76bd67d4 3 3 3 18s
hello-6d5c6db6b4 0 0 0 5m5s
hello-7c87cc4dc9 0 0 0 42s
LOG_LEVEL= с пустым значением — это переменная из configMapKeyRef: у неё нет value, значение подставит kubelet. Reloader добавил в начало списка переменную с хешем, шаблон изменился, и Deployment выкатил новый ReplicaSet 6b76bd67d4 в ту же секунду, когда изменился ConfigMap, — без минутной задержки томов. Новые поды стартовали уже с LOG_LEVEL=warn. Три ReplicaSet-а — три шаблона: исходный 6d5c6db6b4 из прошлой части, 7c87cc4dc9 после set env и 6b76bd67d4 после изменения ConfigMap.
Наконец, Secret в etcd. etcd на стенде работает статическим подом на мастере, и в его образе есть клиент etcdctl; запустим его через crictl (клиент CRI — интерфейса, через который kubelet управляет containerd), а вывод разберём на мастере утилитой hexdump:
user@k8s-master-1:~$ E() { sudo crictl exec "$(sudo crictl ps --name etcd -q)" 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 "$@"; }
user@k8s-master-1:~$ E get /registry/secrets/demo/db-credentials | hexdump -C
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 6d 6f 2f 64 62 2d 63 72 65 64 65 6e |s/demo/db-creden|
00000020 74 69 61 6c 73 0a 6b 38 73 00 0a 0c 0a 02 76 31 |tials.k8s.....v1|
00000030 12 06 53 65 63 72 65 74 12 e1 04 0a a7 04 0a 0e |..Secret........|
00000040 64 62 2d 63 72 65 64 65 6e 74 69 61 6c 73 12 00 |db-credentials..|
...
00000130 22 73 74 72 69 6e 67 44 61 74 61 22 3a 7b 22 70 |"stringData":{"p|
00000140 61 73 73 77 6f 72 64 22 3a 22 5a 71 33 76 4e 38 |assword":"Zq3vN8|
00000150 72 54 31 77 58 36 70 4c 30 73 22 2c 22 75 73 65 |rT1wX6pL0s","use|
...
00000260 7b 7d 7d 42 00 12 1c 0a 08 70 61 73 73 77 6f 72 |{}}B.....passwor|
00000270 64 12 10 5a 71 33 76 4e 38 72 54 31 77 58 36 70 |d..Zq3vN8rT1wX6p|
00000280 4c 30 73 12 0f 0a 08 75 73 65 72 6e 61 6d 65 12 |L0s....username.|
00000290 03 61 70 70 1a 06 4f 70 61 71 75 65 1a 00 22 00 |.app..Opaque..".|
000002a0 0a |.|
000002a1
k8s\0 — метка формата protobuf API-сервера, а дальше в открытую, причём дважды: в аннотации last-applied-configuration (тот самый stringData из манифеста) и в самих данных — password, Zq3vN8rT1wX6pL0s. Даже не base64 — в etcd лежат сами байты. Включим шифрование secretbox по шагам из раздела выше и перезапишем секреты:
user@k8s-master-1:~$ sudo install -d -m 0700 /etc/kubernetes/enc
user@k8s-master-1:~$ KEY=$(head -c 32 /dev/urandom | base64)
user@k8s-master-1:~$ sudo tee /etc/kubernetes/enc/enc.yaml > /dev/null <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: [secrets]
providers:
- secretbox:
keys:
- name: key1
secret: ${KEY}
- identity: {}
EOF
user@k8s-master-1:~$ unset KEY; sudo chmod 0600 /etc/kubernetes/enc/enc.yaml
user@k8s-master-1:~$ sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml ~/kube-apiserver.yaml.orig
user@k8s-master-1:~$ sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml # флаг и том, как выше
Копию манифеста держим вне каталога manifests: kubelet запускает статическим подом каждый файл оттуда. API-сервер недоступен около полуминуты, пока kubelet пересоздаёт под; ещё секунд десять под числится 0/1 Running, пока не пройдёт проверку готовности:
$ kubectl -n kube-system get pod -l component=kube-apiserver
NAME READY STATUS RESTARTS AGE
kube-apiserver-k8s-master-1 1/1 Running 0 36s
$ kubectl get secrets -A -o json | kubectl replace -f -
secret/calico-apiserver-certs replaced
secret/node-certs replaced
secret/typha-certs replaced
secret/typha-certs-noncluster-host replaced
secret/ghcr-pull replaced
secret/api-token replaced
secret/db-credentials replaced
…
user@k8s-master-1:~$ E get /registry/secrets/demo/db-credentials | hexdump -C | head -8
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 6d 6f 2f 64 62 2d 63 72 65 64 65 6e |s/demo/db-creden|
00000020 74 69 61 6c 73 0a 6b 38 73 3a 65 6e 63 3a 73 65 |tials.k8s:enc:se|
00000030 63 72 65 74 62 6f 78 3a 76 31 3a 6b 65 79 31 3a |cretbox:v1:key1:|
00000040 fb 8b 0b 92 8d 47 31 ee 7c a5 d2 85 f2 56 bc 4e |.....G1.|....V.N|
00000050 96 b7 db 37 7e 3e 0c 7b 80 81 c4 b0 78 73 c4 2e |...7~>.{....xs..|
00000060 7f 63 46 3a 2c 9c a1 b9 af 64 a7 1b d9 31 79 17 |.cF:,....d...1y.|
00000070 55 e1 c0 28 f8 0f 3e 9f 08 31 24 0b 5b c0 67 39 |U..(..>..1$.[.g9|
$ kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
Zq3vN8rT1wX6pL0s
Теперь в etcd после ключа — префикс k8s:enc:secretbox:v1:key1:: провайдер, версия и имя ключа, которым API-сервер будет расшифровывать, — а дальше шифротекст; аннотация с stringData зашифрована вместе со всем объектом. ConfigMap по-прежнему лежит открытым (k8s\0 в начале значения): в конфигурации указаны только secrets. Для клиентов API ничего не изменилось: kubectl получает значение как раньше. Перезаписывать нужно все секреты, а не только новые: без replace старые так и остались бы открытыми, и прочитать их помог бы identity в конце списка — по префиксу API-сервер понимает, каким провайдером читать каждое значение.
Не забудьте добавить то же в ClusterConfiguration kubeadm и сохранить /etc/kubernetes/enc/enc.yaml в резервную копию отдельно от снимков etcd. Если же это был опыт на учебном кластере и шифрование нужно выключить, порядок обратный, и ключ удаляется последним: поставить identity первым, а secretbox с тем же ключом — вторым, перезапустить API-сервер, снова выполнить kubectl get secrets -A -o json | kubectl replace -f - (теперь значения пишутся открытыми, у всех ключей в etcd снова k8s\0), вернуть манифест из копии и только после этого удалить /etc/kubernetes/enc. Учебные объекты: kubectl delete pod cfg-demo, kubectl delete configmap hello-config-v3, kubectl delete secret api-token db-env hello-tls; Reloader — helm uninstall reloader -n reloader и kubectl delete namespace reloader.
Источники
- ConfigMaps — ключи
dataиbinaryData, предел 1 МиБ, тома и переменные, неизменяемые ConfigMap. - Configure a Pod to Use a ConfigMap —
--from-file,--from-env-file,envFrom,optional, задержка обновления томов иsubPathбез обновлений. - Secrets — типы Secret и их ключи,
stringData, tmpfs,immutable, долгоживущие токены ServiceAccount. - Managing Secrets using kubectl —
kubectl create secretи чтение значений. - Good practices for Kubernetes Secrets — шифрование, права RBAC, косвенный доступ через создание подов.
- Projected Volumes —
projected-том, Downward API и токен ServiceAccount. - Encrypting Confidential Data at Rest —
EncryptionConfiguration, таблица провайдеров, перезапись секретов, смена ключа, автоматическая перезагрузка, выключение шифрования. - Using a KMS provider for data encryption — KMS v1 и v2, конвертное шифрование, плагин.
- Authorization —
kubectl auth can-iи режимы авторизации, включая Node. - Reloader — аннотации, стратегии
env-varsиannotations, установка.
Конфигурация на месте, приложение запускается, но мы ни разу не задумывались, на какой узел попадёт под и почему именно туда. В примерах мелькали taints, tolerations и drain, а DaemonSet в части 8 закреплял поды за узлами через affinity.
Дальше — планировщик: как kube-scheduler выбирает узел, nodeSelector, affinity и anti-affinity, taints и tolerations, равномерное распределение по зонам и узлам, PodDisruptionBudget и что на самом деле делают kubectl cordon и kubectl drain.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.