fix(ci): докер-секции прод-деплоев исключают друг друга через host-lock (#2950)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 17m17s
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 17m17s
Поправка к #2952. Там я свёл обе группы concurrency к одной и написал, что это
сериализует деплои. Проверил сразу после мержа — не сериализует.
Коммит 2a01dea1 трогает оба деплой-workflow, поэтому запустил обе цепочки. При
работающей общей группе вторая не стартовала бы вовсе. На раннере в этот момент:
TASK-21431_WORKFLOW-Deploy-Trade-In_JOB-build-frontend
TASK-21430_WORKFLOW-Deploy-Trade-In_JOB-test
TASK-21428_WORKFLOW-Deploy_JOB-build-frontend
TASK-21427_WORKFLOW-Deploy_JOB-build-worker
TASK-21426_WORKFLOW-Deploy_JOB-build-backend
Обе идут бок о бок. Forgejo 10.0.3 (gitea-1.22), runner v6.3.1 — workflow-level
concurrency здесь не исполняется. Значит и прежние deploy-prod /
deploy-tradein-prod никогда ничего не делали: причиной гонки было не различие
имён групп, а отсутствие взаимного исключения как такового.
Отдельно — flock я в #2952 отверг по неверному основанию. Я написал, что при
аварийной смерти job'а лок залипает. Перепроверил на настоящем сценарии (обрыв
ssh-сессии, а не kill -9 родителя в отрыве): лок держит живой потомок скрипта —
/run/lock/gendesign-docker-deploy.lock:
gendesign 1968950 F.... bash
gendesign 1968953 F.... sleep
но ведь и докер-команды после обрыва сессии продолжают работать на хосте, так
что отпускать лок в этот момент как раз НЕЛЬЗЯ. Это не дефект, а нужная
семантика: исключение действует ровно пока жив тот, кто мутирует докер.
Правка: обе докер-секции берут общий лок на хосте перед работой. Ожидание
ограничено 900с и падает с сообщением, где написано, чем посмотреть держателя.
Второй ssh-шаг (перезагрузка прокси) лок не берёт — он образов не тянет, пруну
там нечего портить.
Секция concurrency оставлена: заработает при обновлении Forgejo. Но гейт теперь
не выдаёт её за действующий механизм — проверки разделены на обязательные (лок)
и декларативные (группа), и в шапке написано, почему.
Гейт мутационно проверен: убрать flock → 2 failed, развести пути локов →
2 failed, убрать сообщение о таймауте → 1 failed, развести группы → 1 failed,
контроль → 7 passed rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2a01dea103
commit
34fbac205e
3 changed files with 129 additions and 32 deletions
|
|
@ -635,6 +635,31 @@ jobs:
|
|||
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# #2950: взаимное исключение докер-секции двух прод-деплоев.
|
||||
# Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон —
|
||||
# стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM.
|
||||
# Каждый в конце делает `docker image prune -af`, и прун одного сносит
|
||||
# leases ещё не доехавшего `compose pull` другого:
|
||||
# unable to lease content: lease does not exist: not found
|
||||
# 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через
|
||||
# 0.4с после обрыва пула), прод остался на старом коде.
|
||||
#
|
||||
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
|
||||
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
|
||||
# concurrency не исполняется — проверено, обе цепочки стартовали на
|
||||
# одном коммите одновременно. Она оставлена как декларация, которая
|
||||
# заработает после обновления Forgejo; сегодня работает вот этот лок.
|
||||
#
|
||||
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
|
||||
# докер-команды на хосте продолжат работу — и лок продолжит их
|
||||
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
|
||||
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
|
||||
exec 9>/var/lock/gendesign-docker-deploy.lock
|
||||
if ! flock -w 900 9; then
|
||||
echo "ERROR: не дождался лока докер-деплоя за 900с."
|
||||
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
|
||||
exit 1
|
||||
fi
|
||||
cd /opt/gendesign
|
||||
|
||||
# repo уже clone'ен — origin = Forgejo. Подтягиваем последний main.
|
||||
|
|
|
|||
|
|
@ -481,6 +481,31 @@ jobs:
|
|||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# #2950: взаимное исключение докер-секции двух прод-деплоев.
|
||||
# Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон —
|
||||
# стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM.
|
||||
# Каждый в конце делает `docker image prune -af`, и прун одного сносит
|
||||
# leases ещё не доехавшего `compose pull` другого:
|
||||
# unable to lease content: lease does not exist: not found
|
||||
# 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через
|
||||
# 0.4с после обрыва пула), прод остался на старом коде.
|
||||
#
|
||||
# Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ
|
||||
# обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level
|
||||
# concurrency не исполняется — проверено, обе цепочки стартовали на
|
||||
# одном коммите одновременно. Она оставлена как декларация, которая
|
||||
# заработает после обновления Forgejo; сегодня работает вот этот лок.
|
||||
#
|
||||
# Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся,
|
||||
# докер-команды на хосте продолжат работу — и лок продолжит их
|
||||
# прикрывать, что и требуется. Ожидание ограничено: не дождались за
|
||||
# 900с — падаем с внятным сообщением, а не молча ждём вечно.
|
||||
exec 9>/var/lock/gendesign-docker-deploy.lock
|
||||
if ! flock -w 900 9; then
|
||||
echo "ERROR: не дождался лока докер-деплоя за 900с."
|
||||
echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock"
|
||||
exit 1
|
||||
fi
|
||||
cd /opt/gendesign
|
||||
|
||||
# Sync compose / Caddyfile / init scripts from the repo.
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
"""Гейт: оба прод-деплоя обязаны лежать в ОДНОЙ группе concurrency.
|
||||
"""Гейт: докер-секции обоих прод-деплоев исключают друг друга (#2950).
|
||||
|
||||
Почему это тест, а не комментарий в yml (#2950). Деплой ПТИЦЫ и деплой Trade-In
|
||||
ходят по SSH в один и тот же докер-демон — стеки gendesign-* и tradein-* плюс сам
|
||||
forgejo-runner живут на одной VM. Каждый в конце делает `docker image prune -af`.
|
||||
Пока группы разные, Forgejo запускает их параллельно, и прун одного сносит leases
|
||||
Что произошло. Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в один и тот же
|
||||
докер-демон — стеки gendesign-*, tradein-* и сам forgejo-runner живут на одной
|
||||
VM. Каждый в конце делает `docker image prune -af`, и прун одного сносит leases
|
||||
ещё не доехавшего `compose pull` другого:
|
||||
|
||||
unable to lease content: lease does not exist: not found
|
||||
|
||||
20.08 так и вышло: run 8083 упал за 5 секунд, прод остался на старом коде. Разводка
|
||||
групп обратно ничего не сломает в CI и не даст ни одного сигнала — отказ проявится
|
||||
только в следующем совпадении окон двух мержей. Поэтому инвариант зафиксирован здесь.
|
||||
20.08 деплой ПТИЦЫ упал за 5 секунд, прод остался на старом коде.
|
||||
|
||||
`cancel-in-progress: false` частью инварианта тоже является: прод-деплои обязаны
|
||||
достаиваться в очереди, а не отменять друг друга — отменённый деплой оставляет прод
|
||||
на старом коде ровно так же, как упавший.
|
||||
ЧЕМ ЭТО ЧИНИТСЯ СЕГОДНЯ — host-lock, а НЕ секцией `concurrency`. На Forgejo
|
||||
10.0.3 (gitea-1.22) workflow-level concurrency не исполняется: после мержа
|
||||
правки, сводившей обе группы к `deploy-prod`, обе цепочки стартовали на одном
|
||||
коммите ОДНОВРЕМЕННО (`docker ps` на раннере показал их build-джобы бок о бок).
|
||||
Секция оставлена как декларация на будущее — она заработает при обновлении
|
||||
Forgejo, — но выдавать её за действующий механизм нельзя, поэтому проверки
|
||||
разделены: про лок — обязательные, про группу — про декларацию.
|
||||
|
||||
Разводка групп обратно или пропажа `flock` не ломают ни один прогон CI и не
|
||||
дают ни одного сигнала: отказ проявится только в следующем совпадении окон двух
|
||||
мержей. Поэтому инвариант зафиксирован здесь.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
|
@ -28,35 +33,77 @@ WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows"
|
|||
|
||||
PROD_DEPLOYS = ("deploy.yml", "deploy-tradein.yml")
|
||||
|
||||
# Один и тот же путь у обоих — иначе взаимного исключения не выйдет.
|
||||
LOCK_PATH = "/var/lock/gendesign-docker-deploy.lock"
|
||||
|
||||
def _concurrency(name: str) -> dict:
|
||||
|
||||
def _text(name: str) -> str:
|
||||
path = WORKFLOWS / name
|
||||
assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп"
|
||||
return yaml.safe_load(path.read_text()).get("concurrency") or {}
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def _deploy_script(name: str) -> str:
|
||||
"""Тело ssh-шага, который реально мутирует докер (в нём есть агрессивный прун).
|
||||
|
||||
Именно этот блок обязан брать лок. Второй ssh-шаг (перезагрузка прокси)
|
||||
образов не тянет, пруну там нечего портить — от него лок не требуется.
|
||||
"""
|
||||
text = _text(name)
|
||||
marker = "docker image prune -af"
|
||||
assert marker in text, f"{name}: пропал `{marker}` — гейт опирается на него, перепроверить"
|
||||
return text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||||
def test_prod_deploy_declares_concurrency(name: str) -> None:
|
||||
"""У каждого прод-деплоя concurrency вообще объявлена."""
|
||||
assert _concurrency(name).get(
|
||||
"group"
|
||||
), f"{name}: пропала секция concurrency — деплои снова могут пойти параллельно"
|
||||
|
||||
|
||||
def test_both_prod_deploys_share_one_group() -> None:
|
||||
"""Группа у обоих одна и та же — иначе прун одного убьёт pull другого."""
|
||||
groups = {name: _concurrency(name).get("group") for name in PROD_DEPLOYS}
|
||||
assert len(set(groups.values())) == 1, (
|
||||
f"прод-деплои разведены по разным группам concurrency: {groups}. "
|
||||
"Они ходят в ОДИН докер-демон; `docker image prune -af` одного обрывает "
|
||||
"`compose pull` другого (#2950)."
|
||||
def test_prod_deploy_takes_the_host_lock(name: str) -> None:
|
||||
"""Деплой берёт лок на хосте перед докер-секцией."""
|
||||
script = _deploy_script(name)
|
||||
assert f"exec 9>{LOCK_PATH}" in script, (
|
||||
f"{name}: не берёт host-lock {LOCK_PATH}. Без него `docker image prune -af` "
|
||||
"соседнего деплоя оборвёт наш `compose pull` (#2950)."
|
||||
)
|
||||
assert "flock -w" in script, (
|
||||
f"{name}: лок открывается, но не захватывается через `flock -w` — "
|
||||
"открытый fd сам по себе ничего не исключает"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||||
def test_prod_deploy_queues_instead_of_cancelling(name: str) -> None:
|
||||
"""Раны достаиваются в очереди: отменённый деплой = прод на старом коде."""
|
||||
assert _concurrency(name).get("cancel-in-progress") is False, (
|
||||
f"{name}: cancel-in-progress должен быть false — иначе более новый мерж "
|
||||
"отменит выкатку предыдущего и та молча не доедет"
|
||||
def test_lock_wait_is_bounded_and_loud(name: str) -> None:
|
||||
"""Ожидание ограничено и провал слышен: молча ждать вечно — не вариант."""
|
||||
script = _deploy_script(name)
|
||||
assert "не дождался лока докер-деплоя" in script, (
|
||||
f"{name}: нет внятного сообщения на исчерпание ожидания лока — "
|
||||
"деплой упадёт с голым кодом возврата flock"
|
||||
)
|
||||
|
||||
|
||||
def test_both_prod_deploys_use_the_same_lock_path() -> None:
|
||||
"""Путь лока общий. Разные пути = два независимых лока = нет исключения."""
|
||||
holders = {name: f"exec 9>{LOCK_PATH}" in _deploy_script(name) for name in PROD_DEPLOYS}
|
||||
assert all(holders.values()), (
|
||||
f"лок берут не все прод-деплои: {holders}. Взаимное исключение работает, "
|
||||
"только когда ОБА ходят через один и тот же файл."
|
||||
)
|
||||
|
||||
|
||||
# ── Ниже — про секцию concurrency. Сегодня она НЕ исполняется (см. шапку), ────
|
||||
# поэтому это проверки декларации, а не работающего механизма.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PROD_DEPLOYS)
|
||||
def test_prod_deploy_declares_shared_concurrency_group(name: str) -> None:
|
||||
"""Группа объявлена и общая — заработает при обновлении Forgejo.
|
||||
|
||||
Отдельно от лока: если однажды эта секция начнёт исполняться, разведённые
|
||||
группы снова разрешат параллельный запуск.
|
||||
"""
|
||||
conc = yaml.safe_load(_text(name)).get("concurrency") or {}
|
||||
assert (
|
||||
conc.get("group") == "deploy-prod"
|
||||
), f"{name}: группа concurrency = {conc.get('group')!r}, ожидалась общая 'deploy-prod'"
|
||||
assert conc.get("cancel-in-progress") is False, (
|
||||
f"{name}: cancel-in-progress должен быть false — отменённый деплой оставляет "
|
||||
"прод на старом коде ровно так же, как упавший"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue