✻ Урок 5.6 · Тема 5: Kubernetes и Helm
ConfigMap и Secret
Содержание урока
Зачем это нужно
Образ (готовый к запуску «слепок» приложения, урок 4.2) notes:0.4.0 один, а окружений несколько: локально, в dev (тестовая среда разработчиков), в проде (боевая среда, где работают настоящие пользователи). Отличаются адрес базы данных, уровень логов (как подробно приложение пишет о своей работе), пароль. Если зашить это в образ, придётся собирать новый образ на каждое изменение, а пароль окажется в реестре образов (общем складе образов, откуда кластер их скачивает: например, ghcr.io), откуда его может скачать любой с доступом. Kubernetes отделяет настройки от образа: обычные лежат в ConfigMap, чувствительные (пароли, токены) в Secret.
На работе с этим сталкиваются каждый день: под не стартует из-за пропавшего ключа, конфиг поменяли, а приложение работает по-старому, пароль случайно попал в git. Отдельная ловушка: Secret выглядит зашифрованным, но это всего лишь base64. Base64 - способ записать любые данные обычными буквами и цифрами (так «пароль» превращается в 0L/QsNGA0L7Qu9GM); это как написать слово задом наперёд: прочитать может любой, кто знает приём, и никакой ключ не нужен. Подробно разберём ниже.
Шаг проекта: «Заметки» переходят с файла на PostgreSQL (STORE=postgres); обычные настройки берутся из ConfigMap notes-config, строка подключения DATABASE_URL из Secret notes-db. ConfigMap - объект кластера, в котором лежат обычные настройки в виде пар «ключ: значение». Secret - такой же объект, но для паролей и токенов. Строка подключения - одна строка с адресом базы, именем пользователя и паролем, например postgresql://notes:пароль@db:5432/notes: по ней приложение находит базу и входит в неё.
Что нужно знать
- Урок 5.2: поды и Deployment - мы правим
10-deployment.yaml, поэтому нужно понимать шаблон пода иrollout(выкатку: замену старых подов новыми при изменении Deployment) - Урок 5.3: Service и DNS кластера - адрес БД
dbэто DNS-имя Service внутри namespace - Урок 5.5: хранилище и StatefulSet - PostgreSQL уже работает в кластере, Secret
notes-dbс паролем уже создан - Урок 4.5: Compose и PostgreSQL - там пароль жил в
.env, теперь его место занимает Secret - Урок 4.2: Dockerfile - переменные окружения приложения (пары «имя=значение», которые операционная система передаёт запущенной программе, например
LOG_LEVEL=info) и почему образ не должен содержать конфигурацию (конфигурация - настройки, от которых зависит поведение программы, но которые не являются её кодом)
Картина целиком
Представь, что образ приложения это типовой бланк заявления, напечатанный тысячей экземпляров. Бланк одинаковый для всех, но в каждом отделении в него вписывают своё: адрес отделения, язык, город. Бланк не перепечатывают ради этого. Такое «вписываемое» и есть конфигурация.
- ConfigMap это лист с обычными реквизитами: адрес, уровень подробности логов, имя хранилища. Читать его может любой сотрудник отделения. Так и задумано.
- Secret это запечатанный конверт с паролем. Но конверт из прозрачной плёнки: кто получил доступ к конверту, видит содержимое. Защита не в непрозрачности, а в том, кому вообще разрешено брать конверт.
- Под это сотрудник, которому перед сменой выдают бланк, лист и конверт.
- Kubelet (агент на узле, который запускает контейнеры) это тот, кто раскладывает выданное по местам перед началом смены.
flowchart TD
CM["ConfigMap notes-config<br>STORE=postgres, HOST=0.0.0.0,<br>PORT=8080, LOG_LEVEL=info"] -->|"envFrom (все ключи)"| K["kubelet собирает окружение"]
SE["Secret notes-db<br>POSTGRES_PASSWORD=..., DATABASE_URL=postgresql://notes:...@db:5432/notes"] -->|"secretKeyRef (один ключ)"| K
K --> P["под notes: переменные STORE, HOST, PORT, LOG_LEVEL, DATABASE_URL"]
P --> A["app.py читает их через os.environ"]
Аналогия ломается на обновлении: если лист в отделении заменили, сотрудник, уже начавший смену, продолжает работать по старой копии. В Kubernetes так же: переменные окружения читаются один раз при старте, поэтому после правки ConfigMap поды нужно перезапустить.
За урок ты разберёшь: что такое переменные окружения и почему конфигурацию отделяют от образа, как устроены ConfigMap и Secret, чем base64 отличается от шифрования, как настройки доходят до контейнера и что происходит, когда что-то пропало, как менять конфигурацию так, чтобы поды её подхватили.
Теория
Зачем отделять настройки от образа
Начнём с проблемы. Образ (image) это готовый неизменяемый «слепок» приложения: код, интерпретатор, библиотеки. Его собирают один раз, кладут в реестр (registry, склад образов, например ghcr.io) и запускают из него контейнеры. Хорошая практика: образ собирается один раз, а потом тот же образ идёт по цепочке dev, test, prod без пересборки. Тогда тестируется именно то, что поедет в прод.
Но между окружениями различаются вещи:
| Настройка | dev | prod |
|---|---|---|
| адрес базы данных | db-dev.internal:5432 |
db-prod.internal:5432 |
| уровень логов | debug |
info |
| пароль базы | dev-password |
длинная случайная строка |
Если вписать это в образ (в Dockerfile или в код), получится два зла. Первое: на каждое изменение нужно пересобирать образ, и образы dev и prod перестают быть одним и тем же образом. Второе: пароль оказывается внутри образа, а образ лежит в реестре и в кэшах на каждом узле. Любой, кто скачал образ, может достать пароль: из образа он не стирается, даже если в следующей сборке файл удалили.
Решение известно давно как принцип «конфигурация в окружении» (одна из рекомендаций методологии Twelve-Factor App): образ не знает, где он запущен, а настройки ему передают снаружи при запуске. Ты уже делал так в Docker: docker run -e STORE=postgres ... и .env в Compose (урок 4.5). Kubernetes делает то же самое, но хранит настройки в своих объектах: ConfigMap и Secret.
Осторожно, путаница: «Настройки можно оставить в коде, а поменять пересборкой, это же надёжнее». Проблема не в надёжности, а в том, что образ перестаёт быть одним и тем же между окружениями, а пароли попадают в реестр. Отличить просто: для смены значения нужна пересборка образа? Значит, настройка зашита.
Прикинь сам: образ с зашитым паролем лежит в реестре. В следующей сборке ты удалил файл с паролем. Безопасно ли это?
Нет: пароль остался в слоях старого образа, а старые образы лежат в реестре и в кэшах узлов. Пароль нужно считать раскрытым и сменить.
Проверь понимание: назови две причины, по которым пароль базы нельзя писать в Dockerfile или в код.
Ответ
Во-первых, пароль окажется внутри образа и будет доступен всем, кто его скачает, и останется в слоях образа, даже если позже его удалить. Во-вторых, для другого окружения понадобится другой пароль, значит, другая сборка, и образы dev и prod перестанут быть одним и тем же образом.
Главное: образ собирают один раз и передают ему настройки снаружи; пароль в образе или коде это утечка.
Настройки приложение получает через переменные окружения, посмотрим, как они устроены.
Переменные окружения: как приложение получает настройки
Переменная окружения (environment variable) это пара «имя = значение», которую операционная система передаёт запущенной программе. Например, в терминале LOG_LEVEL=debug python3 app.py запускает программу и сообщает ей: «переменная LOG_LEVEL равна debug». Программа спрашивает у системы значение по имени и ведёт себя соответственно. Такие переменные уже были в курсе: в уроке 1.8 файл /etc/notes/notes.env передавал их «Заметкам».
Как читает их наше приложение (это код app.py версии v4 из урока 4.4, напоминание):
HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8080"))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper()
STORE = os.environ.get("STORE", "file")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
os.environ.get("HOST", "127.0.0.1") значит «возьми переменную HOST, а если её нет, используй 127.0.0.1». Так у приложения есть разумные значения по умолчанию.
Одна важная особенность, о которую спотыкаются все: значение переменной окружения это всегда строка. Даже PORT=8080 для программы это текст "8080", и поэтому код превращает его в число через int(...). Это же причина правила ниже: в ConfigMap значения обязаны быть строками.
Ещё одно свойство, которое понадобится дальше: окружение процесса фиксируется в момент запуска. Когда программа стартовала, у неё уже есть копия переменных. Если после этого кто-то поменял «исходное» значение, работающая программа этого не увидит, пока её не перезапустят.
Разобранный пример. Ты запускаешь LOG_LEVEL=info python3 app.py. Программа стартует, читает LOG_LEVEL=info и пишет обычные логи. Потом в другом терминале ты выполняешь export LOG_LEVEL=debug. Работающая программа по-прежнему пишет info: её копия окружения не изменилась. Чтобы применить debug, программу нужно остановить и запустить заново.
Осторожно, путаница: «Я поменял переменную, значит, программа тоже поменяет поведение». Нет: меняется только окружение будущих запусков. Отличить от бага просто: если программа не перезапускалась, старое поведение ожидаемо.
Прикинь сам: программа запущена с
LOG_LEVEL=info, а в другом терминале ты выполнилexport LOG_LEVEL=debug. Что пишет программа?
По-прежнему info: окружение копируется при запуске, и работающая программа изменения не увидит. Нужен перезапуск.
Проверь понимание: в контейнере
PORT=8080. Какого типа значение получит Python доint(...)и почему?
Ответ
Строку "8080". Переменные окружения хранятся как текст, поэтому программа сама превращает их в числа или другие типы.
Главное: переменная окружения это всегда строка, и окружение фиксируется в момент запуска процесса.
В Kubernetes настройки лежат в объекте ConfigMap.
ConfigMap: настройки отдельно от образа
ConfigMap («карта конфигурации») это объект Kubernetes с парами «ключ: значение». Он лежит в namespace (напомним: namespace это «папка» для объектов кластера), любой под этого namespace может его подключить. Размер до 1 МиБ: он для настроек, а не для больших файлов и данных.
Так выглядит ConfigMap для «Заметок»:
apiVersion: v1
kind: ConfigMap
metadata:
name: notes-config
namespace: notes
data:
STORE: postgres
HOST: 0.0.0.0
PORT: "8080"
LOG_LEVEL: info
Разбор по частям. apiVersion: v1 и kind: ConfigMap говорят кластеру, какого типа объект. metadata.name это имя, по которому на него ссылаются поды. В data лежат пары. Обрати внимание на PORT: "8080" в кавычках: в YAML 8080 без кавычек это число, а ConfigMap принимает только строки, и кластер отклонит манифест с ошибкой вида cannot unmarshal number into Go struct field ConfigMap.data of type string. Кавычки превращают число в текст.
Есть три способа использовать ConfigMap в поде:
- Все ключи как переменные окружения:
envFromсconfigMapRef. Каждый ключ становится переменной с тем же именем. - Один ключ как одна переменная:
envсvalueFrom.configMapKeyRef. Можно задать другое имя переменной. - Как файлы: ConfigMap монтируется томом (напоминание: том это каталог, подключаемый в контейнер, см. урок 5.5), каждый ключ становится файлом в этом каталоге, а значение содержимым файла.
Переменные проще всего: приложение читает os.environ, как мы видели в app.py. Файлы удобны, когда программа ждёт конфигурационный файл целиком, например nginx.conf для веб-сервера nginx: в ConfigMap кладут весь текст конфига одним ключом, и он появляется в контейнере как файл.
flowchart LR
subgraph C["ConfigMap demo-config"]
K1["LOG_LEVEL: info"]
K2["GREETING: hello"]
end
subgraph D["Каталог /etc/demo в контейнере (том)"]
F1["/etc/demo/LOG_LEVEL<br>содержимое: info"]
F2["/etc/demo/GREETING<br>содержимое: hello"]
end
K1 --> F1
K2 --> F2
Осторожно, путаница: ConfigMap принимают за файл или за что-то, что «применяется» само. Это просто хранилище пар в кластере. Оно ничего не делает, пока под на него не сослался.
Прикинь сам: в ConfigMap ты написал
PORT: 8080без кавычек. Что ответитkubectl apply?
Ошибку: YAML читает 8080 как число, а в data допустимы только строки. Кавычки превращают число в текст.
Проверь понимание: зачем в ConfigMap значение
PORTзаписано как"8080"в кавычках?
Ответ
Значения в ConfigMap обязаны быть строками. Без кавычек YAML прочитает 8080 как число, и API-сервер отклонит манифест. Кавычки превращают число в строку.
Главное: ConfigMap хранит пары строк до 1 МиБ; подключают его переменными (
envFrom,configMapKeyRef) или файлами через том.
Способ подключения определяет, что произойдёт при изменении ConfigMap.
Как ConfigMap попадает в контейнер: переменные и файлы
Способ подключения определяет, что произойдёт при изменении ConfigMap. Разберём по шагам.
Переменные окружения. Перед запуском контейнера kubelet смотрит в описание пода, находит ссылки на ConfigMap, читает их значения и передаёт контейнеру как окружение. Дальше по правилу выше окружение зафиксировано. Если поменять ConfigMap позже, работающий контейнер ничего не узнает.
Файлы. Kubelet создаёт том с содержимым ConfigMap и монтирует его в контейнер. Kubelet периодически сверяет том с ConfigMap и обновляет файлы, обычно в пределах минуты-полутора. Поэтому файл в контейнере меняется сам. Но приложение должно перечитать файл: nginx, например, перечитывает конфиг по сигналу, а большинство программ читают его один раз при старте.
Сводка:
| Способ | Обновится ли в работающем поде | Что нужно для применения |
|---|---|---|
envFrom, env |
нет, окружение зафиксировано | перезапустить поды: kubectl rollout restart |
| файл из тома | да, через минуту-две | чтобы программа перечитала файл; иногда тоже рестарт |
файл через subPath |
нет, никогда | перезапуск подов |
subPath это способ смонтировать не весь каталог, а один файл из ConfigMap в нужное место. Он удобен, когда нельзя перекрывать весь каталог, но у такого файла нет автообновления: цена за удобство.
Разобранный пример. В ConfigMap LOG_LEVEL: info. Ты подключил его и переменной, и файлом. Потом поменял на debug. Через минуту-две в поде: переменная $LOG_LEVEL по-прежнему info, файл /etc/demo/LOG_LEVEL уже содержит debug. Именно это ты увидишь в практике.
Осторожно, путаница: «Поменял ConfigMap, а приложение работает по-старому, значит, Kubernetes сломан». Нет, это штатное поведение для переменных. Отличить от поломки: сравни значение в ConfigMap (kubectl get configmap) со значением внутри пода (kubectl exec ... printenv). Если различаются, дело в отсутствии перезапуска.
Прикинь сам: ты изменил
LOG_LEVELв ConfigMap, подключённом черезenvFrom. Поды работают. Какой уровень логов у них?
Прежний: окружение читается при старте контейнера. Новое значение подхватят только пересозданные поды (kubectl rollout restart).
Проверь понимание: ты изменил значение
LOG_LEVELв ConfigMap, подключённом черезenvFrom. Поды работают. Какой уровень логов у них?
Ответ
Прежний. Переменные окружения читаются при старте контейнера. Чтобы применить новое значение, поды нужно пересоздать: kubectl rollout restart deployment/notes.
Главное: переменные из ConfigMap не обновляются в работающем поде, файлы из тома обновляются через минуту-две, файл через
subPathне обновляется никогда.
Почему файлы обновляются, а subPath выпадает из правила, видно в устройстве тома.
Как работает обновление файлов из ConfigMap
Разберём подробнее, почему файлы из ConfigMap обновляются, а переменные нет, и почему subPath выпадает из общего правила. Это пригодится, когда «конфиг поменял, а приложение не заметило».
Когда kubelet монтирует ConfigMap как том, он не кладёт файлы прямо в каталог. Он создаёт внутри служебный каталог с отметкой времени и делает в нём настоящие файлы, а в каталоге монтирования размещает символические ссылки (symlink, «ярлык»: файл, который просто указывает на другой файл). Так выглядит содержимое /etc/demo внутри контейнера:
/etc/demo/
..data -> ..2026_09_30_10_00_00.123456789 (ярлык на текущую папку с файлами)
..2026_09_30_10_00_00.123456789/
LOG_LEVEL (настоящий файл)
GREETING
LOG_LEVEL -> ..data/LOG_LEVEL (ярлык, через который ты читаешь)
GREETING -> ..data/GREETING
Когда ConfigMap меняется, kubelet создаёт новую папку с новой отметкой времени, кладёт в неё свежие файлы и одним действием переключает ярлык ..data на новую папку. Смысл трюка: переключение ярлыка происходит мгновенно и целиком, поэтому программа никогда не увидит «половину старого конфига и половину нового». Старая папка после этого удаляется.
Теперь понятно, почему subPath не обновляется: при subPath в контейнер монтируется конкретный файл, а не каталог с ярлыками. Он привязан к старому файлу, а переключение ярлыка ..data его не касается. Поэтому subPath подходит для конфигов, которые читаются один раз при старте, и не подходит для тех, что должны обновляться на лету.
Задержка до минуты-полутора складывается из двух периодов: kubelet сверяет тома периодически (по умолчанию раз в минуту) плюс кэширует содержимое ConfigMap на короткое время. Гарантий «ровно через 10 секунд» нет: если нужен точный момент применения, делай рестарт.
Осторожно, путаница: «Файл обновился, значит, приложение уже использует новое значение». Файл и приложение это разные вещи: программа могла прочитать файл при старте и больше не заглядывать. Отличить: посмотри, умеет ли программа перечитывать конфиг (у неё обычно есть сигнал перезагрузки или отдельная команда).
Прикинь сам: почему конфиг, смонтированный через
subPath, не обновляется при изменении ConfigMap?
В контейнер смонтирован один конкретный файл, а не каталог с ярлыками. Переключение ярлыка ..data его не затрагивает.
Осторожно: файл в контейнере обновился, но программа могла прочитать его при старте и больше не заглядывать: нужен сигнал перезагрузки или рестарт.
Проверь понимание: почему конфиг, смонтированный через
subPath, не обновляется при изменении ConfigMap?
Ответ
Обычный том обновляется переключением ярлыка ..data на новую папку. При subPath в контейнер смонтирован один конкретный файл, а не каталог с ярлыками, поэтому переключение его не затрагивает, и он остаётся старым до пересоздания пода.
Главное: kubelet обновляет том одним переключением ярлыка
..data, поэтому программа не увидит половину старого и половину нового конфига.
Для паролей у Kubernetes есть похожий объект, но обращаться с ним нужно иначе.
Secret: тот же ConfigMap, но для секретов
Пароли и токены нельзя держать в ConfigMap: у ConfigMap нет отдельных мер защиты. Для секретов есть Secret, объект, устроенный так же (пары «ключ: значение», подключение переменными или файлами), но с особым обращением. Что именно защищено, мы разберём чуть ниже, потому что защита скромнее, чем принято думать.
Сначала о том, как хранятся значения. В поле data Secret значения лежат в base64. Это способ записи любых байтов обычными буквами и цифрами. Зачем он нужен: в YAML и JSON нельзя надёжно хранить произвольные байты (переводы строк, непечатаемые символы, бинарные ключи), а буквы, цифры, +, / и = можно. Это кодирование (encoding): обратимое превращение по известному правилу, без ключа. Не шифрование (encryption), где расшифровать можно только зная секретный ключ.
Как работает base64 на пальцах. Берутся байты текста, режутся на группы по три байта (24 бита), каждая группа режется на четыре кусочка по 6 бит, и каждый кусочек (число от 0 до 63) заменяется буквой из таблицы из 64 знаков A-Z a-z 0-9 + /. Разберём слово CHANGE_ME, которое ты будешь использовать в практике:
текст: C H A N G E _ M E
в байтах: 67 72 65 78 71 69 95 77 69
в base64: Q0hB TkdF X01F
результат: Q0hBTkdFX01F
Девять символов текста дают ровно три группы по три байта, поэтому запись получилась без = на конце. Если байтов не кратно трём, добавляются знаки = для выравнивания. Расшифровать Q0hBTkdFX01F может любой одной командой base64 -d, ключ не нужен. Поэтому запись cGFzc3dvcmQ= в чате это не «зашифрованный пароль», а слово password.
Что реально защищает Secret по сравнению с ConfigMap:
- Отдельные права. В Kubernetes доступ к объектам регулируется правилами RBAC (Role-Based Access Control, «доступ по ролям», подробно в уроке 5.12). Можно разрешить человеку читать ConfigMap и запретить читать Secret.
kubectl describe secretне печатает значения, только размеры.- Kubelet держит Secret на узле в tmpfs (файловая система в оперативной памяти), а не записывает на диск узла.
Чего Secret не делает. В etcd (база данных, где кластер хранит всё своё состояние) Secret по умолчанию лежит без шифрования. Любой, у кого есть право get на Secret, получает его значение: kubectl get secret -o yaml отдаёт base64, которое декодируется за секунду. Шифрование etcd (encryption at rest) включают администраторы кластера, в управляемых (managed) кластерах облаков оно обычно уже включено.
Есть удобное поле stringData: в манифесте можно написать значение обычным текстом, кластер сам сложит его в data в base64. Читать stringData обратно нельзя: в get вернётся только data.
Из всего этого следует главное правило: манифест с настоящим паролем в git не кладут, даже в base64. Это не защита, а обычный текст с лишним шагом. В этом уроке Secret создаётся командой, и это осознанный долг проекта: он закроется в уроке 9.2, где секреты будут приходить из специального хранилища.
Осторожно, путаница: «Base64 это шифрование, потому что не читается глазами». Отличить просто: если для превращения обратно нужна только команда, а не секретный ключ, это кодирование.
Прикинь сам: коллега прислал в чат
cGFzc3dvcmQ=и говорит, что это надёжно зашифровано. Что ответишь?
Это base64: echo cGFzc3dvcmQ= | base64 -d даёт password. Ключ не нужен, значит, это кодирование, а не шифрование.
Проверь понимание: коллега прислал в чат
cGFzc3dvcmQ=и говорит, что это надёжно зашифровано. Что ты ответишь?
Ответ
Это base64: echo cGFzc3dvcmQ= | base64 -d даёт password. Никакого ключа не нужно, поэтому это не шифрование. Base64 нужен, чтобы в YAML помещались произвольные байты.
Главное: base64 это обратимое кодирование без ключа; в etcd Secret по умолчанию без шифрования, а защищают его права доступа.
У Secret есть типы, которые подсказывают, что внутри.
Типы Secret
У Secret есть поле type. Оно подсказывает Kubernetes и другим программам, что лежит внутри, и включает проверку формата. Основные типы:
| Тип | Что внутри | Для чего |
|---|---|---|
Opaque |
любые пары «ключ: значение» | по умолчанию, пароли и токены; наш notes-db |
kubernetes.io/tls |
ключи tls.crt и tls.key |
сертификат и приватный ключ для HTTPS (Ingress и Gateway) |
kubernetes.io/dockerconfigjson |
файл .dockerconfigjson |
логин и пароль к реестру образов, чтобы узел мог скачать закрытый образ |
kubernetes.io/basic-auth |
ключи username и password |
пара логин и пароль |
Слово Opaque значит «непрозрачный»: Kubernetes ничего не знает о структуре и не проверяет её. Для остальных типов проверяет: например, Secret типа kubernetes.io/tls без ключа tls.key API отклонит.
Разобранный пример. Когда узлу нужно скачать образ из закрытого реестра, ему нужен логин и пароль. Их кладут в Secret типа dockerconfigjson и ссылаются на него в поде полем imagePullSecrets. Без этого будет ImagePullBackOff с текстом unauthorized. Ты встретишь это в облаке и при работе с приватными реестрами.
Осторожно, путаница: «Тип Secret влияет на безопасность». Нет, все типы хранятся одинаково (base64 в etcd). Тип только помогает проверить формат и подсказывает, для чего Secret.
Прикинь сам: кластер не может скачать образ из закрытого реестра и пишет
ImagePullBackOffсunauthorized. Какой Secret нужен?
Тип kubernetes.io/dockerconfigjson с логином и паролем реестра, на который ссылается imagePullSecrets в поде.
Проверь понимание: в каком случае нужен Secret типа
kubernetes.io/dockerconfigjson?
Ответ
Когда кластер должен скачивать образ из закрытого реестра: логин и пароль к реестру лежат в таком Secret, на него ссылаются через imagePullSecrets в поде.
Главное:
Opaqueпо умолчанию, остальные типы проверяют формат; тип не влияет на безопасность.
Раз защита Secret строится на правах доступа, важны привычки обращения.
Как безопасно обращаться с Secret
Раз защита Secret построена на «кто может прочитать», важны привычки, которые не расширяют круг лиц без нужды.
- Минимум прав. Не давать право читать Secret тем, кому оно не нужно. Учти обход: если у человека есть право создавать поды в namespace, он может создать под, который подключит нужный Secret и выведет значение в лог. Поэтому право «создавать поды» фактически близко к праву «читать секреты этого namespace».
- Минимум ключей.
envFromсsecretRefотдаёт приложению все ключи Secret, аenvсsecretKeyRefтолько названный. У нашегоnotes-dbдва ключа:POSTGRES_PASSWORDнужен базе, а приложению достаточноDATABASE_URL. Значит, для «Заметок» берём толькоDATABASE_URL. Принцип называется «наименьших привилегий» (least privilege): каждому доступно только то, что нужно для работы. - Файлы вместо переменных, когда можно. Переменные окружения видны в
printenv, могут попасть в отчёт при аварии программы и наследуются дочерними процессами. Секрет, смонтированный файлом, читается только тогда, когда программа сама откроет файл. Для нашего приложения переменная проще, поэтому мы её и используем, но знай об этой разнице. - Не печатать в логи и не вставлять в чаты. Значение, один раз попавшее в лог или репозиторий, считается скомпрометированным (раскрытым), его нужно сменить.
- Вне git. Варианты хранения секретов в GitOps-подходе (когда в git лежит вся конфигурация кластера): Sealed Secrets и SOPS (в git лежит зашифрованный манифест), External Secrets Operator (в git лежит только ссылка на значение из хранилища вроде Vault). Разберём в уроке 9.2.
Разобранный пример принципа наименьших привилегий. В namespace notes два ключа в Secret: пароль базы и строка подключения. Приложению нужна только строка. Если подключить весь Secret через envFrom, то при утечке переменных окружения из пода злоумышленник получит оба значения. Если подключить один ключ, второй в поде вообще отсутствует.
Осторожно, путаница: «Внутри кластера все свои, значит, секреты можно раздавать свободно». Кластер делят разные команды и сервисы, и права нужно ограничивать так, как будто в него уже может попасть посторонний.
Прикинь сам: приложению нужен только
DATABASE_URL, а вnotes-dbдва ключа. Как подключить Secret?
Через env с secretKeyRef на один ключ. Тогда POSTGRES_PASSWORD в поде вообще отсутствует и не утечёт из окружения.
Проверь понимание: чем
envFromсоsecretRefхужеenvсsecretKeyRefдля нашего приложения?
Ответ
envFrom передаёт в контейнер все ключи Secret, включая POSTGRES_PASSWORD, который приложению не нужен. secretKeyRef берёт только нужный ключ, поэтому лишнее не попадает в окружение и не может утечь оттуда.
Главное: минимум прав, минимум ключей, не печатать в логи и не класть в git; право создавать поды близко к праву читать секреты namespace.
Что будет, если ссылка на ConfigMap или Secret ведёт в пустоту.
Как настройки доходят до контейнера и что бывает при ошибке
Перед запуском контейнера kubelet «собирает» его окружение: идёт по ссылкам из манифеста, читает ConfigMap и Secret и готовит значения. Если хоть одна ссылка ведёт в пустоту (нет такого ConfigMap, Secret или ключа в нём), контейнер даже не создаётся. Под получает статус CreateContainerConfigError.
Этот статус легко перепутать с другими. Сравним типичные причины, по которым под не работает:
| Статус | На каком шаге сломалось | Где искать причину |
|---|---|---|
ImagePullBackOff |
не удалось скачать образ | describe pod, события |
CreateContainerConfigError |
не собралась конфигурация: нет ConfigMap, Secret или ключа | describe pod, события; логов нет |
CrashLoopBackOff |
контейнер запустился, но процесс завершился с ошибкой | logs, logs --previous |
Главное различие: при CrashLoopBackOff контейнер работал и что-то писал в логи, а при CreateContainerConfigError контейнера не было вообще, поэтому kubectl logs пуст. Причина указана в событиях kubectl describe pod, например: Error: couldn't find key DATABASE_URL in Secret notes/notes-db.
Есть тонкость. Если подключать Secret целиком через envFrom, а нужного ключа в нём нет, ошибки не будет: подключатся те ключи, которые есть, а приложение потом «удивится» отсутствию переменной. Если же ссылаться на конкретный ключ через secretKeyRef, отсутствие ключа даёт CreateContainerConfigError сразу. Для важных настроек, как строка подключения к базе, это лучше: ошибка видна там, где её причина, а не позже и в другом месте.
Ссылку можно объявить необязательной: optional: true. Тогда отсутствие объекта не блокирует запуск, но приложение получит пустую конфигурацию. Для строки подключения к базе optional не ставят.
Прикинь сам: под в
CreateContainerConfigError, аkubectl logsпуст. Почему?
Контейнер ни разу не запускался, логов нет. Причина в kubectl describe pod, раздел Events.
Осторожно: при envFrom отсутствие ключа ошибки не даёт, а при secretKeyRef даёт сразу, и для важных настроек это лучше.
Проверь понимание: под в статусе
CreateContainerConfigError. Ты смотришьkubectl logs, и там пусто. Почему?
Ответ
Контейнер ни разу не запускался, поэтому логов нет. Причину нужно искать в kubectl describe pod (раздел Events) или в kubectl get events.
Главное: нет ConfigMap, Secret или ключа: контейнер не создаётся;
CrashLoopBackOffэто упавший процесс,CreateContainerConfigErrorэто не собранная конфигурация.
Теперь соберём правила применения изменённой конфигурации.
Как применить изменённую конфигурацию
Соберём правила обновления в порядок. Ты поменял ConfigMap или Secret. Что нужно сделать, чтобы приложение стало работать по-новому?
flowchart TD
A["правка ConfigMap или Secret"] --> B{"подключён как env?"}
B -->|да| C["kubectl rollout restart deployment/notes<br>старые поды заменяются новыми по одному, без простоя"]
B -->|"нет (файл)"| D["kubelet обновит файл за 1-2 минуты;<br>программа должна перечитать файл (или тоже рестарт)"]
kubectl rollout restart создаёт новые поды тем же плавным обновлением (rolling update), что и в уроке 5.2: новый под запускается, становится готов, и только потом уходит старый. Новые поды читают актуальные значения.
Вручную помнить про рестарт неудобно, поэтому в чартах Helm (урок 5.9) в шаблон пода кладут аннотацию с хэшем ConfigMap. Хэш (контрольная сумма) меняется, когда меняется содержимое. Шаблон пода изменился, значит, Deployment сам начинает выкатку. Ручной рестарт становится не нужен.
Ещё один приём: immutable: true. ConfigMap или Secret с этим полем нельзя изменить, только удалить и создать заново. Что это даёт: защита от случайной правки, которая мгновенно повлияет на все поды, и меньшая нагрузка на API-сервер (kubelet перестаёт следить за такими объектами). Обновляют их через новое имя: notes-config-v2, и меняют ссылку в Deployment. Смена ссылки меняет шаблон пода, значит, запускается выкатка, а старый ConfigMap остаётся для отката.
Осторожно, путаница: «immutable значит секретный». Нет, это защита от правки, а не от чтения.
Прикинь сам: ты используешь
immutable: trueи хочешь поменятьLOG_LEVEL. Как?
Изменить объект нельзя. Создай notes-config-v2, поменяй ссылку в Deployment и примени: шаблон пода изменился, начнётся выкатка.
Проверь понимание: ты используешь
immutable: trueи хочешь поменятьLOG_LEVEL. Как?
Ответ
Изменить существующий объект нельзя. Создай новый (например notes-config-v2) с новым значением, поменяй ссылку configMapRef.name в Deployment и примени. Изменился шаблон пода, значит, начнётся выкатка. Старый объект оставь на случай отката.
Главное: для переменных нужен
rollout restart, для файлов достаточно подождать и перечитать;immutableзащищает от правки, но не от чтения.
Как решить, что класть в ConfigMap, а что в Secret.
Что куда положить: ConfigMap или Secret
Правило выбора простое: если значение можно спокойно показать в публичном репозитории или на экране проектора, это ConfigMap. Если его раскрытие даёт кому-то доступ или вред, это Secret. Применим к «Заметкам»:
| Настройка | Пример | Куда | Почему |
|---|---|---|---|
| Тип хранилища | STORE=postgres |
ConfigMap | не даёт доступа |
| Адрес для прослушивания | HOST=0.0.0.0 |
ConfigMap | обычный параметр |
| Порт | PORT=8080 |
ConfigMap | не секрет |
| Уровень логов | LOG_LEVEL=info |
ConfigMap | меняется по ситуации |
| Версия в ответах | APP_VERSION=0.4.0 |
ConfigMap | справочная |
| Пароль базы | POSTGRES_PASSWORD |
Secret | даёт доступ к данным |
| Строка подключения | DATABASE_URL |
Secret | внутри пароль |
Пограничные случаи. Адрес базы без пароля (db:5432) сам по себе не секрет, но в нашей строке подключения он склеен с паролем, поэтому вся строка в Secret. Можно разделить: DB_HOST и DB_PORT в ConfigMap, DB_PASSWORD в Secret, а приложение само соберёт URL. Так лучше для гибкости, но в нашем app.py строка подключения единая, поэтому мы её не разбиваем.
Осторожно, путаница: «В ConfigMap можно класть пароль, если кластер закрыт». Отдельные права на Secret работают только если пароль лежит именно в Secret. Пароль в ConfigMap виден всем, кому разрешено читать ConfigMap, а это обычно куда более широкий круг.
Прикинь сам: в какой объект положишь токен доступа к внешнему API, а в какой адрес этого API?
Токен в Secret, адрес в ConfigMap: токен даёт доступ, адрес не секрет.
Проверь понимание: в какой объект положишь токен доступа к внешнему API, а в какой адрес этого API?
Ответ
Токен в Secret, адрес API в ConfigMap: токен даёт доступ и его раскрытие вредно, адрес не секрет.
Главное: можно показать на проекторе, это ConfigMap; раскрытие даёт доступ или вред, это Secret.
Проверить результат можно изнутри самого контейнера.
Как проверить, что контейнер получил настройки
Ты поправил манифест и применил его. Но работает ли приложение с новыми значениями, видно не из YAML, а из того, что реально лежит внутри пода. Нужна проверка «глазами контейнера».
Ты выдал сотруднику бланк, но не уверен, что он взял свежий, а не вчерашний. Проще всего заглянуть к нему на стол. Аналогия неточна тем, что сотрудник сам может взять не ту копию, а контейнер получает ровно то, что собрал kubelet при старте.
Команда kubectl exec запускает программу внутри работающего контейнера. Программа printenv ИМЯ печатает значение переменной окружения. Так ты видишь окружение так, как его видит приложение. Если значение старое, а ConfigMap уже новый, значит, под не перезапускали (переменные читаются один раз при старте).
kubectl -n notes exec deploy/notes -- printenv STORE LOG_LEVEL напечатает две строки: postgres и info. Порядок строк совпадает с порядком имён в команде. Если переменной нет, printenv ничего не печатает для неё и возвращает код 1: пустой вывод здесь ответ «такой переменной в окружении нет». Сравни с kubectl -n notes get configmap notes-config -o yaml: там лежит то, что должно прийти, а printenv показывает то, что пришло. Расхождение между ними почти всегда значит «не перезапустили поды».
Прикинь сам:
get configmapпоказываетLOG_LEVEL: debug, аprintenv LOG_LEVELв поде печатаетinfo. Почему и что сделать?
ConfigMap поменяли после старта пода, а окружение читается один раз. Перезапусти поды: kubectl -n notes rollout restart deployment/notes.
Осторожно: не думай, что значение в kubectl get configmap и значение в поде всегда совпадают. Совпадают только сразу после старта пода: потом ConfigMap можно изменить, а окружение уже запущенного процесса останется прежним.
Проверь понимание:
get configmapпоказываетLOG_LEVEL: debug, аprintenv LOG_LEVELв поде печатаетinfo. Почему и что сделать?
Ответ
ConfigMap поменяли после старта пода, а переменные окружения читаются один раз. Перезапусти поды: kubectl -n notes rollout restart deployment/notes.
Главное:
get configmapпоказывает, что должно прийти, аprintenvв поде показывает, что пришло.
Самая чувствительная настройка это строка подключения к базе.
Строка подключения к базе: DATABASE_URL
Наконец, о самой чувствительной настройке. Приложение подключается к PostgreSQL по строке подключения (connection string) вида URL:
postgresql://notes:s3cr3t@db:5432/notes
| | | | | |
протокол пользователь пароль хост порт база
Разбор: postgresql:// говорит, какой драйвер использовать; notes:s3cr3t это пользователь и пароль через двоеточие; после @ идёт хост (адрес сервера) db и порт 5432; после / имя базы notes. Хост db короткое DNS-имя сервиса из урока 5.5: под и база в одном namespace, поэтому достаточно короткого имени, а полное db.notes.svc.cluster.local тоже сработает.
Пароль в URL нужно кодировать (percent-encoding, «процентное кодирование»): символы вроде /, @, :, + имеют в URL особый смысл, поэтому пароль с ними нужно записывать как %2F, %40 и так далее. Иначе разбор обрежет пароль в неожиданном месте. Проще избежать проблемы: генерировать пароль из шестнадцатеричных символов (только цифры и a-f), как мы делаем командой openssl rand -hex.
Осторожно, путаница: «Строка подключения это обычная настройка, её можно положить в ConfigMap». Она содержит пароль, значит, это секрет. В ConfigMap кладут только то, что не страшно показать: тип хранилища, порт, уровень логов.
Прикинь сам: какие части строки
postgresql://notes:s3cr3t@db:5432/notesсекретны, а какие нет?
Секретен пароль s3cr3t. Остальное само по себе не секрет, но строка с паролем целиком хранится как Secret.
Проверь понимание: какие части строки
postgresql://notes:s3cr3t@db:5432/notesотносятся к секретам, а какие нет?
Ответ
Секретом является пароль s3cr3t. Хост, порт, имя пользователя и имя базы не секретны сами по себе, но строка целиком содержит пароль, поэтому её хранят как Secret.
Главное: строка подключения содержит пароль, поэтому её кладут в Secret; спецсимволы в пароле кодируют или берут пароль из hex-символов.
Теперь пора настроить «Заметки» на PostgreSQL.
Практика
Убедись, что кластер и namespace на месте. kubectl config use-context kind-notes переключает kubectl на твой учебный кластер (контекст это «какой кластер сейчас управляется»), get pods показывает поды:
kubectl config use-context kind-notes
kubectl -n notes get pods
Ожидается, что postgres-0 в статусе Running (колонка STATUS) и READY 1/1. Учебные объекты этого урока называются demo-* и в конце удаляются.
Задание 1. ConfigMap как переменные и как файлы
Цель: увидеть на практике, чем отличается обновление env-переменной от обновления файла.
Предскажи: ты подключишь один и тот же ConfigMap двумя способами и потом поменяешь в нём значение. Что изменится в поде: переменная, файл, оба или ничего?
Ответ
Файл изменится (не сразу, kubelet синхронизирует тома периодически, до минуты-полутора). Переменная останется прежней до пересоздания пода.
Шаги:
- Создай ConfigMap из литералов и посмотри его.
create configmap demo-configсоздаёт объект с именемdemo-config, каждый--from-literal=КЛЮЧ=значениедобавляет одну пару,-o yamlвыводит объект в виде YAML:
kubectl -n notes create configmap demo-config \
--from-literal=LOG_LEVEL=info \
--from-literal=GREETING=hello
kubectl -n notes get configmap demo-config -o yaml
- Создай под, который получает ConfigMap обоими способами. Сохрани как
demo-pod.yaml. Образpython:3.13-slimвзят только ради shell иcat, аcommand: ["sleep", "3600"]заставляет контейнер спать час, чтобы под не завершился:
apiVersion: v1
kind: Pod
metadata:
name: demo-pod
namespace: notes
spec:
containers:
- name: demo
image: python:3.13-slim
command: ["sleep", "3600"]
# Способ 1: все ключи как переменные окружения
envFrom:
- configMapRef:
name: demo-config
# Способ 2: ключи как файлы в каталоге
volumeMounts:
- name: cfg
mountPath: /etc/demo
volumes:
- name: cfg
configMap:
name: demo-config
Разбор манифеста: envFrom с configMapRef превращает все ключи ConfigMap в переменные окружения, volumeMounts монтирует том cfg в /etc/demo, а volumes объявляет, что том cfg берётся из ConfigMap demo-config.
- Запусти и проверь оба способа.
exec demo-pod -- sh -c '...'запускает в контейнере shell с командой. Внутри:echo "env: $LOG_LEVEL"печатает переменную,echo -n "file: "печатает подпись без перевода строки,cat /etc/demo/LOG_LEVELпечатает файл, а последнийechoдобавляет перевод строки (в файле его нет):
kubectl apply -f demo-pod.yaml
kubectl -n notes wait --for=condition=Ready pod/demo-pod --timeout=90s
kubectl -n notes exec demo-pod -- sh -c 'echo "env: $LOG_LEVEL"; echo -n "file: "; cat /etc/demo/LOG_LEVEL; echo'
- Поменяй значение и подожди минуту-две.
patch --type merge -p '{"data":{"LOG_LEVEL":"debug"}}'меняет только указанный ключ, остальное не трогает:
kubectl -n notes patch configmap demo-config --type merge -p '{"data":{"LOG_LEVEL":"debug"}}'
sleep 90
kubectl -n notes exec demo-pod -- sh -c 'echo "env: $LOG_LEVEL"; echo -n "file: "; cat /etc/demo/LOG_LEVEL; echo'
Что должно получиться:
env: info
file: info
и после правки:
env: info
file: debug
Как читать вывод: первая строка это значение из переменной окружения, вторая из файла. После get configmap -o yaml ты увидишь в блоке data обе пары (GREETING: hello и LOG_LEVEL: info) и служебные поля metadata с временем создания и uid: они у тебя будут другими. Главное в результате: значения в первой строке и во второй разошлись, потому что окружение зафиксировано при старте, а том обновился.
Объясни себе:
- Почему файл обновился, а переменная нет?
Типичные ошибки:
Error from server (AlreadyExists): configmaps "demo-config" already exists: объект уже есть, удалиkubectl -n notes delete configmap demo-configили используйapply.- Файл не обновился через 10 секунд: kubelet синхронизирует тома с задержкой, подожди до двух минут. Ещё причина: если монтировать ключ через
subPath, файл не обновляется вообще.
Задание 2. Secret: base64 это не шифрование
Цель: создать Secret, прочитать его обратно и убедиться, что защиты нет.
Предскажи: что покажет kubectl get secret -o yaml для значения, которое ты задал как CHANGE_ME: сам текст, случайную строку или что-то, что нельзя расшифровать без ключа?
Ответ
Строку в base64 (Q0hBTkdFX01F), которая декодируется без всякого ключа.
Шаги:
- Создай Secret командой, значение не остаётся в файле. Ты уже знаешь
create secret generic.describeпоказывает описание без значений:
kubectl -n notes create secret generic demo-secret \
--from-literal=API_TOKEN=CHANGE_ME
kubectl -n notes get secret demo-secret -o yaml
kubectl -n notes describe secret demo-secret
- Достань значение и декодируй.
-o jsonpath='{.data.API_TOKEN}'вырезает из объекта одно поле (путьdata, ключAPI_TOKEN),echoв конце добавляет перевод строки,| base64 -dдекодирует (-dот decode):
kubectl -n notes get secret demo-secret -o jsonpath='{.data.API_TOKEN}'; echo
kubectl -n notes get secret demo-secret -o jsonpath='{.data.API_TOKEN}' | base64 -d; echo
- Сравни с
stringData(значение обычным текстом, кластер сам кодирует).cat <<'YAML' | kubectl apply -f -подаёт текст манифеста на входkubectl apply,-f -значит «читай манифест со стандартного ввода»:
cat <<'YAML' | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: demo-secret-2
namespace: notes
type: Opaque
stringData:
API_TOKEN: CHANGE_ME
YAML
kubectl -n notes get secret demo-secret-2 -o jsonpath='{.data.API_TOKEN}'; echo
Что должно получиться:
Q0hBTkdFX01F
CHANGE_ME
Q0hBTkdFX01F
describe покажет только размеры значений, не сами значения:
Name: demo-secret
Namespace: notes
Labels: <none>
Annotations: <none>
Type: Opaque
Data
====
API_TOKEN: 9 bytes
Как читать вывод: Q0hBTkdFX01F это CHANGE_ME в base64 (разбор буквы за буквой был в теории), base64 -d возвращает исходный текст, а stringData даёт тот же результат в data, потому что кластер закодировал значение сам. В describe значения нет, только размер: 9 bytes это длина слова CHANGE_ME.
Объясни себе:
- Кто в кластере может выполнить шаг 2 и почему это важно для RBAC?
- Чем
stringDataудобнееdata, и почему оба варианта не подходят для git?
Типичные ошибки:
base64: invalid input: в команду попал лишний символ, например перевод строки при копировании. Используйjsonpathи pipe, как выше.error: failed to create secret secrets "demo-secret" already exists: Secret уже создан, удали его или используй--dry-run=client -o yaml | kubectl apply -f -.
Задание 3. Шаг проекта: «Заметки» работают на PostgreSQL
Цель: вынести конфигурацию notes в ConfigMap и Secret, переключить приложение на PostgreSQL и проверить, что заметки лежат в БД, а не в поде.
Предскажи: после переключения на STORE=postgres ты создашь заметку и удалишь все поды notes. Останется ли заметка?
Ответ
Да. Данные теперь хранятся в PostgreSQL на PVC, а не в emptyDir пода.
Шаги:
- Создай
k8s/base/50-configmap.yaml. Это те же настройки, что «Заметки» раньше получали вenvDeployment, только теперь они лежат отдельным объектом.APP_VERSIONпоказывает версию приложения в ответах:
apiVersion: v1
kind: ConfigMap
metadata:
name: notes-config
namespace: notes
labels:
app.kubernetes.io/name: notes
data:
# Хранилище: PostgreSQL вместо файла
STORE: postgres
# В контейнере слушаем все интерфейсы, порт всегда 8080
HOST: 0.0.0.0
PORT: "8080"
LOG_LEVEL: info
APP_VERSION: 0.4.0
Значение PORT в кавычках, потому что в ConfigMap значения только строки (разбор в теории). Поле HOST: 0.0.0.0 значит «слушать на всех сетевых интерфейсах»: в контейнере иначе Service не достучится (см. урок 2.1).
- Добавь в Secret
notes-dbключDATABASE_URL. Пароль уже задан в уроке 5.5 и записан в базу, поэтому берём именно его: смена пароля в Secret не меняет пароль внутри уже созданной БД. Разбор команды:PGPASS=$(...)кладёт пароль в переменную shell (в историю команд он не попадает, потому что мы не набираем его сами);patch secret notes-db --type merge -p '...'добавляет один ключ и не трогает остальные; вstringDataзначение пишется обычным текстом, кластер сам закодирует его в base64;${PGPASS}подставляет пароль;unset PGPASSстирает переменную.
# Достаём пароль из существующего Secret
PGPASS=$(kubectl -n notes get secret notes-db -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)
# Добавляем ключ DATABASE_URL (POSTGRES_PASSWORD остаётся как есть)
kubectl -n notes patch secret notes-db --type merge \
-p "{\"stringData\":{\"DATABASE_URL\":\"postgresql://notes:${PGPASS}@db:5432/notes\"}}"
unset PGPASS
kubectl -n notes describe secret notes-db
Манифест этого Secret в репозиторий не кладём. Адрес db короткий: под и БД в одном namespace, DNS из урока 5.3.
- Замени
k8s/base/10-deployment.yamlцеликом. Метки остаютсяapp.kubernetes.io/name: notes, как в уроке 5.2: селектор Deployment менять нельзя, и по нему находит поды Service из урока 5.3. Изменилось три вещи. Во-первых, добавленenvFromс ConfigMap: все настройки теперь приходят оттуда. Во-вторых,DATABASE_URLберётся из Secret одним ключом (secretKeyRef), а не всем Secret. В-третьих, убраны прямыеenv-значения и томemptyDir: файловое хранилище больше не нужно. Подставь свой GitHub-пользователь вместо<github-user>:
apiVersion: apps/v1
kind: Deployment
metadata:
name: notes
namespace: notes
labels:
app.kubernetes.io/name: notes
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: notes
template:
metadata:
labels:
app.kubernetes.io/name: notes
spec:
containers:
- name: notes
image: ghcr.io/<github-user>/notes:0.4.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
# Обычные настройки: все ключи ConfigMap становятся переменными окружения
envFrom:
- configMapRef:
name: notes-config
# Секрет: только один ключ, а не весь Secret
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: notes-db
key: DATABASE_URL
- Примени и дождись выкатки.
rollout statusждёт, пока все поды обновятся,exec deploy/notesзапускает команду в одном из подов Deployment,printenv STORE LOG_LEVELпечатает значения двух переменных:
kubectl apply -f ~/notes/k8s/base/50-configmap.yaml
kubectl apply -f ~/notes/k8s/base/10-deployment.yaml
kubectl -n notes rollout status deployment/notes
kubectl -n notes exec deploy/notes -- printenv STORE LOG_LEVEL
- Создай заметку, пересоздай поды и прочитай заметки снова.
port-forwardпробрасывает порт сервиса на твой компьютер,&запускает его в фоне,$!это номер фонового процесса,killего останавливает.curl -s -X POST localhost:8080/notes -d '{...}'отправляет заметку,curl -s localhost:8080/notesчитает список:
kubectl -n notes port-forward svc/notes 8080:8080 >/dev/null &
PF=$!
sleep 2
curl -s -X POST localhost:8080/notes -d '{"text":"first in postgres"}'; echo
kill $PF
kubectl -n notes delete pod -l app.kubernetes.io/name=notes
kubectl -n notes rollout status deployment/notes
kubectl -n notes port-forward svc/notes 8080:8080 >/dev/null &
PF=$!
sleep 2
curl -s localhost:8080/notes; echo
kill $PF
- Проверь, что переменные не обновляются сами, и примени изменение перезапуском:
kubectl -n notes patch configmap notes-config --type merge -p '{"data":{"LOG_LEVEL":"debug"}}'
kubectl -n notes exec deploy/notes -- printenv LOG_LEVEL
kubectl -n notes rollout restart deployment/notes
kubectl -n notes rollout status deployment/notes
kubectl -n notes exec deploy/notes -- printenv LOG_LEVEL
- Верни
LOG_LEVEL: info(kubectl apply -f ~/notes/k8s/base/50-configmap.yaml, затемkubectl -n notes rollout restart deployment/notes) и убери учебные объекты:
kubectl -n notes delete pod demo-pod
kubectl -n notes delete configmap demo-config
kubectl -n notes delete secret demo-secret demo-secret-2
rm -f demo-pod.yaml
Что должно получиться:
Name: notes-db
Namespace: notes
...
Data
====
DATABASE_URL: 81 bytes
POSTGRES_PASSWORD: 48 bytes
Дальше выкатка и переменные:
deployment "notes" successfully rolled out
postgres
info
{"id":2}
[{"id":1,"text":"первая заметка в кластере","created_at":"2026-09-29T09:41:07.512348+00:00"},{"id":2,"text":"first in postgres","created_at":"2026-09-29T10:00:00.123456+00:00"}]
info
deployment "notes" successfully rolled out
debug
Как читать вывод: 81 bytes это длина строки подключения (пароль 48 символов плюс postgresql://notes: и @db:5432/notes), а POSTGRES_PASSWORD остался прежним. printenv печатает значения: postgres и info. Заметка получила id 2, потому что заметка с id 1 («первая заметка в кластере») осталась в базе с урока 5.5, а это подтверждает, что данные лежат в PostgreSQL и пережили пересоздание подов. Время created_at и значения id у тебя могут отличаться. Строка info сразу после patch показывает, что переменная не обновилась; debug появилась только после rollout restart.
Манифесты лежат в твоём репозитории ~/notes/k8s/base/. После этого шага долг проекта: Secret хранится в кластере как base64 и вне git, закроется в уроке 9.2.
Объясни себе:
- Почему шаг 6 показал старое значение сразу после
patch? - Что будет, если пересоздать Secret
notes-dbс другимPOSTGRES_PASSWORD, а БД не трогать? - Почему Deployment берёт из Secret один ключ через
secretKeyRef, а не весь Secret черезenvFrom?
Типичные ошибки:
Error: couldn't find key DATABASE_URL in Secret notes/notes-db(статус подаCreateContainerConfigError): в Secret нет ключаDATABASE_URL. Повтори шаг 2.psycopg.OperationalError: connection failed: FATAL: password authentication failed for user "notes": вDATABASE_URLне тот пароль, что внутри БД. Возьми пароль изPOSTGRES_PASSWORD(шаг 2), а не придумывай новый.- Пароль содержит
/,@или+: такой символ ломает разбор URL. Генерируй паролиopenssl rand -hex: только цифры и буквыa-f. The Deployment "notes" is invalid: spec.selector: Invalid value ... field is immutable: в Deployment изменилиselector. Верниapp.kubernetes.io/name: notes, как в уроке 5.2.curl: (7) Failed to connect to localhost port 8080:port-forwardещё не успел запуститься или уже завершён. Запусти его заново и подожди пару секунд.
Нейросеть уверенно говорит «подождите минуту» про переменные окружения, а они сами не обновляются. Сверяй
get configmapиprintenvв поде, и не вставляй в чат настоящие значения секретов.
Сломай и почини
В этом разделе ты тренируешь главный навык дежурного: по симптому найти причину. Скрипт сам ломает конфигурацию «Заметок» в кластере, не заглядывай в него: диагностика и есть упражнение. Он работает только в namespace notes (Deployment notes, ConfigMap notes-config, Secret notes-db), запускай его на своём kind-кластере после задания 3.
Скачай скрипт. У curl флаг -f означает «при ошибке сервера не сохраняй страницу с ошибкой», -L разрешает переходить по перенаправлениям, -o задаёт имя файла:
curl -fsSL -o /tmp/break-5.6.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/5.6/break.sh
bash /tmp/break-5.6.sh 1
Сценарии: 1, 2 и 3. Запуск без sudo. Проходи по одному: запусти, найди причину, почини сам или командой bash /tmp/break-5.6.sh fix, потом бери следующий. Пока сценарий не исправлен, следующий скрипт не запустит. В конце выполни fix и удали скрипт: rm /tmp/break-5.6.sh.
Симптом
- Сценарий 1. После правки конфигурации выкатка застряла: часть подов
notesв статусеCreateContainerConfigError, при этом старые поды ещё работают. - Сценарий 2. Все поды
Running, ноPOST /notesотвечает ошибкой 500 и заметки не сохраняются. - Сценарий 3. В ConfigMap
notes-configзаписаноLOG_LEVEL: debug, а приложение пишет логи как раньше, на уровнеinfo.
Гипотезы
Прежде чем проверять, выпиши хотя бы по две возможные причины для каждого симптома:
- Под не стартует: ссылка на ключ или объект конфигурации указывает в пустоту (нет ключа в Secret, нет ConfigMap, опечатка в имени).
- Под запущен, но приложение не может работать с БД: адрес или пароль в
DATABASE_URLневерны, база не отвечает. - Конфигурацию поменяли, а поды работают по-старому: значение зафиксировано при старте, поды не перезапущены.
Проверки
Разбор: describe pod ... | grep -A8 Events печатает строку Events и восемь строк после неё (-A8, After 8), logs deploy/notes --tail=20 берёт 20 последних строк журнала одного из подов, sort сортирует вывод, grep -E 'A|B' оставляет строки с любым из слов. Значение DATABASE_URL содержит пароль: не вставляй его в чаты, смотри только хост.
kubectl -n notes get pods
kubectl -n notes describe pod -l app.kubernetes.io/name=notes | grep -A8 Events
kubectl -n notes logs deploy/notes --tail=20
kubectl -n notes get secret notes-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d | sed 's|//[^@]*@|//***@|'; echo
kubectl -n notes get configmap notes-config -o yaml
kubectl -n notes exec deploy/notes -- printenv | sort | grep -E 'STORE|LOG_LEVEL'
Команда sed 's|//[^@]*@|//***@|' заменяет всё между // и @ (пользователь и пароль) на звёздочки, чтобы пароль не показывался на экране.
Как читать вывод: в Events строка Warning ... Error: couldn't find key ... сразу называет пропавший объект. Если поды Running, а заметки не сохраняются, смотри logs: ошибка подключения к базе называет проблему (неверное имя хоста или пароль). Если значение в ConfigMap и в printenv расходятся, поды нужно перезапустить.
Исправление
Разбор всех сценариев
Сценарий 1: CreateContainerConfigError.
get pods показывает у нового пода CreateContainerConfigError, а logs пусты (контейнера не было). В describe в событиях:
Error: couldn't find key DATABASE_URL in Secret notes/notes-db
В Secret нет ключа. Старые поды остались, потому что обновление не заменяет их, пока новые не готовы. Добавь ключ обратно, как в шаге 2 задания 3, и примени kubectl -n notes rollout restart deployment/notes: новые поды соберут окружение и запустятся.
Сценарий 2: неверный DATABASE_URL.
Поды Running, потому что проб готовности ещё нет (они появятся в уроке 5.7), но POST /notes отвечает 500. В логах ошибка подключения: could not translate host name "db1" to address (ошибочное имя хоста). Сравни хост в DATABASE_URL с именем Service db и при необходимости с паролем в POSTGRES_PASSWORD. Исправь ключ в Secret и перезапусти поды: переменные читаются только при старте.
Сценарий 3: ConfigMap изменён, поды не перезапущены.
Ты видишь, что в ConfigMap LOG_LEVEL: debug, а printenv в поде показывает info. Ничего не сломано, так работают переменные окружения. Выполни kubectl -n notes rollout restart deployment/notes. Запомни урок: после любой правки ConfigMap или Secret, подключённых переменными, нужен рестарт.
ИИ в помощь
Нейросеть хорошо объясняет разницу между ConfigMap и Secret и разбирает события пода, но настоящие значения секретов ей показывать нельзя. Общие правила: ИИ-помощник.
Задача: выяснить, почему под не стартует из-за конфигурации.
Я учу Kubernetes. Под в статусе CreateContainerConfigError. Вот блок Events из kubectl describe pod:
<вставь строки без значений секретов>
Объясни, чего не нашёл kubelet (ConfigMap, Secret или ключ), и дай три проверки от самой дешёвой.
Проверь ответ: сверь с таблицей статусов из теории: логов нет, причина в событиях. Типичная ошибка: нейросеть предлагает смотреть kubectl logs, а контейнера не было.
Задача: разобрать, почему приложение не подхватило новую конфигурацию.
Я поменял ConfigMap notes-config, а приложение работает по-старому. ConfigMap подключён <вставь:
через envFrom, через env или как файл>. Объясни, почему так, и как применить значение.
Проверь ответ: сравни kubectl get configmap -o yaml и kubectl exec ... printenv. Типичная ошибка: нейросеть говорит «подождите минуту» про переменные окружения, а они сами не обновляются.
Задача: оформить настройки приложения в ConfigMap и Secret.
Вот список настроек приложения: <вставь имена и назначение, без значений секретов>.
Разнеси их по ConfigMap и Secret с пояснением и напиши манифесты. Для паролей оставь заглушки CHANGE_ME.
Проверь ответ: убедись, что строка подключения с паролем лежит в Secret, числа в ConfigMap в кавычках, а в манифесте нет настоящих значений. Типичная ошибка: пароль в ConfigMap «потому что кластер закрытый».
Словарик урока
| Термин | Простыми словами |
|---|---|
| Конфигурация | Настройки приложения, которые различаются между окружениями: адреса, уровень логов, пароли |
| Переменная окружения | Пара «имя = значение», которую система передаёт программе при запуске; значение всегда строка |
| ConfigMap | Объект Kubernetes с обычными настройками в виде пар «ключ: значение» |
| Secret | Объект для паролей и токенов; значения хранит в base64, защита строится на правах доступа |
| base64 | Способ записи любых байтов буквами и цифрами; обратим без ключа, это не шифрование |
| Кодирование и шифрование | Кодирование обратимо для всех, шифрование только для владельца ключа |
envFrom |
Подключить все ключи ConfigMap или Secret как переменные окружения |
configMapKeyRef, secretKeyRef |
Взять один ключ из ConfigMap или Secret в одну переменную |
stringData |
Поле Secret, где значение пишется обычным текстом, а кластер сам кодирует его в data |
| Том с ConfigMap | Каждый ключ появляется файлом в каталоге; файл обновляется сам, переменная нет |
subPath |
Монтирование одного файла из тома; такой файл никогда не обновляется автоматически |
CreateContainerConfigError |
Статус пода: контейнер не создан, потому что нет ConfigMap, Secret или ключа |
optional: true |
Ссылка на конфигурацию, отсутствие которой не мешает запуску |
rollout restart |
Заменить поды новыми тем же плавным обновлением, чтобы они перечитали конфигурацию |
immutable: true |
ConfigMap или Secret нельзя изменить, только заменить новым объектом |
| Принцип наименьших привилегий | Каждому даётся доступ только к тому, что нужно для работы |
| Строка подключения | URL вида postgresql://пользователь:пароль@хост:порт/база |
| etcd | База данных, где кластер хранит всё своё состояние, включая Secret |
| RBAC | Правила, кто в кластере что может делать с какими объектами |
| Реестр образов (registry) | Хранилище образов, откуда узлы скачивают образы для запуска |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем ConfigMap отличается от Secret и когда что использовать?
Ответ
ConfigMap для обычных настроек: уровень логов, адреса, флаги. Secret для паролей, токенов, ключей. Устроены почти одинаково, но Secret можно отдельно ограничить правами RBAC, он не печатается в describe и хранится на узле в памяти. Значения в Secret это base64, а не шифрование.
Что хотят услышать: оба объекта namespaced, лимит 1 МиБ, три способа подключения, что Secret защищён только RBAC и, при настройке, шифрованием etcd.
Красный флаг: «Secret зашифрован» или «в ConfigMap можно класть пароли, они же внутри кластера».
2. [junior] [часто] Твой коллега говорит: «Я закодировал пароль в base64, можно коммитить в git». Что скажешь?
Ответ
Нельзя. Base64 декодируется за секунду без ключа, значит пароль в репозитории раскрыт всем, у кого есть доступ к репозиторию, и остаётся в истории даже после удаления файла. Пароль нужно считать скомпрометированным и сменить. Секреты хранят вне git или шифруют специальными инструментами (они перечислены в вопросе 7).
Что хотят услышать: base64 не шифрование, ротация пароля, чистка истории не спасает, варианты: Sealed Secrets, SOPS, External Secrets с Vault.
Красный флаг: «ничего страшного, репозиторий приватный».
3. [middle] [часто] Ты поменял ConfigMap, а приложение работает по-старому. Твои действия?
Ответ
Проверю, как подключён ConfigMap. Если через env, значение фиксируется при старте: делаю kubectl rollout restart deployment/x и сверяю printenv в новом поде. Если через том, жду синхронизации kubelet и проверяю, перечитывает ли приложение файл. Если subPath, файл вообще не обновится, нужен рестарт.
Что хотят услышать: различие env и volume, rollout restart, хэш конфигурации в аннотации шаблона (Helm checksum), immutable с версионированием имени.
Красный флаг: «удалю поды руками» без понимания причины, или «перезапущу узел».
4. [junior] [на скорость] Как передать ConfigMap приложению: переменными или файлом?
Ответ
Переменными, если приложение читает окружение, а настроек немного. Файлом, если приложению нужен конфигурационный файл целиком, или значение должно обновляться без перезапуска. Учитываю, что env читаются при старте, а файл kubelet обновляет сам, но приложение должно его перечитать.
Что хотят услышать: envFrom против volumeMounts (монтирование тома), обновление файла с задержкой, subPath не обновляется.
Красный флаг: уверенность, что env подхватят изменение сами.
5. [junior] [на скорость] Что означает envFrom и чем отличается от env с valueFrom?
Ответ
envFrom импортирует все ключи ConfigMap или Secret как переменные. env с valueFrom берёт один конкретный ключ и позволяет задать другое имя переменной. Первый короче, второй точнее и не тянет лишнего. Для Secret я предпочитаю второй: приложение получит только нужный пароль, а не все ключи. Кроме того, если ключа нет, secretKeyRef даёт CreateContainerConfigError сразу, а envFrom промолчит.
Что хотят услышать: configMapKeyRef, secretKeyRef, при envFrom в приложение попадают все ключи Secret, включая ненужные.
Красный флаг: не различает и не знает про лишние ключи.
6. [middle] Под в статусе CreateContainerConfigError. Что делаешь?
Ответ
Смотрю kubectl describe pod, раздел Events: там точная причина, например couldn't find key DATABASE_URL in Secret. Логов нет, контейнер не создавался. Проверяю, что Secret или ConfigMap существует в том же namespace, что в нём есть нужный ключ и что имя в Deployment без опечаток. Исправляю объект, kubelet повторит запуск.
Что хотят услышать: отличие от CrashLoopBackOff, describe и события, namespace как частая ловушка.
Красный флаг: начинает с kubectl logs и не понимает, почему они пусты.
7. [middle] Как ты организуешь хранение секретов, если работаешь по GitOps и всё лежит в git?
Ответ
Секреты в открытом виде в git не нужны. Варианты: External Secrets Operator (ESO, программа в кластере) берёт значения из Vault (специальное хранилище секретов) или облачного хранилища и создаёт Secret в кластере; Sealed Secrets и SOPS (программы шифрования) хранят в git зашифрованный манифест, расшифровать который может только кластер или владелец ключа. Я предпочитаю ESO: в git только ссылка на секрет, ротация происходит в хранилище.
Что хотят услышать: ESO, Vault, SOPS с ключом age или облачным KMS (сервис хранения ключей шифрования), Sealed Secrets, ротация и аудит, отсутствие секретов в истории git.
Красный флаг: «храню в приватном репозитории в base64».
8. [middle] Как ограничить, кто может читать Secret в кластере?
Ответ
Через RBAC (правила доступа по ролям): даю Role (набор разрешённых действий) только на нужные ресурсы, не включаю secrets, особенно глаголы get, list, watch. Учитываю, что доступ к созданию подов в namespace тоже фактически даёт доступ к Secret через монтирование. На уровне кластера включаю шифрование etcd и ограничиваю доступ к самому etcd.
Что хотят услышать: kubectl auth can-i get secrets (проверка, разрешено ли действие), что list тоже раскрывает значения, права на создание подов как обход, encryption at rest.
Красный флаг: «Secret по умолчанию виден только своему приложению».
9. [middle] Пароль БД в Secret сменили на новый, поды перезапущены, а приложение получает password authentication failed. Почему?
Ответ
Пароль в Secret и пароль внутри PostgreSQL разные вещи. Переменная POSTGRES_PASSWORD влияет на БД только при первой инициализации каталога данных. Раз данные уже есть на PVC, пароль пользователя нужно менять командой ALTER USER notes PASSWORD ... внутри БД, а затем менять Secret и перезапускать приложение.
Что хотят услышать: порядок ротации, инициализация только на пустом томе, рестарт приложения, пароль без спецсимволов для URL или кодирование.
Красный флаг: «пересоздам поды, и БД подхватит».
10. [middle] Зачем immutable: true у ConfigMap и как с ним обновлять конфигурацию?
Ответ
Иммутабельность защищает от случайной правки, которая мгновенно отразится на всех подах, и снижает нагрузку на API-сервер, потому что kubelet перестаёт следить за объектом. Для изменения создаю новый объект с новым именем, например notes-config-v2, и меняю ссылку в Deployment, это запускает controlled rollout с возможностью откатить.
Что хотят услышать: версионирование имён, откат через rollout undo, автоматизация через Helm или Kustomize (configMapGenerator: он сам добавляет хэш содержимого в имя ConfigMap, урок 5.10).
Красный флаг: думает, что immutable можно отредактировать через kubectl edit.
11. [junior] Какие типы Secret бывают и зачем нужен тип?
Ответ
Тип говорит Kubernetes, какие ключи ожидать, и позволяет проверять формат. Самый частый Opaque: произвольные пары ключ-значение. kubernetes.io/tls хранит tls.crt и tls.key для Ingress и Gateway. kubernetes.io/dockerconfigjson хранит доступ к приватному реестру для imagePullSecrets. Есть ещё kubernetes.io/basic-auth, kubernetes.io/ssh-auth и токены ServiceAccount. Создаю, например, так: kubectl create secret tls NAME --cert=tls.crt --key=tls.key.
Что хотят услышать: Opaque, tls, dockerconfigjson, для чего каждый, валидация ключей.
Красный флаг: Знать только Opaque.
12. [middle] Шифруются ли Secret в etcd по умолчанию? Как это усилить?
Ответ
Нет. Secret по умолчанию лежат в etcd в открытом виде (base64 это кодирование, не шифрование). Включаю шифрование на уровне API-сервера через EncryptionConfiguration, лучше с внешним KMS-провайдером, чтобы ключ хранился не рядом с данными. В managed-кластере провайдер обычно предлагает это как опцию. Дополнительно ограничиваю RBAC на чтение Secret и защищаю бэкапы etcd, потому что в них тоже есть секреты.
Что хотят услышать: по умолчанию без шифрования, EncryptionConfiguration и KMS, RBAC, бэкапы etcd тоже содержат секреты.
Красный флаг: «Secret зашифрован, ведь он base64».
13. [junior] [на скорость] Есть ли ограничение на размер ConfigMap и Secret? Что делать с большим файлом?
Ответ
Да, объект не может быть больше 1 MiB: это ограничение Kubernetes на данные ConfigMap и Secret (у etcd свой лимит запроса, по умолчанию 1,5 MiB, и он настраивается). Большие файлы (модели, дампы, тяжёлые конфиги) в ConfigMap не кладу. Кладу их в образ, в том на PVC, в объектное хранилище или скачиваю init-контейнером при старте. ConfigMap оставляю для небольших конфигов.
Что хотят услышать: около 1 MiB, etcd, альтернативы: образ, том, init-контейнер, объектное хранилище.
Красный флаг: «Лимита нет, кладу что угодно».
Проверено на версиях
Проверено командами на этом курсе (без кластера Kubernetes):
- манифесты ConfigMap, Deployment и Pod из урока:
kubeconform -strictбез замечаний; неверный ConfigMap (число без кавычек вdata)kubeconformотклоняет:got number, want null or string; - base64 разбор
CHANGE_MEиcGFzc3dvcmQ=проверен командойbase64; - скрипт
break/5.6/break.sh:shellcheckбез замечаний, логика проверена чтением, в кластере не запускался.
Не прогонялось в кластере kind, вывод команд kubectl взят из предыдущей редакции урока и сверен по знанию формата (значения AGE, uid, время в created_at и id у тебя будут другими):
- kind: v0.33.0, Kubernetes: 1.37.1 (образ узла
kindest/nodeизkind/kind.yaml), kubectl: 1.37.1; - PostgreSQL: 18 (образ
postgres:18), Python: 3.13 (образpython:3.13-slim); - приложение «Заметки»: v4, образ
notes:0.4.0.
Итог урока: ты умеешь
- объяснить, почему настройки отделяют от образа, и что переменная окружения фиксируется при старте
- создать ConfigMap из литералов и манифеста и подключить его переменными и файлами
- создать Secret командой и прочитать значение через
jsonpathиbase64 -d - объяснить, почему base64 не шифрование и что реально защищает Secret
- отличить
CreateContainerConfigErrorотCrashLoopBackOffи найти причину черезdescribe - применить изменённую конфигурацию через
rollout restart - подключить в Deployment ConfigMap через
envFromи один ключ Secret черезsecretKeyRef - назвать варианты хранения секретов вне git
Дальше: Урок 5.7: Пробы, ресурсы и обновление без простоя
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.