devops-курс Все курсы

✻ Урок 9.2 · Тема 9: Секреты и GitOps

Секреты в Kubernetes: Vault и External Secrets Operator

⏱ 3 ч

Зачем это нужно

В уроке 5.6 пароль базы жил в Kubernetes Secret, созданном командой, а значения в Secret хранятся в base64, то есть почти открытым текстом (base64 это способ записи, а не шифр). Стоит положить такой манифест в git, и пароль утёк навсегда (урок 9.1). В Vault пароль уже лежит, но приложение в кластере ходить в Vault не умеет: ему нужен обычный Secret.

На работе эту связку делает External Secrets Operator (ESO): программа внутри кластера, у которой есть доступ к Vault. Она сама создаёт и обновляет Secret из внешнего хранилища. Оператор (operator) это программа, которая постоянно следит за описаниями в кластере и приводит реальность в соответствие с ними, как курьер, который каждый час сверяет заявку с содержимым склада. Без такой программы тебе пришлось бы копировать пароль из Vault в кластер руками после каждой смены. Подробно разберём в теории. В git остаётся только описание «откуда взять», а не сам пароль. Это описание оформляется как отдельный объект ExternalSecret: заявка вида «возьми по этому адресу из Vault такие-то поля и сложи в Secret с таким-то именем». Как бумажка «заберите посылку из ячейки 42», на которой нет самой посылки. Ту же схему используют с AWS Secrets Manager, Yandex Lockbox и другими хранилищами.

Шаг проекта: ESO ставится в кластер, в Helm-чарте «Заметок» появляется ExternalSecret notes-db (chart 0.4.0), ручной Secret удаляется, пароль базы в git и в манифестах платформы больше не хранится.

Что нужно знать

  • Урок 9.1: проблема секретов и Vault: Vault в кластере, secret/notes/db, политика notes-read, метод входа kubernetes и роль notes, запечатывание.
  • Урок 5.6: ConfigMap и Secret: Secret notes-db, envFrom, почему base64 не шифрование.
  • Урок 5.9: Helm: чарт helm/notes, values.yaml, helm upgrade.
  • Урок 5.12: безопасность Kubernetes: ServiceAccount (учётная запись программы внутри кластера, как пропуск сотрудника, только для программы) и RBAC (правила «кто что может делать в кластере»).
  • Урок 5.13: диагностика Kubernetes: describe, события, логи контроллеров. Контроллер (controller) это программа-«смотритель»: бесконечно сравнивает, что описано в объектах и что есть на деле, и исправляет разницу, как термостат. Подробно в теории ниже.
  • Урок 5.5: StatefulSet с PostgreSQL: пароль в базе задаётся при первой инициализации тома.

Картина целиком

Представь курьерскую службу. Ты, как заказчик, не ездишь на склад сам: пишешь заявку «привезите из ячейки 42 коробку и положите её на мой стол». Курьер знает адрес склада, имеет пропуск и привозит коробку. Через час он проверяет, не заменили ли содержимое на складе, и если заменили, привозит новое.

Здесь склад это Vault, ячейка это secret/notes/db, курьер это ESO, заявка это ресурс ExternalSecret, а стол это обычный Secret в кластере, из которого приложение берёт пароль как обычно. Приложение про склад ничего не знает. Аналогия ломается в одном: курьер не умеет сам переговорить с приложением. Если содержимое коробки поменялось, работающая программа увидит новое значение только после перезапуска.

flowchart LR
    subgraph G["git: только описания"]
        ES["ExternalSecret:<br>взять notes/db из vault-backend,<br>собрать Secret notes-db"]
        CS["ClusterSecretStore:<br>Vault по адресу, вход по роли notes"]
    end
    subgraph K["кластер kind-notes"]
        C["Контроллер ESO<br>namespace external-secrets"]
        SEC["Secret notes-db<br>namespace notes"]
        POD["Под notes"]
    end
    V[("Vault<br>secret/notes/db")]
    ES --> C
    CS --> C
    C -->|"2. вход по SA"| V
    V -->|"3. значения"| C
    C -->|"4. создать или обновить"| SEC
    SEC -->|"env"| POD

Приложение про Vault ничего не знает, а Vault про приложение: между ними только контроллер.

За урок разберёшь каждый кусок. CRD (Custom Resource Definition) это «добавка к словарю» Kubernetes: регистрация нового вида объектов, например ExternalSecret, как если бы в почтовое отделение добавили бланк новой формы, о которой раньше никто не слышал. Без этой добавки Kubernetes не поймёт слово ExternalSecret и отклонит объект. Вот что ещё разберём: что такое оператор и CRD, как ESO входит в Vault без пароля, из каких частей состоит ExternalSecret, как собирается итоговый Secret, что происходит при ротации и при падении Vault.

Теория

Зачем нужна связка: Secret в кластере и Vault снаружи

Kubernetes умеет одно: давать поду переменные и файлы из объектов ConfigMap и Secret. Vault умеет другое: хранить секреты безопасно. Между ними нет встроенного моста. Без моста остаются два плохих варианта: класть Secret-манифест в git (пароль утекает, урок 9.1) или создавать Secret руками командой (нет истории, нет повторяемости, а при пересоздании кластера всё придётся вводить заново).

Аналогия: переводчик между двумя людьми, которые говорят на разных языках. Каждый остаётся при своём языке.

Мост можно построить тремя способами, и полезно понять их сразу, чтобы видеть, почему выбран ESO.

Способ Что делает Плюсы Минусы
External Secrets Operator (ESO) копирует значения из Vault в обычный Secret приложение не меняется, работает env с secretKeyRef из 5.6; поды стартуют, даже если Vault недоступен секрет лежит в etcd (базе кластера) как любой Secret
Vault Agent Injector добавляет в под дополнительный контейнер (sidecar), который кладёт секрет файлом в память пода секрет не попадает в etcd, есть динамические секреты без Vault под не стартует, лишний контейнер в каждом поде
Vault CSI-провайдер монтирует секрет как том через стандартный драйвер CSI (Container Storage Interface) тоже без etcd нужны специальные драйвер и провайдер

Для статических паролей вроде пароля базы «Заметок» мы берём ESO: он проще всех и не требует менять приложение.

Осторожно: «ESO хранит пароли». Нет: ESO только копирует. Источник правды остаётся в Vault, а Secret в кластере это копия, которая обновляется.

Главное: ESO копирует значения из Vault в обычный Secret, источником правды остаётся Vault.

Как Kubernetes узнаёт про новые виды объектов вроде ExternalSecret?

Оператор и CRD: как Kubernetes учат новым словам

В Kubernetes есть готовые объекты: Pod, Deployment, Service, Secret. Но «возьми значение из Vault» среди них нет. Чтобы не переписывать Kubernetes, ему добавляют новые виды объектов и программу, которая с ними работает.

Аналогия: термостат. Ты не включаешь и выключаешь отопление сам, ты задаёшь «хочу 22 градуса», а термостат постоянно сравнивает реальную температуру с желаемой и включает отопление, когда надо. Оператор делает то же самое с кластером.

Вот как это устроено.

  1. CRD (Custom Resource Definition, определение пользовательского ресурса) это «словарь»: ты регистрируешь в API-сервере Kubernetes новый вид объектов. После установки CRD команда kubectl get externalsecrets работает так же, как kubectl get pods.
  2. Контроллер (controller) это программа-цикл. Она следит за объектами своего вида, сравнивает описанное («хочу Secret notes-db с такими-то ключами») с реальным (есть ли такой Secret и такой ли он), и исправляет разницу. Такой цикл называют reconcile (сверка).
  3. Оператор (operator) это контроллер плюс его CRD, упакованные вместе: то, что ты ставишь Helm-чартом.

Разберём на примере. Ты создаёшь ресурс ExternalSecret с именем notes-db. API-сервер сохраняет его как обычный объект, но сам ничего с ним не делает. Контроллер ESO замечает новый объект, читает его поля, идёт в Vault, собирает Secret и записывает результат в статус объекта (SecretSynced). Через час цикл повторяется. Если кто-то удалит Secret руками, контроллер при ближайшей сверке создаст его заново.

Прикинь сам: ты удалил руками Secret notes-db, который создал ESO. Что будет через минуту?

Контроллер при ближайшей сверке увидит, что Secret нет, и создаст его заново: это и есть работа «термостата».

Осторожно: «CRD ставится вместе с приложением». Нет: CRD и оператор ставятся один раз на весь кластер (платформой), а ресурсы (ExternalSecret) уже создаёт каждое приложение у себя.

Главное: CRD добавляет новое слово в словарь API, а оператор следит за объектами этого вида и приводит кластер в соответствие.

Значит, порядок установки имеет значение.

Порядок установки: сначала оператор, потом заявки

В уроке две установки: сначала ESO вместе с CRD, потом в чарте приложения ExternalSecret. Порядок важен: поменяй местами, и установка упадёт.

Аналогия: заявку на склад можно подать, только если склад уже существует и знает такую форму заявки. Если формы нет, приёмщик не поймёт, что с бумажкой делать. Оговорка: Kubernetes не принимает заявку «на будущее», он сразу отвечает ошибкой.

