跳转至
  • 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 Keyre_SBTYJoo8_p8dvTQ8i8QGSxWDwCagDGb2H(已存在 Git 历史) - 企微群机器人 Webhook URLhttps://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

  1. 登录 https://resend.com/api-keys
  2. 找到名为(或 token 为 re_SBTYJoo8_...)的 Key
  3. 点击右侧 Revoke(吊销)· 弹窗确认
  4. 新建一个 Key(名字比如 xisound-api-prod-2026-05)· 复制 re_... 新值
  5. 妥善保存新 Key 到 1Password / Bitwarden / 本地加密笔记(不要立刻贴进任何 .env 文件,等 §4 步骤再填)

为什么必须吊销

Resend Key 的权限等同于"以你的账户发邮件 + 访问收件人列表"。即使你改了 .env.example,Git 历史里的旧 Key 仍然有效,任何克隆过公网仓库的人都能用它。

0.3 步骤 2 · 重建企微群机器人

  1. 企业微信 App → 进入该群 → 右上 ...群机器人
  2. 点开现有的"留资通知机器人"(Webhook 含 key 24f7563b...
  3. 删除机器人(整个 Webhook 立即失效)
  4. 添加机器人 → 名字可保持一致 → 拿到新 Webhook URL(形如 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<new-uuid>
  5. 同样保存到密码管理器,暂不填入 .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):

# 推荐:pip 安装
py -m pip install --user git-filter-repo

# 验证
py -m git_filter_repo --version

如果你更习惯用可执行文件:从 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

cd xisound-api
git filter-repo --replace-text ..\replacements.txt

可能的提示

如果 git-filter-repoRefusing 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 验证

git log -p | Select-String "re_SBTYJoo8|24f7563b"

应无任何命中。

3.6 删除临时文件

Remove-Item ..\replacements.txt

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 Resendhttps://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 腾讯云控制台 · 首次登录

  1. 登录 https://console.cloud.tencent.com/lighthouse
  2. 找到你的香港轻量应用实例 · 点击实例名进入详情
  3. 右上角 登录 → OrcaTerm(浏览器终端)· 用初始 root + 你在控制台设置的密码登录
  4. 记下公网 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 排查:

cat /home/ubuntu/.ssh/authorized_keys   # 应为一行完整 ed25519 公钥
stat /home/ubuntu/.ssh                  # 权限应为 700
stat /home/ubuntu/.ssh/authorized_keys  # 权限应为 600
tail -f /var/log/auth.log               # 另开终端看 SSH 服务报错
密钥登录成功后再执行 §1.8 的"禁用密码登录"。

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 服务强化

sudo vim /etc/ssh/sshd_config

确保以下 5 行的值正确(没有就加,注释的就解注释):

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
ChallengeResponseAuthentication no
sudo systemctl restart sshd

验证 SSH 仍可登录

不要关闭当前 OrcaTerm 会话!开一个新的 PowerShell 窗口执行 ssh xisound-api。如果新会话能进,说明配置正确;如果进不了,立即用旧 OrcaTerm 回滚 /etc/ssh/sshd_configsystemctl 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):

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo vim /etc/fail2ban/jail.local

找到 [sshd] 段,确保:

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd    # 查看是否启动

1.8.5 unattended-upgrades(自动安全更新):

sudo dpkg-reconfigure -plow unattended-upgrades
# prompt 选 <Yes>

