Деплой Trade-In своим prune -af убивает compose pull деплоя ПТИЦЫ (разные группы concurrency, один докер-демон) #2950

Closed
opened 2026-08-20 07:13:53 +00:00 by bot-backend · 4 comments
Collaborator

Симптом

20.08 деплой ПТИЦЫ (run 8083, коммит 6bca4f7e, PR #2947) упал за 5 секунд:

unable to lease content: lease does not exist: not found

Все 7 образов ушли в Interrupted через 0.8 с после старта пула. Билды при этом были зелёные — упал именно шаг deploy.

Причина — по секундам

время (UTC) run что происходит
07:05:05.2 8083 (ПТИЦА) docker compose pull — 7 образов Pulling
07:05:06.0 8083 (ПТИЦА) все 7 Interrupted, unable to lease content
07:05:06.4 8084 (Trade-In) Deleted Images: — отработал docker image prune -af

Прун печатает удаление через 0.4 с после того, как пул оборвался. docker image prune -af сносит всё неиспользуемое, включая свежесозданный (ещё не затегированный) контент начатого пула — вместе с его lease.

Корень

Два workflow ходят по SSH в один и тот же докер-демон, но взаимно не исключаются:

  • .forgejo/workflows/deploy.yml:38-40concurrency: group: deploy-prod
  • .forgejo/workflows/deploy-tradein.ymlconcurrency: group: deploy-tradein-prod

Оба прунят: deploy.yml:745-748 (image prune -af + builder prune -af), deploy-tradein.yml:1104-1106 (rmi + image prune -af).

Группы разные → Forgejo их параллелит → prune одного наезжает на pull другого. cancel-in-progress: false тут не помогает: он сериализует ранов внутри группы, а гонка между группами.

Почему это не разовая неудача

Срабатывает каждый раз, когда мерж в tradein-mvp/** и мерж в backend/** попадают в одно окно ~5 минут. В этот раз три мержа подряд (#2946, #2947, #2948) — и деплой ПТИЦЫ не доехал вообще: прод остался на старом коде, хотя голова main показывала success (зелёным был Trade-In-деплой головы, а не ПТИЦА).

Побочный вывод для проверок

Статус головы main = success не означает, что задеплоен код из головы. Когда последний коммит трогает только tradein-mvp/**, у него запускается только Deploy Trade-In, а деплой ПТИЦЫ висит на своём (более раннем) коммите со своим исходом. Проверять деплой надо по коду в контейнере, а не по цвету головы.

Предлагаемая правка

Взаимное исключение на хосте вокруг докер-мутирующей секции обоих деплоев (flock на общий лок-файл с таймаутом), чтобы не сериализовать ещё и билды — они на раннере и друг другу не мешают.

Альтернатива в одну строку: свести обе группы concurrency к одному имени. Дешевле в правке, но сериализует и билды (~+6 мин ожидания чужого деплоя).

Критерий приёмки

После правки: мерж в backend/** и мерж в tradein-mvp/** с интервалом < 1 мин → оба деплоя завершаются success, ни одного unable to lease content в логах.

## Симптом 20.08 деплой ПТИЦЫ (run 8083, коммит `6bca4f7e`, PR #2947) упал за 5 секунд: ``` unable to lease content: lease does not exist: not found ``` Все 7 образов ушли в `Interrupted` через 0.8 с после старта пула. Билды при этом были зелёные — упал именно шаг `deploy`. ## Причина — по секундам | время (UTC) | run | что происходит | |---|---|---| | 07:05:05.2 | 8083 (ПТИЦА) | `docker compose pull` — 7 образов `Pulling` | | 07:05:06.0 | 8083 (ПТИЦА) | все 7 `Interrupted`, `unable to lease content` | | 07:05:06.4 | 8084 (Trade-In) | `Deleted Images:` — отработал `docker image prune -af` | Прун печатает удаление через 0.4 с после того, как пул оборвался. `docker image prune -af` сносит всё неиспользуемое, включая свежесозданный (ещё не затегированный) контент начатого пула — вместе с его lease. ## Корень Два workflow ходят по SSH в **один и тот же** докер-демон, но взаимно не исключаются: - `.forgejo/workflows/deploy.yml:38-40` → `concurrency: group: deploy-prod` - `.forgejo/workflows/deploy-tradein.yml` → `concurrency: group: deploy-tradein-prod` Оба прунят: `deploy.yml:745-748` (`image prune -af` + `builder prune -af`), `deploy-tradein.yml:1104-1106` (`rmi` + `image prune -af`). Группы разные → Forgejo их параллелит → prune одного наезжает на pull другого. `cancel-in-progress: false` тут не помогает: он сериализует ранов **внутри** группы, а гонка между группами. ## Почему это не разовая неудача Срабатывает каждый раз, когда мерж в `tradein-mvp/**` и мерж в `backend/**` попадают в одно окно ~5 минут. В этот раз три мержа подряд (#2946, #2947, #2948) — и деплой ПТИЦЫ не доехал вообще: прод остался на старом коде, хотя голова main показывала `success` (зелёным был Trade-In-деплой головы, а не ПТИЦА). ## Побочный вывод для проверок Статус головы `main` = `success` **не означает**, что задеплоен код из головы. Когда последний коммит трогает только `tradein-mvp/**`, у него запускается только `Deploy Trade-In`, а деплой ПТИЦЫ висит на своём (более раннем) коммите со своим исходом. Проверять деплой надо по коду в контейнере, а не по цвету головы. ## Предлагаемая правка Взаимное исключение на хосте вокруг докер-мутирующей секции обоих деплоев (`flock` на общий лок-файл с таймаутом), чтобы не сериализовать ещё и билды — они на раннере и друг другу не мешают. Альтернатива в одну строку: свести обе группы concurrency к одному имени. Дешевле в правке, но сериализует и билды (~+6 мин ожидания чужого деплоя). ## Критерий приёмки После правки: мерж в `backend/**` и мерж в `tradein-mvp/**` с интервалом < 1 мин → оба деплоя завершаются `success`, ни одного `unable to lease content` в логах.
Author
Collaborator

Поправка: общая группа concurrency ничего не сериализует на этом Forgejo

Проверил сразу после мержа #2952 — и наблюдение опровергает то, что я написал в PR.

Коммит 2a01dea1 трогает оба деплой-workflow, поэтому запустил обе цепочки. При работающей общей группе вторая не стартовала бы вовсе. Что на самом деле:

FORGEJO-ACTIONS-TASK-21431_WORKFLOW-Deploy-Trade-In_JOB-build-frontend
FORGEJO-ACTIONS-TASK-21430_WORKFLOW-Deploy-Trade-In_JOB-test
FORGEJO-ACTIONS-TASK-21428_WORKFLOW-Deploy_JOB-build-frontend
FORGEJO-ACTIONS-TASK-21427_WORKFLOW-Deploy_JOB-build-worker
FORGEJO-ACTIONS-TASK-21426_WORKFLOW-Deploy_JOB-build-backend

Обе идут одновременно. Версия: Forgejo 10.0.3+gitea-1.22.0, runner v6.3.1 — workflow-level concurrency здесь не исполняется.

Отсюда два следствия, оба неприятные:

  1. Правка #2952 гонку не устраняет — это украшение. Гейт, который я к ней приложил, стережёт инвариант, у которого сегодня нет эффекта.
  2. Прежние deploy-prod / deploy-tradein-prod тоже никогда ничего не делали, как и cancel-in-progress: false. Разные имена групп не были причиной гонки — причиной было отсутствие какого-либо взаимного исключения вообще.

Поправка вторая: flock я отверг по неверному основанию

В PR я написал, что host-lock не годится, потому что «при аварийной смерти job'а лок залипает». Перепроверил на реальном сценарии — обрыв ssh-сессии, а не kill -9 родителя в отрыве:

/run/lock/gendesign-docker-deploy.lock:
   gendesign  1968950 F.... bash
   gendesign  1968953 F.... sleep

Лок держит живой потомок деплой-скрипта. Но ведь и докер-команды после обрыва сессии продолжают работать на хосте — отпустить лок в этот момент было бы как раз неправильно. То есть это не дефект, а нужная семантика: взаимное исключение действует ровно пока жив тот, кто мутирует докер. «Залипание» требует по-настоящему зависшего процесса, а ожидание ограничено flock -w с внятным сообщением.

Мой вывод «flock не годится» был построен на тесте, чьё поведение я истолковал неверно.

Что делаю

Оставляю общую группу (станет действующей при обновлении Forgejo до версии с поддержкой concurrency), но перестаю выдавать её за работающий механизм — и добавляю host-lock, который на этом Forgejo действительно исключает одновременность. Гейт переписываю так, чтобы он стерёг лок, а группу описывал честно — как декларацию на будущее.

## Поправка: общая группа concurrency ничего не сериализует на этом Forgejo Проверил сразу после мержа #2952 — и наблюдение опровергает то, что я написал в PR. Коммит `2a01dea1` трогает **оба** деплой-workflow, поэтому запустил обе цепочки. При работающей общей группе вторая не стартовала бы вовсе. Что на самом деле: ``` FORGEJO-ACTIONS-TASK-21431_WORKFLOW-Deploy-Trade-In_JOB-build-frontend FORGEJO-ACTIONS-TASK-21430_WORKFLOW-Deploy-Trade-In_JOB-test FORGEJO-ACTIONS-TASK-21428_WORKFLOW-Deploy_JOB-build-frontend FORGEJO-ACTIONS-TASK-21427_WORKFLOW-Deploy_JOB-build-worker FORGEJO-ACTIONS-TASK-21426_WORKFLOW-Deploy_JOB-build-backend ``` Обе идут одновременно. Версия: Forgejo `10.0.3+gitea-1.22.0`, runner `v6.3.1` — workflow-level `concurrency` здесь не исполняется. Отсюда два следствия, оба неприятные: 1. **Правка #2952 гонку не устраняет** — это украшение. Гейт, который я к ней приложил, стережёт инвариант, у которого сегодня нет эффекта. 2. **Прежние `deploy-prod` / `deploy-tradein-prod` тоже никогда ничего не делали**, как и `cancel-in-progress: false`. Разные имена групп не были причиной гонки — причиной было отсутствие какого-либо взаимного исключения вообще. ## Поправка вторая: flock я отверг по неверному основанию В PR я написал, что host-lock не годится, потому что «при аварийной смерти job'а лок залипает». Перепроверил на реальном сценарии — обрыв ssh-сессии, а не `kill -9` родителя в отрыве: ``` /run/lock/gendesign-docker-deploy.lock: gendesign 1968950 F.... bash gendesign 1968953 F.... sleep ``` Лок держит **живой потомок** деплой-скрипта. Но ведь и докер-команды после обрыва сессии продолжают работать на хосте — отпустить лок в этот момент было бы как раз неправильно. То есть это не дефект, а нужная семантика: взаимное исключение действует ровно пока жив тот, кто мутирует докер. «Залипание» требует по-настоящему зависшего процесса, а ожидание ограничено `flock -w` с внятным сообщением. Мой вывод «flock не годится» был построен на тесте, чьё поведение я истолковал неверно. ## Что делаю Оставляю общую группу (станет действующей при обновлении Forgejo до версии с поддержкой `concurrency`), но перестаю выдавать её за работающий механизм — и добавляю host-lock, который на этом Forgejo действительно исключает одновременность. Гейт переписываю так, чтобы он стерёг лок, а группу описывал честно — как декларацию на будущее.
Author
Collaborator

Оставшаяся дыра — не гонка прунов, а отмена pending-deploy'я + :latest. Сегодня закрылась удачей в 70 секунд

Хронология 21.08 из статусов run'ов (UTC):

время run событие
10:27:04 8326 (ccf84b4a, #3018, backend+frontend) старт
10:30:10 8326 build-backend ✓
10:34:08 / 10:34:14 8326 build-worker ✓ / build-frontend ✓ — :latest всех трёх = ccf84b4a
10:35:09 push 95db3f44 (#3019, только ops/ и deploy-tradein.yml)
10:35:12 8329 (95db3f44) старт; changes → билды skipped
10:35:13 8326 deploy — «Has been cancelled» (через 4 с после старта 8329; cancel-in-progress: false объявлен, на pending-job не действует)
10:35:26–10:37:05 8329 deploy ✓ — накатил :latest как есть

Прод проверял по коду в контейнерах: backend/worker несут маркер #2867, фронт-чанк тоже — то есть на новом коде. Но только потому, что образы ccf84b4a легли в registry за 70 с до pull'а. Поменяй порядок — и голова main зелёная, а прод на старом образе, без единого сигнала. Host-lock из #2955 этого не ловит: он про одновременные докер-секции, а здесь одна секция вовсе не стартовала.

Что предлагаю (PR #3023)

Не бороться с отменой (это поведение Forgejo), а сделать деплой неспособным молча накатить отставший :latest:

  • образы получают метку org.opencontainers.image.revision=<sha> (build-push-action, все 12 шагов обоих workflow);
  • deploy-job перед SSH гоняет scripts/check-latest-image-revision.sh: ревизия :latest в registry обязана содержать последний коммит по путям компонента (те же фильтры changes, что запускают сборку); пока не содержит — ждёт билд предшественника (до 15 мин), затем ::error и красный деплой. Ревизия новее — ок (workflow_dispatch собирает голову).

Тест — подменный docker + настоящий временный git, коды выхода по значению; мутация «всегда 0» краснит 5 из 7.

Критерий приёмки остаётся тот же, что в постановке

Два мержа подряд (код + ops-only, < 1 мин): второй run ждёт билд первого и проходит, прод на коде головы по каждому компоненту. Если билд первого упал — второй падает с понятным ::error, а не уезжает зелёным на старом образе. Проверю на первых же парных мержах после #3023 и отпишусь с датой.

## Оставшаяся дыра — не гонка прунов, а отмена pending-deploy'я + `:latest`. Сегодня закрылась удачей в 70 секунд Хронология 21.08 из статусов run'ов (UTC): | время | run | событие | |---|---|---| | 10:27:04 | 8326 (`ccf84b4a`, #3018, backend+frontend) | старт | | 10:30:10 | 8326 | build-backend ✓ | | 10:34:08 / 10:34:14 | 8326 | build-worker ✓ / build-frontend ✓ — `:latest` всех трёх = `ccf84b4a` | | 10:35:09 | — | push `95db3f44` (#3019, только `ops/` и `deploy-tradein.yml`) | | 10:35:12 | 8329 (`95db3f44`) | старт; `changes` → билды **skipped** | | **10:35:13** | **8326** | **`deploy` — «Has been cancelled»** (через 4 с после старта 8329; `cancel-in-progress: false` объявлен, на pending-job не действует) | | 10:35:26–10:37:05 | 8329 | `deploy` ✓ — накатил `:latest` как есть | Прод проверял по коду в контейнерах: backend/worker несут маркер #2867, фронт-чанк тоже — то есть на новом коде. **Но только потому, что образы `ccf84b4a` легли в registry за 70 с до pull'а.** Поменяй порядок — и голова main зелёная, а прод на старом образе, без единого сигнала. Host-lock из #2955 этого не ловит: он про одновременные докер-секции, а здесь одна секция вовсе не стартовала. ### Что предлагаю (PR #3023) Не бороться с отменой (это поведение Forgejo), а сделать деплой **неспособным молча накатить отставший `:latest`**: - образы получают метку `org.opencontainers.image.revision=<sha>` (build-push-action, все 12 шагов обоих workflow); - `deploy`-job перед SSH гоняет `scripts/check-latest-image-revision.sh`: ревизия `:latest` в registry обязана **содержать** последний коммит по путям компонента (те же фильтры `changes`, что запускают сборку); пока не содержит — ждёт билд предшественника (до 15 мин), затем `::error` и красный деплой. Ревизия новее — ок (`workflow_dispatch` собирает голову). Тест — подменный `docker` + настоящий временный git, коды выхода по значению; мутация «всегда 0» краснит 5 из 7. ### Критерий приёмки остаётся тот же, что в постановке Два мержа подряд (код + ops-only, < 1 мин): второй run ждёт билд первого и проходит, прод на коде головы по каждому компоненту. Если билд первого упал — второй падает с понятным `::error`, а не уезжает зелёным на старом образе. Проверю на первых же парных мержах после #3023 и отпишусь с датой.
Author
Collaborator

Гард #3023 на проде: три run'а подряд, включая сценарий «второй run, билды пропущены»

run билды гард (из лога шага Гард свежести :latest)
f2945b71 (сам #3023, 12:20/12:24) все 6 образов собраны с меткой 6 × «✓ ревизия f2945b71 содержит последний коммит по компоненту f2945b71»
4ae14055 (#3024, frontend-only, 12:37) backend и browser — skipped, frontend собран backend :latest → «ревизия f2945b71 содержит последний коммит по компоненту f2945b71» ✓; frontend → 4ae14055 ✓; browser → f2945b71
e31c5372 (#3011, 13:00) все собраны backend/browser → «ревизия e31c5372 содержит … 4cb8f32d» ✓, frontend ✓

То есть ровно тот случай, из-за которого задача осталась открытой — run с пропущенными билдами — теперь проверяется против registry по меткам, а не катится вслепую. Формат imagetools inspect --format '{{json .Image}}' на наших образах разобрался без правок, ожидания («жду билд предшественника») ни разу не понадобилось.

Что ещё не проверено

Критерий приёмки из постановки — два мержа с интервалом < 1 мин (код + ops-only): второй run должен дождаться билда первого. Сегодняшние пары шли с интервалом 8–15 мин, ожидание не срабатывало. Ловлю на первой реальной паре и отпишусь с датой; сам такую пару не создаю — это гонка на боевом деплое ради демонстрации.

## Гард #3023 на проде: три run'а подряд, включая сценарий «второй run, билды пропущены» | run | билды | гард (из лога шага `Гард свежести :latest`) | |---|---|---| | `f2945b71` (сам #3023, 12:20/12:24) | все 6 образов собраны с меткой | 6 × «✓ ревизия f2945b71 содержит последний коммит по компоненту f2945b71» | | **`4ae14055` (#3024, frontend-only, 12:37)** | **backend и browser — skipped**, frontend собран | backend `:latest` → «ревизия f2945b71 содержит последний коммит по компоненту f2945b71» ✓; frontend → `4ae14055` ✓; browser → `f2945b71` ✓ | | `e31c5372` (#3011, 13:00) | все собраны | backend/browser → «ревизия e31c5372 содержит … 4cb8f32d» ✓, frontend ✓ | То есть ровно тот случай, из-за которого задача осталась открытой — run с пропущенными билдами — теперь **проверяется против registry по меткам**, а не катится вслепую. Формат `imagetools inspect --format '{{json .Image}}'` на наших образах разобрался без правок, ожидания («жду билд предшественника») ни разу не понадобилось. ### Что ещё не проверено Критерий приёмки из постановки — **два мержа с интервалом < 1 мин** (код + ops-only): второй run должен дождаться билда первого. Сегодняшние пары шли с интервалом 8–15 мин, ожидание не срабатывало. Ловлю на первой реальной паре и отпишусь с датой; сам такую пару не создаю — это гонка на боевом деплое ради демонстрации.
Owner

Проверено в коде на forgejo/main — сделано, закрываю.

PR #2952 (merged 2026-08-20) — прод-деплои сведены в одну группу concurrency deploy-prod (deploy.yml:107, deploy-tradein.yml:54), прун одного больше не убивает pull другого.

Проверено в коде на forgejo/main — сделано, закрываю. PR #2952 (merged 2026-08-20) — прод-деплои сведены в одну группу concurrency `deploy-prod` (deploy.yml:107, deploy-tradein.yml:54), прун одного больше не убивает pull другого.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2950
No description provided.