Пока CRD не зарегистрирован, API-сервер не знает слова ExternalSecret. Helm, отправив в кластер манифест с таким объектом, получит отказ: no matches for kind "ExternalSecret" in version "external-secrets.io/v1". Поэтому порядок такой: (1) ставим ESO, при этом появляются CRD и контроллер; (2) создаём ClusterSecretStore; (3) ставим чарт приложения с ExternalSecret. Так же выключатель externalSecret.enabled в чарте «Заметок» нужен, чтобы чарт оставался рабочим и в кластере без ESO.

Разберём на примере. Коллега делает helm upgrade notes helm/notes --set externalSecret.enabled=true в кластере без ESO. Helm пишет ошибку про no matches for kind. Лечение: сначала поставить ESO (задание 1), подождать, пока поды Running, затем повторить upgrade.

Осторожно: что ошибка значит «сломан чарт». Чарт цел, в кластере нет нужного CRD.

Главное: сначала оператор с CRD, потом хранилище, потом приложение с ExternalSecret.

Проверь понимание: kubectl get externalsecrets отвечает the server doesn't have a resource type "externalsecrets". Что это значит?

Ответ

CRD не зарегистрирован, то есть ESO не установлен (или установлен с отключённой установкой CRD). Сначала ставят оператор, потом создают его объекты.

Теперь разберём, из каких двух объектов состоит настройка.

Два ресурса ESO: где хранилище и что взять

ESO вводит два главных ресурса.

ClusterSecretStore (или SecretStore, если хранилище нужно только в одном namespace) описывает подключение: адрес Vault, путь движка, способ входа. Один на кластер, его ведёт платформа. Слово cluster значит «виден из любого namespace».

ExternalSecret лежит рядом с приложением и описывает что взять: «возьми секрет по такому пути из такого хранилища и собери Secret с таким именем».

Разница как между адресом склада и заявкой на конкретную коробку. Адрес один на всех, заявок у каждого своя.

Разбор ExternalSecret поле за полем (пример из задания 3):

apiVersion: external-secrets.io/v1     # группа и версия CRD, добавленные оператором
kind: ExternalSecret
metadata:
  name: demo
  namespace: notes                     # куда положить итоговый Secret
spec:
  refreshInterval: 1m                  # как часто заново читать Vault
  secretStoreRef:
    name: vault-backend                # какое хранилище использовать
    kind: ClusterSecretStore
  target:
    name: demo                         # имя создаваемого Secret
  dataFrom:
    - extract:
        key: notes/demo                # путь в Vault БЕЗ "secret/" и БЕЗ "data/"
  • key: notes/demo: напомню из урока 9.1: KV v2 это «шкаф» в Vault для пар ключ-значение, он смонтирован (подключён) на путь secret/, как диск на букву. ESO знает про шкаф и его версию, потому что так написано в ClusterSecretStore. Поэтому в key только путь внутри движка. Он же превращается в secret/data/notes/demo при обращении к API (урок 9.1, про путь data).
  • dataFrom.extract: «возьми все пары ключ-значение из этого секрета и сделай из каждой ключ в Secret». Если в Vault token=first, то в Secret появится ключ token.
  • refreshInterval: интервал сверки. Разумное значение для пароля базы: час. Слишком малое (секунды) создаёт лишнюю нагрузку на Vault и засоряет его аудит.

Итоговый Secret создаётся в том же namespace, где лежит ExternalSecret. Это важно: ExternalSecret в namespace notes не может положить Secret в default.

Осторожно: «ClusterSecretStore хранит секреты». Нет: он хранит только способ подключения к хранилищу. Сами значения остаются в Vault.

Главное: ClusterSecretStore отвечает «где», ExternalSecret отвечает «что», и в key пишут путь без secret/ и data/.

Проверь понимание: в поле key ты написал secret/data/notes/db. Что произойдёт?

Ответ

ESO сам добавит secret/data/ в начале пути, получится secret/data/secret/data/notes/db, такого секрета нет: ошибка secret not found (Code: 404). В key пишут только notes/db.

Остался вопрос, как ESO входит в Vault.

Как ESO входит в Vault без пароля: Kubernetes auth

ESO нужен токен Vault, чтобы читать секреты. Но где взять токен, не положив его в ещё один Secret? Это «проблема первого секрета» из урока 9.1. Решение: не хранить пароль, а доказывать свою личность тем, что кластер уже выдал.

Аналогия: пропуск в бизнес-центр по паспорту. Ты не знаешь секретного слова, ты предъявляешь документ, который выдало государство, а охрана проверяет его подлинность у выдавшего органа.

Сначала три понятия.

  • ServiceAccount (SA) это «учётная запись» для программы внутри кластера. Есть у каждого пода (по умолчанию default); у нас есть специальный SA notes в namespace notes (урок 5.12).
  • JWT (JSON Web Token) это подписанный токен: строка, в которой записано, кто и для чего её выдал, и есть подпись, которую нельзя подделать. Kubernetes выдаёт такой токен ServiceAccount’у.
  • TokenReview это запрос к API-серверу Kubernetes: «этот токен настоящий? чей он?». На него отвечает только API-сервер.

Шаги входа. На схеме стрелки слева направо это вопросы, справа налево ответы; POST это тип HTTP-запроса «отправить данные» (урок 2.4), здесь «вот мой JWT и имя роли, впусти»:

sequenceDiagram
    participant E as ESO
    participant V as Vault
    participant K as API-сервер Kubernetes
    E->>K: 1. короткий JWT для SA notes/notes
    K-->>E: JWT, живёт минуты
    E->>V: 2. POST auth/kubernetes/login, role=notes, jwt=...
    V->>K: 3. TokenReview: JWT настоящий?
    K-->>V: да: SA notes, ns notes
    Note over V: 4. сверка с ролью notes: SA=notes и ns=notes
    V-->>E: 5. токен Vault, политика notes-read, TTL 1 час
    E->>V: 6. чтение secret/data/notes/db с этим токеном

