这篇不是单纯命令清单,而是一份可复盘的上线笔记:部署路径、脚本、检查命令、故障现象和处理方式都放在一起,后面排查同类问题可以直接按章节跳转。
一、部署目标 这次要把 PuppetGC 部署到 Ubuntu 服务器,项目目录固定为:
整体结构如下:
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
核心原则:
本地完成提交并推送到 GitHub,排除 PuppetGC.tar、PuppetGC.tar.zip 这类大包产物。
服务器 /opt/PuppetGC 拉取最新代码,准备 config/production.env。
执行 deploy/scripts/deploy-api.sh,创建 Python 3.13 虚拟环境,安装依赖并写入 systemd 服务。
curl 127.0.0.1:9620/health 失败后,通过 journalctl 定位到生产弱密码校验失败。
配置 backup-sqlite.sh + root crontab,每天凌晨自动备份数据库和上传文件。
二、服务器基础准备 先安装基础依赖:
1 2 sudo apt updatesudo 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/PuppetGCsudo mkdir -p /var/log/puppetgcsudo mkdir -p /var/backups/puppetgc
三、拉取代码 进入部署目录:
1 2 3 cd /optsudo git clone https://github.com/Pupper0601/PuppetGC.gitcd /opt/PuppetGC
后续更新代码:
1 2 cd /opt/PuppetGCgit pull --ff-only
如果服务器网络访问 GitHub 不稳定,可以临时配置代理再拉取:
1 2 3 export HTTP_PROXY=http://127.0.0.1:7890export HTTPS_PROXY=http://127.0.0.1:7890git pull --ff-only
四、生产配置 复制模板:
1 2 3 cd /opt/PuppetGCcp config/production.env.example config/production.envnano 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 2 sudo chown root:www-data config/production.envsudo chmod 640 config/production.env
注意:production.env 是真实生产配置,必须在 .gitignore 中排除,不能提交。
APP_ENV=production 打开后,API 会在启动阶段主动校验生产配置。弱密码、默认密钥、不安全 CORS 都会让服务启动失败,这是故意设计的安全闸门。
五、部署 API 服务 项目里已经准备了部署脚本:
1 2 cd /opt/PuppetGCbash deploy/scripts/deploy-api.sh
脚本主要做这些事:
检查 config/production.env 是否存在。
拉取最新代码。
创建或复用 apps/api-py/.venv。
安装 Python 依赖。
初始化数据目录和日志目录权限。
写入 systemd 服务 puppetgc-api.service。
重启 API。
请求 http://127.0.0.1:9620/health 做健康检查。
部署后检查:
1 2 sudo systemctl status puppetgc-api --no-pagercurl http://127.0.0.1:9620/health
正常应该返回类似:
1 2 cd /opt/PuppetGCbash deploy/scripts/deploy-api.sh
1 2 sudo systemctl status puppetgc-api --no-pagersudo 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 -tsudo 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.shsudo /opt/PuppetGC/deploy/scripts/backup-sqlite.shls -lh /var/backups/puppetgc
配置每日凌晨 03:20 自动备份:
加入:
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 pipefailREPO_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/puppetgcsudo chown -R www-data:www-data "${REPO_DIR} /apps/api-py/data" /var/log/puppetgcsed "s#__REPO_DIR__#${REPO_DIR} #g" "${REPO_DIR} /deploy/systemd/puppetgc-api.service" | sudo tee "${SERVICE_TARGET} " >/dev/null sudo systemctl daemon-reloadsudo systemctl enable puppetgc-apisudo systemctl restart puppetgc-apisleep 2curl -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 pipefailREPO_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 pipefailREPO_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_URLexport VITE_APP_URLexport VITE_ADMIN_URLexport VITE_DOWNLOAD_URLcd "${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 pipefailPACKAGE_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 EXITif [ ! -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-pagersudo journalctl -u puppetgc-api -n 100 --no-pagersudo 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/PuppetGCsudo nano config/production.env
修改:
1 ADMIN_SEED_PASSWORD=一个至少12位的强密码
然后重启:
1 2 3 sudo systemctl restart puppetgc-apisudo systemctl status puppetgc-api --no-pagercurl http://127.0.0.1:9620/health
这次真正的根因就是 ADMIN_SEED_PASSWORD 没换成强密码。生产校验报错虽然看起来烦,但它避免了弱口令上线。
如果还有其他配置问题,日志会继续指出,例如:
JWT_SECRET 太短或仍是默认值。
ENCRYPTION_KEY 太短或仍是默认值。
CORS_ALLOW_ORIGINS 为空,或包含 *、null、localhost、127.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
返回:
说明本地和远端一致。
十二、问题四:误以为还有 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_SECRET、ENCRYPTION_KEY、ADMIN_SEED_PASSWORD 和 SMTP_PASS。
处理方式:
提交前先用搜索扫敏感字段。
把文档里的真实值替换成占位符。
确认暂存区没有真实密钥。
如果密钥曾经推送到远端,必须立刻轮换。
本地检查命令:
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
密钥类配置的原则:
十四、最终检查清单 上线前按这个清单过一遍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 cd /opt/PuppetGCsudo systemctl status puppetgc-api --no-pagercurl http://127.0.0.1:9620/health sudo nginx -tsudo systemctl reload nginxcurl https://api.puppetgc.cn/health sudo /opt/PuppetGC/deploy/scripts/backup-sqlite.shls -lh /var/backups/puppetgcsudo journalctl -u puppetgc-api -n 100 --no-pagersudo tail -n 100 /var/log/puppetgc-backup.log
只要这些都通过,说明后端、反代和备份链路都已经闭环。
1 2 3 sudo systemctl status puppetgc-api --no-pagercurl http://127.0.0.1:9620/health sudo journalctl -u puppetgc-api -n 100 --no-pager
1 2 3 sudo nginx -tsudo systemctl reload nginxcurl https://api.puppetgc.cn/health
1 2 3 sudo /opt/PuppetGC/deploy/scripts/backup-sqlite.shls -lh /var/backups/puppetgcsudo tail -n 100 /var/log/puppetgc-backup.log
1 2 git status --short --branch git rev-list --left-right --count origin/main...HEAD
十五、这次部署的关键经验
这次的部署问题都不复杂,但很典型:服务没监听看日志,生产配置不安全就改配置,敏感信息进文档就替换并轮换。把这些流程固定下来,后续上线就会稳很多。
十六、第二阶段:从手动 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 得到:
后来 GitHub Actions 采用源码包部署:
1 2 3 4 GitHub Actions → git archive 生成源码包 → scp 到服务器 → rsync 覆盖 /opt/PuppetGC
结论:
即使 /opt/PuppetGC/.git 还在,当前发布流程也不会依赖服务器执行 git pull。后续可以选择保留 .git,也可以删掉:
1 sudo rm -rf /opt/PuppetGC/.git
但删不删不是关键。关键是团队约定:
十八、问题七: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 ~/.sshecho 'ssh-ed25519 AAAA... github-actions-puppetgc-deploy' >> ~/.ssh/authorized_keyschmod 700 ~/.sshchmod 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 ssh -i .\puppetgc_deploy_key ubuntu@101.43 .9.80
如果仍要求输入密码,说明公钥没有写入对应用户的 authorized_keys。
GitHub Secret 里的 DEPLOY_USER 必须和能免密登录的用户一致。
puppetgc_deploy_key 是私钥,不能提交。后来已经把 puppetgc_deploy_key 和 puppetgc_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 2 git push origin :refs/tags/v0.1.3 git tag -d v0.1.3
教训:
二十、问题九: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
教训:
二十二、问题十一:COSFS 挂载目录被删导致 deleted 挂载 对象存储迁移时,目标是把 API 文件目录:
1 /opt/PuppetGC/apps/api-py/data/storage
迁到腾讯 COS。
错误过程:
先创建 /mnt/puppetgc-bucket/storage。
把它 bind 到应用目录。
后来又删了 /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-apisudo umount /opt/PuppetGC/apps/api-py/data/storagesudo mkdir -p /opt/PuppetGC/apps/api-py/data/storagesudo 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 挂载时,不要继续往里写文件。先停服务、卸载坏挂载、确认真实挂载源,再恢复数据。
恢复 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/storagesudo -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 mountAfter =network-on line.targetWants =network-on line.target[Service] Type =on eshotRemainAfterExit =yes ExecStartPre =/usr/bin/mkdir -p /opt/PuppetGC/apps/api-py/data/storageExecStart =/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/storageExecStop =/usr/bin/umount /opt/PuppetGC/apps/api-py/data/storageTimeoutStartSec =60 TimeoutStopSec =30 [Install] WantedBy =multi-user.target
然后让 API 依赖 COS 挂载:
1 2 3 4 [Unit] After =network.target puppetgc-cos-storage.serviceWants =puppetgc-cos-storage.serviceRequires =puppetgc-cos-storage.service
启用:
1 2 3 4 5 sudo systemctl daemon-reloadsudo systemctl enable puppetgc-cos-storagesudo systemctl start puppetgc-cos-storagesudo systemctl enable puppetgc-apisudo systemctl restart puppetgc-api
验证:
1 2 3 4 sudo systemctl status puppetgc-cos-storage --no-pagerfindmnt /opt/PuppetGC/apps/api-py/data/storage sudo systemctl status puppetgc-api --no-pagercurl 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。
短期底线:
二十七、第二阶段后的新检查清单 当前生产发布完成后,检查项更新为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 gh run list --repo Pupper0601/PuppetGC --workflow "发布版本并部署生产" --limit 5 curl http://127.0.0.1:9620/health sudo systemctl status puppetgc-api --no-pagersudo systemctl status puppetgc-cos-storage --no-pagerfindmnt /opt/PuppetGC/apps/api-py/data/storage sudo -u www-data test -w /opt/PuppetGC/apps/api-py/data/storage && echo okcurl -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 sudo nginx -tsudo 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
二十八、第二阶段的关键经验
这次第二阶段比第一次更折腾:从 GitHub Release、Actions、SSH Secret、tag、COSFS、systemd 到 SQLite 风险,几乎把小团队上线会踩的坑踩了一遍。好处是现在链路已经更清晰:代码归 GitHub,发布归 Release,文件归 COS,进程归 systemd,服务器只负责稳定运行。