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:

  1. сгенерировать ключ (head -c 32 /dev/urandom | base64) и положить конфигурацию в /etc/kubernetes/enc/enc.yaml с правами 0600 для root — это ключ от всех секретов;

  2. добавить API-серверу флаг и том. В статическом поде /etc/kubernetes/manifests/kube-apiserver.yaml это сработает сразу, но kubeadm upgrade перегенерирует манифест, поэтому то же нужно записать в ClusterConfiguration kubeadm:

    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
    
  3. дождаться перезапуска API-сервера: kubelet следит за каталогом manifests и пересоздаёт статический под при изменении файла;

  4. перезаписать существующие секреты: шифрование применяется только при записи, а старые значения так и лежат открытыми. Команда из документации читает все 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.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.