验证配置文件 /etc/apt/apt.conf.d/50unattended-upgrades 中至少启用 security 源:

Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-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__DefaultPassword= 同上(和 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 ps 5 个容器状态:
    • xisound-api · Up (healthy)
    • xisound-postgres · Up (healthy)
    • xisound-redis · Up (healthy)
    • xisound-nginx · Up
    • xisound-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 子域

  1. 登录 https://dash.cloudflare.comjoysnd.comDNS · Records
  2. 点击 Add record
    • TypeA
    • Nameapi(最终解析为 api.joysnd.com
    • IPv4 address<VPS_IP>(你的腾讯云香港实例公网 IP)
    • Proxy status🟠 Proxied(先按这个设置,但我们 §3.3 会临时灰化再改回来)
    • TTL:Auto
  3. Save
  4. 本地 PowerShell 验证:
    nslookup api.joysnd.com
    # Proxy 橙化时会返回 CF 的 IP(104.x.x.x / 172.x.x.x)
    # Proxy 灰化时会返回你的真实 VPS_IP
    

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/ 路径。签发成功后再改回橙化。

操作

  1. CF Dashboard → DNS → api 记录的 Proxy 状态 图标 → 点击 🟠 → 切换为 ☁️ 灰色云(DNS only
  2. 等 30-60 秒(CF 全球传播)
  3. 再次 nslookup api.joysnd.com · 应返回你的真实 <VPS_IP>

3.4 确认腾讯云控制台防火墙(云侧)

这一步是"certbot 超时"的头号根因

UFW 放通了只管主机内,腾讯云控制台的实例"防火墙"是独立的云侧安全组,很多人忘记配。

  1. 腾讯云 → 轻量应用 → 你的实例 → 防火墙(标签页)
  2. 确认 3 条"允许"规则:
    • TCP : 22(来源 0.0.0.0/0
    • TCP : 80(来源 0.0.0.0/0
    • TCP : 443(来源 0.0.0.0/0
  3. 若缺少任何一条,添加规则 → 应用类型选"HTTP(80)"或"HTTPS(443)"或"自定义"

3.5 首次签发 HTTPS 证书

在 VPS 上:

cd /opt/xisound-api

# 确认脚本可执行
chmod +x scripts/certbot-init.sh

# 运行首次签发
./scripts/certbot-init.sh

期望输出片段

[1/4] 检查 nginx 容器状态...
[2/4] 演练签发(--dry-run · 不消耗正式配额)...
Successfully received certificate.
✅ 演练通过 · 准备正式签发
[3/4] 正式签发...
Successfully received certificate.
[4/4] 验证证书文件...
✅ 证书签发成功!

如果失败,直接看 §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

  1. CF → DNS → api 记录 Proxy → 改回 🟠 Proxied
  2. 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:

chmod +x /opt/xisound-api/scripts/certbot-renew.sh
crontab -e

加入两行(每天凌晨 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-websitePUBLIC_API_BASE_URL 指向 https://api.joysnd.com

  1. 登录 https://dash.cloudflare.comWorkers & Pagesxisound-website 项目
  2. SettingsEnvironment variablesProduction 环境
  3. 找到 PUBLIC_API_BASE_URL · 点击 Edit · 改为 https://api.joysnd.com · Save
  4. Deployments → 找到最新部署 → Retry deployment(使新环境变量生效)
  5. 等 2-3 分钟新部署完成
  6. 浏览器强刷 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-website Production 环境变量 PUBLIC_API_BASE_URL = https://api.joysnd.com
  • 浏览器刷新 www.joysnd.com 后 DevTools Network 看到对 api.joysnd.com 的请求

4. 生产端到端验证

4.1 留资全链路测试

场景:浏览器访客 → www.joysnd.com → 填留资表单 → 提交 → 后端入库 + 企微推送 + 邮件通知三项全中。

步骤

  1. 浏览器打开 https://www.joysnd.com/resources/whitepapers/(或任一有留资表单的页面)
  2. 填写姓名 / 邮箱 / 电话 / 公司 / 留言 · 勾选同意
  3. 点击 提交
  4. 观察:
    • 前端:提示"已收到 · 3 个工作日内联系"
    • 浏览器 DevTools → NetworkPOST 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)

排查

docker compose -f docker-compose.prod.yml logs api

常见原因:

日志片段 原因 解决
Npgsql.NpgsqlException: ... Name or service not known 连接串 Host 不对 确认 ConnectionStrings__Default 的 Host= 是 postgres(服务名)
fail to connect ... password authentication failed 密码不匹配 .env.productionPOSTGRES_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.shChallenge failed for domain api.joysnd.comTimeout during connect

排查顺序

  1. DNS 是否生效:本地 nslookup api.joysnd.com 应返回 <VPS_IP>(Proxy 必须灰化)
  2. CF Proxy 是否灰化:DNS record 应是 ☁️ 灰色云,不是 🟠
  3. 云侧防火墙:腾讯云实例 → 防火墙 → TCP 80 是否放通
  4. UFWsudo ufw status 是否含 80/tcp ALLOW
  5. nginx 是否在监听 80docker compose ps nginx 显示 Up
  6. 外部能否访问 80:本地 curl -I http://<VPS_IP>/.well-known/acme-challenge/test 期望 404(说明路径已放行)
  7. 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

  1. 检查 nginx map:grep -A 5 "map \$http_origin" nginx/nginx.conf
  2. 检查 .env.productionCors__AllowedOrigins 是否含该域
  3. 重启 api:docker compose restart api

5.5 企微机器人不推送 · 邮件不发

症状:留资入库成功(/admin/leads 能看到),但企微/邮件无动静。

排查

docker compose logs api | grep -i "wework\|resend\|email"

常见日志:

  • Wework webhook url empty · skip.env.productionWework__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

症状:正常访客被限流。

排查

docker compose exec nginx tail -20 /var/log/nginx/error.log
# 看被限制的 IP

如果 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 .gitignoredata/ 误匹配源码 Data/ 目录(Windows 大小写不敏感陷阱)

症状:本地 dotnet build 通过,但 docker compose build apiCS0246: The type or namespace name 'AppDbContext' could not be found。用 git ls-files src/XiSound.Api/Data/ 发现 AppDbContext.cs 没进 Git(被忽略了)。

根因.gitignore 原来写的是:

# 忽略运行时数据
data/
  • 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
  • 本地自查(发现可疑忽略时):
git check-ignore -v src/XiSound.Api/Data/AppDbContext.cs
# 如果输出任何规则命中 · 说明这个文件被忽略了 · 立刻改 .gitignore

如果你遇到

  1. docker compose build 报源码类型找不到 → git ls-files | grep -i <ClassName> 看文件在不在 Git
  2. 不在 → git check-ignore -v <path> 查是哪条规则命中
  3. .gitignore + git add -f <path> 强制补进来

5.12 Docker Compose ${VAR:-default} 不读 env_file 导致 postgres 密码不一致

症状:postgres 容器启动看似正常,但 api 容器循环崩溃。docker compose logs api 出现:

Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "xisound"

明明 .env.productionPOSTGRES_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.productionsyntax error near unexpected token 'newline'

症状:在 VPS 上跑 ./scripts/certbot-init.sh 脚本签发证书时,一开始就报:

./scripts/certbot-init.sh: line 45: syntax error near unexpected token 'newline'

脚本里那一行写的是:

set -a
source .env.production     # ← 报错在这里
set +a

根因.env.production 有一条值:

Resend__FromAddress=羲音官网 <noreply@send.joysnd.com>
  • bash 的 source(即 .)命令按严格 shell 语法解析整个文件 · 把 <noreply@send.joysnd.com> 解读为输入重定向 · 而 > 紧跟的是空格 + 换行 · 触发语法错误
  • Docker Compose 的 env_file:宽松 KEY=VALUE 解析 · 按字面读整行 · 所以 api 容器内 Resend__FromAddress 的值完全正确
  • source 只是脚本取 CERTBOT_EMAILPRIMARY_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>&#96;</code> 在 dotenv 里合法 · 但在 shell 里是元字符 · source 必炸
  • CI 检查:脚本提交时跑 shellcheck scripts/*.sh · source .env* 模式可列入 ban list
  • 本地测试
# 快速验证 dotenv 是否 source-safe(大概率不是)· 只用于调试
bash -n <(cat .env.production | sed 's/^/export /')

如果你遇到: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 步"验证证书文件存在"却报错退出:

[FATAL] 证书文件缺失:/opt/xisound-api/certbot/conf/live/api.joysnd.com/fullchain.pem

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 里有:

<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
  • 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" → 第一反应查 .csprojInvariantGlobalization · 删之 · 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 里 Consentbool · 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):

OCI runtime exec failed: exec: "wget": executable file not found in $PATH

第二次尝试(改用 bash /dev/tcp):

/bin/sh: 1: cannot create /dev/tcp/localhost/8080: Directory nonexistent

根因分层

尝试 方案 失败原因
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/portbash 独有特性 · Debian /bin/shdash(不是 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.confgit 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 · 注册账号

  1. 打开 https://uptimerobot.com · 点右上 Register for FREE
  2. zhangzm@joysnd.com(已验证能收邮件)注册 · 收验证邮件后点激活链接
  3. 免费套餐: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 SettingsAlert ContactsAdd 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 ProductionEditAlert Contacts To Notify → 勾选 Ops PrimarySave 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
  • Keywordalive
  • Keyword Exists / Not ExistsKeyword Exists
  • 行为:如果 /health 返回 200 但响应体不含 alive 字符串(比如 api 被劫持返回假页面)· 立即告警

HTTPS 证书到期告警

  • 免费档无此功能 · 但本项目 certbot 自动续签已覆盖这个风险
  • 备选:另加一个 Monitor 专门探 https://api.joysnd.com/(首页)· 配 SSL expiry 告警(付费档功能)

多通道告警(免费档支持最多 3 个 Alert Contact):

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;
});
<!-- XiSound.Api.csproj -->
<PackageReference Include="Sentry.AspNetCore" Version="4.12.0" />
# .env.production 追加
Sentry__Dsn=https://<key>@<org-id>.ingest.sentry.io/<project-id>

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 源码链接

关键设计点

  1. Triggerpush: branches: [main] + workflow_dispatch(支持手动重跑)
  2. Permissionspackages: write(推 GHCR)· contents: read(读代码)
  3. Image tag 策略:双 tag · :sha-<commit-hash-7>(永久追溯)+ :latest(生产用)
  4. SSH 部署appleboy/ssh-action@v1.0.3(业界标准 · 官方维护)
  5. 健康检查for i in {1..3}; do curl -f ...; sleep 5; done · 任何一次失败则回滚
  6. 回滚机制:失败时用 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)

  • 首次 pushcommit 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 apiunauthorized 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 新增:

REDIS_PASSWORD=<openssl rand -base64 32>

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 交叉引用

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 .gitignoredata/ 在大小写不敏感文件系统上误匹配源码 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 上可能看不到这类问题
  • 如何避免
    1. .gitignore 里凡纯小写短词必须前导斜杠锚定(/data/)或写精确子路径(/src/.../App_Data/
    2. pre-commit hook 跑 git check-ignore -v $(git ls-tree -r HEAD --name-only) 扫描被意外忽略的文件
    3. 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:容器启动后注入到容器进程的 · 这两者是不同阶段
  • 如何避免
    1. 规则:镜像原生消费的变量(POSTGRES_* / MYSQL_* / ASPNETCORE_*)统一走 env_file: · 不要用 ${VAR:-default} 混合
    2. 规则:只有 compose 自己需要的变量(端口、镜像 tag、network 名)才用 ${VAR:-default} · 放顶层 .env 文件
    3. 密码类变量绝不给 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 脚本执行 · 元字符会被解释
  • 如何避免
    1. 永远不要 source .env* · 改用专门工具:docker composeenv_file:dotenv-clidirenv、或 grep | cut
    2. 需要在 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 + 自定义规则 ban source.*\.env

④ Linux 权限 + TLS 生态

  • 为什么会发生:生产级 TLS 证书目录必须是 700(私钥防护)· symlink 在 chmod 700 目录下 · 非 root 用户无法 traverse → 无法 stat → [ -f ] / ls / cat 都失败;这不是 bug · 是设计
  • 如何避免
    1. 规则:任何 Docker volume 映射过来的、容器内 root 产出的敏感目录(证书、postgres PGDATA、redis dump)· 宿主机验证一律用容器内视角
    2. 运维脚本优先用 docker compose run --rm --entrypoint sh <svc> -c "ls ..." · 而非宿主机 ls / test
    3. 遇到"文件明明存在但脚本说没有"时 · 先 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 输入直接失败
  • 如何避免
    1. 规则:对外提供 HTTP API 的服务(尤其有中文客户)· 绝对不要InvariantGlobalization=true
    2. Dockerfilemcr.microsoft.com/dotnet/aspnet:8.0(已含基础 ICU)· 不要为省 30MB 用 runtime-deps + alpine 裸底
    3. 集成测试里必须有一条"发中文 JSON"的断言 · 否则只做 ASCII 测试会漏掉这个坑到生产
  • 检测脚本(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 重建 · 数据会丢
  • 如何避免
    1. 规则:任何多 provider 的 EF Core 项目 必须添加 IDesignTimeDbContextFactory<T> 明确 design-time 用哪个 provider · 不依赖环境变量
    2. 规则:审阅 migration PR 时 · grep 列类型字符串 · 不能出现"不该出现的 provider 关键词"(如 PG 项目不能有 "TEXT"/"INTEGER" · MySQL 项目不能有 "timestamp with time zone"
    3. 规则:生产首次部署后立刻 \d <table> 核对列类型 · 如果列类型不匹配 Model 预期 · 立刻回滚别让假数据入库
  • 检测脚本
# 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[[<<<)都不工作
  • 如何避免
    1. 规则:HEALTHCHECK / RUN 用到的任何工具 · 必须先 apt-get install 确保存在 · 别靠"镜像应该有"
    2. 规则:CMD-SHELL 写脚本时 · 假设就是 POSIX sh · 不用 bash 扩展;或明确 CMD ["bash", "-c", "..."] 并确认镜像里有 bash
    3. 规则docker build 之后 docker run --rm <img> which curl wget bash sh nc ss netstat 列一下"这个镜像到底有啥工具"
  • 检测脚本
# 提交 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"的坑
  • 如何避免
    1. 规则.gitignore 掉生效的运行态文件 · 仓库只保留 *.example 样板
    2. 规则:部署脚本要幂等自愈 · 每次启动前 cp example → runtime-file · 或更智能地根据当前环境条件决定用哪个样板
    3. 规则:运维 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 · 必须收到非空响应体(curl Received>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 的核心价值。


**Xisound · M6.2 Stage 1+2+3 Deployment Manual · v1.2 · 2026-05-08** *非运维背景也能走完 · 从密钥吊销到生产上线闭环 · 含 8 大实战踩坑复盘 · 端到端冒烟全绿* © MMXXVI · Xisound AlgoDepartment