Files
codex-app-server/deploy/host_deploy.sh
T
host 57f8852634 fix: worker 代码变化自退 + 部署时清孤儿 worker
2026-10-27 暂存清理实弹:05:51 的旧 docker worker 成孤儿后
继续用旧代码抢任务、按老路失败且不删暂存;autoscaler 用
start_new_session=True 拉 worker、部署重启杀不到非 unit 进程。

两层修:
1. worker 常驻循环每轮边界比对 worker.py 文件签名
   (mtime_ns + size),部署换目录后必变,变了就以 75
   (EX_TEMPFAIL,失败类)退出——检查点在轮次边界,绝不
   中断在跑任务。退出后:unit 托管的由 systemd on-failure
   重拉新版,autoscaler 托管的由 reconcile 重拉,孤儿直接
   消失。附带好处:以后部署不用再手动重启本地 worker。
2. host_deploy.sh 换目录后、启动服务前清孤儿:按 cgroup
   过滤杀非 .service 的 cloud.worker 进程(桥接修补前就存
   在的孤儿),先 TERM(跑完当前任务再退),30 秒未退的
   再 KILL;unit 托管的(如 host-1)不动。pgrep 用
   [c]loud.worker 写法,避免检查命令自匹配。

测试:test_worker.py 新增 2 个(签名变了则 75 退出且不认领、
签名不变则跑到 max_seconds);反向验证:撤掉 worker 修复
时正好前者变红。清扫过滤器主机干跑:两个现役 worker 均
为 unit cgroup,全 KEEP,杀 0 个。全量 deploy 19、host
62、cloud 619 全过。
2026-10-27 11:23:49 +08:00

