Commit 8a0972f4 authored by xuchentao's avatar xuchentao

ci: make release deployment idempotent

parent 0f249b2a
Pipeline #410 failed with stages
in 4 seconds
# 塑美俏官网 · GitLab CI/CD 安全部署
# 流程:准备 release → 安装依赖 → 安全部署 → 配置 Nginx → 验证与清理
# 流程:幂等部署 → 配置 Nginx → 验证与清理
# 触发:push 到 main 分支
#
# ⚠️ GitLab 版本:11.7(较旧)
......@@ -25,58 +25,15 @@ variables:
PUBLIC_HOST: "101.126.10.129"
stages:
- prepare
- install
- deploy
- nginx
- verify
# 所有 job 使用同一目录。CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。
# 不使用 CI_JOB_ID,因为同一 pipeline 的不同 job 必须共享 release
# CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。同一 deploy job 被重试时,
# 会重新导出相同提交并执行 npm ci;若该 pipeline 已上线,则直接成功退出
# ── 1. 准备独立 release ───────────────────────────────
prepare:
stage: prepare
tags:
- sumeiqiao-prod
only:
- main
script:
- export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
- echo "[prepare] 提交:$CI_COMMIT_SHA"
- echo "[prepare] Release:$RELEASE_DIR"
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" && exit 1)
- test -n "$CI_PIPELINE_ID" || (echo "CI_PIPELINE_ID 缺失" && exit 1)
- test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" && exit 1)
- mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$RELEASE_DIR"
# GitLab Runner 已检出本次提交;直接导出受 Git 管理的文件,不修改正在运行的 current。
- git archive "$CI_COMMIT_SHA" | tar -x -C "$RELEASE_DIR"
- printf '%s\n' "$CI_COMMIT_SHA" > "$RELEASE_DIR/.commit-sha"
- printf '%s\n' "$CI_PIPELINE_ID" > "$RELEASE_DIR/.pipeline-id"
- test -L "$RELEASE_DIR/.env" || ln -s "$SHARED_ENV" "$RELEASE_DIR/.env"
- echo "[prepare] Release 准备完成"
- ls -la "$RELEASE_DIR" | sed -n '1,30p'
# ── 2. 安装依赖 ──────────────────────────────────────
# release 尚未启用,安装失败不会影响线上服务,也不需要占用 CMS 构建锁。
install:
stage: install
tags:
- sumeiqiao-prod
only:
- main
script:
- export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
- echo "[install] 开始安装依赖:$RELEASE_DIR"
- cd "$RELEASE_DIR"
- npm ci
- echo "[install] 依赖安装完成"
- du -sh node_modules
# ── 3. 安全部署 ──────────────────────────────────────
# 共享锁内重新读取最新文章并构建,然后原子切换 current、仅重启本项目 PM2、健康检查。
# 常规失败会在锁内自动恢复上一成功版本。
# ── 1. 幂等部署 ──────────────────────────────────────
# 创建 release、安装依赖和部署在同一个 job 中完成,不依赖跨 job 保留 node_modules。
deploy:
stage: deploy
tags:
......@@ -84,15 +41,79 @@ deploy:
only:
- main
script:
- export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
- echo "[deploy] 等待 CMS 共享构建锁"
- echo "[deploy] 目标 Release:$RELEASE_DIR"
- test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" && exit 1)
- cd "$RELEASE_DIR"
- node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$SITE_URL" "$CI_PIPELINE_ID"
- echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
- |
set -Eeuo pipefail
RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
echo "[deploy] 提交:$CI_COMMIT_SHA"
echo "[deploy] 目标 Release:$RELEASE_DIR"
test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" && exit 1)
test -n "$CI_PIPELINE_ID" || (echo "CI_PIPELINE_ID 缺失" && exit 1)
case "$CI_PIPELINE_ID" in
*[!0-9]*) echo "CI_PIPELINE_ID 必须是数字" >&2; exit 2 ;;
esac
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" && exit 1)
test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" && exit 1)
case "$RELEASE_DIR" in
"$RELEASES_DIR"/*) ;;
*) echo "release 路径越界" >&2; exit 2 ;;
esac
# ── 4. 安装并验证 Nginx 配置 ─────────────────────────
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ] && [ -f "$RELEASE_DIR/.deploy-success" ]; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[deploy] 该 pipeline 已部署且服务正常,无需重复执行"
exit 0
fi
echo "本次 release 已上线但内容不完整或服务异常,拒绝原地修改" >&2
exit 2
fi
if [ "$current_target" = "$RELEASE_DIR" ]; then
echo "current 已指向未标记成功的本次 release,拒绝原地修改" >&2
exit 2
fi
if [ -n "$current_target" ] && [ -f "$current_target/.pipeline-id" ]; then
current_pipeline_id=$(cat "$current_target/.pipeline-id")
case "$current_pipeline_id" in
*[!0-9]*|'') echo "当前版本的 pipeline 标识不合法" >&2; exit 2 ;;
esac
if [ "$current_pipeline_id" -gt "$CI_PIPELINE_ID" ]; then
echo "较新的 pipeline $current_pipeline_id 已部署,拒绝重试旧 pipeline $CI_PIPELINE_ID" >&2
exit 3
fi
fi
test ! -L "$RELEASE_DIR" || (echo "release 目录不能是软链接" && exit 2)
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$RELEASE_DIR"
if [ -f "$RELEASE_DIR/.commit-sha" ] && [ "$(cat "$RELEASE_DIR/.commit-sha")" != "$CI_COMMIT_SHA" ]; then
echo "release 中的提交标识不匹配" >&2
exit 2
fi
if [ -f "$RELEASE_DIR/.pipeline-id" ] && [ "$(cat "$RELEASE_DIR/.pipeline-id")" != "$CI_PIPELINE_ID" ]; then
echo "release 中的 pipeline 标识不匹配" >&2
exit 2
fi
if [ -e "$RELEASE_DIR/.env" ] && [ ! -L "$RELEASE_DIR/.env" ]; then
echo "release 中的 .env 必须是软链接" >&2
exit 2
fi
git config --global --add safe.directory "$CI_PROJECT_DIR"
git archive "$CI_COMMIT_SHA" | tar -x -C "$RELEASE_DIR"
printf '%s\n' "$CI_COMMIT_SHA" > "$RELEASE_DIR/.commit-sha"
printf '%s\n' "$CI_PIPELINE_ID" > "$RELEASE_DIR/.pipeline-id"
ln -sfn "$SHARED_ENV" "$RELEASE_DIR/.env"
cd "$RELEASE_DIR"
echo "[deploy] 安装依赖"
npm ci
du -sh node_modules
echo "[deploy] 等待 CMS 共享构建锁"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$SITE_URL" "$CI_PIPELINE_ID"
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
# ── 2. 安装并验证 Nginx 配置 ─────────────────────────
# 复制 current release 中的配置,检查语法后重新加载 Nginx。
configure_nginx:
stage: nginx
......@@ -109,7 +130,7 @@ configure_nginx:
- 'curl --fail --silent --show-error --max-time 5 --header "Host: $PUBLIC_HOST" http://127.0.0.1/ >/dev/null'
- echo "[nginx] Nginx 配置与公网入口验证完成"
# ── 5. 验证并只保留当前 + 上一个成功版本 ─────────────
# ── 3. 验证并只保留当前 + 上一个成功版本 ─────────────
# when: always 用于输出最终状态;生产健康检查和自动回退已经在 deploy 的共享锁内完成。
verify:
stage: verify
......@@ -136,5 +157,10 @@ verify:
else
echo "[verify] 本次版本未部署成功,线上 current 保持在其他版本"
fi
echo "[verify] PM2 当前运行项目:"
if ! sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" list; then
echo "[verify] PM2 项目列表读取失败"
verify_status=1
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
......@@ -106,7 +106,7 @@ CMS_DATA_DIR=/root/sumeiqiao/shared/cms-data
CMS_BUILD_LOCK=/root/sumeiqiao/shared/site-build.lock
```
CI/CD 应在新的 release 目录部署代码,不要在正在运行的目录执行 `git clean`。仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7,分为准备、安装、部署、配置 Nginx、验证五个阶段,每个阶段都会实时输出日志。安装依赖只操作尚未启用的独立 release;最终生产构建、切换 current 软链接、重启 PM2 和健康检查在同一把共享锁内完成。应用健康检查通过后才复制 Nginx 配置、检查语法并 reload。健康检查失败时会自动切回上一版本。
CI/CD 应在新的 release 目录部署代码,不要在正在运行的目录执行 `git clean`。仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7,分为幂等部署、配置 Nginx、验证三个阶段。创建 release、执行 `npm ci`、生产构建和切换都在同一个 deploy job 中完成,不依赖不同 job 之间保留 `node_modules`。同一 pipeline 重试时会重建缺失的依赖;如果该 release 已成功上线,则直接作为成功处理。最终生产构建、切换 current 软链接、重启 PM2 和健康检查在同一把共享锁内完成。应用健康检查通过后才复制 Nginx 配置、检查语法并 reload。健康检查失败时会自动切回上一版本。
```bash
node --env-file=/root/sumeiqiao/shared/.env scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh /root/sumeiqiao /root/sumeiqiao/releases/<release> sumeiqiao /usr/bin/pm2 /root/.pm2 1 http://127.0.0.1:8789/
......@@ -154,6 +154,6 @@ gitlab-runner ALL=(root) NOPASSWD: SUMEIQIAO_NGINX
多个 GitLab pipeline 即使同时进入部署阶段,也会由服务器共享锁依次执行;部署持锁时,文章后台不会排队受理发布操作,而会明确提示编辑人员稍后重试。
验证阶段只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
验证阶段会输出 PM2 当前运行的全部项目,并只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。即使失败目录已被清理,重试 deploy job 也会重新创建 release 和依赖。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
普通本地构建或不包含 release 切换的 CI 可以直接运行 `npm run build`,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment