PuppetGC 生产服务器部署复盘

木偶AI正在绞尽脑汁想思路ING···
木偶のAI摘要
DeepSeek-Chat

这篇不是单纯命令清单,而是一份可复盘的上线笔记:部署路径、脚本、检查命令、故障现象和处理方式都放在一起,后面排查同类问题可以直接按章节跳转。

一、部署目标

这次要把 PuppetGC 部署到 Ubuntu 服务器,项目目录固定为:

1
/opt/PuppetGC

整体结构如下:

1
2
3
4
5
6
7
8
Nginx
├─ puppetgc.cn / www.puppetgc.cn 官网静态资源
├─ app.puppetgc.cn 创作者端静态资源
├─ admin.puppetgc.cn 管理后台静态资源
└─ api.puppetgc.cn 反向代理到 127.0.0.1:9620

FastAPI
└─ puppetgc-api.service systemd 托管,仅监听本机 9620

核心原则:

API 不直接暴露公网,只允许 Nginx 反代访问

真实生产密钥只放在 config/production.env,不提交 Git

SQLite 数据库和上传文件每天自动备份

所有上线前检查都以 /health 为准

部署过程

  1. 推送代码

本地完成提交并推送到 GitHub,排除 PuppetGC.tarPuppetGC.tar.zip 这类大包产物。

  1. 服务器拉取

服务器 /opt/PuppetGC 拉取最新代码,准备 config/production.env

  1. 启动 API

执行 deploy/scripts/deploy-api.sh,创建 Python 3.13 虚拟环境,安装依赖并写入 systemd 服务。

  1. 排查失败

curl 127.0.0.1:9620/health 失败后,通过 journalctl 定位到生产弱密码校验失败。

  1. 补齐备份

配置 backup-sqlite.sh + root crontab,每天凌晨自动备份数据库和上传文件。

二、服务器基础准备

先安装基础依赖:

1
2
sudo apt update
sudo apt install -y nginx git curl rsync sqlite3 python3.13 python3.13-venv

确认版本:

1
2
3
python3.13 --version
sqlite3 --version
nginx -v

准备目录:

1
2
3
sudo mkdir -p /opt/PuppetGC
sudo mkdir -p /var/log/puppetgc
sudo mkdir -p /var/backups/puppetgc

三、拉取代码

进入部署目录:

1
2
3
cd /opt
sudo git clone https://github.com/Pupper0601/PuppetGC.git
cd /opt/PuppetGC

后续更新代码:

1
2
cd /opt/PuppetGC
git pull --ff-only

如果服务器网络访问 GitHub 不稳定,可以临时配置代理再拉取:

1
2
3
export HTTP_PROXY=http://127.0.0.1:7890
export HTTPS_PROXY=http://127.0.0.1:7890
git pull --ff-only

四、生产配置

复制模板:

1
2
3
cd /opt/PuppetGC
cp config/production.env.example config/production.env
nano config/production.env

关键配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PORT=9620
APP_ENV=production
JWT_SECRET=替换为至少32位随机字符串
ENCRYPTION_KEY=替换为生产随机密钥
CORS_ALLOW_ORIGINS=https://admin.puppetgc.cn,https://app.puppetgc.cn,https://puppetgc.cn,https://www.puppetgc.cn

ADMIN_SEED_EMAIL=admin@example.com
ADMIN_SEED_PASSWORD=替换为至少12位强密码

SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=mail@example.com
SMTP_PASS=替换为SMTP授权码
SMTP_FROM=mail@example.com
SMTP_FROM_NAME=PuppetGC
MAIL_LOG_CODE=false

生成随机密钥可以用:

1
openssl rand -hex 32

设置权限:

1
2
sudo chown root:www-data config/production.env
sudo chmod 640 config/production.env

注意:production.env 是真实生产配置,必须在 .gitignore 中排除,不能提交。

APP_ENV=production 打开后,API 会在启动阶段主动校验生产配置。弱密码、默认密钥、不安全 CORS 都会让服务启动失败,这是故意设计的安全闸门。

五、部署 API 服务

项目里已经准备了部署脚本:

1
2
cd /opt/PuppetGC
bash deploy/scripts/deploy-api.sh

脚本主要做这些事:

  1. 检查 config/production.env 是否存在。
  2. 拉取最新代码。
  3. 创建或复用 apps/api-py/.venv
  4. 安装 Python 依赖。
  5. 初始化数据目录和日志目录权限。
  6. 写入 systemd 服务 puppetgc-api.service
  7. 重启 API。
  8. 请求 http://127.0.0.1:9620/health 做健康检查。

部署后检查:

