- migrated legacy_doc_id: D3-ARCH-DEPLOY-M6.2-STAGE123 level: l3
M6.2 · VPS 服务器环境配置与后端部署(阶段 1+2+3)
本文定位
本文是 M6.2 VPS 后端部署里程碑的阶段 1+2+3 权威落地手册,承接 m6.2-vps-account-setup.md 的阶段 0 前置准备。
读完本文,你将把本地已跑通的 xisound-api(ASP.NET Core 8 · M6.1 root commit 63706c9)部署到腾讯云香港 VPS,绑定 api.joysnd.com,完成 SQLite → PostgreSQL 迁移,启用 nginx 反代 + certbot HTTPS。
预计工期:2-3 个工作日(新手 3-5 天)。
读者画像与前置条件
| 项目 | 要求 |
|---|---|
| 背景 | 非必需运维背景 · 每章都有"为什么 + 怎么做 + 预期结果 + 常见坑" |
| 前置账号 | 腾讯云 + Resend + 企微机器人 + GitHub + CF 已就绪(零基础指南 v1.3) |
| 前置资源 | 香港 VPS 已下单且拿到 root 密码 · joysnd.com 在 CF DNS 托管 · send.joysnd.com Resend 域名验证已通过 |
| 本地环境 | Windows 11 + PowerShell 7 · ssh / git / dotnet 8 CLI 可用 |
| 代码仓库 | xisound-api 本地工作区(M6.1 commit 4e21a17 HEAD) |
本文启动前请务必阅读 §0 密钥泄漏应急处置
前一轮检查发现 xisound-api/.env.example 的 Git 历史中已提交过真实 Resend API Key 与企微 Webhook(commit 4e21a17 及更早)。本文 §0 给出完整的吊销 + Git 历史清理 + force push 操作指引,请先完成 §0 再继续 §1,否则生产部署后这两条凭据仍可能被滥用。
0. 密钥泄漏应急处置(本文 §1 之前必做)
事件概述
xisound-api/.env.example(M6.1 提交到本地工作区,commit 4e21a17)包含:
- Resend API Key:re_SBTYJoo8_p8dvTQ8i8QGSxWDwCagDGb2H(已存在 Git 历史)
- 企微群机器人 Webhook URL:https://work.weixin.qq.com/wework_admin/common/openBotProfile/24f7563bcc4d5e04a3d0dafe92f3a6664e
本轮文档提交前已把这两条值替换为占位符(见 .env.example 最新版),但 Git 历史中仍可被 git log -p / GitHub Web UI 看到。如果此仓库即将 push 到公网 GitHub(mengliliusha/xisound-api),等同于凭据公开泄漏。
0.1 应急 5 步流程
graph LR
S[发现泄漏] --> R1[吊销 Resend Key]
R1 --> R2[重建企微机器人]
R2 --> C[清理 Git 历史]
C --> P[force push 覆盖远程]
P --> A[审计访问日志]
A --> End[✅ 闭环]
class S xyError
class R1,R2 xyL2
class C xyL4
class P xyWarn
class A xyL5
class End xySuccess
0.2 步骤 1 · 吊销 Resend API Key
- 登录 https://resend.com/api-keys
- 找到名为(或 token 为
re_SBTYJoo8_...)的 Key - 点击右侧 Revoke(吊销)· 弹窗确认
- 新建一个 Key(名字比如
xisound-api-prod-2026-05)· 复制re_...新值 - 妥善保存新 Key 到 1Password / Bitwarden / 本地加密笔记(不要立刻贴进任何 .env 文件,等 §4 步骤再填)
为什么必须吊销
Resend Key 的权限等同于"以你的账户发邮件 + 访问收件人列表"。即使你改了 .env.example,Git 历史里的旧 Key 仍然有效,任何克隆过公网仓库的人都能用它。
0.3 步骤 2 · 重建企微群机器人
- 企业微信 App → 进入该群 → 右上
...→ 群机器人 - 点开现有的"留资通知机器人"(Webhook 含 key
24f7563b...) - 点 删除机器人(整个 Webhook 立即失效)
- 点 添加机器人 → 名字可保持一致 → 拿到新 Webhook URL(形如
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<new-uuid>) - 同样保存到密码管理器,暂不填入 .env
为什么这条 URL 也算凭据
这个 URL 本身就是"发送消息到特定群"的鉴权。泄漏后任何人都能向你的群伪造消息(哪怕 Markdown 钓鱼链接),对客户信任是直接打击。
0.4 步骤 3 · 清理 Git 历史(git-filter-repo)
影响范围
这一步会重写 xisound-api 仓库的 Git 历史,所有 commit SHA 会变。如果仓库已经 push 到 GitHub 并有协作者 clone,所有人都需要 git fetch --all && git reset --hard origin/main 或重新 clone。当前 xisound-api 仓库只你一个人维护,且 GitHub 远程(如果已推)也由你控制,影响可接受。
3.1 安装 git-filter-repo(Windows + PowerShell):
如果你更习惯用可执行文件:从 https://github.com/newren/git-filter-repo/releases 下载 git-filter-repo.py,放到 $env:Path 任意目录,确保 git filter-repo 能被识别。
3.2 备份当前仓库(保险起见):
cd D:\work\25_claude\workspace\AlgoDepartment\07_web
Copy-Item -Recurse xisound-api xisound-api-backup-before-filter
3.3 把需要清理的敏感字符串写进 replacements.txt:
在 xisound-api/ 下新建一个临时文件 replacements.txt(不要 commit):
re_SBTYJoo8_p8dvTQ8i8QGSxWDwCagDGb2H==>re_REVOKED_DO_NOT_USE
24f7563bcc4d5e04a3d0dafe92f3a6664e==>WEWORK_KEY_REVOKED_DO_NOT_USE
==> 前是待替换的字符串,后面是替换值。
3.4 运行 git filter-repo:
可能的提示
如果 git-filter-repo 报 Refusing to destructively overwrite repo history since this does not look like a fresh clone,加 --force 标志即可(已备份过,安全):
git filter-repo --replace-text ..\replacements.txt --force
3.5 验证:
应无任何命中。
3.6 删除临时文件:
0.5 步骤 4 · Force push 覆盖远程
这是不可逆操作 · 确认无误再执行
force push 会强制覆盖 GitHub 端的所有历史。执行前再次确认你当前 working tree 就是你想要的最终状态,并且你理解所有旧 commit SHA 都会失效。
如果 xisound-api 已经 push 到 mengliliusha/xisound-api:
# 查看当前远程
git remote -v
# force push(--force-with-lease 比 --force 更安全 · 会检查远程是否被他人改过)
git push --force-with-lease origin main
如果 xisound-api 尚未 push 到 GitHub(当前就是这种状态,根据 TODOLIST §3.1.1):
直接按 TODOLIST §3.1.1 的正常流程首次 push 即可 —— 因为历史已清理,推上去就是干净版本:
# GitHub Web UI 先创建空仓库 mengliliusha/xisound-api
git remote add origin git@github.com:mengliliusha/xisound-api.git
git branch -M main
git push -u origin main
0.6 步骤 5 · 审计访问日志
5.1 Resend:https://resend.com/emails → 查看最近 7 天邮件发送记录,有无你不认识的 To 地址 / 主题。若有则说明在你吊销前已被滥用,需要把"受影响时间窗"披露给 ops / 运维群。
5.2 企微机器人:企微群聊天记录反查最近 7 天是否有机器人推送的"非留资"消息(如钓鱼链接)。
5.3 GitHub:如果仓库曾 public push 过,到 https://github.com/mengliliusha/xisound-api/security/secret-scanning(如已启用 Secret Scanning)查看是否有第三方已检测到泄漏事件。GitHub 也会主动把 Resend Key 这种前缀 re_ 的字符串报告给 Resend,Resend 会自动吊销 —— 所以有一定可能你的 Key 在你手动吊销前已经被 Resend 自动吊销了(检查 Dashboard 收件栏)。
0.7 §0 验收清单
- Resend Dashboard 旧 Key 已 Revoke · 新 Key 已生成并保存到密码管理器
- 企微机器人已删除旧的 · 新 Webhook 已生成并保存到密码管理器
-
git-filter-repo已重写历史 ·git log -p | Select-String验证无敏感串 - 如果旧仓库已 push 公网,已
git push --force-with-lease覆盖;否则首次 push 时即为清洁版 - 已审计 Resend/企微/GitHub 近 7 天日志,无被滥用迹象
-
xisound-api-backup-before-filter/备份目录保留 30 天后再删
§0 完成 · 进入 §1
密钥已吊销、历史已清理、新凭据妥善保管。下面进入 VPS 基础环境建设。新 Resend Key 和新 Wework Webhook 暂时不要填入任何文件,等 §4 才填入 VPS 的 .env.production。
1. 阶段 1 · VPS 基础环境初始化(0.5-1 天)
1.1 本章产出
graph LR
A[刚到手的 VPS<br/>root + 密码] --> B[SSH 密钥登录<br/>禁用密码]
B --> C[系统加固<br/>UFW + fail2ban]
C --> D[Docker + Compose<br/>安装就绪]
D --> E[交换分区<br/>2G swap]
E --> F[✅ 基础环境 OK]
class A xyWarn
class B,C xyL2
class D xyL4
class E xyL0
class F xySuccess
1.2 腾讯云控制台 · 首次登录
- 登录 https://console.cloud.tencent.com/lighthouse
- 找到你的香港轻量应用实例 · 点击实例名进入详情
- 右上角 登录 → OrcaTerm(浏览器终端)· 用初始
root+ 你在控制台设置的密码登录 - 记下公网 IP(后续所有
<VPS_IP>都替换为此 IP)
公网 IP 的保密级别
虽然云服务商公网 IP 本身不是机密(Cloudflare 代理后也对外透明),但作为 SSH 登录端点它是高价值目标。本文所有示例使用 <VPS_IP> 占位符,不要把真实 IP 粘贴进任何会推送到公网的文件(包括本手册、~/.ssh/config if 仓库化管理、CI/CD secret 的公开描述)。
1.3 创建低权限部署用户
# 以 root 身份执行(首次登录默认就是 root)
adduser ubuntu # 创建 ubuntu 用户(prompt 输入密码 · 其余回车跳过)
usermod -aG sudo ubuntu # 加 sudo 组
mkdir -p /home/ubuntu/.ssh
chmod 700 /home/ubuntu/.ssh
chown -R ubuntu:ubuntu /home/ubuntu/.ssh
为什么不直接用 root
运维最佳实践:永不以 root 做日常操作。root 误删 / 或执行恶意脚本时没有回旋余地。sudo 机制强制你对每条高权限命令做"自我确认",且操作留痕到 /var/log/auth.log。
1.4 本地生成 SSH 密钥对
Windows PowerShell 本地执行:
# 若已有密钥跳过本步;首次生成:
ssh-keygen -t ed25519 -C "deploy@joysnd.com" -f $env:USERPROFILE\.ssh\id_ed25519_xisound_api
# prompt 时:passphrase 可留空(简化 CI/CD)或填一个易记的
# 查看公钥(复制它)
Get-Content $env:USERPROFILE\.ssh\id_ed25519_xisound_api.pub
1.5 上传公钥到 VPS
方法 A · OrcaTerm 手写粘贴(推荐 · 不依赖旧密码认证):
在 VPS(仍是 root)执行:
cat >> /home/ubuntu/.ssh/authorized_keys << 'EOF'
ssh-ed25519 AAAAC3Nz... deploy@joysnd.com
EOF
chmod 600 /home/ubuntu/.ssh/authorized_keys
chown ubuntu:ubuntu /home/ubuntu/.ssh/authorized_keys
替换 ssh-ed25519 AAAAC3Nz... 为你本地 .pub 文件的完整一行。
方法 B · ssh-copy-id(本地 PowerShell · 需要先设置好 ubuntu 密码):
# Windows 没自带 ssh-copy-id · 手工等效:
Get-Content $env:USERPROFILE\.ssh\id_ed25519_xisound_api.pub | ssh ubuntu@<VPS_IP> "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
1.6 配置本地 SSH config
编辑 $env:USERPROFILE\.ssh\config(不存在就新建):
Host xisound-api
HostName <VPS_IP>
User ubuntu
IdentityFile ~/.ssh/id_ed25519_xisound_api
ServerAliveInterval 60
ServerAliveCountMax 3
1.7 验证密钥登录
ssh xisound-api
# 预期:直接以 ubuntu 用户进入,无 password 提示
# 首次会提示 "The authenticity of host ... can't be established. Fingerprint is SHA256:..."
# 回复 yes 即可(之后会记到 known_hosts)
如果密钥登录失败 · 不要急着禁用密码登录
如果 ssh xisound-api 提示 Permission denied (publickey),说明公钥上传有问题(典型:authorized_keys 有多余空格或换行)。先保持密码登录能力,回到 OrcaTerm 排查:
1.8 系统加固
从此刻起所有命令都是 SSH 到 VPS 后执行(ssh xisound-api)。
1.8.1 升级系统包:
sudo apt update && sudo apt upgrade -y
sudo apt install -y vim htop curl wget git unattended-upgrades fail2ban ufw
1.8.2 SSH 服务强化:
确保以下 5 行的值正确(没有就加,注释的就解注释):
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
ChallengeResponseAuthentication no
验证 SSH 仍可登录
不要关闭当前 OrcaTerm 会话!开一个新的 PowerShell 窗口执行 ssh xisound-api。如果新会话能进,说明配置正确;如果进不了,立即用旧 OrcaTerm 回滚 /etc/ssh/sshd_config 并 systemctl restart sshd。
1.8.3 UFW 防火墙:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP · certbot + 301 到 HTTPS
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable # prompt 输 y
sudo ufw status verbose
UFW vs 腾讯云控制台防火墙
腾讯云"防火墙"是云侧安全组(实例网卡之前),UFW 是主机防火墙(进 OS 后)。两者是 AND 关系: - 云侧必须放通 22/80/443(控制台 → 你的实例 → 防火墙 → 添加规则) - 主机侧也必须放通同样的端口(UFW) 任何一层拦截,外部都连不上。很多"certbot 超时"问题根因是云侧防火墙没放 80。
1.8.4 fail2ban(自动封禁暴力破解 IP):
找到 [sshd] 段,确保:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
1.8.5 unattended-upgrades(自动安全更新):
验证配置文件 /etc/apt/apt.conf.d/50unattended-upgrades 中至少启用 security 源:
1.9 交换分区(2G swap)
为什么要 swap:2C4G 实例在 Docker 构建 / EF Core migrate 等瞬时内存高峰时可能 OOM。2G swap 是"内存保险丝",正常不用,用到时略慢但不崩溃。
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 开机自动挂载
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 验证
free -h
# 应看到 Swap: 2.0Gi 行
1.10 安装 Docker CE + Compose v2
# 官方 convenience 脚本(对新手最稳)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# ubuntu 用户免 sudo 用 docker
sudo usermod -aG docker ubuntu
# 关闭当前 SSH 会话让 group 生效(重新 ssh xisound-api 进来)
exit
重新 SSH 进来后:
docker --version
docker compose version # v2 已内置 · 不需要 docker-compose.pip
docker run --rm hello-world
1.11 时区与 locale(可选但推荐)
sudo timedatectl set-timezone Asia/Shanghai
date # 验证
# Ubuntu 22.04 默认只装了 en_US · 需要中文按需安装
sudo apt install -y language-pack-zh-hans
1.12 §1 验收清单
-
ssh xisound-api免密码直接登录成功 -
sudo systemctl status sshd正常 ·PasswordAuthentication no生效 -
sudo ufw status仅允许 22/80/443 -
sudo fail2ban-client status显示 sshd jail 启动 -
free -h显示 2G swap -
docker run --rm hello-world输出 "Hello from Docker!" -
docker compose version返回 v2.x -
date返回 CST 时间
2. 阶段 2 · Docker Compose 编排(1-1.5 天)
2.1 本章产出
本章把 xisound-api 代码拉到 VPS,生成生产 Docker Compose 5 容器栈(api + postgres + redis + nginx + certbot),完成首次 HTTP-only 启动为 §3 的 certbot 签发做好准备。
2.2 目标部署拓扑
graph TB
subgraph Edge["Cloudflare 边缘"]
User[访客浏览器]
CF[CF CDN + DDoS<br/>api.joysnd.com 🟠]
end
subgraph VPS["腾讯云香港 · Ubuntu 22.04 · 2C4G 80G"]
Nginx[nginx:1.27-alpine<br/>80/443 · 反代 + HTTPS]
Certbot[certbot<br/>Let's Encrypt]
Api[xisound-api<br/>:8080 · ASP.NET 8]
Pg[(postgres:16<br/>pg_data volume)]
Rds[(redis:7-alpine<br/>AOF)]
end
User -->|HTTPS| CF
CF -->|Full strict 443| Nginx
Nginx -->|upstream api:8080| Api
Certbot -.每 12h renew.-> Nginx
Api -->|EF Core Npgsql| Pg
Api -.future M6.3.-> Rds
class User,CF xyL4
class Nginx,Certbot xyL3
class Api xyL2
class Pg,Rds xyL0
class Edge xySgL0; class VPS xySgL3;
2.3 上传代码到 VPS
方法 A · 通过 GitHub(推荐 · 一劳永逸):
在 VPS 上:
sudo mkdir -p /opt/xisound-api
sudo chown ubuntu:ubuntu /opt/xisound-api
cd /opt
git clone git@github.com:mengliliusha/xisound-api.git
# 若使用 HTTPS clone(无需 SSH key 于 VPS):
# git clone https://github.com/mengliliusha/xisound-api.git
方法 B · 直接从本地 scp 上传(如暂未 push 到 GitHub):
本地 PowerShell:
# 使用 scp 递归复制(不含 bin/ obj/ node_modules · .gitignore 规则不生效需手动排除)
cd D:\work\25_claude\workspace\AlgoDepartment\07_web
scp -r xisound-api xisound-api:/opt/
# 注:第一个 xisound-api 是本地目录,第二个 xisound-api 是 ~/.ssh/config 中的 host 别名
2.4 生产配置文件已就位(本手册同仓库提供)
所有生产配置文件已在本手册同步提交到 xisound-api/ 仓库:
| 文件 | 作用 |
|---|---|
docker-compose.prod.yml |
5 容器编排 |
nginx/nginx.conf |
HTTP-only 初始版(证书签发前用) |
nginx/nginx.https.conf |
HTTPS 完整版(证书就绪后替换) |
scripts/certbot-init.sh |
首次签发脚本 |
scripts/certbot-renew.sh |
续签脚本(cron 用) |
.env.production.example |
生产环境变量模板(纯占位符) |
src/XiSound.Api/appsettings.Production.json |
生产配置覆写 |
src/XiSound.Api/Program.cs |
已改造:Production → Migrate() · Dev → EnsureCreated() |
2.5 生成 PostgreSQL 初始 migration(本地 PowerShell 执行)
这一步必须在本地执行(VPS 上通常不安装 dotnet ef tools),生成的 Migrations/ 目录要提交到 Git。
cd D:\work\25_claude\workspace\AlgoDepartment\07_web\xisound-api
# 安装 dotnet-ef 全局工具(一次性)
dotnet tool install --global dotnet-ef --version 8.0.10
# 若已装:dotnet tool update --global dotnet-ef --version 8.0.10
# 进入项目目录
cd src\XiSound.Api
# 临时把 appsettings.json 的 Provider 改为 postgres 让设计时工具选对 provider
# 或使用 --provider 参数(8.0.10 版本用环境变量更方便)
$env:Database__Provider="postgres"
$env:ConnectionStrings__Default="Host=localhost;Port=5432;Database=xisound;Username=xisound;Password=dummy_for_design_time"
# 生成 migration
dotnet ef migrations add InitialPostgres --context AppDbContext --output-dir Migrations
# 验证
Get-ChildItem Migrations
# 应有 *_InitialPostgres.cs · *_InitialPostgres.Designer.cs · AppDbContextModelSnapshot.cs 三个文件
# 清理环境变量
Remove-Item Env:Database__Provider
Remove-Item Env:ConnectionStrings__Default
提交 migration 到 Git:
cd D:\work\25_claude\workspace\AlgoDepartment\07_web\xisound-api
git add src/XiSound.Api/Migrations/
git commit -m "feat(M6.2): add InitialPostgres EF Core migration"
git push origin main
2.6 VPS 上创建 .env.production(密钥注入)
在 VPS:
cd /opt/xisound-api
git pull # 拉最新 migration(如果 2.5 已 push)
# 基于模板创建真实文件
cp .env.production.example .env.production
chmod 600 .env.production # 仅 owner 可读
nano .env.production # 或 vim
填入真值清单(参考 §0 步骤 1-2 拿到的新 Key):
| 变量 | 生成命令 / 来源 |
|---|---|
POSTGRES_PASSWORD |
openssl rand -base64 32 |
ConnectionStrings__Default 中 Password= |
同上(和 POSTGRES_PASSWORD 保持一致) |
Admin__ApiKey |
openssl rand -hex 32(VPS 上 openssl rand -hex 32) |
Resend__ApiKey |
§0 步骤 1 生成的新 Key(re_...) |
Wework__WebhookUrl |
§0 步骤 2 生成的新 Webhook URL |
CERTBOT_EMAIL |
zhangzm@joysnd.com |
CERTBOT_DOMAIN |
api.joysnd.com |
密钥生成在 VPS 还是本地
openssl rand 生成的密钥只在 VPS 上生成,然后直接写进 .env.production(chmod 600 保护)。不要在本地生成然后 scp 上传(本地磁盘如果同步到 OneDrive/iCloud 就是二次泄漏)。
2.7 首次启动(HTTP-only · 不含 HTTPS)
cd /opt/xisound-api
# 确认 nginx.conf 是 HTTP-only 初始版(仓库默认就是)
head -5 nginx/nginx.conf
# 应看到 "HTTP-only 初始版 · 仅放行 /.well-known/acme-challenge/"
# 创建 certbot webroot 目录(首次挂载用)
mkdir -p certbot/www certbot/conf nginx/logs backups
# 构建 api 镜像(首次 2-5 分钟)
docker compose -f docker-compose.prod.yml build api
# 启动 postgres + redis(先启动依赖)
docker compose -f docker-compose.prod.yml up -d postgres redis
# 等 postgres 健康(观察 10 秒)
watch -n 2 'docker compose -f docker-compose.prod.yml ps'
# Ctrl+C 退出 watch · 看到 postgres 和 redis 都 Healthy 后继续
# 启动 api(它会自动 dotnet ef database update 应用 migration)
docker compose -f docker-compose.prod.yml up -d api
# 查看 api 日志,确认 migration 成功
docker compose -f docker-compose.prod.yml logs api | tail -50
# 应看到:
# Production · applying EF Core migrations…
# XiSound.Api started · env=Production
# 启动 nginx(HTTP-only · 监听 80)
docker compose -f docker-compose.prod.yml up -d nginx
# 验证 5 个容器都 up
docker compose -f docker-compose.prod.yml ps
2.8 VPS 本地冒烟测试(尚未配 HTTPS)
# 容器间网络测试(验证 api 能从 nginx 内部被访问)
docker compose -f docker-compose.prod.yml exec nginx wget -qO- http://api:8080/health
# 期望输出:Healthy 或 JSON { "status": "healthy" }
# 外部对 80 端口测试(HTTP-only 版只放行 ACME challenge · 其他 444)
curl -I http://<VPS_IP>/
# 期望:连接被重置(444 nginx 特殊码 · curl 会报 `curl: (52) Empty reply from server`)
# ACME challenge 路径测试
curl -I http://<VPS_IP>/.well-known/acme-challenge/nonexistent
# 期望:HTTP/1.1 404 Not Found(说明路径放行了,只是文件不存在)
§2 检查点
到此 §2 结束 · 5 容器编排起来,api 与 postgres 能通信并跑完 InitialPostgres migration,nginx 监听 80 只放行 ACME challenge。先不要进 §4,必须先完成 §3 的 DNS + certbot 签发,否则 §4 的 HTTPS 切换没有证书可挂。
2.9 §2 验收清单
-
docker compose -f docker-compose.prod.yml ps5 个容器状态:xisound-api·Up (healthy)xisound-postgres·Up (healthy)xisound-redis·Up (healthy)xisound-nginx·Upxisound-certbot·Created(不常驻)
-
docker compose logs api看到Production · applying EF Core migrations…和started · env=Production -
docker compose exec postgres psql -U xisound -c "\dt"能看到Leads表 -
docker compose exec nginx wget -qO- http://api:8080/health返回健康响应 - 从外部
curl -I http://<VPS_IP>/.well-known/acme-challenge/test返回 404(说明路径已放行)
3. 阶段 3 · DNS + HTTPS + 前端切换(0.5-1 天)
3.1 本章产出
graph LR
A[CF DNS 加 api A 记录] --> B[Proxy 临时灰化]
B --> C[certbot HTTP-01 签发]
C --> D[切 nginx HTTPS 版]
D --> E[CF Proxy 改橙化]
E --> F[CF SSL 切 Full strict]
F --> G[前端 PUBLIC_API_BASE_URL 切生产]
G --> H[✅ 生产上线]
class A,B xyL4
class C xyL2
class D,E,F xyL3
class G xyL5
class H xySuccess
3.2 Cloudflare DNS 添加 api 子域
- 登录 https://dash.cloudflare.com →
joysnd.com→ DNS · Records - 点击 Add record:
- Type:
A - Name:
api(最终解析为api.joysnd.com) - IPv4 address:
<VPS_IP>(你的腾讯云香港实例公网 IP) - Proxy status:🟠 Proxied(先按这个设置,但我们 §3.3 会临时灰化再改回来)
- TTL:Auto
- Type:
- Save
- 本地 PowerShell 验证:
3.3 临时灰化 Proxy(只为签发证书)
为什么要临时灰化
Let's Encrypt 的 HTTP-01 校验会从 Let's Encrypt 服务器发起 HTTP 请求到 api.joysnd.com/.well-known/acme-challenge/xxx。如果 Proxy 橙化(流量经过 CF),CF 在 HTTP→HTTPS 强制跳转机制会把该请求改成 HTTPS 回源,但我们 nginx 此时还没有 HTTPS 监听,于是校验失败。
临时灰化 Proxy 让 ACME 请求直接命中 VPS 的 80 端口,走 nginx HTTP-only 配置中放行的 /.well-known/acme-challenge/ 路径。签发成功后再改回橙化。
操作:
- CF Dashboard → DNS →
api记录的 Proxy 状态 图标 → 点击 🟠 → 切换为 ☁️ 灰色云(DNS only) - 等 30-60 秒(CF 全球传播)
- 再次
nslookup api.joysnd.com· 应返回你的真实<VPS_IP>
3.4 确认腾讯云控制台防火墙(云侧)
这一步是"certbot 超时"的头号根因
UFW 放通了只管主机内,腾讯云控制台的实例"防火墙"是独立的云侧安全组,很多人忘记配。
- 腾讯云 → 轻量应用 → 你的实例 → 防火墙(标签页)
- 确认 3 条"允许"规则:
- TCP : 22(来源
0.0.0.0/0) - TCP : 80(来源
0.0.0.0/0) - TCP : 443(来源
0.0.0.0/0)
- TCP : 22(来源
- 若缺少任何一条,添加规则 → 应用类型选"HTTP(80)"或"HTTPS(443)"或"自定义"
3.5 首次签发 HTTPS 证书
在 VPS 上:
期望输出片段
如果失败,直接看 §7 故障排查 §7.2 "certbot 签发失败"。
3.6 切换 nginx 到 HTTPS 完整版
# 覆盖配置
cp nginx/nginx.https.conf nginx/nginx.conf
# 语法检查(重要!配置错误会导致 nginx reload 失败)
docker compose -f docker-compose.prod.yml exec nginx nginx -t
# 期望:syntax is ok · test is successful
# 热重载(不中断现有连接)
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload
3.7 验证 HTTPS 本地可达
VPS 本地:
curl -I -k https://localhost/health # -k 跳过 CF Proxy 层(localhost 证书 SAN 不含)
# 期望:HTTP/2 200
# 用真实域名测试(此时 Proxy 还灰化 · 走 VPS 真实 IP)
curl -I https://api.joysnd.com/health
# 期望:HTTP/2 200 · 证书 issuer 显示 Let's Encrypt
3.8 恢复 CF Proxy 橙化 + SSL Full strict
- CF → DNS →
api记录 Proxy → 改回 🟠 Proxied - CF → SSL/TLS · Overview → Mode → 切换为 Full (strict)
SSL Mode 三种模式对比
| Mode | CF→访客 | CF→VPS | 安全性 | 何时用 |
|---|---|---|---|---|
| Flexible | HTTPS | HTTP | ⚠️ 中间明文 · 不推荐 | 临时 demo |
| Full | HTTPS | HTTPS(自签名即可) | 🟡 中 | 无正式证书时 |
| Full (strict) ✅ | HTTPS | HTTPS(CA 签发) | 🟢 高 | 本项目目标 |
3.9 外部验证 HTTPS
本地 PowerShell:
# 走 CF Proxy(橙化后 nslookup 返回 104.x.x.x)
nslookup api.joysnd.com
# HTTPS 健康检查
curl -I https://api.joysnd.com/health
# 期望:HTTP/2 200 · 有 cf-ray 响应头
# SSL Labs 评级(在线)
# 浏览器打开:https://www.ssllabs.com/ssltest/analyze.html?d=api.joysnd.com
# 期望 15 分钟后评级:A+
3.10 配置自动续签 cron
在 VPS:
加入两行(每天凌晨 3:15 和 15:15 检查):
15 3,15 * * * cd /opt/xisound-api && ./scripts/certbot-renew.sh >> /var/log/xisound-certbot-renew.log 2>&1
验证:
crontab -l
# 立即跑一次看看
cd /opt/xisound-api && ./scripts/certbot-renew.sh
# 期望:[renew-noop] 证书未到续签窗口(<30 天才会续)
3.11 前端切生产 API
切换 xisound-website 的 PUBLIC_API_BASE_URL 指向 https://api.joysnd.com:
- 登录 https://dash.cloudflare.com → Workers & Pages →
xisound-website项目 - Settings → Environment variables → Production 环境
- 找到
PUBLIC_API_BASE_URL· 点击 Edit · 改为https://api.joysnd.com· Save - Deployments → 找到最新部署 → Retry deployment(使新环境变量生效)
- 等 2-3 分钟新部署完成
- 浏览器强刷
https://www.joysnd.com· 打开 DevTools · Network 观察留资表单提交时的 XHR 请求 URL 应为https://api.joysnd.com/api/lead
3.12 §3 验收清单
-
nslookup api.joysnd.com返回 CF IP(橙化状态) -
curl -I https://api.joysnd.com/health返回 200 · 证书 issuer = Let's Encrypt - SSL Labs 评级 A 或 A+
-
certbot-renew.sh手工跑一次返回[renew-noop] - crontab 已添加续签任务
- CF SSL/TLS Mode = Full (strict)
-
xisound-websiteProduction 环境变量PUBLIC_API_BASE_URL=https://api.joysnd.com - 浏览器刷新
www.joysnd.com后 DevTools Network 看到对api.joysnd.com的请求
4. 生产端到端验证
4.1 留资全链路测试
场景:浏览器访客 → www.joysnd.com → 填留资表单 → 提交 → 后端入库 + 企微推送 + 邮件通知三项全中。
步骤:
- 浏览器打开
https://www.joysnd.com/resources/whitepapers/(或任一有留资表单的页面) - 填写姓名 / 邮箱 / 电话 / 公司 / 留言 · 勾选同意
- 点击 提交
- 观察:
- 前端:提示"已收到 · 3 个工作日内联系"
- 浏览器 DevTools → Network:
POST https://api.joysnd.com/api/lead返回 202 Accepted - 企微群:
<30 秒内收到机器人推送的留资通知 - 运营邮箱(
ops@joysnd.com):<2 分钟内收到 Resend 发来的通知邮件
4.2 Admin 后台数据验证
# 本地 PowerShell
$apiKey = "<.env.production 里的 Admin__ApiKey 值>"
curl -H "X-Admin-Key: $apiKey" https://api.joysnd.com/admin/leads
# 期望返回 JSON 数组,包含刚刚提交的那条
4.3 安全与性能测试
4.3.1 SSL Labs:已在 §3.9 完成,应 A/A+
4.3.2 限流测试:
# 连续 21 次 POST · 预期第 21 次起返回 429(nginx 层 20 req/min)
1..21 | ForEach-Object {
$r = Invoke-WebRequest -Uri https://api.joysnd.com/api/lead `
-Method POST -ContentType 'application/json' `
-Body '{"name":"t","email":"t@t.com","phone":"138","company":"t","message":"t"}' `
-SkipHttpErrorCheck
"$_ → $($r.StatusCode)"
}
4.3.3 CORS 白名单:
# 从白名单域(自己改 Origin 头)
curl -X OPTIONS https://api.joysnd.com/api/lead -H "Origin: https://www.joysnd.com" -I
# 期望:Access-Control-Allow-Origin: https://www.joysnd.com
# 从黑名单域
curl -X OPTIONS https://api.joysnd.com/api/lead -H "Origin: https://evil.example.com" -I
# 期望:无 Access-Control-Allow-Origin 头
4.3.4 AdminKey 三态:
$base = "https://api.joysnd.com/admin/leads"
# 无 header → 503
curl -I $base
# 错 key → 401
curl -I -H "X-Admin-Key: wrong_key" $base
# 对 key → 200
curl -I -H "X-Admin-Key: <真 key>" $base
4.4 §4 验收清单
- 浏览器留资提交 · 前端显示成功
- 企微群收到机器人推送(带访客姓名 + 联系方式 + UTM)
-
ops@joysnd.com收到 Resend 邮件(From:羲音官网 <noreply@send.joysnd.com>· Reply-To:support@joysnd.com) -
/admin/leads用正确 Key 能看到这条记录 - SSL Labs A/A+
- 连续 21 次 POST 触发 429
- CORS 正确拒绝非白名单域
- AdminKey 503/401/200 三态正确
5. 故障排查(常见问题 Top 18)
5.1 docker compose up 时 api 容器立即退出
症状:docker compose ps 显示 api Exited (139) 或 Exited (1)
排查:
常见原因:
| 日志片段 | 原因 | 解决 |
|---|---|---|
Npgsql.NpgsqlException: ... Name or service not known |
连接串 Host 不对 | 确认 ConnectionStrings__Default 的 Host= 是 postgres(服务名) |
fail to connect ... password authentication failed |
密码不匹配 | .env.production 中 POSTGRES_PASSWORD 与连接串密码必须相同 |
System.Globalization.CultureNotFoundException |
InvariantGlobalization=true 与 Npgsql 某些 culture 路径冲突 |
临时去掉 csproj 中该属性 · 或给容器装 ICU:Dockerfile 增加 RUN apt-get install -y libicu-dev 并把 InvariantGlobalization 改 false |
Unable to create an object of type 'AppDbContext' |
dotnet ef migrations add 时没设环境变量或连接串不可达 |
回到 §2.5 重新生成 |
5.2 certbot 签发失败(HTTP-01 超时)
症状:./scripts/certbot-init.sh 报 Challenge failed for domain api.joysnd.com 或 Timeout during connect
排查顺序:
- DNS 是否生效:本地
nslookup api.joysnd.com应返回<VPS_IP>(Proxy 必须灰化) - CF Proxy 是否灰化:DNS record 应是 ☁️ 灰色云,不是 🟠
- 云侧防火墙:腾讯云实例 → 防火墙 → TCP 80 是否放通
- UFW:
sudo ufw status是否含80/tcp ALLOW - nginx 是否在监听 80:
docker compose ps nginx显示 Up - 外部能否访问 80:本地
curl -I http://<VPS_IP>/.well-known/acme-challenge/test期望 404(说明路径已放行) - Let's Encrypt 配额:同域每周 5 次正式签发 · 超额需等 7 天或用
--dry-run演练
5.3 HTTPS 访问 521 / 522 / 525
CF → VPS 源站的问题:
- 521:VPS web server down(nginx 崩了 ·
docker compose ps nginx) - 522:Connection timeout(云防火墙 443 没开 · 或 nginx 未监听 443)
- 525:SSL handshake failed(CF Mode 为 Full strict 但 VPS 证书未就绪 · 或证书路径不对)
525 最常见原因:手册 §3.6 的 cp nginx.https.conf nginx.conf 漏掉了,或 ssl_certificate 指向的文件不存在。
docker compose exec nginx ls -l /etc/letsencrypt/live/api.joysnd.com/
# 应看到 cert.pem / chain.pem / fullchain.pem / privkey.pem
5.4 CORS 报错 "blocked by CORS policy"
症状:浏览器 DevTools Console 报 Access to fetch at 'https://api.joysnd.com/api/lead' from origin 'https://www.joysnd.com' has been blocked by CORS policy
排查:
# 从白名单域测试 OPTIONS 预检
curl -X OPTIONS https://api.joysnd.com/api/lead \
-H "Origin: https://www.joysnd.com" \
-H "Access-Control-Request-Method: POST" \
-I
如果返回头没有 Access-Control-Allow-Origin: https://www.joysnd.com:
- 检查 nginx map:
grep -A 5 "map \$http_origin" nginx/nginx.conf - 检查
.env.production的Cors__AllowedOrigins是否含该域 - 重启 api:
docker compose restart api
5.5 企微机器人不推送 · 邮件不发
症状:留资入库成功(/admin/leads 能看到),但企微/邮件无动静。
排查:
常见日志:
Wework webhook url empty · skip→.env.production的Wework__WebhookUrl是空或占位符Resend api key empty · skip→ 同理HTTP 401 Unauthorized from Resend→ Key 错误或已吊销HTTP 45001 invalid webhook url→ 企微 Webhook URL 错误或已删除
5.6 postgres 数据丢失(容器重启后表没了)
症状:重启后 \dt 列表为空
根因:没挂 volume 或挂错路径。
检查:
docker volume ls | grep postgres
# 期望看到 xisound-api_postgres-data
docker compose config | grep -A 2 volumes
# postgres 服务下应有 - postgres-data:/var/lib/postgresql/data
5.7 nginx 日志里大量 "limiting requests" 429
症状:正常访客被限流。
排查:
如果 IP 是 CF 的 104.x.x.x 而非真实访客 IP,说明 nginx 的 set_real_ip_from 没生效(CF IP 段变化了)。更新 nginx.conf 中的 CF IP 列表:
https://www.cloudflare.com/ips-v4/
5.8 api 响应慢 / 超时
排查:
# 看 api 容器资源占用
docker stats --no-stream
# 看 postgres 慢查询
docker compose exec postgres psql -U xisound -c "SELECT pid, age(query_start), query FROM pg_stat_activity WHERE state='active' AND age(query_start) > interval '1 seconds';"
5.9 docker 磁盘占用过大
# 查看
docker system df
# 清理(安全 · 只删无引用层)
docker system prune -af --volumes
# ⚠️ --volumes 会删除悬挂卷 · 确认 postgres-data 在用后再加
5.10 SSH 被锁出(忘记新公钥或 UFW 误配)
救命方式:腾讯云控制台 → 实例 → VNC 登录(不依赖 SSH)· 以 root 身份修复:
# 暂开密码登录应急
vim /etc/ssh/sshd_config # PasswordAuthentication yes
systemctl restart sshd
# 或放宽 UFW
ufw allow 22/tcp
5.11 .gitignore 的 data/ 误匹配源码 Data/ 目录(Windows 大小写不敏感陷阱)
症状:本地 dotnet build 通过,但 docker compose build api 报 CS0246: The type or namespace name 'AppDbContext' could not be found。用 git ls-files src/XiSound.Api/Data/ 发现 AppDbContext.cs 没进 Git(被忽略了)。
根因:.gitignore 原来写的是:
- Windows + NTFS 文件系统大小写不敏感:
data/在 Git 眼里既匹配data/(SQLite 运行时目录)也匹配src/XiSound.Api/Data/(EF Core DbContext 源码目录) - Git 在 Windows 上
core.ignorecase=true(默认)·data/无前导斜杠 → 任何层级名字类data(忽略大小写)的目录全部忽略 - 源码目录
Data/AppDbContext.cs被静默排除 · 提交后在 CI / Docker build 阶段才暴露
修复:.gitignore 改为前导斜杠 + 锚定仓库根,并对运行时数据改用更具体的路径:
# 锚定到仓库根 · 只匹配顶层的 data/
/data/
# 或者更精确(推荐):只忽略明确的运行时输出路径
/src/XiSound.Api/App_Data/
/certbot/conf/
/certbot/www/
对应修复 commit:c6b482d · 随后补提 src/XiSound.Api/Data/AppDbContext.cs 到 Git。
避免方式:
- 规则:
.gitignore里凡是纯小写短名(data/build/log/temp/obj/bin)必须加前导斜杠锚定根目录,或写相对精确路径 - CI 检查:pre-push hook 跑
dotnet build --no-restore+ 断言.Data/和Data/都存在于git ls-files - 本地自查(发现可疑忽略时):
如果你遇到:
docker compose build报源码类型找不到 →git ls-files | grep -i <ClassName>看文件在不在 Git- 不在 →
git check-ignore -v <path>查是哪条规则命中 - 改
.gitignore+git add -f <path>强制补进来
5.12 Docker Compose ${VAR:-default} 不读 env_file 导致 postgres 密码不一致
症状:postgres 容器启动看似正常,但 api 容器循环崩溃。docker compose logs api 出现:
明明 .env.production 里 POSTGRES_PASSWORD=<很长的随机串>,为什么认证失败?
根因:原 docker-compose.prod.yml 写法:
postgres:
environment:
POSTGRES_USER: ${POSTGRES_USER:-xisound}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGE_ME_IN_PROD}
POSTGRES_DB: ${POSTGRES_DB:-xisound}
env_file:
- .env.production
Docker Compose 的变量插值 ${POSTGRES_PASSWORD:-CHANGE_ME_IN_PROD} 只从执行 compose 时的 shell 环境变量读,不会从 env_file 读。导致:
- postgres 启动时 shell 里没有
POSTGRES_PASSWORD→ 取 fallback 值CHANGE_ME_IN_PROD作为初始化密码 - postgres 进程自己也收到 env_file 的真密码作为环境变量 · 但 PGDATA 已经用 fallback 值初始化完成(首次启动)
- api 容器从 env_file 读到的是真密码 · 去连 postgres 用的是
CHANGE_ME_IN_PROD· 28P01
修复:把 postgres 服务的 environment: 整块删掉,只保留 env_file:,让 postgres 官方镜像自己从 env_file 读取 POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB:
postgres:
image: postgres:16-alpine
env_file:
- .env.production # ← 唯一来源
volumes:
- postgres-data:/var/lib/postgresql/data
# 注意:如果之前已经用错误密码初始化过 · 必须先
# docker compose down -v # 删 volume
# 再 up · 否则新密码不会生效
对应修复 commit:ca44ff5。
避免方式:
- 规则:Docker Compose 服务配置中 · 同一个变量只能在
environment:或env_file:二选一 · 不要并存 - 二选一策略:
- 凡是镜像原生支持 env 读取的变量(如 postgres 的
POSTGRES_*、redis 的--requirepass、.NET 的ASPNETCORE_*)→ 统一放env_file: - 仅用于 compose 本身插值的(如端口号、hostname)→ 才用
environment:+${VAR:-default} - 排查命令:
# 查看容器真正拿到的环境变量
docker compose -f docker-compose.prod.yml exec postgres env | grep POSTGRES_
# 如果 POSTGRES_PASSWORD 的值不对 · 八成是模板陷阱
如果你遇到:postgres 认证失败但 env 文件密码明显正确 → docker compose down -v 清 volume → 删 environment: 块 → up -d 重启。
5.13 bash source .env.production 报 syntax error near unexpected token 'newline'
症状:在 VPS 上跑 ./scripts/certbot-init.sh 脚本签发证书时,一开始就报:
脚本里那一行写的是:
根因:.env.production 有一条值:
- bash 的
source(即.)命令按严格 shell 语法解析整个文件 · 把<noreply@send.joysnd.com>解读为输入重定向 · 而>紧跟的是空格 + 换行 · 触发语法错误 - Docker Compose 的
env_file:是宽松 KEY=VALUE 解析 · 按字面读整行 · 所以 api 容器内Resend__FromAddress的值完全正确 source只是脚本取CERTBOT_EMAIL和PRIMARY_DOMAIN两个变量 · 却被一个无关的行搞垮
修复:certbot-init.sh 不再整份 source,改用 grep + cut 精准取需要的 2 个变量,并 tr -d 去掉可能的引号:
# 旧(错误)
set -a
source .env.production
set +a
# 新(正确)· scripts/certbot-init.sh
CERTBOT_EMAIL="$(grep -E '^CERTBOT_EMAIL=' .env.production | cut -d= -f2- | tr -d '"'"'"'')"
PRIMARY_DOMAIN="$(grep -E '^PRIMARY_DOMAIN=' .env.production | cut -d= -f2- | tr -d '"'"'"'')"
# 断言非空
: "${CERTBOT_EMAIL:?CERTBOT_EMAIL missing in .env.production}"
: "${PRIMARY_DOMAIN:?PRIMARY_DOMAIN missing in .env.production}"
对应修复 commit:32e245b。
避免方式:
- 规则:
.env文件是 dotenv 格式(宽松),不要用source/.来读 · 用grep | cut或专门的 dotenv 解析工具(direnv/dotenv-cli) - 含特殊字符的值:
< > & | ; ( ) $ \``<code>`</code>在 dotenv 里合法 · 但在 shell 里是元字符 ·source必炸 - CI 检查:脚本提交时跑
shellcheck scripts/*.sh·source .env*模式可列入 ban list - 本地测试:
如果你遇到:shell 脚本引用 .env 报语法错 → 立刻改用 grep | cut 取值;如果值很多,用 docker compose run --env-file .env.production --rm alpine env 把解析交给 Docker。
5.14 certbot 明明签发成功,但 [ -f ... ] 误报"证书缺失"
症状:./scripts/certbot-init.sh 脚本第 3 步 certbot 日志明确显示:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/api.joysnd.com/fullchain.pem
但紧接着第 4 步"验证证书文件存在"却报错退出:
用 sudo ls /opt/xisound-api/certbot/conf/live/api.joysnd.com/ 能看到 4 个 symlink · 但普通 ls 或 [ -f ... ] 确实看不见。
根因:Let's Encrypt certbot 在容器内 /etc/letsencrypt/live/<domain>/ 的产出结构是:
drwx------ root root live/api.joysnd.com/ ← 目录 mode 是 700 (rwx------)
lrwxrwxrwx root root fullchain.pem → ../../archive/api.joysnd.com/fullchain1.pem
lrwxrwxrwx root root privkey.pem → ../../archive/api.joysnd.com/privkey1.pem
lrwxrwxrwx root root chain.pem → ../../archive/api.joysnd.com/chain1.pem
lrwxrwxrwx root root cert.pem → ../../archive/api.joysnd.com/cert1.pem
- 目录本身
chmod 700 root:root· 只有 root 能 traverse(x权限) - 通过 volume 映射到宿主机的
/opt/xisound-api/certbot/conf/live/api.joysnd.com/· 目录权限保留 - 脚本以非 root 的
ubuntu用户跑 ·[ -f .../fullchain.pem ]需要 stat 这个 symlink · 必须先能进入父目录 · 无x权限 → EACCES →[ -f ]返回 false → 误报"缺失" - 证书其实签发成功且正确放置 · 只是宿主机非 root 用户看不见
修复:certbot-init.sh 第 4 步从"宿主机 [ -f ] 检查"改为"容器内以 root 身份 ls 验证":
# 旧(错误)· 宿主机 ubuntu 用户视角
for f in fullchain.pem privkey.pem chain.pem; do
[ -f "./certbot/conf/live/${PRIMARY_DOMAIN}/$f" ] \
|| { echo "[FATAL] 证书文件缺失:$f"; exit 1; }
done
# 新(正确)· 容器内 root 身份
docker compose -f docker-compose.prod.yml run --rm --entrypoint sh certbot \
-c "ls -l /etc/letsencrypt/live/${PRIMARY_DOMAIN}/fullchain.pem \
/etc/letsencrypt/live/${PRIMARY_DOMAIN}/privkey.pem \
/etc/letsencrypt/live/${PRIMARY_DOMAIN}/chain.pem" \
|| { echo "[FATAL] 证书文件缺失(容器内验证)"; exit 1; }
对应修复 commit:df463d5。
避免方式:
- 规则:凡是 Docker volume 映射的、来自容器内 root 产出的目录 · 宿主机验证一律用容器内视角(
docker compose run --rm <svc> ls ...) - 典型场景清单:Let's Encrypt 证书目录、postgres PGDATA(
/var/lib/postgresql/data)、redis dump.rdb、任何chmod 700/600的敏感目录 - 诊断命令:
# 怀疑是权限问题时 · 切 root 再看
sudo ls -la /opt/xisound-api/certbot/conf/live/api.joysnd.com/
sudo stat /opt/xisound-api/certbot/conf/live/api.joysnd.com/fullchain.pem
# 或者直接容器内看
docker compose run --rm --entrypoint sh certbot \
-c "ls -la /etc/letsencrypt/live/api.joysnd.com/"
如果你遇到:脚本报"文件缺失"但 certbot 日志显示成功 → 先 sudo ls 确认 · 若 sudo 能看见而非 sudo 看不见 → 改容器内验证。
5.15 InvariantGlobalization=true 阻止 UTF-8 中文/多字节 JSON 反序列化
症状:生产部署后首次 POST 业务测试:
curl -X POST https://api.joysnd.com/api/lead \
-H 'Content-Type: application/json' \
-d '{"stage":"e2e","name":"生产上线测试","email":"a@b.com","consent":true}'
返回:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"$.name": ["The JSON value could not be converted to System.String. Path: $.name, LineNumber: 0, BytePositionInLine: 42."]
}
}
值明明是合法 UTF-8 字符串,为什么反序列化失败?
根因:src/XiSound.Api/XiSound.Api.csproj 里有:
InvariantGlobalization=true是 .NET 为了减小镜像体积的开关 · 不加载 ICU 库 · 全局使用 Invariant Culture- System.Text.Json 在反序列化多字节 UTF-8 字符(中文 / 日文 / 韩文 / Emoji)时 · 依赖 ICU 的 Unicode 规范化 · 缺 ICU → 解码失败 → 报"不能转换为 System.String"
- 纯 ASCII 输入(
"name":"Test User")也可能成功 · 导致开发期测试不到 · 生产首次中文请求才炸 - 典型误用:ASP.NET Core 8 的
aspnet:8.0基础镜像自带基础 ICU(用于 globalization 的 Alpine/Debian 都有)· 不需要强制 Invariant
修复:直接删掉这一行(或整段 PropertyGroup):
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
- <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
对应修复 commit:6eadf04。修完后必须:
docker compose -f docker-compose.prod.yml build api # 重新构建镜像
docker compose -f docker-compose.prod.yml up -d api # 换容器
避免方式:
- 规则:只要服务处理任何用户输入或本地化文本 · 一律不要开
InvariantGlobalization=true - 什么时候可以开:纯数字计算 / 纯 ASCII 协议的后台 worker / lambda · 且你已在 CI 跑过中文/日文字符串测试
- CI 检查:集成测试里加一条:
[Fact]
public async Task PostLead_AcceptsChineseName()
{
var resp = await _client.PostAsJsonAsync("/api/lead", new {
stage = "test", name = "测试用户", email = "a@b.com", consent = true
});
resp.EnsureSuccessStatusCode(); // 若 InvariantGlobalization 还在 · 这里会 400
}
- 镜像体积代价:保留 ICU 约增加
aspnet:8.0镜像 30-40 MB · 对 xisound-api 这种对外公开的 API 服务完全可接受
如果你遇到:POST 中文 JSON 返回 400 "could not be converted to System.String" → 第一反应查 .csproj 里 InvariantGlobalization · 删之 · rebuild · 不要去改 JSON 序列化配置(治标不治本)。
5.16 dotnet ef migrations add 用 SQLite provider 生成 PostgreSQL migration(类型映射灾难)
症状:生产 POST /api/lead 返回 HTTP 500 + content-length: 0 · api 日志:
Npgsql.PostgresException (0x80004005): 42804: column "Consent" is of type integer but expression is of type boolean
POSITION: 294
at XiSound.Api.Controllers.LeadsController.Submit(LeadDto dto) in /src/.../LeadsController.cs:line 80
C# Model 里 Consent 是 bool · PostgreSQL 表里却是 integer 列 · EF Core 发 boolean 参数时 PG 严格拒绝。
根因:dotnet ef migrations add InitialPostgres 执行时 · Program.cs 里的 provider 切换逻辑:
var provider = config["Database:Provider"] ?? "sqlite"; // ← 默认 sqlite
if (string.Equals(provider, "postgres", ...)) opt.UseNpgsql(connStr);
else opt.UseSqlite(connStr);
依赖 Database:Provider 配置值 · 但 dotnet ef CLI 启动的子进程可能不继承父 shell 的临时环境变量(尤其 PowerShell $env:Database__Provider="postgres" 只在当前会话有效)· 所以 design-time 走了 fallback SQLite 分支 · 生成的 migration 列类型是 SQLite 风格:
// ❌ 错的(SQLite 风格 · 被应用到 PG 后语义漂移)
Consent = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 64, ...),
PostgreSQL 认识字符串 "INTEGER"(当作 int4)· 于是把 Consent 建成了 integer 列 · 运行时 EF Core 发 bool 参数 · 42804 语法错误。
修复:加一个 IDesignTimeDbContextFactory<AppDbContext> 硬编码 Npgsql provider · 让 design-time 工具永远用正确的类型映射表:
// src/XiSound.Api/Data/AppDbContextDesignTimeFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace XiSound.Api.Data;
public class AppDbContextDesignTimeFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Port=5432;Database=xisound;Username=xisound;Password=design_time_dummy")
.Options;
return new AppDbContext(options);
}
}
然后:
# 本地(Windows)
cd xisound-api\src\XiSound.Api
# 删除旧的错误 migration
Remove-Item -Force Migrations\*_InitialPostgres.cs, Migrations\*_InitialPostgres.Designer.cs, Migrations\AppDbContextModelSnapshot.cs
# 重新生成(这次强制 Npgsql provider)
dotnet ef migrations add InitialPostgres --context AppDbContext --output-dir Migrations
新 migration 应包含 PostgreSQL 原生类型:
// ✅ 对的(Npgsql 风格)
Consent = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, ...),
VPS 端(必须清掉已污染的 postgres volume,否则 Migrate() 看到历史表会跳过重建):
cd /opt/xisound-api && git pull
docker compose -f docker-compose.prod.yml down
docker volume rm xisound-api_postgres-data # ⚠️ 清坏 schema
docker compose -f docker-compose.prod.yml build --no-cache api
docker compose -f docker-compose.prod.yml up -d
对应修复 commit:453bdbe。
避免方式:
- 规则:任何支持多 provider 的 EF Core 项目 · 只要 Program.cs 用了
if (provider=="xxx") UseXxx else UseYyy动态切换 · 必须额外加一个IDesignTimeDbContextFactory明确 design-time 用哪个 provider · 别指望环境变量传递 - CI 检查:提交 migration 时自动 diff 列类型:
# 任何新 migration · 列类型字符串不得含 SQLite 风格关键词
grep -E 'type:\s*"(TEXT|INTEGER|REAL|BLOB)"' src/XiSound.Api/Migrations/*.cs \
&& { echo "ERROR: SQLite-style column types in migration · regenerate with Npgsql provider"; exit 1; }
- 验证命令:
# 部署后立刻查 schema 真实列类型
docker compose exec postgres psql -U xisound -d xisound -c '\d "Leads"' | grep -E 'Consent|CreatedAt|Name'
# 期望:Consent | boolean · CreatedAt | timestamp with time zone · Name | character varying(64)
如果你遇到:生产 POST 返回 500 + PG 42804/42883 类型错误 → 查 migration 文件列类型字符串 → 若为 "TEXT"/"INTEGER" → 按上述步骤重建。
5.17 HEALTHCHECK 工具链二连坑(wget 缺失 → dash 不支持 /dev/tcp → 装 curl 解决)
症状:docker compose ps 长期显示 Up X minutes (unhealthy)(但服务实际可用)· docker inspect api 看到 State.Health.Log:
第一次尝试(用 wget):
第二次尝试(改用 bash /dev/tcp):
根因分层:
| 尝试 | 方案 | 失败原因 |
|---|---|---|
| ① | HEALTHCHECK ... wget --spider ... |
mcr.microsoft.com/dotnet/aspnet:8.0(Debian 12 slim)为精简镜像 · 既不含 curl 也不含 wget |
| ② | HEALTHCHECK CMD-SHELL "exec 3<>/dev/tcp/localhost/8080" |
/dev/tcp/host/port 是 bash 独有特性 · Debian /bin/sh 是 dash(不是 bash)· dash 不支持 /dev/tcp 伪设备 |
| ③ ✅ | Dockerfile 安装 curl + HEALTHCHECK ... curl --fail ... |
成功 · HTTP-level 探活(最精确) |
unhealthy 本身不会触发容器重启(docker 默认行为)· 但:
- 运维监控会报警(误报)
- depends_on: condition: service_healthy 的链路会卡住启动
- 用户在 docker ps 里看不清楚"到底是坏了还是没坏"
修复:
# src/xisound-api/Dockerfile 的 runtime stage 加 curl
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# +8MB · 换来 HTTP-level 健康探测
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# ... 其他指令 ...
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl --fail --silent --output /dev/null http://localhost:8080/health || exit 1
同步更新 docker-compose.prod.yml(双保险覆盖):
api:
healthcheck:
test: ["CMD-SHELL", "curl --fail --silent --output /dev/null http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
对应修复 commit:1442c78。
避免方式:
- 规则:任何在容器里要跑的外部命令(
wget/curl/nc/ss/netstat)必须先在 Dockerfile 中apt-get install· 不要假设基础镜像"应该有" - 规则:不要用
/dev/tcp除非你明确指定了bash而不是sh· 即使你写了#!/bin/bash·HEALTHCHECK CMD-SHELL里调的还是/bin/sh - 验证脚本:
# 提交 Dockerfile 前本地验证 healthcheck 能跑
docker build -t xisound-api:test .
docker run -d --name tmp xisound-api:test
sleep 40 # 等过 start_period
docker inspect tmp --format 'Status={{.State.Health.Status}} · FailingStreak={{.State.Health.FailingStreak}}'
# 期望:Status=healthy · FailingStreak=0
docker rm -f tmp
- 镜像扫清单:
.NET aspnet:8.0/python:3.12-slim/node:20-alpine默认都不含 curl/wget · 必装 · 否则 HEALTHCHECK 必炸
如果你遇到:容器状态长期 unhealthy 但服务可用 → docker inspect <container> --format '{{range .State.Health.Log}}{{.Output}}{{end}}' 查 · executable file not found 说明命令不在镜像 · 装之或改命令
5.18 nginx.conf 被 git pull 覆盖回 HTTP-only 版导致 443 连接被重置(CF 521)
症状:CF 公网访问突然返回 521(CF 连不到源站)· VPS 本地:
curl -k -I https://localhost/health
# curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to localhost:443
但 docker compose ps nginx 显示 Up · UFW 443 放行 · 云防火墙 443 放行。
根因:本仓库按 §2.4 设计 · nginx/nginx.conf 在仓库里是 HTTP-only 初始版(首次部署用 · 只开 80 端口 · 为 certbot 签发服务)。§3.6 证书签发成功后 · 用户手动执行 cp nginx/nginx.https.conf nginx/nginx.conf 把内容换成 HTTPS 完整版 · 但本地仓库的 nginx.conf 文件仍然是 git-tracked · 这就埋雷了:
- 场景 A:用户在运维过程中执行
git pull· 若上游nginx.conf没变化 · 本地修改被保留 · 无事 - 场景 B:如果因为某种原因触发了 git 对
nginx.conf的重置(比如git checkout ./git reset --hard/ 合并冲突时选择了上游版)· 本地的 HTTPS 版就会被默默覆盖回 HTTP-only 版 - 场景 C:在
docker compose down+ 某些 git 操作后重启 nginx · 它就会加载仓库的 HTTP-only 版 · 443 端口根本没监听 → 内核 RST → 521
nginx 进程本身正常(Up 12 minutes),但它只监听 80 端口 · 443 的 SYN 包在 TCP/IP 栈层面被拒绝。
修复:
立即处置(恢复 443 服务):
cd /opt/xisound-api
cp nginx/nginx.https.conf nginx/nginx.conf
docker compose -f docker-compose.prod.yml exec nginx nginx -t # 语法验证
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload # 热重载
# 验证:
curl -k -I https://localhost/health # 期望 HTTP 200
长期方案(防止再次被覆盖)二选一:
方案 A · 把 nginx.conf 从 Git 移除 tracking(推荐):
cd xisound-api
# 让 nginx.conf 只在仓库里有一个"样板"文件名 nginx.conf.example
git mv nginx/nginx.conf nginx/nginx.conf.http-only.example
echo 'nginx/nginx.conf' >> .gitignore
# 部署时明确两步 cp:
# 首次签发前:cp nginx/nginx.conf.http-only.example nginx/nginx.conf
# 证书就绪后:cp nginx/nginx.https.conf nginx/nginx.conf
方案 B · 部署脚本自动切换:写个 scripts/nginx-use-https.sh,每次 docker compose up -d nginx 前强制执行:
#!/usr/bin/env bash
cd "$(dirname "$0")/.."
if [ -f certbot/conf/live/api.joysnd.com/fullchain.pem ]; then
cp nginx/nginx.https.conf nginx/nginx.conf
echo "[nginx-use-https] installed HTTPS config"
else
echo "[nginx-use-https] cert not yet issued · keeping HTTP-only"
fi
避免方式:
- 规则:凡是"两态切换"的配置文件(签发前 HTTP-only / 签发后 HTTPS · dev/prod 等)· 不要让一个文件名在 git 里承担"两种状态" · 用两份互不覆盖的样板(
nginx.conf.http.example+nginx.conf.https.example)+.gitignore实际生效的nginx.conf - 部署清单(加进 README 或运维手册):每次
git pull之后、docker compose up -d之前 · 检查grep 'listen 443 ssl' nginx/nginx.conf是否有命中 - 告警:UptimeRobot(M6.2 阶段 5)定期探
https://api.joysnd.com/health· 521 / reset by peer 立刻告警
如果你遇到:CF 突然 521 · VPS 端 curl https://localhost 也 reset → 第一反应 grep 'listen 443' nginx/nginx.conf · 若无命中立刻 cp nginx.https.conf nginx.conf + reload。
6. M6.2 阶段 4-6(备份 · 监控 · CI/CD)
手册 v1.2 起 · 本章把原"概览指向"升级为完整实施手册,覆盖 M6.2 剩余三个阶段。实施顺序按"风险兜底优先"原则:
| 阶段 | 内容 | 工期 | 启动判据 | 状态 |
|---|---|---|---|---|
| 阶段 6 · 备份 | pg_dump cron + 7 天轮转 + 可选 COS 异地 + 季度恢复演练 | 0.5 天 | 无前置依赖 · 最急(数据零备份是生产红线) | ✅ 已完成(2026-05-08) |
| 阶段 5 · 监控 | UptimeRobot 5min 探活 + 腾讯云主机告警(Sentry 延后) | 0.5 天 | 无前置依赖 · 告警兜底第二优先级 | ✅ 已完成(2026-05-08) |
| 阶段 4 · CI/CD | GitHub Actions → GHCR → SSH deploy + healthcheck 回滚 | 1 天 | 备份 + 监控就绪 · 用户决策"不等 48h" | ✅ 已完成(2026-05-08) |
M6.2 全部 6 阶段已闭环 · 2026-05-08
阶段 0 账号/VPS 采购 + 阶段 1+2+3 环境/编排/HTTPS 上线 + 阶段 4 CI/CD 自动部署 + 阶段 5 监控告警 + 阶段 6 数据备份 · 生产 https://api.joysnd.com 已具备完整的生产级运维能力。
6.1 阶段 6 · PostgreSQL 每日备份(✅ 已完成)
6.1.1 设计目标
- RPO(数据丢失窗口) ≤ 24 小时:每天一次 pg_dump · 最坏情况丢失不到 1 天数据
- RTO(恢复时间) ≤ 15 分钟:本地保留最近 7 天 · 一条 gunzip + psql 命令即可恢复
- 防爆盘:单次备份 < 1 MB(留资量小)· 7 天合计 < 10 MB · 对 VPS 磁盘无压力
- 可选异地:腾讯云 COS 保留 30 天 · 应对 VPS 物理磁盘故障
6.1.2 交付物清单
| 文件 | 说明 |
|---|---|
scripts/backup-db.sh |
主备份脚本(112 行 · 含完整注释)· 源码 |
VPS crontab 30 2 * * * |
每天 UTC 02:30(北京 10:30)自动触发 · 与证书续签(03:00)错开 |
/opt/xisound-api/backups/xisound-YYYYMMDD-HHMMSS.sql.gz |
备份文件目录 · chmod 600 |
/opt/xisound-api/logs/backup-db.log |
执行日志 · 含 [backup-ok] / [prune-ok] / [cos-skip] 等标签 |
6.1.3 脚本核心逻辑(解读)
# 1. pg_dump → gzip 管道(--clean --if-exists 保证恢复时能干净重建)
docker compose -f docker-compose.prod.yml exec -T postgres \
pg_dump -U xisound -d xisound --clean --if-exists -F p \
| gzip -c > "./backups/xisound-${TS}.sql.gz"
# 2. 7 天轮转(find -mtime +7 -delete)
find "./backups" -name 'xisound-*.sql.gz' -type f -mtime +7 -delete
# 3. 可选 COS(只在 COS_BUCKET 环境变量存在且 coscli 安装时触发 · 否则 [cos-skip])
if [ -n "$COS_BUCKET" ] && command -v coscli &> /dev/null; then
coscli cp "$BACKUP_FILE" "${COS_BUCKET}/postgres/xisound-${TS}.sql.gz"
fi
6.1.4 部署步骤(复盘)
ssh xisound-api
cd /opt/xisound-api
# 1. 拉最新代码(含 scripts/backup-db.sh · +x 权限已随 Git 记录)
git pull
# 2. 确认脚本可执行(应为 -rwxrwxr-x)
ls -la scripts/backup-db.sh
# 3. 首次手动跑验证(应看到 [backup-ok] + 生成 .sql.gz 文件)
mkdir -p logs
./scripts/backup-db.sh
# 4. 验证备份内容完整性(应看到 CREATE TABLE "Leads" + 23 列 · Consent boolean · CreatedAt timestamp with time zone)
zcat backups/xisound-*.sql.gz | head -20
zcat backups/xisound-*.sql.gz | grep -A 25 'CREATE TABLE.*Leads' | head -30
# 5. 追加 cron 任务(每天 UTC 02:30 · 与 03:00 证书续签错开)
(crontab -l 2>/dev/null | grep -v 'backup-db.sh'; \
echo '30 2 * * * /opt/xisound-api/scripts/backup-db.sh >> /opt/xisound-api/logs/backup-db.log 2>&1') | crontab -
# 6. 验证 crontab 两任务就位
crontab -l | grep -E 'certbot-renew|backup-db'
6.1.5 手动恢复演练(季度一次 · DR 验证)
# 场景:从最近备份恢复到临时数据库验证完整性(不影响生产库)
# 1. 选一份备份
ls -lah /opt/xisound-api/backups/
# 比如选 xisound-20260508-031608.sql.gz
# 2. 在 postgres 容器创建临时库
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U xisound -d xisound -c 'CREATE DATABASE xisound_dr_test;'
# 3. 恢复到临时库
gunzip -c /opt/xisound-api/backups/xisound-20260508-031608.sql.gz | \
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U xisound -d xisound_dr_test
# 4. 对比生产库和临时库的 Leads 行数(应一致或接近 · 差值 = 备份时刻到当前时刻新增的留资数)
echo "PROD:"
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U xisound -d xisound -t -c 'SELECT COUNT(*) FROM "Leads";'
echo "RESTORED:"
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U xisound -d xisound_dr_test -t -c 'SELECT COUNT(*) FROM "Leads";'
# 5. 清理临时库
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U xisound -d xisound -c 'DROP DATABASE xisound_dr_test;'
6.1.6 启用 COS 异地备份(可选 · 推荐生产第 2 个月启用)
为什么要:本地 7 天备份只防"数据库表误删" · 不防"VPS 磁盘硬件故障 / VPS 被整体删除"。COS 异地保留 30 天 · 兜底跨故障域。
前置:腾讯云 COS 账号 + 创建 Bucket(建议用北京区 · 与 VPS 香港区跨域)。
# 1. VPS 上安装 coscli
wget https://github.com/tencentyun/coscli/releases/download/v0.20.0/coscli-linux \
-O /usr/local/bin/coscli
sudo chmod +x /usr/local/bin/coscli
# 2. 配置 SecretId/SecretKey(交互式 · 填腾讯云 API 密钥)
coscli config init
# 3. 测试连通
coscli ls cos://your-bucket-name-1300000000
# 4. 在 /opt/xisound-api/.env.production 追加(备份脚本读取)
echo 'COS_BUCKET=cos://your-bucket-name-1300000000' >> .env.production
# 5. 改 cron 让脚本能读到 COS_BUCKET(方法一:脚本里改用 grep + cut;方法二:crontab 前置 source)
# 推荐方法二 · 改 crontab 那一行:
30 2 * * * cd /opt/xisound-api && export COS_BUCKET=$(grep '^COS_BUCKET=' .env.production | cut -d= -f2-) && ./scripts/backup-db.sh >> logs/backup-db.log 2>&1
# 6. 手工触发验证
./scripts/backup-db.sh
# 期望:[cos-ok] cos://your-bucket/postgres/xisound-YYYYMMDD-HHMMSS.sql.gz
COS 生命周期规则(腾讯云控制台配 · 非脚本职责):
- Bucket → 基础配置 → 生命周期 → 添加规则
- 匹配前缀:postgres/xisound-
- 30 天后 → 转归档存储 / 或直接删除(按成本敏感度)
6.1.7 首次部署实测记录(2026-05-08)
[2026-05-08T03:16:08Z] 开始 pg_dump → ./backups/xisound-20260508-031608.sql.gz
[2026-05-08T03:16:08Z] [backup-ok] ./backups/xisound-20260508-031608.sql.gz (4.0K)
[2026-05-08T03:16:08Z] 清理 7 天以前的旧备份...
[2026-05-08T03:16:08Z] [prune-ok] 删除 0 个旧备份
[2026-05-08T03:16:08Z] 当前备份目录:1 个文件 · 总计 8.0K
[2026-05-08T03:16:08Z] [cos-skip] COS 未配置 · 仅本地备份
[2026-05-08T03:16:08Z] 备份流程完成
验证:备份文件大小 1.8K(gzip 压缩后)· 未压缩原始 SQL 4 KB · 含 CREATE TABLE "Leads" 23 列完整定义 · Consent boolean · CreatedAt timestamp with time zone 类型全部符合 §5.16 修复后状态。
6.2 阶段 5 · UptimeRobot + 腾讯云主机告警(✅ 已完成)
6.2.1 设计目标
- 探活检测:每 5 分钟主动探
https://api.joysnd.com/health· 2 次连续失败立刻邮件告警 - 主机告警:腾讯云主机 CPU/内存/磁盘/网络异常 · 短信 + 邮件告警
- 告警收敛:避免"狼来了"· 短间隔重复告警折叠为一条
- Sentry 延后:M6.3 账号系统上线涉及复杂异常场景时再集成(当前业务简单 · 日志里的异常堆栈够查)
6.2.2 UptimeRobot 配置步骤(10 分钟完成)
步骤 1 · 注册账号
- 打开 https://uptimerobot.com · 点右上 Register for FREE
- 用
zhangzm@joysnd.com(已验证能收邮件)注册 · 收验证邮件后点激活链接 - 免费套餐:50 个监控项 · 5 分钟间隔(本项目只需 1 个 · 绰绰有余)
步骤 2 · 添加 Monitor
Dashboard → + Add New Monitor:
| 字段 | 值 |
|---|---|
| Monitor Type | HTTP(s) |
| Friendly Name | XiSound API Production |
| URL (or IP) | https://api.joysnd.com/health |
| Monitoring Interval | 5 minutes(免费档最小) |
| Monitor Timeout | 30 seconds(足够 · CF + VPS 正常响应 < 500ms) |
| HTTP Method | GET |
点 Create Monitor。
步骤 3 · 配告警通知(Alert Contacts)
进入 My Settings → Alert Contacts → Add Alert Contact:
| 字段 | 值 |
|---|---|
| Alert Contact Type | E-mail |
| Friendly Name | Ops Primary |
| E-mail to Send | zhangzm@joysnd.com |
| Send notifications for | When the monitor goes DOWN and comes back UP |
点 Create Alert Contact · 收验证邮件 · 点链接激活。
步骤 4 · 把 Alert Contact 绑到 Monitor
回到 Monitor 列表 · 点 XiSound API Production → Edit → Alert Contacts To Notify → 勾选 Ops Primary → Save Changes。
步骤 5 · 验证告警流程(可选但推荐)
主动触发一次 DOWN 告警测试:
# VPS 临时停掉 nginx 1 分钟(会让 /health 无法响应)
ssh xisound-api
cd /opt/xisound-api
docker compose -f docker-compose.prod.yml stop nginx
# 等 6-10 分钟(UptimeRobot 至少要 2 次连续失败才告警)
# 收到"Monitor is DOWN"邮件后:
docker compose -f docker-compose.prod.yml start nginx
# 再等 6-10 分钟 · 会收到"Monitor is UP"邮件
6.2.3 UptimeRobot 高级配置(可选)
Keyword Monitoring(升级版 · 不仅检查 200 状态码 · 还要求响应体含特定关键词):
- Monitor Type 改为
Keyword - Keyword:
alive - Keyword Exists / Not Exists:
Keyword Exists - 行为:如果
/health返回 200 但响应体不含alive字符串(比如 api 被劫持返回假页面)· 立即告警
HTTPS 证书到期告警:
- 免费档无此功能 · 但本项目 certbot 自动续签已覆盖这个风险
- 备选:另加一个 Monitor 专门探
https://api.joysnd.com/(首页)· 配 SSL expiry 告警(付费档功能)
多通道告警(免费档支持最多 3 个 Alert Contact):
- 主邮箱:
zhangzm@joysnd.com - 备份邮箱:
support@joysnd.com - Webhook(可转企微机器人 · 复用留资通知的机器人):参考 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx 文档
6.2.4 腾讯云主机告警(免费内置)
腾讯云控制台 → 云监控 → 告警策略 → 新建:
| 字段 | 值 |
|---|---|
| 策略名称 | XiSound VPS 资源告警 |
| 策略类型 | 云服务器-基础监控 |
| 关联实例 | 选中你的香港 Lighthouse 实例 |
| 触发条件 | 见下表 |
推荐触发条件 4 条:
| 指标 | 阈值 | 持续周期 |
|---|---|---|
| CPU 使用率 | > 80% | 连续 5 次(每次 1 分钟) |
| 内存使用率 | > 85% | 连续 5 次 |
| 磁盘使用率(系统盘) | > 85% | 连续 3 次 |
| 公网出带宽 | > 80 Mbps | 连续 3 次(可能是攻击) |
接收方式:邮箱 + 短信(腾讯云账号绑定的手机号)
6.2.5 Sentry .NET SDK 集成(本轮延后 · M6.3 再做)
延后理由:
1. 当前 api 业务简单(POST /api/lead + GET /admin/leads)· 异常点少 · docker compose logs api 已足够查
2. Sentry 免费档 5000 事件/月 · 但接入需要改 csproj + Program.cs · 再添 Sentry__Dsn 环境变量 · 增加维护面
3. M6.3 账号系统上线后 · JWT 验证 / 签名 URL / Redis session 等复杂流程的异常 stack trace 才真正需要结构化收集
留存 TODO(M6.3 阶段一起做):
// src/XiSound.Api/Program.cs 开头附近
builder.WebHost.UseSentry(o => {
o.Dsn = builder.Configuration["Sentry:Dsn"]; // 从 .env.production 注入
o.Environment = builder.Environment.EnvironmentName;
o.TracesSampleRate = 0.2; // 采样 20% 请求性能数据 · 免费档防止超额
o.AttachStacktrace = true;
});
6.2.6 验收清单
- UptimeRobot 注册 · Monitor
XiSound API Production创建 · 间隔 5 分钟 - Alert Contact
Ops Primary绑定 · 邮件告警地址已验证 - 触发一次 DOWN/UP 测试 · 确认邮件收到
- 腾讯云主机告警策略创建 · 4 条触发条件 · 邮件 + 短信接收
- Sentry 延后到 M6.3 集成
6.3 阶段 4 · GitHub Actions CI/CD(✅ 已完成)
6.3.1 设计目标
- 触发方式:
git push origin main自动触发(main 分支 = 生产分支 · 无需手动 deploy) - 流程:Checkout → 单元测试(占位 · 暂无)→ 构建 Docker 镜像 → 推送到 GHCR → SSH 到 VPS →
docker compose pull + up -d --force-recreate→ 健康检查 → 失败自动回滚到上一版 - 镜像仓库:GitHub Container Registry(GHCR · 免费 · 无需额外账号)
- 部署权限:专用 deploy SSH key(区别于日常
ssh xisound-api的 key · 最小权限原则) - 健康检查:
curl https://api.joysnd.com/health连续 3 次 5 秒间隔 · 任何一次失败则回滚
6.3.2 总流程图
graph LR
Dev[本地<br/>git push origin main] --> GA[GitHub Actions]
GA --> Build[docker build<br/>→ ghcr.io/xxx/xisound-api:SHA + :latest]
Build --> SSH[SSH 到 VPS]
SSH --> Pull[docker compose pull api]
Pull --> Recreate[up -d --force-recreate api]
Recreate --> HC{health check<br/>×3}
HC -->|all 200| Done[✅ 部署成功]
HC -->|any fail| Rollback[docker tag previous-image<br/>→ :latest + recreate]
Rollback --> Fail[❌ 部署失败<br/>但服务恢复到上版]
class Dev,GA xyL4
class Build,Pull,Recreate xyL3
class SSH xyL2
class HC xyL5
class Done xySuccess
class Rollback,Fail xyWarn
6.3.3 VPS 端准备工作(一次性 · 5 分钟)
步骤 1 · 生成专用 deploy key 对(本地 PowerShell):
ssh-keygen -t ed25519 -C "github-actions-deploy@xisound" -f $env:USERPROFILE\.ssh\xisound_deploy -N ""
# 生成:~/.ssh/xisound_deploy(私钥)+ ~/.ssh/xisound_deploy.pub(公钥)
步骤 2 · 把公钥加到 VPS authorized_keys:
# 本地:把公钥内容一键推到 VPS
Get-Content $env:USERPROFILE\.ssh\xisound_deploy.pub | ssh xisound-api "cat >> ~/.ssh/authorized_keys"
# VPS 端验证
ssh xisound-api "tail -3 ~/.ssh/authorized_keys"
步骤 3 · 测试新 deploy key 能登录:
ssh -i $env:USERPROFILE\.ssh\xisound_deploy ubuntu@api.joysnd.com "whoami && hostname"
# 期望:ubuntu + VM-0-7-ubuntu(或你 VPS 的 hostname)
步骤 4 · 读出私钥内容备用(GitHub secrets 要填这个):
Get-Content $env:USERPROFILE\.ssh\xisound_deploy -Raw
# 输出的多行内容(含 -----BEGIN OPENSSH PRIVATE KEY----- 头尾)完整复制
6.3.4 GitHub 仓库配置(3 条 Secrets)
位置:https://github.com/mengliliusha/xisound-api/settings/secrets/actions
点 New repository secret 添加 3 条:
| Name | Value |
|---|---|
VPS_HOST |
api.joysnd.com(或直接 IP 43.161.245.208) |
VPS_USER |
ubuntu |
VPS_SSH_PRIVATE_KEY |
完整私钥内容(上一步 Get-Content 的输出 · 含 -----BEGIN 和 -----END) |
私钥安全提示
VPS_SSH_PRIVATE_KEY 一旦泄漏 · 任何人都能以 ubuntu 身份登录你的 VPS · 等同于完全控制。所以:
- 绝对不要把私钥内容贴进任何文档 / 聊天 / 邮件
- GitHub secrets 是加密存储 · 只有 Actions runner 临时解密使用 · 安全
- 如果怀疑泄漏:立即在 VPS 上 vim ~/.ssh/authorized_keys 删除对应公钥那一行 + 重新生成 key + 更新 GitHub secret
6.3.5 Workflow 文件 · .github/workflows/deploy.yml
交付物:本仓库已创建 .github/workflows/deploy.yml(见 GitHub 源码链接)
关键设计点:
- Trigger:
push: branches: [main]+workflow_dispatch(支持手动重跑) - Permissions:
packages: write(推 GHCR)·contents: read(读代码) - Image tag 策略:双 tag ·
:sha-<commit-hash-7>(永久追溯)+:latest(生产用) - SSH 部署:
appleboy/ssh-action@v1.0.3(业界标准 · 官方维护) - 健康检查:
for i in {1..3}; do curl -f ...; sleep 5; done· 任何一次失败则回滚 - 回滚机制:失败时用
docker tag <prev-sha>:latest恢复上一版 + recreate
6.3.6 部署时序(实际触发流程)
# 1. 开发者本地改代码 → 提交 → push
cd xisound-api
git add -A
git commit -m "feat: add new endpoint"
git push origin main
# 2. GitHub Actions 自动触发(约 3-5 分钟完成)
# - checkout + setup .NET(~30s)
# - docker buildx build + push ghcr.io(~2-3min)
# - SSH 到 VPS + docker compose pull + up -d(~20s)
# - healthcheck ×3(~15s)
# - 若失败:自动回滚(~20s)
# 3. 开发者在 GitHub Actions 页面看进度
# https://github.com/mengliliusha/xisound-api/actions
# 绿色勾 = 部署成功 · 红色叉 = 失败已回滚
6.3.7 本地手动 deploy 仍保留(作为 CI/CD 失效时的后备)
场景:GitHub Actions 暂时不可用(比如 Actions 限额用完 · 或 workflow bug 卡住)时,仍可走原手动流程:
ssh xisound-api
cd /opt/xisound-api
git pull
docker compose -f docker-compose.prod.yml build --no-cache api
docker compose -f docker-compose.prod.yml up -d --force-recreate api
6.3.8 首次触发实测记录(2026-05-08)
- 首次 push:
commit XXXXXXX(.github/workflows/deploy.yml本身就是这条 commit 的内容之一) - Actions 执行时长:约 3 分 20 秒
- 结果:✅ 绿色勾 · 生产
https://api.joysnd.com/health返回 200 - GHCR 镜像:
ghcr.io/mengliliusha/xisound-api:latest· 公开只读 + 推送需要 PAT(workflow 里用GITHUB_TOKEN自动获取)
6.3.9 常见问题 · CI/CD 专题
| 问题 | 排查路径 |
|---|---|
| Actions 卡在 "Login to GHCR" | permissions: packages: write 是否在 workflow 顶层 |
SSH 失败 Permission denied (publickey) |
VPS_SSH_PRIVATE_KEY 是否完整复制(含首尾 -----BEGIN/END 行 + 中间每一行)· VPS authorized_keys 是否真有对应公钥 |
docker compose pull api 报 unauthorized |
GHCR 镜像被设为私有 · VPS 端要 docker login ghcr.io;或把 package visibility 改为 public |
| Healthcheck 失败但生产实际能访问 | CF Proxy 可能缓存了 5xx · workflow 里 curl 走 https://api.joysnd.com 时要加 -H 'Cache-Control: no-cache' |
| 镜像 tag 冲突 | 同一个 commit push 两次 · :sha-<hash> 会被覆盖 · 但 :latest 正常滚动 · 无实际影响 |
6.3.10 验收清单
- 本地生成
xisound_deploy/xisound_deploy.pub密钥对 - VPS
authorized_keys追加 deploy 公钥 ·ssh -i xisound_deploy ubuntu@api.joysnd.com能登录 - GitHub 仓库 3 条 secrets 配置到位(
VPS_HOST/VPS_USER/VPS_SSH_PRIVATE_KEY) -
.github/workflows/deploy.yml就位并被 push 触发 - 首次自动 deploy 成功 · GHCR 有
xisound-api:latest+:sha-<hash>镜像 - 生产
https://api.joysnd.com/health= 200
7. 附录
7.1 生产目录结构(VPS 上 /opt/xisound-api/)
/opt/xisound-api/
├── .env.production (chmod 600 · 真密钥 · 不进 Git)
├── .env.production.example (模板 · 进 Git)
├── docker-compose.prod.yml
├── Dockerfile
├── nginx/
│ ├── nginx.conf (当前生效 · 签发前=HTTP-only · 签发后=HTTPS 版)
│ ├── nginx.https.conf (HTTPS 完整版模板)
│ └── logs/ (nginx access/error 日志)
├── scripts/
│ ├── certbot-init.sh
│ └── certbot-renew.sh
├── certbot/
│ ├── conf/ (证书 · Let's Encrypt /etc/letsencrypt)
│ │ └── live/api.joysnd.com/
│ │ ├── fullchain.pem
│ │ ├── privkey.pem
│ │ └── chain.pem
│ └── www/ (ACME challenge webroot)
├── backups/ (pg_dump 输出 · M6.2 阶段 6 启用)
└── src/ (.NET 源码 · 部署时仅构建用)
7.2 常用运维命令速查
# 查看所有容器状态
docker compose -f docker-compose.prod.yml ps
# 查看某服务实时日志
docker compose -f docker-compose.prod.yml logs -f api
# 重启 api(不重启 nginx/postgres)
docker compose -f docker-compose.prod.yml restart api
# 拉新镜像 + 重启(部署新版本)
docker compose -f docker-compose.prod.yml pull api
docker compose -f docker-compose.prod.yml up -d api
# nginx 热重载(改了 nginx.conf)
docker compose -f docker-compose.prod.yml exec nginx nginx -t
docker compose -f docker-compose.prod.yml exec nginx nginx -s reload
# 进 postgres shell
docker compose -f docker-compose.prod.yml exec postgres psql -U xisound
# 进 api 容器 bash
docker compose -f docker-compose.prod.yml exec api bash
# 手动跑续签
./scripts/certbot-renew.sh
# 查看实时资源占用
docker stats
7.3 M6.3 启用 Redis 密码(升级路径 · 本轮未做)
M6.3 账号系统上线时,Redis 会承载 JWT blacklist + session,建议启用密码:
# docker-compose.prod.yml 中 redis 服务
redis:
command: redis-server --appendonly yes --appendfsync everysec --requirepass ${REDIS_PASSWORD}
env_file:
- .env.production
.env.production 新增:
api 侧连接串改为 redis://:<password>@redis:6379/0。
7.4 术语表
| 术语 | 一句话 |
|---|---|
| HTTP-01 校验 | Let's Encrypt 签发证书时,要你在 /.well-known/acme-challenge/<token> 放一个文件,它来 HTTP 读,读到就证明你拥有此域 |
| HSTS | HTTP Strict Transport Security · 告诉浏览器"以后永远用 HTTPS 访问此站",防降级攻击 |
| OCSP Stapling | 证书吊销状态预查询 · 减少客户端查询 CA 的延迟 |
| Full (strict) | CF SSL 模式:CF↔访客 HTTPS + CF↔VPS HTTPS(且 VPS 证书必须被公共 CA 签发) |
| Migrations | EF Core 的 schema 演进机制 · 每次改 model 生成一次 migration · 生产按序应用 |
expose vs ports |
expose 只在 docker network 内可达;ports 是对宿主机端口映射(对外) |
| webroot | certbot 验证方式之一 · 把 challenge 文件放在 nginx 能 serve 的路径 |
| deploy-hook | certbot 续签成功后的回调 · 用来触发 nginx reload |
7.5 交叉引用
- 阶段 0 前置准备零基础指南:
m6.2-vps-account-setup.md - M6.1 部署方案总图:
07web-m6.1-deploy-plan.md§5.3 - MkDocs 文档站部署参考:
docs-site-deployment.md - xisound-api TODO 清单:
TODOLIST.md§3 - 项目本地副本:
07_web/doc/M6-deployment-guide.md
7.6 版本历史
| 版本 | 日期 | 要点 |
|---|---|---|
| v1.0 | 2026-05-07 | 首版 · 9 章覆盖 M6.2 阶段 1+2+3 全流程 · 含 §0 密钥吊销应急处置 · VPS IP 全程占位符 · certbot 两步启动 · EF Core Migrations 指引 · CF SSL Full strict |
| v1.1 | 2026-05-07 | §5 故障排查从 Top 10 扩展到 Top 15 · 新增 5 个本轮实战踩坑(§5.11-§5.15:.gitignore 大小写 / Compose 模板 / source .env / symlink 700 / InvariantGlobalization)· 新增 §7.7 踩坑经验总结(按 5 个生态归类 · 附修复 commit 证据)· hero_label 升级 v1.1 · tags 补充 pitfalls, lessons-learned |
| v1.2 | 2026-05-08 | §5 故障排查再扩 3 条 · Top 15 → Top 18(§5.16 EF Core design-time provider fallback 导致 PG 42804 · §5.17 HEALTHCHECK 工具链二连坑 wget 缺失 + dash 无 /dev/tcp · §5.18 nginx.conf 被 git pull 覆盖回 HTTP-only 触发 CF 521)· §7.7 踩坑总结扩至 8 坑(新增 ⑥⑦⑧ 生态:EF Core design-time · 镜像工具链 · 两态配置文件的 git 管理)· hero_sub 改"含 8 大实战踩坑" · 修复 commit 453bdbe(design-time factory)+ 1442c78(Dockerfile curl) |
7.7 踩坑经验总结(按生态归类 · 本轮 M6.2 阶段 1+2+3 实战复盘)
本轮部署从"本地跑通"到"生产 https://api.joysnd.com 上线 + 端到端冒烟全绿"过程中,共发现并修复 8 大生态典型坑。每一条都在 §5.11-§5.18 给出了症状、根因、修复与避免方式。本节做总览归类,方便未来部署前作预检查表使用。
7.7.1 八坑一览表
| # | 生态 | 坑的核心一句话 | 触发阶段 | 修复 commit | 手册位置 |
|---|---|---|---|---|---|
| 1 | Git + Windows | .gitignore 的 data/ 在大小写不敏感文件系统上误匹配源码 Data/ 目录 · AppDbContext.cs 被静默忽略 |
本地 dotnet build 过 · Docker build 炸 |
c6b482d |
§5.11 |
| 2 | Docker Compose | environment: 中的 ${VAR:-default} 只读 shell 环境 · 不读 env_file · postgres 用 fallback 值初始化 · api 用真密码连 · 28P01 |
VPS 首次 up -d |
ca44ff5 |
§5.12 |
| 3 | bash shell | source .env.production 按严格 shell 语法解析 · 值含 <noreply@...> → syntax error near unexpected token 'newline' |
VPS certbot 脚本首跑 | 32e245b |
§5.13 |
| 4 | Linux 权限 + TLS | Let's Encrypt 产出的 live/<domain>/ 是 chmod 700 root:root + symlink · 宿主机非 root 用户 [ -f ] 看不见 · 误报"证书缺失" |
VPS certbot 签发后 | df463d5 |
§5.14 |
| 5 | .NET ICU / UTF-8 | <InvariantGlobalization>true</InvariantGlobalization> 禁 ICU · System.Text.Json 无法反序列化中文/日文/韩文 · POST 中文 JSON 返回 400 |
生产首次 POST 中文请求 | 6eadf04 |
§5.15 |
| 6 | EF Core design-time | dotnet ef migrations add 环境变量未透传 · fallback 到 SQLite provider · 生成 type:"TEXT"/"INTEGER" 列 · PG 把 Consent 建成 integer · 运行时 bool 参数 42804 |
生产首次 POST(PG schema 已污染) | 453bdbe |
§5.16 |
| 7 | 容器镜像工具链 | aspnet:8.0 (Debian slim) 既无 curl 也无 wget · /bin/sh 是 dash · 不支持 /dev/tcp · HEALTHCHECK 三次尝试才成功(wget → /dev/tcp → apt install curl) |
部署后持续 unhealthy 标签 | 1442c78 |
§5.17 |
| 8 | Git 两态配置文件 | nginx.conf 在仓库里是 HTTP-only 初始版 · 证书签发后手动 cp nginx.https.conf nginx.conf · 某次 git 操作(pull/checkout/reset)悄悄覆盖回 HTTP-only · 443 停止监听 · CF 521 |
日常运维 git pull 后 | 运维处置(cp + reload) | §5.18 |
7.7.2 生态坑 · 按"为什么 + 如何避免 + 检测项"详展
① Git + Windows 生态
- 为什么会发生:Windows NTFS 默认大小写不敏感 · Git 在 Windows 上
core.ignorecase=true·.gitignore不带前导斜杠的短名(data/build/log)会递归匹配所有层级同名目录(忽略大小写)· 开发者在 macOS/Linux 上可能看不到这类问题 - 如何避免:
.gitignore里凡纯小写短词必须前导斜杠锚定(/data/)或写精确子路径(/src/.../App_Data/)- pre-commit hook 跑
git check-ignore -v $(git ls-tree -r HEAD --name-only)扫描被意外忽略的文件 - CI 上跑
dotnet build --no-restore+ 断言关键源码文件都在git ls-files里
- 检测脚本:
# 任何时刻自查:所有源码目录是否都进了 Git
find src -type f -name '*.cs' -print0 | xargs -0 -n1 git check-ignore -v 2>&1 | grep -v '^$'
# 如果有任何输出 · 都是被忽略的源码 · 立即修
② Docker Compose 生态
- 为什么会发生:
${VAR:-default}是 Compose 文件的变量插值语法 · 在 compose 命令启动前就已经展开 · 展开时只看执行 compose 时的 shell 环境 +.env文件(compose 同目录的那个,不是env_file:指向的)·env_file:是容器启动后注入到容器进程的 · 这两者是不同阶段 - 如何避免:
- 规则:镜像原生消费的变量(
POSTGRES_*/MYSQL_*/ASPNETCORE_*)统一走env_file:· 不要用${VAR:-default}混合 - 规则:只有 compose 自己需要的变量(端口、镜像 tag、network 名)才用
${VAR:-default}· 放顶层.env文件 - 密码类变量绝不给 fallback 默认值 · 缺失就应该启动失败(改用
${VAR:?not set}语法)
- 规则:镜像原生消费的变量(
- 检测脚本:
# 启动后立刻验证 · 容器里真正的环境变量是不是你期望的
docker compose -f docker-compose.prod.yml exec postgres env | grep -E 'POSTGRES_(USER|PASSWORD|DB)='
docker compose -f docker-compose.prod.yml exec api env | grep -E '(ConnectionStrings__|Resend__|WeWork__)'
③ bash shell 生态
- 为什么会发生:
.env/dotenv格式是宽松 KEY=VALUE · 允许值含空格、<、>、&、引号等 shell 元字符;而source/.是把文件当完整 shell 脚本执行 · 元字符会被解释 - 如何避免:
- 永远不要
source .env*· 改用专门工具:docker compose的env_file:、dotenv-cli、direnv、或grep | cut - 需要在 shell 脚本里单独取一两个变量时 · 用:
- 永远不要
get_env() { grep -E "^$1=" "${2:-.env.production}" | cut -d= -f2- | tr -d '"'"'"''; }
EMAIL="$(get_env CERTBOT_EMAIL)"
DOMAIN="$(get_env PRIMARY_DOMAIN)"
- 检测脚本:pre-commit 跑
shellcheck scripts/*.sh+ 自定义规则 bansource.*\.env
④ Linux 权限 + TLS 生态
- 为什么会发生:生产级 TLS 证书目录必须是 700(私钥防护)· symlink 在 chmod 700 目录下 · 非 root 用户无法 traverse → 无法 stat →
[ -f ]/ls/cat都失败;这不是 bug · 是设计 - 如何避免:
- 规则:任何 Docker volume 映射过来的、容器内 root 产出的敏感目录(证书、postgres PGDATA、redis dump)· 宿主机验证一律用容器内视角
- 运维脚本优先用
docker compose run --rm --entrypoint sh <svc> -c "ls ..."· 而非宿主机ls/test - 遇到"文件明明存在但脚本说没有"时 · 先
sudo ls对比非 sudo · 判断是不是权限问题
- 检测脚本:
# certbot 签发后自验
docker compose -f docker-compose.prod.yml run --rm --entrypoint sh certbot \
-c "ls -la /etc/letsencrypt/live/api.joysnd.com/ && \
openssl x509 -in /etc/letsencrypt/live/api.joysnd.com/fullchain.pem -noout -dates -subject -issuer"
⑤ .NET ICU / UTF-8 生态
- 为什么会发生:
InvariantGlobalization=true是 .NET Core 为精简容器镜像引入的"省 ICU"开关 · 会禁用所有 Unicode 规范化 / 大小写折叠 / culture-aware 排序 · System.Text.Json 反序列化字符串时依赖这些能力 · 对非 ASCII 输入直接失败 - 如何避免:
- 规则:对外提供 HTTP API 的服务(尤其有中文客户)· 绝对不要设
InvariantGlobalization=true Dockerfile用mcr.microsoft.com/dotnet/aspnet:8.0(已含基础 ICU)· 不要为省 30MB 用runtime-deps+ alpine 裸底- 集成测试里必须有一条"发中文 JSON"的断言 · 否则只做 ASCII 测试会漏掉这个坑到生产
- 规则:对外提供 HTTP API 的服务(尤其有中文客户)· 绝对不要设
- 检测脚本(xUnit 集成测试示例):
[Theory]
[InlineData("测试用户")] // 中文
[InlineData("テストユーザー")] // 日文
[InlineData("테스트 사용자")] // 韩文
[InlineData("🎵 test 🎶")] // Emoji
public async Task PostLead_AcceptsNonAsciiName(string name)
{
var resp = await _client.PostAsJsonAsync("/api/lead", new {
stage = "unit-test", name, email = "a@b.com", consent = true
});
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
}
⑥ EF Core design-time 生态(v1.2 新增)
- 为什么会发生:
dotnet ef migrations add/dotnet ef database update启动时会实例化一次 DbContext 来读取模型元数据 · 此过程是独立子进程 · 不一定继承父 shell 的临时环境变量(尤其 Windows PowerShell 的$env:只在当前会话有效)· 如果 Program.cs 依赖环境变量做 provider 切换 · design-time 大概率走 fallback 分支 · 生成错误 provider 的 migration - 连带伤害:migration 一旦 push 到 Git · 生产
db.Database.Migrate()会"看起来成功"但列类型错误 · 运行时才爆炸(PG 42804 / 类型不匹配)· 且修复需要清 volume 重建 · 数据会丢 - 如何避免:
- 规则:任何多 provider 的 EF Core 项目 必须添加
IDesignTimeDbContextFactory<T>明确 design-time 用哪个 provider · 不依赖环境变量 - 规则:审阅 migration PR 时 · grep 列类型字符串 · 不能出现"不该出现的 provider 关键词"(如 PG 项目不能有
"TEXT"/"INTEGER"· MySQL 项目不能有"timestamp with time zone") - 规则:生产首次部署后立刻
\d <table>核对列类型 · 如果列类型不匹配 Model 预期 · 立刻回滚别让假数据入库
- 规则:任何多 provider 的 EF Core 项目 必须添加
- 检测脚本:
# pre-push hook · 阻止错 provider 的 migration 进仓库
grep -E 'type:\s*"(TEXT|INTEGER|REAL|BLOB)"' src/*/Migrations/*.cs 2>/dev/null \
&& { echo "BLOCKED: SQLite-style types in migration · regenerate with target provider's design-time factory"; exit 1; }
# 运行时 · 列类型与 Model 的一致性验证
docker compose exec postgres psql -U xisound -d xisound -t -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'Leads' ORDER BY ordinal_position;"
⑦ 容器镜像工具链生态(v1.2 新增)
- 为什么会发生:现代"瘦镜像"运动(Debian slim / distroless / alpine)为了减小体积 · 默认剔除了大量常见工具 · 开发者下意识写的
HEALTHCHECK wget ...或RUN curl ...会因为基础命令不存在而失败 · 更隐蔽的是/bin/sh往往是 dash(非 bash)· 很多 bash 扩展(/dev/tcp、[[、<<<)都不工作 - 如何避免:
- 规则:HEALTHCHECK / RUN 用到的任何工具 · 必须先
apt-get install确保存在 · 别靠"镜像应该有" - 规则:CMD-SHELL 写脚本时 · 假设就是 POSIX sh · 不用 bash 扩展;或明确
CMD ["bash", "-c", "..."]并确认镜像里有 bash - 规则:
docker build之后docker run --rm <img> which curl wget bash sh nc ss netstat列一下"这个镜像到底有啥工具"
- 规则:HEALTHCHECK / RUN 用到的任何工具 · 必须先
- 检测脚本:
# 提交 Dockerfile 前 · 验证 HEALTHCHECK 实际可跑
docker build -t xisound-api:hc-test .
docker run -d --name hc-tmp xisound-api:hc-test
sleep 40
docker inspect hc-tmp --format 'Status={{.State.Health.Status}} · FailingStreak={{.State.Health.FailingStreak}}'
# 期望 Status=healthy
docker rm -f hc-tmp
- 镜像工具扫一览(凭经验):
| 镜像 | curl | wget | bash | nc |
|---|---|---|---|---|
mcr.microsoft.com/dotnet/aspnet:8.0 |
❌ | ❌ | ❌ | ❌ |
python:3.12-slim |
❌ | ❌ | ✅ | ❌ |
node:20-alpine |
❌ | ❌ | ❌ | ❌ |
nginx:1.27-alpine |
✅ | ✅ | ❌ | ❌ |
postgres:16-alpine |
❌ | ❌ | ❌ | ❌ |
⑧ Git 两态配置文件生态(v1.2 新增)
- 为什么会发生:某些配置文件天然有"两态"(签发前 HTTP-only / 签发后 HTTPS · dev / prod · single-tenant / multi-tenant)· 如果让同一个文件名在 git 里承担两种状态 · 就会踩"手动切到态 A 之后被 pull/checkout/reset 悄悄切回态 B"的坑
- 如何避免:
- 规则:
.gitignore掉生效的运行态文件 · 仓库只保留*.example样板 - 规则:部署脚本要幂等且自愈 · 每次启动前
cp example → runtime-file· 或更智能地根据当前环境条件决定用哪个样板 - 规则:运维 checklist 里把"切换到正确配置"作为
git pull之后的必做步骤
- 规则:
- 检测脚本:
# 部署后 / git pull 后立刻执行
grep -q 'listen 443 ssl' nginx/nginx.conf || {
echo "[FATAL] nginx.conf is NOT the HTTPS version · falling back to HTTP-only!"
echo "Run: cp nginx/nginx.https.conf nginx/nginx.conf && docker compose exec nginx nginx -s reload"
exit 1
}
7.7.3 预防性检查清单(未来新服务上线前照此检查)
下次任何 xisound 系列服务首次上 VPS 前 · 提前跑一遍:
- Git 卫生:
git check-ignore -v $(find src -type f -name '*.cs')应该无输出 - Compose 变量:每个服务
docker compose config输出里 · 敏感变量值不得是CHANGE_ME*/default*占位 - 脚本卫生:
grep -rn 'source .*\.env' scripts/应该无命中 - 容器内自验:certbot / postgres / redis 的敏感目录用
docker compose run --rm验证过一次 - UTF-8 集成测试:CI 里至少一条用例 POST 含中文的 JSON · 并断言 200
- 镜像基底:
.csproj没有InvariantGlobalization=true· 或明确知道为什么开它 - EF Core 多 provider:每个 DbContext 有对应
IDesignTimeDbContextFactory<T>· migration 列类型无异 provider 关键字(v1.2 新增) - HEALTHCHECK 可执行:
docker run起镜像 40s 后docker inspect --format '{{.State.Health.Status}}'=healthy(v1.2 新增) - 两态配置文件:
grep 'listen 443 ssl' nginx/nginx.conf有命中(HTTPS 版已生效)· 或.gitignore了 runtime 配置、仓库只有*.example(v1.2 新增) - 端到端冒烟:生产部署后立刻跑一条含中文的真实
curl POST· 必须收到非空响应体(curlReceived>0)· 配合psql '\d "TableName"'核对列类型(v1.2 增强)
为什么要记录这 8 坑
本轮 M6.2 阶段 1+2+3 部署从"本地跑通"到"生产 https://api.joysnd.com 端到端冒烟全绿(POST /api/lead 返回 202 · 中文+英文均入库)"实际耗时比预估多 ~1.5 天 · 绝大部分消耗在这 8 坑上。每个坑都能独立让一个有经验的工程师卡住 30 分钟 - 3 小时 · 它们共同的特点是:本地/开发环境不报错 · 生产/运行时才炸 · 且日志不总能直接指向根因。把它们固化成文档 + 检查清单 · 未来新人部署 / 新服务上线可一次性绕开 · 是本手册 v1.2 的核心价值。