404 lines
17 KiB
YAML
404 lines
17 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 }}
|
||
|
||
# Runner 宿主机私钥模式(推荐放宿主机时使用)
|
||
# - 配置 vars.RUNNER_SSH_KEY_PATH 后将优先使用该私钥文件
|
||
# - 例:/home/runner/.ssh/deploy_key 或 /root/.ssh/deploy_key
|
||
RUNNER_SSH_KEY_PATH: ${{ vars.RUNNER_SSH_KEY_PATH }}
|
||
|
||
# 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 }}
|
||
|
||
# 可选:远端 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
|
||
|
||
# 优先:使用 Runner 宿主机上的私钥文件(避免在 CI 中传输私钥)
|
||
SSH_KEY_PATH=""
|
||
if [[ -n "${RUNNER_SSH_KEY_PATH:-}" ]]; then
|
||
SSH_KEY_PATH="${RUNNER_SSH_KEY_PATH}"
|
||
if [[ ! -f "${SSH_KEY_PATH}" ]]; then
|
||
echo "未找到 Runner 宿主机私钥文件:${SSH_KEY_PATH}"
|
||
echo "请在 Runner 宿主机上放置私钥,并确保该路径对 Runner 执行用户可读。"
|
||
exit 1
|
||
fi
|
||
chmod 600 "${SSH_KEY_PATH}" || true
|
||
else
|
||
# 兜底:从 Secrets 写入私钥(不推荐,但兼容)
|
||
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 单行值)。"
|
||
printf '%s' "${SSH_KEY_B64}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||
else
|
||
CLEAN_B64="$(printf '%s' "${SSH_KEY_B64}" | tr -d '\r\n\t ')"
|
||
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
|
||
printf '%s' "${SSH_KEY}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||
fi
|
||
chmod 600 "${SSH_KEY_PATH}"
|
||
fi
|
||
|
||
# 自检(不输出私钥内容,仅输出元信息,方便定位是否写入了错误文件/被截断)
|
||
echo "SSH_KEY_PATH=${SSH_KEY_PATH}"
|
||
echo "SSH key 字节数:$(wc -c < "${SSH_KEY_PATH}" | tr -d ' ')"
|
||
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 sha256sum >/dev/null 2>&1; then
|
||
echo "SSH key sha256(前12位):$(sha256sum "${SSH_KEY_PATH}" | awk '{print substr($1,1,12)}')"
|
||
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
|
||
|
||
# 供后续步骤统一使用
|
||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||
echo "SSH_KEY_PATH=${SSH_KEY_PATH}" >> "$GITHUB_ENV"
|
||
fi
|
||
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||
echo "SSH_KEY_PATH=${SSH_KEY_PATH}" >> "$GITEA_ENV"
|
||
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_KEY_PATH}" \
|
||
-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_KEY_PATH}" -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/mindfulness-server.conf}"
|
||
BLUE_PORT="${BLUE_PORT:-8001}"
|
||
GREEN_PORT="${GREEN_PORT:-8002}"
|
||
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
||
|
||
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
||
|
||
ssh -p "${SSH_PORT}" -i "${SSH_KEY_PATH}" -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}" "${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"
|
||
REMOTE_ENV_FILE="$9"
|
||
|
||
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}"
|
||
|
||
# 启动新颜色容器
|
||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/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
|
||
|
||
${SUDO} docker run -d \
|
||
--name "mindfulness-server-${NEW_COLOR}" \
|
||
--restart=always \
|
||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||
"${ENV_FILE_ARGS[@]}" \
|
||
"${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 "mindfulness-server-${NEW_COLOR}" || true
|
||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || 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 "mindfulness-server-${NEW_COLOR}" || 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 "mindfulness-server-${NEW_COLOR}" || 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 "mindfulness-server-${NEW_COLOR}" || 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 "mindfulness-server-${NEW_COLOR}" || 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 "mindfulness-server-${NEW_COLOR}" || true
|
||
exit 1
|
||
fi
|
||
|
||
# 记录当前在线颜色
|
||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||
|
||
# 下线旧容器(切流后再停旧的)
|
||
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||
|
||
echo "部署完成:${NEW_COLOR} 已上线"
|
||
REMOTE
|
||
|