Local Kubernetes Dev — Part 10: Configuration and secrets
Как отделить конфигурацию от кода через ConfigMap и Secret, почему base64 — это не шифрование, как не утащить секрет в git и как держать разные значения для dev и prod без копипасты манифестов.
В прошлой главе мы подняли рядом с 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.
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"Применяем и проверяем:
1kubectl apply -f configmap.yaml
2kubectl -n myapp get configmap myapp-config -o yamlТо же самое можно создать императивно, не написав ни строчки YAML — удобно для быстрых проверок:
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 — это кодирование, а не шифрование: оно обратимо одной командой и не даёт никакой конфиденциальности.
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 сам закодирует значения при сохранении:
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):
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 рекомендуют:
- Включить Encryption at Rest — шифрование секретов в etcd. Без него на проде они лежат открытым текстом (в base64).
- Настроить RBAC по принципу наименьших привилегий. Важный нюанс: право
listна секреты неявно раскрывает их содержимое, так что раздавать его направо и налево нельзя. - Ограничивать доступ к Secret конкретными контейнерами, которым он действительно нужен.
- Для серьёзных нагрузок — выносить секреты во внешние хранилища (HashiCorp Vault, облачные key vault'ы) через Secrets Store CSI Driver.
Относитесь к Secret как к настоящему секрету
Для локального dev-кластера на k3d можно не городить Vault — но привычку относиться к Secret как к настоящему секрету, а не к «base64-обфускации», лучше выработать сразу.
Прокидывание в под: env, envFrom, монтирование файлами
Создать ConfigMap и Secret — полдела. Теперь их значения надо доставить в контейнер. Есть три основных способа.
Способ 1: одна переменная из одного ключа (env + valueFrom)
Когда нужно прокинуть конкретный ключ под конкретным именем переменной:
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 целиком, не перечисляя каждый ключ:
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 как том: каждый ключ становится файлом, имя ключа — именем файла, значение — содержимым.
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. Под продолжит работать со старыми значениями, пока вы его не перезапустите:
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.
1# .gitignore
2secret.yaml
3*.secret.yaml
4.env1# 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:
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 (наложения) для каждого окружения, где лежат только отличия в виде патчей.
1k8s/
2 base/
3 kustomization.yaml
4 deployment.yaml
5 configmap.yaml
6 overlays/
7 dev/
8 kustomization.yaml
9 prod/
10 kustomization.yaml1# 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).
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 с дефолтами + по файлу оверрайдов на окружение, где лежат только отличия.
1# values.yaml — дефолты
2logLevel: info
3image:
4 tag: latest1# values-dev.yaml — только то, что отличается
2logLevel: debug1helm 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.