Роль notes в Vault мы создали в уроке 9.1 (шаг 5 скрипта seed-vault.sh): она привязана к SA notes в namespace notes и к политике notes-read. Токен, который получит ESO, умеет читать только secret/data/notes/*.

Разберём на примере: три неудачи. Личность это пара «имя + namespace», любое несовпадение даёт отказ, и по тексту ошибки видно, какое:

Что сделал Ответ Vault
в ClusterSecretStore указал role: notes-typo invalid role name "notes-typo"
SA notes из namespace default (роль привязана к namespace notes) namespace not authorized
SA с другим именем, но в notes service account name not authorized
всё совпало, но политика роли не покрывает путь permission denied уже после входа (код 403, но в запросе к secret/data/...)

Осторожно: «достаточно, чтобы SA назывался как надо». Имя без namespace ничего не значит: в разных namespace могут быть SA с одним именем.

Главное: вход по JWT ServiceAccount: Vault проверяет его у API-сервера и сверяет пару «имя и namespace» с ролью.

Проверь понимание: ты создал SA notes в namespace default и указал его в ClusterSecretStore. Роль в Vault привязана к SA notes в namespace notes. Что получишь?

Ответ

Vault отклонит вход: namespace not authorized, ExternalSecret получит статус SecretSyncedError. Имя SA совпало, но «личность» это пара имя + namespace.

Отказы при этом бывают двух видов.

Отказ бывает двух видов: «кто ты» и «что тебе можно»

В ошибках ESO встречаются коды 400 и 403, и без понимания различия диагностика превращается в угадывание. Кроме того, это два главных вопроса любой защиты, и они задаются по очереди.

Аналогия: на проходной охранник сначала смотрит пропуск: настоящий ли, твой ли (это «кто ты»). Потом смотрит список допусков: можно ли тебе в третий цех (это «что тебе можно»). Можно пройти первую проверку и провалить вторую: пропуск настоящий, но в цех не пускают. Оговорка: в ИТ обе проверки делает одна программа, и ошибки выглядят похоже, поэтому нужно читать код.

Первый вопрос называется аутентификация (authentication): подтвердить личность. Второй авторизация (authorization): проверить права. В Vault они разделены:

  • Аутентификация: метод kubernetes проверяет JWT и роль. Сюда попадают ошибки вида 400 invalid role name и namespace not authorized: Vault не признал личность или роль.
  • Авторизация: политика роли проверяет путь. Ошибка 403 permission denied на запрос к secret/data/...: личность признана, но путь политикой не разрешён. Осторожно: 403 отдаёт и сам вход через auth/kubernetes/login при неверном JWT или неудачном TokenReview. Поэтому смотри не только код, а URL запроса (auth/kubernetes/login или secret/data/...) и текст ошибки.

Разберём на примере. ESO входит в Vault ролью notes, токен настоящий: аутентификация пройдена. Он читает secret/data/billing/card: политика notes-read покрывает только secret/data/notes/*. Результат: 403 permission denied. Искать причину в JWT бессмысленно, надо смотреть политику. Обратный случай: роли notes нет вообще, Vault отвечает 400 invalid role name, а о политике речь ещё не идёт: до неё очередь не дошла.

Осторожно: что permission denied значит «неправильный пароль». Нет: это значит «пароль правильный, но прав не хватает». Неправильный пароль даёт ошибку входа.

Главное: аутентификация отвечает «кто ты», авторизация «что тебе можно», и текст ошибки показывает, на каком слое отказ.

Проверь понимание: ESO получил 403 permission denied при чтении пути. Что проверишь первым: JWT или политику?

Ответ

Политику (vault policy read notes-read). Если 403 пришёл на запрос к secret/data/..., вход уже прошёл и не хватает прав на путь. Но 403 бывает и у auth/kubernetes/login (плохой JWT), поэтому сверяй URL запроса и текст ошибки, а не один код.

Теперь о том, как часто ESO ходит в Vault.

Как часто ESO спрашивает Vault: интервал и его цена

Поле refreshInterval выглядит мелочью, но от него зависят две вещи: как быстро новый пароль дойдёт до кластера и сколько запросов получит Vault. Обе цены платит кто-то другой, поэтому значение нужно выбирать осознанно.

Аналогия: как часто ты заглядываешь в почтовый ящик. Каждые пять минут: письма приходят быстро, но ты постоянно бегаешь к ящику. Раз в день: спокойно, но срочное письмо лежит до вечера. Оговорка: у ESO есть «ускоритель»: можно дёрнуть вручную, не ожидая очередного обхода. Делается это аннотацией force-sync (annotation: заметка на объекте, которую Kubernetes не разбирает; ESO следит за ней и, увидев новое значение, читает Vault сразу). Команда будет в практике.

ESO читает Vault раз в refreshInterval для каждого ExternalSecret. Каждое чтение это запрос к Vault, и он записывается в аудит (урок 9.1). Значит, при интервале в 1 секунду и 50 ExternalSecret Vault получит 50 запросов в секунду и его журнал распухнет.

Разберём на примере. 50 секретов. Интервал 1h: 50 запросов в час, новый пароль доходит в худшем случае через час. Интервал 1m: 50 × 60 = 3000 запросов в час, новый пароль доходит за минуту. В учебных заданиях мы ставим 1m, чтобы увидеть ротацию своими глазами, а в рабочем чарте берём 1h: пароли базы меняются редко.

Прикинь сам: у тебя 200 ExternalSecret и refreshInterval: 30s. Сколько запросов в час получит Vault?

200 секретов × 120 раз в час = 24 000 запросов. Для рабочей системы это много: берут 1h и ускоряют разово через force-sync.

Осторожно: что интервал определяет, когда приложение увидит пароль. Он определяет только, когда обновится Secret. Поду ещё нужен перезапуск (см. выше про переменные окружения).

Главное: интервал это компромисс между свежестью секрета и нагрузкой на Vault, а на приложение он не влияет.

Проверь понимание: ты поставил refreshInterval: 1s «чтобы быстрее». Что пострадает?

Ответ

Vault получит лавину запросов, аудит-журнал быстро разрастётся, а выигрыша почти нет: приложение всё равно увидит новое значение только после перезапуска пода.

Дальше о том, из чего собирается итоговый Secret.

Из чего собирается итоговый Secret: шаблон и владение

В Vault лежат три поля: username, password, database. А приложению нужна одна строка подключения DATABASE_URL (урок 4.5). Копировать поля один в один недостаточно, их нужно скомпоновать.

Для этого в ExternalSecret есть поле target.template: в нём описывается, какие ключи будут в Secret и что в них записать. Значения из Vault подставляются шаблоном Go (text/template): двойные фигурные скобки и точка перед именем поля.

Разберём на примере. Пусть в Vault лежит username=notes, password=a+b/c=, database=notes. Шаблон собирает postgresql://notes:ПАРОЛЬ@db:5432/notes. Проблема в пароле: символы +, /, = имеют в URL особый смысл (/ начинает путь, = разделяет параметры), и строка распадётся не там. Поэтому пароль пропускают через функцию urlquery: она заменяет спецсимволы на %-коды:

Символ + / =
код в URL %2B %2F %3D

Пароль a+b/c= после urlquery превращается в a%2Bb%2Fc%3D, и URL остаётся корректным: postgresql://notes:a%2Bb%2Fc%3D@db:5432/notes. Пароль из openssl rand -base64 24 (наш случай) содержит такие символы регулярно.

Владение. Поле creationPolicy: Owner означает, что ESO владеет созданным Secret: создаёт, обновляет и удаляет его вместе с ExternalSecret. Отсюда две ловушки:

  1. Если Secret с таким именем уже создан вручную, ESO с Owner не может его присвоить и будет сообщать об ошибке. Ручной Secret надо удалить до применения ExternalSecret.
  2. Если удалить ExternalSecret, исчезнет и Secret, а поды, которые его используют, при следующем создании не запустятся.

Ещё ловушка: две пары скобок. Helm (урок 5.9) тоже использует двойные фигурные скобки для своих шаблонов. Если написать шаблон ESO внутри шаблона Helm как есть, Helm попытается сам выполнить его и упадёт (нет поля .username в его данных). Поэтому внутренние скобки Helm-у передают как строку: {{ "{{ .username }}" }}. Helm выводит строку, а ESO получает готовый шаблон. Как именно, увидишь в задании 4.

Осторожно: «urlquery нужен всегда». Нужен только там, где значение вставляется в URL. Для чистого пароля в POSTGRES_PASSWORD он не нужен, это приведёт к неверному значению.

Главное: шаблон собирает из полей Vault нужные приложению строки, а Owner связывает жизнь Secret с ExternalSecret.

Проверь понимание: что будет с Secret notes-db, если удалить ExternalSecret notes-db при creationPolicy: Owner?

Ответ

Secret удалится вместе с ним, поды при следующем создании упадут с CreateContainerConfigError (статус «контейнер не удалось собрать»: Kubernetes не нашёл Secret, из которого должен взять переменные; см. урок 5.13). Поэтому удалять ExternalSecret на живом приложении нельзя, а в GitOps (урок 9.3) это делается осознанно.

Сведём всё вместе на одном примере.

Весь путь на одном примере с настоящими значениями

Соберём всё в один сквозной пример: что лежит на каждом шаге и как одно значение превращается в другое. Пусть в Vault по пути secret/notes/db лежит:

username = notes
password = a+b/c=
database = notes
  1. ExternalSecret notes-db создан. Контроллер ESO берёт его secretStoreRef (хранилище vault-backend) и dataFrom.extract.key (notes/db).
  2. Вход в Vault. По схеме из раздела про Kubernetes auth ESO получает токен Vault с политикой notes-read и TTL 1 час (TTL, time to live: «срок жизни», через час токен сам перестанет работать, и ESO при следующем обращении войдёт заново).
  3. Чтение. ESO обращается к secret/data/notes/db. Политика notes-read это разрешает (путь secret/data/notes/*, действие read). Vault отвечает тремя парами.
  4. Шаблон. Для каждого ключа из target.template.data ESO подставляет значения. DATABASE_URL получает postgresql://notes:a%2Bb%2Fc%3D@db:5432/notes, а POSTGRES_PASSWORD получает сырое a+b/c=.
  5. Запись. ESO создаёт (или обновляет) Secret notes-db в namespace notes. В нём два ключа DATABASE_URL и POSTGRES_PASSWORD, а значения хранятся в base64.
  6. Статус. ESO пишет в статус ExternalSecret: SecretSynced, время последней синхронизации и версию секрета в Vault.

Если посмотреть готовый Secret и раскодировать значения, получится тот же результат, что в шаге 4 (команда kubectl -n notes get secret notes-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d печатает строку подключения). Так ты можешь проверить, что ESO собрал именно то, что ожидает приложение.

Полезная привычка: при любой проблеме иди по этим шести шагам сверху вниз и находи первый, который не выполнился. Шаги 1 и 6 видны в kubectl get externalsecret, шаги 2 и 3 в событиях describe (там текст ответа Vault), шаги 4 и 5 в самом Secret.

Главное: каждое значение проходит путь: Vault, шаблон, Secret, переменная пода, и на каждом шаге его можно проверить.

Как узнать, что всё это работает?

Как читать статус ExternalSecret

У ESO нет окна с индикатором. Единственное место, где он сообщает, жив ли он, это статус объекта ExternalSecret. Научившись читать статус, ты за десять секунд скажешь, в порядке ли связка.

Аналогия: индикаторы на панели автомобиля: зелёный «порядок», красный «неисправность», и в руководстве написано, что означает каждый. Оговорка: лампа красная, но не говорит, какая именно деталь сломалась, за подробностями нужно смотреть журнал (describe).

У каждого объекта Kubernetes есть поле status, в которое пишет контроллер. У ExternalSecret там список состояний (conditions). Главное называется Ready: True значит «синхронизировано», False значит «не получилось». Рядом причина (reason): SecretSynced при успехе, SecretSyncedError при ошибке. Команда kubectl get externalsecret показывает то же в колонках STATUS и READY. Подробный текст ошибки пишется в события, их читают командой kubectl describe externalsecret <имя>.

Разберём на примере. kubectl -n notes get externalsecret notes-db даёт SecretSynced True: ESO прочитал Vault и собрал Secret. Если SecretSyncedError False, открывай describe и иди в конец: в блоке Events будет сообщение Vault, например Code: 403 ... permission denied. Слой определяй по URL запроса (auth/kubernetes/login или secret/data/...) и тексту ошибки, а не по одному коду (предыдущий раздел).

Осторожно: что Ready True гарантирует работающее приложение. Нет: это значит только, что Secret собран. Перезапущены ли поды и сменился ли пароль в базе, статус не скажет.

Главное: Ready True значит «Secret собран», а причину отказа ищут в событиях describe.

Проверь понимание: READY True, а приложение отвечает password authentication failed. Где искать?

Ответ

В согласованности Secret и базы данных: пароль в Vault и Secret уже новый, а в самой PostgreSQL остался старый. Статус ESO этого не видит.

Посмотрим, что именно можно хранить в git.

Что лежит в git и что нет: граница безопасности

Главный выигрыш всей схемы в том, что в репозитории нет пароля. Но чтобы это не осталось лозунгом, нужно точно понимать, что именно попадает в git, а что нет.

Аналогия: список посылок на складе и сами посылки. Список (что где лежит, кому выдавать) можно повесить на доске объявлений, ведь он ничего не раскрывает. Сами посылки держат за замком. Оговорка: список всё же показывает структуру склада, поэтому его нельзя публиковать для всех подряд.

Вот как это устроено.

В git (безопасно) Не в git (остаётся в Vault и кластере)
ExternalSecret: имя Secret, путь notes/db значение пароля
имя ClusterSecretStore и роль notes JWT и токены Vault
шаблон сборки строки подключения сама собранная строка с паролем
адрес Vault доли ключа и root-токен

Разберём на примере. Посторонний прочитал репозиторий и видит: «приложение берёт поле password из notes/db в Vault». Это полезно ему только если он уже внутри кластера с правом читать Vault. Без этого у него нет ничего, чем можно воспользоваться. При этом сам пароль можно менять как угодно часто, в git не изменится ни строчки: изменяется только содержимое Vault.

Осторожно: что раз пароля в git нет, то путь и имя роли можно выкладывать публично. Они не секрет, но раскрывают, какие у тебя есть секреты и как устроены роли. Доступ к репозиторию всё равно ограничивают.

Главное: в git лежит описание «откуда взять», а значения остаются в Vault и кластере.

Проверь понимание: в репозиторий закоммитили ExternalSecret с путём notes/db. Надо ли менять пароль из-за этого?

Ответ

Нет. В манифесте нет значения пароля, только путь к нему. Ротация нужна, если в git попало само значение.

Но копия в кластере тоже требует защиты.

Secret в etcd: насколько это плохо

Главный довод против ESO: копия пароля лежит в Kubernetes Secret, то есть в базе кластера (etcd), и вся защита копии это права доступа Kubernetes. Стоит понимать, что это значит на самом деле.

  • Кто может прочитать Secret. Тот, у кого в RBAC (урок 5.12) есть право get на Secret в этом namespace. Дать его надо как можно меньшему кругу: разработчикам обычно нужно видеть поды и логи, а не пароли.
  • Что в etcd. По умолчанию значения там лежат в открытом виде (base64 не шифрование). В управляемых облаках и при включённом шифровании (EncryptionConfiguration) они шифруются на диске. Для учебного kind этого нет.
  • Что даёт Vault поверх. Аудит доступа к оригиналу, история версий, ротация в одном месте и возможность отозвать доступ ESO одной командой. Копия в кластере при этом остаётся, и это осознанный компромисс: приложение и его поды не зависят от Vault в каждый момент.

Если требования к безопасности строже (нельзя хранить секрет в etcd вообще), берут Injector или CSI: они кладут секрет в память пода, минуя Secret.

Осторожно: «раз Secret в etcd, Vault не нужен». Нужен: без Vault пришлось бы держать источник правды в git или в чьей-то голове, а история, аудит и отзыв остались бы недоступны.

Главное: Secret в etcd это base64 без шифрования по умолчанию, защищают его RBAC и шифрование etcd.

Значит, важно, кто может ссылаться на хранилище.

Кто может ссылаться на ClusterSecretStore

ClusterSecretStore виден из любого namespace, а значит, любой, кто может создавать ExternalSecret в своём namespace, может попросить у него секрет. Ограничивает его только политика роли в Vault: она разрешает чтение secret/data/notes/* и ничего больше. Поэтому:

  • роль и политика привязаны к конкретному ServiceAccount и пути, а не «ко всему кластеру»;
  • если нужны разные права разным командам, делают отдельные SecretStore в их namespace и отдельные роли Vault;
  • нельзя расширять политику «на всякий случай»: тот, кто получит право создавать ExternalSecret, получит и всё, что разрешает роль.

Это то же самое «минимум прав» из урока 9.1, только на стороне кластера.

Главное: любой, кто может создать ExternalSecret, получит всё, что разрешает роль, поэтому политику держат узкой.

Теперь о том, как приложение берёт значение.

Как под берёт значение из Secret: переменная или файл

Прикинь сам: Secret обновили в 11:00, а под стартовал в 10:00. Какое значение увидит процесс через переменную окружения в 11:30?

Прежнее: переменные задаются при старте контейнера. Новое значение получит только новый под.

Мы создали Secret, но пароль должен попасть внутрь работающей программы. Программа не знает ничего про Kubernetes, она читает либо переменные окружения, либо файлы. Значит, Kubernetes должен сам положить значение туда, где программа его ждёт.

Аналогия: доставка еды на работу. Можно оставить пакет у вахтёра, а можно положить на твой стол. Вахтёр (переменная окружения) выдаёт один раз, когда ты пришёл. Стол (файл) можно пополнять в течение дня. Оговорка: на столе новое появится, но заметишь ли ты его, зависит от тебя (приложения).

Есть два способа подключить Secret к поду (урок 5.6).

  1. Переменные окружения (env и envFrom). Kubernetes при старте контейнера читает Secret и передаёт его поля как переменные процесса. envFrom берёт все поля разом. Значения записываются в процесс один раз.
  2. Файл в томе (volumeMounts). Каждое поле Secret становится файлом внутри контейнера, например /etc/notes/password. Kubernetes обновляет такие файлы сам (примерно за минуту), но программа должна перечитать файл.

Разберём на примере. «Заметки» берут DATABASE_URL из Secret notes-db как переменную окружения (env с secretKeyRef). Под стартовал в 10:00, в процессе DATABASE_URL=postgresql://notes:old@db/notes. В 11:00 ESO обновил Secret: значение там теперь new. Переменная в живом процессе всё равно old: ОС не меняет переменные у уже запущенной программы. Новое значение получит только новый под: отсюда kubectl rollout restart в следующих разделах.

Осторожно: что «Secret обновился, значит приложение увидело». Между ними стоит граница: переменные окружения фиксируются при старте, файлы обновляются, но их надо перечитывать.

Главное: переменные окружения фиксируются при старте, а файл в томе Kubernetes обновляет сам.

Проверь понимание: в каком из двух способов значение в запущенном поде обновится без перезапуска, если приложение перечитывает файл?

Ответ

В способе с файлом в томе: Kubernetes сам обновит файл (примерно за минуту), а приложение прочтёт свежее значение. Переменные окружения без перезапуска не меняются никогда.

Что происходит при смене пароля?

Ротация: обновилось в Vault, что дальше

Секреты надо менять (урок 9.1, ротация). Значит, новое значение должно дойти до приложения. Цепочка из четырёх звеньев, и на каждом свои задержки.

flowchart LR
    V["Vault:<br>новое значение записано"] -->|"не позже refreshInterval"| S["Secret в кластере:<br>ESO перечитал"]
    S -->|"только после перезапуска пода"| P["Под:<br>переменные env"]
    P -->|"меняется отдельно: ALTER USER"| DB[("База данных:<br>пароль пользователя")]

Четыре звена, и каждое обновляется само по себе.

  1. Vault → Secret. ESO перечитывает Vault раз в refreshInterval. Ускорить можно вручную: аннотация force-sync на ExternalSecret.
  2. Secret → под. Переменные окружения (env и envFrom) читаются один раз при старте контейнера. Обновлённый Secret не меняет переменных уже запущенного процесса. Нужен перезапуск: kubectl rollout restart deployment/notes (команда из урока 5.6: Deployment по очереди заменяет поды новыми, и новые читают свежий Secret) или контроллер вроде Reloader (отдельная программа-сторож: замечает, что Secret изменился, и сама перезапускает использующие его поды; в курсе мы перезапускаем руками). Если Secret смонтирован файлом (volume), файл обновится сам примерно за минуту, но приложение должно перечитать его.
  3. Пароль в самой базе. Пароль пользователя notes записан внутри PostgreSQL при первой инициализации тома (урок 5.5). Изменение Secret его не меняет. Пока в базе старый пароль, а в приложении новый, вход даёт password authentication failed. Значит, менять пароль надо и в базе, и в Vault, согласованно. Для нашего стенда мы приводим базу в соответствие с Vault командой ALTER USER (SQL-команда PostgreSQL «измени пользователя», в нашем случае «задай пользователю notes новый пароль»; её выполняют внутри пода с базой). Полное решение (динамические секреты, при которых пароль создаёт сам Vault) вне курса.

Осторожно: «ESO сам обновит приложение». Нет: он обновляет только Secret. Перезапуск подов и смена пароля в базе остаются на тебе.

Главное: ротация это цепочка из четырёх звеньев, и ESO обновляет только Secret.

Проверь понимание: пароль сменили в Vault. Увидит ли уже работающий под новое значение переменной окружения?

Ответ

Нет. ESO обновит Secret, но процесс в поде продолжит жить со старым значением до перезапуска пода. Поэтому после ротации нужен kubectl rollout restart или Reloader.

И последний вопрос: что если Vault недоступен.

Что происходит при недоступном Vault

Vault упал, запечатан (урок 9.1) или сервис недоступен. Уже созданный Secret остаётся на месте, поэтому работающие поды не страдают, и новые поды тоже стартуют, пока Secret существует. Страдает только синхронизация: ExternalSecret получает статус SecretSyncedError, обновления не приходят.

Это главное отличие ESO от Injector: там недоступный Vault означает, что новый под не стартует вовсе. Но есть исключение: если Secret ещё ни разу не создавался (первый деплой на чистом кластере), под без Secret не запустится (CreateContainerConfigError).

Опасность такого отказа в том, что он тихий: сервис работает, никто не жалуется, а ротация, ради которой всё построено, давно не проходит. Поэтому нужен алерт на статус ExternalSecret.

Главное: при недоступном Vault Secret остаётся и поды живут, но ротация молча останавливается.

Где это встретится дальше

  • В уроке 9.3 ExternalSecret будет лежать в git, а Flux применит его сам.
  • В уроке 9.5 пароль базы будет управляться той же схемой.

Практика

Ожидается стенд после урока 9.1: кластер kind-notes, Vault распечатан (unseal) в namespace vault, secret/notes/db заполнен, релиз notes стоит в namespace notes, Secret notes-db создан вручную (5.6). Проверь исходное состояние:

kubectl config use-context kind-notes
kubectl -n vault get pods
kubectl -n notes get secret notes-db
NAME      READY   STATUS    RESTARTS   AGE
vault-0   1/1     Running   0          2d
NAME       TYPE     DATA   AGE
notes-db   Opaque   2      6d

Как читать вывод: 1/1 у vault-0 значит, что Vault распечатан и готов; Opaque это обычный тип Secret «произвольные данные»; DATA 2 число ключей внутри (у тебя может отличаться, возраст тоже). Если vault-0 показывает 0/1, Vault запечатан: распечатай его командами из 9.1 (scripts/seed-vault.sh идемпотентный, можно запускать повторно).

Задание 1. Установить External Secrets Operator

Цель: поставить ESO v2.11.0 через Helm и убедиться, что появились его CRD и поды.

Предскажи: сколько подов появится в namespace external-secrets и какие ресурсы (kubectl api-resources) с группой external-secrets.io добавятся?

Ответ

Три пода: сам контроллер, cert-controller (управляет сертификатом вебхука) и webhook (проверяет ресурсы при создании). Появятся CRD externalsecrets, secretstores, clustersecretstores и другие.

Разбор команд.

  • helm repo add external-secrets <url> добавляет каталог чартов под коротким именем, helm repo update обновляет его список.
  • helm install <релиз> <репозиторий>/<чарт> --version 2.11.0: установить чарт, версию закрепляем явно, чтобы у всех было одно и то же. --namespace external-secrets --create-namespace ставит в отдельный namespace, создав его. --wait --timeout 3m ждёт, пока поды станут готовыми, но не дольше трёх минут.
  • kubectl api-resources --api-group=external-secrets.io -o name | head -5: список типов объектов из группы ESO (те самые CRD), -o name только имена, head -5 первые пять.

Шаги:

  1. Добавь репозиторий чартов и установи версию 2.11.0:
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
  --version 2.11.0 \
  --namespace external-secrets --create-namespace \
  --wait --timeout 3m
  1. Проверь поды и CRD:
kubectl -n external-secrets get pods
kubectl api-resources --api-group=external-secrets.io -o name | head -5

Что должно получиться (имена подов и возраст у тебя другие; порядок и состав CRD зависят от версии):

NAME                                                READY   STATUS    RESTARTS   AGE
external-secrets-6c8f9d7b5-kq2xw                    1/1     Running   0          58s
external-secrets-cert-controller-7d9c4f6b8-p4tzn    1/1     Running   0          58s
external-secrets-webhook-5b7f8c9d64-m9vh2           1/1     Running   0          58s
acraccesstokens.generators.external-secrets.io
clusterexternalsecrets.external-secrets.io
clustergenerators.generators.external-secrets.io
clustersecretstores.external-secrets.io
externalsecrets.external-secrets.io

Как читать вывод: три пода 1/1 Running: контроллер (делает работу), cert-controller (выпускает сертификат для вебхука) и webhook (проверяет ресурсы при создании, ExternalSecret с ошибкой в описании он отклонит сразу). Строки имя.группа это CRD: часть до первой точки это вид ресурса, остальное группа.

Объясни себе:

  • Зачем оператору отдельный вебхук и что случится с созданием ExternalSecret, если под вебхука не запущен?
  • Почему CRD ставятся вместе с оператором, а не вместе с приложением?

Типичные ошибки:

  • Error: INSTALLATION FAILED: cannot re-use a name that is still in use: релиз external-secrets уже есть, посмотри helm list -A и используй helm upgrade.
  • Internal error occurred: failed calling webhook "validate.externalsecret.external-secrets.io": под вебхука ещё не готов, подожди Running 1/1 и повтори kubectl apply.
  • Error: no cached repo found: не выполнен helm repo update.

Не понял, почему ClusterSecretStore не готов? Вставь в нейросеть вывод kubectl describe clustersecretstore vault-backend и спроси, какой слой отказа это: вход или права. Сверь с таблицей отказов в теории.

Задание 2. Подключить Vault: ClusterSecretStore

Цель: описать подключение к Vault с входом по Kubernetes auth и добиться статуса Ready.

Предскажи: какие три вещи в Vault должны существовать, чтобы вход прошёл? Подсказка: вспомни 9.1.

Ответ

Включённый метод auth/kubernetes, роль notes, привязанная к SA notes в namespace notes, и политика notes-read на роли. Всё это создал scripts/seed-vault.sh. Кроме Vault нужен сам SA notes в кластере.

Разбор команд.

  • vault read auth/kubernetes/role/notes печатает настройки роли; kubectl -n vault exec vault-0 -- env VAULT_TOKEN=... vault ... запускает эту команду внутри пода Vault с токеном (как в 9.1). Токен root лежит в ~/.notes-secrets/, jq -r .root_token достаёт его из JSON.
  • kubectl create serviceaccount notes --dry-run=client -o yaml | kubectl apply -f -: --dry-run=client -o yaml не создаёт SA, а печатает манифест, kubectl apply -f - применяет его из стандартного ввода. Такая пара безопасна при повторе: apply создаст SA, если его нет, и ничего не сломает, если он есть (в отличие от простого create, который во второй раз упал бы).

Шаги:

  1. Проверь роль в Vault:
ROOT_TOKEN=$(jq -r .root_token ~/.notes-secrets/vault-init.json)
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT_TOKEN" \
  vault read auth/kubernetes/role/notes
  1. Убедись, что ServiceAccount существует (создай, если нет; команда идемпотентна):
kubectl -n notes create serviceaccount notes --dry-run=client -o yaml | kubectl apply -f -
  1. Создай k8s/platform/clustersecretstore.yaml:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      # Адрес Vault внутри кластера (сервис из урока 9.1)
      server: "http://vault.vault.svc:8200"
      # Движок kv-v2 смонтирован в secret/
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          # Путь, под которым включён метод kubernetes
          mountPath: "kubernetes"
          role: "notes"
          # ESO запросит токен именно для этого SA
          serviceAccountRef:
            name: notes
            namespace: notes

Построчно: provider.vault выбирает тип хранилища; server это адрес сервиса Vault в кластере (имя.namespace.svc, порт 8200); path и version говорят, что движок смонтирован на secret/ и это KV v2 (поэтому в ExternalSecret пишут только notes/db); auth.kubernetes описывает вход: метод включён на пути kubernetes, роль notes, а токен ESO запросит для SA notes из namespace notes. Ни пароля, ни токена Vault в файле нет.

  1. Примени и проверь статус:
kubectl apply -f k8s/platform/clustersecretstore.yaml
kubectl get clustersecretstore vault-backend

Что должно получиться:

Key                              Value
---                              -----
alias_name_source                serviceaccount_uid
bound_service_account_names      [notes]
bound_service_account_namespaces [notes]
policies                         [notes-read]
token_ttl                        1h
NAME            AGE   STATUS   CAPABILITIES   READY
vault-backend   6s    Valid    ReadWrite      True

Как читать вывод: первая таблица это отрывок настроек роли (у тебя строк больше): bound_service_account_names и ..._namespaces это та самая пара «имя + namespace», policies политика, token_ttl срок токена. Вторая: STATUS Valid и READY True значат, что ESO вошёл в Vault. CAPABILITIES ReadWrite это то, что ESO мог бы делать в принципе, читать он будет только то, что разрешает политика.

Объясни себе:

  • Почему bound_service_account_namespaces нужно ограничивать конкретным namespace, а не *?
  • Почему в ClusterSecretStore нет ни пароля, ни токена Vault?

Типичные ошибки:

  • no matches for kind "ClusterSecretStore" in version "external-secrets.io/v1": CRD не установлены (задание 1) или установлена старая версия ESO.
  • STATUS InvalidProviderConfig, в событии unable to log in with Kubernetes auth: ... Errors: * invalid role name "notes": в Vault нет роли, перезапусти scripts/seed-vault.sh.
  • Vault is sealed: распечатай Vault (урок 9.1).

Задание 3. Ротация: как обновление доходит до Secret

Цель: на демонстрационном секрете увидеть refreshInterval и то, что под сам не обновляется. Боевой пароль базы пока не трогаем.

Предскажи: ты меняешь значение в Vault. Через сколько Secret обновится при refreshInterval: 1m и изменится ли значение в уже запущенном поде?

Ответ

Secret обновится не позже чем через минуту. Переменная окружения в запущенном поде не изменится до его перезапуска (см. вопрос в теории). Если Secret смонтирован файлом (volume), файл обновится сам примерно за минуту, но приложение должно перечитать его.

Разбор команд.

  • vault kv put secret/notes/demo token=first: записать демо-секрет (для root-токена путь пишут с secret/ в начале, здесь нет отдельного -mount).
  • kubectl apply -f - <<'YAML' ... YAML: применить манифест, который тут же пишем в стандартный ввод (heredoc; кавычки вокруг YAML отключают подстановки shell).
  • kubectl -n notes get secret demo -o jsonpath='{.data.token}' | base64 -d; echo: jsonpath достаёт одно поле, значения в Secret закодированы base64, base64 -d раскодирует, echo добавляет перевод строки.
  • sleep 70 ждёт чуть больше refreshInterval в минуту.

Шаги:

  1. Положи демо-секрет и создай ExternalSecret:
ROOT_TOKEN=$(jq -r .root_token ~/.notes-secrets/vault-init.json)
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT_TOKEN" \
  vault kv put secret/notes/demo token=first

kubectl apply -f - <<'YAML'
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: demo
  namespace: notes
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: demo
  dataFrom:
    - extract:
        key: notes/demo
YAML
  1. Проверь синхронизацию и значение:
kubectl -n notes get externalsecret demo
kubectl -n notes get secret demo -o jsonpath='{.data.token}' | base64 -d; echo
  1. Смени значение в Vault, подожди минуту и прочитай Secret снова:
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT_TOKEN" \
  vault kv put secret/notes/demo token=second
sleep 70
kubectl -n notes get secret demo -o jsonpath='{.data.token}' | base64 -d; echo
  1. Убери демо за собой:
kubectl -n notes delete externalsecret demo
kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT_TOKEN" \
  vault kv metadata delete secret/notes/demo

Что должно получиться:

NAME   STORETYPE            STORE           REFRESH INTERVAL   STATUS         READY
demo   ClusterSecretStore   vault-backend   1m                 SecretSynced   True
first
second

Как читать вывод: SecretSynced True значит, что ESO прочитал Vault и создал Secret. first это значение сразу после создания, second то, что появилось после минуты ожидания: ротация дошла до Secret без твоих действий. После шага 4 Secret demo тоже исчезает: им владел ESO (Owner).

Объясни себе:

  • Как ускорить синхронизацию, не дожидаясь интервала? (Подсказка: kubectl annotate externalsecret demo force-sync=$(date +%s) --overwrite: $(date +%s) даёт число секунд, каждый раз новое, и новое значение аннотации заставляет ESO перечитать Vault.)
  • Какой refreshInterval разумен для пароля базы и почему не 1s?

Типичные ошибки:

  • Error from server (NotFound): secrets "demo" not found сразу после apply: синхронизация ещё не прошла, подожди 5-10 секунд.
  • Code: 404. Errors: * secret not found: в Vault нет пути secret/notes/demo, проверь шаг 1 (в key пишется notes/demo без secret/ и без data/).

Шаблон target.template легко написать неправильно. Попроси нейросеть объяснить каждую строку шаблона и проверь её ответ командой kubectl get secret notes-db -o jsonpath=... с base64 -d.

Задание 4. Шаг проекта: ExternalSecret в чарте, пароль из Vault

Цель: заменить ручной Secret notes-db на Secret от ESO и убрать пароль из git. Chart 0.4.0.

Предскажи: если применить ExternalSecret notes-db, пока ручной Secret с таким же именем существует, что скажет ESO? И почему нельзя просто взять новый пароль из Vault, не трогая PostgreSQL?

Ответ

ESO не сможет стать владельцем чужого Secret: статус SecretSyncedError, Secret не перезаписан. Поэтому ручной Secret сначала удаляют. Про PostgreSQL: пароль пользователя notes записан в самой БД при инициализации тома (5.5). Изменение Secret его не меняет, значит, пароль в БД надо привести в соответствие с Vault, иначе приложение получит password authentication failed.

Разбор команд.

  • sed -i 's/^version: .*/version: 0.4.0/' helm/notes/Chart.yaml: заменить в файле строку, начинающуюся с version:, на version: 0.4.0. Флаг -i правит файл на месте (на macOS нужен sed -i '').
  • helm lint helm/notes: проверить чарт на очевидные ошибки. helm template notes helm/notes --set ... показать, какие манифесты получатся, ничего не применяя. | grep -A3 DATABASE_URL оставить строку с DATABASE_URL и три строки после.
  • printf "ALTER USER notes PASSWORD '%s';\n" "$PW" | kubectl ... exec -i postgres-0 -- psql -U notes -d notes: printf подставляет пароль в SQL-команду, exec -i передаёт её в psql (клиент PostgreSQL) внутри пода базы, -U пользователь, -d база. unset PW ROOT_TOKEN стирает переменные из памяти shell.
  • rollout restart и rollout status перезапускают поды и ждут окончания (урок 5.7).
  • curl -s -o /dev/null -w '%{http_code}\n' ... --cacert <(...): -s тихо, -o /dev/null выбросить тело, -w напечатать только код ответа, --cacert доверять указанному сертификату, <(...) подставляет вывод команды как временный файл.

