В прошлой главе мы подняли рядом с myapp его зависимость — PostgreSQL. И сразу всплыл неудобный вопрос: а откуда сервис узнает, по какому адресу стучаться в базу, с каким логином и паролем? Захардкодить строку подключения прямо в коде или в Dockerfile — плохая идея: тогда один и тот же образ нельзя без пересборки запустить локально, в стейджинге и в проде. Правильный путь — отделить конфигурацию от кода. В Kubernetes для этого есть два объекта: ConfigMap для нечувствительных настроек и Secret для паролей, ключей и токенов.

В этой главе разберёмся, чем они отличаются, как прокинуть их значения в под (Pod — минимальная запускаемая единица в k8s, обёртка вокруг одного или нескольких контейнеров), почему base64 — это не шифрование, как не утащить секрет в git и как держать разные значения для dev и prod без копипасты манифестов.

ConfigMap: нечувствительные настройки и переменные окружения

ConfigMap — это API-объект Kubernetes для хранения нечувствительных данных в виде пар «ключ-значение». Его задача — сделать образ контейнера переносимым: вся среда-зависимая конфигурация живёт снаружи образа, а не внутри него (ConfigMaps, kubernetes.io).

Что важно знать про ConfigMap сразу:

  • Он не обеспечивает секретности. Это прямо написано в документации: для конфиденциальных данных нужен Secret, а не ConfigMap.
  • Лимит размера — 1 MiB. Если конфигурации больше, придётся монтировать том или использовать внешнее хранилище.
  • Есть два поля для данных: data (текст в UTF-8) и binaryData (бинарные данные в base64).
  • ConfigMap и под, который его использует, обязаны быть в одном namespace (namespace — логический раздел кластера; у нас это myapp).
  • Можно сделать ConfigMap неизменяемым: поле immutable: true (доступно с Kubernetes v1.19). Это защищает от случайных правок и снижает нагрузку на kube-apiserver.

Для myapp вынесем в ConfigMap всё нечувствительное: уровень логирования, имя базы, хост и порт PostgreSQL.

yaml
1# configmap.yaml
2apiVersion: v1
3kind: ConfigMap
4metadata:
5  name: myapp-config
6  namespace: myapp
7data:
8  LOG_LEVEL: "debug"
9  DB_HOST: "postgres"
10  DB_PORT: "5432"
11  DB_NAME: "myapp"

Применяем и проверяем:

bash
1kubectl apply -f configmap.yaml
2kubectl -n myapp get configmap myapp-config -o yaml

То же самое можно создать императивно, не написав ни строчки YAML — удобно для быстрых проверок:

bash
1kubectl -n myapp create configmap myapp-config \
2  --from-literal=LOG_LEVEL=debug \
3  --from-file=config.properties

Флаг --from-literal кладёт одну пару ключ-значение, --from-file — берёт содержимое файла (имя файла станет ключом, содержимое — значением).

Secret: пароли/ключи/токены и почему base64 ≠ шифрование

Для пароля от PostgreSQL ConfigMap не годится — нужен Secret. По структуре он почти такой же (пары ключ-значение), но предназначен для чувствительных данных и обрабатывается в кластере чуть аккуратнее.

И сразу — главное заблуждение новичков. Значения в Secret хранятся в base64, и многие думают, что это «зашифровано». Это не так. base64 — это кодирование, а не шифрование: оно обратимо одной командой и не даёт никакой конфиденциальности.

bash
1echo 'czNjcjN0' | base64 -d   # выведет: s3cr3t

Более того, в документации Kubernetes прямо сказано: «Kubernetes Secrets are, by default, stored unencrypted in the API server's underlying data store (etcd)» — то есть по умолчанию секреты лежат в хранилище кластера (etcd) без шифрования, просто в base64-кодировке (Secrets, kubernetes.io). Доступ к их содержимому имеет любой, у кого есть права на чтение через API, доступ к etcd или возможность создавать поды в этом namespace.

Создадим Secret для пароля базы. Чтобы не возиться с ручным base64-кодированием, используем поле stringData — API сам закодирует значения при сохранении:

yaml
1# secret.yaml
2apiVersion: v1
3kind: Secret
4metadata:
5  name: myapp-db-secret
6  namespace: myapp
7type: Opaque
8stringData:
9  DB_USER: "myapp"
10  DB_PASSWORD: "s3cr3t"

