#!/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 [args] ${YELLOW}命令:${NC} ${GREEN}install${NC} 首次部署 (helm install) ${GREEN}upgrade${NC} 升级镜像 (用 version 写所有服务 tag) ${GREEN}upgrade --reuse-values${NC} 升级, 复用 values ${GREEN}rollback${NC} [revision] 回滚 (默认上一版) ${GREEN}status${NC} 看 helm status + pod 状态 ${GREEN}logs${NC} 看 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 }" 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 < /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 "$@"