1
2
sudo systemctl status puppetgc-api --no-pager
curl http://127.0.0.1:9620/health

正常应该返回类似:

1
{"ok":true}
1
2
cd /opt/PuppetGC
bash deploy/scripts/deploy-api.sh
1
2
sudo systemctl status puppetgc-api --no-pager
sudo journalctl -u puppetgc-api -n 100 --no-pager
1
2
curl http://127.0.0.1:9620/health
sudo ss -lntp | grep 9620 || true

六、Nginx 配置

API 域名的重点是反向代理到本机 9620:

1
2
3
4
5
6
7
8
9
10
11
12
server {
listen 80;
server_name api.puppetgc.cn;

location / {
proxy_pass http://127.0.0.1:9620;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

检查并重载:

1
2
sudo nginx -t
sudo systemctl reload nginx

公网验证:

1
curl http://api.puppetgc.cn/health

如果已经配置 HTTPS:

1
curl https://api.puppetgc.cn/health

七、每日备份

备份脚本:

1
/opt/PuppetGC/deploy/scripts/backup-sqlite.sh

脚本默认备份:

  • SQLite 数据库:/opt/PuppetGC/apps/api-py/data/puppetgc.db
  • 上传文件:/opt/PuppetGC/apps/api-py/data/uploads
  • 备份目录:/var/backups/puppetgc
  • 保留天数:14 天

先手动验证:

1
2
3
sudo chmod +x /opt/PuppetGC/deploy/scripts/backup-sqlite.sh
sudo /opt/PuppetGC/deploy/scripts/backup-sqlite.sh
ls -lh /var/backups/puppetgc

配置每日凌晨 03:20 自动备份:

1
sudo crontab -e

加入:

1
20 3 * * * /opt/PuppetGC/deploy/scripts/backup-sqlite.sh >/var/log/puppetgc-backup.log 2>&1

如果想保留 30 天:

1
20 3 * * * KEEP_DAYS=30 /opt/PuppetGC/deploy/scripts/backup-sqlite.sh >/var/log/puppetgc-backup.log 2>&1

查看备份日志:

1
sudo tail -n 100 /var/log/puppetgc-backup.log

备份脚本要先手动跑通一次,再交给 crontab。这样可以提前发现 sqlite3 未安装、目录权限不对、数据库文件路径不一致等问题。

八、部署脚本留档

这些脚本是这次部署闭环的关键,放在文章里是为了后面复盘时不用再翻仓库。真实生产密钥仍然只在服务器 config/production.env,脚本里不能写死。

deploy-api.sh:部署并重启 API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/usr/bin/env bash
set -euo pipefail

REPO_DIR="${REPO_DIR:-/opt/PuppetGC}"
PYTHON_BIN="${PYTHON_BIN:-python3.13}"
SERVICE_NAME="${SERVICE_NAME:-puppetgc-api.service}"
SERVICE_TARGET="/etc/systemd/system/${SERVICE_NAME}"

cd "${REPO_DIR}"

if [ ! -f "config/production.env" ]; then
echo "缺少 config/production.env,请先复制 production.env.example 并填写真实生产配置。"
exit 1
fi

sudo chown root:www-data "${REPO_DIR}/config/production.env"
sudo chmod 640 "${REPO_DIR}/config/production.env"

if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git pull --ff-only
fi

cd "${REPO_DIR}/apps/api-py"

if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "未找到 ${PYTHON_BIN}。请先安装 Python 3.13,或用 PYTHON_BIN=/path/to/python3.13 指定解释器。"
exit 1
fi

if [ -d ".venv" ]; then
VENV_VERSION="$(.venv/bin/python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
if [ "${VENV_VERSION}" != "3.13" ]; then
echo "当前 apps/api-py/.venv 使用 Python ${VENV_VERSION},项目生产部署要求 Python 3.13。"
echo "请执行:rm -rf ${REPO_DIR}/apps/api-py/.venv"
echo "然后重新运行:bash deploy/scripts/deploy-api.sh"
exit 1
fi
else
"${PYTHON_BIN}" -m venv .venv
fi

. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt

sudo mkdir -p "${REPO_DIR}/apps/api-py/data"
sudo mkdir -p /var/log/puppetgc
sudo chown -R www-data:www-data "${REPO_DIR}/apps/api-py/data" /var/log/puppetgc

sed "s#__REPO_DIR__#${REPO_DIR}#g" "${REPO_DIR}/deploy/systemd/puppetgc-api.service" | sudo tee "${SERVICE_TARGET}" >/dev/null

sudo systemctl daemon-reload
sudo systemctl enable puppetgc-api
sudo systemctl restart puppetgc-api

sleep 2
curl -fsS http://127.0.0.1:9620/health
echo
echo "PuppetGC API 已启动。"
backup-sqlite.sh:每日备份 SQLite 和上传文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env bash
set -euo pipefail

REPO_DIR="${REPO_DIR:-/opt/PuppetGC}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/puppetgc}"
KEEP_DAYS="${KEEP_DAYS:-14}"
STAMP="$(date +%Y%m%d-%H%M%S)"
DATA_DIR="${REPO_DIR}/apps/api-py/data"
DB_PATH="${DATA_DIR}/puppetgc.db"
TARGET_DIR="${BACKUP_DIR}/${STAMP}"

mkdir -p "${TARGET_DIR}"

if [ -f "${DB_PATH}" ]; then
sqlite3 "${DB_PATH}" ".backup '${TARGET_DIR}/puppetgc.db'"
else
echo "未找到数据库文件:${DB_PATH}"
fi

if [ -d "${DATA_DIR}/uploads" ]; then
tar -czf "${TARGET_DIR}/uploads.tar.gz" -C "${DATA_DIR}" uploads
fi

find "${BACKUP_DIR}" -mindepth 1 -maxdepth 1 -type d -mtime +"${KEEP_DAYS}" -exec rm -rf {} +

echo "备份完成:${TARGET_DIR}"
deploy-static.sh:构建并发布静态站点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env bash
set -euo pipefail

REPO_DIR="${REPO_DIR:-/opt/PuppetGC}"
WEB_ROOT="${WEB_ROOT:-/var/www/puppetgc}"
VITE_API_BASE_URL="${VITE_API_BASE_URL:-https://api.puppetgc.cn}"
VITE_APP_URL="${VITE_APP_URL:-https://app.puppetgc.cn}"
VITE_ADMIN_URL="${VITE_ADMIN_URL:-https://admin.puppetgc.cn}"
VITE_DOWNLOAD_URL="${VITE_DOWNLOAD_URL:-https://download.puppetgc.cn}"

export VITE_API_BASE_URL
export VITE_APP_URL
export VITE_ADMIN_URL
export VITE_DOWNLOAD_URL

cd "${REPO_DIR}"

if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git pull --ff-only
fi

pnpm install --frozen-lockfile
pnpm build:web
pnpm build:admin
pnpm build:website

sudo mkdir -p "${WEB_ROOT}/app" "${WEB_ROOT}/admin" "${WEB_ROOT}/www" "${WEB_ROOT}/download/windows" "${WEB_ROOT}/download/mac"
sudo rsync -a --delete "${REPO_DIR}/apps/desktop/src/renderer/dist/" "${WEB_ROOT}/app/"
sudo rsync -a --delete "${REPO_DIR}/apps/admin/dist/" "${WEB_ROOT}/admin/"
sudo rsync -a --delete "${REPO_DIR}/apps/website/dist/" "${WEB_ROOT}/www/"
sudo chown -R www-data:www-data "${WEB_ROOT}"

echo "静态站点已部署:${WEB_ROOT}"
apply-upload-package.sh:无 Git 场景下应用上传包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#!/usr/bin/env bash
set -euo pipefail

PACKAGE_PATH="${1:-/tmp/PuppetGC.zip}"
REPO_DIR="${REPO_DIR:-/opt/PuppetGC}"
WORK_DIR="$(mktemp -d /tmp/puppetgc-upload.XXXXXX)"

cleanup() {
rm -rf "${WORK_DIR}"
}
trap cleanup EXIT

if [ ! -f "${PACKAGE_PATH}" ]; then
echo "未找到上传包:${PACKAGE_PATH}"
exit 1
fi

if ! command -v unzip >/dev/null 2>&1; then
echo "缺少 unzip,请先执行:sudo apt install -y unzip"
exit 1
fi

if ! command -v rsync >/dev/null 2>&1; then
echo "缺少 rsync,请先执行:sudo apt install -y rsync"
exit 1
fi

mkdir -p "${REPO_DIR}"
unzip -q "${PACKAGE_PATH}" -d "${WORK_DIR}"

SOURCE_DIR="${WORK_DIR}"
if [ -d "${WORK_DIR}/PuppetGC" ]; then
SOURCE_DIR="${WORK_DIR}/PuppetGC"
fi

if [ ! -f "${SOURCE_DIR}/package.json" ] || [ ! -d "${SOURCE_DIR}/apps" ]; then
echo "上传包结构不正确:需要包含 package.json 和 apps/。"
exit 1
fi

rsync -a --delete \
--exclude '.git/' \
--exclude 'node_modules/' \
--exclude '.venv/' \
--exclude 'dist/' \
--exclude 'out/' \
--exclude 'config/production.env' \
--exclude 'apps/api-py/data/' \
"${SOURCE_DIR}/" "${REPO_DIR}/"

echo "上传包已应用到 ${REPO_DIR}。生产配置与 API 数据目录已保留。"

这几个脚本的共同点是:先失败、再退出。set -euo pipefail 可以避免命令失败后继续往下跑,部署脚本尤其需要这种保守策略。

九、问题一:curl 本机 9620 失败

现象:

1
2
curl http://127.0.0.1:9620/health
curl: (7) Failed to connect to 127.0.0.1 port 9620 after 0 ms: Couldn't connect to server

判断:

这不是 Nginx 问题,而是 API 进程没有监听 9620。

排查命令:

1
2
3
sudo systemctl status puppetgc-api --no-pager
sudo journalctl -u puppetgc-api -n 100 --no-pager
sudo ss -lntp | grep 9620 || true

这三个命令分别确认:

  • systemd 服务是否启动。
  • 服务启动失败的具体异常。
  • 9620 端口是否真的有进程监听。

curl 127.0.0.1:9620 都连不上时,说明本机端口没有进程监听。此时不要先查域名、证书、Nginx,先查 puppetgc-api 服务。

十、问题二:生产配置弱密码导致 API 启动失败

日志里看到:

1
2
RuntimeError: 生产配置不安全:ADMIN_SEED_PASSWORD 必须替换为强密码
ERROR: Application startup failed. Exiting.

原因:

APP_ENV=production 后,API 启动时会执行生产安全校验。ADMIN_SEED_PASSWORD 如果还是默认值,或长度小于 12 位,就会拒绝启动。

解决:

1
2
cd /opt/PuppetGC
sudo nano config/production.env

修改:

1
ADMIN_SEED_PASSWORD=一个至少12位的强密码

然后重启:

1
2
3
sudo systemctl restart puppetgc-api
sudo systemctl status puppetgc-api --no-pager
curl http://127.0.0.1:9620/health

这次真正的根因就是 ADMIN_SEED_PASSWORD 没换成强密码。生产校验报错虽然看起来烦,但它避免了弱口令上线。

如果还有其他配置问题,日志会继续指出,例如:

  • JWT_SECRET 太短或仍是默认值。
  • ENCRYPTION_KEY 太短或仍是默认值。
  • CORS_ALLOW_ORIGINS 为空,或包含 *nulllocalhost127.0.0.1

十一、问题三:Git 推送 TLS 中断

本地提交后推送 GitHub 时遇到:

1
2
fatal: unable to access 'https://github.com/Pupper0601/PuppetGC.git/':
TLS connect error: unexpected eof while reading

原因:

网络到 GitHub 的 TLS 连接被中断,不是代码问题。

解决:

1
2
3
$env:HTTP_PROXY='http://127.0.0.1:7890'
$env:HTTPS_PROXY='http://127.0.0.1:7890'
git push origin main

推送成功后用下面命令确认本地和远端同步:

1
git rev-list --left-right --count origin/main...HEAD

返回:

1
0 0

说明本地和远端一致。

十二、问题四:误以为还有 21 个文件没提交

现象:

IDE 里看到还有一批未提交文件,以为 PuppetGC 没有提交干净。

实际情况:

E:\PuppetGC 仓库已经提交并推送完成,只剩两个未跟踪的大包:

1
2
PuppetGC.tar
PuppetGC.tar.zip

这两个是 100MB 以上的打包产物,不应该当源码提交。

IDE 里看到的另一批文件来自另一个仓库:

1
C:\Users\puppe\.openclaw\workspace

排查方式:

1
2
git status --short --branch
git -C C:\Users\puppe\.openclaw\workspace status --short --branch

教训:

看到 IDE 的 Git 面板提示时,要先确认当前打开的是哪个 Git 仓库,尤其是同时打开多个项目目录时。

十三、问题五:部署文档误写真实密钥

部署文档里曾经出现过真实的 JWT_SECRETENCRYPTION_KEYADMIN_SEED_PASSWORDSMTP_PASS

处理方式:

  1. 提交前先用搜索扫敏感字段。
  2. 把文档里的真实值替换成占位符。
  3. 确认暂存区没有真实密钥。
  4. 如果密钥曾经推送到远端,必须立刻轮换。

本地检查命令:

1
rg -n --hidden --glob '!.git/**' --glob '!node_modules/**' -i "(api[_-]?key|secret|token|password|passwd|private[_-]?key|SMTP_PASS|JWT_SECRET|ENCRYPTION_KEY)" .

提交前再查暂存区:

1
2
git diff --cached --check
git diff --cached --name-only

密钥类配置的原则:

示例文档只写占位符

.envproduction.env 不进 Git

如果真实值被复制进文档,就当作已经泄漏处理,重新生成

十四、最终检查清单

上线前按这个清单过一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cd /opt/PuppetGC

# API 服务
sudo systemctl status puppetgc-api --no-pager
curl http://127.0.0.1:9620/health

# Nginx
sudo nginx -t
sudo systemctl reload nginx
curl https://api.puppetgc.cn/health

# 备份
sudo /opt/PuppetGC/deploy/scripts/backup-sqlite.sh
ls -lh /var/backups/puppetgc

# 日志
sudo journalctl -u puppetgc-api -n 100 --no-pager
sudo tail -n 100 /var/log/puppetgc-backup.log

只要这些都通过,说明后端、反代和备份链路都已经闭环。

1
2
3
sudo systemctl status puppetgc-api --no-pager
curl http://127.0.0.1:9620/health
sudo journalctl -u puppetgc-api -n 100 --no-pager
1
2
3
sudo nginx -t
sudo systemctl reload nginx
curl https://api.puppetgc.cn/health
1
2
3
sudo /opt/PuppetGC/deploy/scripts/backup-sqlite.sh
ls -lh /var/backups/puppetgc
sudo tail -n 100 /var/log/puppetgc-backup.log
1
2
git status --short --branch
git rev-list --left-right --count origin/main...HEAD

十五、这次部署的关键经验

curl 127.0.0.1:9620/health 失败时,优先查 systemd 和端口监听,不要先怀疑 Nginx

生产环境必须让配置校验严格一点,宁可启动失败,也不要带弱密码上线

部署文档不能写真实密钥,哪怕只是临时记录

大包、构建产物、数据库文件不要提交到代码仓库

多仓库同时打开时,先确认 git status 的工作目录

备份必须手动跑通一次,再交给 crontab

这次的部署问题都不复杂,但很典型:服务没监听看日志,生产配置不安全就改配置,敏感信息进文档就替换并轮换。把这些流程固定下来,后续上线就会稳很多。

十六、第二阶段:从手动 pull 改成 GitHub Release 发布

第一次上线后,原始流程是:

1
2
3
本地提交代码
→ 服务器 git pull
→ 服务器执行部署脚本

这个流程早期能用,但生产上有几个明显问题:

  • 线上到底跑的是哪个版本,不够清楚。
  • 回滚靠手动记 commit,风险大。
  • 服务器既负责运行,又负责构建,职责混在一起。
  • 代码推送、构建产物、部署记录没有统一入口。

所以第二阶段改成:

1
2
3
4
5
6
本地 push main
→ 打 tag
→ GitHub Release
→ GitHub Actions 构建 Web/Admin/官网
→ scp 产物到服务器
→ 服务器解包并重启 API

需要注意的是:push main 不等于上线。真正触发生产发布的是 GitHub Release,或者手动运行工作流。

正式发布命令:

1
2
3
4
5
6
git add .
git commit -m 'xxx'
git push origin main

git tag -a v0.1.3 -m 'v0.1.3'
git push origin v0.1.3

然后在 GitHub 页面:

1
Releases -> Draft a new release -> 选择 v0.1.3 -> Publish release

当前工作流只构建:

1
2
3
4
app.puppetgc.cn     Web 创作端
admin.puppetgc.cn 运营后台
puppetgc.cn 官网
api.puppetgc.cn API 后端

Windows/macOS 客户端打包暂缓,避免客户端问题阻塞 Web 端上线。

这次最大的流程变化是:服务器不再是 Git 工作台,而只是运行环境。以后不要再在服务器手动 git pull 更新生产代码,统一通过 GitHub Release 发布。

十七、问题六:服务器已有 clone,再用 Actions 推送会不会冲突

服务器最早是通过 git clone 得到:

1
/opt/PuppetGC

后来 GitHub Actions 采用源码包部署:

1
2
3
4
GitHub Actions
→ git archive 生成源码包
→ scp 到服务器
→ rsync 覆盖 /opt/PuppetGC

结论:

1
不冲突,但不要混用。

即使 /opt/PuppetGC/.git 还在,当前发布流程也不会依赖服务器执行 git pull。后续可以选择保留 .git,也可以删掉:

1
sudo rm -rf /opt/PuppetGC/.git

但删不删不是关键。关键是团队约定:

服务器不再手动 git pull

上线只认 GitHub Release

回滚通过 Actions 手动指定旧 tag

十八、问题七:SSH 部署密钥和 known_hosts 混淆

GitHub Actions 通过 SSH 登录服务器时,需要三类东西:

1
2
3
部署私钥:DEPLOY_SSH_PRIVATE_KEY
部署公钥:写入服务器 ~/.ssh/authorized_keys
服务器指纹:DEPLOY_SSH_KNOWN_HOSTS

这三者作用不同:

放哪里 作用
puppetgc_deploy_key GitHub Secret Actions 登录服务器用的私钥
puppetgc_deploy_key.pub 服务器 authorized_keys 允许这把私钥登录
DEPLOY_SSH_KNOWN_HOSTS GitHub Secret 校验连到的真是目标服务器

生成部署 key:

1
ssh-keygen -t ed25519 -C 'github-actions-puppetgc-deploy' -f .\puppetgc_deploy_key

把公钥加到服务器:

1
2
3
4
mkdir -p ~/.ssh
echo 'ssh-ed25519 AAAA... github-actions-puppetgc-deploy' >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

known_hosts 可以从本机已有记录取:

1
ssh-keygen -F 101.43.9.80

填入 GitHub Secret 时,只填类似下面的主机记录,不填注释行:

1
2
3
101.43.9.80 ssh-ed25519 AAAA...
101.43.9.80 ssh-rsa AAAA...
101.43.9.80 ecdsa-sha2-nistp256 AAAA...

曾经遇到的问题:

1
Permission denied (publickey,password)

判断:

这不是 known_hosts 问题。如果是 known_hosts,通常会报:

1
Host key verification failed

解决方式:

  1. 先确认本机能免密登录:
1
ssh -i .\puppetgc_deploy_key ubuntu@101.43.9.80
  1. 如果仍要求输入密码,说明公钥没有写入对应用户的 authorized_keys
  2. GitHub Secret 里的 DEPLOY_USER 必须和能免密登录的用户一致。

puppetgc_deploy_key 是私钥,不能提交。后来已经把 puppetgc_deploy_keypuppetgc_deploy_key.pub 加入 .gitignore,避免误传到 GitHub。

十九、问题八:Git tag 名称写乱

发布过程中出现过:

1
2
fatal: tag 'v0.1.0' already exists
error: src refspec v0.1.1 does not match any

原因:

  • 本地已经有 v0.1.0,重复创建会失败。
  • 推送 v0.1.1 前,并没有先创建 v0.1.1

检查 tag:

1
2
git tag --list
git tag --points-at HEAD

创建并推送新 tag:

1
2
git tag -a v0.1.3 -m 'v0.1.3'
git push origin v0.1.3

如果 tag 已经创建错了,还没有推远端,可以删本地:

1
git tag -d v0.1.3

如果已经推远端,删除要更谨慎:

1
2
git push origin :refs/tags/v0.1.3
git tag -d v0.1.3

教训:

tag 名和 Release 名保持一致

每次发布前先 git tag --list 看现有版本

不要把 v0.1.0 的 tag message 写成 v0.1.1

二十、问题九:GitHub Actions 里 pnpm 找不到

发布 v0.1.2 时,GitHub Actions 报错:

1
2
Node 20 is being deprecated.
Error: Unable to locate executable file: pnpm.

原因:

workflow 写成了:

1
2
3
4
5
6
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- run: corepack enable

setup-node 开启 cache: pnpm 时,runner 里还没有 pnpm,所以缓存步骤直接失败。

解决:

改成先安装 pnpm,再启用 Node 缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
env:
NODE_VERSION: '24'
PNPM_VERSION: '10.33.0'

steps:
- name: 配置 pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false

- name: 配置 Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

同时把 Node 从 20 升到 24,避免 runner 对 Node 20 的弃用警告。

验证结果:

1
2
3
v0.1.3
构建发布产物:success
推送产物到生产服务器:success

二十一、问题十:Actions 上传特别慢

一开始发布包上传很慢,十几分钟还没结束。

排查后发现源码包里带了本地数据:

1
apps/api-py/data/storage/...

这些文件本来是运行期数据,不应该进入代码发布包。

本地对比:

1
2
优化前源码包:约 113MB
优化后源码包:约 1.75MB

解决:

新增 .gitattributes

1
2
3
apps/api-py/data/** export-ignore
apps/api-py/*.db export-ignore
apps/api-py/*.db-* export-ignore

作用:

git archive 生成源码包时,会自动排除这些运行期数据。

同时 workflow 做了两个小优化:

1
2
fetch-depth: 1
cache: pnpm

教训:

代码包只放代码,不放数据库和上传文件

.gitignore 防止本地误提交,.gitattributes export-ignore 防止发布包误打包

运行期数据应该进对象存储或备份系统,不进 GitHub Release 源码包

二十二、问题十一:COSFS 挂载目录被删导致 deleted 挂载

对象存储迁移时,目标是把 API 文件目录:

1
/opt/PuppetGC/apps/api-py/data/storage

迁到腾讯 COS。

错误过程:

  1. 先创建 /mnt/puppetgc-bucket/storage
  2. 把它 bind 到应用目录。
  3. 后来又删了 /mnt/puppetgc-bucket/storage

结果 findmnt 显示:

1
/opt/PuppetGC/apps/api-py/data/storage /dev/vda2[/mnt/puppetgc-bucket/storage//deleted] ext4

这说明挂载源是本地 ext4 里的一个已删除目录,不是 COS。

处理方式:

1
2
3
4
5
sudo systemctl stop puppetgc-api
sudo umount /opt/PuppetGC/apps/api-py/data/storage
sudo mkdir -p /opt/PuppetGC/apps/api-py/data/storage
sudo rsync -a /opt/PuppetGC/apps/api-py/data/storage.bak/ /opt/PuppetGC/apps/api-py/data/storage/
sudo systemctl start puppetgc-api

后来真正成功的 COS 挂载状态是:

1
2
TARGET                                 SOURCE FSTYPE
/opt/PuppetGC/apps/api-py/data/storage cosfs fuse.cosfs

看到 deleted 挂载时,不要继续往里写文件。先停服务、卸载坏挂载、确认真实挂载源,再恢复数据。

二十三、问题十二:rsync 到 COSFS 时 chown 报 Input/output error

恢复 storage.bak 到 COSFS 时,第一次用了:

1
sudo rsync -a storage.bak/ storage/

最后报错:

1
2
rsync: chown ".../storage/." failed: Input/output error (5)
rsync error: some files/attrs were not transferred (code 23)

原因:

COSFS 不是普通 ext4 文件系统,不支持完整的 POSIX owner/group/perms 操作。rsync -a 会保留 owner、group、权限和时间戳,所以在 chown 阶段失败。

解决:

同步到 COSFS 时不要使用 -a,改用:

1
2
3
sudo rsync -rtv --omit-dir-times --no-owner --no-group --no-perms \
/opt/PuppetGC/apps/api-py/data/storage.bak/ \
/opt/PuppetGC/apps/api-py/data/storage/

恢复后验证:

1
2
3
4
find /opt/PuppetGC/apps/api-py/data/storage.bak -type f | wc -l
find /opt/PuppetGC/apps/api-py/data/storage -type f | wc -l
du -sh /opt/PuppetGC/apps/api-py/data/storage
sudo -u www-data sh -c 'echo ok > /opt/PuppetGC/apps/api-py/data/storage/.write-test && rm -f /opt/PuppetGC/apps/api-py/data/storage/.write-test'

当时结果:

1
2
3
4
备份文件数:116
恢复后文件数:116
恢复后大小:112M
www-data 写入测试:通过

二十四、问题十三:COS 挂载必须由 systemd 管理

手动挂载 COS 成功后,还有一个隐患:

1
2
3
服务器重启后 COS 没挂上
API 先启动
上传文件写回本地空目录

解决方式:

新增 systemd 服务:

1
puppetgc-cos-storage.service

核心配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Unit]
Description=PuppetGC COS storage mount
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/usr/bin/mkdir -p /opt/PuppetGC/apps/api-py/data/storage
ExecStart=/usr/local/bin/cosfs puppetgc-1300525283:/mnt/storage /opt/PuppetGC/apps/api-py/data/storage -ourl=http://cos.ap-shanghai.myqcloud.com -odbglevel=err -oallow_other -opublic_bucket=1 -oensure_diskfree=10240
ExecStartPost=/usr/bin/findmnt /opt/PuppetGC/apps/api-py/data/storage
ExecStop=/usr/bin/umount /opt/PuppetGC/apps/api-py/data/storage
TimeoutStartSec=60
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

然后让 API 依赖 COS 挂载:

1
2
3
4
[Unit]
After=network.target puppetgc-cos-storage.service
Wants=puppetgc-cos-storage.service
Requires=puppetgc-cos-storage.service

启用:

1
2
3
4
5
sudo systemctl daemon-reload
sudo systemctl enable puppetgc-cos-storage
sudo systemctl start puppetgc-cos-storage
sudo systemctl enable puppetgc-api
sudo systemctl restart puppetgc-api

验证:

1
2
3
4
sudo systemctl status puppetgc-cos-storage --no-pager
findmnt /opt/PuppetGC/apps/api-py/data/storage
sudo systemctl status puppetgc-api --no-pager
curl http://127.0.0.1:9620/health

最终状态:

1
2
3
4
puppetgc-cos-storage.service enabled
puppetgc-api.service enabled
storage FSTYPE = fuse.cosfs
API /health = ok

二十五、问题十四:公网 curl reset,但服务器本机验证正常

发布 v0.1.3 后,本机访问:

1
curl https://api.puppetgc.cn/health

出现:

1
Recv failure: Connection was reset

但在服务器本机验证:

1
2
3
4
curl -fsS --resolve api.puppetgc.cn:443:127.0.0.1 https://api.puppetgc.cn/health
curl -fsS --resolve app.puppetgc.cn:443:127.0.0.1 https://app.puppetgc.cn/release.json
curl -fsS --resolve admin.puppetgc.cn:443:127.0.0.1 https://admin.puppetgc.cn/release.json
curl -fsS --resolve puppetgc.cn:443:127.0.0.1 https://puppetgc.cn/release.json

结果全部正常:

1
{"ok":true,"service":"PuppetGC-api"}

release.json 也显示:

1
2
3
4
{
"version": "v0.1.3",
"git_commit_short": "e772c1b"
}

判断:

Nginx、证书、API、静态站点在服务器侧都正常。外部 curl reset 更像本机网络、运营商、代理或 TLS 链路问题。最终验收应结合:

  • 服务器本机 --resolve 验证。
  • 浏览器访问正式域名。
  • Nginx access/error log。

二十六、问题十五:SQLite 只能短期顶住

当前核心业务数据还在:

1
/opt/PuppetGC/apps/api-py/data/puppetgc.db

文件已经迁到 COS,但数据库仍是 SQLite。

短期可以用于内测,但长期生产有风险:

  • 并发写入能力弱。
  • 多实例部署困难。
  • 备份、恢复、审计不如 PostgreSQL。
  • 复杂查询和数据增长后风险变高。

下一步建议迁移到 PostgreSQL:

1
2
3
4
5
1. 先保证 SQLite 每日备份。
2. 后端支持 DATABASE_URL。
3. 检查 SQLAlchemy 模型兼容 PostgreSQL。
4. 编写 SQLite -> PostgreSQL 迁移脚本。
5. 停 API,备份 SQLite,迁移数据,切 DATABASE_URL。

短期底线:

SQLite 文件必须每天备份

SQLite 不放 COSFS

文件目录走 COS,数据库仍留本地磁盘

二十七、第二阶段后的新检查清单

当前生产发布完成后,检查项更新为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# GitHub Actions
gh run list --repo Pupper0601/PuppetGC --workflow "发布版本并部署生产" --limit 5

# API
curl http://127.0.0.1:9620/health
sudo systemctl status puppetgc-api --no-pager

# COS
sudo systemctl status puppetgc-cos-storage --no-pager
findmnt /opt/PuppetGC/apps/api-py/data/storage
sudo -u www-data test -w /opt/PuppetGC/apps/api-py/data/storage && echo ok

# 静态站点版本
curl -fsS --resolve app.puppetgc.cn:443:127.0.0.1 https://app.puppetgc.cn/release.json
curl -fsS --resolve admin.puppetgc.cn:443:127.0.0.1 https://admin.puppetgc.cn/release.json
curl -fsS --resolve puppetgc.cn:443:127.0.0.1 https://puppetgc.cn/release.json

# Nginx
sudo nginx -t
sudo systemctl status nginx --no-pager

现在一次成功发布的标志是:

1
2
3
4
5
6
GitHub Actions success
API /health ok
release.json version = 最新 tag
storage FSTYPE = fuse.cosfs
puppetgc-api active
puppetgc-cos-storage active

二十八、第二阶段的关键经验

push main 只是提交代码,GitHub Release 才是上线

Actions 里要先安装 pnpm,再让 setup-node 使用 pnpm cache

发布源码包必须排除运行期数据,.gitattributes export-ignore 很关键

COSFS 不是 ext4,不要对挂载目录 chown -Rrsync -a

对象存储挂载必须 systemd 化,API 要依赖挂载服务启动

SQLite 只能短期用于内测,生产增长后要迁 PostgreSQL

这次第二阶段比第一次更折腾:从 GitHub Release、Actions、SSH Secret、tag、COSFS、systemd 到 SQLite 风险,几乎把小团队上线会踩的坑踩了一遍。好处是现在链路已经更清晰:代码归 GitHub,发布归 Release,文件归 COS,进程归 systemd,服务器只负责稳定运行。