- README env 表格加入 RADAR_TARGETS、RADAR_ZOOM 備註(zoom 11 單點 vs zoom 15 多點的差異) - INFRA.md 加 3.13.5 多點 union 掃描節,記錄 root cause(單點 zoom 11 密度過低)、解法、生產設定、二階段優化方向 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
基礎設施地圖與 2026-04-20 演進紀錄
這份文件的受眾是一年後忘光的你自己。涵蓋:
- 目前整套 pokemon 家族的架構現況(哪台機跑什麼、流量怎麼走)
- 今天(2026-04-20)做了什麼、為什麼這麼做
- 日常操作 cookbook
- 已知限制與 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 Server(browser 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()只 catchjson.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()非同步更新 - 互斥 lock:Redis
SET NX EXon keypokemon:radar:fetching(TTL 180s)避免並發 - cron 可以完全停掉(之前的
fetch_and_upload.sh/radar_fetcher.py排程在生產 crontab 裡都已註解) - 新增環境變數:
STALE_THRESHOLD、FETCH_LOCK_KEY、FETCH_LOCK_TTL
3.5 前端 repo 改走 API(Mixed Content 坑)
- 使用者要前端直接打 API,不走舊的
scp spots.json → caddy流程 - 第一次改:
API_URL = http://192.168.42.108:8008/scan(錯:使用者一開始說 88.108,subagent 照做後使用者更正為 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 還沒有
/scanfix,連不到 Redis 直接 500 - 發現 3:生產
.envREDIS_HOST=192.168.42.211已經連不到(該機器不存在了) - 做法:
sudo tar備份整個目錄到/root/pokemon-radar-api-backup-<ts>.tar.gz- 把
.env/state.json搬到/tmp rm -rf /opt/pokemon-radar-api+ 從 Giteagit clone- 還原
.env/state.json uv syncsupervisorctl 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.comA record → NPM public IP (125.229.110.50),僅 DNS 不走 CF proxy(LE HTTP challenge 需要直達) - NPM API (
http://192.168.42.124:81/api/):POST /api/tokens用admin@example.tw+ password → JWTPOST /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 前端部署到 caddy(Phase 1)
- caddy
/opt/caddy/www/pokemon/目錄 perm777、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 stdlibhttp.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建 webhook(events=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 加:
[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 進 container,container 能寫任何子目錄- 新增 helper
/opt/git-webhook/add-deploy.sh <owner> <repo> <target-dir> <token>:- 生 random secret 寫到
hooks/<repo>/secret - 寫
hooks/<repo>/deploy.sh(git clone + rsync) - 呼叫 Gitea API 建 webhook
- 觸發 test 驗證
- 生 random secret 寫到
舊 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 是給舊 pipeline(scp 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街區級密度(zoom 越大單點範圍越小但回傳密度越高) FETCH_LOCK_TTL拉高到400(5 點序列跑總耗時 ~50s,但要留餘裕)
目前生產設定 5 個 target:台北車站 / 內湖南港 / 板橋 / 新竹 / 宜蘭。測試 twpk 內湖那個 1km² 街區:0 → 2 筆。還不理想但方向對;可加 target 或降 MIN_REMAINING 進一步補密度。
3.13 UI 顯示資料更新時間
使用者想看到「這份資料什麼時候抓的」:
- 後端
/scanresponse 新增generated_at欄位(ISO 8601,台北時區),從 Redis 存的 fetcher payload 頂層讀出 ScanResponsemodel 補Optional[str]對應- 前端右下角
<div id="updated-badge">半透明深色 banner,文字格式「資料更新於 HH:MM:SS(N 分鐘前)」- 相對時間:<30s 剛剛 / <60m 分鐘前 / <24h 小時前 / 否則日期
setInterval每 10 秒重算顯示(不重 fetch)- 只在成功拿到有 items 的 response 時更新,空/錯誤分支保留上次值
後端變更需手動部署到 42.108:git pull && uv sync && supervisorctl restart。前端透過 webhook 自動部署。
4. 日常操作 Cookbook
新增一個 repo 自動部署
前提:repo 已在 Gitea,且有 main branch。
ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \
<owner> <repo> <target-dir> <GITEA_TOKEN>'
範例(把 timmy/blog 自動部署到 caddy):
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
ssh 192.168.42.104 'sudo docker logs git-webhook --tail 50'
手動重新部署
ssh 192.168.42.104 'sudo /opt/git-webhook/hooks/<repo>/deploy.sh'
暫停自動部署
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)操作
# 重啟
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:
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
# 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 自動 sourcepokemon-radar-api後端還是手動git pull + restart。仿add-deploy.sh做一個帶supervisorctl restart的 deploy.sh 即可(需要 webhook container 能 ssh 進 host 執行 supervisorctl,或改用 host-level daemon)