Привет. Меня зовут Денис, я занимаюсь разработкой и доставкой приложений под Kubernetes с 2020 года. За это время через мои руки прошло достаточно проектов, чтобы заметить одну повторяющуюся вещь: почти в каждом из них рано или поздно заводился собственный набор скриптов, склеивающих сборку образа и выкатку релиза. Скрипты были разные, а болезнь одна – они прирастали к проекту и не переносились на следующий.
В этой статье я разберу контур доставки, который мы собрали, чтобы эту болезнь вылечить. Он ставит два совершенно разных по стеку приложения – React плюс Spring Boot и Angular плюс FastAPI – в кластеры двух окружений одной командой, при этом сам про эти приложения ничего не знает. Всё, что их связывает, – небольшой контракт из нескольких переменных.
Материал устроен как разбор рабочего кейса: сначала проблема, далее решение с кодом, потом операции и результат. Отдельным разделом в конце – список того, где эта схема неудобна, дорога или просто не нужна. Я специально не стал прятать его в сноски: без этого раздела получился бы рассказ про идеальную схему, а таких схем не бывает.
Сразу оговорю границы. Разворачивание кластеров в эту историю не входит: контур работает с уже готовыми кластерами и занимается только доставкой в них. Не входит сюда и выбор CI-системы – всё, что описано дальше, это набор команд, которые одинаково запускаются руками с сервера деплоя, из GitLab CI или из Jenkins. Речь именно про то, что происходит между «код в репозитории» и «релиз в кластере».
Всё, что я показываю, лежит в открытом репозитории werf_ci_demo: контур, оба приложения, чарты и документация. Ссылки на конкретные файлы – по ходу текста, сводный список – в конце. Kubernetes и контейнеры я предполагаю знакомыми, а вот werf объясню с нуля: он не обязателен для понимания идеи, и знать его заранее не нужно.
Проблема: доставка распадается на две половины
Доставка приложения в Kubernetes состоит из двух разных задач. Первая – собрать образ и положить его в registry. Вторая – отрендерить манифесты и применить релиз в кластер. Инструменты для этих задач тоже разные: сборщик образов и шаблонизатор манифестов.
Между ними есть стык, и держит его человек. Выглядит это примерно так:
docker build -t registry.example.com/app1:1.4.2 docker push registry.example.com/app1:1.4.2 # а теперь не забыть поправить тег в values, иначе выкатится старый образ sed -i 's/tag:.*/tag: 1.4.2/' .helm/values-prod.yaml helm upgrade --install app1 .helm --namespace app1-prod
Здесь всё держится на дисциплине. Забыл поправить тег – выкатил предыдущую версию и полчаса ищешь, почему исправленный баг воспроизводится. Поправил, но не собрал – получил ImagePullBackOff. Собрал под другим тегом – в registry накопился мусор, который никто не удалит.
Дальше история развивается предсказуемо. Эти три команды обрастают проверками, переменными окружения, ветками по окружениям, и превращаются в deploy.sh на двести строк. В нём зашиты имя образа, неймспейс, хост для ingress, адрес registry. Когда в компании появляется второй проект, у него заводится свой deploy.sh – похожий, но не такой же, потому что стек другой и мелочи не совпадают.
Через год у вас пять проектов и пять почти одинаковых скриптов, каждый из которых знает про своё приложение слишком много. Изменение в общем подходе – например, переезд на другой registry – это пять правок в пяти местах.
Причём переезд в CI ничего принципиально не меняет. Скрипт переезжает в .gitlab-ci.yml или в Jenkinsfile почти как есть, только теперь его части размазаны по стадиям пайплайна, а условия по окружениям превращаются в правила rules: и when:. Знание о приложении никуда не делось – оно просто переехало из одного файла в описание пайплайна, где читать его ещё труднее. Я видел пайплайны, в которых имя неймспейса встречалось в семи местах, и все семь надо было менять согласованно.
Есть и вторая сторона той же проблемы – окружения. Как только появляется prod рядом с dev, в скриптах заводятся ветки if [ "$ENV" = "prod" ], и с каждым релизом их становится больше. Обычно ветвление начинается с невинного «в проде другой хост», а заканчивается тем, что dev и prod проходят по разным путям кода. И когда в проде что-то ломается, воспроизвести это в dev не получается: пути-то разные.