Шаги:

  1. Добавь в helm/notes/values.yaml блок (значения по умолчанию, ESO выключен):
existingSecret: notes-db

externalSecret:
  # Включает ExternalSecret вместо ручного Secret
  enabled: false
  store: vault-backend
  # Путь в kv-v2 без префикса secret/
  remoteKey: notes/db

Если ключ existingSecret уже есть в файле, второй раз его не добавляй.

  1. Создай helm/notes/templates/externalsecret.yaml. Внутри шаблона живут два уровня двойных фигурных скобок: внешний для Helm, внутренний для ESO. Внутренний оборачивают в строковый литерал, чтобы Helm вывел его как есть:
{{- if .Values.externalSecret.enabled }}
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ .Values.existingSecret }}
  labels:
    app.kubernetes.io/name: notes
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: {{ .Values.externalSecret.store }}
    kind: ClusterSecretStore
  target:
    name: {{ .Values.existingSecret }}
    creationPolicy: Owner
    template:
      data:
        # Строка подключения для приложения (контракт 4.4)
        DATABASE_URL: 'postgresql://{{ "{{ .username }}" }}:{{ "{{ .password | urlquery }}" }}@db:5432/{{ "{{ .database }}" }}'
        # Тот же пароль нужен StatefulSet postgres (5.5)
        POSTGRES_PASSWORD: '{{ "{{ .password }}" }}'
  dataFrom:
    - extract:
        key: {{ .Values.externalSecret.remoteKey }}
{{- end }}

