#!/usr/bin/env bash # ============================================================================ # opsxsql 一键部署(参照 opsx-loki 最新 install.sh) # # 自动适配:阿里云 ACK / 腾讯云 TKE / AWS EKS / 华为云 CCE / GCP GKE / Azure AKS / K3s / 自建 # 幂等:重复执行不覆盖密钥、不重建数据卷 # # 用法: # bash kubernetes/install.sh # 自动探测,默认镜像 opsxsql:latest # IMAGE=registry.example.com/opsxsql:v1 bash kubernetes/install.sh # WITH_NODEPORT=1 bash kubernetes/install.sh # 同时开 NodePort(:30900) # bash kubernetes/install.sh --nodeport # 等价于上一行 # bash kubernetes/install.sh --ingress opsxsql.example.com # 通用 nginx Ingress # STORAGE_CLASS=local-path NS=opsxsql PVC_SIZE=20Gi bash kubernetes/install.sh # ============================================================================ set -euo pipefail NS="${NS:-opsxsql}" IMAGE="${IMAGE:-opsxsql:latest}" SECRET_NAME="opsxsql-secrets" WITH_NODEPORT="${WITH_NODEPORT:-0}" INGRESS_HOST="" STORAGE_CLASS="${STORAGE_CLASS:-}" PVC_SIZE="${PVC_SIZE:-10Gi}" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m✅\33[0m %s\n' "$*"; } warn() { printf '\033[1;33m⚠️ \33[0m %s\n' "$*"; } die() { printf '\033[1;31m❌\33[0m %s\n' "$*" >&2; exit 1; } # ---- 参数解析(兼容 flags 与环境变量) ---- while [[ $# -gt 0 ]]; do case "$1" in --nodeport) WITH_NODEPORT=1; shift ;; --ingress) INGRESS_HOST="${2:?--ingress 需要域名参数}"; shift 2 ;; -h|--help) sed -n '2,17p' "$0"; exit 0 ;; *) die "未知参数: $1(看 --help)" ;; esac done # ============================================================================ # 0) 前置检查 # ============================================================================ command -v kubectl >/dev/null 2>&1 || die "未找到 kubectl,请先安装" kubectl version --request-timeout=5s >/dev/null 2>&1 || die "kubectl 无法连接集群,请检查 kubeconfig" # ============================================================================ # 0.1) 云平台识别(信息展示 + 失败诊断用) # ============================================================================ detect_cloud() { local pid="" pid=$(kubectl get nodes -o jsonpath='{.items[0].spec.providerID}' 2>/dev/null || true) case "${pid}" in qcloud://*) echo "腾讯云 TKE" ;; aws://*) echo "AWS EKS" ;; aliyun://*) echo "阿里云 ACK" ;; huaweicloud*|huawei*) echo "华为云 CCE" ;; gce://*) echo "GCP GKE" ;; azure://*) echo "Azure AKS" ;; k3s://*) echo "K3s(自建)" ;; *) echo "自建 / 其他 K8s" ;; esac } # ============================================================================ # 0.2) StorageClass 探测 # 优先级:显式 STORAGE_CLASS > default 注解 > 唯一 SC > 失败 # 结果写入全局 RESOLVED_SC / SC_ERROR(不要用 $() 调用,子 shell 赋值不回传) # ============================================================================ RESOLVED_SC="" SC_ERROR="" resolve_storage_class() { local sc="" all="" count=0 RESOLVED_SC=""; SC_ERROR="" if [ -n "${STORAGE_CLASS}" ]; then if ! kubectl get sc "${STORAGE_CLASS}" >/dev/null 2>&1; then SC_ERROR="指定的 StorageClass '${STORAGE_CLASS}' 不存在。现有:$(kubectl get sc -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" return 1 fi RESOLVED_SC="${STORAGE_CLASS}"; return 0 fi sc=$(kubectl get sc -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}' 2>/dev/null | head -1 || true) if [ -n "${sc}" ]; then RESOLVED_SC="${sc}"; return 0; fi all=$(kubectl get sc -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) count=$(printf '%s' "${all}" | grep -c . || true) if [ "${count}" = "1" ]; then RESOLVED_SC="${all}"; return 0; fi return 1 } # ============================================================================ # 0.3) 自建集群兜底:无任何 StorageClass 时创建「静态 local PV」(零外部依赖) # hostPath + DirectoryOrCreate + nodeAffinity 锁节点 + WaitForFirstConsumer + Retain # ============================================================================ LOCAL_SC_NAME="opsxsql-local" LOCAL_PV_NAME="opsxsql-data-pv" LOCAL_PV_SIZE="${PVC_SIZE}" LOCAL_PV_PATH="${PV_PATH:-/var/lib/opsxsql-data}" pick_local_node() { if [ -n "${NODE_NAME:-}" ]; then echo "${NODE_NAME}"; return 0; fi kubectl get nodes --no-headers \ -o custom-columns=NAME:.metadata.name,READY:.status.conditions[-1].status 2>/dev/null \ | awk '$2=="True"{print $1; exit}' } create_local_storage() { local node node="$(pick_local_node)" [ -n "${node}" ] || { warn "找不到 Ready 节点,无法创建本地存储"; return 1; } info "创建节点本地存储:节点=${node} 路径=${LOCAL_PV_PATH} 容量=${LOCAL_PV_SIZE}" cat </dev/null apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: ${LOCAL_SC_NAME} provisioner: kubernetes.io/no-provisioner volumeBindingMode: WaitForFirstConsumer reclaimPolicy: Retain EOF cat </dev/null apiVersion: v1 kind: PersistentVolume metadata: name: ${LOCAL_PV_NAME} labels: app: opsxsql spec: capacity: storage: ${LOCAL_PV_SIZE} accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: ${LOCAL_SC_NAME} hostPath: path: ${LOCAL_PV_PATH} type: DirectoryOrCreate nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - ${node} EOF ok "已创建 StorageClass ${LOCAL_SC_NAME} + PV ${LOCAL_PV_NAME}(节点 ${node})" warn "注意:数据固定在该节点 ${LOCAL_PV_PATH},Pod 不会漂移到其他节点" RESOLVED_SC="${LOCAL_SC_NAME}" return 0 } CLOUD="$(detect_cloud)" info "opsxsql 部署开始(namespace: ${NS},镜像 ${IMAGE})" info "集群: $(kubectl config current-context 2>/dev/null || echo unknown)" info "平台: ${CLOUD}" # LOCAL_STORAGE 三态:auto(默认,无 SC 时自动建) / off(只提示) / force(强制建) LOCAL_STORAGE="${LOCAL_STORAGE:-auto}" if [ "${LOCAL_STORAGE}" = "force" ]; then warn "LOCAL_STORAGE=force:强制使用节点本地存储" create_local_storage || die "创建节点本地存储失败" elif ! resolve_storage_class; then if [ -n "${SC_ERROR}" ]; then die "${SC_ERROR}" fi echo "" if [ "${LOCAL_STORAGE}" = "auto" ]; then warn "集群没有可用 StorageClass(自建集群常见)→ 自动启用节点本地存储" create_local_storage || die "创建节点本地存储失败" else warn "未找到可用的 StorageClass,且 LOCAL_STORAGE=off" echo "" echo " 现有 StorageClass:" kubectl get sc 2>/dev/null | sed 's/^/ /' || echo " (无)" echo "" echo " 解决办法(选一个):" echo " 1) 手动指定(推荐,若已有可用 SC):" echo " STORAGE_CLASS= bash kubernetes/install.sh" echo " 2) 标记某个 SC 为默认:" echo " kubectl patch sc -p '{\"metadata\":{\"annotations\":{\"storageclass.kubernetes.io/is-default-class\":\"true\"}}}'" echo " 3) 自建集群安装 local-path-provisioner:" echo " kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml" echo " STORAGE_CLASS=local-path bash kubernetes/install.sh" echo "" die "StorageClass 未就绪,部署中止(避免 PVC 永久 Pending)" fi else ok "StorageClass: ${RESOLVED_SC}" fi # ============================================================================ # 1) 命名空间 # ============================================================================ cat </dev/null apiVersion: v1 kind: Namespace metadata: name: ${NS} EOF # ============================================================================ # 2) Secret(幂等:已存在则跳过,绝不覆盖 —— SECRET_KEY 换了已加密数据不可解) # ============================================================================ rand_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 elif [ -c /dev/urandom ]; then head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' else die "无法生成随机密钥(缺 openssl 且无 /dev/urandom)" fi } if kubectl -n "${NS}" get secret "${SECRET_NAME}" >/dev/null 2>&1; then info "Secret ${SECRET_NAME} 已存在,跳过(不覆盖现有密钥)" else info "生成随机密钥 SECRET_KEY / JWT_SECRET(丢失则已加密的实例/LDAP 密码无法解密,请自行备份)" kubectl -n "${NS}" create secret generic "${SECRET_NAME}" \ --from-literal=SECRET_KEY="$(rand_hex)" \ --from-literal=JWT_SECRET="$(rand_hex)" >/dev/null ok "Secret 已创建" fi # ============================================================================ # 3) 数据卷(渲染注入探测到的 StorageClass 与容量) # ============================================================================ RENDER_DIR="$(mktemp -d)" trap 'rm -rf "${RENDER_DIR}"' EXIT awk -v sc="${RESOLVED_SC}" -v size="${PVC_SIZE}" ' /^spec:/ && !done { print; print " storageClassName: " sc; done=1; next } /storage:/ && !sized { $0=" storage: " size; sized=1 } { print } ' "${DIR}/pvc.yaml" > "${RENDER_DIR}/pvc.yaml" kubectl apply -f "${RENDER_DIR}/pvc.yaml" # ============================================================================ # 4) 工作负载 + Service(镜像渲染进 deployment) # ============================================================================ sed "s|image: opsxsql:latest.*|image: ${IMAGE}|" "${DIR}/deployment.yaml" > "${RENDER_DIR}/deployment.yaml" kubectl apply -f "${RENDER_DIR}/deployment.yaml" kubectl apply -f "${DIR}/service.yaml" if [ "${WITH_NODEPORT}" = "1" ]; then kubectl apply -f "${DIR}/service-nodeport.yaml" ok "NodePort Service 已创建(:30900)" fi if [ -n "${INGRESS_HOST}" ]; then sed "s|host: opsxsql.example.com.*|host: ${INGRESS_HOST}|" "${DIR}/ingress.yaml" > "${RENDER_DIR}/ingress.yaml" kubectl apply -f "${RENDER_DIR}/ingress.yaml" ok "Ingress 已创建(${INGRESS_HOST},nginx class;其他云参照 ${DIR}/examples/)" fi # ============================================================================ # 5) 等待就绪(失败时按云诊断) # ============================================================================ info "等待 Deployment 就绪(最多 180 秒)..." if ! kubectl -n "${NS}" rollout status deployment/opsxsql --timeout=180s; then echo "" warn "部署未在 180 秒内就绪,开始诊断:" echo "" kubectl -n "${NS}" get po -l app.kubernetes.io/name=opsxsql -o wide 2>/dev/null | sed 's/^/ /' REASON=$(kubectl -n "${NS}" get po -l app.kubernetes.io/name=opsxsql -o jsonpath='{.items[0].status.containerStatuses[0].state.waiting.reason}' 2>/dev/null || true) echo "" case "${REASON}" in ImagePullBackOff|ErrImagePull) warn "镜像拉取失败(${REASON})" echo " → 当前镜像: ${IMAGE}" echo " → 该节点拉不到该仓库。改为目标云可拉的仓库后重跑:" echo " 腾讯云 TCR : ccr.ccs.tencentyun.com//opsxsql:latest" echo " 阿里云 ACR : registry.cn-hangzhou.aliyuncs.com//opsxsql:latest" echo " 华为云 SWR : swr.cn-north-4.myhuaweicloud.com//opsxsql:latest" echo " 自建 Harbor: harbor.your.domain/library/opsxsql:latest" echo " 示例: IMAGE=registry.cn-hangzhou.aliyuncs.com//opsxsql:latest bash kubernetes/install.sh" ;; CreateContainerConfigError) warn "容器配置缺失(通常是 Secret 不存在)" echo " → 检查: kubectl -n ${NS} get secret ${SECRET_NAME}" ;; "") PVC_PHASE=$(kubectl -n "${NS}" get pvc opsxsql-data -o jsonpath='{.status.phase}' 2>/dev/null || echo unknown) if [ "${PVC_PHASE}" != "Bound" ]; then warn "PVC 未绑定(phase=${PVC_PHASE})" echo " → 检查 StorageClass: kubectl -n ${NS} describe pvc opsxsql-data" echo " → ${CLOUD} 建议的 SC:" case "${CLOUD}" in 腾讯云*) echo " cbs" ;; AWS*) echo " gp3 / gp2" ;; 阿里云*) echo " alicloud-disk-essd" ;; 华为云*) echo " csi-disk" ;; *) echo " local-path(需装 local-path-provisioner)" ;; esac fi ;; esac echo "" kubectl -n "${NS}" describe po -l app.kubernetes.io/name=opsxsql 2>/dev/null | tail -15 | sed 's/^/ /' die "部署未完成,请按上面提示处理" fi ok "Deployment 已就绪" # ============================================================================ # 6) 输出访问信息 # ============================================================================ POD=$(kubectl -n "${NS}" get po -l app.kubernetes.io/name=opsxsql -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "-") POD_IP=$(kubectl -n "${NS}" get po -l app.kubernetes.io/name=opsxsql -o jsonpath='{.items[0].status.podIP}' 2>/dev/null || echo "-") NODE=$(kubectl -n "${NS}" get po -l app.kubernetes.io/name=opsxsql -o jsonpath='{.items[0].spec.nodeName}' 2>/dev/null || echo "-") echo "" echo "════════════════════════════════════════════════════════" ok "opsxsql 部署完成" echo "════════════════════════════════════════════════════════" echo " 平台 : ${CLOUD}" echo " Namespace : ${NS}" echo " 镜像 : ${IMAGE}" echo " 存储类 : ${RESOLVED_SC}" echo " Pod : ${POD} (node=${NODE} ip=${POD_IP})" echo " Service : opsxsql.${NS}.svc.cluster.local:80" [ "${WITH_NODEPORT}" = "1" ] && echo " NodePort : <任意节点IP>:30900" [ -n "${INGRESS_HOST}" ] && echo " Ingress : http://${INGRESS_HOST}" echo "" echo " 初始账号 : admin / admin123 ← 首次登录请立即改密" echo "" echo " 本地访问 : kubectl -n ${NS} port-forward svc/opsxsql 9000:80" echo " 浏览器打开 http://localhost:9000" echo "" echo " 数据备份 : kubectl -n ${NS} exec deploy/opsxsql -- sqlite3 /app/data/sqlite3.db '.backup /app/data/backup.db'" echo " 配 Ingress(可选):参照 ${DIR}/examples/ 按云选模板" echo "════════════════════════════════════════════════════════"