379 lines
13 KiB
Bash
Executable File
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 "$@"
|