Commit 17a35237 authored by xuchentao's avatar xuchentao

refactor: simplify production deployment

parent b6f59780
Pipeline #431 passed with stage
in 26 seconds
# 本地开发配置:复制为 .env 后使用。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
# 复制为 .env 后使用。生产环境请生成独立密钥。
CMS_PORT=8791
CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_API_KEY=replace-with-openssl-rand-hex-32
CMS_DATA_DIR=.runtime/cms-data
CMS_BUILD_LOCK=.runtime/site-build.lock
# CMS 数据与构建锁必须持久化在 shared 中,不能放入部署时会被覆盖的 current。
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
# 银妆官网 · GitLab CI/CD 安全部署
# 流程:幂等部署 → 配置 Nginx → 验证与清理
# 银妆官网 · GitLab CI/CD 单目录覆盖部署
# 流程:停止应用 → 覆盖 current → 安装与构建 → 启动并检查 → 更新 Nginx
# 触发:push 到 main 分支
#
# ⚠️ GitLab 版本:11.7(较旧)
# - 不使用 workflow:rules
# - 不使用 rules
# - 不使用 needs/DAG
# - 不使用 resource_group
# - 不使用 cache:policy
# 仅使用 only、stages、script、when 等基础语法。
# Runner 注册时请使用标签:yinzhuang-prod
# GitLab 版本:11.7
# 仅使用 stages、only、script 等基础语法;服务器部署锁用于拒绝并发部署。
variables:
DEPLOY_ROOT: "/root/yinzhuang"
RELEASES_DIR: "/root/yinzhuang/releases"
CURRENT_DIR: "/root/yinzhuang/current"
SHARED_DIR: "/root/yinzhuang/shared"
SHARED_ENV: "/root/yinzhuang/shared/.env"
DEPLOY_LOCK: "/root/yinzhuang/shared/deploy.lock"
APP_NAME: "yinzhuang"
PORT: "8790"
SITE_URL: "http://127.0.0.1:8790/"
PM2_BIN: "/usr/bin/pm2"
PM2_HOME_DIR: "/root/.pm2"
......@@ -26,14 +20,7 @@ variables:
stages:
- deploy
- nginx
- verify
# CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。同一 deploy job 被重试时,
# 会重新导出相同提交并执行 npm ci;若该 pipeline 已上线,则直接成功退出。
# ── 1. 幂等部署 ──────────────────────────────────────
# 创建 release、安装依赖和部署在同一个 job 中完成,不依赖跨 job 保留 node_modules。
deploy:
stage: deploy
tags:
......@@ -43,124 +30,154 @@ deploy:
script:
- |
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 ;;
case "$DEPLOY_ROOT" in
/*) ;;
*) echo "DEPLOY_ROOT 必须是绝对路径" >&2; exit 2 ;;
esac
case "$PM2_BIN" in
/*) ;;
*) echo "PM2_BIN 必须是绝对路径" >&2; exit 2 ;;
esac
case "$PM2_HOME_DIR" in
/*) ;;
*) echo "PM2_HOME_DIR 必须是绝对路径" >&2; exit 2 ;;
esac
if [ "$CURRENT_DIR" != "$DEPLOY_ROOT/current" ] || [ "$SHARED_DIR" != "$DEPLOY_ROOT/shared" ]; then
echo "current 或 shared 目录不属于部署根目录" >&2
exit 2
fi
if [ "$SHARED_ENV" != "$SHARED_DIR/.env" ] || [ "$DEPLOY_LOCK" != "$SHARED_DIR/deploy.lock" ]; then
echo "生产配置或部署锁路径不属于 shared 目录" >&2
exit 2
fi
case "$APP_NAME" in
*[!A-Za-z0-9_.-]*|'') echo "PM2 应用名包含不安全字符" >&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 ;;
case "$PM2_USE_SUDO" in
0|1) ;;
*) echo "PM2_USE_SUDO 只能是 0 或 1" >&2; exit 2 ;;
esac
test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" >&2 && exit 2)
test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" >&2 && exit 2)
command -v flock >/dev/null || (echo "服务器缺少 flock" >&2 && exit 2)
command -v curl >/dev/null || (echo "服务器缺少 curl" >&2 && exit 2)
command -v node >/dev/null || (echo "服务器缺少 Node.js" >&2 && exit 2)
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
if [ -L "$DEPLOY_ROOT" ] || { [ -e "$DEPLOY_ROOT" ] && [ ! -d "$DEPLOY_ROOT" ]; }; then
echo "DEPLOY_ROOT 必须是非软链接实体目录:$DEPLOY_ROOT" >&2
exit 2
fi
if [ "$current_target" = "$RELEASE_DIR" ]; then
echo "current 已指向未标记成功的本次 release,拒绝原地修改" >&2
mkdir -p "$DEPLOY_ROOT"
if [ "$(readlink -f "$DEPLOY_ROOT")" != "$DEPLOY_ROOT" ] || [ -L "$DEPLOY_ROOT" ]; then
echo "DEPLOY_ROOT 必须是非软链接实体目录:$DEPLOY_ROOT" >&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
if [ -L "$SHARED_DIR" ] || { [ -e "$SHARED_DIR" ] && [ ! -d "$SHARED_DIR" ]; }; then
echo "shared 必须是非软链接实体目录:$SHARED_DIR" >&2
exit 2
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
mkdir -p "$SHARED_DIR"
if [ "$(readlink -f "$SHARED_DIR")" != "$SHARED_DIR" ]; then
echo "shared 必须位于指定部署目录:$SHARED_DIR" >&2
exit 2
fi
if [ -f "$RELEASE_DIR/.pipeline-id" ] && [ "$(cat "$RELEASE_DIR/.pipeline-id")" != "$CI_PIPELINE_ID" ]; then
echo "release 中的 pipeline 标识不匹配" >&2
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" >&2 && exit 2)
# GitLab 11.7 没有 resource_group。非阻塞锁让并发 pipeline 直接失败,
# 避免较早的 pipeline 排队后覆盖较新的提交。
exec 9>"$DEPLOY_LOCK"
if ! flock -n 9; then
echo "已有部署正在进行,本次 pipeline 不进入等待队列" >&2
exit 3
fi
pm2_command() {
if [ "$PM2_USE_SUDO" = "1" ]; then
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" "$@"
else
env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" "$@"
fi
}
# 先停止且只停止本项目进程。单目录部署失败时不会自动回滚。
if pm2_command describe "$APP_NAME" >/dev/null 2>&1; then
echo "[deploy] 停止本项目 PM2 进程:$APP_NAME"
pm2_command delete "$APP_NAME"
fi
# 首次迁移时只移除 current 软链接本身,不触碰它原来指向的 release。
if [ -L "$CURRENT_DIR" ]; then
old_current_target=$(readlink -f "$CURRENT_DIR" 2>/dev/null || true)
echo "[deploy] 移除旧 current 软链接(原目标保留):${old_current_target:-未知}"
unlink "$CURRENT_DIR"
elif [ -e "$CURRENT_DIR" ] && [ ! -d "$CURRENT_DIR" ]; then
echo "current 存在但不是目录,拒绝覆盖:$CURRENT_DIR" >&2
exit 2
fi
if [ -e "$RELEASE_DIR/.env" ] && [ ! -L "$RELEASE_DIR/.env" ]; then
echo "release 中的 .env 必须是软链接" >&2
mkdir -p "$CURRENT_DIR"
if [ -L "$CURRENT_DIR" ] || [ "$(readlink -f "$CURRENT_DIR")" != "$CURRENT_DIR" ]; then
echo "current 必须是指定位置的实体目录:$CURRENT_DIR" >&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"
echo "[deploy] 清空 current"
find "$CURRENT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
echo "[deploy] 导出提交到 current"
git -c safe.directory="$CI_PROJECT_DIR" -C "$CI_PROJECT_DIR" cat-file -e "$CI_COMMIT_SHA^{commit}"
git -c safe.directory="$CI_PROJECT_DIR" -C "$CI_PROJECT_DIR" archive "$CI_COMMIT_SHA" | tar -x -C "$CURRENT_DIR"
ln -s "$SHARED_ENV" "$CURRENT_DIR/.env"
cd "$RELEASE_DIR"
cd "$CURRENT_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")"
echo "[deploy] 使用 shared 中的 CMS 数据和构建锁进行构建"
node --env-file="$SHARED_ENV" -e '
const path = require("node:path");
const shared = path.resolve(process.argv[1]);
for (const name of ["CMS_DATA_DIR", "CMS_BUILD_LOCK"]) {
const value = process.env[name];
if (!value) throw new Error(`${name} 未配置`);
const resolved = path.resolve(value);
if (resolved === shared || !resolved.startsWith(`${shared}${path.sep}`)) {
throw new Error(`${name} 必须位于 ${shared}/ 下`);
}
}
' "$SHARED_DIR"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- npm run build:inside-lock
# ── 2. 安装并验证 Nginx 配置 ─────────────────────────
# 复制 current release 中的配置,检查语法后重新加载 Nginx。
configure_nginx:
stage: nginx
tags:
- yinzhuang-prod
only:
- main
script:
- echo "[nginx] 开始配置公网入口:$PUBLIC_HOST"
- test -f "$DEPLOY_ROOT/current/deploy/nginx/yinzhuang.conf" || (echo "缺少 Nginx 配置" && exit 2)
- sudo -n /usr/bin/install -o root -g root -m 0644 "$DEPLOY_ROOT/current/deploy/nginx/yinzhuang.conf" /etc/nginx/conf.d/yinzhuang.conf
- sudo -n /usr/sbin/nginx -t
- sudo -n /usr/bin/systemctl reload nginx
- 'curl --fail --silent --show-error --max-time 5 --header "Host: $PUBLIC_HOST" http://127.0.0.1/ >/dev/null'
- echo "[nginx] Nginx 配置与公网入口验证完成"
# ── 3. 验证并只保留当前 + 上一个成功版本 ─────────────
# when: always 用于输出最终状态;生产健康检查和自动回退已经在 deploy 的共享锁内完成。
verify:
stage: verify
tags:
- yinzhuang-prod
only:
- main
when: always
script:
- export RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
- echo "[verify] 本次 Release:$RELEASE_DIR"
- echo "[verify] 当前指向:$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || echo 未部署)"
- |
verify_status=0
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ]; then
if curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[verify] 服务运行正常:$SITE_URL"
else
echo "[verify] 服务无响应,输出本项目最近日志"
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" logs "$APP_NAME" --lines 30 --nostream || true
verify_status=1
echo "[deploy] 启动本项目 PM2 进程"
pm2_command start "$CURRENT_DIR/server.mjs" \
--name "$APP_NAME" \
--cwd "$CURRENT_DIR" \
--node-args="--env-file=$SHARED_ENV"
healthy=0
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 3 "$SITE_URL" >/dev/null; then
healthy=1
break
fi
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
echo "[deploy] 等待服务启动... ($attempt/30)"
sleep 1
done
if [ "$healthy" != "1" ]; then
echo "健康检查失败:$SITE_URL" >&2
pm2_command logs "$APP_NAME" --lines 30 --nostream || true
exit 1
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
echo "[deploy] 更新并验证 Nginx"
test -f "$CURRENT_DIR/deploy/nginx/yinzhuang.conf" || (echo "缺少 Nginx 配置" >&2 && exit 2)
sudo -n /usr/bin/install -o root -g root -m 0644 "$CURRENT_DIR/deploy/nginx/yinzhuang.conf" /etc/nginx/conf.d/yinzhuang.conf
sudo -n /usr/sbin/nginx -t
sudo -n /usr/bin/systemctl reload nginx
curl --fail --silent --show-error --max-time 5 --header "Host: $PUBLIC_HOST" http://127.0.0.1/ >/dev/null
echo "[deploy] 部署完成:$CURRENT_DIR"
echo "[deploy] 旧 releases 目录未被读取或清理;确认无用后请人工删除"
......@@ -39,7 +39,7 @@ npm start
- `CMS_SECRET`:后台会话签名密钥
- `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token
- `CMS_DATA_DIR`:生产环境的用户数据目录,建议设置为仓库外的绝对路径
- `CMS_BUILD_LOCK`:代码部署与文章发布共用的锁文件,生产环境各个 release 必须配置成同一路径
- `CMS_BUILD_LOCK`:代码部署与文章发布共用的锁文件,生产环境必须放在 `shared/`
## 后台 API
......@@ -87,7 +87,7 @@ npm start
文章状态分为“新建未发布”“修改未发布”和“已上线”。文章网址、摘要、作者及 SEO 时间信息均由程序自动生成,编辑人员只需填写标题、分类和正文。
## 两条部署流水线
## 内容构建与代码部署
### 用户提交文章
......@@ -106,12 +106,20 @@ CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
```
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/yinzhuang/shared/.env scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh /root/yinzhuang /root/yinzhuang/releases/<release> yinzhuang /usr/bin/pm2 /root/.pm2 1 http://127.0.0.1:8790/
```text
/root/yinzhuang/
├── current/ # 唯一运行版本,必须是实体目录
└── shared/ # 环境配置、CMS 数据、上传文件和锁文件
```
仓库中的 `.gitlab-ci.yml` 兼容 GitLab 11.7,CI 仅有一个 `deploy` 阶段和一个 job。完整流程是:停止本项目 PM2 进程,清空 `current`,用 `git archive` 导出当前提交,关联 `shared/.env`,执行 `npm ci` 和生产构建,启动 PM2,进行本机健康检查,最后安装并验证 Nginx 配置。
部署全程持有 `shared/deploy.lock`,并发 pipeline 会直接失败而不会排队,避免旧 pipeline 随后覆盖新提交。生产构建另外使用 `CMS_BUILD_LOCK` 与后台内容构建互斥。PM2 命令只操作配置的 `APP_NAME`,不会执行 `delete all``restart all``pm2 kill`
该部署方式会在依赖安装和构建期间短暂停机,不保存历史版本,也不支持版本切换或自动回滚。部署失败时应修复问题后重新运行 pipeline。
### GitLab CI/CD 配置
默认假设 GitLab Shell Runner 与生产服务位于同一台服务器,并使用以下路径:
......@@ -133,6 +141,14 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
CMS_PORT=8790
```
`CMS_DATA_DIR` 中包含工作稿、已发布文章、分类和上传文件。数据库文件、其他业务数据及所有运行期生成的持久化内容也必须放在 `shared/` 中,再通过环境变量或软链接提供给应用。`current` 的全部内容会在每次部署时删除,不能用于持久化数据。
### 从旧 releases 结构迁移
首次执行新流程时,CI 会先停止本项目 PM2 进程。如果 `current` 是软链接,只会执行 `unlink` 删除链接本身,并保留链接指向的旧 release;随后创建同名实体目录并部署。
旧的 `/root/yinzhuang/releases` 不在新部署脚本的操作范围内。确认不再需要回退且已完成必要备份后,由管理员人工删除;CI 不会读取、清理或修改该目录。
首次运行 pipeline 前,需要允许 Runner 执行参数固定的 Nginx 安装、检查和 reload 命令:
```bash
......@@ -152,8 +168,4 @@ gitlab-runner ALL=(root) NOPASSWD: YINZHUANG_NGINX
保存后执行 `sudo visudo -cf /etc/sudoers.d/yinzhuang-runner` 检查语法。CI 只会从 `/root/yinzhuang/current` 读取本项目配置,并只会更新 `/etc/nginx/conf.d/yinzhuang.conf`
多个 GitLab pipeline 即使同时进入部署阶段,也会由服务器共享锁依次执行;部署持锁时,文章后台不会排队受理发布操作,而会明确提示编辑人员稍后重试。
验证阶段会输出 PM2 当前运行的全部项目,并只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。即使失败目录已被清理,重试 deploy job 也会重新创建 release 和依赖。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
普通本地构建或不包含 release 切换的 CI 可以直接运行 `npm run build`,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
普通本地构建可以直接运行 `npm run build`,它会自行取得同一把内容构建锁,避免多个构建同时读取或覆盖文章数据及静态网站。
......@@ -42,5 +42,8 @@ npm start # 启动 standalone 服务
- Nginx 配置:`deploy/nginx/yinzhuang.conf`
- 生产端口:`8790`
- 站点域名:`www.inzung.cn`
- 服务器目录:`/root/yinzhuang/current`(唯一运行版本,实体目录)与 `/root/yinzhuang/shared`(持久化数据)
部署前请在服务器创建独立 `.env`,替换 `CMS_PASSWORD``CMS_SECRET``CMS_API_KEY`,并按实际环境调整部署路径、域名和 Runner 标签。
CI 使用单目录覆盖部署:停止本项目 PM2 进程,清空 `current`,导出当前 Git 提交,关联 `shared/.env`,安装依赖并构建,再启动应用、执行健康检查并更新 Nginx。该流程不创建或清理历史 release,也不提供自动回滚;安装和构建期间站点会短暂停机。
部署前请在服务器创建 `/root/yinzhuang/shared/.env`,替换 `CMS_PASSWORD``CMS_SECRET``CMS_API_KEY`,并确保 `CMS_DATA_DIR``CMS_BUILD_LOCK` 都指向 `shared/` 内。上传文件、CMS/数据库数据、构建锁及其他运行期生成的数据不得存放在 `current`。详细迁移和服务器配置见 [CMS_README.md](./CMS_README.md)
#!/usr/bin/env bash
set -Eeuo pipefail
deploy_root=${1:?缺少部署根目录}
pipeline_release=${2:-}
case "$deploy_root" in
/*) ;;
*) echo "部署根目录必须是绝对路径" >&2; exit 2 ;;
esac
deploy_root=$(readlink -f "$deploy_root")
releases_dir="$deploy_root/releases"
current_link="$deploy_root/current"
previous_marker="$deploy_root/shared/previous-release"
if [ -n "$pipeline_release" ]; then
pipeline_release=$(readlink -f "$pipeline_release")
case "$pipeline_release" in
"$releases_dir"/*) ;;
*) echo "本 pipeline 的 release 路径越界" >&2; exit 2 ;;
esac
fi
if [ ! -d "$releases_dir" ]; then
echo "[cleanup] Releases 目录不存在,无需清理"
exit 0
fi
current_target=$(readlink -f "$current_link" 2>/dev/null || true)
previous_target=""
if [ -f "$previous_marker" ]; then
previous_target=$(sed -n '1p' "$previous_marker")
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
previous_target=$(readlink -f "$previous_target")
fi
fi
echo "[cleanup] 保留当前版本:${current_target:-}"
echo "[cleanup] 保留上一成功版本:${previous_target:-}"
for candidate in "$releases_dir"/*; do
[ -e "$candidate" ] || continue
if [ -L "$candidate" ] || [ ! -d "$candidate" ]; then
echo "[cleanup] 跳过非普通目录:$candidate"
continue
fi
resolved=$(readlink -f "$candidate")
case "$resolved" in
"$releases_dir"/*) ;;
*) echo "[cleanup] 跳过越界路径:$candidate"; continue ;;
esac
if [ "$resolved" = "$current_target" ] || { [ -n "$previous_target" ] && [ "$resolved" = "$previous_target" ]; }; then
echo "[cleanup] 保留:$resolved"
continue
fi
if [ "$resolved" = "$pipeline_release" ] || [ -f "$resolved/.deploy-success" ]; then
echo "[cleanup] 删除旧 Release:$resolved"
rm -rf -- "$resolved"
else
echo "[cleanup] 跳过未完成 Release(可能属于其他 pipeline):$resolved"
fi
done
echo "[cleanup] 清理完成,当前 Releases:"
find "$releases_dir" -mindepth 1 -maxdepth 1 -type d -print | sort
#!/usr/bin/env bash
set -Eeuo pipefail
deploy_root=${1:?缺少部署根目录}
release_dir=${2:?缺少 release 目录}
app_name=${3:?缺少 PM2 应用名}
pm2_bin=${4:?缺少 PM2 可执行文件路径}
pm2_home=${5:?缺少 PM2_HOME}
pm2_use_sudo=${6:-1}
healthcheck_url=${7:-http://127.0.0.1:${CMS_PORT:-8790}/}
pipeline_id=${8:?缺少 CI pipeline ID}
case "$deploy_root" in
/*) ;;
*) echo "部署根目录必须是绝对路径" >&2; exit 2 ;;
esac
case "$release_dir" in
"$deploy_root"/releases/*) ;;
*) echo "release 目录必须位于 $deploy_root/releases/ 下" >&2; exit 2 ;;
esac
case "$app_name" in
*[!A-Za-z0-9_.-]*|'') echo "PM2 应用名包含不安全字符" >&2; exit 2 ;;
esac
case "$pm2_bin" in
/*) ;;
*) echo "PM2 可执行文件必须是绝对路径" >&2; exit 2 ;;
esac
case "$pm2_home" in
/*) ;;
*) echo "PM2_HOME 必须是绝对路径" >&2; exit 2 ;;
esac
case "$pm2_use_sudo" in
0|1) ;;
*) echo "PM2_USE_SUDO 只能是 0 或 1" >&2; exit 2 ;;
esac
case "$pipeline_id" in
*[!0-9]*|'') echo "CI pipeline ID 必须是数字" >&2; exit 2 ;;
esac
shared_dir="$deploy_root/shared"
shared_env="$shared_dir/.env"
current_link="$deploy_root/current"
if [ "${YINZHUANG_SITE_LOCK_HELD:-}" != "1" ]; then
echo "部署必须通过 run-with-site-lock.mjs 执行" >&2
exit 2
fi
if [ ! -d "$release_dir" ] || [ ! -f "$release_dir/package-lock.json" ]; then
echo "release 目录不完整:$release_dir" >&2
exit 2
fi
if [ ! -f "$shared_env" ]; then
echo "缺少生产环境配置:$shared_env" >&2
exit 2
fi
case "${CMS_DATA_DIR:-}" in
"$shared_dir"/*) ;;
*) echo "CMS_DATA_DIR 必须位于 $shared_dir/ 下" >&2; exit 2 ;;
esac
case "${CMS_BUILD_LOCK:-}" in
"$shared_dir"/*) ;;
*) echo "CMS_BUILD_LOCK 必须位于 $shared_dir/ 下" >&2; exit 2 ;;
esac
if [ -e "$current_link" ] && [ ! -L "$current_link" ]; then
echo "$current_link 必须是软链接,拒绝覆盖真实目录" >&2
exit 2
fi
command -v curl >/dev/null
test -x "$pm2_bin"
if [ ! -f "$release_dir/.pipeline-id" ] || [ "$(cat "$release_dir/.pipeline-id")" != "$pipeline_id" ]; then
echo "release 的 pipeline 标识不匹配" >&2
exit 2
fi
pm2_command() {
if [ "$pm2_use_sudo" = "1" ]; then
sudo -n env PM2_HOME="$pm2_home" "$pm2_bin" "$@"
else
env PM2_HOME="$pm2_home" "$pm2_bin" "$@"
fi
}
# 只删除并重建 APP_NAME 对应的一个进程;不执行 pm2 kill、delete all、restart all 或 pm2 save。
start_current_app() {
if pm2_command describe "$app_name" >/dev/null 2>&1; then
pm2_command delete "$app_name"
fi
pm2_command start "$current_link/server.mjs" \
--name "$app_name" \
--cwd "$current_link" \
--node-args="--env-file=$shared_env"
}
previous_target=""
if [ -L "$current_link" ]; then
previous_target=$(readlink -f "$current_link")
fi
# GitLab 11.7 没有 resource_group。若较新的 pipeline 已上线,拒绝旧 pipeline 覆盖它。
if [ -n "$previous_target" ] && [ -f "$previous_target/.pipeline-id" ]; then
current_pipeline_id=$(cat "$previous_target/.pipeline-id")
case "$current_pipeline_id" in
*[!0-9]*|'') echo "当前版本的 pipeline 标识不合法" >&2; exit 2 ;;
esac
if [ "$current_pipeline_id" -gt "$pipeline_id" ]; then
echo "较新的 pipeline $current_pipeline_id 已部署,拒绝旧 pipeline $pipeline_id 覆盖" >&2
exit 3
fi
if [ "$current_pipeline_id" -eq "$pipeline_id" ] && [ "$previous_target" = "$release_dir" ]; then
echo "该 pipeline 已经部署,无需重复切换"
exit 0
fi
fi
switched=0
rollback() {
exit_code=$?
trap - ERR
if [ "$switched" = "1" ]; then
echo "新版本部署失败,正在恢复上一版本……" >&2
rollback_link="$deploy_root/.current-rollback-$$"
if [ -n "$previous_target" ] && [ -d "$previous_target" ]; then
ln -s "$previous_target" "$rollback_link"
mv -Tf "$rollback_link" "$current_link"
start_current_app || true
else
if pm2_command describe "$app_name" >/dev/null 2>&1; then
pm2_command delete "$app_name" || true
fi
echo "没有可恢复的上一版本,已停止本项目进程" >&2
fi
fi
exit "$exit_code"
}
trap rollback ERR
cd "$release_dir"
echo "[deploy] 使用共享用户内容执行最终构建"
npm run build:inside-lock
next_link="$deploy_root/.current-next-$$"
ln -s "$release_dir" "$next_link"
mv -Tf "$next_link" "$current_link"
switched=1
start_current_app
healthy=0
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 3 "$healthcheck_url" >/dev/null; then
healthy=1
break
fi
echo "等待服务启动... ($attempt/30)"
sleep 1
done
if [ "$healthy" != "1" ]; then
echo "健康检查失败:$healthcheck_url" >&2
pm2_command logs "$app_name" --lines 30 --nostream || true
false
fi
trap - ERR
previous_marker_tmp="$shared_dir/.previous-release-$$"
printf '%s\n' "$previous_target" > "$previous_marker_tmp"
mv -f "$previous_marker_tmp" "$shared_dir/previous-release"
printf '%s\n' "$pipeline_id" > "$release_dir/.deploy-success"
echo "部署成功:$release_dir"
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