Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
247 lines
7.8 KiB
Bash
247 lines
7.8 KiB
Bash
#!/bin/bash
|
||
# Harbor Watchdog — 運行中崩潰自動恢復
|
||
# 2026-04-05 Claude Code: 解決 Harbor Exited(128) 死鎖問題
|
||
# 首席架構師 Review 2026-04-05: 修正 I1(lockfile) I2(stderr) I3(loop防護) I4(指令退出碼) S1(驗證等待)
|
||
#
|
||
# 問題根因:
|
||
# Docker restart:always 在 Exited(128) 情況下重試後進入 backoff 放棄
|
||
# harbor-core/db/portal 啟動時連 syslog:1514(harbor-log 未就緒)失敗 exit
|
||
#
|
||
# 設計:此 watchdog 每 60 秒輪詢 Harbor API,
|
||
# 不健康 → 等 5s 確認 → 執行 4 Phase 修復(harbor-log first 策略)
|
||
# 使用 lockfile 防止與 startup-110.sh 並行修復(I1)
|
||
#
|
||
# 部署位置: /usr/local/bin/harbor-watchdog.sh (on 192.168.0.110)
|
||
# systemd unit: /etc/systemd/system/harbor-watchdog.service
|
||
|
||
HARBOR_DIR="/home/wooo/harbor/harbor"
|
||
LOG="/var/log/harbor-watchdog.log"
|
||
LOCKFILE="/var/lock/harbor-repair.lock"
|
||
CHECK_INTERVAL=60 # 秒
|
||
MODE="${1:---watch}"
|
||
EXPECTED_HOST_IP="${AWOOOI_HARBOR_EXPECTED_HOST_IP:-192.168.0.110}"
|
||
|
||
# 注意:不使用 set -e,watchdog 是長駐 loop,-e 會在任何指令失敗時終止腳本
|
||
# 所有關鍵指令的退出碼需明確捕捉(I4)
|
||
set -uo pipefail
|
||
|
||
log() {
|
||
local line
|
||
line="[$(date '+%Y-%m-%d %H:%M:%S')] [watchdog] $*"
|
||
if { [ -e "$LOG" ] && [ -w "$LOG" ]; } || { [ ! -e "$LOG" ] && [ -w "$(dirname "$LOG")" ]; }; then
|
||
echo "$line" | tee -a "$LOG"
|
||
else
|
||
echo "$line"
|
||
fi
|
||
}
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
Usage: harbor-watchdog.sh [--watch|--check|--repair-once|--apply-once]
|
||
|
||
Modes:
|
||
--watch Default systemd watchdog loop.
|
||
--check Read-only Harbor readiness check; performs no Docker action.
|
||
--repair-once Run one bounded Harbor repair cycle on 192.168.0.110.
|
||
--apply-once Alias of --repair-once.
|
||
|
||
Safety:
|
||
Repair modes fail closed unless the local host has 192.168.0.110, or
|
||
AWOOOI_ALLOW_NON_110_HARBOR_REPAIR=1 is explicitly set.
|
||
EOF
|
||
}
|
||
|
||
probe_harbor_code() {
|
||
local code
|
||
# 注意:Harbor /v2/ 正常回傳 401(需認證),不能用 -f(-f 把 401 視為失敗 exit 22)
|
||
# 使用 127.0.0.1 避免 IPv6 解析問題
|
||
code=$(curl -s --max-time 5 -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/v2/ 2>/dev/null || echo "000")
|
||
if [ -z "$code" ]; then
|
||
code="000"
|
||
fi
|
||
echo "$code"
|
||
}
|
||
|
||
harbor_is_healthy() {
|
||
local code
|
||
code="$(probe_harbor_code)"
|
||
[ "$code" = "401" ] || [ "$code" = "200" ]
|
||
}
|
||
|
||
host_has_expected_ip() {
|
||
command -v ip >/dev/null 2>&1 || return 1
|
||
ip -o -4 addr show 2>/dev/null | grep -q " ${EXPECTED_HOST_IP}/"
|
||
}
|
||
|
||
require_expected_host_for_apply() {
|
||
if [ "${AWOOOI_ALLOW_NON_110_HARBOR_REPAIR:-0}" = "1" ]; then
|
||
log "AWOOOI_ALLOW_NON_110_HARBOR_REPAIR=1,略過 expected host guard"
|
||
return 0
|
||
fi
|
||
if host_has_expected_ip; then
|
||
return 0
|
||
fi
|
||
log "REFUSE harbor repair: expected_host_ip=${EXPECTED_HOST_IP} not present; run on 110 local console/root shell"
|
||
return 1
|
||
}
|
||
|
||
check_harbor() {
|
||
local code ready docker_cli docker_active dir_exists compose_exists lock_exists host_match
|
||
code="$(probe_harbor_code)"
|
||
ready=false
|
||
if [ "$code" = "401" ] || [ "$code" = "200" ]; then
|
||
ready=true
|
||
fi
|
||
docker_cli=false
|
||
command -v docker >/dev/null 2>&1 && docker_cli=true
|
||
docker_active="unknown"
|
||
if command -v systemctl >/dev/null 2>&1; then
|
||
docker_active="$(systemctl is-active docker 2>/dev/null || true)"
|
||
fi
|
||
dir_exists=false
|
||
[ -d "$HARBOR_DIR" ] && dir_exists=true
|
||
compose_exists=false
|
||
[ -f "$HARBOR_DIR/docker-compose.yml" ] && compose_exists=true
|
||
lock_exists=false
|
||
[ -e "$LOCKFILE" ] && lock_exists=true
|
||
host_match=false
|
||
host_has_expected_ip && host_match=true
|
||
|
||
cat <<EOF
|
||
AWOOOI_HARBOR_WATCHDOG_CHECK
|
||
mode=check
|
||
target=127.0.0.1:5000/v2/
|
||
expected_host_ip=${EXPECTED_HOST_IP}
|
||
expected_host_ip_present=${host_match}
|
||
harbor_dir=${HARBOR_DIR}
|
||
harbor_dir_exists=${dir_exists}
|
||
harbor_compose_exists=${compose_exists}
|
||
docker_cli_available=${docker_cli}
|
||
docker_service_state=${docker_active}
|
||
lockfile=${LOCKFILE}
|
||
lockfile_exists=${lock_exists}
|
||
harbor_local_v2_http_status=${code}
|
||
harbor_ready=${ready}
|
||
check_only=true
|
||
docker_compose_action_performed=false
|
||
container_remove_performed=false
|
||
service_restart_performed=false
|
||
host_reboot_performed=false
|
||
EOF
|
||
}
|
||
|
||
repair_harbor() {
|
||
require_expected_host_for_apply || return 1
|
||
|
||
# I1: lockfile 防止與 startup-110.sh 並行修復
|
||
exec 9>"$LOCKFILE"
|
||
if ! flock -n 9; then
|
||
log "另一修復程序正在進行,跳過本次修復"
|
||
return 0
|
||
fi
|
||
|
||
log "⚠️ 開始修復 Harbor(4 Phase 策略)..."
|
||
cd "$HARBOR_DIR" || { log "❌ 找不到 Harbor 目錄: $HARBOR_DIR"; return 1; }
|
||
|
||
# Phase 1: 清除所有 Exited Harbor 容器(打破 backoff 死鎖)
|
||
local exited
|
||
exited=$(docker ps -a --filter name="harbor-" --filter status=exited --format "{{.Names}}" 2>/dev/null || true)
|
||
if [ -n "$exited" ]; then
|
||
log "Phase 1: 清除 Exited 容器: $(echo "$exited" | tr '\n' ' ')"
|
||
echo "$exited" | xargs docker rm -f 2>&1 | while IFS= read -r line; do log " $line"; done || true
|
||
else
|
||
log "Phase 1: 無 Exited 容器"
|
||
fi
|
||
|
||
# Phase 2: 只啟動 harbor-log(I2: 捕捉 stderr,I4: 確認退出碼)
|
||
log "Phase 2: 啟動 harbor-log..."
|
||
if ! docker compose up -d harbor-log 2>&1 | while IFS= read -r line; do log " $line"; done; then
|
||
log "❌ harbor-log 啟動指令失敗"
|
||
return 1
|
||
fi
|
||
|
||
# Phase 3: 等 harbor-log healthy(最多 90s)
|
||
log "Phase 3: 等待 harbor-log healthy(最多 90s)..."
|
||
local i status
|
||
for i in $(seq 1 18); do
|
||
status=$(docker inspect --format='{{.State.Health.Status}}' harbor-log 2>/dev/null || echo "missing")
|
||
if [ "$status" = "healthy" ]; then
|
||
log "✅ harbor-log healthy (${i}x5s)"
|
||
break
|
||
fi
|
||
sleep 5
|
||
done
|
||
|
||
status=$(docker inspect --format='{{.State.Health.Status}}' harbor-log 2>/dev/null || echo "unknown")
|
||
if [ "$status" != "healthy" ]; then
|
||
log "❌ harbor-log 未 healthy (status=$status),放棄本次修復"
|
||
return 1
|
||
fi
|
||
|
||
# Phase 4: 啟動全組件(I2: 捕捉 stderr)
|
||
log "Phase 4: 啟動 Harbor 全組件..."
|
||
if ! docker compose up -d 2>&1 | while IFS= read -r line; do log " $line"; done; then
|
||
log "❌ Harbor 全組件啟動指令失敗"
|
||
return 1
|
||
fi
|
||
|
||
# S1: 等 30s 讓 harbor-core 完成初始化(原 10s 不夠)
|
||
log "等待 30s 讓 harbor-core 完成初始化..."
|
||
sleep 30
|
||
|
||
if harbor_is_healthy; then
|
||
log "✅ Harbor 修復成功"
|
||
return 0
|
||
else
|
||
log "❌ Harbor 修復後仍不健康,需人工介入"
|
||
log " 手動: cd $HARBOR_DIR && docker compose down && docker compose up -d harbor-log && sleep 60 && docker compose up -d"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
case "$MODE" in
|
||
--check)
|
||
check_harbor
|
||
exit 0
|
||
;;
|
||
--repair-once|--apply-once)
|
||
log "=== Harbor Watchdog 單次修復開始 ==="
|
||
if harbor_is_healthy; then
|
||
log "Harbor 已健康,跳過修復"
|
||
check_harbor
|
||
exit 0
|
||
fi
|
||
repair_harbor
|
||
rc=$?
|
||
check_harbor
|
||
exit "$rc"
|
||
;;
|
||
--watch)
|
||
;;
|
||
-h|--help)
|
||
usage
|
||
exit 0
|
||
;;
|
||
*)
|
||
usage
|
||
exit 2
|
||
;;
|
||
esac
|
||
|
||
log "=== Harbor Watchdog 啟動 (interval=${CHECK_INTERVAL}s) ==="
|
||
|
||
# I3: while loop 本體用 || true 保護,子 shell 異常不終止整個 watchdog
|
||
while true; do
|
||
(
|
||
if ! harbor_is_healthy; then
|
||
# 等 5 秒再確認(避免短暫不穩定誤報)
|
||
sleep 5
|
||
if ! harbor_is_healthy; then
|
||
repair_harbor
|
||
fi
|
||
fi
|
||
) || log "⚠️ 本次檢查週期發生未預期錯誤,繼續下一週期"
|
||
|
||
sleep "$CHECK_INTERVAL"
|
||
done
|