Разбор: первая строка if .Values.externalSecret.enabled включает весь манифест только когда флаг true (по умолчанию выключен, чарт работает как раньше). Двойные скобки Helm заменит значениями из values.yaml. Конструкция {{ "..." }} выводит внутреннюю строку как есть. creationPolicy: Owner и urlquery разобраны в теории. Ключ POSTGRES_PASSWORD нужен базе: StatefulSet из урока 5.5 читает его из того же Secret.

  1. Подними версию чарта и проверь рендер, ничего не применяя:
sed -i 's/^version: .*/version: 0.4.0/' helm/notes/Chart.yaml   # macOS: sed -i ''
helm lint helm/notes
helm template notes helm/notes --set externalSecret.enabled=true \
  | grep -A3 'DATABASE_URL'
  1. Приведи пароль в PostgreSQL в соответствие с Vault (Vault источник правды):
ROOT_TOKEN=$(jq -r .root_token ~/.notes-secrets/vault-init.json)
PW=$(kubectl -n vault exec vault-0 -- env VAULT_TOKEN="$ROOT_TOKEN" \
  vault kv get -field=password secret/notes/db)
printf "ALTER USER notes PASSWORD '%s';\n" "$PW" \
  | kubectl -n notes exec -i postgres-0 -- psql -U notes -d notes