Ключевая мысль, к которой я пришёл: проблема не в том, что инструментов два. Проблема в том, что контур доставки знает приложение. Пока он знает – он не переносится, и каждый следующий продукт оплачивает разработку своего контура заново.
Отсюда и требование к решению, от которого я отталкивался: контур должен уметь выкатывать приложение, ничего про него не зная. Не «знать про него меньше», а именно ничего – иначе граница снова поплывёт на первом же нестандартном случае.
werf и converge: что это и зачем
werf – открытый CLI-инструмент, который закрывает обе половины доставки в одной программе. Сборку он описывает в werf.yaml, деплой берёт из helm-совместимого чарта в каталоге .helm/. Принципиально здесь то, что обе операции идут из одного запуска и от одного состояния git, поэтому werf сам знает, под каким тегом образ окажется в манифесте. Тот самый стык, который в примере выше держал человек, здесь держит инструмент.
werf.yaml продукта может быть коротким – вот реальный файл одного из приложений:
project: app1-java-react configVersion: 1 cleanup: disableGitHistoryBasedPolicy: true keepPolicies: - references: branch: /.*/ imagesPerReference: last: 2 {{- range $path, $content := .Files.Glob ".werf-partial/*.yaml" }} --- {{ tpl $content $ }} {{- end }}
Описания самих образов вынесены в .werf-partial/*.yaml и подключаются циклом – так каждый компонент (бекенд, фронтенд, dev-варианты) описан отдельным файлом, а не одной простынёй.
Центральная команда – werf converge. Одним вызовом она собирает недостающие образы, публикует их в registry и применяет релиз в кластер. То есть build, publish и deploy в одной операции:
werf converge \ --env=prod \ --namespace=app1-java-react-prod \ --atomic \ --timeout=300
Две вещи, которые делают converge удобной в скриптах. Первая – идемпотентность: повторный запуск без изменений в git ничего не пересобирает и не переразворачивает, он сверяет желаемое состояние с фактическим и доводит кластер до желаемого. Вторая – флаг --atomic: при сбое релиз откатывается целиком, без частично применённых изменений. Публикация становится операцией «всё или ничего», и это ровно то поведение, которое хочется от команды, запускаемой из CI.

Ещё один термин, который встретится дальше - giterminism. werf по умолчанию требует, чтобы состояние сборки определялось коммитом, а не тем, что лежит в рабочем дереве. Правило полезное: оно гарантирует, что из одного коммита получится один и тот же образ. В нашем демо оно ослаблено, и в разделе про компромиссы я объясню, чем мы за это платим.
«Зачем werf, если есть Helm?» Helm отвечает только за деплой и про сборку не знает ничего. Тег образа в values.yaml кто-то должен подставить – руками или другим инструментом. werf убирает этот шаг: и сборка, и рендер манифестов идут из одного запуска, поэтому имя собранного образа подставляется автоматически.
«А почему не ArgoCD?» GitOps решает другую задачу – поддержание состояния кластера в соответствии с репозиторием. Это хороший подход, и если он у вас есть, он не конфликтует с тем, что описано дальше. Но стык «собрать образ и выкатить именно его» GitOps сам по себе не закрывает: собранный образ туда всё равно кто-то должен принести.
werf убирает ручную синхронизацию тегов. Но контур вокруг него всё ещё может знать приложение – и вот как мы его развязали.
Контур kube_ci и контракт .helm/def.sh
Контур называется kube_ci и представляет собой набор bash-скриптов, которые вызывают werf с подготовленными параметрами. Внутрь его работы они не лезут: не парсят манифесты, не подставляют теги, не знают, что за приложение перед ними.
Знание о приложении живёт в одном файле – .helm/def.sh в каталоге продукта. Окружения объявлены в нём как обычные shell-функции:
#!/bin/bash # Контракт с kube_ci: env-функция (имя == ключу в productlist) экспортирует # переменные для utils/03-werf-converge.sh. CI_TAG -- единая версия из VERSION. function dev() { export APPNAME=app1-java-react export ENVNAME=dev export NAMESPACE=app1-java-react export CI_URL=app1-java-react-dev-<node-ip>.nip.io export CI_TAG=$(cat VERSION) } function prod() { export APPNAME=app1-java-react export ENVNAME=prod export NAMESPACE=app1-java-react export CI_URL=app1-java-react-prod-<node-ip>.nip.io export CI_TAG=$(cat VERSION) }
Здесь <node-ip> – адрес узла кластера, у вас он будет свой. Обязательных переменных три: APPNAME (имя приложения, оно же репозиторий образа), ENVNAME (имя окружения) и CI_URL (хост для ingress). NAMESPACE необязателен и по умолчанию равен APPNAME. Всё остальное – на усмотрение продукта.
Продукт подключается к окружению одной строкой в productlist:
PRODUCTS=( [app1-java-react]=dev [app2-python-angular]=dev )
Ключ – каталог продукта, значение – имя функции в его def.sh. Именно поэтому функции называются dev и prod: контур вызовет ту, имя которой найдёт здесь.
Теперь самое интересное – как контур забирает эти переменные. Вот фрагмент функции deploy(), которая делает всю работу:
function deploy() { source "$(~/bin/trdl use werf 2 stable)" source .helm/def.sh env=$1 && $env # вызов env-функции: dev или prod [ -z "$NAMESPACE" ] && NAMESPACE=$APPNAME export WERF_REPO="$REGISTRY/$APPNAME" KUBE_NAMESPACE=${NAMESPACE}-${ENVNAME} # Переменные CI_* из контракта пробрасываются в helm через --set (имя в нижнем # регистре). Перебор -- по именам реально экспортированных CI_*-переменных, # значение берётся косвенно -- безопасно к спецсимволам, кавычкам и пробелам. ci_set_args=() for v in ${!CI_@}; do [ "$v" = "CI_TAG" ] && continue ci_set_args+=(--set "${v,,}=${!v}") done # version-based тег образов (из def.sh через CI_TAG) custom_tag_args=() [ -n "$CI_TAG" ] && custom_tag_args=(--use-custom-tag="%image%-$CI_TAG")
Начало функции простое: подключить контракт через source и вызвать env-функцию по имени. После этого нужные переменные просто есть в окружении – разбирать текст файла не требуется.
А дальше происходит то, ради чего всё затевалось.
Конструкция ${!CI_@} возвращает имена всех экспортированных переменных, начинающихся с CI_, а ${!v} берёт значение переменной по её имени. Контур не знает и не хочет знать, какие именно CI_* объявил продукт: он перебирает то, что реально появилось в окружении, и превращает каждую в --set имя_в_нижнем_регистре=значение. Продукт, которому понадобилась своя переменная, добавляет её в def.sh – и она приезжает в чарт. Скрипты контура при этом не меняются.
CI_TAG исключён из общего перебора: он уезжает отдельным флагом --use-custom-tag, потому что это не значение для чарта, а способ тегирования образов.
Релиз всегда ставится в неймспейс <NAMESPACE>-<ENVNAME> – это видно в третьем фрагменте выше. Благодаря такому вычислению одно приложение в двух окружениях не конфликтует, даже если оба окружения смотрят в один физический кластер. У нас сейчас так и есть – об этом в компромиссах.
Всё, что относится к самому кластеру, а не к приложению, лежит отдельно – в файле k8s_defs каталога окружения:
NODE_IP=${K8S_NODE_IP:-<node-ip>} REGISTRY=${REGISTRY:-registry-${NODE_IP}.nip.io} KUBECONTEXT=${KUBECONTEXT:-<context>} KUBECONFIG=${KUBECONFIG:-~/.kube/config} kubectl config use-context "$KUBECONTEXT"
Это вторая половина развязки, и она не менее важна первой. Приложение объявляет, кто оно такое; окружение объявляет, куда ставить. Пересечение этих двух наборов и даёт конкретную выкатку. Когда мы разведём dev и prod по разным физическим кластерам, поменяются ровно две строки в k8s_defs этого окружения – ни контракты продуктов, ни скрипты контура не откроются.
Обратите внимание на подстановки вида ${REGISTRY:-...}: любое значение переопределяется переменной окружения. Это то, что делает контур пригодным для CI без единой правки – в GitLab CI адрес registry и контекст приезжают из переменных проекта, а на машине разработчика работают дефолты из файла.
Помимо CI_* контур пробрасывает в чарт ещё пару вещей – например, git-identity машины деплоя, чтобы dev-поды добавляли коммиты от нужного автора. Это тоже сделано перебором, а не хардкодом: git config user.name читается на месте, и если его нет, флаг просто не добавляется. Общий принцип один – контур передаёт то, что нашёл, и не требует, чтобы продукт что-то знал заранее.
Наконец, у продукта есть три необязательных хука: require.sh перед сборкой, predeploy.sh перед деплоем и postdeploy.sh после. Через predeploy.sh продукт может, например, положить ssh-ключ в .helm/tmp/ до того, как werf начнёт рендерить секрет; через postdeploy.sh – напечатать адреса развёрнутых ресурсов. Хуков может не быть вовсе – контур проверяет наличие файла и молча идёт дальше.

Опционально контур подхватывает .helm/values-<ENVNAME>.yaml и .helm/secrets-<ENVNAME>.yaml, а также хуки продукта: require.sh перед сборкой, predeploy.sh перед деплоем и postdeploy.sh после. Хуки – тоже часть контракта: их может не быть вовсе.
Инвариант, который из всего этого следует, я сформулирую прямо: в скриптах контура нет ни одной ветки вида «если это app1». Это не декларация, это проверяемое утверждение – код открыт, поиск по репозиторию занимает минуту.
Два продукта как доказательство
Утверждение о развязке стоит доказывать, а не заявлять. Поэтому в демо два продукта, и выбраны они максимально непохожими:
Продукт | Фронт | Бек | Хранилище |
|---|---|---|---|
| React | Spring Boot (Java) | PostgreSQL + pgAdmin |
| Angular | FastAPI (Python) | PostgreSQL + pgAdmin |
Разные языки, разные сборочные системы (maven против pip), разные фронтенд-тулчейны (vite против angular-cli). Если бы контур где-то подглядывал внутрь приложения, эти двое разошлись бы немедленно.
Вот их контракты рядом – я убрал всё, кроме различий:
# apps/app1-java-react/.helm/def.sh function dev() { export APPNAME=app1-java-react export ENVNAME=dev export NAMESPACE=app1-java-react export CI_URL=app1-java-react-dev-<node-ip>.nip.io export CI_TAG=$(cat VERSION) } # apps/app2-python-angular/.helm/def.sh function dev() { export APPNAME=app2-python-angular export ENVNAME=dev export NAMESPACE=app2-python-angular export CI_URL=app2-python-angular-dev-<node-ip>.nip.io export CI_TAG=$(cat VERSION) }
Структура одинаковая, различаются только значения. Ни слова про Java, Python, maven или npm – всё это живёт в werf.yaml и Dockerfile продукта, куда контур не заглядывает.
Подключение обоих в окружение – две строки:
PRODUCTS=( [app1-java-react]=dev [app2-python-angular]=dev )
Отсюда следует практический вывод, ради которого всё и делалось: добавление третьего продукта – это новый каталог с .helm/def.sh и одна строка в productlist. Скрипты контура при этом не открываются вообще.
Различия стеков живут ровно там, где им и место – в описании сборки. У Java-продукта это многостадийная сборка с maven и прогревом зависимостей, у Python – установка пакета и зависимостей через pip, у фронтов – npm с разными сборщиками. Всё это описано в .werf-partial/*.yaml каждого продукта и подключается в werf.yaml циклом по файлам, который я показывал выше. Контур эти файлы не читает: для него существует только команда werf converge и набор флагов к ней.
Одинаковое у продуктов тоже есть, и это не совпадение: у обоих PostgreSQL как StatefulSet, pgAdmin для доступа к базе, ConfigMap с init.sql для первичной инициализации. Так сделано намеренно – чтобы разница между продуктами была именно в стеке приложения, а не в наборе инфраструктурных объектов. Иначе доказательство развязки не стоило бы ничего: легко не знать про приложение, если приложения устроены одинаково.
Версия продукта лежит в файле VERSION рядом с исходниками, и он же служит источником истины. Скрипт scripts/set-version.sh раскатывает значение по всем местам, где версия дублируется: VERSION, pom.xml (или pyproject.toml у Python-продукта), package.json фронта, version и appVersion в Chart.yaml. В режиме bump он ещё и добавляет изменённые файлы в индекс git – но ни коммита, ни тега не создаёт, это остаётся за человеком.
Дальше версия идёт по цепочке сама: def.sh читает VERSION в CI_TAG, контур передаёт его через --use-custom-tag в тег образа, а вместе с релизом значение попадает в поле APP VERSION истории helm. Эта цепочка пригодится в следующем разделе: именно по ней потом опознают, какая версия сейчас выкачена.

Оговорюсь сразу: бизнес-логика обоих приложений демонстрационная. Они носители стека и контракта, а не пример прикладной архитектуры – смотреть в них надо на устройство доставки, а не на код формы ввода.
Разработка прямо в кластере
Это вторая половина ядра и главная вещь, которую я хотел бы, чтобы читатель забрал из статьи. Тезис такой: dev-окружение собрано из тех же кубовых объектов, что prod, и разработчик работает прямо в нём.
В dev-окружении разворачивается тот же чарт: Ingress, Service, StatefulSet с Postgres, ConfigMap, Secret, pgAdmin. Отличается только форма приложения. В prod это Deployment с собранным образом, в dev – долгоживущий под-песочница с sleep infinity, в котором лежит клон репозитория и стоят сборочные инструменты. Обе формы описаны в одном чарте и выбираются условием:
{{- if eq .Values.env "dev" }} apiVersion: apps/v1 kind: StatefulSet metadata: name: app1-java-react-backend-dev spec: serviceName: app1-java-react-backend template: spec: initContainers: - name: prepare-dev-env image: {{ index .Values.werf.image "backend-dev" }} command: ["sh", "-c", "cd /home/app/prepare-scripts && sudo -E ./init-dev-env.sh"] {{- end }}
Init-контейнер prepare-dev-env при первом старте готовит песочницу: клонирует репозиторий в /workspace, проставляет права, настраивает git и ssh. Дальше под просто живёт, пока его не пересоздадут.
Разработчик подключается к этому поду через VS Code Remote и работает так же, как работал бы локально – правка, запуск dev-сервера, проверка в браузере:
kubectl --context <ctx> -n app1-java-react-dev get pods # подключиться VS Code Remote к поду *-backend-dev или *-frontend-dev, # открыть /workspace/werf_ci_demo/apps/app1-java-react/backend cd /workspace/werf_ci_demo/apps/app1-java-react/backend ./dev-start.sh # mvn -q -DskipTests spring-boot:run
Скрипт dev-start.sh лежит рядом с исходниками каждого компонента и знает, как поднимать именно его: Spring Boot слушает 0.0.0.0:8080 с context-path /api, Angular и React стартуют через свои сборщики с той же привязкой к порту, FastAPI поднимается через uvicorn. Порт один и тот же намеренно – Service и Ingress чарта не должны знать, какой фреймворк за ними стоит.
«А почему не docker-compose?» Это главный вопрос к схеме, и ответ у меня по пунктам.
Compose моделирует контейнеры и docker-сеть. Kubernetes-слоя в этой модели нет вовсе, поэтому всё, что относится к нему, там не отлаживается: маршрутизация Ingress с префиксами /api и /pgadmin, аннотации nginx и rewrite; service discovery по DNS и targetPort; readiness- и liveness-пробы; поведение rollout и стратегия updateStrategy: OnDelete у Postgres; монтирование Secret и ConfigMap; init-контейнеры; семантика PVC и StatefulSet. Наконец, сам werf converge с его хуками – в compose его тоже нет.
Класс ошибок «локально работало, в кластере нет» появляется ровно там, где среды расходятся. Когда dev собран из тех же объектов, что prod, этот класс почти исчезает.
Второй аргумент – эксплуатационный. Compose стал бы вторым артефактом доставки рядом с чартом, и его пришлось бы поддерживать параллельно. Один артефакт означает, что dev и prod различаются только окружением в def.sh, а не двумя независимыми описаниями, которые расходятся на третьем месяце.
Третий – практический. Сборка (maven, npm, pip), кеши и базы живут на ноде кластера. Ноутбук держит только редактор, и ему не нужны ни Docker Desktop, ни правильная версия JDK, ни совпадающая версия Node. Среду задаёт dev-образ, и она одинакова у всех.
Отдельно про кеши. У каждого dev-пода есть persistent-тома: workspace с клоном репозитория и homeapp с домашним каталогом. В них переживают перезапуск пода node_modules, .m2, кеши pip и даже установленный .vscode-server. Без этого рестарт пода стоил бы получаса пересборки, и вся схема оказалась бы неудобной.
Синхронизация между рабочей машиной и подом идёт только через git. Ни scp, ни rsync: разработчик пушит с рабочей машины, в поде делает git pull.
cd /workspace/werf_ci_demo git pull
Так цикл разделяется на две зоны. Внутренняя петля – правка, отладка, тест – замыкается в поде и повторяется сколько угодно раз. Внешний контур – push, выкатка через kube_ci, прод – проходится реже и через общую инфраструктуру.

И теперь та часть, без которой раздел был бы рекламой. dev-схема стоит денег в сопровождении. Нужна вторая форма в чарте. Нужны отдельные dev-образы с тулчейном. Нужен ssh-ключ в поде, чтобы git pull работал. Команды в dev-start.sh подобраны под поведение инструментов именно в поде и отличаются от того, что написано в документации фреймворков: npm вместо corepack, бинарь из ./node_modules/.bin, uvicorn без --reload, строгий 0.0.0.0:8080, allowedHosts под ingress. Каждая такая мелочь – результат отладки, и их набирается заметное количество.
Я считаю эту цену оправданной, потому что она платится один раз на продукт, а экономит на каждом расхождении dev и prod. Но если у вашей команды один стек и одно окружение, скажу прямо: compose вам, скорее всего, хватит.
Операции: публикация, откат, снос, очистка
Всё, что описано выше, сводится к четырём командам, которые запускаются из каталога окружения. dev и prod – это два таких каталога с одинаковым набором скриптов и разными k8s_defs.
Публикация:
cd kube_ci/dev ./pull_products.sh && ./00-build-deploy.sh --all ./04-smoke.sh --all # read-only проверка после выкатки
pull_products.sh связывает каталоги продуктов из apps/ в рабочий каталог окружения, 00-build-deploy.sh проходит по productlist и вызывает deploy() для каждого продукта. Можно указать конкретный продукт вместо --all.
Стоит обратить внимание на устройство самого 00-build-deploy.sh – он занимает два десятка строк и целиком состоит из подключения контракта, перебора продуктов и вызова deploy(). Никакой логики про конкретное приложение в нём нет и появиться не может: всё, что он знает, – это список каталогов и имена env-функций.
Второй строкой идёт smoke-проверка – она не пытается угадывать, что за приложение перед ней, а проверяет, что поды в состоянии Running и что служебные сервисы отвечают. В dev этого достаточно – там приложение поднимается разработчиком вручную, и требовать от него ответа на health-check было бы неправильно. Ненулевой код возврата означает, что хотя бы у одного продукта провалена обязательная проверка, и это ровно то, что нужно CI-системе.
Откат. Это операция для чрезвычайной ситуации, и устроена она так, чтобы ей можно было воспользоваться под давлением:
./03-rollback.sh app1-java-react # без указания ревизии печатается история релиза: # REVISION UPDATED STATUS APP VERSION DESCRIPTION # 3 2026-08-05.. superseded 1.4.1 Upgrade complete # 4 2026-08-06.. deployed 1.4.2 Upgrade complete ./03-rollback.sh app1-java-react 3
Без номера ревизии скрипт печатает helm history релиза: ревизия, дата, статус и APP VERSION – это и есть версия продукта из CI_TAG. С номером – проверяет, что образы нужной версии есть в registry, печатает предупреждение про базу и выполняет helm rollback.
Возврат проходит за секунды: образ целевой ревизии уже лежит в registry, пересобирать нечего. И это важное отличие от «выкатить прошлый коммит заново» – такой путь требует восстановить рабочее дерево той версии и пройти полную сборку.
Два ограничения, о которых надо знать заранее.
Первое: helm rollback возвращает Deployment-ы, Service-ы, ConfigMap-ы и Secret-ы, но не трогает схему и данные PostgreSQL. База живёт на persistent-томе, init.sql применяется только при первой инициализации пустого тома. Если между текущей и целевой версией была forward-несовместимая миграция, откат вернёт старый код поверх новой схемы. Приведение схемы – отдельный ручной шаг, скрипт предупреждает об этом перед откатом.
Второе: история helm-релиза – единственный реестр выкаченных версий. Контур не тегирует версии в git, поэтому «какие версии уже были в этом окружении» знает только helm.
Снос и очистка:
./01-dismiss.sh app1-java-react # удалить релиз продукта из окружения ./02-purge-stages.sh # почистить локальные стадии сборки
Снос и откат намеренно требуют явного имени продукта – --all для них не работает. Без аргумента скрипт завершается с ошибкой и печатает список доступных продуктов. Это дешёвая защита от опечатки в терминале, за которую однажды скажешь спасибо. Публикация и smoke, наоборот, --all принимают: их повторный запуск безопасен.
Про очистку стоит сказать отдельно, потому что она про другое. 02-purge-stages.sh чистит локальный кеш стадий сборки на машине деплоя, а не образы в registry. Кеш стадий растёт от каждой сборки и рано или поздно съедает диск; в registry же за глубину хранения отвечают политики в werf.yaml продукта. Это разные хранилища с разным жизненным циклом, и путать их не стоит – очистка кеша не удалит ни одного образа, на который может понадобиться откатиться.

Механизм у dev и prod один и тот же. Разница – в k8s_defs окружения, где заданы контекст кластера и адрес registry, и в неймспейсе, который вычисляется из контракта.
Компромиссы и границы применимости
Демо построено на реальном preprod-кластере, и ряд решений в нём принят в пользу простоты. Ниже – список того, чем мы за это платим, и что меняется при переносе в настоящий прод.
Вот флаги, которые отвечают за большую часть компромиссов:
werf converge \ --insecure-registry=true \ --skip-tls-verify-registry=true \ --loose-giterminism=true \ --atomic=true \ --timeout=300
Registry без TLS. --insecure-registry и --skip-tls-verify-registry позволяют работать с registry, у которого нет валидного сертификата. В dev-окружении это экономит время, в проде – нормальный сертификат и аутентификация, оба флага убираются.
Ослабленный giterminism. --loose-giterminism снимает требование werf собирать строго из коммита. Это удобно, когда правка проверяется прямо на сервере деплоя, но ровно на эту величину теряется гарантия воспроизводимости: из одного коммита в разное время может получиться разный образ. В проде флаг убирается первым.
Очистка registry. В werf.yaml отключена политика очистки по git-истории, вместо неё хранятся последние два образа на ветку. Registry всё равно растёт, и его приходится чистить осознанно.
Хосты nip.io вместо DNS и TLS. CI_URL вида app1-java-react-dev-<node-ip>.nip.io даёт рабочий хост без записи в DNS. Для дев-окружения это идеально, для прода – неприемлемо: нужен реальный домен и сертификат.
Ключ шифрования werf. Секреты в чарте зашифрованы, ключ лежит вне репозитория и раздаётся вручную. Работает, но ручное распространение ключа – слабое место схемы; промышленный вариант – внешнее хранилище секретов.
ssh-ключ в поде. Чтобы git pull работал внутри dev-пода, приватный ключ монтируется в него Secret’ом. Это осознанная дыра ради удобства разработки, и в prod-форме её нет.
Postgres с updateStrategy: OnDelete. Под базы не перезапускается автоматически при изменении StatefulSet – обновление руками. Это защита от неожиданного рестарта базы в момент, когда его никто не ждёт.
Один кластер на два окружения. Сейчас dev и prod смотрят в один физический кластер и различаются неймспейсом. Когда появятся отдельные кластеры, поменяются KUBECONTEXT и REGISTRY в k8s_defs окружения – и больше ничего. Это, кстати, неплохая проверка развязки: если бы окружение было зашито в скрипты, переезд стоил бы дороже.

И границы применимости, которые я бы обозначил прямо. Если у вас уже работает GitOps и он вас устраивает – описанный контур решает другую задачу и вряд ли нужен целиком. Если вам нужен hot inner-loop с мгновенным hot-reload и вы готовы мириться с расхождением сред – специализированные инструменты дадут более короткий цикл. Если у вас один продукт и один стек – вся история про развязку контура и приложения превращается в решение несуществующей проблемы.
Что в итоге
Контур получился такой: один артефакт доставки на все окружения, воспроизводимость от git-состояния, откат за секунды через историю helm-релиза. Подключение нового продукта – это .helm/def.sh на десять строк и строка в productlist; скрипты контура при этом не открываются.
Но главное, что я бы забрал из этого опыта, к werf не привязано - работает не конкретный инструмент, а идея контракта: контур доставки не должен знать приложение. Как только знание переезжает из скриптов в контракт, контур перестаёт прирастать к проекту – и следующий продукт подключается за десять минут, а не за неделю.
Код открыт, можно посмотреть и разобрать:
репозиторий werf_ci_demo – контур, два продукта, документация;
Если у вас было так же – расскажите в комментариях, чем закончилось у вас. Интересны случаи, когда от подобной схемы отказались: причины отказа порою полезнее случаев удачного применения.

