fix(acceptance): 隔离控制面抖动与租约瞬态故障

线上 P24 验收暴露出高频 kubectl exec 放大 K3s API 压力、门禁查询挤占关键连接池,以及 PostgreSQL 锁超时被误判为租约所有权丢失。

本次合并验收身份查询、在租约有效期内重试瞬态续期错误、修复人工审核残留 attempt,并增加滚动后 etcd 稳定窗口、节点直连指标和双站独立报告。

验证:Go 全量测试、go vet、聚焦 race、gofmt、迁移安全检查、bash -n、ShellCheck、manual release test。
This commit is contained in:
2026-08-01 01:51:44 +08:00
parent be6ce7f78a
commit 132cda35d8
10 changed files with 495 additions and 56 deletions
+197 -27
View File
@@ -43,6 +43,9 @@ Optional model overrides (otherwise selected from current production candidates)
AI_GATEWAY_ACCEPTANCE_IDENTITY_SHARDS
AI_GATEWAY_ACCEPTANCE_API_MEDIA_REQUEST_CONCURRENCY
AI_GATEWAY_ACCEPTANCE_NODE_MEMORY_PRECONDITION_PERCENT
AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS
AI_GATEWAY_ACCEPTANCE_ETCD_BACKEND_COMMIT_MAX_MS
AI_GATEWAY_ACCEPTANCE_ETCD_WAL_FSYNC_MAX_MS
EOF
}
@@ -90,6 +93,10 @@ require_commands curl git go jq node openssl sed shasum
: "${AI_GATEWAY_ACCEPTANCE_WORKER_MEMORY_REQUEST_MIB:=1536}"
: "${AI_GATEWAY_ACCEPTANCE_WORKER_CPU_REQUEST_MILLICORES:=500}"
: "${AI_GATEWAY_ACCEPTANCE_NODE_MEMORY_PRECONDITION_PERCENT:=65}"
: "${AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS:=60}"
: "${AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_TIMEOUT_SECONDS:=240}"
: "${AI_GATEWAY_ACCEPTANCE_ETCD_BACKEND_COMMIT_MAX_MS:=50}"
: "${AI_GATEWAY_ACCEPTANCE_ETCD_WAL_FSYNC_MAX_MS:=15}"
: "${AI_GATEWAY_ACCEPTANCE_AUTOSCALING_MAX_REPLICAS_NINGBO:=2}"
: "${AI_GATEWAY_ACCEPTANCE_AUTOSCALING_MAX_REPLICAS_HONGKONG:=2}"
: "${AI_GATEWAY_ACCEPTANCE_SOAK_DURATION:=2h}"
@@ -146,6 +153,20 @@ fi
echo 'AI_GATEWAY_ACCEPTANCE_NODE_MEMORY_PRECONDITION_PERCENT must be between 50 and 79' >&2
exit 1
}
[[ $AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS -ge 30 &&
$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS -le 300 &&
$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_TIMEOUT_SECONDS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_TIMEOUT_SECONDS -ge $AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS &&
$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_TIMEOUT_SECONDS -le 900 ]] || {
echo 'post-rollout stability window must be 30..300 seconds and timeout must be window..900 seconds' >&2
exit 1
}
[[ $AI_GATEWAY_ACCEPTANCE_ETCD_BACKEND_COMMIT_MAX_MS =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_ETCD_WAL_FSYNC_MAX_MS =~ ^[1-9][0-9]*$ ]] || {
echo 'etcd latency gates must be positive integer milliseconds' >&2
exit 1
}
[[ $AI_GATEWAY_ACCEPTANCE_AUTOSCALING_MAX_REPLICAS_NINGBO =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_AUTOSCALING_MAX_REPLICAS_HONGKONG =~ ^[1-9][0-9]*$ &&
$AI_GATEWAY_ACCEPTANCE_AUTOSCALING_MAX_REPLICAS_NINGBO -le 16 &&
@@ -244,6 +265,7 @@ video_stable_throughput=
video_admitted_throughput=
certified_max_replicas_ningbo=1
certified_max_replicas_hongkong=1
runtime_observation_started_at=
cleanup() {
local status=$?
@@ -1443,6 +1465,125 @@ apply_capacity_profile() {
active_profile=$profile
}
capture_etcd_latency_counters() {
local output=$1
local site host metrics backend_sum backend_count wal_sum wal_count
echo 'site,backend_sum,backend_count,wal_sum,wal_count' >"$output"
for site in ningbo hongkong losangeles; do
case $site in
ningbo) host=$CLUSTER_NINGBO_HOST ;;
hongkong) host=$CLUSTER_HONGKONG_HOST ;;
losangeles) host=$CLUSTER_LOS_ANGELES_HOST ;;
esac
metrics=$(cluster_ssh "$host" 'curl -fsS --max-time 5 http://127.0.0.1:2381/metrics') || return 1
backend_sum=$(awk '$1=="etcd_disk_backend_commit_duration_seconds_sum" {print $2}' <<<"$metrics")
backend_count=$(awk '$1=="etcd_disk_backend_commit_duration_seconds_count" {print $2}' <<<"$metrics")
wal_sum=$(awk '$1=="etcd_disk_wal_fsync_duration_seconds_sum" {print $2}' <<<"$metrics")
wal_count=$(awk '$1=="etcd_disk_wal_fsync_duration_seconds_count" {print $2}' <<<"$metrics")
[[ $backend_sum =~ ^[0-9.]+$ && $backend_count =~ ^[0-9]+$ &&
$wal_sum =~ ^[0-9.]+$ && $wal_count =~ ^[0-9]+$ ]] || return 1
echo "$site,$backend_sum,$backend_count,$wal_sum,$wal_count" >>"$output"
done
}
control_plane_hard_errors_since() {
local since=$1
local host total=0 count pod
[[ $since =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
for host in "$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST" "$CLUSTER_LOS_ANGELES_HOST"; do
count=$(cluster_ssh "$host" \
"journalctl -u k3s --since '$since' --no-pager 2>/dev/null | awk 'BEGIN {IGNORECASE=1} /http: Handler timeout/ || /rejected connection on peer endpoint.*(timeout|reset)/ || /etcdserver: request timed out/ || /leader changed/ {count++} END {print count+0}'") || return 1
[[ $count =~ ^[0-9]+$ ]] || return 1
total=$((total + count))
done
while read -r pod; do
[[ -n $pod ]] || continue
count=$(remote_kubectl logs "$pod" -n "$namespace" -c postgres --since-time="$since" 2>/dev/null |
awk 'BEGIN {IGNORECASE=1}
/API server connectivity issue/ ||
/Instance connectivity error/ ||
/terminating walreceiver due to timeout/ ||
/synchronous commit.*cancel/ {count++}
END {print count+0}') || return 1
[[ $count =~ ^[0-9]+$ ]] || return 1
total=$((total + count))
done < <(remote_kubectl get pods -n "$namespace" \
-l 'cnpg.io/cluster=easyai-postgres' \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
echo "$total"
}
evaluate_etcd_latency_window() {
local before=$1
local after=$2
local output=$3
echo 'site,backend_commit_average_ms,wal_fsync_average_ms' >"$output"
awk -F',' \
-v backend_limit="$AI_GATEWAY_ACCEPTANCE_ETCD_BACKEND_COMMIT_MAX_MS" \
-v wal_limit="$AI_GATEWAY_ACCEPTANCE_ETCD_WAL_FSYNC_MAX_MS" '
NR==FNR {
if (FNR>1) {
backend_sum[$1]=$2; backend_count[$1]=$3
wal_sum[$1]=$4; wal_count[$1]=$5
}
next
}
FNR==1 {next}
{
backend_delta_count=$3-backend_count[$1]
wal_delta_count=$5-wal_count[$1]
if (backend_delta_count<=0 || wal_delta_count<=0) {failed=1; next}
backend_ms=($2-backend_sum[$1])*1000/backend_delta_count
wal_ms=($4-wal_sum[$1])*1000/wal_delta_count
printf "%s,%.3f,%.3f\n", $1, backend_ms, wal_ms
if (backend_ms>=backend_limit || wal_ms>=wal_limit) failed=1
}
END {exit failed}
' "$before" "$after" >>"$output"
}
wait_for_post_rollout_stability() {
local profile=$1
local profile_slug deadline attempt=0 since before after latency_report errors
profile_slug=$(printf '%s' "$profile" | tr '[:upper:]' '[:lower:]')
latency_report=$report_root/$profile_slug-control-plane-stability.csv
deadline=$((SECONDS + AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_TIMEOUT_SECONDS))
while (( SECONDS + AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS <= deadline )); do
attempt=$((attempt + 1))
since=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
before=$temporary_root/etcd-before-$profile_slug-$attempt.csv
after=$temporary_root/etcd-after-$profile_slug-$attempt.csv
capture_etcd_latency_counters "$before" || continue
sleep "$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS"
capture_etcd_latency_counters "$after" || continue
errors=$(control_plane_hard_errors_since "$since" || echo 1)
if [[ $errors == 0 ]] && evaluate_etcd_latency_window "$before" "$after" "$latency_report" &&
verify_control_plane_ready_now; then
runtime_observation_started_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
echo "post_rollout_stability=PASS profile=$profile window_seconds=$AI_GATEWAY_ACCEPTANCE_POST_ROLLOUT_STABILITY_SECONDS hard_errors=0"
return 0
fi
echo "post-rollout control plane not stable: profile=$profile attempt=$attempt hard_errors=${errors:-unknown}" >&2
done
return 1
}
verify_control_plane_ready_now() {
local host ready_output sync_state memory_percent
for host in "$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST" "$CLUSTER_LOS_ANGELES_HOST"; do
ready_output=$(cluster_ssh "$host" "k3s kubectl get --raw='/readyz?verbose'")
grep -Fq '[+]etcd ok' <<<"$ready_output" || return 1
done
[[ $(remote_kubectl get cluster easyai-postgres -n "$namespace" -o 'jsonpath={.status.readyInstances}') == 2 ]] || return 1
sync_state=$(database_query "SELECT COALESCE(string_agg(sync_state,','),'') FROM pg_stat_replication;")
[[ ",$sync_state," == *,sync,* || ",$sync_state," == *,quorum,* ]] || return 1
while read -r _node _cpu _cpu_percent _memory memory_percent; do
memory_percent=${memory_percent%\%}
[[ $memory_percent =~ ^[0-9]+$ ]] || return 1
(( memory_percent < 80 )) || return 1
done < <(remote_kubectl top nodes --no-headers)
}
capacity_controller_status() {
local pod status
while read -r pod; do
@@ -2078,7 +2219,10 @@ run_distributed_load_profile() {
ningbo_report=$temporary_root/ningbo-$artifact
hongkong_report=$temporary_root/hongkong-$artifact
[[ -f $ningbo_report && -f $hongkong_report ]] || return 1
merge_remote_load_reports "$profile" "$ningbo_report" "$hongkong_report" "$report_path"
install -m 0600 "$ningbo_report" "${report_path%.json}-ningbo.json"
install -m 0600 "$hongkong_report" "${report_path%.json}-hongkong.json"
merge_remote_load_reports "$profile" \
"${report_path%.json}-ningbo.json" "${report_path%.json}-hongkong.json" "$report_path"
(( ningbo_status == 0 && hongkong_status == 0 )) || return 1
jq -e '.secretSafe == true and .passed == true' "$report_path" >/dev/null
}
@@ -2191,40 +2335,76 @@ verify_cluster_links() {
}
verify_control_plane_and_recent_logs() {
local host ready_output workload pod error_count
local host ready_output workload pod error_count log_window
log_window=--since=10m
if [[ -n $runtime_observation_started_at ]]; then
log_window=--since-time=$runtime_observation_started_at
fi
for host in "$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST" "$CLUSTER_LOS_ANGELES_HOST"; do
ready_output=$(cluster_ssh "$host" "k3s kubectl get --raw='/readyz?verbose'")
grep -Fq '[+]etcd ok' <<<"$ready_output"
grep -Fq '[+]etcd ok' <<<"$ready_output" || return 1
if [[ -n $runtime_observation_started_at ]]; then
error_count=$(cluster_ssh "$host" \
"journalctl -u k3s --since '$runtime_observation_started_at' --no-pager 2>/dev/null | awk 'BEGIN {IGNORECASE=1} /http: Handler timeout/ || /rejected connection on peer endpoint.*(timeout|reset)/ || /etcdserver: request timed out/ || /leader changed/ {count++} END {print count+0}'")
[[ $error_count == 0 ]] || return 1
fi
done
for workload in api worker; do
while read -r pod; do
error_count=$(remote_kubectl logs "$pod" -n "$namespace" --since=10m |
error_count=$(remote_kubectl logs "$pod" -n "$namespace" "$log_window" |
awk 'BEGIN {IGNORECASE=1}
/postgres_unavailable/ ||
/postgres readiness.*(fail|unavailable|error)/ ||
/leadership elector.*(error|fail)/ ||
/concurrency lease.*(renew.*(error|fail)|lost)/ {count++}
/concurrency lease.*(renew.*(error|fail)|lost)/ ||
/task execution lease (lost|renewal failed)/ ||
/upstream submission requires manual review/ {count++}
END {print count+0}')
[[ $error_count == 0 ]]
[[ $error_count == 0 ]] || return 1
done < <(remote_kubectl get pods -n "$namespace" \
-l "app.kubernetes.io/name=easyai-$workload" \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
done
while read -r pod; do
error_count=$(remote_kubectl logs "$pod" -n "$namespace" -c postgres --since=10m |
error_count=$(remote_kubectl logs "$pod" -n "$namespace" -c postgres "$log_window" |
awk 'BEGIN {IGNORECASE=1}
/apply request took too long/ ||
/dial tcp.*6443/ ||
/API server connectivity issue/ ||
/Instance connectivity error/ ||
/terminating walreceiver due to timeout/ ||
/synchronous commit.*cancel/ ||
/canceling statement due to user request/ ||
/canceling statement due to lock timeout/ ||
/(liveness|readiness).*fail/ {count++}
END {print count+0}')
[[ $error_count == 0 ]]
[[ $error_count == 0 ]] || return 1
done < <(remote_kubectl get pods -n "$namespace" \
-l 'cnpg.io/cluster=easyai-postgres' \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
}
workload_metrics_for_site() {
local workload=$1
local site=$2
local host pod_ip
[[ $workload == api || $workload == worker ]]
[[ $site == ningbo || $site == hongkong ]]
if [[ $site == ningbo ]]; then
host=$CLUSTER_NINGBO_HOST
else
host=$CLUSTER_HONGKONG_HOST
fi
pod_ip=$(remote_kubectl get pods -n "$namespace" \
-l "app.kubernetes.io/name=easyai-$workload,easyai.io/site=$site" -o json |
jq -r '[.items[]
| select(.metadata.deletionTimestamp == null)
| select(any(.status.conditions[]?; .type=="Ready" and .status=="True"))
| .status.podIP][0] // empty')
[[ $pod_ip =~ ^[0-9a-fA-F:.]+$ ]] || return 1
cluster_ssh "$host" "curl -fsS --max-time 5 http://$pod_ip:8088/metrics"
}
current_max_pod_memory_mib() {
remote_kubectl top pods -n "$namespace" \
-l 'app.kubernetes.io/part-of=easyai-ai-gateway' --no-headers |
@@ -2432,11 +2612,7 @@ WHERE status='active'
local river_pool_max river_acquired river_idle
local lease_failures lease_lost
for site in ningbo hongkong; do
pod=$(remote_kubectl get pods -n "$namespace" \
-l "app.kubernetes.io/name=easyai-api,easyai.io/site=$site" \
-o 'jsonpath={.items[0].metadata.name}')
metrics=$(remote_kubectl exec -n "$namespace" "$pod" -- \
wget -qO- http://127.0.0.1:8088/metrics)
metrics=$(workload_metrics_for_site api "$site")
pool_max=$(awk '$1=="easyai_gateway_postgres_pool_max_connections" {print $2}' <<<"$metrics")
acquired=$(awk '$1=="easyai_gateway_postgres_pool_acquired_connections" {print $2}' <<<"$metrics")
idle=$(awk '$1=="easyai_gateway_postgres_pool_idle_connections" {print $2}' <<<"$metrics")
@@ -2458,10 +2634,7 @@ WHERE status='active'
done
for _ in 1 2 3 4 5 6; do
for site in ningbo hongkong; do
pod=$(remote_kubectl get pods -n "$namespace" \
-l "app.kubernetes.io/name=easyai-worker,easyai.io/site=$site" \
-o 'jsonpath={.items[0].metadata.name}')
metrics=$(remote_kubectl exec -n "$namespace" "$pod" -- wget -qO- http://127.0.0.1:8088/metrics)
metrics=$(workload_metrics_for_site worker "$site")
desired=$(awk '$1=="easyai_gateway_async_worker_desired_capacity" {print $2}' <<<"$metrics")
current=$(awk '$1=="easyai_gateway_async_worker_capacity" {print $2}' <<<"$metrics")
global=$(awk '$1=="easyai_gateway_worker_global_capacity" {print $2}' <<<"$metrics")
@@ -2508,7 +2681,7 @@ WHERE status='active'
sample_pressure() {
local output=$1
local stop_file=$2
local database_state node_memory_percent pod_memory_mib worker_resources pool_state pod metrics
local database_state node_memory_percent pod_memory_mib worker_resources pool_state metrics
local consecutive_failures=0
echo 'timestamp,queued,running,oldest_wait_seconds,db_connections,db_max_connections,node_memory_percent,pod_memory_mib,pool_acquired_max,pool_max,pool_idle_min,empty_acquire_total,canceled_acquire_total,lease_failure_total,lease_lost_total,active_instances_min,allocated_capacity_max,critical_pool_acquired_max,critical_pool_max,critical_pool_idle_min,critical_empty_acquire_total,critical_canceled_acquire_total,river_pool_acquired_max,river_pool_max,river_pool_idle_min,river_empty_acquire_total,river_canceled_acquire_total,worker_memory_mib,worker_cpu_millicores' >"$output"
while [[ ! -f $stop_file ]]; do
@@ -2536,15 +2709,7 @@ WHERE acceptance_run_id='$run_id'::uuid;"); then
if ! pool_state=$(
for workload in api worker; do
for site in ningbo hongkong; do
pod=$(remote_kubectl get pods -n "$namespace" \
-l "app.kubernetes.io/name=easyai-$workload,easyai.io/site=$site" -o json |
jq -r '[.items[]
| select(.metadata.deletionTimestamp == null)
| select(any(.status.conditions[]?; .type=="Ready" and .status=="True"))
| .metadata.name][0] // empty') || exit
[[ -n $pod ]] || continue
metrics=$(remote_kubectl exec -n "$namespace" "$pod" -- \
wget -qO- http://127.0.0.1:8088/metrics) || exit
metrics=$(workload_metrics_for_site "$workload" "$site") || exit
awk -v workload="$workload" '
$1=="easyai_gateway_postgres_pool_acquired_connections" {acquired=$2}
$1=="easyai_gateway_postgres_pool_max_connections" {pool_max=$2}
@@ -3132,6 +3297,11 @@ for profile in P24 P28 P32; do
failure_reason="failed to apply $profile"
break
fi
if ! wait_for_post_rollout_stability "$profile"; then
failure_gate_id=post_rollout_control_plane_stability
failure_reason="$profile control plane did not stabilize after rollout"
break
fi
profile_passed=true
for repetition in 1 2 3; do
if ! run_capacity_round "$profile" "$repetition"; then