unset PW ROOT_TOKEN
  1. Удали ручной Secret и обнови релиз:
kubectl -n notes delete secret notes-db
helm upgrade notes helm/notes --namespace notes --set externalSecret.enabled=true
kubectl -n notes get externalsecret notes-db
  1. Убедись, что приложение подхватило Secret. Поды пересоздаются, чтобы прочитать новое значение:
kubectl -n notes rollout restart deployment/notes
kubectl -n notes rollout status deployment/notes
curl -s -o /dev/null -w '%{http_code}\n' https://notes.lab/readyz --cacert <(kubectl -n notes get secret notes-tls -o jsonpath='{.data.tls\.crt}' | base64 -d)
  1. Закрой хвосты в git: пароля нет в манифестах, а .env помечен как локальный:
grep -rn 'kind: Secret' k8s/ helm/ || echo "Secret-манифестов с паролем нет"
{ echo '# Только для локальной отладки в compose. На платформе пароль лежит в Vault (урок 9.2).'; cat .env.example; } > .env.example.new && mv .env.example.new .env.example
git add k8s/platform helm/notes .env.example
git commit -m "9.2: пароль БД из Vault через External Secrets Operator (chart 0.4.0)"

Что должно получиться:

        DATABASE_URL: 'postgresql://{{ .username }}:{{ .password | urlquery }}@db:5432/{{ .database }}'
        # Тот же пароль нужен StatefulSet postgres (5.5)
        POSTGRES_PASSWORD: '{{ .password }}'
  dataFrom:
