Files
pokemon-radar-api/docs/INFRA.md
Timmy 3f2c03d4bb docs: INFRA.md 更新生產 29 個 target 清單與覆蓋實測
- 取代舊 5 target 記錄(台北/內湖/板橋/新竹/宜蘭)
- 完整列出大台北 11 點、新北 12 點、新竹 1 點、宜蘭 5 點共 29 個座標
- FETCH_LOCK_TTL 從 400 調整到 600(29 點序列 ~5 分鐘)
- 新增實測數據:760 筆 filtered(台北 577 / 新北 713 / 宜蘭 64)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:50:04 +08:00

389 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 基礎設施地圖與 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 端不用改)。
### 3.12 前端 SWR 冷啟動 UX空資料自動重試
問題Redis 快取過期時,第一次 `/scan``source: "redis (empty)"`,要等 ~30-60 秒 fetcher 完成才有資料。使用者第一次開頁就撞上,看到紅框誤以為壞了。
前端 `loadScan()` 改成:
- 空資料不走紅框錯誤分支,改顯示中性灰框 banner「正在更新資料請稍候…N/10
- 每 8 秒 `setTimeout` 遞迴重試,上限 10 次80 秒)
- 拿到資料自動清 timer + 隱藏 banner
- 超時顯示中性警告「後端仍無資料,請稍後重新整理」
- 真正網路 / HTTP / JSON 錯誤 → 維持原本紅框
這次的 push 也是 **webhook pipeline 第一次真實使用**push → Gitea 觸發 webhook → 42.104 自動 rsync → caddy 下次 request 即新版。之前都是用 Gitea API 的 test endpoint 模擬。
### 3.13.5 多點 union 掃描zoom 調整)
原設計 `RADAR_ZOOM=11` 是給舊 pipelinescp spots.json 給 caddy用的想一次抓整個北台灣。新 pipeline 前端會聚焦特定街區zoom=11 下單位面積密度太低(實測 twpk 某 1km² 街區顯示 98 marker我們 API 在同範圍為 0
改成 **多點 union**
- `radar_fetcher.py` 支援 `RADAR_TARGETS=name:lat,lng;...` 環境變數
- 主流程 loop 每個 target 呼叫 Function Server`(id, lat, lng, expire_time)` dedupe 後 union
- 單點失敗不阻斷其他 target
- 搭配 `RADAR_ZOOM=15` 街區級密度
- `FETCH_LOCK_TTL=600` 保 29 點序列跑的耗時(~5 分鐘)
**目前生產 29 個 target**(涵蓋大台北 / 新北 / 宜蘭主要人口區 + 新竹備援):
```
台北:台北車站(25.0478,121.5170)、中山(25.0634,121.5264)、松山(25.0499,121.5777)、
信義(25.0330,121.5645)、大安(25.0263,121.5443)、萬華(25.0356,121.4990)、
士林(25.0875,121.5260)、北投(25.1316,121.4986)、內湖(25.0824,121.5877)、
南港(25.0544,121.6065)、文山(24.9890,121.5691)
新北:板橋(25.0141,121.4631)、三重(25.0613,121.4860)、新莊(25.0385,121.4502)、
中和(25.0070,121.4995)、永和(25.0093,121.5148)、新店(24.9650,121.5423)、
土城(24.9722,121.4419)、汐止(25.0692,121.6415)、淡水(25.1718,121.4489)、
三峽(24.9349,121.3746)、林口(25.0775,121.3931)、蘆洲(25.0872,121.4692)
新竹:新竹(24.8138,120.9675)
宜蘭:宜蘭市(24.7511,121.7535)、羅東(24.6770,121.7681)、礁溪(24.8234,121.7708)、
蘇澳(24.5952,121.8523)、頭城(24.8600,121.8256)
```
驗證single fetch 回 760 筆 filtered台北市 577 / 新北市 713 / 宜蘭縣 64。街區級密度仍不如 twpkzoom 17 視角下單點有 98 marker但整個北北宜已有覆蓋若要再補可加密 target 或降 `MIN_REMAINING`
### 3.13 UI 顯示資料更新時間
使用者想看到「這份資料什麼時候抓的」:
- 後端 `/scan` response 新增 `generated_at` 欄位ISO 8601台北時區從 Redis 存的 fetcher payload 頂層讀出
- `ScanResponse` model 補 `Optional[str]` 對應
- 前端右下角 `<div id="updated-badge">` 半透明深色 banner文字格式「資料更新於 HH:MM:SSN 分鐘前)」
- 相對時間:<30s 剛剛 / <60m 分鐘前 / <24h 小時前 / 否則日期
- `setInterval` 每 10 秒重算顯示(不重 fetch
- 只在成功拿到有 items 的 response 時更新,空/錯誤分支保留上次值
後端變更需手動部署到 42.108`git pull && uv sync && supervisorctl restart`。前端透過 webhook 自動部署。
---
## 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
- `pokemon-radar-api` 後端還是手動 `git pull + restart`。仿 `add-deploy.sh` 做一個帶 `supervisorctl restart` 的 deploy.sh 即可(需要 webhook container 能 ssh 進 host 執行 supervisorctl或改用 host-level daemon