From 53caaf54f0cd5b149e499a641b0a69f4eea25207 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 07:01:13 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20EC2=20=E6=A0=88?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=90=8E=E7=9A=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md 重写:去掉 EC2/marzban 部署、架构、cutover/rollback 命令、自动部署 CD; 改为自有 VPS 自建方向(RackNerd Debian 12、ssh racknerd 免密、512MB→SQLite)、 补 server/ Go 后端与多数据库(DB_DRIVER)说明;保留设计 token 单源段 - 删 docs/回滚手册.md(EC2 edge 切换/回滚,脚本已删) - 删 docs/备份与灾备.md(EC2 MySQL/marzban xtrabackup→S3,backup 栈已删) - app/kernel/poc/README.md:REALITY 参数来源由 EC2 改为自建节点(single-node) Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 129 ++++++-------- app/kernel/poc/README.md | 14 +- docs/回滚手册.md | 123 ------------- docs/备份与灾备.md | 371 --------------------------------------- 4 files changed, 57 insertions(+), 580 deletions(-) delete mode 100644 docs/回滚手册.md delete mode 100644 docs/备份与灾备.md diff --git a/CLAUDE.md b/CLAUDE.md index 2a890c3..334c57a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,87 +4,66 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 这个仓库是什么 -Pangolin 是面向中国大陆用户的终端 VPN(科学上网)项目。本仓库**不是应用代码**,而是: +Pangolin 是面向中国大陆用户的终端 VPN(科学上网)项目,**自研全栈**: -- `deploy/` —— 数据/分发面的容器化部署(Docker + Gitea Actions CI/CD) -- `docs/` —— 设计与运维文档(`技术方案.md`、`回滚手册.md`) -- `plan/` —— 分阶段实施计划(phase-0 → phase-3 + 安全/获客横切) +- `server/` —— Go 控制面 + 节点 agent + sing-box 数据面(HTTP API + gRPC mTLS) +- `client/` —— Flutter 客户端(内嵌 sing-box + TUN 全局代理) +- `web/` —— 用户中心(usercenter)+ 官网(website) +- `deploy/` —— 节点部署:`bootstrap/`(新机初始化)+ `single-node/`(整套自建栈) +- `design/` —— 设计 token 单源 + 规格;`docs/` / `plan/` —— 文档与计划 -客户端(Flutter)与后端面板尚未进入本仓库;当前实质内容是 `deploy/`。 +> 历史:早期借 EC2 上的 Marzban 临时顶现网。**本仓库已不再管理那套 EC2/marzban 部署** +> (相关 `deploy/edge|singbox|scripts|docker-compose.yml` 与 Gitea 自动部署 CD 已删除), +> 全面转向在自有 VPS 上跑自研栈。 -## 部署目标与不可破坏的约束(最重要) +## 部署目标:自有 VPS 节点 -部署目标是**一台已在生产运行的 EC2**(`18.136.60.128`,ap-southeast-1,user `ec2-user`),上面还跑着**与本项目无关、不能搞坏**的服务:`marzban`(线上 REALITY 代理 + 面板)、`jiu`(jiu_mysql + jiu.service :8080 + 静态)、`blog`(blog/umami/postgres)、`billing.service` :18080。 +当前节点:**RackNerd VPS `107.172.55.251`**(Debian 12 bookworm,root,1 核 / 512MB / 2G swap)。 -- **线上代理 = Marzban 的 REALITY(443 → 127.0.0.1:10443),绝不能中断。** 运维者本人通过 Shadowrocket 连的就是它。 -- 切换/重建 `pangolin-edge`(占用 443)会有 **~1-2 秒** TCP 交接,代理客户端会自动重连——选空闲时段做。 -- **注意自噬隧道**:运维者本机常挂 VPN,且该 VPN 可能正穿这台的 443。重建 edge 时 443 闪断会**把你自己的 SSH 也掐了**(SSH 经代理隧道)。重建后等几秒重连即可,不是故障。 -- 从运维者 Mac 做"外部"验证不可信(fake-IP DNS / 经 Cloudflare)。**权威验证一律在 EC2 内部做**(`ssh ec2` 后 curl `127.0.0.1` / openssl `127.0.0.1`)。 +- **SSH 免密**:本机 `~/.ssh/config` 有别名 `racknerd`(密钥登录,密码登录已禁)→ 直接 `ssh racknerd`。 +- 已用 `deploy/bootstrap/init.sh` 加固(ufw + fail2ban + BBR + swap + pangolin 用户)。 +- **512MB 内存是硬约束**:控制面用 **SQLite**(见下),不要在这上面跑 MySQL 8。 +- 改任何机器系统(装包/改配置/重装)前**先问用户**(只读操作除外)。 -## 目标主机的关键事实(代码里看不出来) - -- 部署目录:**`~/pangolin`(即 `/home/ec2-user/pangolin`)**,不是 `/opt`。 -- `ec2-user` **免 sudo 跑 docker**;但**通用 `sudo` 需要密码**(Claude 跑不了)。需要 root 的步骤(如 `systemctl stop nginx`)必须让用户执行;`crontab` 在用户设置里被 deny。 -- Compose 是**独立 `docker-compose`**(无 `docker compose` v2 插件),且**不支持 `compose build`**。因此: - - 命令一律用 `docker-compose -p pangolin ...` - - edge 镜像用 **`docker build -t pangolin-edge:local ./edge`** 单独构建,compose 只 `up`(不要 `--build`)。 -- `edge`、`singbox` 用 **`network_mode: host`**(才能绑 80/443/udp443 并回连一堆只听 `127.0.0.1` 的后端)。 -- 证书在 `/etc/letsencrypt`(Let's Encrypt:blog/jiu/vpn.51yanmei.com),**rw 挂进 edge**。 - -## 架构(部署后) - -``` -EC2 :443/tcp + :80 → pangolin-edge (自定义镜像: nginx + certbot) - stream ssl_preread 按 SNI 分流(edge/stream.conf): - www.cloudflare.com/default → 10443 Marzban REALITY(线上,勿断) - vpn/blog/jiu.51yanmei.com → 8443/8444/8445(edge 内 http vhost) - http vhost(edge/conf.d/*)→ marzban面板8899 / blog3000+umami3001 / jiu8080 + 静态 -EC2 :443/udp → pangolin-singbox (Hysteria2) -``` - -- `pangolin-edge`:分发层。**自定义镜像自带 certbot,容器内每 12h 自动 DNS-01 续期 + 本容器内 `nginx -s reload`**(见 `deploy/edge/pangolin-entrypoint.sh`)——无宿主 cron、无 docker socket。 -- `pangolin-singbox`:Hysteria2 加速线(UDP 443)。 -- `pangolin-xray`:REALITY,**本台不启用**(compose `profile: newnode`,留给新节点;本台 REALITY 仍由 Marzban 提供)。 -- `certbot` 服务:仅 `profile: certbot`,用于**手动**首次签发/重配;日常续期由 edge 自身完成。 - -`deploy/edge/conf.d/*.conf` 与 `stream.conf` 是**现网宿主 nginx 配置的逐字拷贝**——改动需与现网后端地址保持一致。镜像版本固定(nginx 1.27 / sing-box v1.13.12 / xray 26.3.27)。 - -## 常用命令(在 `deploy/` 下) +## deploy/ 结构 ```bash -# 幂等部署(免 root):起 singbox;宿主 nginx 已停则构建+起 edge,否则只校验 edge 配置 -./scripts/deploy.sh +# ① 新机初始化(幂等,root 跑一次;后续每台新机复用) +# 远程:scp -r deploy/bootstrap racknerd:/root/ && ssh racknerd 'cd /root/bootstrap && bash init.sh' +deploy/bootstrap/init.sh # 加固/swap/BBR/pangolin用户/ufw/fail2ban/Telegram监控 +deploy/bootstrap/monitor/ # pangolin-monitor(节点自报)+ deadman-watch(常在线主机探活) -# 一次性切换:宿主 nginx → edge 容器(需 root,用户执行;失败自动回滚) -sudo bash scripts/cutover.sh - -# 回滚:停 edge,恢复宿主 nginx(需 root,用户执行) -sudo bash scripts/rollback.sh - -# 仅构建 edge 镜像 / 重建 edge(会有 ~1-2s 443 闪断) -docker build -t pangolin-edge:local ./edge -docker-compose -p pangolin up -d edge - -# 离线校验 edge 配置(不绑端口,挂真实证书) -docker run --rm -v "$PWD/edge/nginx.conf:/etc/nginx/nginx.conf:ro" \ - -v "$PWD/edge/stream.conf:/etc/nginx/stream.conf:ro" -v "$PWD/edge/conf.d:/etc/nginx/conf.d:ro" \ - -v /etc/letsencrypt:/etc/letsencrypt:ro -v /opt/jiu:/opt/jiu:ro -v /var/www/pay:/var/www/pay:ro \ - nginx:1.27-alpine nginx -t - -# 验证证书自续期(staging,不动正式证书) -docker exec pangolin-edge certbot renew --dry-run - -# 取客户端导入串(Hy2,含口令,不进日志) -cat secrets/clients.txt - -# 脚本改动后:本地 shellcheck -S warning deploy/scripts/*.sh +# ② 整套自建栈(一台 VPS 跑 MySQL/Redis + 控制面 + agent + sing-box,端到端真连) +sudo VPS_IP=<公网IP> bash deploy/single-node/deploy.sh ``` +`deploy/bootstrap/bootstrap.env`、`/etc/pangolin-monitor.env`、`deploy/single-node` 的 +`/etc/pangolin*` 密钥均 **不入 git**。 + +## server/ 后端(Go) + +- 二进制:`cmd/{server,agent,nodectl,migrate}`;`go build ./...` 直接编译,免确认。 +- 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染 + sing-box 配置 → `systemctl restart sing-box`。客户端连节点真实出网。 + +### 数据层:多数据库(一个环境变量切换) + +server 已与具体 DB 解绑(裸 SQL + 薄方言层,`internal/db/dialect.go`): + +- `DB_DRIVER=mysql`(默认)| `sqlite`;`DB_DSN`(mysql DSN / sqlite 文件路径或 `:memory:`)。 +- 迁移分 `server/migrations/{mysql,sqlite}/` 两套,`migrate` 按驱动选源(`golang-migrate`)。 +- SQLite 用 `modernc.org/sqlite`(纯 Go 免 CGO)+ `_txlock=immediate`(等价 MySQL 行锁的悲观语义)。 +- 测试:`go test ./...`(含 SQLite 实库测试,免 docker)+ `./server/run_sqlite_test.sh`; + MySQL 集成测试 `./server/run_mysql_test.sh`(需 docker)。 +- **改 schema/查询**:upsert 用 `dialect.Upsert(...)`(中性 `EXCLUDED.col` 记法),行锁用 + `dialect.LockForUpdate()`;时间等一律 Go 端算好传 `?`,**不要**用 `UTC_TIMESTAMP()`/ + `NOW()`/`FIELD()` 等 MySQL 专属构造(已全部清除,加回会破坏可移植性)。 + ## CI/CD -`push deploy/**` → NAS 上的 Gitea(`ssh://git@192.168.3.200:2222/wangjia/pangolin.git`)→ Gitea Actions runner → `ssh ec2` → `rsync deploy/ → ~/pangolin/` → `deploy.sh`(免 root,幂等)。 -- 需在 Gitea 配 Secrets:`EC2_SSH_KEY` / `EC2_HOST` / `EC2_USER` / `CF_API_TOKEN`,并把 `.gitea/workflows/deploy.yml` 的 `runs-on` 改成实际 runner label。 -- 手动等价流程:`rsync -az --exclude secrets deploy/ ec2:pangolin/ && ssh ec2 'cd ~/pangolin && ./scripts/deploy.sh'`。 +`.gitea/workflows/ci.yml`(runner label `nas`):shellcheck(bootstrap/single-node 脚本)、 +OpenAPI 结构校验、UI 文案脱敏扫描、Flutter analyze+test。**仅校验,无部署动作。** +- 节点部署是手动/按需的(scp+ssh 跑 bootstrap / single-node),不走 CI 自动推送。 ## 设计 Token 单源模型 @@ -94,18 +73,12 @@ cat secrets/clients.txt # Flutter token(生成 client/lib/pangolin_tokens.gen.dart) node design/codegen/gen_flutter_tokens.mjs -# usercenter CSS token(由 prebuild/predev 钩子自动触发,也可手动) +# usercenter / website CSS token(由 prebuild/predev 钩子自动触发,也可手动) cd web/usercenter && npm run gen:tokens - -# website CSS token(由 prebuild/predev 钩子自动触发,也可手动) -cd web/website && npm run gen:tokens +cd web/website && npm run gen:tokens ``` - `client/lib/pangolin_tokens.gen.dart` — **勿手改**,由生成器覆盖。 -- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme` 等),不含 token 数值。 -- `design/flutter/` 目录已删除;Flutter 组件的 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。 +- `client/lib/pangolin_theme.dart` — 只含实现层(`PangolinScheme`/`PangolinText`/`PangolinTheme`),不含 token 数值。 +- `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。 - **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。 - -## 密钥与渲染产物 - -`deploy/secrets/`、`*/config.json`、`cloudflare.ini` 均 **gitignore**。`gen-secrets.sh` 在 EC2 首跑生成 Hy2 口令/自签证书;`CF_API_TOKEN`(本机 `~/.env` 已加载,CI 由 Secret 注入)渲染 `cloudflare.ini`。模板用 `__PLACEHOLDER__`,脚本 `sed` 渲染。 diff --git a/app/kernel/poc/README.md b/app/kernel/poc/README.md index 70a817e..6f03281 100644 --- a/app/kernel/poc/README.md +++ b/app/kernel/poc/README.md @@ -37,21 +37,19 @@ sudo chmod u+s "${SINGBOX_PATH}" ### 3. 获取 REALITY 服务端参数 -从部署节点的 EC2 拿 REALITY 公钥和 UUID: +从自建节点(`deploy/single-node`)拿 REALITY 公钥/short_id 和节点 UUID: ```bash -# EC2 上 Xray REALITY 密钥对(deploy/xray/secrets/ 或 Bitwarden) -ssh ec2 "cat ~/pangolin/xray/secrets/reality_keys.json 2>/dev/null || echo '不存在,需手动生成'" - -# 若未生成,在 EC2 上运行: -# docker run --rm ghcr.io/xtls/xray-core x25519 | tee /tmp/reality_keys.txt +# 节点上由 single-node/deploy.sh 生成的 REALITY 参数: +ssh "grep -E 'REALITY_PBK|REALITY_SHORT_ID' /etc/pangolin/reality.env" +# 节点 UUID:见 single-node 的 seed,或控制面 nodes 表 ``` ### 4. 渲染 PoC 配置 ```bash -export SERVER_HOST="18.136.60.128" # EC2 公网 IP -export SERVER_PORT="11443" # Xray VLESS 端口 +export SERVER_HOST="<节点公网IP>" # 自建节点公网 IP +export SERVER_PORT="11443" # REALITY 端口 export REALITY_UUID="your-uuid-here" export REALITY_PUBLIC_KEY="your-x25519-public-key" export REALITY_SHORT_ID="your-short-id" diff --git a/docs/回滚手册.md b/docs/回滚手册.md deleted file mode 100644 index 161d6bf..0000000 --- a/docs/回滚手册.md +++ /dev/null @@ -1,123 +0,0 @@ -# Pangolin 切换回滚手册(nginx 宿主 → 容器) - -> 适用对象:把宿主 nginx 切换到 `pangolin-edge` 容器(`cutover.sh`)的那一步。 -> 核心原理:**宿主 nginx 配置全程没动**(切换只是 `systemctl stop/disable nginx`), -> 所以回滚 = 停掉 edge 容器 + 重新启动宿主 nginx,即可秒级回到原状态。 - ---- - -## 0. 一句话回滚(绝大多数情况用这个) - -在你的 Mac 上执行(需输 sudo 密码): - -```bash -! ssh -t ec2 'sudo bash /home/ec2-user/pangolin/scripts/rollback.sh' -``` - -脚本会:① 停 `pangolin-edge` 释放 80/443 → ② `systemctl start nginx` → ③ 自检 REALITY + blog/jiu/vpn。 -看到 `回滚完成 ✅` 即恢复原状。 - -> `cutover.sh` 在切换失败时**会自动回滚**;本手册用于"自动回滚没生效"或"切换后才发现问题"。 - ---- - -## 1. 什么时候要回滚 - -- `cutover.sh` 报错且没自动恢复(罕见)。 -- 切换"成功"了,但发现某站点异常、代理不稳、证书报错等。 -- 任何你想退回宿主 nginx 的情况。 - ---- - -## 2. 手动分步回滚(脚本不可用时) - -逐条在 EC2 上执行(`ssh ec2` 后): - -```bash -# (1) 先停 edge 容器 —— 必须先停,否则它占着 443/80,宿主 nginx 起不来 -docker-compose -p pangolin stop edge # 或: docker stop pangolin-edge - -# (2) 恢复宿主 nginx(切换时被 disable 过,这里重新 enable+start,需 root) -sudo systemctl enable nginx -sudo systemctl start nginx -systemctl is-active nginx # 应为 active - -# (3) 验证 -echo | openssl s_client -connect 127.0.0.1:443 -servername www.cloudflare.com 2>/dev/null | head -1 -for d in blog jiu vpn; do - curl -ks -o /dev/null -w "$d -> %{http_code}\n" --resolve $d.51yanmei.com:443:127.0.0.1 https://$d.51yanmei.com/ -done -``` - -> `pangolin-singbox`(Hy2,UDP 443)与宿主 nginx(TCP)不冲突,**回滚时无需停它**,Hy2 线路可继续用。 - ---- - -## 3. 故障排查 - -### 3.1 宿主 nginx 起不来:`bind() to 0.0.0.0:443 ... Address already in use` -edge 容器没真正停掉,还占着端口: -```bash -docker ps | grep pangolin-edge # 看是否还在 -docker stop pangolin-edge -sudo ss -tlnp | grep -E ':443|:80' # 确认端口已释放(无 docker-proxy/nginx 占用) -sudo systemctl start nginx -``` - -### 3.2 宿主 nginx 配置报错(理论上不会,因为没动过) -```bash -sudo nginx -t # 看具体报错行 -sudo journalctl -u nginx -n 50 --no-pager -``` -若 `/etc/nginx/stream.conf` 或 `conf.d/*` 被误改,从备份恢复(切换流程未改这些文件,通常无需)。 - -### 3.3 edge 容器一直重启/起不来(切换阶段) -```bash -docker logs pangolin-edge --tail 50 # 看 nginx 报错 -docker run --rm \ - -v ~/pangolin/edge/nginx.conf:/etc/nginx/nginx.conf:ro \ - -v ~/pangolin/edge/stream.conf:/etc/nginx/stream.conf:ro \ - -v ~/pangolin/edge/conf.d:/etc/nginx/conf.d:ro \ - -v /etc/letsencrypt:/etc/letsencrypt:ro \ - -v /opt/jiu:/opt/jiu:ro -v /var/www/pay:/var/www/pay:ro \ - nginx:1.27-alpine nginx -t # 离线校验,定位问题 -``` -定位后直接回滚(第 0/2 节),不阻塞线上。 - ---- - -## 4. 验证回滚成功(从你的 Mac,外部视角) - -```bash -for d in blog jiu vpn; do - curl -s -o /dev/null -w "$d -> %{http_code}\n" --max-time 10 https://$d.51yanmei.com/ -done -# 应都为 200;Shadowrocket 代理应自动重连可用 -``` - -回滚后端口归位:`443/80` 由宿主 `nginx` 进程监听(非 docker-proxy)。 - ---- - -## 5. 彻底撤销 pangolin(如决定放弃本次方案) - -```bash -# 停并删除 pangolin 容器(edge / singbox) -docker-compose -p pangolin down # 在 ~/pangolin 下执行 -# 关闭 Hy2 的安全组入站(可选,本机 aws 凭据执行) -# aws ec2 revoke-security-group-ingress --region ap-southeast-1 \ -# --group-id sg-040cf4cd11bdf4d5b --protocol udp --port 443 --cidr 0.0.0.0/0 -# 宿主 nginx 已在第 2 节恢复;~/pangolin 目录可保留或删除 -``` - ---- - -## 6. 关键事实(为什么回滚是安全的) - -- 切换**只**对宿主做了 `systemctl stop/disable nginx`,**没改任何 nginx 配置文件**。 -- `/etc/letsencrypt` 证书只读挂载,从未被写。 -- edge 容器是独立 project(`-p pangolin`),停它不影响 marzban/jiu/billing/blog。 -- 因此恢复宿主 nginx 即 100% 回到切换前状态;唯一"残留"是 Hy2 容器(可留可停)。 - -> 证书续期提醒:回到宿主 nginx 后,原有的 `certbot --nginx` 续期方式继续可用(无需改动)。 -> 只有在**保持容器化**的情况下,才需要按 `deploy/README.md` 切换到 DNS-01 续期。 diff --git a/docs/备份与灾备.md b/docs/备份与灾备.md deleted file mode 100644 index f469364..0000000 --- a/docs/备份与灾备.md +++ /dev/null @@ -1,371 +0,0 @@ -# Pangolin 备份与灾备手册 - -> 目标:**RPO ≤ 15min,RTO ≤ 4h** -> -> 策略:xtrabackup 每日全量 + binlog 每 15min 增量,age 加密,异地 S3(独立身份账号)存储;月度恢复演练自动化。 - ---- - -## 架构一览 - -``` -EC2 (生产) - ├── MySQL (jiu_mysql, marzban) - │ ├── 每日 02:00 xtrabackup 全量 ─────────────────────┐ - │ └── 每 15min xtrabackup 增量 + binlog flush ──────┤ - ├── PostgreSQL (blog, umami) │ - │ ├── 每日 02:00 pg_dump 全量 ────────────────────── │ - │ └── 每 15min pg_dump 快照 ────────────────────── age 加密 - └── Marzban SQLite │ - └── 每日 02:00 sqlite3 .backup ─────────────────────┘ - ↓ - S3(独立 IAM,仅 PutObject) - s3://pangolin-backup-XXXXXX/ - ├── mysql/full/ - ├── mysql/inc/ - ├── mysql/binlog/ - ├── postgres/full/ - ├── postgres/inc/ - ├── marzban/ - └── dr-reports/ - -每月 1 日 03:00 DR 演练:下载 → 解密 → 验证 → 报告 -``` - ---- - -## 初始化步骤(首次部署) - -### 1. 创建备份 S3 Bucket(独立 IAM 账号) - -> 使用**与生产 AWS 账号隔离**的独立 IAM 用户,做到数据面与备份面隔离。 - -```bash -# 在 AWS Console 或 CLI 执行(使用管理员账号): -aws s3api create-bucket \ - --bucket pangolin-backup-$(openssl rand -hex 4) \ - --region ap-southeast-1 \ - --create-bucket-configuration LocationConstraint=ap-southeast-1 - -# 开启版本控制(防误删) -aws s3api put-bucket-versioning \ - --bucket YOUR_BUCKET_NAME \ - --versioning-configuration Status=Enabled - -# 配置生命周期:标准 IA 30 天后 Glacier,365 天后删除 -cat > /tmp/lifecycle.json << 'EOF' -{ - "Rules": [{ - "ID": "backup-lifecycle", - "Status": "Enabled", - "Filter": {}, - "Transitions": [ - {"Days": 30, "StorageClass": "STANDARD_IA"}, - {"Days": 90, "StorageClass": "GLACIER"} - ], - "Expiration": {"Days": 365} - }] -} -EOF -aws s3api put-bucket-lifecycle-configuration \ - --bucket YOUR_BUCKET_NAME \ - --lifecycle-configuration file:///tmp/lifecycle.json -``` - -**创建只写 IAM 用户(备份服务器使用):** - -```json -// backup-only-policy.json(只允许 PutObject,不允许读取或删除) -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:PutObjectAcl", - "s3:GetBucketLocation", - "s3:ListBucket" - ], - "Resource": [ - "arn:aws:s3:::YOUR_BUCKET_NAME", - "arn:aws:s3:::YOUR_BUCKET_NAME/*" - ] - }] -} -``` - -```bash -aws iam create-user --user-name pangolin-backup-writer -aws iam put-user-policy --user-name pangolin-backup-writer \ - --policy-name backup-only --policy-document file://backup-only-policy.json -aws iam create-access-key --user-name pangolin-backup-writer -# 记录 AccessKeyId 和 SecretAccessKey -``` - -**创建只读 IAM 用户(DR 演练时恢复用):** - -```json -// restore-policy.json -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": ["s3:GetObject", "s3:ListBucket"], - "Resource": [ - "arn:aws:s3:::YOUR_BUCKET_NAME", - "arn:aws:s3:::YOUR_BUCKET_NAME/*" - ] - }] -} -``` - -### 2. 生成 age 加密密钥对 - -```bash -# 在备份容器内(或本机有 age 的地方)生成: -docker run --rm pangolin-backup:local age-keygen 2>&1 | tee age-keypair.txt - -# 输出示例: -# Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# AGE-SECRET-KEY-1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX - -# ⚠️ 重要安全操作: -# 1. 将私钥(AGE-SECRET-KEY-1...)存入密码管理器(如 Bitwarden) -# 2. 打印并存入保险箱(物理备份) -# 3. 删除本地私钥文件,服务器上只保留公钥 -rm age-keypair.txt # 删除含私钥的文件 -``` - -或使用 `gen-secrets.sh`(需本机已安装 age-keygen): - -```bash -cd deploy/ -./scripts/gen-secrets.sh # 自动生成,见输出指示处理私钥 -``` - -### 3. 配置备份环境变量 - -```bash -cd deploy/ -cp backup/backup.env.example secrets/backup.env -vim secrets/backup.env # 填写以下关键项: -# - BACKUP_S3_BUCKET、BACKUP_AWS_ACCESS_KEY_ID/SECRET -# - AGE_PUBLIC_KEY(从步骤 2 获取) -# - MYSQL_USER/PASSWORD、PG_USER/PASSWORD -# - PG_DATABASES(如 blog,umami) -``` - -### 4. 准备 MySQL 备份账号 - -在 MySQL 中执行(通过 mysql 客户端连接 jiu_mysql): - -```sql -CREATE USER 'backup_user'@'%' IDENTIFIED BY 'STRONG_PASSWORD'; -GRANT RELOAD, LOCK TABLES, REPLICATION CLIENT, PROCESS, SUPER, - CREATE, INSERT, SELECT, BACKUP_ADMIN ON *.* TO 'backup_user'@'%'; -FLUSH PRIVILEGES; -``` - -确认 binlog 已开启: - -```sql -SHOW VARIABLES LIKE 'log_bin'; -- 应为 ON -SHOW BINARY LOGS; -- 列出 binlog 文件 -``` - -若未开启,在 MySQL 配置中添加(需重启 MySQL): - -```ini -[mysqld] -log_bin = mysql-bin -binlog_format = ROW -expire_logs_days = 3 -``` - -### 5. 构建并启动备份容器 - -```bash -cd deploy/ -# 构建镜像(宿主 docker-compose 不支持 compose build) -docker build -t pangolin-backup:local ./backup - -# 如果 MySQL 在 Docker 卷内,先确认卷路径: -docker inspect | grep -A5 Mounts - -# 启动(修改 .env 添加必要的主机路径变量) -MYSQL_DATADIR_HOST=/var/lib/docker/volumes/jiu_jiu_mysql_data/_data \ -MARZBAN_DATA_HOST=/opt/marzban \ -docker-compose -p pangolin up -d backup - -# 查看日志 -docker logs -f pangolin-backup -``` - ---- - -## 日常运维 - -### 查看备份状态(RPO/RTO 实时检查) - -```bash -docker exec pangolin-backup /scripts/verify-rpo-rto.sh -``` - -输出示例: - -``` -[OK] MySQL-增量: 最新备份距今 8min (RPO ≤ 15min ✓) -[OK] PG-增量: 最新备份距今 7min (RPO ≤ 15min ✓) -[OK] MySQL 全量: 最近 14h 内有全量备份 -[OK] DR 演练: 上次演练距今 12 天 -状态: 达标 ✅ -``` - -### 查看 cron 日志 - -```bash -docker logs --tail 100 pangolin-backup -# 或 -docker exec pangolin-backup tail -50 /var/log/backup/cron.log -``` - -### 手动触发全量备份 - -```bash -docker exec pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/backup-full.sh' -``` - -### 手动触发 DR 演练 - -```bash -# 如需验证解密恢复,临时注入私钥 -docker exec -e AGE_PRIVATE_KEY="$(cat age-private.txt)" \ - pangolin-backup bash -c '. /etc/backup-env.sh && /scripts/dr-drill.sh' -``` - -### 列出 S3 备份文件 - -```bash -docker exec pangolin-backup bash -c '. /etc/backup-env.sh && \ - AWS_PROFILE=backup aws s3 ls s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/ --recursive \ - | sort | tail -20' -``` - ---- - -## 灾备恢复流程(实际灾难时) - -> **前提**:已有 age 私钥(从密码管理器取出),已有恢复目标服务器 - -### MySQL 完整恢复 - -```bash -# 1. 将私钥挂载到恢复容器 -docker run -it --rm \ - -e AGE_PRIVATE_KEY="$(cat age-private.txt)" \ - -e AWS_PROFILE=restore \ - # ... 其他环境变量 ... - -v /restore:/restore \ - pangolin-backup:local bash - -# 2. 在容器内执行恢复 -RESTORE_DIR=/restore /scripts/restore.sh mysql full 20240101_020000 - -# 3. 如需应用增量(最近一次全量后的某个增量时间点) -RESTORE_DIR=/restore /scripts/restore.sh mysql inc 20240101_020000 20240101_150000 - -# 4. 容器会提示手动操作步骤: -# - systemctl stop mysql -# - rm -rf /var/lib/mysql/* -# - xtrabackup --copy-back --target-dir=/restore/mysql/20240101_020000 -# - chown -R mysql:mysql /var/lib/mysql -# - systemctl start mysql -``` - -### PostgreSQL 完整恢复 - -```bash -RESTORE_DIR=/restore /scripts/restore.sh postgres blog 20240101_150000 -# 恢复到 blog_restored 数据库,验证后再重命名 -``` - -### RTO 测量标准 - -| 步骤 | 预计耗时 | -|------|---------| -| S3 下载全量备份(50GB)| ~30min | -| age 解密 | ~2min | -| xtrabackup --prepare | ~20min | -| 数据文件复制(rsync)| ~30min | -| MySQL 启动 + 应用增量/binlog | ~30min | -| 验证(基本查询)| ~10min | -| **合计** | **~2h(远低于 4h)** | - ---- - -## 月度 DR 演练报告 - -演练报告自动上传至:`s3://YOUR_BUCKET/ec2-ap-southeast-1/dr-reports/drill_YYYYMMDD_HHMMSS.txt` - -本地日志:`docker exec pangolin-backup tail -100 /var/log/backup/dr-drill.log` - -演练覆盖项目: - -| 检查项 | 标准 | -|--------|------| -| RPO 验证 | 最新备份距今 ≤ 15min | -| 备份文件完整性 | tar 归档可读,age 解密成功 | -| MySQL xtrabackup prepare | 无错误 | -| MySQL 临时恢复验证 | 容器启动后可执行查询 | -| PostgreSQL 备份结构验证 | pg_restore --list 通过 | -| RTO 演练总耗时 | ≤ 4h | - ---- - -## 安全要点 - -- **age 私钥不存服务器**:加密用公钥,解密用私钥(离线保存) -- **独立 IAM 账号**:备份写入账号仅有 `s3:PutObject`,即使服务器被入侵也无法读取历史备份 -- **加密存储**:所有备份在上传前均 age 加密,S3 中无明文数据 -- **版本控制**:S3 开启版本控制,防止误操作覆盖 -- **生命周期**:30 天 → IA,90 天 → Glacier,365 天 → 删除(成本控制) -- **凭证不入库**:`deploy/secrets/` 在 `.gitignore` 中 - ---- - -## 故障排查 - -### 备份容器无法连接 MySQL - -```bash -# 检查 MySQL 是否可达 -docker exec pangolin-backup mysql \ - --host=127.0.0.1 --port=3306 \ - --user=backup_user --password=YOUR_PW \ - -e "SELECT 1" -# 若失败:检查防火墙、MySQL bind-address(应为 0.0.0.0 或 127.0.0.1) -``` - -### xtrabackup 提示版本不匹配 - -xtrabackup 8.0 只支持 MySQL 8.0。若生产 MySQL 是 5.7,需修改 Dockerfile 安装 `percona-xtrabackup-24`: - -```dockerfile -RUN percona-release setup pxb24 \ - && apt-get install -y percona-xtrabackup-24 -``` - -### S3 上传失败 - -```bash -# 检查凭据 -docker exec pangolin-backup bash -c 'AWS_PROFILE=backup aws sts get-caller-identity' -# 检查 bucket 权限 -docker exec pangolin-backup bash -c \ - "AWS_PROFILE=backup aws s3 cp /dev/null s3://${BACKUP_S3_BUCKET}/test-write && echo OK" -``` - -### DR 演练中 Docker 不可用 - -演练脚本中的 MySQL 临时容器启动依赖 Docker-in-Docker 或宿主 Docker socket。 -若不可用,脚本会跳过容器验证,只做 `xtrabackup --prepare` 完整性验证。