Раздача медиа отвечала 500 на каждую плитку, а под капотом был настоящий
ответ S3:
SignatureDoesNotMatch: The request signature we calculated does not match
the signature you provided
status code: 403, request id: 18CA4386CBFA3E5D
Ключ к разгадке — что ответ пришёл БЫСТРО (0.07 с) и с настоящим request id.
То есть это не таймаут до недоступного хоста: s3-proxy реально достучался до
чужого MinIO. В base адрес зашит внутри vault-шаблона:
AWS_API_ENDPOINT=https://minio.contour.infra.sarex.tech
и это домен другого контура. Наши ключи там не подходят — к счастью, иначе
контур молча читал бы и писал в чужое хранилище.
Ровно та же ловушка, что была у backend и celery: значение внутри шаблона
аннотации, через values не перекрывается, поэтому заменяется аннотация
целиком. Тогда я поправил два релиза из трёх и до s3-proxy не дошёл.
После правки чужой домен в рендере clusters/aero/apps не встречается ни разу.
Следующая задача падала на
run.py:91 data = json.load(file)
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
то есть снова читала пустой файл — на этот раз django-auth.json.
Аннотации пода показали, почему: VAULT_MOUNT_PATH у движка ОДИН НА ВСЕ
файлы. Он подставляет его в каждую аннотацию, меняя только имя ключа:
agent-inject-file-django-auth: django-auth.json
agent-inject-template-django-auth: {{- with secret
"secrets/data/apps/processing/yc-s3" -}}
Ключа django-auth.json в том секрете не было, агент отрендерил пустоту.
Поэтому путь переименован в secrets/apps/processing/job-files: имя yc-s3
описывало один из файлов и врало о содержимом. В секрет добавлен ключ
django-auth.json — объект {"token": "..."}, а не голая строка, потому что
задача читает файл целиком через json.load. Значение то же, что у
workspaces в DJANGO_BASIC_AUTH: base64 от логина и пароля администратора.
Правило на будущее: появился новый файл, который движок кладёт в под
задачи, — добавляется ключ в этот же секрет, с именем ровно как у файла.
Адрес вида /workflows/<uuid> — это путь ВНУТРИ SPA платформы, который
должна обработать она сама, отдав index.html. Маршрут /workflows/ на
frontend-svc.processing уводил его в nginx микрофронтенда, где такого файла
нет, и браузер получал
404 Not Found — nginx/1.19.6
Статика микрофронтенда доезжает и без этого маршрута: nginx платформы сам
проксирует /workflows/*.js в processing (apps/django/aero/nginx-configmap).
Это уже проверялось — 200, 282339 байт.
Маршрут /workflows/api/ оставлен: его nginx платформы не обслуживает, а
бандл ходит по нему относительным путём.
403 Forbidden на HeadObject объяснялся не кредами и не отсутствием объекта —
и то и другое я проверил, они в порядке. Дело в схеме файла.
Движок читает /vault/secrets/processing-s3 с ключами host/bucket/verify, а
под задачи разбирает свой файл моделью S3AccountModel с ключами
endpoint/bucket_name/access_key_id/secret_access_key/use_ssl. Совпадают
только два имени из пяти.
Коварство в том, что pydantic на формате движка НЕ падает: access_key_id и
secret_access_key подходят по имени, а endpoint и bucket_name молча берут
значения по умолчанию. Поэтому вместо понятной ошибки конфигурации задача
шла в чужое хранилище и получала 403 — креды при этом верные.
endpoint пишется без схемы, протокол задаёт use_ssl; у нас MinIO внутри
кластера по http.
Два шага к тому, чтобы под задачи наконец поехал.
--- 1. S3 через vault-аннотации, а не Secret в namespace ---
Аннотации созданного пода показали точное поведение движка:
agent-inject-secret-yc-s3: <VAULT_MOUNT_PATH>
agent-inject-template-yc-s3: {{- with secret "<VAULT_MOUNT_PATH>" -}}
{{ index .Data.data "yc-s3-service-account.json" }}
secret-volume-path-yc-s3: /etc/sarex/yc-s3
То есть VAULT_MOUNT_PATH вопреки имени — не корень KV, а ПОЛНЫЙ путь
секрета, а имя ключа в нём должно совпадать с именем файла. Путь исправлен
на secrets/data/apps/processing/yc-s3 (форма чтения KV v2), и заведён
соответствующий секрет с ключом yc-s3-service-account.json.
Инжекция уже подтверждена на живом поде: agent-inject-status: injected,
том vault-secrets-custom-0, Secret yc-s3 в namespace больше не монтируется.
--- 2. Секрет реестра для подов задач ---
Под задачи вставал в Init:0/1:
Unable to retrieve some image pull secrets (dockerhub)
Движок проставляет Job'ам imagePullSecrets: dockerhub, и это имя зашито в
бинарник — настраиваемой переменной нет, из pull-настроек существует только
DEFAULT_IMAGE_PULL_POLICY. Поэтому заводим секрет с таким именем в namespace
задач через существующий механизм registry_secrets; содержимое то же, что у
regcred.
Сборка contour_a03d37da-dirty2 умеет не монтировать в Job готовый Secret, а
вешать на него аннотации vault-agent — тогда файл с кредами кладёт агент, и
Secret в namespace задач не нужен. Так это и работает в UGMK, где никакого
yc-s3 в namespace нет.
В master workflows-engine этого кода нет (сборка с невлитой ветки), поэтому
набор переменных восстановлен по строкам бинарника: VAULT_ROLE, VAULT_SA,
VAULT_MOUNT_PATH плюс аннотации role/secret-volume-path/agent-inject-*.
В base задан только VAULT_USE=true, остальных трёх нет ни в одном кластере
репозитория. Оттого аннотации выходили неполными, агент в под задачи ничего
не клал и файл читался как пустой.
Значения предварительные: точный путь Vault, который движок запрашивает,
будет виден в аннотациях созданного пода задачи — по нему и уточним.
В комментарии стоял тег contour_3ef5b462 — он был взят грепом по репозиторию
ДО первого слияния с master, а master поднял образ до contour_a03d37da-dirty2
(коммит f55f254), и в кластере всё это время крутится именно он. Утверждение
"образ о переменной не знает" поэтому необоснованно: сборка ручная и сделана
тем же коммитом, что добавил переменную.
Сам вывод не меняется: селектор задаётся явно, потому что проверить поддержку
переменной нечем, а не потому что её точно нет.
Задача падала ещё до обращения к S3, на разборе конфигурации:
pydantic ValidationError for S3AccountModel
Value error, Expecting value: line 1 column 1 (char 0)
ValueError: SERVICE_S3 is not valid
"Expecting value ... char 0" — это разбор пустоты: файла по пути не было.
Секрет secrets/minio/apps/processing из Vault при этом настроен верно, и
движок его видит (S3_SERVICE_ACCOUNT=/vault/secrets/processing-s3). Но в
поды задач движок его НЕ передаёт — для них у него отдельный механизм,
зашитый в код:
services[S3Storage] = Service{
CredentialsName: "yc-s3",
CredentialsPath: "/etc/sarex/yc-s3",
Envs: {"SERVICE_S3": `{"service_account":
"/etc/sarex/yc-s3/yc-s3-service-account.json"}`},
}
(pkg/kube_services/services.go)
То есть он монтирует в Job обычный Secret с именем yc-s3 из namespace
задач (JOBS_NAMESPACE=processing). Ни имя секрета, ни путь, ни имя файла
не настраиваются.
vault-agent сюда не дотягивается по построению: поды задач движок создаёт
сам во время работы, аннотаций инжекции у них нет. Поэтому секрет заводит
ansible — tasks/app-secrets.yml, шаблон app-secrets.yaml.j2 и описание в
k8s_file_secrets. Формат содержимого повторяет
engine/yc-s3-service-account.json из compose-стека и совпадает с тем, что
vault-agent рендерит самому движку.
Манифест рендерится, применяется и сразу удаляется — в нём креды открытым
текстом, ровно как у секретов реестра. Namespace объявлен в том же
манифесте: на холодном старте Flux до слоя apps ещё не дошёл, а apply в
несуществующий namespace упал бы. Повторное создание безвредно, так же
заведён namespace vault.
Симптом: движок брал задачу и падал на её создании —
ERROR create k8s job: create k8s job: unknown service
Ни одного пода при этом не создавалось.
Причина — моя правка. Я выставил ENABLE_TOLERATION=0, чтобы поды задач не
получали nodeSelector dedicated=processing на несуществующие выделенные
ноды. Но в движке ЭТОТ ЖЕ флаг управляет регистрацией классов ресурсов:
if resCfg.EnableToleration {
services[HighResources] = Service{...}
}
(pkg/kube_services/services.go)
Поэтому задача с service_request "high-resources" отваливалась не на
планировании, а раньше — на разборе, как неизвестный сервис. В логе при
этом ни слова про nodeSelector, связь с флагом неочевидна.
Вместо выключения флага селектор переведён на метку, которая есть на всех
трёх нодах контура, — kubernetes.io/os=linux. Класс сервисов остаётся
зарегистрированным, nodeSelector совпадает с любой нодой, toleration на
несуществующий taint безвреден. То же сделано для HIGH_MEM и PERSISTENT и
для DEFAULT_NODE_SELECTOR_*, которые раньше были пустыми строками.
Ресурсы не трогаем: в base это 1 CPU и 1Gi.
IGNORE_TAINTS_AND_NODE_SELECTOR, приехавший в base из master, для этого не
подходит: в исходниках workflows-engine такой переменной нет вовсе, образ
contour_3ef5b462 о ней не знает.
Продолжение разбора: после появления bimapidb оба пода упали на
psycopg2.ProgrammingError: ltree type not found in the database
а миграция api — на
type "ltree" does not exist
LINE 2: ALTER TABLE bimelement ADD hierarchy ltree null;
models/database.py при инициализации зовёт register_ltree(conn), без
расширения приложение не стартует. Роль bim не суперпользователь и
CREATE EXTENSION сама не сделает — расширение обязан завести бутстрап,
как у sarex_db и workflow_db. Добавлено в contour.databases.
--- Почему понадобилась отдельная задача ansible ---
Базы и расширения описаны декларативно, но заводит их скрипт бутстрапа
ТОЛЬКО при старте пода postgresql. На живом кластере правка списка
доезжает до Kubernetes и не делает ничего: спецификация пода не меняется,
пересоздания нет. Flux при этом полностью зелёный, а приложение падает с
"database does not exist" — связь между причиной и симптомом неочевидна.
Альтернатива — перезапуск postgresql-0, но это обрыв коннектов django,
processing и workspaces ради одной базы.
Добавлены tasks/databases.yml, список sarex_databases в defaults и задача
`uv run poe dbsync`. Делает ровно то же, что бутстрап, но без простоя, и
идемпотентно: существующие базы пропускаются, расширения создаются через
IF NOT EXISTS. Расширения обрабатываются отдельным шагом и всегда, а не
только для новых баз, — расширение может появиться в списке позже самой
базы, ровно так и вышло с ltree.
Проверено на живом контуре: bim-api 2/2 (uWSGI поднял воркеров),
bim-worker 2/2 (celery подключился к брокеру, 8 задач зарегистрировано).
Оба пода уходили в рестарты, причины разные и обе — мои.
1. bim-api:
psycopg2.OperationalError: FATAL: database "bimapidb" does not exist
Имя базы у приложения ЗАХАРДКОЖЕНО и переменной не задаётся — в
entrypoint_api.sh (yoyo apply ... /bimapidb) и в models/database.py
(PooledPostgresqlExtDatabase('bimapidb', ...)). Переменной DB_NAME в
коде нет вообще, я подставлял её впустую. Запись в contour.databases
переименована bim_db -> bimapidb, DB_NAME из vault-шаблона убран.
Пустая bim_db остаётся в СУБД: бутстрап умеет только создавать. Данных
в ней нет — проверено, ноль таблиц.
2. bim-worker:
Exception: ENV_VARIABLE NOT SET: GOOGLE_APPLICATION_CREDENTIALS
settings.py делает check_settings(): проходит по ВСЕМ ключам ENV_FIELDS
и падает на первом незаданном, до проверки флагов дело не доходит.
Убирать переменную вместе с Google-хранилищем было нельзя. Возвращена;
файла по этому пути нет и не нужно — при FEATURE_ENABLE_GCLOUD=false
его никто не открывает, важно лишь наличие самой переменной.
Отсюда правило для этого приложения: набор переменных должен повторять
apps/bim/dsinv целиком, образ там тот же (870965d1...). Менять можно
только значения — адреса, бакет, брокер, — но не состав.
Разворачивается ИМЕННО bimbackend (Python, пара api + worker), а не
platform/bim-backend-v2 из apps/bim/base — тот откачен коммитом 285255e.
../base здесь намеренно не подключён.
Различить два приложения по образу нельзя: оба пушат bim-api. Различие в
тегах — у bimbackend полный SHA коммита (CI пушит bim-api:${CI_COMMIT_SHA}
с master), у bim-backend-v2 contour_*. Взят 870965d1..., тот же, что
крутится в dsinv.
Манифесты обычные, а не universal-chart: в репозитории сервис так и описан
(apps/bim/dsinv), своего чарта у него нет.
Отличия от dsinv:
* секреты из Vault вместо secretKeyRef на несуществующие Secret'ы;
штатный entrypoint файлы читать не умеет, поэтому обёрнут в sh -ec;
* PROCESSING_API_URL и WORKSPACES_API_URL приведены к именам контура —
в dsinv это workflows-service:8000 и workspaces-service:8000, здесь
оба сервиса называются backend-svc и слушают 80;
* RABBIT_HOST у воркера в dsinv указывает на rabbitmq-service.bim —
брокера в этом namespace нет ни там, ни здесь; у api в том же файле
адрес правильный. Поставлен общий брокер контура, vhost корневой;
* S3_BUCKET переведён на общее медиахранилище sarex-media-storage;
* убраны GOOGLE_APPLICATION_CREDENTIALS и том с ключом — Google в
контуре нет, флаг FEATURE_ENABLE_GCLOUD и так выключен;
* NodePort с прибитым 31352 заменён на ClusterIP: наружу сервис выходит
через общий Gateway;
* снят nodeSelector name=generic, imagePullSecrets переведён на regcred.
ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, принято осознанно. S3 у этого приложения на MinIO
не настраивается: в storage/s3.py адрес зашит в код —
endpoint_url='https://storage.yandexcloud.net'
переменной окружения для него нет. Из контура он недостижим, поэтому
операции с файлами будут падать по таймауту, а остальные эндпоинты живы.
Альтернативы — пересборка образа с вынесенным endpoint либо перевод на
BIM_CURRENT_STORAGE=local (тип есть в storage/storages.py).
Наружу выведен на bim.sarex.local.lonsdaleites.ru, имя добавлено в
dnsNames сертификата. Тот же адрес приложение знает про себя через
BIM_API_EXTERNAL_HOST и строит по нему ссылки.
Коммит c8eefab развернул platform/bim-backend-v2 (Go, один httpserver) —
он приезжает из apps/bim/base. Нужен же aero/bimbackend: Python, пара
api + worker, S3-бакет sarex-bim-storage, связки с processing и
workspaces. В iac он лежит не в base, а отдельными манифестами
bim-api.yaml и bim-worker.yaml (см. apps/bim/dsinv).
Спутать их легко: ОБА пушат в реестр один и тот же образ bim-api, и
различить можно только по содержимому — v2 пишет в лог
"Starting BIMv2 API server", а у bimbackend точки входа
entrypoint_api.sh и entrypoint_worker.sh.
Откатывается всё: релиз, маршрут наружу, имя в сертификате, адрес
BIMV2_INTERNAL_HOST у django и обвязка (роль Vault, секрет, regcred,
путь синхронизации). Заводить их заново под bimbackend дешевле, чем
править остатки под другой сервис — у него могут отличаться и namespace,
и имя базы.
База bim_db при этом НЕ трогается и пересоздания не требует: проверено
до отката — ноль таблиц, 9 МБ пустого шаблона. v2 к ней подключился, но
схему не создал, миграции не отработали.
Релиз идёт из base без единого патча, и это проверено по исходникам
bim-backend-v2, а не принято на веру:
* DJANGO_HOST в base уже правильный (backend-svc.django:80) — редкость,
у processing и workspaces там лежал несуществующий backend:8000;
* DB_CERT_PATH_2/3/4 указывают на сертификат управляемой СУБД Яндекса,
которого в контуре нет, но зануления не требуют: файл читается только
под флагом ENABLE_SSL, а при нуле к строке подключения дописывается
?sslmode=disable и путь не трогается;
* пять комплектов POSTGRES_*_N в vault-шаблоне смотрят на один хост —
в проде это реплики, в контуре СУБД одна.
Django ходил в никуда: BIMV2_INTERNAL_HOST в base ведёт в namespace
bim-api к сервису bim-backend-v2-service, а релиз разворачивается в
namespace bim и называет сервис backend-svc. Поправлено в обоих релизах
django — backend и celery.
Наружу выведен отдельным поддоменом bim.sarex.local.lonsdaleites.ru, а не
префиксом на домене платформы: своего фронтенда у сервиса нет, общего с
платформой origin тоже, а его пути конфликтовали бы с маршрутом /api/
django. Имя добавлено в dnsNames сертификата — без этого браузер получил
бы сертификат без него.
Обвязка: regcred и роль Vault для namespace bim, apps/bim в
gitea_sync_paths, секрет secrets/apps/bim/postgres (база bim_db и роль bim
уже заведены в contour.databases).
ИЗВЕСТНЫЙ РИСК, проверяется логами первого пода. В ветке develop у
bim-backend-v2 миграции катятся при старте httpserver, ошибка фатальна, а
адрес БД внутри RunOnStartup взят не из конфига, а из константы
rc1b-sse4o3n9vea392g4.mdb.yandexcloud.net:6432. Из контура он недостижим.
Если этот код попал в образ contour_f9f2a39-dirty, под уйдёт в
CrashLoopBackOff и лечиться это будет только пересборкой образа.
Аннотации Flux force, prune и ssa принимают enabled/disabled, а не
булево. Написанное "true" Flux молча игнорировал.
Пока Job'а не существовало, это ничем себя не проявляло: обычный apply
проходил, бакеты заводились, всё выглядело рабочим. Сломалось на первой
же правке скрипта — spec.template у Job неизменяем, и слой
infra-controllers встал целиком:
Job/minio/minio-buckets dry-run failed (Invalid):
Job.batch "minio-buckets" is invalid: spec.template: ... field is immutable
а вместе с ним встал зависящий от него слой apps: измерения не получили
новый бакет не потому, что правка неверна, а потому что до них не дошла
очередь.
--- Бакет ---
В base у measurements имя бакета ЗАШИТО в самом vault-шаблоне:
"buckets":["measurements"]
Из-за этого он складывал файлы в собственный бакет, тогда как
медиахранилище контура одно — sarex-media-storage: туда пишет django и
оттуда же раздаёт s3-proxy на маршруте /media/. Ссылка на файл
измерения, отданная через django, вела бы в пустоту.
Шаблон переопределён в apps/measurements/aero так, чтобы имя бакета
читалось из секрета — как уже читается endpoint. Значение задаётся в
одном месте, vault_app_secrets, и совпадает с sarex_django_s3_bucket.
Заменить пришлось аннотацию целиком: значение внутри шаблона, через
values его не перекрыть.
Из Job'а бакетов убран measurements. Это не удаление: Job умеет только
mc mb --ignore-existing, ранее созданный бакет остаётся на месте — мы
лишь перестаём его заводить.
Порядок применения безопасен: в platform.yml vault-k8s.yml идёт до
gitea-sync.yml, то есть секрет получит ключ bucket раньше, чем Flux
пересоздаст под. Иначе vault-agent-init не отрендерил бы шаблон с
несуществующим ключом и под ушёл бы в Init:Error.
--- Перекат нагрузок ---
Единственное действие, которое до сих пор делалось руками мимо роли, —
rollout restart после правки ConfigMap. Оно понадобилось дважды (порядок
set/rewrite в nginx и auth_type) и оба раза выглядело как «правка не
поехала»: Flux рапортует Applied revision, а поведение старое.
Причина в том, что у релизов universal-chart в шаблоне пода нет
контрольной суммы конфига: ConfigMap меняется, спецификация пода — нет,
Kubernetes не видит причин пересоздавать под. Декларативно это здесь не
лечится — имя ConfigMap передаётся чарту строкой, монтирует он его сам, а
configMapGenerator переименовал бы ресурс, не поправив ссылку внутри
чарта, который рендерится уже в кластере.
Добавлены tasks/reload.yml, список sarex_reload_workloads в defaults и
задача `uv run poe reload`. По умолчанию выключено: перезапуск подов
посреди обычного деплоя происходить сам не должен.
Чарт дашборда своего пользователя не создаёт — он лишь показывает форму
ввода токена и ходит в API от имени того, чей токен введён. До сих пор
дашборд открывался, но войти в него было нечем.
Добавлены ServiceAccount dashboard-admin, привязка к встроенной роли
cluster-admin и Secret типа kubernetes.io/service-account-token.
Secret нужен именно ради бессрочности: с Kubernetes 1.24 ServiceAccount
не получает вечный токен автоматически, а kubectl create token выдаёт
короткоживущий. Secret с аннотацией kubernetes.io/service-account.name —
единственный штатный способ получить токен без срока действия: поле
token заполняет контроллер, в репозиторий оно не попадает.
Права взяты максимальные осознанно: дашборд задуман как инструмент
оператора — логи, exec, перезапуск нагрузок, — и с меньшими правами
половина экранов отдаёт ошибки доступа. Для закрытого тестового контура
это приемлемо; сузить до read-only можно заменой roleRef на встроенную
view, отозвать доступ — удалением Secret.
Envoy перехватывает исходящий трафик через iptables-правила, которые
ставит init-контейнер istio-init. Правила появляются раньше, чем envoy
начинает слушать, поэтому приложение, дёрнувшее сеть в первые секунды,
получает не таймаут, а connection refused — пакет уже завёрнут на порт
envoy, а там ещё никого нет.
Так падал workflows-api: контейнер стартовал и умирал в ту же секунду,
INFO Starting workflows migrations
INFO init database connection...
FATAL [dial tcp 10.43.195.4:5432: connect: connection refused]
при restartCount=0 у istio-proxy. Со второй попытки envoy успевал, и
дальше всё работало — то есть симптомом был ровно один рестарт на
старте, который легко списать на случайность.
Настройка сделана на уровне mesh, а не аннотацией на поде: уязвимы все,
кто ходит в БД или брокер сразу при запуске, а это почти каждый бэкенд
контура — django катит migrate первым делом, celery подключается к
rabbitmq, workspaces-api и engine-low к postgres. Они не падали не
потому, что устроены иначе, а потому что успевали.
Цена — каждый под стартует на время готовности envoy дольше, обычно
секунду-две: istio добавляет в sidecar postStart-хук, блокирующий запуск
остальных контейнеров. На Job'ы не влияет, у них инжекция отключена.
Действует в момент инжекции, поэтому на уже запущенные поды не
распространяется — хук появится при следующем пересоздании.
Config держит копию конфига в localStorage под ключом "config" и на
старте берёт синхронно кеш, сверяя файл асинхронно, а http-service.ts
читает auth_type при импорте модуля. Первая загрузка после правки лишь
обновляет кеш — применяется он со следующей. Без этой сноски выглядит
так, будто подмена config.json не сработала.
Платформа редиректила на zitadel.contour.infra.sarex.tech — чужой
контур, из aero недостижимый, — и падала с
invalid_request: The requested redirect_uri is missing in the client
configuration
то есть войти было нельзя вообще.
Zitadel во фронтенде выключается штатно и в рантайме. На старте он
делает fetch("/static/config.json") и, если там есть auth_type,
БЕЗУСЛОВНО перекрывает им режим, зашитый в сборку
(src/Model/api/http-service.ts в generic/sarex-frontend). Значений два:
zitadel и original; original — классический вход по логину и паролю с
JWT, ради которого backend и получает из Vault ключи RS512, а
SERVER_ZITADEL_ENABLED в оверлее уже стоит в False.
Файл отдаётся прямо из nginx, а не подкладывается томом: static/config.json
в sarex-frontend лежит в .gitignore и заполняется на каждом стенде своим —
это штатная точка расширения. В апстримном nginx.conf под него заведён
такой же location = с no-store, только читающий с диска.
Через localStorage подменить нельзя: оверрайд оттуда действует лишь для
endpoint'ов stage, preprod и local, contour в этом списке нет.
master добавил в apps/processing/base/engine-low.yaml переменную
IGNORE_TAINTS_AND_NODE_SELECTOR, из-за чего всё начиная с индекса 21
сдвинулось на единицу. Патчи оверлея адресуются по индексу, и сборка
слоя apps упала:
testing value /spec/values/services/backend/envs/22/name failed
Это ровно то, ради чего перед каждым replace стоит op: test — вместо
тихой перезаписи соседней переменной получили явную остановку сборки.
Индексы сдвинуты: 21→22, 22→23, 25→26, 26→27, 27→28, 28→29, 30→31,
31→32, 32→33, 66→67, 69→70, 70→71. Индексы 2, 5 и 6 не изменились.
Сама новая переменная патча не требует: в base у неё "true", то есть
апстрим теперь по умолчанию игнорирует taint'ы и nodeSelector — как раз
то поведение, ради которого здесь зануляются ENABLE_TOLERATION и
DEFAULT_NODE_SELECTOR_*. Прежние правки оставлены: они не мешают, а
убирать их вслепую, не зная приоритета флагов внутри engine, рискованно.
Комментарий в оверлее переписан с «в бандле есть srx_workspaces» на
проверяемые факты. Прежняя формулировка была получена grep'ом по
подстроке, которая матчит и srx_workspacesV2, — вывод оказался верным
по случайности.
Что проверено:
* в бандле платформы из 18 упоминаний remoteEntry.js воркспейсовое
одно, /workspaces-v2/..., и встречается только srx_workspacesV2;
* отдаваемый контуром /module/remoteEntry.js объявляет то же имя;
* в cr.yandex у workspaces-v2-frontend 61 тег contour_*, у v1
(workspaces-frontend) ни одного, образа workspace-frontend нет.
Тег contour_2a4ce3fd из base — самая свежая contour-сборка: по датам
создания образов в реестре она от 2026-07-24, предыдущая от 2026-07-19.
Собирает такие образы джоба build_contour из
generic/build-contour-frontend по правилу «ветка master и
ENABLE_BUILD_IMAGE_CONTOUR=true», то есть contour_* всегда с мастера.
Переопределять тег в оверлее не нужно.
apps/workspaces/aero — оверлей поверх base:
* DJANGO_HOST в base неверен дважды: сервиса backend не существует
(он backend-svc), и 8000 — это targetPort контейнера, а сервис
принимает на 80. Проверено в кластере: backend-svc:80 отдаёт 200,
backend-svc:8000 таймаутит, backend:8000 не резолвится. Та же
ошибка была в base у processing/engine-low.
* ENVIRONMENT/DJANGO_ORIGINATOR — «prod» заменён на aero: значение
уезжает в Sentry и в заголовки запросов к смежным сервисам.
* DOCUMENTATION_HOST переписан на честное имя внутри кластера.
Выключить интеграцию нечем: DOCUMENTATION_LOGGER_FEATURE гасит
только логирование, а GetDocumentByWS вызывается из обработчика
GET workspace безусловно. Сервиса documentations в контуре нет и
не будет — карточка воркспейса отдаётся без документа.
Фронтенд идёт из base без патчей и наружу НЕ публикуется: это Module
Federation remote srx_workspaces, его подгружает главный фронтенд
платформы. Версия — v2, и это не выбор: в бандле
sarex-frontend-dev:contour_5.22.0 лежит строка
/workspaces-v2/module/remoteEntry.js и имя ремоута srx_workspaces,
обращений к v1 нет вовсе. У v1 (platform/workspaces-frontend) к тому же
нет ни одной contour-сборки.
nginx-configmap: исправлен порядок директив в трёх location'ах.
set обязан идти ДО rewrite ... break — обе принадлежат
ngx_http_rewrite_module, и флаг break прекращает обработку его
директив, поэтому set после него не выполнялся. Переменная оставалась
пустой, nginx писал «no host in upstream :80» и отдавал 500. Так падали
и /workspaces-v2/..., и /workflows/... — маршрут / при этом работал,
из-за чего дефект и не был замечен раньше.
Обвязка: regcred и роль Vault для namespace workspaces, apps/workspaces
в gitea_sync_paths, секреты secrets/apps/workspaces/postgres и
secrets/vault/common/django_auth. Последний — вопреки имени пути НЕ
токен Zitadel: workspaces читает оттуда ключ key и кладёт его в
DJANGO_BASIC_AUTH, то есть это base64 логина и пароля администратора
django. Совпадение проверено в кластере по sha256.
Фронтенд был исключён из-за адресов, вшитых в бандл на этапе сборки через
константу __BUILD_ENV__. Опасение не подтвердилось: сборка обращается к тому
же origin относительными путями — иначе в yc-k8s-test и d8-ugmk-prod не
понадобился бы отдельный маршрут /workflows/api/ перед /workflows/.
Маршруты добавлены на домен платформы, а не на отдельный поддомен: поддомен
потребовал бы пересборки образа с другим __BUILD_ENV__.
/workflows/api/ -> rewrite /api/ -> backend-svc.processing
/workflows/ -> rewrite / -> frontend-svc.processing
Порядок внутри пары значим: /workflows/api/ обязан идти перед /workflows/,
иначе запросы к API уйдут в раздачу статики. Оба стоят перед /api/ и / —
правила django их бы перехватили.
Маршруты положены в существующий VirtualService platform, а не в отдельный:
Istio не сливает несколько VirtualService на одной паре хост+gateway, и часть
правил молча потерялась бы.
Развёрнут частично, и это осознанно:
* engine удалён — в base у него replicaCount 0, то есть он выключен и там,
а образ прибит к тегу с опечаткой в имени (workflows-endigne_prod);
* frontend удалён — адреса бэкендов зашиты в образ на этапе сборки через
__BUILD_ENV__, и ни один готовый профиль не смотрит на домен контура.
Подключать его имеет смысл только вместе с пересборкой образа.
Отключены смежные сервисы, которых в контуре нет: documentations (PDM,
filestream), resources, bim-api, workspace, issues, comparisons, mailgun,
SMTP. Оставлен только S3. Вместе с флагом ENABLE_SMTP убрана аннотация
инжекции secrets/vault/common/smtp_auth — секрета нет, и vault-agent-init
оставил бы под в Init:Error, не дойдя до флага.
S3 приезжает из Vault отдельным файлом: приложение читает по пути из
S3_SERVICE_ACCOUNT целый JSON, а не пару переменных. Набор ключей повторяет
engine/yc-s3-service-account.json из compose-стека, который так же смотрел
в MinIO.
Планирование подов задач: engine работает kubernetes-исполнителем и сам
создаёт Job'ы, подставляя им nodeSelector dedicated=processing. Выделенных
нод в контуре нет, поды висли бы в Pending — как это уже было у postgresql.
ENABLE_TOLERATION и DEFAULT_NODE_SELECTOR_* сняты.
Заодно у celery поправлены четыре адреса на wb.sarex.io — чужой контур,
недостижимый из aero. У backend те же переменные уже вели внутрь кластера;
при WORKFLOWS_USE=1 расхождение давало бы зависания вместо явной ошибки.
Патчи адресуются по индексу env, поэтому перед каждым replace стоит op: test
на имя переменной: смена порядка в base уронит сборку явно, а не перезапишет
соседнее значение.
Django обращается к сервису измерений при MEASUREMENTS_USE_MEASUREMENTS=1
(значение по умолчанию в base), так что без него часть API отвечала ошибкой.
Сервис переименован в measurements-service, как в brusnika-*. В base он
называется measurements-svc, а django ходит на measurements-service —
переименовать сервис дешевле, чем патчить переменную в двух релизах django.
Бакеты теперь заводит Job в infrastructure/minio/aero, а не команда руками.
Чарт MinIO этого не умеет: форк не принимает ни buckets, ни provisioning.
Так бакет django был создан императивно и не пережил бы пересоздание
контура — развернув всё с нуля, получили бы работающий MinIO и приложения,
падающие на отсутствующем бакете. Measurements сломался бы на этом сразу:
имя бакета зашито в его vault-шаблоне. Идемпотентность даёт
mc mb --ignore-existing.
Сайдкар istio у Job отключён: контейнер задачи выходит, envoy продолжает
работать, и под навсегда остаётся Running.
Celery не поднимался: worker падал в CrashLoopBackOff на
amqp.exceptions.AccessRefused (403) при подключении к брокеру.
Диагностика уводила в сторону — 403 читается как несовпадение пароля, но
пароль был верный: хеши значения в Vault, в файле, смонтированном в под
брокера, и в собранном BROKER_URL совпадали, а rabbitmqctl authenticate_user
с ним проходил. Настоящая причина нашлась в логе брокера:
PLAIN login refused: user 'rabbit' can only connect via localhost
Пользователю разрешён вход только с localhost, поэтому отбивалось любое
подключение из кластера — и по той же причине в management UI нельзя было
войти снаружи.
Лечится так же, как в d8-ugmk-prod: флагом auth.enableLoopbackUser и
дублирующим advancedConfiguration. Дубль не избыточен — флаг лишь пишет в
rabbitmq.conf строку "loopback_users.<user> = false", которую брокер не
применяет: в рантайме loopback_users всё равно оставался [<<"rabbit">>].
Значение задаёт именно advanced.config.
Worker был отключён патчем удаления, пока не было очередей. RabbitMQ и MinIO
развёрнуты, схема БД накачена — включаем.
Расхождения с base у celery ровно те же, что у backend: нет Kafka, MinIO
живёт внутри кластера. Аннотации инжекции несуществующих секретов убраны —
иначе vault-agent-init не отрендерит шаблон и под навсегда останется в
Init:Error. SERVER_ZITADEL_ENABLED не трогаем: в base у celery он уже False.
Миграции и создание администратора в стартовый скрипт worker'а намеренно НЕ
добавлены: схемой владеет backend, а два процесса, катящих миграции при
одновременном старте, — гонка на ровном месте.
Тег образа выравнен с backend (production_f813140d вместо production_a96dead0).
Разные версии кода у web и worker ломаются не при старте, а на конкретной
задаче, когда worker десериализует аргументы по своей версии модели.
Учётка создавалась задачей ansible через kubectl exec — по явной команде
оператора и в обход GitOps. Теперь это делает сам под при каждом старте:
стартовый скрипт подхватывает креды из Vault и вызывает штатную
createsuperuser --noinput.
Идемпотентность обеспечена ветвлением, а не подавлением ошибки: на уже
существующем логине Django возвращает "That username is already taken" с
ненулевым кодом, что под set -e из command базы уронило бы под на втором
запуске. Ошибка ожидаема и гасится веткой else.
Заодно в стартовый скрипт добавлен migrate. В entrypoint.sh образа он уже
есть, но запущен без set -e — его падение не мешает uwsgi стартовать.
Именно так контур несколько часов отдавал 200 на /admin/ с пустой схемой:
backend не мог аутентифицироваться в СУБД, миграции падали, приложение
работало. Здесь команда идёт под set -e, поэтому отказ виден сразу.
Ansible остаётся владельцем значений: генерирует логин и пароль (теперь
безусловно, а не по флагу — их ждёт vault-agent) и кладёт в
secrets/apps/django/superuser. Задача poe superuser печатает креды.
Убраны временные диагностические задачи с ignore_errors, добавленные при
разборе отказа createsuperuser.
Первый прикладной сервис переехал из compose в кластер. Развёрнуты backend,
frontend, redis, srx-admin и s3-proxy; celery выключен до следующего шага.
Платформа доступна по https://sarex.local.lonsdaleites.ru — корень отдаёт
frontend, /api/ и /admin/ уходят в backend, /media/ в s3-proxy.
Kafka и Zitadel в контуре не развёрнуты, и приложению они не нужны:
SERVER_KAFKA_ENABLED в base уже False, а zitadel_enabled в sarex-backend
читается ровно в одном месте — update_ams_user(), которая при выключенном
флаге сразу выходит. Но аннотации инжекции этих секретов пришлось убрать:
vault-agent-init не рендерит шаблон для несуществующего секрета и роняет под
в Init:Error ещё до старта приложения.
nginx-configmap заменён целиком. В base зашиты upstream'ы на namespace pm,
workspaces и processing; nginx резолвит их при старте и падал с
"host not found in upstream". pm убран как ненужный, для остальных двух
адрес вынесен в переменную с resolver — их резолвинг откладывается до
запроса, поэтому появление сервисов позже не потребует правки конфига.
Заменять пришлось именно патчем: объект с тем же kind+name уже приходит из
base, и добавление его в resources валит сборку слоя целиком.
Секреты django в Vault: postgres, rabbitmq, minio и общие RSA-ключи JWT.
RabbitMQ и MinIO подключены под административными кредами — отдельных
пользователей приложения чарты создавать не умеют, это осознанный долг.
Задача создания суперпользователя переписана с docker compose exec на
kubectl exec. ВНИМАНИЕ: она пока НЕ РАБОТАЕТ — команда падает, вывод скрыт
no_log. Механизм проверен отдельно и исправен, значит ошибка на стороне
Django. Чтобы увидеть причину, нужно временно снять no_log.
PostgreSQL разворачивается пустым инстансом, состав баз повторяет то, что
сейчас провижинит postgres-init в compose:
sarex_db -> django (ltree)
workflow_db -> processing (uuid-ossp, ltree, hstore)
bim_db -> bim
workspace_db -> workspace
Пароли администратора и владельцев берутся из Vault через
vault-agent-injector, в репозитории их нет. Расширения создаёт бутстрап
заранее: роли не суперпользователи и CREATE EXTENSION в миграциях им
недоступен. timescaledb включён в shared_preload_libraries сразу, хотя этим
базам не нужен, — параметр читается только при старте, и добавить его позже
означает перезапуск СУБД.
nodeSelector снимается postRenderers'ом. Чарт по умолчанию ставит
dedicated: sts в расчёте на выделенный пул нод; в контуре таких нет, и под
навсегда повисал в Pending, а следом застревал PVC, потому что у local-path
режим WaitForFirstConsumer. Через values это недостижимо: Helm сливает карты
по ключам, поэтому ни {}, ни другая метка dedicated: sts не убирают.
Цикл реконсиляции сокращён с 10 до 2 минут у всех Kustomization и
HelmRelease контура. Корневая Kustomization патчится из clusters/aero:
её файл генерирует flux bootstrap с пометкой DO NOT EDIT, а флаг --interval
задаёт периодичность только GitRepository. Таймаут установки postgresql
снижен с 20 до 10 минут — он определяет цену одной неудачной итерации.
Отключение собственных Gateway/VirtualService/Certificate чарта rabbitmq
переведено с JSON6902 на postRenderers: kustomize нормализует null в {}, а
пустую карту Helm сливает с дефолтами, поэтому настоящий null до Helm не
доходит ни одним путём.
Топология k3s: 1 мастер + 2 воркера (убран k3s-worker-3 с томом).
Vault ↔ k3s. Vault живёт в compose, вне кластера, поэтому получает
статический IP 172.28.0.13, DNS-мост в namespace vault и token reviewer
(SA vault-auth + system:auth-delegator). Ansible включает auth/kubernetes,
передавая адрес apiserver, CA и JWT ревьюера явно, и заводит политику с
ролями. Секрет token reviewer'а читается целиком в JSON: вариант
-o jsonpath={.data.ca\.crt} НЕ работает — модуль command разбирает строку
через shlex, съедает обратный слэш, kubectl возвращает пустую строку с
кодом 0, и отказ остаётся незамеченным до падения vault write.
Синхронизация репозитория в gitea. Нужное подмножество путей (замыкание
ссылок clusters/aero) едет архивом на хост и коммитится там: gitea не
публикуется дальше самого хоста, контур остаётся замкнутым. flux-bootstrap
вынесен в отдельную волну — до неё Vault получает auth/kubernetes, а
репозиторий наполняется, иначе первая реконсиляция падает на пустом репо.
Инфраструктура: vault-agent-injector (чарт vault-contour в режиме внешнего
Vault), local-path-provisioner взамен встроенного в k3s (--disable=
local-storage; путь данных прежний), rabbitmq и minio с кредами из Vault.
Выход наружу: порты 80/443 k3s-server опубликованы (istio ingressgateway
занимает эти hostPort), общий contour-gateway на wildcard-хост, сертификат
Let's Encrypt через http01. Имена в сертификате перечислены явно —
http01 не выдаёт wildcard, для них нужен dns01.
Разорваны два дедлока Flux: infra-configs больше не зависит от
infra-controllers (издатели не должны зависеть от здоровья чартов, которые
их используют), istio-config получил disableWait.
Модули ядра iptable_nat и смежные грузятся на хосте: ноды k3s —
контейнеры, istio-init правит iptables через ядро хоста, а на RED OS 8
(nf_tables) legacy-модули не загружены, из-за чего любой под с
istio-injection навсегда вставал в Init:Error.
Собственные Gateway/VirtualService/Certificate чартов rabbitmq и dashboard
отключены — маршрутизация описана централизованно. У rabbitmq это сделано
postRenderers, а не values: kustomize нормализует null в {}, а пустую карту
Helm сливает с дефолтами, возвращая их целиком.