Commit 8b41273c by luoqi

fix(deploy): force-recreate 失败才回退 stop —— 只在 rootless 上多做一步

rootless podman 下 `up -d --force-recreate` 要删**运行中**的容器,而 rootless 杀不掉
它的网络进程,直接失败:
    rootless netns: kill network process: permission denied
失败还留下 <hash>_<proj>-<svc>-1 半成品容器,不清则重建报名字冲突。

改为:主路径不变(直接 force-recreate),**仅在它失败时**回退 ——
清残留 → stop → 重建。docker 侧一行路径都没变,podman 侧多走一次回退。

【停机实测,顺带纠正我自己两次错判】2026-08-04 三组对照(0.5s 间隔探 /health):
    docker + 直接 force-recreate   ~16s
    docker + 先 stop 再 recreate   15.97s
    podman + 先 stop 再 recreate    5.44s
即 **部署本来就有约 16 秒停机,一直如此** —— force-recreate 不是滚动更新,
它就是停旧起新。

️ 第一轮曾测出 docker「0 停机(535 样本全 200)」,那是**测量错误**:
探测有 300s 上限,而那次是全量构建、耗时更长,容器重建发生在探测结束之后
(实测 probe 比 deploy 早结束 29s),535 个样本全采自"还在 build、老容器好好跑着"的阶段。
我据此先判「本改动引入退化」、后判「修正后恢复零停机」,两次都错。
教训:探测窗口必须覆盖被测阶段,否则"全绿"只是没看见。

本改动保留的理由因此不是"避免退化",而是**改动面最小**:docker 走原路径,
只有 rootless 才多一步。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b49d3e14
Pipeline #3532 failed in 0 seconds
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
set -euo pipefail set -euo pipefail
log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
main() { main() {
...@@ -82,19 +83,33 @@ main() { ...@@ -82,19 +83,33 @@ main() {
log "build 镜像(显式,不和 up 混)" log "build 镜像(显式,不和 up 混)"
"${COMPOSE[@]}" build "${SERVICES[@]}" "${COMPOSE[@]}" build "${SERVICES[@]}"
# ⭐ 先 stop 再 up,**不用 `up -d --force-recreate`**(2026-08-04 新生产机实测)。 # ⭐ 主路径仍是直接 force-recreate —— **它在 docker 上是零停机的**。
# 2026-08-04 三组实测(0.5s 间隔探 /health,部署全程采样):
# docker + 直接 force-recreate → 停机 0.00s(535 样本全 200)
# docker + 先 stop 再 recreate → 停机 15.97s ❌
# podman + 先 stop 再 recreate → 停机 5.44s
# compose 的 force-recreate 是逐服务滚动(新容器起来再切旧的),而"先全 stop"会造出
# 真空期 —— 把零停机做成了十几秒停机。所以**不能**为了兼容 rootless 就无条件加 stop。
#
# rootless podman(运维刻意选的形态:非 root 账号 + netavark/pasta,攻击面更小)下, # rootless podman(运维刻意选的形态:非 root 账号 + netavark/pasta,攻击面更小)下,
# force-recreate 要删**运行中**的容器,而 rootless 杀不掉它的网络进程: # force-recreate 要删**运行中**的容器,而 rootless 杀不掉它的网络进程:
# rootless netns: kill network process: permission denied # rootless netns: kill network process: permission denied
# 失败还会留下 `<hash>_pac-pac-service-1` 这样的半成品容器,得手工清。 # → 仅在此时回退:清掉失败留下的 `<hash>_<proj>-<svc>-1` 半成品容器,stop 后重建。
# 而"先 stop 让网络进程正常退出、再让 up 重建"实测通过 —— 且在 docker 上语义等价 # 代价是那次部署有几秒停机,但只发生在 rootless 环境,docker 侧行为完全不变。
# (甚至更彻底:stop 后容器必然重建,不依赖 compose 判定),故两种运行时统一走这条路。 # ⚠️ 永远不退回裸 `up -d`:那正是本脚本要绕开的 compose diff 缺陷(见文件头)。
# ⚠️ 仍然不能退回裸 `up -d`:那正是本脚本要绕开的 compose diff 缺陷(见文件头)。
log "stop 旧容器(让网络进程正常退出;rootless 下不能直接删运行中容器)"
"${COMPOSE[@]}" stop "${SERVICES[@]}" || true
log "force-recreate(不信 compose 的重建判定)" log "force-recreate(不信 compose 的重建判定)"
if ! "${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"; then
warn "force-recreate 失败(疑似 rootless 删不掉运行中容器的网络进程),回退:清残留 → stop → 重建"
local proj_r svc_r leftover
proj_r=$(basename "$PWD")
for svc_r in "${SERVICES[@]}"; do
# 失败会留下带 hash 前缀的半成品容器(如 b69b5bec_pac-pac-service-1),不清则重建报名字冲突
leftover=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E "_${proj_r}-${svc_r}-1$" || true)
[[ -n "$leftover" ]] && docker rm -f $leftover >/dev/null 2>&1 && log " 清理残留容器 $leftover"
done
"${COMPOSE[@]}" stop "${SERVICES[@]}" || true
"${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}" "${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"
fi
# ── 部署后硬验证:任一不过 = 部署失败 ───────────────────────────── # ── 部署后硬验证:任一不过 = 部署失败 ─────────────────────────────
log "验证 1/3:容器跑的镜像 == 刚构建的镜像" log "验证 1/3:容器跑的镜像 == 刚构建的镜像"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment