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
This diff is collapsed.
......@@ -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