268 lines
11 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# 主机侧唯一部署脚本(取代 deploy.sh 内嵌 HOST_SCRIPT 与
# ~/workspace/ssh-helpers/deploy_*_host.sh 等多份手工脚本)。
#
# 设计目标:旧 tar 绝不能被误部署,health 通过绝不能代替“新代码上线”。
#
# 调用(VM 侧 deploy.py 传过来,绝不由人工在主机上拼路径):
# sh host_deploy.sh <tarball> <expected_sha256> <expected_commit>
# tarball:远端唯一路径,例如 /tmp/codex-app-server-releases/<commit>.tar.gz
# 绝不再用 /tmp/codex-app-server-code.tar.gz 这种通用路径。
# expected_sha256:VM 侧算好的 tar sha256,主机侧先验再动任何东西。
# expected_commit:git 完整 commit,tar 根目录的 DEPLOYED_COMMIT
# 必须与它一致,否则直接退出,旧代码原地不动。
#
# 流程(任何一步失败都走回滚,退出非零):
# 1. 验 tar sha256,不符直接退出,不备份、不解包、不重启。
# 2. 验 tar 内 DEPLOYED_COMMIT,不符直接退出。
# 3. 解包到 staging 目录(与 APP_DIR 同一文件系统,便于 mv 换目录),
# 并对 staging 做 python 编译检查,语法坏的包绝不上线。
# 4. 备份当前 live 代码(备份名含旧 commit + 时间戳,留最近 5 份;
# 备份 tar 遇运行中文件变化返回 1 时不直接 abort,只要备份文件
# 非空就继续,见 PR #46 教训)。
# 5. 停 server(s) + worker/autoscaler,再换目录:live -> old,
# staging -> live,work/ 与 host-config-real.json(如有)原样搬回。
# 换目录代替“原地 rm + 解包”,中途失败不会留半解包的 live。
# 6. 启动并验 health 60 秒:ok=true 且 backend=postgres 且 db_ok=true,
# 且 live 的 DEPLOYED_COMMIT 等于 expected_commit;health 里
# 若有 commit 字段且非空,也必须等于 expected_commit。
# server-2(HA 阶段 2 加的 8766)若存在必须同批停/起、同批验
# health,否则 LB 会混跑新旧内存代码(2026-10-27 首推教训)。
# 7. 成功打印 RELEASE_OK commit=... sha=... backup=...,删掉 old 目录
# (备份 tar 已留档);失败则把 old 换回、重启、再验 health,
# 回滚后 health 恢复也算部署失败(新代码未上线),退出非零。
#
# 可覆盖的环境变量(仅用于本地仿真测试,生产用默认值):
# CODEX_DEPLOY_APP_DIR、CODEX_DEPLOY_BACKUP_DIR、
# CODEX_DEPLOY_HEALTH_INTERVAL、CODEX_DEPLOY_HEALTH_ATTEMPTS
# 回滚边界:只回代码,不回数据库(迁移只加列、幂等,见 deploy.sh 注释)。
# 密钥:本脚本不读 env 文件、不打印任何密钥。
set -eu
TARBALL=${1:-}
EXPECTED_SHA=${2:-}
EXPECTED_COMMIT=${3:-}
if [ -z "$TARBALL" ] || [ -z "$EXPECTED_SHA" ] || [ -z "$EXPECTED_COMMIT" ]; then
echo "用法:sh host_deploy.sh <tarball> <expected_sha256> <expected_commit>" >&2
exit 2
fi
if [ ! -f "$TARBALL" ]; then
echo "部署包不存在,拒绝部署(旧代码不动):$TARBALL" >&2
exit 2
fi
APP_DIR=${CODEX_DEPLOY_APP_DIR:-/opt/codex-app-server}
APP_BASE=$(basename "$APP_DIR")
BACKUP_DIR=${CODEX_DEPLOY_BACKUP_DIR:-/opt/codex-app-server-backups}
STAGING_DIR=${CODEX_DEPLOY_STAGING_DIR:-/opt/codex-app-server-staging}
OLD_DIR=${CODEX_DEPLOY_OLD_DIR:-/opt/codex-app-server-old}
FAILED_DIR=${CODEX_DEPLOY_FAILED_DIR:-/opt/codex-app-server-failed}
PRESERVE_DIR=${CODEX_DEPLOY_PRESERVE_DIR:-/opt/codex-app-server-preserve}
HEALTH_INTERVAL=${CODEX_DEPLOY_HEALTH_INTERVAL:-5}
HEALTH_ATTEMPTS=${CODEX_DEPLOY_HEALTH_ATTEMPTS:-12}
# --- 1. 验 sha:不符直接退出,什么都不动 -------------------------------
ACTUAL_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
echo "部署包 sha256 不符,拒绝部署(旧代码不动):" >&2
echo " expected=$EXPECTED_SHA" >&2
echo " actual=$ACTUAL_SHA tarball=$TARBALL" >&2
exit 2
fi
# --- 2. 验 tar 内清单:DEPLOYED_COMMIT 必须等于 expected -------------
TARBALL_COMMIT=$(tar -xzf "$TARBALL" -O DEPLOYED_COMMIT 2>/dev/null | tr -d ' \n\r' || true)
if [ "$TARBALL_COMMIT" != "$EXPECTED_COMMIT" ]; then
echo "部署包内 DEPLOYED_COMMIT 与期望 commit 不符,拒绝部署(旧代码不动):" >&2
echo " expected=$EXPECTED_COMMIT tarball_commit=$TARBALL_COMMIT" >&2
exit 2
fi
# --- 3. staging 解包 + 编译检查 ----------------------------------------
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
tar -xzf "$TARBALL" -C "$STAGING_DIR"
python3 -m compileall -q "$STAGING_DIR" || {
echo "staging 编译检查失败,拒绝部署(旧代码不动)" >&2
rm -rf "$STAGING_DIR"
exit 2
}
check_health() {
for port in $HEALTH_PORTS; do
OUT=$(curl -s --max-time 5 "http://127.0.0.1:$port/health" || true)
if [ -z "$OUT" ]; then return 1; fi
if ! printf '%s' "$OUT" | EXPECTED_COMMIT="$EXPECTED_COMMIT" python3 -c '
import json, os, sys
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(1)
if not (d.get("ok") is True and d.get("backend") == "postgres"
and d.get("db_ok") is True):
sys.exit(1)
commit = d.get("commit")
if commit is not None and commit != os.environ.get("EXPECTED_COMMIT"):
sys.exit(1)
sys.exit(0)
'; then
return 1
fi
done
return 0
}
wait_health() {
i=0
while [ "$i" -lt "$HEALTH_ATTEMPTS" ]; do
if check_health; then return 0; fi
sleep "$HEALTH_INTERVAL"
i=$((i + 1))
done
return 1
}
worker_unit() {
if systemctl is-active -q codex-autoscaler 2>/dev/null; then
echo codex-autoscaler
else
echo codex-cloud-worker
fi
}
# server-2(8766)是 HA 阶段 2 加的;unit 存在才纳入停/起与 health
# 检查,老主机没有它时退回单机行为。2026-10-27 首推教训:脚本只动
# server-1 时,LB 上游的 server-2 仍跑旧内存代码,新旧混跑。
SERVER_UNITS="codex-cloud-server"
HEALTH_PORTS="8765"
if systemctl cat codex-cloud-server-2 >/dev/null 2>&1; then
SERVER_UNITS="$SERVER_UNITS codex-cloud-server-2"
HEALTH_PORTS="$HEALTH_PORTS 8766"
fi
# --- 4. 备份 live(名含旧 commit,留 5 份) ------------------------------
mkdir -p "$BACKUP_DIR"
PREV_COMMIT=unknown
if [ -f "$APP_DIR/DEPLOYED_COMMIT" ]; then
PREV_COMMIT=$(tr -d ' \n\r' < "$APP_DIR/DEPLOYED_COMMIT" || echo unknown)
[ -n "$PREV_COMMIT" ] || PREV_COMMIT=unknown
fi
STAMP=$(date +%Y%m%d%H%M%S)
BACKUP_FILE="$BACKUP_DIR/codex-app-server-${PREV_COMMIT}-${STAMP}.tar.gz"
# PR #46 教训:备份 tar 遇运行中文件变化返回 1 时不 abort,文件非空即继续。
tar --exclude="$APP_BASE/work" --exclude="$APP_BASE/worker-heartbeat.json" \
-czf "$BACKUP_FILE" -C "$(dirname "$APP_DIR")" "$APP_BASE" || true
if [ ! -s "$BACKUP_FILE" ]; then
echo "备份失败(备份文件为空),拒绝部署(旧代码不动)" >&2
rm -rf "$STAGING_DIR"
exit 2
fi
echo "已备份当前代码到:$BACKUP_FILE"
ls -t "$BACKUP_DIR"/codex-app-server-*.tar.gz 2>/dev/null \
| tail -n +6 | xargs -r rm -f
WORKER_UNIT=$(worker_unit)
echo "停服务并换目录:worker_unit=$WORKER_UNIT server_units=$SERVER_UNITS"
systemctl stop "$WORKER_UNIT" $SERVER_UNITS || true
# --- 5. 换目录:live -> old,staging -> live,保留文件搬回 --------------
rm -rf "$OLD_DIR" "$PRESERVE_DIR"
mkdir -p "$PRESERVE_DIR"
for name in work host-config-real.json; do
if [ -e "$APP_DIR/$name" ]; then
mv "$APP_DIR/$name" "$PRESERVE_DIR/$name"
fi
done
mv "$APP_DIR" "$OLD_DIR"
mv "$STAGING_DIR" "$APP_DIR"
for name in work host-config-real.json; do
if [ -e "$PRESERVE_DIR/$name" ]; then
mv "$PRESERVE_DIR/$name" "$APP_DIR/$name"
fi
done
rmdir "$PRESERVE_DIR" 2>/dev/null || true
if id codexsvc >/dev/null 2>&1; then
chown -R codexsvc:codexsvc "$APP_DIR"
fi
# --- 5b. 清孤儿 worker ---------------------------------------------------
# 非 systemd unit cgroup 的 cloud.worker 进程(手动/setsid 拉起的旧
# worker):部署重启杀不到它(2026-10-27 实弹:05:51 的旧 docker
# worker 成孤儿后继续用旧代码抢任务、按老路失败且不删暂存)。
# unit 托管的 worker(cgroup 含 .service,如 codex-host-worker)
# 不动:它们靠新版 worker 的代码变化自退 + systemd on-failure 重拉。
# 先 TERM(worker 收到后停认领、跑完当前任务再退),30 秒内未退
# 的再 KILL;被中断的在跑任务由陈旧回收标 interrupted,可续跑。
for _pid in $(pgrep -f "[c]loud.worker" 2>/dev/null || true); do
_cg=$(cat /proc/$_pid/cgroup 2>/dev/null || true)
case "$_cg" in
*".service"*) ;;
*) echo "清孤儿 worker:pid=$_pid"; kill $_pid 2>/dev/null || true ;;
esac
done
for _i in $(seq 1 30); do
_left=0
for _pid in $(pgrep -f "[c]loud.worker" 2>/dev/null || true); do
_cg=$(cat /proc/$_pid/cgroup 2>/dev/null || true)
case "$_cg" in
*".service"*) ;;
*) _left=1 ;;
esac
done
[ "$_left" = "0" ] && break
sleep 1
done
for _pid in $(pgrep -f "[c]loud.worker" 2>/dev/null || true); do
_cg=$(cat /proc/$_pid/cgroup 2>/dev/null || true)
case "$_cg" in
*".service"*) ;;
*) echo "孤儿 worker 未退,KILL:pid=$_pid"; kill -9 $_pid 2>/dev/null || true ;;
esac
done
rollback() {
echo "新代码 health 未通过,开始回滚到备份:$BACKUP_FILE" >&2
systemctl stop "$WORKER_UNIT" $SERVER_UNITS || true
rm -rf "$FAILED_DIR" "$PRESERVE_DIR"
mkdir -p "$PRESERVE_DIR"
for name in work host-config-real.json; do
if [ -e "$APP_DIR/$name" ]; then
mv "$APP_DIR/$name" "$PRESERVE_DIR/$name"
fi
done
mv "$APP_DIR" "$FAILED_DIR"
mv "$OLD_DIR" "$APP_DIR"
for name in work host-config-real.json; do
if [ -e "$PRESERVE_DIR/$name" ]; then
mv "$PRESERVE_DIR/$name" "$APP_DIR/$name"
fi
done
rmdir "$PRESERVE_DIR" 2>/dev/null || true
if id codexsvc >/dev/null 2>&1; then
chown -R codexsvc:codexsvc "$APP_DIR"
fi
systemctl start $SERVER_UNITS "$WORKER_UNIT" || true
if wait_health; then
echo "部署失败,已回滚到备份,health 已恢复(新代码未上线)" >&2
else
echo "已回滚但 health 仍失败,需人工介入" >&2
fi
exit 1
}
# --- 6. 启动 + 验 health + 验上线 commit -------------------------------
systemctl start $SERVER_UNITS "$WORKER_UNIT" || true
if ! wait_health; then
rollback
fi
LIVE_COMMIT=$(tr -d ' \n\r' < "$APP_DIR/DEPLOYED_COMMIT" 2>/dev/null || true)
if [ "$LIVE_COMMIT" != "$EXPECTED_COMMIT" ]; then
echo "上线 commit 不符(health 通过但代码不是新版),开始回滚:" >&2
echo " expected=$EXPECTED_COMMIT live=$LIVE_COMMIT" >&2
rollback
fi
rm -rf "$OLD_DIR"
echo "RELEASE_OK commit=$EXPECTED_COMMIT sha=$EXPECTED_SHA backup=$BACKUP_FILE"
exit 0