feat(deploy): 增加三节点 K3s 高可用迁移能力
新增 WireGuard 全互联、三 server embedded-etcd K3s、CloudNativePG 双实例、Barman OSS 备份、双 NGINX、Kubernetes Secret/RBAC 与本地旧文件按严格 24 小时清理。 新增维护窗口数据迁移、digest 固定滚动发布、应用回滚、跨节点文件 E2E、节点和数据库故障演练、CNPG 恢复与 etcd 快照验收脚本;洛杉矶仅作为带 NoSchedule 污点的仲裁节点。 所有生产 Secret 只在执行时从 0600 本地环境和旧生产容器导入,仓库不保存凭据;公网入口保持人工 DNS 故障切换边界。 验证:bash -n、ShellCheck、kubectl kustomize、Node 语法检查、Secret 扫描、OSS put/head/delete 实测。
This commit is contained in:
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck source=scripts/cluster/common.sh
|
||||
source "$script_dir/common.sh"
|
||||
|
||||
mode=${1:-precutover}
|
||||
[[ $mode == precutover || $mode == postcutover ]] || {
|
||||
echo 'usage: verify-cluster.sh [precutover|postcutover]' >&2
|
||||
exit 64
|
||||
}
|
||||
load_cluster_env
|
||||
|
||||
hosts=("$CLUSTER_NINGBO_HOST" "$CLUSTER_HONGKONG_HOST" "$CLUSTER_LOS_ANGELES_HOST")
|
||||
wireguard_ips=(10.77.0.1 10.77.0.2 10.77.0.3)
|
||||
for source_index in 0 1 2; do
|
||||
handshakes=$(cluster_ssh "${hosts[$source_index]}" \
|
||||
"wg show wg0 latest-handshakes | awk '\$2 > 0 && systime() - \$2 < 180 { count++ } END { print count + 0 }'")
|
||||
[[ $handshakes -eq 2 ]] || {
|
||||
echo "stale WireGuard handshake on ${hosts[$source_index]}" >&2
|
||||
exit 1
|
||||
}
|
||||
for target_index in 0 1 2; do
|
||||
[[ $source_index -eq $target_index ]] && continue
|
||||
ping_output=$(cluster_ssh "${hosts[$source_index]}" \
|
||||
"ping -c 10 -W 2 ${wireguard_ips[$target_index]}")
|
||||
packet_loss=$(sed -nE 's/.* ([0-9.]+)% packet loss.*/\1/p' <<<"$ping_output")
|
||||
average_rtt=$(sed -nE 's#.* = [0-9.]+/([0-9.]+)/.*#\1#p' <<<"$ping_output")
|
||||
awk -v loss="$packet_loss" 'BEGIN { exit !(loss < 1) }'
|
||||
if [[ $source_index -eq 2 || $target_index -eq 2 ]]; then
|
||||
awk -v rtt="$average_rtt" 'BEGIN { exit !(rtt < 300) }'
|
||||
else
|
||||
awk -v rtt="$average_rtt" 'BEGIN { exit !(rtt < 80) }'
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo 'wireguard_acceptance=PASS handshakes=6 packet_loss_lt_1=true'
|
||||
|
||||
cluster_state=$(cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
kubectl='k3s kubectl'
|
||||
[[ $($kubectl get nodes --no-headers | awk '$2 == "Ready" { count++ } END { print count + 0 }') -eq 3 ]]
|
||||
$kubectl get --raw='/readyz?verbose' | grep -q '\[+\]etcd ok'
|
||||
[[ $($kubectl get node easyai-los-angeles -o json |
|
||||
jq '[.spec.taints[]? | select(.key=="easyai.io/witness" and .effect=="NoSchedule")] | length') -eq 1 ]]
|
||||
[[ $($kubectl get pods -A -o json |
|
||||
jq '[.items[] | select(.spec.nodeName=="easyai-los-angeles") |
|
||||
select(.metadata.namespace != "kube-system" and .metadata.namespace != "cnpg-system" and .metadata.namespace != "cert-manager")] | length') -eq 0 ]]
|
||||
printf 'nodes=%s\n' "$($kubectl get nodes --no-headers | wc -l)"
|
||||
REMOTE
|
||||
)
|
||||
[[ $cluster_state == nodes=3 ]]
|
||||
echo 'k3s_control_plane=PASS servers=3 witness_business_pods=0'
|
||||
|
||||
if [[ $mode == postcutover ]]; then
|
||||
database_state=$(cluster_ssh "$CLUSTER_NINGBO_HOST" 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
kubectl='k3s kubectl'
|
||||
[[ $($kubectl get cluster easyai-postgres -n easyai -o jsonpath='{.status.readyInstances}') -eq 2 ]]
|
||||
primary=$($kubectl get cluster easyai-postgres -n easyai -o jsonpath='{.status.currentPrimary}')
|
||||
[[ $($kubectl get pod "$primary" -n easyai -o jsonpath='{.spec.nodeName}') == easyai-ningbo ||
|
||||
$($kubectl get pod "$primary" -n easyai -o jsonpath='{.spec.nodeName}') == easyai-hongkong ]]
|
||||
replication=$($kubectl exec -n easyai "$primary" -c postgres -- \
|
||||
psql -X -U easyai -d easyai_ai_gateway -At -c \
|
||||
"SELECT count(*) FROM pg_stat_replication WHERE sync_state IN ('sync','quorum');")
|
||||
[[ $replication -eq 1 ]]
|
||||
[[ $($kubectl get pods -n easyai -l app.kubernetes.io/name=easyai-api \
|
||||
--field-selector=status.phase=Running --no-headers | wc -l) -eq 2 ]]
|
||||
[[ $($kubectl get pods -n easyai -l app.kubernetes.io/name=easyai-web \
|
||||
--field-selector=status.phase=Running --no-headers | wc -l) -eq 2 ]]
|
||||
images=$($kubectl get deployments -n easyai -o json |
|
||||
jq -r '.items[] | select(.metadata.name | startswith("easyai-")) |
|
||||
.spec.template.spec.containers[].image')
|
||||
if grep -Ev '@sha256:[0-9a-f]{64}$' <<<"$images" | grep -q .; then
|
||||
echo 'one or more application images are not digest-pinned' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $($kubectl get secret easyai-gateway-security-events -n easyai \
|
||||
-o json | jq '.data | length') -eq 4 ]]
|
||||
[[ $($kubectl auth can-i update secret/easyai-gateway-security-events -n easyai \
|
||||
--as=system:serviceaccount:easyai:easyai-ai-gateway) == yes ]]
|
||||
[[ $($kubectl auth can-i update secret/easyai-ai-gateway-runtime -n easyai \
|
||||
--as=system:serviceaccount:easyai:easyai-ai-gateway) == no ]]
|
||||
printf 'primary=%s\n' "$primary"
|
||||
REMOTE
|
||||
)
|
||||
[[ $database_state == primary=easyai-postgres-* ]]
|
||||
echo "postgres_acceptance=PASS $database_state synchronous_replicas=1"
|
||||
|
||||
for edge_ip in "${CLUSTER_NINGBO_HOST#*@}" "${CLUSTER_HONGKONG_HOST#*@}"; do
|
||||
curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \
|
||||
https://ai.51easyai.com/api/v1/healthz | grep -q easyai-ai-gateway
|
||||
curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \
|
||||
https://ai.51easyai.com/api/v1/readyz | grep -q '"ok":true'
|
||||
curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \
|
||||
https://ai.51easyai.com/api/v1/openapi.json | grep -q '"/api/v1/chat/completions"'
|
||||
curl -fsS --max-time 10 --resolve "ai.51easyai.com:443:$edge_ip" \
|
||||
https://ai.51easyai.com/ | grep -q 'EasyAI AI Gateway'
|
||||
done
|
||||
echo 'nginx_edges=PASS ningbo=true hongkong=true dns_changed=false'
|
||||
fi
|
||||
|
||||
echo "cluster_verification=PASS mode=$mode"
|
||||
Reference in New Issue
Block a user