type: Opaque — это тип по умолчанию для произвольных пользовательских данных. Помимо него есть специализированные типы: kubernetes.io/dockerconfigjson (для доступа к приватным registry), kubernetes.io/tls (TLS-сертификаты), kubernetes.io/basic-auth, kubernetes.io/ssh-auth и другие — Kubernetes валидирует их структуру.

Тот же Secret императивно (пригодится дальше для sealed-secrets):

bash
1kubectl -n myapp create secret generic myapp-db-secret \
2  --from-literal=DB_USER=myapp \
3  --from-literal=DB_PASSWORD='s3cr3t' \
4  --dry-run=client -o yaml

Флаг --dry-run=client -o yaml не создаёт ничего в кластере, а печатает готовый манифест в stdout — удобно, когда нужно получить YAML, но не применять его сразу.

Раз base64 не защищает, какие меры реально нужны (особенно на проде)? Официальные good practices for Secrets рекомендуют:

  1. Включить Encryption at Rest — шифрование секретов в etcd. Без него на проде они лежат открытым текстом (в base64).
  2. Настроить RBAC по принципу наименьших привилегий. Важный нюанс: право list на секреты неявно раскрывает их содержимое, так что раздавать его направо и налево нельзя.
  3. Ограничивать доступ к Secret конкретными контейнерами, которым он действительно нужен.
  4. Для серьёзных нагрузок — выносить секреты во внешние хранилища (HashiCorp Vault, облачные key vault'ы) через Secrets Store CSI Driver.

Относитесь к Secret как к настоящему секрету

Для локального dev-кластера на k3d можно не городить Vault — но привычку относиться к Secret как к настоящему секрету, а не к «base64-обфускации», лучше выработать сразу.

Прокидывание в под: env, envFrom, монтирование файлами

Создать ConfigMap и Secret — полдела. Теперь их значения надо доставить в контейнер. Есть три основных способа.

Способ 1: одна переменная из одного ключа (env + valueFrom)

Когда нужно прокинуть конкретный ключ под конкретным именем переменной:

yaml
1env:
2  - name: LOG_LEVEL
3    valueFrom:
4      configMapKeyRef:
5        name: myapp-config
6        key: LOG_LEVEL
7  - name: DB_PASSWORD
8    valueFrom:
9      secretKeyRef:
10        name: myapp-db-secret
11        key: DB_PASSWORD

Способ 2: все ключи сразу (envFrom)

Если хочется прокинуть весь ConfigMap/Secret целиком, не перечисляя каждый ключ:

yaml
1envFrom:
2  - configMapRef:
3      name: myapp-config
4  - secretRef:
5      name: myapp-db-secret
6      optional: true
7    prefix: DB_

Здесь prefix добавляет приставку ко всем именам переменных из этого источника, а optional: true означает «не падай, если источника нет». По умолчанию optional равен false — и если под ссылается на несуществующий ConfigMap или Secret, он просто не стартует (Configure a Pod to Use a ConfigMap, kubernetes.io).

Способ 3: монтирование файлами (volume)

Иногда приложению удобнее читать конфиг из файла, а не из переменной окружения (например, TLS-сертификат). Тогда монтируем ConfigMap или Secret как том: каждый ключ становится файлом, имя ключа — именем файла, значение — содержимым.

yaml
1volumes:
2  - name: db-secret-vol
3    secret:
4      secretName: myapp-db-secret
5volumeMounts:
6  - name: db-secret-vol
7    mountPath: /etc/secrets
8    readOnly: true

После этого внутри контейнера появятся файлы /etc/secrets/DB_USER и /etc/secrets/DB_PASSWORD. Через поля items[].path и items[].mode можно переопределить имена файлов и права доступа.

Критическая разница: env не обновляется, volume — обновляется

Это та ловушка, на которую напарывается почти каждый. Переменные окружения фиксируются на старте пода и не обновляются, если вы поменяли ConfigMap или Secret. Под продолжит работать со старыми значениями, пока вы его не перезапустите:

bash
1kubectl -n myapp rollout restart deployment/myapp

А вот том, смонтированный из ConfigMap/Secret, обновляется автоматически (с небольшой задержкой на синхронизацию kubelet) — но только если монтирование сделано без subPath. Правда, и тут есть нюанс: само приложение должно уметь перечитывать файл, k8s лишь обновит его содержимое на диске.

Для myapp на FastAPI это означает: уровень логирования и строку подключения мы прокидываем через env (это нормально, они меняются редко и под всё равно перезапускается при деплое нового образа). А когда захочется «горячей» подмены конфига без рестарта — используем volume mount и читаем файл в рантайме.

Как не закоммитить секрет в git

Самый частый и самый болезненный прокол — закоммитить реальный Secret в репозиторий. Напомню: base64 — не защита, поэтому Secret-манифест с настоящим паролем в git равносилен утечке пароля. И git помнит всё: даже если вы потом удалите файл, значение останется в истории. Официальные good practices прямо советуют не хранить манифесты Secret в системе контроля версий (good practices, kubernetes.io).

Реальный Secret в git — это утечка пароля

base64 — не защита. Secret-манифест с настоящим паролем в git равносилен утечке этого пароля — и git помнит всё, так что удаление файла позже не убирает значение из истории.

Минимум для новичка: шаблоны + .gitignore

Коммитьте в git только шаблоны без реальных значений, а настоящие файлы держите вне git.

bash
1# .gitignore
2secret.yaml
3*.secret.yaml
4.env
yaml
1# secret.example.yaml — этот файл коммитим, он без секретов
2apiVersion: v1
3kind: Secret
4metadata:
5  name: myapp-db-secret
6  namespace: myapp
7type: Opaque
8stringData:
9  DB_USER: "CHANGE_ME"
10  DB_PASSWORD: "CHANGE_ME"

Коллега клонирует репозиторий, копирует secret.example.yaml в secret.yaml, подставляет реальные значения — и git их уже не увидит.

Для прода: Sealed Secrets

А что, если хочется хранить секреты в git по-настоящему — например, для GitOps, когда весь желаемый стейт кластера лежит в репозитории? Тогда секрет нужно зашифровать перед коммитом. Распространённое решение — Sealed Secrets от Bitnami (good practices упоминают его наряду с External Secrets Operator и SOPS).

Работает на асимметричной криптографии. В кластер ставится controller с парой ключей. CLI-утилита kubeseal шифрует ваш Secret публичным ключом и выдаёт новый объект — SealedSecret. Расшифровать его может только controller своим приватным ключом, уже внутри кластера. Поэтому SealedSecret безопасно коммитить даже в публичный репозиторий: расшифровать его не сможет никто, включая вас самих.

Типичный workflow:

bash
1# создаём обычный Secret как манифест, но не применяем, а шифруем
2kubectl -n myapp create secret generic myapp-db-secret \
3  --from-literal=DB_PASSWORD='s3cr3t' \
4  --dry-run=client -o yaml \
5  | kubeseal -o yaml > sealed-db-secret.yaml
6
7# sealed-db-secret.yaml безопасно коммитить
8git add sealed-db-secret.yaml && git commit -m "add sealed db secret"
9
10# в кластере controller сам развернёт его в настоящий Secret
11kubectl apply -f sealed-db-secret.yaml

Несколько вещей, о которых стоит помнить:

  • Шифрование привязано к паре namespace + имя: SealedSecret, созданный для одного namespace/имени, не расшифруется под другим.
  • Приватный ключ controller'а — это обычный Secret в его namespace. Ключи ротируются (по умолчанию раз в 30 дней), старые при этом сохраняются. Обязательно делайте бэкап приватного ключа: потеряете его — и уже закоммиченные SealedSecret превратятся в нечитаемый мусор.

Для локального dev на k3d Sealed Secrets обычно избыточны — хватит шаблонов и .gitignore. Но знать направление полезно: ровно этот же репозиторий поедет в CI и на прод (см. главу про подготовку к деплою и CI).

Разные значения для разных окружений без дублирования

myapp локально ходит в PostgreSQL внутри кластера с паролем-заглушкой и LOG_LEVEL=debug, а на проде — в managed-базу с настоящим паролем и LOG_LEVEL=info. Копировать ради этого все манифесты в две папки и поддерживать их синхронно — прямой путь к расхождениям. Есть два подхода, чтобы этого избежать. Здесь сосредоточимся на том, что уникально для конфигов, — генераторах configMapGenerator/secretGenerator; общую механику разложения по окружениям в связке с CI подробнее разбирает глава про подготовку к деплою.

Kustomize (встроен в kubectl)

Идея Kustomize: есть base с общими ресурсами и overlays (наложения) для каждого окружения, где лежат только отличия в виде патчей.

text
1k8s/
2  base/
3    kustomization.yaml
4    deployment.yaml
5    configmap.yaml
6  overlays/
7    dev/
8      kustomization.yaml
9    prod/
10      kustomization.yaml
yaml
1# overlays/dev/kustomization.yaml
2resources:
3  - ../../base
4configMapGenerator:
5  - name: myapp-config
6    behavior: merge
7    literals:
8      - LOG_LEVEL=debug

Здесь работают configMapGenerator и secretGenerator — они генерируют ConfigMap/Secret из литералов, файлов или env-файлов. Их фишка — content-based hash в суффиксе имени: при изменении содержимого меняется имя объекта (например, myapp-config-7c8f...), а Kustomize автоматически правит ссылки в Deployment. Изменилось имя → меняется Deployment → k8s сам делает rolling update. То есть проблему «поменял ConfigMap, а под работает на старых env» (см. раздел выше) Kustomize решает за вас (configMapGenerator, Kustomize reference).

bash
1kubectl kustomize overlays/dev | kubectl apply -f -
2# или короче:
3kubectl apply -k overlays/dev

Если hash-суффикс мешает (например, на имя ссылаются извне), его отключают через disableNameSuffixHash: true — но тогда автоматического перезапуска при смене конфига не будет. Поле behavior управляет тем, что генератор делает с одноимённым ресурсом из base: create (по умолчанию), replace или merge.

Helm (если используете чарты)

Если вы пакуете myapp в Helm-чарт (про упаковку myapp в чарт и values по окружениям), тот же принцип реализуется через файлы значений: один чарт + values.yaml с дефолтами + по файлу оверрайдов на окружение, где лежат только отличия.

yaml
1# values.yaml — дефолты
2logLevel: info
3image:
4  tag: latest
yaml
1# values-dev.yaml — только то, что отличается
2logLevel: debug
bash
1helm install myapp ./chart -f values.yaml -f values-dev.yaml
2helm upgrade myapp ./chart -f values-dev.yaml --set image.tag=abc123

Флаг -f можно указывать несколько раз — файлы сливаются сверху вниз, и при конфликте побеждает последний. Полный порядок приоритета (от низкого к высокому): дефолтный values.yaml чарта → значения родительского чарта → файлы из -f → флаги --set (Values Files, Helm Docs). Чтобы удалить ключ из дефолтов, присвойте ему null.

Какой подход выбрать — дело вкуса и того, что уже принято в команде. Kustomize не требует шаблонов и идёт «в комплекте» с kubectl; Helm даёт полноценные шаблоны и пакетирование. Оба решают одну задачу: одно описание сервиса, разные значения для разных мест — без копипасты.

Что запомнить

  • ConfigMap — для нечувствительного, Secret — для паролей и ключей. ConfigMap секретности не даёт, лимит 1 MiB, должен быть в одном namespace с подом.
  • base64 в Secret — это кодирование, а не шифрование. По умолчанию секреты лежат в etcd незашифрованными. На проде включайте Encryption at Rest и настраивайте RBAC.
  • Три способа доставки: env (один ключ), envFrom (все ключи), volume mount (файлы). env не обновляется без рестарта пода; volume — обновляется (без subPath).
  • Никогда не коммитьте реальные секреты. Минимум — шаблоны + .gitignore; для прода — Sealed Secrets / External Secrets / SOPS.
  • Разные окружения без дублирования — Kustomize (base + overlays, generator с hash-суффиксом) или Helm (один чарт + values-*.yaml).

В следующей главе займёмся сетью: как достучаться до myapp снаружи кластера и настроить Ingress.

Источники

Хотите чистую и безопасную работу с конфигами и секретами?
Нужна помощь с настройкой ConfigMap и Secret, тем, чтобы не утащить креды в git, или разложением значений по dev и prod? Помогу выстроить управление конфигурацией, которому можно доверять.