執行環境生命週期與 Cold Start 的解剖
FaaS 不是「每次呼叫生一個用完即丟的行程」,而是一台經歷 INIT、多次 INVOKE 凍結與解凍、最後 SHUTDOWN 的隔離微虛擬機。
在運算模型光譜篇,我們看過 VM、編排容器、Serverless 容器與 FaaS 四個刻度的責任邊界與月費交叉點。當決定採用 AWS Lambda 或 Google Cloud Run functions 時,真正決定系統尾延遲(p99)、吞吐上限與下游資料庫生死的,不再是作業系統排程,而是執行環境(Execution Environment)的生命週期與並發模型。
以 AWS Lambda 為例,每個執行環境跑在獨立的 Firecracker microVM 內,生命週期嚴格分為三個階段:INIT、INVOKE 與 SHUTDOWN。只有當現有已初始化的環境都在忙碌、或環境因閒置與定期輪替被回收時,新請求才會觸發完整的冷啟動(Cold Start);隨後的請求只要落入已就緒的環境,就會走解凍重用的暖啟動(Warm Start)。
sequenceDiagram
participant R as 呼叫端 / 事件源
participant C as Lambda 控制平面
participant E as 執行環境 (microVM)
R->>C: 第 1 次請求 (無可用暖環境)
C->>E: 配置 microVM + INIT (Extension -> Runtime -> Function 靜態碼)
C->>E: INVOKE (執行 handler)
E-->>R: 回應結果 (計費含 Init Duration + Duration)
Note over E: 凍結 (Freeze):背景計時器與執行緒全部暫停
R->>C: 第 2 次請求 (命中暖環境)
C->>E: 解凍 (Unfreeze) + INVOKE (跳過 INIT)
E-->>R: 回應結果 (僅收本次 Duration)
Note over C,E: 閒置逾時後發送 SHUTDOWN (預留最多 2 秒供 Extension 刷出遙測)
冷啟動慢在 INIT 的三步串列初始化;暖重用跳過 INIT 直接進入 INVOKE,但兩次呼叫之間環境處於凍結狀態,不可依賴背景非同步工作。
INIT 階段的三個子步驟與 2025/2026 計費變革
當新環境啟動時,INIT 依序執行三件事:啟動所有外部監控與安全性代理(Extension init)、拉起語言執行期(Runtime init,如 JVM、Node.js V8 或 Python 解譯器),最後執行 handler 函式以外的全域靜態程式碼(Function init,如載入大型 SDK、初始化資料庫連線池或讀取參數)。
- INIT 階段全面納入計費(2025 年 8 月起標準化):早期 AWS Lambda 對使用受管執行期(Managed Runtime)與 ZIP 封裝的 on-demand 函式,不會將
Init Duration計入帳單;但自 2025 年 8 月起,Lambda 已將計費規則統一——不論是 ZIP、OCI 容器映像、自訂 Runtime 或 Provisioned Concurrency,冷啟動的Init Duration一律加進Billed Duration按 GB·s 收費。把肥大的依賴樹塞在全域載入,現在不只傷 p99 延遲,還會直接拉高冷啟動帳單。 - 10 秒 INIT 超時與雙重計費陷阱:標準 on-demand 呼叫的
INIT階段硬上限是 10 秒。若靜態初始化在 10 秒內沒跑完,Lambda 會強制中止並緊接著在第一次INVOKE內以函式自身的 timeout 重跑一次INIT——兩次嘗試的時間都會被計入帳單。 - 跨呼叫重用與凍結邊界:全域變數與
/tmp暫存目錄(可配置 512 MB 至 10,240 MB)在同一個執行環境的多次 warm invocation 之間會保留。然而,一旦handler回傳,環境立即被凍結(如同收到SIGSTOP):任何未await的背景 Promise、未刷出的非同步 log 或計時器都會停擺,直到下一次呼叫解凍才突然吐出,造成跨請求的日誌與狀態污染。
全域變數只適合放「丟掉也能重建」的資源(例如 SDK client、連線池、唯讀設定快取)。你永遠無法保證下一筆請求會落進同一個執行環境,也無法阻止平台在背景輪替基礎設施時直接銷毀環境。
消弭冷啟動的兩條路:Provisioned Concurrency 與 SnapStart
一個是用常駐預算換取預熱好的活環境,一個是在發佈版本時把初始化後的記憶體拍成快照。兩者不能混用,各自有不同的失效邊界。
當裁剪依賴套件、改用延遲載入(Lazy Initialization)仍壓不下 JVM 或大型框架動輒數秒的冷啟動時,平台提供兩條截然不同的架構路徑:Provisioned Concurrency(預置並發,對應 Cloud Run 的 min-instances)與 Lambda SnapStart。
beforeCheckpoint,拍下加密記憶體/磁碟快照afterRestore + INVOKE三種路徑把 INIT 的時間成本放在不同時刻:On-Demand 放在使用者請求當下、Provisioned Concurrency 放在常駐待命帳單、SnapStart 放在版本發佈時的快照。
Provisioned Concurrency(PC)的成本折衷與溢出行為
Provisioned Concurrency 會在流量進來前就建立指定數量的執行環境並跑完 INIT。它的優勢是完全不挑語言與框架,且預熱環境可搭配 Application Auto Scaling 依排程或利用率調整。但設計時有兩個常被忽略的限制:
- 溢出(Spillover)不是排隊,而是退回冷啟動:若設定
PC = 100,瞬間湧入 140 個並發請求,前 100 個命中預熱環境、零冷啟動,但多出的 40 個會立即以標準 On-Demand 模式拉起新環境——也就是那 40 個請求依然會吃到完整冷啟動。 - 60% 利用率分水嶺:因為預置環境全天候收費,AWS 官方實務建議在該預置量能維持 60% 以上穩定利用率時才最具成本效益;若流量極度稀疏卻為了消弭偶發冷啟動而長開 PC,就失去了 FaaS 縮到零的計費優勢。
SnapStart 的快照還原原理與三大面試考點
Lambda SnapStart 採用 Firecracker 虛擬機快照技術:在發佈函式版本(Publish Version)時啟動一個環境跑完 INIT,觸發 beforeCheckpoint hook,接著將整個 microVM 的記憶體頁與磁碟狀態凍結成加密快照並放入低延遲快取。之後每當需要擴縮新環境時,直接從快照 Restore 記憶體狀態並執行 afterRestore hook,把數秒的類別載入縮短至數百毫秒內。
| 比較維度 | Provisioned Concurrency (PC) | Lambda SnapStart | Cloud Run min-instances |
|---|---|---|---|
| 核心機制 | 預先拉起並維持活著的執行環境 | 發佈時執行 INIT 並拍下 Firecracker 快照,擴縮時還原 | 預先維持常駐容器實例(單實例可接多並發) |
| 支援 Runtime | 全部 Runtime 與容器映像 | Java、Python、.NET 受管執行期(不支援容器映像) | 全部語言與自訂容器 |
| 計費結構 | 預置時間(每 GB·s)+ 請求 + 執行時間 | Java 免收快照費;Python / .NET 收快照快取與每次還原費 | 閒置實例收低費率或依 instance-based 計價 |
| 互斥與硬限制 | 不可與 SnapStart 同版本並用 | 不可與 PC、EFS、或大於 512 MB 的 /tmp 並用 |
需自行確保實例內多執行緒/並行請求安全 |
SnapStart 解決了「突發擴縮不想付常駐待命費」的痛點,但把狀態複製的副作用留給了工程師。
因為所有新環境都是從同一份記憶體快照複製(Clone)出來的,任何在 INIT 階段生成的隨機數種子(PRNG seed)、預先產生的 UUID、或是暫存的短期 IAM 憑證,在數百個還原環境中都會一模一樣;同時,在 INIT 建立的長 TCP 連線在數小時後還原時也早已失效。使用 SnapStart 必須遵守兩條鐵律:(1) 唯一 ID 與隨機數一律放進 handler 或 afterRestore 重新播種;(2) 網路連線在 afterRestore 驗證或重建。
afterRestore 處理隨機數種子重複與過期連線。
並發模型、擴縮速率與配額數學
標準 AWS Lambda 一個環境同時只接一個請求;Cloud Run functions 一個實例可接數十至上千個並行請求。兩者差了一個並發除數 k。
在系統設計面試中估算 FaaS 容量,起點永遠是 Little's Law:系統同時在途的請求數 L,等於每秒到達率 λ(rps)乘上單次平均執行時間 W(秒)。如果每個執行環境能同時處理 k 個請求,所需的活躍環境數 E 就是:
每個 microVM 在同一時刻只處理 1 個請求。當 λ = 600 rps、每次呼叫等下游 API 或資料庫 W = 0.4 秒,系統同時需要 600 × 0.4 = 240 個獨立執行環境。好處是請求之間具備硬體級隔離、不會因隔壁請求 OOM 而陪葬;代價是所有 I/O 等待時間都一比一放大成環境數、冷啟動次數與下游連線數。
Cloud Run functions(第 2 代起底層即為 Cloud Run service)預設每實例並發為 80 × vCPU(最高可設 1,000);AWS 亦在 Lambda Web Functions 與 Managed Instances 引入單環境多並發。同樣 L = 240 個在途請求,若每實例可並行處理 k = 40 個 I/O 請求,只需 ceil(240 ÷ 40) = 6 個實例,冷啟動與連線數直接縮小 40 倍,但程式必須具備並行安全且共享故障半徑。
k = 1 把並行問題交給平台開更多 microVM;k > 1 把並行交回容器內的事件迴圈或執行緒池,換取極低的冷啟動與連線開銷。
「帳號總並發配額」與「每 10 秒擴縮速率」是兩把不同的尺
許多工程師以為把 AWS 帳號的區域並發配額(Account Concurrency Quota,預設 1,000,可申請調高至數萬)拉高,就能無上限吸收瞬間洪峰。事實上,AWS Lambda 對每一個 Event Function 還有一道獨立的擴縮速率上限(Concurrency Scaling Rate):
- 每函式每 10 秒最多新增 1,000 個執行環境(或相當於每 10 秒增加 10,000 rps 的處理能力)。這條限制是 per-function 的,不會因為別的函式閒置就借給你,也不會因為帳號總配額調高到 10,000 就自動消失。
- 冷啟動期間的「峰值環境膨脹」:在突發流量的第一波,尚未預熱的環境必須先跑完 Tinit 才能進入 W,代表該環境被佔用的總時間變成 Tinit + W。若 W = 400 ms 而冷啟動 Tinit = 800 ms,冷啟動期間每個新環境的周轉率只有暖環境的三分之一,瞬時並發環境需求會短暫膨脹為穩態的 1 + Tinit / W = 3 倍!一旦超過首個 10 秒視窗可供給的
PC + 1,000個環境,多出的同步請求就會收到429 TooManyRequestsException。 - Reserved Concurrency(預留並發)的雙面刃:Reserved Concurrency 同時扮演「保底配額」與「硬上限(Hard Cap)」。替關鍵函式預留 200 並發,代表另外 800 個帳號配額留給其他函式,但也代表這支關鍵函式自己永遠不能超過 200 並發;且 Reserved Concurrency 只切配額、完全不會預熱環境(設為 0 則常作為緊急斷路開關)。
根據 L = λ × W,當請求到達率 λ 完全沒變(例如維持 500 rps),只因下游支付閘道或資料庫延遲從 100 ms 退化到 2,000 ms,Lambda 的並發環境數就會從 50 瞬間暴漲 20 倍到 1,000,直接吃乾整個帳號的並發配額!這就是為什麼 FaaS 呼叫外部依賴時,必須設定嚴格的短 Timeout 與失敗快速退回,絕不能放任函式乾等 15 分鐘。
資料庫連線風暴與 RDS Proxy 收斂架構
幾百個瞬間拉起的 microVM 各自握手一條資料庫連線,會在抵達運算配額前先把 PostgreSQL / MySQL 的 max_connections 擊穿。
在傳統常駐容器架構中,20 個 Pod 各自維護一個 max = 10 的本地連線池,資料庫最多只會看到 200 條連線;當流量暴增時,請求是在應用程式記憶體裡的連線池排隊。但在標準 AWS Lambda(k = 1)中,每個執行環境同一時間只跑 1 個請求,環境之間完全不共享記憶體與連線池——600 個並發環境就意味著 600 個獨立行程各自向資料庫發起 TCP + TLS + 認證交握,並把連線握在全域變數裡直到環境銷毀。
RDS Proxy 擋在數百個短命或凍結的執行環境前面:只有正在執行 SQL 交易的那幾十毫秒才借用後端資料庫連線,其餘時間立即歸還池中。
三種收斂連線風暴的工程手段與「連線釘住(Pinning)」陷阱
- 部署外部交易級連線池(RDS Proxy / PgBouncer):Lambda 雖然維持與 Proxy 的連線,但 Proxy 對後端資料庫採交易級複用(Transaction-level Multiplexing)。若一次 400 ms 的函式呼叫只有 25 ms 真正在跑 SQL,理論複用倍率可達 16 倍。但務必小心 Session Pinning(連線釘住):只要程式碼執行了改變工作階段狀態的指令(如
SET變更時區或參數、使用暫存表、開啟未關閉的 Prepared Statement),RDS Proxy 為了安全就無法把該連線借給別的環境,複用倍率瞬間退化回 1:1。 - 提高每實例並發度(Cloud Run functions 或 Lambda Managed Instances):當單一容器實例同時承接 k = 40 個請求,這 40 個請求就能像傳統微服務一樣共用實例內的同一個連線池,從源頭把實例數與連線池總數除以 k。
- 改用無狀態 HTTP 資料層或上游限流:DynamoDB 走無狀態 HTTPS API,天生沒有 TCP 連線池上限問題;若後端是關聯式資料庫且無法承受高並發,則應以 SQS 佇列削峰加上
Reserved Concurrency(或 Event Source Mapping 的MaximumConcurrency)把同時寫入資料庫的環境數鎖在安全範圍內。
以下模型以 Little's Law(L = λ × W)為核心,並計入冷啟動期間未預熱環境因 INIT 佔用時間拉長為 W + Tinit 所造成的瞬時環境膨脹、AWS Lambda 每 10 秒最多擴增 1,000 個環境的速率限制,以及對後端資料庫(設 max_connections = 200)的連線衝擊。
試著將「資料庫連線收斂機制」切到「直連 DB(1:1)」,或把執行時間 W 從 400 ms 拉到 1,200 ms:即使到達率不變,冷啟動期間膨脹的環境數也會立刻把 200 條資料庫連線衝爆。
非同步事件承接、部分批次失敗與冪等鐵律
FaaS 承接佇列與事件流時,平台內建的重試與批次拉取保證的是 At-Least-Once;沒有開啟部分批次失敗回報與冪等保護,一筆壞資料就會重播整批交易。
佇列本身的語義(Visibility Timeout、DLQ)與事件驅動的服務拆分(Outbox、Choreography),分別在訊息佇列基礎篇與事件驅動架構篇討論過。本節聚焦於 FaaS 執行環境在最末端如何承接這些呼叫——因為呼叫模式不同,重試主體與失敗邊界完全不同。
| 呼叫模式 | 典型上游來源 | 誰負責重試與退避? | FaaS 端的關鍵防護設計 |
|---|---|---|---|
| 同步呼叫 (Synchronous) | API Gateway、ALB、Cloud Run HTTP 觸發 | 平台不自動重試,直接回傳錯誤給呼叫端 | API 層帶 Idempotency-Key,控制 INIT 與下游 Timeout 避免觸發 29 秒/60 秒閘道逾時 |
| 非同步呼叫 (Asynchronous) | EventBridge、S3 事件、SNS(推入 Lambda 內部佇列) | Lambda 內建佇列自動重試最多 2 次(共 3 次執行,含退避) | 設定 MaximumEventAgeInSeconds 與 OnFailure Destination(保留完整錯誤上下文) |
| 事件源映射 (Event Source Mapping) | SQS、Kinesis、Managed Kafka(由 Lambda Poller 批次拉取) | 整批拉取,失敗回退至來源佇列/分片重試直到過期或入 DLQ | 必開 ReportBatchItemFailures + 外部冪等表 + 限制 MaximumConcurrency |
最容易出事的是第三類 Event Source Mapping:Poller 幫你把 10 筆訊息包成一個 Batch 餵進同一次 INVOKE,預設只要 1 筆拋出例外,整批 10 筆都會被當成失敗重試。
Partial Batch Response 與為何「開了部分失敗回報仍必須冪等」
當 Lambda 透過 Event Source Mapping 消費 SQS 或 Kinesis(例如 batchSize = 10)時,若第 7 筆訊息因格式錯誤或下游暫時超時而讓 handler 拋出例外,預設行為是整批 10 筆訊息都不刪除,待 Visibility Timeout 到期後,前 6 筆已經成功扣款或發信的訊息會被重新投遞再跑一次。
flowchart LR
B[Poller 拉取 10 筆訊息] --> H[Handler 逐筆處理]
H --> C{開啟 ReportBatchItemFailures?}
C -->|未開啟: 拋出例外| A[整批 10 筆退回重試]
C -->|已開啟: 回傳失敗 ID 清單| P[前 6 筆刪除, 僅失敗項重試]
P --> I[下游仍須以 event_id 做冪等檢查]
開啟 ReportBatchItemFailures 能擋掉「單筆失敗拖累同批成功項」,但擋不掉「第 6 筆剛寫入資料庫、函式卻在回傳前 OOM 或 Timeout」的跨次重試。
因此,在 FaaS 處理非同步事件時,必須同時落地兩道防線:
- 啟用
ReportBatchItemFailures(Partial Batch Response):捕捉單筆訊息的例外,將失敗的itemIdentifier(如 SQSmessageId)放入batchItemFailures陣列回傳,讓成功處理的訊息正常確認刪除。 - 以業務冪等鍵(Idempotency Key)守住寫入端:就算開啟了部分批次失敗回報,如果函式在寫完資料庫之後、回傳 JSON 給 Lambda Runtime 之前剛好撞上函式 Timeout 或容器崩潰,Lambda 無法收到回傳清單,依然會把整批訊息退回重試。寫入下游時必須攜帶唯一事件 ID(例如在 DynamoDB 使用
ConditionExpression: "attribute_not_exists(event_id)"搭配狀態與 TTL,或在關聯式資料庫以唯一索引做INSERT ... ON CONFLICT DO NOTHING)。
當 SQS 堆積數萬筆訊息時,Lambda Event Source Mapping 預設會以每分鐘擴增數十個並發的速度一路拉高到 1,000 並發,瞬間把下游慢速 API 或資料庫打垮;若你在函式上把 Reserved Concurrency 設小,又會導致 Poller 拉了訊息卻因函式自身被 429 節流而直接把訊息推往 DLQ。正確作法是在 SQS Event Source Mapping 上設定 MaximumConcurrency——它直接限制 Poller 最多只用幾個執行環境拉取,不會產生假性節流與誤入 DLQ。
ReportBatchItemFailures 避免單筆毒丸重播整批、在 Event Source Mapping 設定 MaximumConcurrency 保護下游,並以持久化冪等鍵處理 Timeout 造成的重試。