
前情提要
我有一個自己每天重度使用的主力 LINE Bot:kkdai/linebot-helper-python。
最初它只是一個「丟網址進去、回傳繁體中文摘要」的小工具,但過去一年半裡,我陸續往裡面塞進了各式各樣的 Gemini 新能力:
- 多來源內容載入與文件轉檔:網頁三層爬蟲 Fallback Chain(HTTP $\rightarrow$ Firecrawl $\rightarrow$ SingleFile Headless Chromium)、YouTube 影片摘要、PDF / Word / PPT / Excel / EPUB 自動轉 Markdown,以及一鍵產出 FB / LinkedIn / Threads 社群爆款文案 Carousel。
- 書籤與 Grounding 深度研究報告:
/save、/list、/search書籤管理,點擊卡片上的「📄 詳細研究報告」就會結合 Google Search Grounding 寫出一份完整 Markdown 報告,渲染成獨立 HTML 網頁(/reports/{id})並永久保存在 Cloud Firestore。 - Google Maps Grounding 與 Gemini Batch API 美食大數據分析:傳送 LINE 位置座標搜尋附近加油站、停車場與餐廳,還能針對特定餐廳抓出 50+ 則最新評論,丟進 Gemini Batch API(享 50% 成本折扣)跑深度招牌菜與雷點分析,透過 Webhook 或背景輪詢推播回 LINE。
- Agentic Vision 視覺標註:上傳圖片後讓
gemini-3-flash-preview寫 Python Code 畫出標註框(Bounding Box),轉存成圖片推回聊天室。 - LINE 語音訊息 TTS 與 LIFF 即時雙向語音助手:語音訊息自動轉錄並搭配
gemini-3.1-flash-tts-preview+ffmpeg產生 M4A 語音朗讀;打開 LIFF 還能透過 WebSocket 雙向串流gemini-3.1-flash-live-preview,支援免持 VAD、Barge-in 打斷與 Live Tool Calling。
功能很爽,但代價也很明顯:Python + FastAPI + 一大票文件解析與 AI 套件的常駐記憶體越來越高,為了讓背景任務與多併發不卡頓,這台 Cloud Run 服務不知不覺已經開到了 4 vCPU / 4 GiB Memory(且開啟 --no-cpu-throttling)。
身為一個長期寫 Go 的人,看著一台個人日常助理吃掉 4 vCPU / 4 GiB 實在有點忍不下去了。於是決定動手做一件事:把整套 linebot-helper-python 100% 改寫成 Go 1.26 最新版(kkdai/linebot-helper-go),直接部署到同一個 GCP 專案的 Cloud Run 上,並且做到與舊版共享 Firestore 資料、隨時可零停機切換。
一、改寫前先立規矩:用 GitHub Issues 拆解 11 個里程碑
把一個將近 90KB main.py 外加十幾個模組的 Python 專案完整搬到 Go,如果沒有先拆好邊界、邊寫邊測,很容易寫出一座無法維護的新迷宮。
因此在建立 kkdai/linebot-helper-go 之後,第一件事不是寫 Code,而是對照 Python 版的所有功能,先在 GitHub 開出 Tracking Issue #12 與 11 個子任務 Issues(#1 ~ #11),每個 Issue 對應一個獨立套件與完整的單元測試:
| 里程碑 Issue | 對應 Go 套件 | 核心職責與相容性重點 |
|---|---|---|
| #1 專案骨架與 Dockerfile | main.go, internal/config |
Go 1.26 net/http、環境變數 100% 相容 Python 版、Multi-stage Dockerfile(整合 Chromium / single-file / ffmpeg) |
| #2 Gemini 雙通道客戶端 | internal/gemini |
同時支援 Vertex AI (ADC) 與 Gemini Developer API (GOOGLE_AI_API_KEY),涵蓋 Grounding、Code Execution 與 TTS |
| #3 Firestore 持久化層 | internal/firestore, internal/services |
完全相容 Python 版 chat_sessions、bookmarks、reports、batch_jobs 四大 Collection Schema,無憑證時自動降級為記憶體儲存 |
| #4 LINE Messaging API 與 Flex | internal/line |
HMAC-SHA256 簽章驗證、Reply / Push / Loading Animation、社群文案與書籤 Flex Carousel 組裝 |
| #5 網址爬蟲與多格式文件轉換 | internal/loader |
HTTP(含 PTT over18=1)$\rightarrow$ Firecrawl $\rightarrow$ SingleFile 三層時限預算 Fallback,以及 PDF / DOCX / PPTX / XLSX / EPUB / ODF 轉 Markdown |
| #6 AI 工具集 | internal/ai |
摘要、社群爆款文案、Grounding 研究報告、YouTube 分析、Agentic Vision、GitHub Issues 彙整、Maps 搜尋 |
| #7 ADK Orchestrator 與專職 Agents | internal/agents |
整合官方 google.golang.org/adk v1.9.0,建立 Root Orchestrator 與 Chat / Content / Location / Vision / GitHub 5 個專職 Agent |
| #8 非同步 Webhook 與 Postback | internal/handlers |
Webhook 秒回 200 OK、背景 Goroutine 處理事件、9 種 Postback 分派與 /reports/{id}、/images/{id}、/audio/{id} 端點 |
| #9 Gemini Batch API 美食分析 | internal/services/batch_service.go |
JSONL 批次工作上傳、Static HMAC Webhook、Dynamic RS256 JWKS Webhook 驗證與 60 秒背景輪詢 |
| #10 LIFF 即時語音助手 | internal/voice, static/liff |
/ws/voice/{session_id} 雙向 WebSocket Relay、Push-to-Talk / 免持 VAD、Barge-in、Session Resumption 與 Live Tool Calling |
| #11 Cloud Run 部署與驗證 | Cloud Run (us-central1) |
沿用 Python 版線上環境變數部署,壓測並調降容器資源規格 |
整體架構如下圖所示:
flowchart TB
subgraph Client["LINE 客戶端與 LIFF"]
LINE["LINE App 聊天室<br/>(文字 / 網址 / 圖片 / 語音 / 位置 / Postback)"]
LIFF["LIFF 即時語音助手<br/>(/liff/ + WebSocket PCM 16kHz/24kHz)"]
end
subgraph CloudRun["Google Cloud Run: linebot-helper-go (Go 1.26, 1 vCPU / 1 GiB)"]
Mux["Go 1.26 net/http Server<br/>(HMAC 驗證 + 立即回傳 200 OK)"]
VoiceRelay["internal/voice<br/>Gemini Live BidiGenerateContent Relay"]
Orch["internal/agents (google.golang.org/adk v1.9.0)<br/>Root Orchestrator + 5 Sub-Agents"]
Loader["internal/loader<br/>HTTP -> Firecrawl -> SingleFile<br/>+ PDF/Office/EPUB 轉 Markdown"]
BatchSvc["internal/services/batch_service<br/>Gemini Batch API + JWKS/HMAC Webhook + 60s Poller"]
end
subgraph GCP["Google Cloud & AI 後端"]
Vertex["Vertex AI / Gemini Developer API<br/>(Search Grounding / Maps / Vision / TTS / Live)"]
FS[("Cloud Firestore<br/>chat_sessions | bookmarks<br/>reports | batch_jobs")]
end
LINE -->|"POST /"| Mux
LIFF <-->|"GET /ws/voice/{id}"| VoiceRelay
Mux --> Orch
Mux --> Loader
Mux --> BatchSvc
VoiceRelay <-->|"WebSocket"| Vertex
VoiceRelay -.->|"delegate_to_orchestrator"| Orch
Orch --> Vertex
Orch --> Loader
Orch <--> FS
BatchSvc <--> Vertex
BatchSvc <--> FS
二、五個在改寫過程中特別有感的工程設計
1. Vertex AI 與 Developer API 雙軌並行的 Gemini Client
在這個 LINE Bot 裡,並不是所有功能都走同一個 Gemini 端點:
- 一般對話、Grounding 搜尋、Google Maps Grounding、圖片分析:走 Vertex AI(使用 Cloud Run 服務帳號的 Application Default Credentials,不需 API Key)。
- Native TTS 語音合成(
gemini-3.1-flash-tts-preview)、Gemini Batch API 與 Gemini Live API WebSocket:走 Gemini Developer API(使用GOOGLE_AI_API_KEY)。 - Agentic Vision(
gemini-3-flash-preview+ Code Execution):優先走 Developer API,若未設定 Key 則自動 Fallback 回 Vertex AI。
在 Go 版 internal/gemini/client.go 中,我們把這兩條路徑收斂在同一個 gemini.Client 底下,連 TTS 吐回來的 Raw PCM(24kHz, 16-bit, mono)透過 ffmpeg 轉成 LINE 支援的 audio/mp4(.m4a)並計算音訊毫秒數,都在同一個模組裡一氣呵成。
2. 100% 相容 Python 版的 Firestore Schema(實現零停機切換的關鍵)
改寫服務最怕「切換過去後,使用者的對話記憶、存了一半的書籤或之前產生的研究報告網址全部失效」。
為此,我們在 internal/firestore 與 internal/services 嚴格對齊了 Python 版的 4 個 Firestore Collection 名稱與欄位結構:
chat_sessions:儲存多輪對話歷史與使用者最後傳送的 GPS 座標(last_location)。bookmarks:儲存/save書籤、社群文案候選書籤(status: candidate$\rightarrow$saved),以及 7 天快取的report_id與report_generated_at。reports:儲存 Grounding 深度研究報告渲染好的完整 HTML(支援/reports/{report_id}永久調閱)。batch_jobs:儲存 Gemini Batch API 任務 ID 與 LINEuser_id的映射。
這意味著 Python 版與 Go 版可以同時掛在線上讀寫同一個 Firestore。把 LINE Webhook 指過去的那一秒,所有舊書籤跟報告連結完全不受影響;萬一想切回 Python 版,也只需要改一行 Webhook URL。
3. 原生 Go 實作多格式文件轉 Markdown(告別笨重的 Python 文件套件)
Python 版用了 anydoc 等套件把 PDF、DOCX、PPTX、XLSX、ODT、EPUB、CSV、RTF 轉成 Markdown 再餵給 Gemini 摘要。這些套件在 Python 裡往往拉進一大串 C 擴充與相依套件。
到了 Go 版 internal/loader/doc_converter.go,因為 .docx、.pptx、.xlsx、.odt、.epub 本質上全部都是 ZIP 封裝的 XML 檔案,我們直接用 Go 標準庫的 archive/zip + encoding/xml 搭配輕量的 PDF 文字提取器,不到幾百行純 Go 程式碼就把所有主流辦公文件與電子書格式乾乾淨淨轉成結構化 Markdown,編譯速度快、執行期幾乎零額外記憶體開銷!
三、誠實面對架構:Python 版的「概念型 ADK」vs. Go 版真正導入 google.golang.org/adk v1.9.0
在檢查程式碼時,我們發現了一個很有趣的歷史痕跡:
如果你打開原本 Python 版 agents/orchestrator.py 的開頭,會看到註解寫著:
Orchestrator Agent - 基於 Google ADK 的主控 Agent
但如果你去翻 Python 版的 requirements.txt 與整包原始碼,會發現裡面其實根本沒有安裝 google-adk,也沒有任何一行 from google.adk import ...!當時的 Python 版只是「借用 ADK 的分層概念」,用純 Python Class 手刻了 Orchestrator、ChatAgent、ContentAgent 等類別,底層直接呼叫 google-genai。
這次改寫成 Go 版,我們決定玩真的——直接在 go.mod 引入 Google 官方最新的 google.golang.org/adk v1.9.0(搭配 google.golang.org/genai v1.57.0):
| 比較項目 | 原 Python 版 (linebot-helper-python) |
新 Go 版 (linebot-helper-go) |
|---|---|---|
| ADK 套件依賴 | ❌ 無(未安裝 google-adk) |
✅ google.golang.org/adk v1.9.0 |
| Agent 定義方式 | 自訂 Python Class | 使用 llmagent.New(llmagent.Config{...}) 建立 agent.Agent |
| Sub-Agents 階層 | 手動持有物件屬性 | Root orchestrator 透過 SubAgents: []agent.Agent{...} 掛載 5 個專職子 Agent |
| Tools 綁定方式 | 手動方法呼叫 | 使用 functiontool.New() 註冊型別安全的 Go 函式工具(load_url_content、summarize_youtube_video、search_nearby_places) |
| Model 介面 | 直接呼叫 google.genai.Client |
實作 google.golang.org/adk/model.LLM 與 adkgemini.NewModel(Vertex AI Backend) |
下一步:從「建立 ADK Agent 樹」走向「全面由 ADK Runner 驅動」(Issue #13)
為了確保第一版遷移 100% 通過所有既有測試案例,目前 ProcessText 仍保留了原本的快速意圖路由。但我們已經在 Issue #13 ([RFC] Google ADK (v1.9.0) 深度整合與架構演進提案) 規劃好接下來最讓人期待的 5 個 ADK 升級:
- 用
runner.Runner+SubAgents/agenttool取代硬編碼 Regex 路由:讓使用者在 LINE 輸入複合指令(如「幫我查這篇文章提到的餐廳在哪裡,順便存進書籤」)時,由 ADK 自動轉移或平行呼叫多個 Sub-Agent。 - 新增
BookmarkAgent與研究報告functiontool:使用者不必再背/save、/search斜線指令,直接用自然語言說「幫我找上次存過關於 MCP 的書籤並做成研究報告」就能觸發。 - 實作對接 Cloud Firestore 的
adk/session.Service:把現有的firestore.KVStore封裝成標準session.Service,讓 ADK Runner 原生管理跨輪次對話 Event 與狀態。 - 導入
workflowagents(sequentialagent/parallelagent):將深度研究報告拆解為「擷取 $\rightarrow$ Google Search 查證 $\rightarrow$ 報告撰寫」序列工作流;將 FB / LinkedIn / Threads 爆款文案改為平行 Sub-Agents 同步產出。 - 讓 LIFF 即時語音助手的
delegate_to_orchestrator直接串聯 ADK Runner。
四、Cloud Run 部署與瘦身:機器規格直接砍到 1/4!
完成全部 10 個核心套件與單元測試後(go vet ./... && go test ./... 全數通過),我們直接將 linebot-helper-go 部署上 Cloud Run。
來看看同一個 GCP 專案上,Python 版與 Go 版的容器資源配置對比:
| Cloud Run 服務 | 執行環境 | CPU | Memory | Concurrency | CPU 分配模式 | 相對資源成本 |
|---|---|---|---|---|---|---|
linebot-helper-python |
Python 3.11 + FastAPI | 4 vCPU | 4 GiB | 80 | Always allocated (--no-cpu-throttling) |
100% (基準) |
linebot-helper-go |
Go 1.26 原生編譯 | 1 vCPU | 1 GiB | 160 | Always allocated (--no-cpu-throttling) |
25% (立省 75%) |
你可能會問:Go 編譯出來的執行檔常駐記憶體才 20~30 MB,為什麼不乾脆開 512 MiB 就好,要留 1 vCPU / 1 GiB?
這兩個數字背後都有實際的工程考量:
- 為什麼是
1 vCPU? 因為 LINE Bot 需要在收到 Webhook 的瞬間秒回200 OK,並在背景 Goroutine 繼續跑 Gemini 生成、研究報告渲染,加上每 60 秒還有一個輪詢 Gemini Batch API 的背景 Ticker,所以必須開啟--no-cpu-throttling(CPU always allocated)。而在 Cloud Run 的規範中,開啟--no-cpu-throttling的最低 CPU 門檻就是1 vCPU。 - 為什麼是
1 GiB Memory? Go 程式本身確實不到 30 MB,但別忘了我們的網址爬蟲第 3 層 Fallback 在容器內裝了single-file+ Headless Chromium(用來對付連 Firecrawl 都擋的動態渲染網站),再加上語音訊息合成需要呼叫ffmpeg。當 Headless Chromium 偶爾被喚醒時,瞬間會吃掉 300~400 MB 記憶體。保留1 GiB既能確保 Chromium 啟動時絕不 OOM,又能把機器成本直接砍到原本 Python 版的 1/4!
部署指令非常乾淨:
gcloud run deploy linebot-helper-go \
--source . \
--project <YOUR_GCP_PROJECT_ID> \
--region us-central1 \
--cpu 1 \
--memory 1Gi \
--allow-unauthenticated \
--no-cpu-throttling
五、雲端部署最容易踩坑與遺漏的 7 個魔鬼細節
把 LINE Bot 從本機測試搬上 Cloud Run、或是從舊的 Python 服務切換到新的 Go 服務時,「程式編譯得過」往往只完成了 60%。真正的雷通常藏在 Cloud Run 的執行期生命週期、網路代理、容器沙盒與外部端點設定裡。
以下整理這次部署過程中最容易被遺漏的 7 個關鍵盲點:
遺漏 1:忘了加 --no-cpu-throttling,或是把 r.Context() 傳進背景 Goroutine
LINE Webhook 規範要求伺服器在收到事件後快速回傳 200 OK,而我們的重度任務(例如耗時 1~2 分鐘的 Grounding 深度研究報告、Agentic Vision 寫 Code 畫圖、語音轉錄加 TTS,以及每 60 秒輪詢一次的 Gemini Batch API Poller)全部都是先回 200 OK、再開 go func() 在背景跑完後用 PushMessage 推播。
這裡有兩個新手與老手都極容易踩到的坑:
- Cloud Run 預設會在 HTTP Response 送出瞬間「凍結 CPU」:
如果不加
--no-cpu-throttling,只要handleWebhookCallback一 return200 OK,Cloud Run 會在幾十毫秒內把容器 CPU 降到接近 0。這時你的背景 Goroutine 就會像被按了暫停鍵一樣卡在半空中,直到幾分鐘後下一個 HTTP 請求進來才跟著動幾步,使用者永遠收不到推播! - 背景 Goroutine 絕對不能沿用
r.Context(): 在 Go 的net/http中,r.Context()會在 HTTP Handler 結束(送出200 OK)的那一刻立刻觸發Canceled!如果你順手寫了go s.handleMessageEvent(r.Context(), event),背景正在呼叫 Vertex AI 或 Firestore 的請求會直接死在context canceled錯誤。
// ❌ 錯誤寫法:r.Context() 在 return 200 OK 瞬間就會被 cancel,背景任務必死
go s.handleMessageEvent(r.Context(), eventCopy)
// ✅ 正確寫法:脫離 HTTP Request 生命週期,改用獨立的 Background Context(內部再依任務設 Timeout)
go s.handleMessageEvent(context.Background(), eventCopy)
遺漏 2:Vertex AI GOOGLE_CLOUD_LOCATION=global 的網域拼接陷阱
在沿用舊版環境變數時,我們看到 GOOGLE_CLOUD_LOCATION=global(因為許多 Gemini 3.x Preview 模型在 Vertex AI 是掛在 global Location 下)。
如果你在寫 Go 的 Vertex AI REST Client 時,直接照一般區域的習慣用 fmt.Sprintf("https://%s-aiplatform.googleapis.com", location),當 location 為 "global" 時,拼出來的網址會變成:
❌
https://global-aiplatform.googleapis.com(這個網域在 GCP 根本不存在,DNS lookup 會直接失敗!)
在 Vertex AI 的規範中,只有區域端點(如 us-central1、asia-east1)才有前綴;當 Location 為 global 時,必須切換為無前綴的 https://aiplatform.googleapis.com:
func (c *Client) vertexEndpointBase() string {
if c.VertexBaseURL != "" {
return strings.TrimRight(c.VertexBaseURL, "/")
}
if c.Location == "" || c.Location == "global" {
return "https://aiplatform.googleapis.com"
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com", c.Location)
}
遺漏 3:用 --set-env-vars 搬移環境變數導致特殊字元截斷或密鑰外洩
要把 linebot-helper-python 線上十幾個環境變數原封不動搬到 linebot-helper-go 時,千萬不要手拼 --set-env-vars="KEY1=VAL1,KEY2=VAL2...":
- 只要 किसी Token 或 Prompt 字串裡帶有逗號
,或等號=,gcloud的參數解析就會切錯位置甚至直接報錯。 - 把環境變數直接攤在指令列參數裡,整串 Secret 就會留在終端機歷史紀錄或對話 Log 中。
最穩妥的做法:直接透過 gcloud run services describe --format=json 在本機背景轉出暫存的 /tmp/env.yaml,部署時用 --env-vars-file=/tmp/env.yaml 餵進去,部署完立刻 rm -f /tmp/env.yaml。全程既不會印出任何一把 Secret,也能 100% 保留所有特殊字元。
遺漏 4:忽略 Cloud Run 反向代理的 X-Forwarded-Proto,導致 LINE 拒收圖片與音檔
這個 LINE Bot 有四個地方需要動態組出「指向自己伺服器的公開網址」:
- 研究報告頁面:
https://<host>/reports/{report_id} - Agentic Vision 標註圖片:
https://<host>/images/{image_id} - TTS 語音合成音檔:
https://<host>/audio/{audio_id} - Gemini Batch API Dynamic Webhook:
https://<host>/api/gemini-callback/dynamic
要注意的是:LINE Messaging API 的 ImageMessage 與 AudioMessage 強制要求網址必須是 https://。而在 Cloud Run 架構下,HTTPS TLS 在外層的 Google Front End (GFE) 就已經終止了,進到容器內 Go http.Request 時 r.TLS 是 nil。如果你沒有去讀 X-Forwarded-Proto 與 X-Forwarded-Host 標頭,組出來的 http:// 網址傳給 LINE Push API 時會直接被拒絕!
遺漏 5:Go 追求極簡映像檔(scratch / distroless)卻忘了外部執行檔與 Chromium 沙盒限制
寫 Go 服務時,我們很習慣用 FROM gcr.io/distroless/static 把 Docker Image 壓到 20MB 以下。但在這支多功能 LINE Bot 上這樣做會立刻撞牆:
- 缺少
ffmpeg:TTS 語音合成收到 Gemini 回傳的 PCM 後呼叫exec.Command("ffmpeg", ...)轉.m4a會直接報executable file not found。 - 缺少 Headless Chromium、中文字型與容器沙盒旗標:
爬蟲第 3 層 Fallback 使用
single-file驅動 Headless Chromium。在 Cloud Run 的容器沙盒環境裡,如果不給 Chromium 加上--no-sandbox、--disable-gpu與--disable-dev-shm-usage(因為容器預設的/dev/shm共享記憶體很小),或者基底映像檔沒裝fonts-noto-cjk(抓下來的中文網頁亂碼)與ca-certificates(HTTPS 憑證驗證失敗),爬蟲在雲端就會無聲無息地 Crash。
因此我們的 Dockerfile 採用 golang:1.26-bookworm 編譯後,Runtime 階段選用 debian:bookworm-slim 並明確安裝 ca-certificates、ffmpeg、chromium、fonts-noto-cjk 與 @single-file/cli。
遺漏 6:In-Memory 暫存與 Cloud Run 自動水平擴縮(Multi-Instance)的狀態分裂
雖然我們把對話歷史、書籤、研究報告與 Batch Job 映射這四大核心狀態都持久化到了 Cloud Firestore,但有幾項「生命週期只有 5 分鐘」的二進位資料是放在 Go 記憶體 map 裡的:
- 使用者剛上傳、正在等待點選「識別圖片 / Agentic Vision」Quick Reply 的圖片(
imageTempStore) - 剛畫好標註框、等待 LINE 伺服器來拉取的 PNG(
/images/{image_id}) - 剛合成完、等待 LINE 伺服器來拉取的 M4A 音檔(
/audio/{audio_id})
這在單一實例下完全沒問題,但一旦 Cloud Run 因為瞬間流量擴展成 2 個以上的 Instance,就會發生狀態分裂:
- 使用者傳圖片打到 Instance A(圖片存在 A 的記憶體)。
- 3 秒後使用者按下「Agentic Vision」按鈕,Webhook 打到 Instance B $\rightarrow$ B 找不到圖片,直接回覆「⚠️ 圖片已過期,請重新傳送」!
- 或者 Instance A 產出
/audio/{id}並呼叫 LINE Reply API,結果 LINE 的媒體伺服器來下載音檔時打到 Instance B 拿到404,使用者在聊天室裡就只會看到一個無法播放的語音泡泡。
解法:因為 Go 單一實例就能輕鬆扛起高併發(我們將 containerConcurrency 設為 160),對於個人或中小型助理,建議將 --max-instances 限制為 1(若未來需要多實例高可用,則必須將 /images 與 /audio 的暫存改寫入 Cloud Storage 或 Firestore)。
遺漏 7:切換時只改了 Messaging API Webhook,卻漏了 Firebase Hosting WebSocket 限制與 LIFF Endpoint
準備從舊服務切到新服務時,最常遺漏以下三個外圍設定:
| 檢查點 | 為什麼容易遺漏? | 實際影響與正確做法 |
|---|---|---|
| 1. 現有 Webhook 是否躲在 Firebase Hosting 後面? | 我們透過 LINE API (GET /v2/bot/channel/webhook/endpoint) 查詢現有設定時,才發現原本的 Webhook 指在 *.firebaseapp.com(由 firebase.json rewrite 到 Cloud Run)。 |
Firebase Hosting 不支援 WebSocket 代理! 如果 LIFF 語音助手(/ws/voice/{id})走 Firebase Hosting 網域,WebSocket 握手會直接失敗。因此切換時應直接指向 Cloud Run 網址,或確保 LIFF 走直連。 |
| 2. LINE Login Channel 的 LIFF Endpoint URL | LIFF App 的設定不在 Messaging API Channel,而是放在同一個 Provider 底下的 LINE Login Channel 分頁裡。 | 只改 Webhook 沒改 LIFF Endpoint 的話,聊天室打字是新的 Go 版在回,但點開語音助手卻還是連回舊的 Python 版。 |
| 3. Gemini Batch API 的靜態 Webhook / Eventarc | 若在 GCP 有設定 Eventarc 或專案級靜態 Webhook 指到 /api/gemini-callback/static。 |
記得一併更新目標網址(而 Dynamic Webhook /api/gemini-callback/dynamic 則會自動抓取進站 Request 的 Host,無須手動修改)。 |
六、切換前盤點:從 GCP Log 抓出三個「殭屍端點」,順便規劃收編 n8n 排程!
在撰寫 README 的「從 Python 版切換」步驟時,原本列了一條:
「把呼叫
/hn、/hf、/urls的外部排程改指向新服務。」
但我轉念一想:等等,這三個端點現在真的還有被我的 n8n 呼叫嗎?
我們立刻用 gcloud logging read 撈了 GCP 專案裡 linebot-helper-python 與 n8n 服務的執行紀錄,結果發現了一個大驚喜:
/hn、/hf、/urls是 2024 年 11 月 初版寫的端點,當時是讓外部排程把網址陣列 POST 進來給 LINE Bot 代為摘要並推播。- 但現在雲端上的
n8n服務(跑著 「RSS to Sheet」、「每日工作(抓論文/分享影片)」、「Daily LINE Sender」 與 「AI 趨勢自動化電子報」 四支 Active Workflows),早就已經改成在n8n內部自己跑 Firecrawl、自己呼叫 Gemini 摘要、自己組裝 Flex JSON 並直接打https://api.line.me/v2/bot/message/push! - 換句話說,
linebot-helper-python上的/hn、/hf、/urls過去幾個月來自n8n的呼叫次數是 0!
更有趣的是,在檢查 n8n 的 Debug Log 時,我們還意外抓到 n8n 在每天早上 6 點推播 YouTube 新影片摘要時,曾經因為 Javascript 節點組出來的 Flex Carousel 字數或縮圖格式問題,被 LINE API 回傳:
Workflow execution finished with error: A message (messages[0]) in the request body is invalid
這個發現讓我們立刻做了兩個決定:
決定 1:立刻砍掉 linebot-helper-go 裡的 /hn、/hf、/urls
既然沒有任何外部服務在呼叫,留在公開的 Cloud Run 上反而只是多三個沒有簽章保護的攻擊面。直接從 server.go 移除後重新部署,線上打 /hn、/hf、/urls 全部回傳 404 Not Found,切換時也只需要在 LINE Developers Console 改 Webhook URL 跟 LIFF Endpoint URL 兩個地方就好!
決定 2:開立 Issue #14 ~ Issue #19,把 n8n 的工作流收編進 linebot-helper-go!
既然 linebot-helper-go 已經是一台常駐 1 vCPU / 1 GiB(--no-cpu-throttling)的極速 Go 伺服器,裡面又有現成的三層爬蟲 loader.Loader、強型別 LINE Flex Builder、Gemini Batch API、Firestore BookmarkService 與 ReportStore,為什麼還要另外養一台 n8n + Postgres 資料庫?
更重要的是——如果把這些每日排程全部改由 linebot-helper-go 透過目前同一個 LINE 官方帳號推播,就能打通「推播 $\rightarrow$ 互動」的完美閉環:
- Issue #14 (
Batch Task 1/5):建立受保護的POST /api/tasks/{task_name}排程端點与 Firestorefeed_items去重佇列(取代原本用 Google Sheets 比對 Row 的做法),統一使用主官方帳號推播。 - Issue #15 (
Batch Task 2/5):每日 10:00 抓取 Hugging Face Daily Papers,在推播論文摘要卡片時直接附上 「🔖 儲存書籤」 與 「📄 詳細研究報告」 按鈕,看到好論文一鍵就能生成 Grounding 深度網頁報告。 - Issue #16 (
Batch Task 3/5):每日 06:00 巡檢 YouTube 追蹤頻道新片,用 Go 強型別 Flex Builder 徹底解決n8n的400 Invalid Request Body問題,並在卡片直接附上 「📝 詳細摘要」 Postback 按鈕。 - Issue #17 (
Batch Task 4/5):整合 HN、Hatena IT、Google Dev Blog、LY Blog、Steam 等多來源 RSS 巡檢與每日 06:00 精選推播。 - Issue #18 (
Batch Task 5/5):每週五 17:00 彙整當週精選內容與書籤,直接渲染成/reports/{id}精美網頁版 AI 趨勢週報 推播至 LINE。
七、最後一個資安提醒:別把 Cloud Run 真實網址留在公開 Git 歷史裡
在寫完部署紀錄與切換教學後,我做了一件很重要的收尾工作:把 README.md 與 GitHub Issue 留言裡所有出現過的 .run.app 真實服務網址、GCP Project Number 與 LIFF ID 全部換成 <YOUR_CLOUD_RUN_SERVICE_URL> 佔位符,並重寫 Git Commit 歷史消除痕跡。
原因很簡單:LINE Bot 的 Cloud Run 服務因為要接收 LINE 平台的 Webhook,必須設定為 --allow-unauthenticated。雖然我們的 POST / 有嚴格驗證 X-Line-Signature(沒有 Channel Secret 簽章一律擋在門外回 400),但把真實的 .run.app 網址直接寫在公開 GitHub Repo 的 README 上,還是很容易引來無謂的自動化爬蟲掃描與打流量的噪音。
公開程式碼分享架構與實作,真實端點只留在自己的設定後台,這才是長久維護個人 AI 助理最安心的做法。
八、Cloud Run 部署與遷移自我檢查清單(Checklist)
如果你也打算把現有的 Python LINE Bot 改寫成 Go 並搬上 Cloud Run,切換流量前可以用這份清單逐項打勾:
- 1. CPU 節流設定:只要有背景 Goroutine(Webhook 秒回
200 OK後背景推播、或是背景定時 Ticker),務必加上--no-cpu-throttling(且 CPU 至少設為1)。 - 2. Context 生命週期解耦:背景 Goroutine 禁止傳入
r.Context(),一律改用context.Background()搭配獨立的context.WithTimeout。 - 3. Vertex AI
global網域判斷:若GOOGLE_CLOUD_LOCATION=global,確認 Endpoint Base URL 為https://aiplatform.googleapis.com而非global-aiplatform.googleapis.com。 - 4. 外部二進位工具與字型:若容器內有呼叫
ffmpeg、single-file或 Headless Chromium,確認最終階段 Docker Image 包含對應執行檔、ca-certificates、fonts-noto-cjk以及 Chromium 的--no-sandbox --disable-dev-shm-usage參數。 - 5. 反向代理 HTTPS 標頭:動態產生
/images/{id}、/audio/{id}、/reports/{id}網址時,優先讀取X-Forwarded-Proto與X-Forwarded-Host,確保傳給 LINE 的媒體網址必為https://。 - 6. In-Memory 暫存與 Instance 數量:若有短時效記憶體 map(如暫存圖片/音檔),建議設
--max-instances=1並調高concurrency,或改存 GCS / Firestore,避免多實例狀態分裂。 - 7. 雙通道 LINE 設定同步:除了更新 Messaging API 的 Webhook URL 外,別忘了更新同 Provider 下 LINE Login Channel 的 LIFF Endpoint URL,並確認 WebSocket 路徑沒有被不支援 WS 的 CDN / Firebase Hosting 攔截。
- 8. 清理廢棄端點與公開資訊:從 Cloud Logging 確認無用的舊端點(如
/hn、/hf、/urls)並直接移除,且確保公開 Repo 與 Git 歷史紀錄中不包含真實.run.app網址與密鑰。
小結與開源專案連結
這次從 kkdai/linebot-helper-python 到 kkdai/linebot-helper-go 的改寫之旅,幾個最值得帶走的收穫:
- 先開 Issue 再寫扣:11 個里程碑 Issue 讓大型語言改寫變得高度可控,每個模組都能獨立跑過單元測試再往上疊。
- 共享資料層是零停機遷移的底氣:只要 Firestore Collection Schema 對齊,新舊兩個語言的服務就能隨時無痛切換。
- Go 在 Cloud Run 上的資源效率極高:同樣掛載 Headless Chromium 與
ffmpeg,從 Python4 vCPU / 4 GiB換成 Go1 vCPU / 1 GiB,帳單直接打 25 折,回應速度還更快。 - 切換前先看 Cloud Logging:真實的流量 Log 不會騙人,一眼就能看出哪些端點早已退役、哪些外部
n8n工作流其實更適合收編進同一個服務裡。
完整程式碼與後續的 ADK v1.9.0 / Batch Tasks 演進計畫都已開源在 GitHub:kkdai/linebot-helper-go,歡迎參考與交流!
- Golang (80) ,
- Go 1.26 (1) ,
- LINEBot (40) ,
- Cloud Run (19) ,
- ADK (12) ,
- Gemini (38) ,
- Firestore (6) ,
- GCP (14) ,
- Gemini Live API (4)