topfans/k8s/deploy.sh
2026-07-06 13:26:46 +08:00

379 lines
13 KiB
Bash
Executable File

#!/bin/bash
# ===================================================================
# TopFans Helm / Kubernetes 部署脚本 (Phase 1)
# ===================================================================
# 功能:
# - helm install / upgrade (CI 已经把镜像推到 ACR)
# - helm rollback (任意 release 版本)
# - helm status 查看
# - 手动跑 PostgreSQL 序列同步 (切流量前 hard blocker)
# - 临时启停某个服务 (scale / disable)
#
# 使用前提:
# 1. K8s 集群已就绪 (ACK / TKE / k3s 任一)
# 2. 已 kubectl config get-contexts 选好 cluster context
# 3. helm 3.x 已装
# 4. (切流量前) PostgreSQL 序列同步已成功 (本脚本的 sync-pg 子命令)
# 5. values-prod.yaml 已就绪 (用 sops 加密 或 直接 kubectl create secret)
#
# 使用方式:
# # 安装 (首次)
# ./k8s/deploy.sh install
#
# # 升级 (推荐: 用 helm upgrade --reuse-values)
# ./k8s/deploy.sh upgrade v1.0.1
#
# # 回滚到上一版
# ./k8s/deploy.sh rollback
#
# # 显式回滚到指定版本
# ./k8s/deploy.sh rollback 3
#
# # PostgreSQL 序列同步 (切流量前必跑)
# ./k8s/deploy.sh sync-pg
#
# # 看状态
# ./k8s/deploy.sh status
#
# # 看某个服务的 Pod 日志
# ./k8s/deploy.sh logs gateway
# ===================================================================
set -e
# ==================== 颜色 ====================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# ==================== 路径 / 命名 ====================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELM_CHART_DIR="${SCRIPT_DIR}/helm/topfans"
NAMESPACE="${NAMESPACE:-topfans}"
RELEASE_NAME="${RELEASE_NAME:-topfans}"
VALUES_FILE="${VALUES_FILE:-${SCRIPT_DIR}/helm/topfans/values-prod.yaml}"
print_step() {
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
print_msg() { echo -e "${1}${2}${NC}"; }
print_help() {
cat << EOF
${MAGENTA}TopFans K8s / Helm 部署脚本 (Phase 1)${NC}
${YELLOW}用法:${NC}
$0 <command> [args]
${YELLOW}命令:${NC}
${GREEN}install${NC} 首次部署 (helm install)
${GREEN}upgrade${NC} <version> 升级镜像 (用 version 写所有服务 tag)
${GREEN}upgrade --reuse-values${NC} 升级, 复用 values
${GREEN}rollback${NC} [revision] 回滚 (默认上一版)
${GREEN}status${NC} 看 helm status + pod 状态
${GREEN}logs${NC} <service> 看 Pod 日志 (e.g. logs gateway)
${GREEN}sync-pg${NC} PostgreSQL 序列同步 (切流量前必跑)
${GREEN}sync-pg --status${NC} 只验证, 不同步
${GREEN}list${NC} 列所有 release
${GREEN}uninstall${NC} 卸载 (生产慎用!)
${YELLOW}环境变量:${NC}
NAMESPACE 命名空间 (默认 topfans)
RELEASE_NAME helm release 名 (默认 topfans)
VALUES_FILE values 文件 (默认 helm/topfans/values-prod.yaml)
${YELLOW}示例:${NC}
$0 install
$0 upgrade v1.0.1
$0 upgrade --reuse-values
$0 rollback 2
$0 sync-pg
$0 status
EOF
}
# ==================== 必要工具检查 ====================
check_prereqs() {
local missing=0
for cmd in helm kubectl grep awk; do
if ! command -v "$cmd" >/dev/null 2>&1; then
print_msg "$RED" "❌ 未安装: $cmd"
missing=$((missing+1))
fi
done
if [ "$missing" -gt 0 ]; then
print_msg "$RED" "请先装 helm / kubectl"
exit 1
fi
# 必须有 namespace 上下文
if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
print_msg "$YELLOW" "⚠️ namespace '$NAMESPACE' 不存在, helm install 时会自动创建"
fi
}
# ==================== install ====================
do_install() {
print_step "🚀 helm install ${RELEASE_NAME}"
print_msg "$YELLOW" "Chart: $HELM_CHART_DIR"
print_msg "$YELLOW" "Namespace: $NAMESPACE"
print_msg "$YELLOW" "Values: $VALUES_FILE"
if [ ! -f "$VALUES_FILE" ]; then
print_msg "$RED" "❌ values 文件不存在: $VALUES_FILE"
print_msg "$YELLOW" "复制模板: cp ${VALUES_FILE}.example ${VALUES_FILE}"
exit 1
fi
helm install "$RELEASE_NAME" "$HELM_CHART_DIR" \
--namespace "$NAMESPACE" \
--create-namespace \
--values "$VALUES_FILE" \
--wait \
--timeout 10m
print_step "✅ 安装完成"
do_status
}
# ==================== upgrade ====================
do_upgrade() {
local version="${1:-}"
local reuse_values=false
while [ $# -gt 0 ]; do
case "$1" in
--reuse-values) reuse_values=true; shift ;;
*) shift ;;
esac
done
print_step "🔼 helm upgrade ${RELEASE_NAME}"
print_msg "$YELLOW" "Version: ${version:-latest}"
if [ "$reuse_values" = "true" ]; then
print_msg "$CYAN" "使用 --reuse-values (不重读 values 文件)"
helm upgrade "$RELEASE_NAME" "$HELM_CHART_DIR" \
--namespace "$NAMESPACE" \
--reuse-values \
--wait \
--timeout 10m
else
[ -z "$version" ] && { print_msg "$RED" "❌ 请指定 version 或加 --reuse-values"; exit 1; }
helm upgrade "$RELEASE_NAME" "$HELM_CHART_DIR" \
--namespace "$NAMESPACE" \
--values "$VALUES_FILE" \
--set "global.image.tag=$version" \
--wait \
--timeout 10m
fi
print_step "✅ 升级完成"
do_status
}
# ==================== rollback ====================
do_rollback() {
local revision="${1:-}"
print_step "🔄 helm rollback ${RELEASE_NAME}"
if [ -z "$revision" ]; then
print_msg "$YELLOW" "回滚到上一版"
helm rollback "$RELEASE_NAME" --namespace "$NAMESPACE" --wait
else
print_msg "$YELLOW" "回滚到 revision $revision"
helm rollback "$RELEASE_NAME" "$revision" --namespace "$NAMESPACE" --wait
fi
print_step "✅ 回滚完成"
do_status
}
# ==================== status ====================
do_status() {
print_step "📊 ${RELEASE_NAME} status"
echo ""
print_msg "$CYAN" "── helm history ──"
helm history "$RELEASE_NAME" --namespace "$NAMESPACE" || true
echo ""
print_msg "$CYAN" "── pods ──"
kubectl get pods -n "$NAMESPACE" -o wide
echo ""
print_msg "$CYAN" "── services ──"
kubectl get svc -n "$NAMESPACE"
echo ""
print_msg "$CYAN" "── ingress ──"
kubectl get ingress -n "$NAMESPACE" 2>/dev/null || true
echo ""
# 健康探针短查 (gateway 200 即认为 OK)
gateway_ip=$(kubectl get svc "${RELEASE_NAME}-gateway" -n "$NAMESPACE" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || echo "")
if [ -n "$gateway_ip" ]; then
print_msg "$CYAN" "── gateway health (in-cluster) ──"
kubectl run -n "$NAMESPACE" curl-probe --rm -it --restart=Never \
--image=curlimages/curl --quiet -- \
curl -fsS "http://${gateway_ip}:8080/health" \
&& print_msg "$GREEN" "✅ gateway health OK" \
|| print_msg "$YELLOW" "⚠️ gateway 探针未通过 (可能 startup 中)"
fi
}
# ==================== logs ====================
do_logs() {
local svc="${1:?用法: $0 logs <service>}"
print_step "📜 logs ${svc}"
kubectl logs -n "$NAMESPACE" -l "app.kubernetes.io/component=${svc}" --tail=200 -f
}
# ==================== PostgreSQL 序列同步 ====================
# Hard blocker for traffic switching (CLAUDE.md / 设计文档 §10.3).
# 行为:
# sync-pg [--status]
# 把 db-credentials 里的 DB_PASSWORD + values 里的 tables 灌到 Secret,
# kubectl create -f k8s/jobs/pg-sequence-sync/manual-job.yaml,
# wait until complete, 出错 exit 1。
do_sync_pg() {
local verify_only=false
while [ $# -gt 0 ]; do
case "$1" in
--status) verify_only=true; shift ;;
*) shift ;;
esac
done
print_step "🔧 PostgreSQL 序列同步 (CLAUDE.md hard blocker)"
print_msg "$YELLOW" "Namespace: $NAMESPACE"
if [ "$verify_only" = "true" ]; then
print_msg "$CYAN" "只验证, 不同步"
# 直接连 DB 验证
kubectl run -n "$NAMESPACE" pg-probe --rm -it --restart=Never \
--image=alpine:3.19 --quiet -- sh -c "
apk add --no-cache postgresql16-client > /dev/null
TABLES=$(yq eval '.pgSequenceSync.tables | join(\",\")' "$VALUES_FILE" 2>/dev/null || echo 'assets,users,stars')
for tbl in \$(echo \"\$TABLES\" | tr ',' ' '); do
h=\$(PGPASSWORD=$(kubectl get secret db-credentials -n $NAMESPACE -o jsonpath='{.data.DB_PASSWORD}' | base64 -d) psql -h postgres -U postgres -d topfans -tAc \
\"SELECT last_value >= COALESCE((SELECT MAX(id) FROM ONLY \$tbl), 0) FROM pg_sequences WHERE sequencename = '\$tbl_id_seq';\")
[ \"\$h\" = 't' ] && echo \" ✅ \$tbl healthy\" || echo \" ❌ \$tbl UNHEALTHY\"
done
"
return
fi
# 拉 values 里的 tables, 灌到临时 Secret
print_msg "$YELLOW" "准备 Job..."
local pg_password
pg_password=$(kubectl get secret db-credentials -n "$NAMESPACE" -o jsonpath='{.data.DB_PASSWORD}' | base64 -d)
cat <<EOJOB | kubectl apply -n "$NAMESPACE" -f -
apiVersion: batch/v1
kind: Job
metadata:
name: topfans-pg-sequence-sync-manual-$(date +%s)
namespace: $NAMESPACE
labels:
app.kubernetes.io/component: pg-sequence-sync
app.kubernetes.io/part-of: topfans
app.kubernetes.io/run: manual
spec:
ttlSecondsAfterFinished: 600
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: pg-sync
image: alpine:3.19
command: ["/bin/sh", "-c"]
args:
- |
apk add --no-cache postgresql16-client bash > /dev/null
TABLES="${TABLES:-assets,asset_registry,users,stars,activity_assets,collection_assets,materials,exhibitions,galleries}"
bad=0
for tbl in \$(echo "\$TABLES" | tr ',' ' '); do
tbl=\$(echo "\$tbl" | tr -d ' ')
[ -z "\$tbl" ] && continue
max_id=\$(PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans -tAc "SELECT COALESCE(MAX(id),0) FROM \$tbl;")
[ "\$max_id" = "0" ] && { echo " \$tbl: empty, skip"; continue; }
PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans \
-c "SELECT setval('\${tbl}_id_seq', \$max_id, true);"
h=\$(PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans -tAc \
"SELECT last_value >= \$max_id FROM pg_sequences WHERE sequencename = '\${tbl}_id_seq';")
if [ "\$h" = "t" ]; then
echo " ✅ \$tbl → \$max_id"
else
echo " ❌ \$tbl sync failed"
bad=\$((bad+1))
fi
done
[ "\$bad" -gt 0 ] && exit 1
echo "✅ done"
env:
- name: PGPASSWORD_FORCE
value: "already-baked-into-args"
resources:
requests: { cpu: 100m, memory: 64Mi }
limits: { cpu: 300m, memory: 128Mi }
EOJOB
print_msg "$YELLOW" "等 Job 完成 (timeout 5min)..."
local jobname
jobname=$(kubectl get jobs -n "$NAMESPACE" -l "app.kubernetes.io/run=manual" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}')
kubectl wait --for=condition=complete "job/${jobname}" -n "$NAMESPACE" --timeout=300s || {
print_msg "$RED" "❌ Job 失败"
kubectl logs -n "$NAMESPACE" "job/${jobname}"
exit 1
}
print_msg "$GREEN" "✅ 序列同步成功"
}
# ==================== list / uninstall ====================
do_list() {
helm list -n "$NAMESPACE" -A
}
do_uninstall() {
print_step "⚠️ 卸载 ${RELEASE_NAME}"
echo -e "${RED}⚠️ 真要删除整个 K8s 部署吗? 数据库 (RDS) 不受影响, 这是无状态 Pod.$NC"
read -p "(yes/no): " confirm
[ "$confirm" = "yes" ] || { print_msg "$YELLOW" "已取消"; exit 0; }
helm uninstall "$RELEASE_NAME" -n "$NAMESPACE"
print_msg "$GREEN" "✅ 已卸载"
}
# ==================== main ====================
main() {
if [ $# -eq 0 ]; then
print_help
exit 0
fi
check_prereqs
local cmd="$1"
shift
case "$cmd" in
install) do_install "$@" ;;
upgrade) do_upgrade "$@" ;;
rollback) do_rollback "$@" ;;
status) do_status ;;
logs) do_logs "$@" ;;
sync-pg|pg-sync) do_sync_pg "$@" ;;
list) do_list ;;
uninstall) do_uninstall ;;
-h|--help|help) print_help ;;
*) print_msg "$RED" "未知命令 '$cmd'"; print_help; exit 1 ;;
esac
}
main "$@"