Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
Y
yinzhuang
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
yinzhuang
Commits
17a35237
Commit
17a35237
authored
Jul 31, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: simplify production deployment
parent
b6f59780
Pipeline
#431
passed with stage
in 26 seconds
Changes
6
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
163 additions
and
365 deletions
+163
-365
.env.example
.env.example
+4
-4
.gitlab-ci.yml
.gitlab-ci.yml
+133
-116
CMS_README.md
CMS_README.md
+22
-10
README.md
README.md
+4
-1
cleanup-releases.sh
scripts/cleanup-releases.sh
+0
-65
deploy-release.sh
scripts/deploy-release.sh
+0
-169
No files found.
.env.example
View file @
17a35237
# 本地开发配置:复制为 .env 后使用。
# 复制为 .env 后使用。生产环境请生成独立密钥。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
CMS_PORT=8791
CMS_PORT=8791
CMS_PASSWORD=replace-with-a-strong-password
CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_API_KEY=replace-with-openssl-rand-hex-32
CMS_API_KEY=replace-with-openssl-rand-hex-32
CMS_DATA_DIR=.runtime/cms-data
# CMS 数据与构建锁必须持久化在 shared 中,不能放入部署时会被覆盖的 current。
CMS_BUILD_LOCK=.runtime/site-build.lock
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
.gitlab-ci.yml
View file @
17a35237
# 银妆官网 · GitLab CI/CD
安全
部署
# 银妆官网 · GitLab CI/CD
单目录覆盖
部署
# 流程:
幂等部署 → 配置 Nginx → 验证与清理
# 流程:
停止应用 → 覆盖 current → 安装与构建 → 启动并检查 → 更新 Nginx
# 触发:push 到 main 分支
# 触发:push 到 main 分支
#
#
# ⚠️ GitLab 版本:11.7(较旧)
# GitLab 版本:11.7
# - 不使用 workflow:rules
# 仅使用 stages、only、script 等基础语法;服务器部署锁用于拒绝并发部署。
# - 不使用 rules
# - 不使用 needs/DAG
# - 不使用 resource_group
# - 不使用 cache:policy
# 仅使用 only、stages、script、when 等基础语法。
# Runner 注册时请使用标签:yinzhuang-prod
variables
:
variables
:
DEPLOY_ROOT
:
"
/root/yinzhuang"
DEPLOY_ROOT
:
"
/root/yinzhuang"
RELEASES_DIR
:
"
/root/yinzhuang/releases
"
CURRENT_DIR
:
"
/root/yinzhuang/current
"
SHARED_DIR
:
"
/root/yinzhuang/shared"
SHARED_DIR
:
"
/root/yinzhuang/shared"
SHARED_ENV
:
"
/root/yinzhuang/shared/.env"
SHARED_ENV
:
"
/root/yinzhuang/shared/.env"
DEPLOY_LOCK
:
"
/root/yinzhuang/shared/deploy.lock"
APP_NAME
:
"
yinzhuang"
APP_NAME
:
"
yinzhuang"
PORT
:
"
8790"
SITE_URL
:
"
http://127.0.0.1:8790/"
SITE_URL
:
"
http://127.0.0.1:8790/"
PM2_BIN
:
"
/usr/bin/pm2"
PM2_BIN
:
"
/usr/bin/pm2"
PM2_HOME_DIR
:
"
/root/.pm2"
PM2_HOME_DIR
:
"
/root/.pm2"
...
@@ -26,14 +20,7 @@ variables:
...
@@ -26,14 +20,7 @@ variables:
stages
:
stages
:
-
deploy
-
deploy
-
nginx
-
verify
# CI_PIPELINE_ID 保证不同 pipeline 不会写同一个 release。同一 deploy job 被重试时,
# 会重新导出相同提交并执行 npm ci;若该 pipeline 已上线,则直接成功退出。
# ── 1. 幂等部署 ──────────────────────────────────────
# 创建 release、安装依赖和部署在同一个 job 中完成,不依赖跨 job 保留 node_modules。
deploy
:
deploy
:
stage
:
deploy
stage
:
deploy
tags
:
tags
:
...
@@ -43,124 +30,154 @@ deploy:
...
@@ -43,124 +30,154 @@ deploy:
script
:
script
:
-
|
-
|
set -Eeuo pipefail
set -Eeuo pipefail
RELEASE_DIR="$RELEASES_DIR/${CI_COMMIT_SHA}-${CI_PIPELINE_ID}"
echo "[deploy] 提交:$CI_COMMIT_SHA"
echo "[deploy] 提交:$CI_COMMIT_SHA"
echo "[deploy] 目标 Release:$RELEASE_DIR"
test -n "$CI_COMMIT_SHA" || (echo "CI_COMMIT_SHA 缺失" && exit 1)
case "$DEPLOY_ROOT" in
test -n "$CI_PIPELINE_ID" || (echo "CI_PIPELINE_ID 缺失" && exit 1)
/*) ;;
case "$CI_PIPELINE_ID" in
*) echo "DEPLOY_ROOT 必须是绝对路径" >&2; exit 2 ;;
*[!0-9]*) echo "CI_PIPELINE_ID 必须是数字" >&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
esac
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" && exit 1)
case "$PM2_USE_SUDO" in
test -x "$PM2_BIN" || (echo "PM2 不存在或不可执行:$PM2_BIN" && exit 1)
0|1) ;;
case "$RELEASE_DIR" in
*) echo "PM2_USE_SUDO 只能是 0 或 1" >&2; exit 2 ;;
"$RELEASES_DIR"/*) ;;
*) echo "release 路径越界" >&2; exit 2 ;;
esac
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 [ -L "$DEPLOY_ROOT" ] || { [ -e "$DEPLOY_ROOT" ] && [ ! -d "$DEPLOY_ROOT" ]; }; then
if [ "$current_target" = "$RELEASE_DIR" ] && [ -f "$RELEASE_DIR/.deploy-success" ]; then
echo "DEPLOY_ROOT 必须是非软链接实体目录:$DEPLOY_ROOT" >&2
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
exit 2
echo "[deploy] 该 pipeline 已部署且服务正常,无需重复执行"
exit 0
fi
fi
echo "本次 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
exit 2
fi
fi
if [
"$current_target" = "$RELEASE_DIR" ]
; then
if [
-L "$SHARED_DIR" ] || { [ -e "$SHARED_DIR" ] && [ ! -d "$SHARED_DIR" ]; }
; then
echo "
current 已指向未标记成功的本次 release,拒绝原地修改
" >&2
echo "
shared 必须是非软链接实体目录:$SHARED_DIR
" >&2
exit 2
exit 2
fi
fi
if [ -n "$current_target" ] && [ -f "$current_target/.pipeline-id" ]; then
mkdir -p "$SHARED_DIR"
current_pipeline_id=$(cat "$current_target/.pipeline-id")
if [ "$(readlink -f "$SHARED_DIR")" != "$SHARED_DIR" ]; then
case "$current_pipeline_id" in
echo "shared 必须位于指定部署目录:$SHARED_DIR" >&2
*[!0-9]*|'') echo "当前版本的 pipeline 标识不合法" >&2; exit 2 ;;
exit 2
esac
fi
if [ "$current_pipeline_id" -gt "$CI_PIPELINE_ID" ]; then
test -f "$SHARED_ENV" || (echo "缺少生产配置:$SHARED_ENV" >&2 && exit 2)
echo "较新的 pipeline $current_pipeline_id 已部署,拒绝重试旧 pipeline $CI_PIPELINE_ID" >&2
# GitLab 11.7 没有 resource_group。非阻塞锁让并发 pipeline 直接失败,
# 避免较早的 pipeline 排队后覆盖较新的提交。
exec 9>"$DEPLOY_LOCK"
if ! flock -n 9; then
echo "已有部署正在进行,本次 pipeline 不进入等待队列" >&2
exit 3
exit 3
fi
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
fi
}
test ! -L "$RELEASE_DIR" || (echo "release 目录不能是软链接" && exit 2)
# 先停止且只停止本项目进程。单目录部署失败时不会自动回滚。
mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$RELEASE_DIR"
if pm2_command describe "$APP_NAME" >/dev/null 2>&1; then
if [ -f "$RELEASE_DIR/.commit-sha" ] && [ "$(cat "$RELEASE_DIR/.commit-sha")" != "$CI_COMMIT_SHA" ]; then
echo "[deploy] 停止本项目 PM2 进程:$APP_NAME"
echo "release 中的提交标识不匹配" >&2
pm2_command delete "$APP_NAME"
exit 2
fi
fi
if [ -f "$RELEASE_DIR/.pipeline-id" ] && [ "$(cat "$RELEASE_DIR/.pipeline-id")" != "$CI_PIPELINE_ID" ]; then
echo "release 中的 pipeline 标识不匹配" >&2
# 首次迁移时只移除 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
exit 2
fi
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
exit 2
fi
fi
git config --global --add safe.directory "$CI_PROJECT_DIR"
echo "[deploy] 清空 current"
git archive "$CI_COMMIT_SHA" | tar -x -C "$RELEASE_DIR"
find "$CURRENT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
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] 导出提交到 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 "$CURRENT_DIR"
echo "[deploy] 安装依赖"
echo "[deploy] 安装依赖"
npm ci
npm ci
du -sh node_modules
echo "[deploy] 等待 CMS 共享构建锁"
echo "[deploy] 使用 shared 中的 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"
node --env-file="$SHARED_ENV" -e '
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
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 配置 ─────────────────────────
echo "[deploy] 启动本项目 PM2 进程"
# 复制 current release 中的配置,检查语法后重新加载 Nginx。
pm2_command start "$CURRENT_DIR/server.mjs" \
configure_nginx
:
--name "$APP_NAME" \
stage
:
nginx
--cwd "$CURRENT_DIR" \
tags
:
--node-args="--env-file=$SHARED_ENV"
-
yinzhuang-prod
only
:
healthy=0
-
main
for attempt in $(seq 1 30); do
script
:
if curl --fail --silent --show-error --max-time 3 "$SITE_URL" >/dev/null; then
-
echo "[nginx] 开始配置公网入口:$PUBLIC_HOST"
healthy=1
-
test -f "$DEPLOY_ROOT/current/deploy/nginx/yinzhuang.conf" || (echo "缺少 Nginx 配置" && exit 2)
break
-
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
fi
else
echo "[verify] 本次版本未部署成功,线上 current 保持在其他版本"
fi
fi
echo "[verify] PM2 当前运行项目:"
echo "[deploy] 等待服务启动... ($attempt/30)"
if ! sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" list; then
sleep 1
echo "[verify] PM2 项目列表读取失败"
done
verify_status=1
if [ "$healthy" != "1" ]; then
echo "健康检查失败:$SITE_URL" >&2
pm2_command logs "$APP_NAME" --lines 30 --nostream || true
exit 1
fi
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 目录未被读取或清理;确认无用后请人工删除"
CMS_README.md
View file @
17a35237
...
@@ -39,7 +39,7 @@ npm start
...
@@ -39,7 +39,7 @@ npm start
-
`CMS_SECRET`
:后台会话签名密钥
-
`CMS_SECRET`
:后台会话签名密钥
-
`CMS_API_KEY`
:外部程序调用管理 API 时使用的 Bearer Token
-
`CMS_API_KEY`
:外部程序调用管理 API 时使用的 Bearer Token
-
`CMS_DATA_DIR`
:生产环境的用户数据目录,建议设置为仓库外的绝对路径
-
`CMS_DATA_DIR`
:生产环境的用户数据目录,建议设置为仓库外的绝对路径
-
`CMS_BUILD_LOCK`
:代码部署与文章发布共用的锁文件,生产环境
各个 release 必须配置成同一路径
-
`CMS_BUILD_LOCK`
:代码部署与文章发布共用的锁文件,生产环境
必须放在
`shared/`
中
## 后台 API
## 后台 API
...
@@ -87,7 +87,7 @@ npm start
...
@@ -87,7 +87,7 @@ npm start
文章状态分为“新建未发布”“修改未发布”和“已上线”。文章网址、摘要、作者及 SEO 时间信息均由程序自动生成,编辑人员只需填写标题、分类和正文。
文章状态分为“新建未发布”“修改未发布”和“已上线”。文章网址、摘要、作者及 SEO 时间信息均由程序自动生成,编辑人员只需填写标题、分类和正文。
##
两条部署流水线
##
内容构建与代码部署
### 用户提交文章
### 用户提交文章
...
@@ -106,12 +106,20 @@ CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
...
@@ -106,12 +106,20 @@ CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
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
```
text
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/
/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 CI/CD 配置
默认假设 GitLab Shell Runner 与生产服务位于同一台服务器,并使用以下路径:
默认假设 GitLab Shell Runner 与生产服务位于同一台服务器,并使用以下路径:
...
@@ -133,6 +141,14 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
...
@@ -133,6 +141,14 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
CMS_PORT=8790
CMS_PORT=8790
```
```
`CMS_DATA_DIR`
中包含工作稿、已发布文章、分类和上传文件。数据库文件、其他业务数据及所有运行期生成的持久化内容也必须放在
`shared/`
中,再通过环境变量或软链接提供给应用。
`current`
的全部内容会在每次部署时删除,不能用于持久化数据。
### 从旧 releases 结构迁移
首次执行新流程时,CI 会先停止本项目 PM2 进程。如果
`current`
是软链接,只会执行
`unlink`
删除链接本身,并保留链接指向的旧 release;随后创建同名实体目录并部署。
旧的
`/root/yinzhuang/releases`
不在新部署脚本的操作范围内。确认不再需要回退且已完成必要备份后,由管理员人工删除;CI 不会读取、清理或修改该目录。
首次运行 pipeline 前,需要允许 Runner 执行参数固定的 Nginx 安装、检查和 reload 命令:
首次运行 pipeline 前,需要允许 Runner 执行参数固定的 Nginx 安装、检查和 reload 命令:
```
bash
```
bash
...
@@ -152,8 +168,4 @@ gitlab-runner ALL=(root) NOPASSWD: YINZHUANG_NGINX
...
@@ -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`
。
保存后执行
`sudo visudo -cf /etc/sudoers.d/yinzhuang-runner`
检查语法。CI 只会从
`/root/yinzhuang/current`
读取本项目配置,并只会更新
`/etc/nginx/conf.d/yinzhuang.conf`
。
多个 GitLab pipeline 即使同时进入部署阶段,也会由服务器共享锁依次执行;部署持锁时,文章后台不会排队受理发布操作,而会明确提示编辑人员稍后重试。
普通本地构建可以直接运行
`npm run build`
,它会自行取得同一把内容构建锁,避免多个构建同时读取或覆盖文章数据及静态网站。
验证阶段会输出 PM2 当前运行的全部项目,并只保留当前版本和上一个成功版本。清理脚本不会删除其他 pipeline 尚未完成的 release;本 pipeline 失败产生的目录会在自己的验证阶段清理。即使失败目录已被清理,重试 deploy job 也会重新创建 release 和依赖。若 Runner 被强制终止导致验证阶段未执行,可能留下一个未完成目录,但不会影响线上版本,可在确认没有对应 pipeline 运行后手动删除。
普通本地构建或不包含 release 切换的 CI 可以直接运行
`npm run build`
,它会自行取得同一把锁。这样两条流水线不会同时读取或覆盖文章数据,也不会同时替换静态网站。
README.md
View file @
17a35237
...
@@ -42,5 +42,8 @@ npm start # 启动 standalone 服务
...
@@ -42,5 +42,8 @@ npm start # 启动 standalone 服务
-
Nginx 配置:
`deploy/nginx/yinzhuang.conf`
-
Nginx 配置:
`deploy/nginx/yinzhuang.conf`
-
生产端口:
`8790`
-
生产端口:
`8790`
-
站点域名:
`www.inzung.cn`
-
站点域名:
`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
)
。
scripts/cleanup-releases.sh
deleted
100644 → 0
View file @
b6f59780
#!/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
scripts/deploy-release.sh
deleted
100644 → 0
View file @
b6f59780
#!/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
"
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment