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