498 lines
22 KiB
YAML
498 lines
22 KiB
YAML
name: Deploy Server (SSH + Nginx 蓝绿)
|
||
|
||
# 手动触发:选择部署环境(dev/pro)并输入要部署的镜像 tag
|
||
on:
|
||
workflow_dispatch:
|
||
inputs:
|
||
env:
|
||
description: '部署环境'
|
||
type: choice
|
||
required: true
|
||
options:
|
||
- dev
|
||
- pro
|
||
tag:
|
||
description: '要部署的镜像 Tag(例如:v1.2.7)'
|
||
required: true
|
||
|
||
# 同一环境同一时间只允许一个部署在跑,避免互相覆盖
|
||
concurrency:
|
||
group: deploy-server-${{ github.event.inputs.env }}
|
||
cancel-in-progress: false
|
||
|
||
jobs:
|
||
deploy:
|
||
runs-on: ubuntu-latest
|
||
env:
|
||
# 统一在 env 中注入变量,减少 runner 差异带来的兼容性问题
|
||
DEPLOY_ENV: ${{ github.event.inputs.env }}
|
||
DEPLOY_TAG: ${{ github.event.inputs.tag }}
|
||
|
||
# 镜像名(优先 vars.DOCKER_IMAGE;未配置则步骤里兜底)
|
||
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
|
||
|
||
# 可选:明确 registry(私有仓库用)。未配置会从 DOCKER_IMAGE 推断
|
||
DOCKER_REGISTRY: ${{ vars.DOCKER_REGISTRY }}
|
||
|
||
# SSH:dev 用 secrets,pro 用 vars(按你现有用法)
|
||
SSH_HOST: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_HOST || vars.PRO_SSH_HOST }}
|
||
SSH_USER: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_USER || vars.PRO_SSH_USER }}
|
||
SSH_PORT: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_PORT || vars.PRO_SSH_PORT }}
|
||
SSH_KEY: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY || secrets.PRO_SSH_KEY }}
|
||
# 推荐把私钥做成 base64(单行)存到 Secrets,避免多行变量丢换行
|
||
SSH_KEY_B64: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY_B64 || secrets.PRO_SSH_KEY_B64 }}
|
||
|
||
# Nginx upstream 配置(建议放到 vars)
|
||
NGINX_UPSTREAM_FILE: ${{ vars.NGINX_UPSTREAM_FILE }}
|
||
NGINX_UPSTREAM_NAME: ${{ vars.NGINX_UPSTREAM_NAME }}
|
||
|
||
# 蓝绿端口(宿主机端口,建议放到 vars;未配置则脚本内有默认值)
|
||
BLUE_PORT: ${{ vars.BLUE_PORT }}
|
||
GREEN_PORT: ${{ vars.GREEN_PORT }}
|
||
|
||
# 容器内监听端口(FastAPI 常用 8000;未配置则默认 8000)
|
||
CONTAINER_PORT: ${{ vars.CONTAINER_PORT }}
|
||
|
||
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
||
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
||
|
||
# 定时服务健康检查路径(检查 Redis/Worker/Beat;未配置则默认 /v1/push/scheduler/health)
|
||
SCHEDULER_HEALTHCHECK_PATH: ${{ vars.SCHEDULER_HEALTHCHECK_PATH }}
|
||
|
||
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
||
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
||
|
||
steps:
|
||
- name: 配置 SSH Key(密钥登陆)
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# 镜像名兜底(与 .gitea/workflows/server-build.yml 的默认一致)
|
||
if [[ -z "${DOCKER_IMAGE:-}" ]]; then
|
||
DOCKER_IMAGE="docker.damer.fun/damer/mindfulness-server"
|
||
fi
|
||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV"
|
||
fi
|
||
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITEA_ENV"
|
||
fi
|
||
|
||
mkdir -p ~/.ssh
|
||
chmod 700 ~/.ssh
|
||
|
||
# 从 Secrets 写入私钥(推荐使用 *_SSH_KEY_B64)
|
||
SSH_KEY_PATH="${HOME}/.ssh/id_rsa"
|
||
if [[ -n "${SSH_KEY_B64:-}" ]]; then
|
||
# 兼容两种常见误配置:
|
||
# 1) *_SSH_KEY_B64 里其实粘贴的是“原始私钥”(包含 -----BEGIN ... PRIVATE KEY-----)
|
||
# 2) base64 值在粘贴/保存过程中混入空白或其他无关字符
|
||
if printf '%s' "${SSH_KEY_B64}" | grep -qE 'BEGIN[[:space:]].*PRIVATE[[:space:]]KEY'; then
|
||
echo "提示:检测到 *_SSH_KEY_B64 看起来是原始私钥内容,将按原始私钥写入(建议你改用真正的 base64 单行值)。"
|
||
echo "SSH_KEY_B64 字符数(原始):${#SSH_KEY_B64}"
|
||
printf '%s' "${SSH_KEY_B64}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||
else
|
||
CLEAN_B64="$(printf '%s' "${SSH_KEY_B64}" | tr -d '\r\n\t ')"
|
||
echo "SSH_KEY_B64 字符数(原始/清理后):${#SSH_KEY_B64}/${#CLEAN_B64}"
|
||
if ! printf '%s' "${CLEAN_B64}" | base64 -d -i | tr -d '\r' > "${SSH_KEY_PATH}"; then
|
||
echo "私钥 base64 解码失败:"
|
||
echo "- 请确认你在 Gitea Secrets 配置的 *_SSH_KEY_B64 是“单行 base64 字符串”,不要带引号/前后空格。"
|
||
echo "- 推荐用仓库里的脚本生成并复制:node scripts/ssh-key-to-b64.mjs ~/.ssh/你的私钥 --clipboard"
|
||
exit 1
|
||
fi
|
||
fi
|
||
else
|
||
echo "使用 SSH_KEY(原始私钥)写入。SSH_KEY 字符数:${#SSH_KEY}"
|
||
printf '%s' "${SSH_KEY}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||
fi
|
||
chmod 600 "${SSH_KEY_PATH}"
|
||
|
||
# 自检(不输出私钥内容,仅输出元信息,方便定位是否写入了错误文件/被截断)
|
||
echo "SSH_KEY_PATH=${SSH_KEY_PATH}"
|
||
KEY_BYTES="$(wc -c < "${SSH_KEY_PATH}" | tr -d ' ')"
|
||
KEY_LINES="$(wc -l < "${SSH_KEY_PATH}" | tr -d ' ')"
|
||
echo "SSH key 字节数:${KEY_BYTES}"
|
||
echo "SSH key 行数:${KEY_LINES}"
|
||
echo "SSH key 首行:$(head -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
|
||
echo "SSH key 末行:$(tail -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
|
||
if command -v stat >/dev/null 2>&1; then
|
||
# Ubuntu runner: stat -c;不同系统做兼容
|
||
if stat -c '%a %U %G %n' "${SSH_KEY_PATH}" >/dev/null 2>&1; then
|
||
echo "SSH key 权限:$(stat -c '%a %U %G %n' "${SSH_KEY_PATH}")"
|
||
else
|
||
echo "SSH key 权限:$(stat -f '%Lp %Su %Sg %N' "${SSH_KEY_PATH}" 2>/dev/null || true)"
|
||
fi
|
||
fi
|
||
if command -v file >/dev/null 2>&1; then
|
||
echo "file(1) 识别:$(file "${SSH_KEY_PATH}")"
|
||
fi
|
||
if command -v sha256sum >/dev/null 2>&1; then
|
||
echo "SSH key sha256(前12位):$(sha256sum "${SSH_KEY_PATH}" | awk '{print substr($1,1,12)}')"
|
||
fi
|
||
echo "ssh 版本:$(ssh -V 2>&1 || true)"
|
||
echo "ssh-keygen 版本:$(ssh-keygen -V 2>&1 || true)"
|
||
echo "openssl 版本:$(openssl version 2>&1 || true)"
|
||
|
||
# 进一步校验:OpenSSH 私钥中间的 base64 块是否可解码(不输出内容)
|
||
if grep -q '^-----BEGIN OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}" && grep -q '^-----END OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}"; then
|
||
# 注意:不要用变量名 in(是 awk 关键字,部分实现会报语法错)
|
||
OPENSSH_B64_LEN="$(
|
||
awk '
|
||
BEGIN{in_block=0; n=0}
|
||
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
|
||
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
|
||
in_block==1{gsub(/\r/,""); n+=length($0)}
|
||
END{print n}
|
||
' "${SSH_KEY_PATH}" 2>/dev/null || true
|
||
)"
|
||
if [[ -n "${OPENSSH_B64_LEN:-}" ]]; then
|
||
echo "OpenSSH base64 块字符数(合计):${OPENSSH_B64_LEN}"
|
||
fi
|
||
|
||
# 该校验仅用于提示,不应阻断部署流程
|
||
if ! awk '
|
||
BEGIN{in_block=0}
|
||
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
|
||
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
|
||
in_block==1{gsub(/\r/,""); print}
|
||
' "${SSH_KEY_PATH}" 2>/dev/null | tr -d '\n' | base64 -d >/dev/null 2>&1; then
|
||
echo "提示:OpenSSH 私钥的 base64 块无法解码(疑似内容被截断/损坏),将继续执行 ssh-keygen 校验定位。"
|
||
fi
|
||
fi
|
||
|
||
# 额外信息:ssh-keygen -lf 的报错(不输出私钥内容)
|
||
if ! ssh-keygen -lf "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_fingerprint.err; then
|
||
echo "ssh-keygen -lf 报错:"
|
||
cat ~/.ssh/ssh_key_fingerprint.err || true
|
||
fi
|
||
|
||
# 快速校验私钥是否可解析(不会输出私钥内容)
|
||
if ! ssh-keygen -y -f "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_check.err; then
|
||
echo "SSH 私钥无法解析。下面是 ssh-keygen 的报错(不包含私钥内容):"
|
||
cat ~/.ssh/ssh_key_check.err || true
|
||
echo
|
||
echo "常见原因:"
|
||
echo "- 你填的是公钥(.pub),不是私钥"
|
||
echo "- 私钥带口令(CI 无法交互输入 passphrase)"
|
||
echo "- 内容复制丢换行/被截断/不是正确的 base64"
|
||
echo
|
||
echo "推荐:生成一把“无口令”的部署专用私钥,并用 base64 存储到 Secrets(*_SSH_KEY_B64)。"
|
||
exit 1
|
||
fi
|
||
|
||
SSH_PORT="${SSH_PORT:-22}"
|
||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||
echo "SSH_PORT=${SSH_PORT}" >> "$GITHUB_ENV"
|
||
fi
|
||
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||
echo "SSH_PORT=${SSH_PORT}" >> "$GITEA_ENV"
|
||
fi
|
||
|
||
# 预写 known_hosts,避免交互
|
||
ssh-keyscan -p "${SSH_PORT}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||
|
||
- name: 验证 SSH 连接(快速定位公钥/用户/端口问题)
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
SSH_PORT="${SSH_PORT:-22}"
|
||
ssh -p "${SSH_PORT}" \
|
||
-i ~/.ssh/id_rsa \
|
||
-o StrictHostKeyChecking=yes \
|
||
-o BatchMode=yes \
|
||
-o IdentitiesOnly=yes \
|
||
"${SSH_USER}@${SSH_HOST}" 'echo "SSH_OK $(whoami)@$(hostname)"'
|
||
|
||
- name: 远程登录 Docker Registry(如需私有镜像)
|
||
shell: bash
|
||
env:
|
||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# 如果镜像是公开的,可不配 DOCKER_USERNAME/DOCKER_TOKEN;此步骤会自动跳过
|
||
if [[ -z "${DOCKER_USERNAME:-}" || -z "${DOCKER_TOKEN:-}" ]]; then
|
||
echo "未配置 DOCKER_USERNAME/DOCKER_TOKEN,跳过远程 docker login。"
|
||
exit 0
|
||
fi
|
||
|
||
SSH_PORT="${SSH_PORT:-22}"
|
||
|
||
# 推断 registry:优先使用 DOCKER_REGISTRY;否则从 DOCKER_IMAGE 的第一个段推断
|
||
REG="${DOCKER_REGISTRY:-}"
|
||
if [[ -z "${REG}" ]]; then
|
||
FIRST_SEG="${DOCKER_IMAGE%%/*}"
|
||
if [[ "${FIRST_SEG}" == *.* || "${FIRST_SEG}" == *:* || "${FIRST_SEG}" == "localhost" ]]; then
|
||
REG="${FIRST_SEG}"
|
||
fi
|
||
fi
|
||
if [[ -z "${REG}" ]]; then
|
||
echo "无法推断 registry(看起来像 Docker Hub 公有镜像),跳过 docker login。"
|
||
exit 0
|
||
fi
|
||
|
||
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes \
|
||
"${SSH_USER}@${SSH_HOST}" bash -s -- "${DOCKER_TOKEN}" "${DOCKER_USERNAME}" "${REG}" <<'REMOTE'
|
||
set -euo pipefail
|
||
|
||
TOKEN="$1"
|
||
USERNAME="$2"
|
||
REGISTRY="$3"
|
||
|
||
SUDO=""
|
||
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
|
||
SUDO="sudo"
|
||
fi
|
||
|
||
printf '%s' "$TOKEN" | ${SUDO} docker login "${REGISTRY}" -u "$USERNAME" --password-stdin
|
||
REMOTE
|
||
|
||
- name: SSH 部署(Nginx 蓝绿切换)
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
SSH_PORT="${SSH_PORT:-22}"
|
||
NGINX_UPSTREAM_NAME="${NGINX_UPSTREAM_NAME:-mindfulness_backend}"
|
||
NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/api.damer.fun.conf}"
|
||
BLUE_PORT="${BLUE_PORT:-8001}"
|
||
GREEN_PORT="${GREEN_PORT:-8002}"
|
||
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
||
SCHEDULER_HEALTHCHECK_PATH="${SCHEDULER_HEALTHCHECK_PATH:-/v1/push/scheduler/health}"
|
||
|
||
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
||
|
||
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${SCHEDULER_HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
||
set -euo pipefail
|
||
|
||
IMAGE="$1"
|
||
TAG="$2"
|
||
UPSTREAM_FILE="$3"
|
||
UPSTREAM_NAME="$4"
|
||
BLUE_PORT="$5"
|
||
GREEN_PORT="$6"
|
||
CONTAINER_PORT="$7"
|
||
HEALTHCHECK_PATH="$8"
|
||
SCHEDULER_HEALTHCHECK_PATH="$9"
|
||
REMOTE_ENV_FILE="${10}"
|
||
|
||
APP_DIR="/opt/mindfulness-server"
|
||
ACTIVE_FILE="${APP_DIR}/active_color"
|
||
mkdir -p "${APP_DIR}"
|
||
|
||
# 判断 sudo(如果非 root 且存在 sudo,则使用 sudo)
|
||
SUDO=""
|
||
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
|
||
SUDO="sudo"
|
||
fi
|
||
|
||
ACTIVE_COLOR="blue"
|
||
if [[ -f "${ACTIVE_FILE}" ]]; then
|
||
ACTIVE_COLOR="$(cat "${ACTIVE_FILE}" || echo blue)"
|
||
fi
|
||
|
||
if [[ "${ACTIVE_COLOR}" == "blue" ]]; then
|
||
NEW_COLOR="green"
|
||
NEW_PORT="${GREEN_PORT}"
|
||
OLD_COLOR="blue"
|
||
OLD_PORT="${BLUE_PORT}"
|
||
else
|
||
NEW_COLOR="blue"
|
||
NEW_PORT="${BLUE_PORT}"
|
||
OLD_COLOR="green"
|
||
OLD_PORT="${GREEN_PORT}"
|
||
fi
|
||
|
||
echo "当前在线:${ACTIVE_COLOR}(${OLD_PORT}),准备发布:${NEW_COLOR}(${NEW_PORT})"
|
||
|
||
# 拉取镜像
|
||
${SUDO} docker pull "${IMAGE}:${TAG}"
|
||
|
||
# 启动新颜色容器(API + Worker + Beat)
|
||
API_NAME="mindfulness-server-api-${NEW_COLOR}"
|
||
WORKER_NAME="mindfulness-server-worker-${NEW_COLOR}"
|
||
BEAT_NAME="mindfulness-server-beat-${NEW_COLOR}"
|
||
|
||
# 兼容旧命名:之前可能只有一个 mindfulness-server-blue/green
|
||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
||
|
||
${SUDO} docker rm -f "${API_NAME}" >/dev/null 2>&1 || true
|
||
${SUDO} docker rm -f "${WORKER_NAME}" >/dev/null 2>&1 || true
|
||
${SUDO} docker rm -f "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
|
||
ENV_FILE_ARGS=()
|
||
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
||
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
||
echo "将使用远端 env 文件:${REMOTE_ENV_FILE}"
|
||
elif [[ -n "${REMOTE_ENV_FILE}" ]]; then
|
||
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
||
fi
|
||
|
||
# API(对外暴露端口,仅该容器参与蓝绿切流)
|
||
${SUDO} docker run -d \
|
||
--name "${API_NAME}" \
|
||
--restart=always \
|
||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||
"${ENV_FILE_ARGS[@]}" \
|
||
-e START_API=1 -e START_WORKER=0 -e START_BEAT=0 \
|
||
"${IMAGE}:${TAG}"
|
||
|
||
# Worker(处理异步/ETA 任务,不暴露端口)
|
||
${SUDO} docker run -d \
|
||
--name "${WORKER_NAME}" \
|
||
--restart=always \
|
||
"${ENV_FILE_ARGS[@]}" \
|
||
-e START_API=0 -e START_WORKER=1 -e START_BEAT=0 \
|
||
"${IMAGE}:${TAG}"
|
||
|
||
# Beat(定时调度,不暴露端口)
|
||
${SUDO} docker run -d \
|
||
--name "${BEAT_NAME}" \
|
||
--restart=always \
|
||
"${ENV_FILE_ARGS[@]}" \
|
||
-e START_API=0 -e START_WORKER=0 -e START_BEAT=1 \
|
||
"${IMAGE}:${TAG}"
|
||
|
||
# 健康检查
|
||
if [[ "${HEALTHCHECK_PATH}" != /* ]]; then
|
||
HEALTHCHECK_PATH="/${HEALTHCHECK_PATH}"
|
||
fi
|
||
HEALTH_URL="http://127.0.0.1:${NEW_PORT}${HEALTHCHECK_PATH}"
|
||
echo "健康检查:${HEALTH_URL}"
|
||
|
||
for i in $(seq 1 30); do
|
||
if command -v curl >/dev/null 2>&1; then
|
||
if curl -fsS "${HEALTH_URL}" >/dev/null; then
|
||
echo "健康检查通过"
|
||
break
|
||
fi
|
||
elif command -v wget >/dev/null 2>&1; then
|
||
if wget -q -O /dev/null "${HEALTH_URL}"; then
|
||
echo "健康检查通过"
|
||
break
|
||
fi
|
||
else
|
||
echo "远端缺少 curl/wget,跳过 HTTP 健康检查"
|
||
break
|
||
fi
|
||
|
||
if [[ "$i" -eq 30 ]]; then
|
||
echo "健康检查失败:新版本未就绪,回滚并退出"
|
||
${SUDO} docker logs --tail 200 "${API_NAME}" || true
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
sleep 2
|
||
done
|
||
|
||
# 定时服务健康检查(确保 Redis/Worker/Beat 都 OK,避免“接口正常但定时任务没跑”)
|
||
if [[ "${SCHEDULER_HEALTHCHECK_PATH}" != /* ]]; then
|
||
SCHEDULER_HEALTHCHECK_PATH="/${SCHEDULER_HEALTHCHECK_PATH}"
|
||
fi
|
||
SCHED_URL="http://127.0.0.1:${NEW_PORT}${SCHEDULER_HEALTHCHECK_PATH}"
|
||
echo "定时服务健康检查:${SCHED_URL}"
|
||
|
||
# Beat 心跳是按分钟刷新,这里最多等 90 秒(45*2s)
|
||
for i in $(seq 1 45); do
|
||
RES="$(curl -fsS "${SCHED_URL}" 2>/dev/null || true)"
|
||
if [[ -n "${RES}" ]] \
|
||
&& echo "${RES}" | grep -q '"redis":{"ok":true' \
|
||
&& echo "${RES}" | grep -q '"worker":{"ok":true' \
|
||
&& echo "${RES}" | grep -q '"beat":{"ok":true' ; then
|
||
echo "定时服务健康检查通过:${RES}"
|
||
break
|
||
fi
|
||
|
||
if [[ "$i" -eq 45 ]]; then
|
||
echo "定时服务健康检查失败:${RES}"
|
||
echo "Worker/Beat 日志(各 120 行):"
|
||
${SUDO} docker logs --tail 120 "${WORKER_NAME}" || true
|
||
${SUDO} docker logs --tail 120 "${BEAT_NAME}" || true
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
sleep 2
|
||
done
|
||
|
||
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
||
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
||
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
|
||
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
||
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
|
||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
|
||
# 备份(回滚用)
|
||
BACKUP_FILE="${UPSTREAM_FILE}.bak.$(date +%s)"
|
||
${SUDO} cp -f "${UPSTREAM_FILE}" "${BACKUP_FILE}"
|
||
|
||
echo "切换 upstream:${UPSTREAM_NAME}(${OLD_PORT} -> ${NEW_PORT})通过 backup 切换"
|
||
|
||
TMP_FILE="$(mktemp)"
|
||
${SUDO} awk -v name="${UPSTREAM_NAME}" -v old="${OLD_PORT}" -v new="${NEW_PORT}" '
|
||
BEGIN { in_up = 0 }
|
||
$0 ~ ("^[[:space:]]*upstream[[:space:]]+" name "[[:space:]]*\\{[[:space:]]*$") { in_up = 1 }
|
||
in_up == 1 {
|
||
# 新端口:主(去掉 backup)
|
||
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" new)) {
|
||
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
|
||
}
|
||
# 旧端口:备(确保有 backup;)
|
||
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" old)) {
|
||
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
|
||
sub(/;[[:space:]]*$/, " backup;")
|
||
}
|
||
}
|
||
in_up == 1 && $0 ~ /^[[:space:]]*\}[[:space:]]*$/ { in_up = 0 }
|
||
{ print }
|
||
' "${UPSTREAM_FILE}" > "${TMP_FILE}"
|
||
|
||
${SUDO} cp -f "${TMP_FILE}" "${UPSTREAM_FILE}"
|
||
rm -f "${TMP_FILE}"
|
||
|
||
# 校验并 reload nginx(失败则回滚并退出)
|
||
if ${SUDO} nginx -t; then
|
||
${SUDO} nginx -s reload
|
||
else
|
||
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
||
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
||
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
||
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||
exit 1
|
||
fi
|
||
|
||
# 记录当前在线颜色
|
||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||
|
||
# 下线旧容器(切流后再停旧的)
|
||
OLD_API_NAME="mindfulness-server-api-${OLD_COLOR}"
|
||
OLD_WORKER_NAME="mindfulness-server-worker-${OLD_COLOR}"
|
||
OLD_BEAT_NAME="mindfulness-server-beat-${OLD_COLOR}"
|
||
${SUDO} docker rm -f "${OLD_API_NAME}" "${OLD_WORKER_NAME}" "${OLD_BEAT_NAME}" >/dev/null 2>&1 || true
|
||
# 兼容旧命名
|
||
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||
|
||
echo "部署完成:${NEW_COLOR} 已上线"
|
||
REMOTE
|
||
|