NAME       STORETYPE            STORE           REFRESH INTERVAL   STATUS         READY
notes-db   ClusterSecretStore   vault-backend   1h                 SecretSynced   True
deployment "notes" successfully rolled out
200
Secret-манифестов с паролем нет

Как читать вывод: строки со скобками нужны именно такими: значит, Helm не съел внутренний шаблон и ESO получит его целиком (если бы там стояли пустые значения или ошибка, экранирование неверное). SecretSynced True значит, что Secret собран из Vault. rolled out это перезапуск подов завершён, 200 значит, что приложение работает и достучалось до базы (проверка /readyz). Последняя строка значит, что в k8s/ и helm/ больше нет манифестов Secret с паролем.

Объясни себе:

  • Почему у ключа POSTGRES_PASSWORD в шаблоне тот же пароль, что и в DATABASE_URL?
  • Где теперь хранится пароль и кто из людей и систем может его прочитать? Сравни с ситуацией до урока.
  • Что мешает удалить .env из compose совсем? Почему для платформы долг закрыт, а для локальной отладки остаётся пометка?

Типичные ошибки:

  • ExternalSecret notes-db в SecretSyncedError, в kubectl describe externalsecret notes-db жалоба, что Secret уже существует и ему не принадлежит (при creationPolicy: Owner ESO чужой Secret не берёт): ручной Secret не удалён или пересоздан, повтори kubectl -n notes delete secret notes-db. Ошибка Helm invalid ownership metadata здесь не при чём: она бывает, когда в манифестах релиза лежит объект с чужим владельцем.
  • FATAL: password authentication failed for user "notes" в логах приложения: пропущен шаг 4, пароль в БД не совпал с Vault.
  • could not parse ... invalid port number in URL или invalid URL: в пароле есть / или +, а шаблон без | urlquery.
  • error calling urlquery/function "urlqery" not defined: опечатка в имени функции, ошибка видна в kubectl describe externalsecret notes-db.
  • helm template ничего не выводит: забыл --set externalSecret.enabled=true, по умолчанию блок отключён.

Сломай и почини

Скачай скрипт и запусти сценарий. Не читай его: причину нужно найти диагностикой. Понадобятся ESO и ExternalSecret notes-db из заданий 1 и 4.

curl -fsSL -o /tmp/break-9.2.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/9.2/break.sh
bash /tmp/break-9.2.sh 1      # или 2, 3; random выбирает случайно

Починка: bash /tmp/break-9.2.sh fix.

Симптом

Что-то одно из списка (какое именно, тебе неизвестно):

  1. kubectl -n notes get externalsecret notes-db показывает SecretSyncedError, Secret не обновляется.
  2. После правки описания ESO по-прежнему не может войти в Vault, хотя роль и политика на месте.
  3. Vault недоступен: ExternalSecret красный, но приложение отвечает.

