docs: 新增 INFRA.md 記錄跨 repo 部署架構與 2026-04-20 演進

- 架構現況:前端 / API / Redis / webhook 自動部署 / NPM 反代 全景圖
- 機器與端點速查(42.108 / 42.104 / 42.124 / 外部 domain / git repos)
- 今天做了什麼的時序紀錄(含 bug / 踩坑 / 解法)
- 日常操作 cookbook(新增自動部署 repo、查 log、手動部署、API 端操作、NPM 建 proxy host)
- 已知限制與 follow-up(state.json 過期、token 歷史殘留、監控缺口等)
- README 首段加指引連到 INFRA.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:08:33 +08:00
parent 2f7285437b
commit 863036c293
2 changed files with 334 additions and 0 deletions

View File

@@ -4,6 +4,8 @@
這份 README 是給**一年後回來看的你自己**:最常用的指令放最上面,架構與決策放最下面。`CLAUDE.md` 是給 AI 助手用的,可以忽略。
跨 repo / 跨機器的部署地圖前端、API、webhook 自動部署、NPM 反代、Gitea放在 [`docs/INFRA.md`](docs/INFRA.md),回憶「當初怎麼設定的」從那篇下手。
---
## 最常用的指令

332
docs/INFRA.md Normal file
View File

@@ -0,0 +1,332 @@
# 基礎設施地圖與 2026-04-20 演進紀錄
這份文件的受眾是**一年後忘光的你自己**。涵蓋:
1. 目前整套 pokemon 家族的**架構現況**(哪台機跑什麼、流量怎麼走)
2. 今天2026-04-20做了什麼、**為什麼**這麼做
3. **日常操作** cookbook
4. 已知限制與 follow-up
單一專案的使用手冊請看各 repo 自己的 `README.md`。本檔是**跨 repo / 跨機器的全景圖**。
---
## 1. 架構現況
```
使用者瀏覽器
│ https://web.lotimmy.com/pokemon/
NPM (192.168.42.124:443, LE 憑證)
│ 反代
caddy (192.168.42.104:80) serve /opt/caddy/www/pokemon/
│ index.html + img/ 等靜態檔
瀏覽器 JS fetch https://poke-api.lotimmy.com/scan
│ NPM (192.168.42.124:443, LE 憑證) 反代
pokemon_radar_api (192.168.42.108:8008, supervisor 管)
│ stale-while-revalidate
│ - /scan 永遠秒回 Redis 當下資料
│ - 若 TTL < STALE_THRESHOLD 或 key 不存在 → 背景 thread 呼叫 radar_fetcher.main()
│ 互斥 lock key: pokemon:radar:fetching (180s TTL)
Redis (192.168.42.108:127.0.0.1:6379, Docker redis:7-alpine)
│ key: pokemon:radar:latest (TTL 300s)
│ 寫入
radar_fetcher (192.168.42.108:8008 API 背景 thread 觸發)
│ POST code → twpk_radar.mjs
Function Server (192.168.42.124:13000)
│ Puppeteer + stealth + state.json cookies
twpkinfo.com/ipoke.aspx
```
### 另一條:自動部署 pipeline
```
本機 git push → Gitea (192.168.42.124:31337)
│ POST /hooks/<name> + HMAC-SHA256 (X-Gitea-Signature)
git-webhook (192.168.42.104:9000, Docker)
│ 動態讀 hooks/<name>/{secret,deploy.sh}
│ 簽章驗證通過 → subprocess deploy.sh
git clone → rsync 到該 repo 配置的 target dir
caddy (或其他 serve) 自動提供新版(無需 reload
```
---
## 2. 機器與端點速查
| 機器 | 功能 | 關鍵 port / 路徑 | 服務管理 |
|---|---|---|---|
| `192.168.42.108` (CT108) | `pokemon_radar_api` + 本機 Redis + 其他 8 個 uvicorn service | `:8008` API、`:127.0.0.1:6379` Redis | supervisor (`/etc/supervisor/conf.d/pokemon-radar-api.conf`) |
| `192.168.42.104` (CT104) | caddy 靜態檔 + `git-webhook` 自動部署 | `:80`/`:443` caddy、`:9000` webhook | caddy 手動啟動、webhook 用 Docker Compose |
| `192.168.42.124` | Gitea + NPM + Function Serverbrowser automation+ Pi-hole 等 | `:31337` Gitea、`:81` NPM UI、`:13000` Function Server | Docker Compose多個 |
| `192.168.42.211` | **已死** — 舊的生產 Redis網路不通 | — | — |
### 外部 domain
| Domain | A record | 服務 |
|---|---|---|
| `web.lotimmy.com` | NPM public IP | 前端地圖caddy 上) |
| `poke-api.lotimmy.com` | `125.229.110.50`NPM public | 後端 API |
| `<service>.lotimmy.com` / `<service>.timmy.us.kg` / `<service>.timmy.edu.kg` | 同上 | NPM 管理的其他 30+ 服務 |
### Git Repos
| Repo | 遠端 | 角色 |
|---|---|---|
| `pokemon-radar-api` | `http://192.168.42.124:31337/timmy/pokemon-radar-api.git` (master) | 後端 API + fetcher |
| `pokemon` | `http://192.168.42.124:31337/timmy/pokemon.git` (main) | 前端 Leaflet 地圖 |
兩 repo 都是**公開 Gitea repo**內網sensitive 的 `.env` / `state.json``.gitignore` 內不上傳。
---
## 3. 今天做了什麼2026-04-20
按時序與因果。中間發現不少 legacy 問題,列出來避免未來重蹈。
### 3.1 pokemon-radar-api 本機環境整理
- 發現 repo 根目錄有 5 個 `*_BACKUP_<timestamp>` 手動備份檔,已清除
-`/scan`:舊版 `get_data_from_redis()` 只 catch `json.JSONDecodeError`Redis 連不上時會直接 500。改成回 `(data, ttl, err)` tuple、`scan()``err` 區分 `redis`/`redis (empty)`/`redis (unreachable)`**所有情況都回 200**
- `radar_fetcher._postprocess` 與舊 CLI 的 `PokemonRadarClient.postprocess` 篩選邏輯重複實作,目前維持(改動 scope 太大)
### 3.2 建立 Gitea repo + 推送
- 原本 repo 是 git init 但從未 push 過
- 新建 Gitea `timmy/pokemon-radar-api`public
- 關鍵:**第一次 push 前先補 `.gitignore``.env` / `state.json` / runtime 輸出**,否則會連 `FUNCTION_TOKEN` 和 cookies 一起推上去
- Push 用 `git -c http.extraHeader="Authorization: token ..."` 方式token 不存 `.git/config`
### 3.3 移除 hard-coded token
- `run_pokemon_radar_api.sh``pokemon_location_fetcher.py` 裡都寫死 `FUNCTION_TOKEN=6R0W53R135510`
- 改成從 env 讀(`os.getenv("FUNCTION_TOKEN", "")`),並在 `pokemon_location_fetcher.py``load_dotenv()`
- `pyproject.toml` 補宣告 `python-dotenv`(專案已在用但未列 dep
- **注意**git 歷史第一個 commit `f7ef105` 仍含 token因 repo 是 public 且已 push。要徹底清需 `git filter-repo`,目前暫不做
### 3.4 實作 stale-while-revalidate (v2.1)
- 問題:定期 cron 跑 fetcher → 沒人用時也一直打 Function Server + twpkinfo.com會被標記
- 解法:`/scan` 永遠秒回當下 Redis 資料;若 TTL < `STALE_THRESHOLD`(預設 120s或 key 不存在,用 `threading.Thread` 背景呼叫 `radar_fetcher.main()` 非同步更新
- 互斥 lockRedis `SET NX EX` on key `pokemon:radar:fetching`TTL 180s避免並發
- cron 可以完全停掉(之前的 `fetch_and_upload.sh` / `radar_fetcher.py` 排程在生產 crontab 裡都已註解)
- 新增環境變數:`STALE_THRESHOLD``FETCH_LOCK_KEY``FETCH_LOCK_TTL`
### 3.5 前端 repo 改走 APIMixed Content 坑)
- 使用者要前端直接打 API不走舊的 `scp spots.json → caddy` 流程
- 第一次改:`API_URL = http://192.168.42.108:8008/scan`**錯**:使用者一開始說 88.108subagent 照做後使用者更正為 42.108
- 第二次改:`API_URL = http://192.168.42.108:8008/scan`(對的 IP
- **但**頁面是 `https://web.lotimmy.com/pokemon/`,打 HTTP API 是 Mixed Content瀏覽器擋
- 第三次改:`API_URL = https://poke-api.lotimmy.com/scan`(走 NPM 反代)
### 3.6 生產機 42.108 部署(發現多層問題)
- **發現 1**:生產機 `/opt/pokemon-radar-api` 根本不是健康 git repo`.git` 存在但 master 0 commits是手工 scp 上去的 Dec 8 舊版)
- **發現 2**:舊版 API 還沒有 `/scan` fix連不到 Redis 直接 500
- **發現 3**:生產 `.env` `REDIS_HOST=192.168.42.211` 已經**連不到**(該機器不存在了)
- 做法:
1. `sudo tar` 備份整個目錄到 `/root/pokemon-radar-api-backup-<ts>.tar.gz`
2.`.env` / `state.json` 搬到 `/tmp`
3. `rm -rf /opt/pokemon-radar-api` + 從 Gitea `git clone`
4. 還原 `.env` / `state.json`
5. `uv sync`
6. `supervisorctl restart pokemon-radar-api`
- 驗證:`/scan` 回 200`source=redis (unreachable)` — Redis 死的事實被誠實暴露
### 3.7 在 42.108 跑本機 Redis
- `docker run -d --name pokemon-radar-redis --restart unless-stopped -p 127.0.0.1:6379:6379 redis:7-alpine`**綁 loopback only**,不對外暴露)
- `.env``REDIS_HOST=127.0.0.1``REDIS_PASSWORD=` 留空
- `supervisorctl restart pokemon-radar-api`
- 第一次 `/scan``source=redis (empty)` + 背景 fetcher 觸發lock 有值)
- 背景 fetcher 完成51 筆進 Redis、TTL 299s
- 第二次 `/scan``source=redis`、12ms 回應、有資料
### 3.8 NPM 反代 HTTPS解 Mixed Content
- DNS: `poke-api.lotimmy.com` A record → NPM public IP (`125.229.110.50`),僅 DNS 不走 CF proxyLE HTTP challenge 需要直達)
- NPM API (`http://192.168.42.124:81/api/`)
1. `POST /api/tokens``admin@example.tw` + password → JWT
2. `POST /api/nginx/proxy-hosts` 建 proxy host`certificate_id: "new"` 一併申請 LE cert
- 建好 proxy_host id=56 / cert id=84
- `curl https://poke-api.lotimmy.com/ping` → 200
### 3.9 前端部署到 caddyPhase 1
- caddy `/opt/caddy/www/pokemon/` 目錄 perm `777`、timmy 可直接寫(但本身嵌在 `/opt/caddy/.git` 的 repo 結構裡,所以不能單獨 git init 變新 repo
- `scp index.html` 手動覆蓋一次,讓 prod 立即有新版
- 同時為 Phase 2 的 webhook 自動化鋪路
### 3.10 Webhook-driven 自動部署Phase 2
先做版(單 repo 寫死 pokemon
- 在 42.104 建 `/opt/git-webhook/`
- `server.py`Python stdlib `http.server`50 行HMAC-SHA256 驗證)
- `deploy.sh`git clone depth=1 → rsync --exclude=.git 到 `/opt/caddy/www/pokemon/`
- `Dockerfile``python:3.12-alpine` + `apk add git rsync`
- `docker-compose.yml`mount `/opt/caddy/www/pokemon`
- Gitea API `POST /api/v1/repos/timmy/pokemon/hooks` 建 webhookevents=push, branch_filter=main, 帶 secret
**卡關**Gitea test webhook 204 但 container 沒收到。查 Gitea log 發現:
```
Unable to deliver webhook task[N]: ...
webhook can only call allowed HTTP servers
(check your webhook.ALLOWED_HOST_LIST setting),
deny '192.168.42.104:9000'
```
Gitea 預設 SSRF 保護擋所有 RFC1918 內網 IP。
**解法**:在 `/opt/gitea/data/gitea/conf/app.ini` 加:
```ini
[webhook]
ALLOWED_HOST_LIST = external,192.168.42.0/24,loopback
```
`sudo docker restart gitea`。修好後 webhook 立刻通。
### 3.11 Webhook 動態化 + helper
單 repo 寫死會限制未來。改成動態 hook loader
- `server.py``POST /hooks/<name>` → 從 `/app/hooks/<name>/secret` 讀該 hook 的 secret、`/app/hooks/<name>/deploy.sh` 跑部署
- `server.py``hooks/` 都是 volume mount不用 rebuild image 就可新增)
- `/opt/caddy/www` 整個 mount 進 containercontainer 能寫任何子目錄
- 新增 helper `/opt/git-webhook/add-deploy.sh <owner> <repo> <target-dir> <token>`
1. 生 random secret 寫到 `hooks/<repo>/secret`
2.`hooks/<repo>/deploy.sh`git clone + rsync
3. 呼叫 Gitea API 建 webhook
4. 觸發 test 驗證
舊 global secret 從 `.env` 遷到 `hooks/pokemon/secret`(沿用原值保持 Gitea 端不用改)。
---
## 4. 日常操作 Cookbook
### 新增一個 repo 自動部署
前提repo 已在 Gitea且有 main branch。
```bash
ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \
<owner> <repo> <target-dir> <GITEA_TOKEN>'
```
範例(把 `timmy/blog` 自動部署到 caddy
```bash
ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \
timmy blog /opt/caddy/www/blog 737e5eb08811...'
```
完成後push 該 repo main → 自動部署。
### 查 webhook deploy log
```bash
ssh 192.168.42.104 'sudo docker logs git-webhook --tail 50'
```
### 手動重新部署
```bash
ssh 192.168.42.104 'sudo /opt/git-webhook/hooks/<repo>/deploy.sh'
```
### 暫停自動部署
```bash
ssh 192.168.42.104 'sudo docker stop git-webhook' # 停掉 webhook receiver
# 或
# 在 Gitea UI (http://192.168.42.124:31337/<owner>/<repo>/settings/hooks) disable
```
### API 端42.108)操作
```bash
# 重啟
ssh 192.168.42.108 'sudo supervisorctl restart pokemon-radar-api'
# 查 log
ssh 192.168.42.108 'sudo tail -f /var/log/supervisor/pokemon-radar-api.log'
# 看 Redis 內容
ssh 192.168.42.108 'sudo docker exec pokemon-radar-redis redis-cli GET pokemon:radar:latest' | head -100
# 手動觸發 fetcher
ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo /root/.local/bin/uv run python radar_fetcher.py'
```
### 更新生產 API 程式碼
後續 pokemon-radar-api 的 commit push 到 Gitea 後,生產機還要手動 pull + restart
```bash
ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo git pull && sudo /root/.local/bin/uv sync && sudo supervisorctl restart pokemon-radar-api'
```
(若要把 API 端也做成 webhook 自動部署需要設計一個能「git pull + uv sync + supervisorctl restart」的 deploy.sh並讓 webhook container 有權限 ssh 或 supervisorctl — 比前端複雜。先保持手動)
### 新增 NPM 反代 + LE cert
```bash
# 1. DNS: 在 Cloudflare 加 <service>.lotimmy.com A record → 125.229.110.50(僅 DNS不走 CF proxy
# 2. NPM API 建 proxy host或到 http://192.168.42.124:81/ UI 按按鈕)
TOKEN=$(curl -sS -X POST http://192.168.42.124:81/api/tokens \
-H "Content-Type: application/json" \
-d '{"identity":"admin@example.tw","secret":"<password>"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -sS -X POST http://192.168.42.124:81/api/nginx/proxy-hosts \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"domain_names": ["<service>.lotimmy.com"],
"forward_scheme": "http", "forward_host": "192.168.42.X", "forward_port": NNNN,
"certificate_id": "new",
"ssl_forced": true, "http2_support": true, "block_exploits": true,
"meta": {"letsencrypt_agree": true, "letsencrypt_email": "admin@example.tw", "dns_challenge": false},
"locations": []
}'
```
---
## 5. 已知限制與 follow-up
### 必看
- **state.json 是整條 pipeline 最脆弱的點**:過期時 Function Server 返回 200 但 results 空或登入頁pipeline 靜默吐 0 筆。目前 state 是 2025-11-04 的,仍有效但總有一天會壞。重登 twpkinfo.com 匯出新 state 即可
- **42.108 supervisor conf 裡仍硬寫 `POKER_TOKEN="6R0W53R135510"`**`/etc/supervisor/conf.d/pokemon-radar-api.conf``environment=` 那行),程式碼已不讀這個變數,但機器上 token 還在。非緊急,可空檔時清掉
- **第一個 commit `f7ef105` 含歷史 token**public repo 歷史檔仍含當時 `FUNCTION_TOKEN=6R0W53R135510`,要徹底清需 `git filter-repo` + force push。目前暫不處理
### 小疵
- `.env.bak.<ts>` 備份檔留在 `/opt/pokemon-radar-api/``/opt/gitea/data/gitea/conf/`,確認系統穩定後可刪
- 兩個 fetcher`radar_fetcher.py` + `pokemon_location_fetcher.py`)篩選邏輯重複實作,合併到共用 module 是理想做法(但兩條 pipeline 短期共存,現在不急)
- 前端 `API_URL` 仍 hardcode 在 `index.html`,未來要做 dev/prod 切換可抽 `config.js` 或用 `window.location.hostname` 判斷
- 前端空 items 用紅色 `showLoadError` 呈現,嚴格說這不是錯誤而是 SWR 暫態,可改中性 banner + auto retry
- `pokemon-radar-api` 沒做 webhook 自動部署(還是手動 git pull + restart若希望一致化可仿 `add-deploy.sh` 但加上 `supervisorctl restart` 步驟
### 沒做但值得之後做
- 監控webhook deploy 失敗時沒通知、API 500 時沒通知;可考慮 ntfy / Discord webhook 串起來
- Redis 在 42.108 Docker 裡,**沒設 persistence volume**restart container 資料丟掉但 SWR 會自動重建,實務上無感。若以後需要 persist`-v redis-data:/data`
- `add-deploy.sh` 需要 Gitea token 當 arg使用上每次要貼。可在 42.104 寫個 `/opt/git-webhook/.env``GITEA_TOKEN`helper 自動 source