Гипотезы

  • Роль в Vault не существует или названа иначе, чем в ClusterSecretStore.
  • ServiceAccount из ClusterSecretStore не тот, что в bound_service_account_* роли: другое имя или другой namespace.
  • Политика роли не разрешает чтение пути secret/data/notes/*.
  • Vault запечатан, под не работает или Service без endpoints.
  • Сломан сам оператор: поды external-secrets не запущены.

Проверки

Иди от статуса к причине, не гадай:

kubectl -n notes describe externalsecret notes-db | tail -15
kubectl describe clustersecretstore vault-backend | tail -10
kubectl -n external-secrets logs deploy/external-secrets --tail=20
kubectl -n vault get pods,endpoints vault

Сообщение из describe содержит текст ответа Vault, и по нему сразу видно слой: 400 (роль или привязка), 403 (политика), connection refused или no such host (сеть и сам Vault), Vault is sealed (печать).

Исправление

Разбор трёх сценариев

1. permission denied / invalid role name "notes". Сообщение вида Code: 400. Errors: * invalid role name "notes" говорит: роли нет или в ClusterSecretStore другое имя. Сверь role в хранилище с vault list auth/kubernetes/role. Если роли нет, запусти scripts/seed-vault.sh. Ответ Code: 403 ... permission denied уже после успешного входа означает, что политика роли не покрывает путь: проверь vault policy read notes-read, путь должен быть secret/data/notes/* (у kv-v2 в пути политики есть data/).

2. SA не в том namespace. Сообщение service account name not authorized или namespace not authorized при существующей роли. Сравни serviceAccountRef в хранилище с bound_service_account_names и bound_service_account_namespaces в роли. Исправь serviceAccountRef (или привязку роли) и примени. ExternalSecret перечитает хранилище сам, ускорить можно аннотацией force-sync.

3. Vault недоступен. В сообщении dial tcp ...: connect: connection refused или Vault is sealed. Проверь kubectl -n vault get pods,endpoints: под не запущен, Vault запечатан (unseal по 9.1) или Service без endpoints. Приложение при этом работает: Secret уже создан и остаётся в кластере. Симптом опасен тем, что о проблеме ты узнаешь только по алерту на статус ExternalSecret, а не по падению сервиса, и при первом деплое на чистом кластере под без Secret не стартует.

Общий вывод: у ESO нет «магии», вся диагностика в kubectl describe externalsecret и логах контроллера. Читай текст ошибки Vault целиком.

ИИ в помощь

Нейросеть хорошо читает статусы и события ESO, но путает версии API (v1beta1 и v1) и пути KV. Общие правила: ИИ-помощник. Настоящие значения секретов в запрос не вставляй: заменяй их на CHANGE_ME.

Задача: разобрать статус ExternalSecret.

Вот вывод `kubectl describe externalsecret notes-db -n notes`:
<вставь блок Status и Events>.
Объясни, на каком слое отказ (вход или права или путь), и дай три проверки от самой вероятной.

Проверь ответ: выполни проверки и сверь с таблицей отказов в теории. Типичная ошибка: совет расширить политику до secret/* или выдать root-токен, хотя достаточно поправить имя роли или путь без data.

Задача: составить шаблон Secret.

В Vault по пути `notes/db` лежат поля `username`, `password`, `database`. Напиши `target.template` для ESO, который соберёт `DATABASE_URL` вида `postgresql://user:pass@db:5432/name` с безопасной вставкой пароля.

Проверь ответ: проверь, что пароль идёт через urlquery, а скобки не конфликтуют с Helm: в чарте шаблон нужно оборачивать в raw. Нейросети часто забывают про оба.

Задача: прикинуть нагрузку на Vault.

У меня <N> ExternalSecret и интервал <значение>. Сколько запросов в час получит Vault и какой интервал разумен для рабочей системы?

Проверь ответ: пересчитай сам: число секретов × число обходов в час. Типичная ошибка: нейросеть берёт общую цифру из статей, а не твои значения.

Словарик урока

Термин Простыми словами
External Secrets Operator (ESO) программа в кластере, копирующая секреты из внешнего хранилища в Kubernetes Secret
оператор (operator) контроллер плюс его CRD, поставляемые вместе
контроллер (controller) программа-цикл, сверяющая описанное с реальным и исправляющая разницу
reconcile (сверка) один проход цикла контроллера
CRD регистрация нового вида объектов в Kubernetes
ClusterSecretStore / SecretStore описание подключения к хранилищу (на весь кластер или на один namespace)
ExternalSecret заявка «возьми этот секрет из хранилища и собери из него Secret»
refreshInterval как часто ESO перечитывает хранилище
creationPolicy: Owner ESO владеет созданным Secret и удаляет его вместе с ExternalSecret
ServiceAccount (SA) учётная запись программы внутри кластера
JWT подписанный токен, в котором записано, кто его выдал и кому
TokenReview запрос к API-серверу «этот токен настоящий?»
Kubernetes auth вход в Vault по токену ServiceAccount без пароля
роль Vault правило «этот SA получает такие политики на такой срок»
Vault Agent Injector добавляет в под sidecar, кладущий секрет в файл; альтернатива ESO
sidecar дополнительный контейнер в поде рядом с основным
urlquery функция шаблона, заменяющая спецсимволы на %-коды для URL
аутентификация / авторизация Подтвердить, кто ты / проверить, что тебе можно
conditions (состояния) Список в status объекта: Ready, причина SecretSynced или SecretSyncedError
envFrom / volume Два способа отдать Secret поду: переменными при старте или файлами, которые обновляются
force-sync Аннотация на ExternalSecret, которая просит ESO перечитать Vault немедленно
Reloader контроллер, перезапускающий поды при изменении Secret
etcd база данных кластера, где Kubernetes хранит все объекты, в том числе Secret

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Почему нельзя хранить Secret Kubernetes в git, даже если он в base64?

Ответ

Base64 это кодировка, а не шифрование: base64 -d возвращает пароль за секунду. Пароль в git останется в истории навсегда и доступен всем с доступом к репозиторию. Нужен либо внешний менеджер секретов (Vault + ESO), либо шифрование перед коммитом (SOPS, Sealed Secrets).

Что хотят услышать: история git, base64 не защита, три варианта решения.

Красный флаг: «в приватном репозитории можно».

2. [middle] [часто] Чем ESO лучше или хуже Vault Agent Injector, когда что выбираешь?

Ответ

ESO создаёт обычный Secret: приложение не меняется, поды не зависят от Vault при старте, зато секрет лежит в etcd кластера. Injector кладёт секрет файлом в память пода, поддерживает динамические секреты и обновление без пересоздания, но требует sidecar и Vault при каждом старте. Для обычных статических паролей я беру ESO, для динамических учётных данных и строгих требований к хранению в etcd Injector или CSI.

Что хотят услышать: плюсы и минусы обоих, вопрос etcd и шифрования, критерии выбора.

Красный флаг: «Injector устарел» или «ESO безопаснее всегда».

3. [middle] ExternalSecret в статусе SecretSyncedError. Как диагностируешь?

Ответ

Сначала kubectl describe externalsecret: в событиях полный текст ответа Vault. Дальше по коду: 400 это роль или привязка SA, 403 политика, connection refused сеть или Vault, sealed печать. Потом смотрю логи контроллера ESO и состояние ClusterSecretStore. Приложение обычно не затронуто, пока Secret уже создан.

Что хотят услышать: порядок от статуса к слою, знание, что Secret остаётся, проверка роли и SA в Vault.

Красный флаг: «пересоздам ESO» без чтения сообщения об ошибке.

4. [middle] В Vault поменяли пароль, а приложение продолжает использовать старый. Почему и что делать?

Ответ

ESO обновит Secret в пределах refreshInterval, но переменные окружения читаются при старте контейнера, значит, под надо перезапустить. Ускорить синхронизацию можно аннотацией force-sync, перезапустить rollout restart или поставить Reloader. Отдельно проверяю, сменился ли пароль в самой БД: секрет и БД должны меняться согласованно.

Что хотят услышать: env читаются один раз, refreshInterval, Reloader, согласованность с БД.

Красный флаг: «ESO сам перезапускает поды».

5. [middle] Vault лёг ночью. Что произойдёт с приложениями, которые берут секреты через ESO?

Ответ

Уже работающие и новые поды продолжат жить, пока Secret существует: он лежит в кластере. Синхронизация остановится, ExternalSecret покраснеет, обновления и ротация не дойдут. Проблема возникнет при первом деплое на чистом кластере или при удалении Secret. Поэтому нужен алерт на статус ExternalSecret и мониторинг самого Vault.

Что хотят услышать: разница с sidecar-инжектором, что Secret остаётся, алерт на SecretSyncedError, риск чистого кластера.

Красный флаг: «все поды упадут» или «ничего страшного, никто не заметит».

6. [middle] Ты применил ExternalSecret, а ESO пишет, что не может создать Secret. В кластере уже есть Secret с тем же именем, созданный вручную. Что делаешь?

Ответ

ESO с creationPolicy: Owner не берёт чужой ресурс. Удаляю ручной Secret (предварительно сохранив, откуда брался пароль) и даю ESO создать свой. Проверяю, что значение совпадает с ожидаемым приложением, и что пароль в БД тоже согласован. Для миграции без простоя иногда используют creationPolicy: Merge (ESO добавляет ключи в существующий Secret), но это исключение.

Что хотят услышать: политики Owner, Merge, Orphan, порядок миграции, риск простоя.

Красный флаг: правит ручной Secret руками, чтобы ESO «подхватил».

7. [middle] Как ESO входит в Vault и почему для этого не нужен пароль?

Ответ

По Kubernetes auth: ESO получает токен ServiceAccount, Vault проверяет его у API-сервера (TokenReview) и сверяет пару «имя SA + namespace» с ролью. Совпало, выдаёт короткоживущий токен с политикой роли. Секрет для входа не хранится нигде, подтверждается сама «личность» рабочей нагрузки.

Что хотят услышать: TokenReview, привязка роли к SA и namespace, минимальная политика, TTL токена.

Красный флаг: «ESO хранит root-токен Vault в Secret».

8. [middle] Пароль в Vault сменили, ExternalSecret синхронизировался, а приложение получает password authentication failed. В чём дело?

Ответ

Пароль пользователя хранится в самой БД, и обновление Secret его не меняет. Значит, Vault и БД разошлись. Смотрю, кто менял значение, привожу БД в соответствие (ALTER USER) или откатываю значение в Vault (kv-v2 хранит версии). Правильный процесс: менять пароль в БД и в Vault в одном сценарии, а лучше перейти на динамические секреты.

Что хотят услышать: источник правды один, версии kv-v2, динамические секреты БД как решение.

Красный флаг: «перезапущу поды, пока не заработает».

9. [junior] Чем ClusterSecretStore отличается от SecretStore?

Ответ

SecretStore действует в одном namespace, а ClusterSecretStore доступен из любого namespace кластера. Платформенная команда ведёт один общий ClusterSecretStore, а команды приложений создают только свои ExternalSecret. Если хранилище кластерное, нужно ограничить, кто может на него ссылаться.

Что хотят услышать: область действия, разделение ответственности, вопрос изоляции.

Красный флаг: «одно и то же».

10. [middle] Ты заметил, что пароль БД попал в git-историю. Что делаешь, по шагам?

Ответ

Считаю пароль скомпрометированным: меняю в БД и Vault, перевыкатываю, проверяю логи доступа. Чистка истории (git filter-repo) не заменяет смены пароля, так как копии уже могли разойтись. Затем закрываю причину: секрет в Vault, ExternalSecret в чарте, сканер секретов в CI.

Что хотят услышать: ротация прежде чистки истории, аудит доступа, профилактика.

Красный флаг: «удалю коммит и продолжу».

11. [junior] Как заставить ExternalSecret синхронизироваться прямо сейчас, не дожидаясь интервала?

Ответ

ESO перечитывает секрет по refreshInterval. Чтобы не ждать, вешаю аннотацию: kubectl annotate externalsecret notes external-secrets.io/force-sync=$(date +%s) --overwrite. Потом смотрю статус: kubectl get externalsecret notes и kubectl describe. Если после синхронизации приложение всё равно видит старое значение, значит, оно не перечитывает Secret: переменные окружения подхватятся только после перезапуска пода.

Что хотят услышать: refreshInterval, аннотация force-sync, проверка статуса, переменные окружения требуют перезапуска.

Красный флаг: удаляет Secret вручную и надеется на лучшее.

12. [middle] Как собрать в Secret строку подключения из нескольких значений Vault?

Ответ

В ExternalSecret использую шаблон spec.target.template: вытаскиваю отдельные поля из Vault и собираю строку. Например, значение вида postgres://{{ .user }}:{{ .password }}@db:5432/notes в data шаблона. Так в Vault лежат исходные поля, а приложение получает готовую строку. Проверяю, что поля названы так же, как в data или dataFrom, и что спецсимволы в пароле не ломают URL: их нужно экранировать.

Что хотят услышать: target.template, поля из Vault, экранирование спецсимволов, готовый Secret для приложения.

Красный флаг: склеивает строку в коде и кладёт пароль в ConfigMap.

13. [middle] Какие права ты выдаёшь ESO в Vault и как не дать одной команде читать чужие секреты?

Ответ

Даю только read и list, если он нужен, на конкретные пути, а не на весь secret/*. Для разных неймспейсов использую отдельные SecretStore и отдельные роли Vault, привязанные к своему ServiceAccount и неймспейсу. ClusterSecretStore ограничиваю полем conditions с перечнем разрешённых неймспейсов. Иначе любой, кто может создать ExternalSecret, прочитает всё, что доступно ESO. Права ESO потом проверяю в аудите Vault.

Что хотят услышать: read на нужные пути, роль на неймспейс, ограничение ClusterSecretStore, риск общего доступа.

Красный флаг: один токен с правами на всё для всего кластера.

Проверено на версиях

  • External Secrets Operator: v2.11.0 (Helm-чарт 2.11.0), Vault v2.1.1 (чарт HashiCorp, standalone), PostgreSQL 18: версии из курса, кластер в этой редакции не запускался, вывод kubectl и vault сверен по документации и предыдущей редакции.
  • Helm: v4.3.0, kind: v0.33.0, kubectl: 1.37.1, Kubernetes: 1.37.1: версии стенда курса, не перепроверялись.
  • Шаблон externalsecret.yaml: разбор экранирования скобок сделан по правилам Helm, helm template не прогонялся.
  • break.sh: проверен shellcheck и чтением, на кластере не запускался.

Итог урока: ты умеешь

  • умею объяснить, как ESO доставляет секрет из Vault в Kubernetes Secret
  • умею объяснить, что такое оператор и CRD
  • умею установить ESO конкретной версии через Helm и проверить CRD и поды
  • умею описать ClusterSecretStore с Kubernetes auth и добиться Ready
  • умею собрать ExternalSecret с шаблоном DATABASE_URL и экранировать его внутри Helm-шаблона
  • умею мигрировать ручной Secret на ESO, не сломав пароль в БД
  • умею читать причину SecretSyncedError и отличать роль, привязку SA, политику и недоступность Vault
  • умею объяснить, что происходит с приложением при недоступном Vault и почему после ротации нужен перезапуск

Дальше: Урок 9.3: GitOps: Flux разворачивает «Заметки» из git

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 9 урок 9.2 3 ч курс 0/0 ← → уроки