Skip to main content

Docker多階段構建

· 3 min read

從文章Docker鏡像分層與層共享中我們知道,Docker鏡像是由一層一層疊起來的,每一層對應Dockerfile中的一條指令。這些層都是唯讀的,構建完成後不可以修改。

問題

構建時候需要用到的東西,運行時候不需要,一個典型的python應用,構建需要

  • gcc/build-essential
  • pip
  • .h文件
  • 測試依賴

但是運行的時候只需要

  • python解釋器
  • 裝好的包
  • 你的代碼

先看沒有優化的鏡像有多大

假設現在有一個FastAPI應用:

myapp/
├── main.py
├── requirements.txt
└── Dockerfile

requirements

fastapi==0.110.0
uvicorn==0.29.0
pandas==2.2.0 #觸發了gcc的安裝
numpy==1.26.4
scikit-learn==1.4.0

最常見的寫法,能跑就行

FROM python:3.11

WORKDIR /app

COPY requirements.txt .

RUN pip install -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

這是本項目構建完後可能出現的大小

$ docker build -t myapp:naive .
$ docker images myapp:naive

REPOSITORY TAG IMAGE ID SIZE
myapp naive a1b2c3d4e5f6 1.08GB

1GB 多,原因:

python:3.11 基础镜像本身 900MB+ pip 安装时下载的 wheel 缓存没清 编译 numpy/pandas 留下的编译工具

pip的緩存是什麼

pip 安装包时,会先把 .whl 文件下载到本地缓存目录,装完之后缓存文件还留着,下次装同一个包可以不用重新下载。

安裝 numpy 的過程:

pip 下載 numpy-1.26.4-cp311-linux_x86_64.whl

存一份到緩存目錄 ~/.cache/pip/ ← 這份沒用了,但還占著空間

解壓安裝到 site-packages/ ← 這才是真正要用的

所以鏡像中存了兩份 numpy,一份是 pip 的緩存,一份是安裝好的包。

加入 --no-cache-dir 參數,pip 就不會把下載的包存到緩存目錄了:

RUN pip install --no-cache-dir -r requirements.txt

apt安裝包

安裝系統工具時,apt也會留下緩存

RUN apt-get update && \
apt-get install -y build-essential && \
rm -rf /var/lib/apt/lists/*

/var/lib/apt/lists/ 裡面存的是軟件包的索引列表,之後不會用到了。

為什麼分開RUN清理不掉,

RUN apt-get install -y gcc        # 第N層,安裝了gcc,留下了緩存
RUN rm -rf /var/lib/apt/lists/* # 第N+1層:只是標記刪除

在同一層產生,在同一層清理,才會真的消失。

即使用了緩存之後,鏡像大小可能並沒有降低多少。

$ docker images myapp:no-cache

REPOSITORY TAG SIZE
myapp no-cache 870MB

多階段構建

用一個階段來構建,安裝所有依賴,編譯好包,然後把需要的東西複製到另一個乾淨的階段,這樣就不會把構建過程中產生的垃圾帶到最終鏡像裡了。


FROM python:3.11 AS builder

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir \
--prefix=/install \
-r requirements.txt

FROM python:3.11-slim AS runtime

WORKDIR /app

COPY --from=builder /install /usr/local

COPY . .

RUN useradd -m appuser
USER appuser

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

如果現在構建:

$ docker build -t myapp:multistage .
$ docker images myapp

REPOSITORY TAG SIZE
myapp naive 1.08GB
myapp no-cache 870MB
myapp multistage 195MB

總結

多階段構建的本質是:

構建環境和運行環境分離。

Docker鏡像分層與層共享

· 3 min read

第一個問題

假設你的伺服器上跑了兩個容器:

container1: Ubuntu(200MB) + Nginx(50MB) = 250MB
container2: Ubuntu(200MB) + MySQL(100MB) = 300MB

按照常識來說,磁碟總共應該花了200+200+200+50+100=750MB,但是實際上只有200+50+100=350MB。這是為什麼呢?

因為Ubuntu那200MB的鏡像只存了一份,這就是Docker分層架構要做的事情。

Docker鏡像的本質:一疊唯讀的層

Docker鏡像不是一個整體的大檔案,而是一層一層疊起來的,每一層對應Dockerfile中的一條指令。

FROM ubuntu:22.04 # 這一層包含了ubuntu的檔案
RUN apt-get install -y nginx # 這一層包含了安裝nginx的檔案
RUN apt-get install -y mysql-server # 這一層包含了安裝mysql的檔案

每一層只記錄相對上一層的改變

┌─────────────────────┐
│ 第3層: config檔案 │ 1MB(只有這層的新內容)
├─────────────────────┤
│ 第2層: Nginx │ 30MB
├─────────────────────┤
│ 第1層: Ubuntu │ 200MB
└─────────────────────┘

所有層都是唯讀的,構建完成後不可以修改。

三:層共享

當你基於同一個 Ubuntu 構建兩個不同鏡像時:

image1 (nginx):   [Ubuntu 200MB] → [Nginx 50MB]
image2 (mysql): [Ubuntu 200MB] → [MySQL 100MB]

Docker 透過每層的 Content Hash(內容哈希)來識別層是否相同。Ubuntu 層的 hash 完全一致,所以磁碟上只存一份:

磁碟實際儲存:

┌─────────────────────┐
│ MySQL層 │ 100MB ← image2 獨有
├─────────────────────┤
│ Nginx層 │ 50MB ← image1 獨有
├─────────────────────┤
│ Ubuntu層 │ 200MB ← 兩個鏡像共享
└─────────────────────┘

總計:350MB,而不是 750MB

四:容器執行時

鏡像層是唯讀的,那容器如果在執行的時候寫檔案怎麼辦?

Docker在唯讀鏡像層上面再加了一層可寫的容器層

┌─────────────────────┐
│ 容器層(可寫) │ ← 容器執行時產生的檔案寫在這裡
├─────────────────────┤
│ Nginx層(唯讀) │
├─────────────────────┤
│ Ubuntu層(唯讀) │
└─────────────────────┘

這就是 Copy-on-Write(寫時複製)機制:

讀檔案:從上往下找,找到就返回
寫檔案:先把檔案從唯讀層複製到容器層,再修改

所以即使 100 個容器基於同一個鏡像,鏡像層依然只有一份,每個容器只多出自己那個很薄的容器層。

container1  container2  container3
[寫層1] [寫層2] [寫層3] ← 各自獨立,很小
↘ ↓ ↙
[共享的唯讀鏡像層] ← 只有一份

五:這對Dockerfile寫法的啟示

利用層快取:把不常變的指令放前面,常變的指令放後面,這樣修改後只需要重建後面的層,前面的層可以複用。

# ✅ 好的寫法
COPY requirements.txt . # 依賴檔案變化少 → 快取穩定
RUN pip install -r requirements.txt
COPY . . # 程式碼經常變 → 放最後

# ❌ 差的寫法
COPY . . # 程式碼一變,下面的依賴安裝就要重跑
RUN pip install -r requirements.txt

合併RUN減少層數

# ❌ 產生3層,中間層的快取檔案仍佔空間
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ 一層搞定,最終只保留有用的檔案
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*

結束了碩士生涯

· One min read

碩士生涯結束了

隨著最後一門考試結束,我長達17年的學生生涯正式畫上句號。從小學到大學,再到碩士,每一個階段都充滿了挑戰和成長。回首過去,感謝一路上支持我的家人、朋友和老師們,是你們的鼓勵讓我堅持走到今天。

設計Redis鎖的思路

· 4 min read

Redis鎖

當Redis被用來做分布式鎖、限流、去重的時候,key應該如何設計?

建立模型

在建立鎖之前,先根據我們要解決的問題想一下以下幾個點:

  • 什麼東西會被加鎖?
  • 在做什麼的時候需要加鎖?
  • 在什麼時間窗口內需要加鎖?
  • 在什麼環境下鎖生效?

可以把key想像為一個業務範圍的名字。

例如: rate_limit:prod:payment:user:12345:create_order:2026-04-13T12:00

這串key的意思是在 prod 環境,payment 業務裡,user 123 在 2026-04-13 12:00 這一分鐘內 create_order 的次數。

在比如說: lock:prod:order_id:0519:pay

表示在prod環境裡,對 order_id 0519 的 pay 操作加鎖。

所以 key 設計的本質是:把抽象的控制範圍變成一個穩定、唯一、可過期、可觀察的名字。

一個好的key

應該設計有如下結構的鎖:

{purpose}:{env}:{domain}:{scope_type}:{scope_id}:{action}:{window / version}

purpose: lock / rate_limit / deduplication 表示key做什麼?

env: prod / staging / dev 表示在哪個環境?

domain: payment / user / inventory 表示業務範圍?

scope_type: user_id / order_id / ip 表示鎖的範圍類型?

scope_id: 12345 / 0519

action: create_order / pay 表示鎖的具體行為?

window / version: 2026-04-13T12:00 / v1 表示鎖的時間窗口或者版本?

rate_limit:prod:auth:user:123:login:202604211035
rate_limit:prod:auth:ip:1.2.3.4:login:202604211035
lock:prod:payment:order:98765:capture
dedupe:prod:notification:user:123:send_email:template_abc

一些設計

鎖的粒度

lock:prod:order:pay

表示所有訂單使用一把鎖,會導致大量的業務邏輯阻塞。

lock:prod:order:0519:pay:request_id:abc

每個請求都有不同的request_id,那就失去了鎖的意義,因為每次都會拿到不同的鎖。

鎖的粒度應該有如下的考慮:

哪些請求同時執行,會互相破環狀態?

Redis鎖

Redis做鎖的時候,常見的命令是

SET key value NX PX

其中:

  • key 是鎖的名字,根據上面的設計原則來定義。
  • value 是鎖的持有者,可以是請求ID、主機ID等唯一標識,方便後續的解鎖和觀察。
  • NX 表示只有當鍵不存在時才會設置成功,適合用於鎖的場景。
  • PX 表示設置鍵的過期時間,適合用於自動解鎖。

刪除的時候

DEL key

這樣會出現問題,因為會發生:

  • A請求獲取鎖成功,設置過期時間為10秒。
  • A請求處理業務邏輯,可能需要5秒。
  • A請求完成業務邏輯,準備釋放鎖,
  • 此時鎖已經過期了,A請求刪除的其實是別人的鎖,導致其他請求的鎖被誤刪。

正確的做法應該是compare token and delete,確保只有鎖的持有者才能釋放鎖。

DynamoDB鎖

DynamoDB做鎖的時候,通常一個lock key就是一條item。

PK = LOCK#prod#order#order_id#123#pay
{
"PK": "LOCK#prod#order#order#98765#pay",
"owner_token": "uuid",
"lease_until": 1776748500,
"created_at": 1776748470,
"fencing_token": 1024
}

獲取鎖的時候用:

attribute_not_exists(PK) OR lease_until < :now

如果item不存在,可以拿鎖。

如果item存在但是過期了,也可以拿鎖。

比較

Redis 很適合:

  • 秒級 / 分鐘級限流。
  • 短租約分布式鎖。
  • 高頻快速計數。
  • 需要低延遲的控制面。

Redis key 設計時特別注意:

  • key 數量會不會爆炸。
  • 每個 key 是否都有合理 TTL。
  • 熱 key 是否會集中在少數幾個 key 上。
  • Redis Cluster 中多 key Lua 操作是否落在同一 hash slot。

Redis實戰

第一步

在本地docker中開啟一個Redis容器

image.png

第二步

開啟兩個終端,在終端中分別輸入

docker exec -it redis-1 redis-cli

第三步

在第一個終端中輸入

SET lock:prod:order:order_id:0001:pay owner-A NX PX 10000

返回OK,表示鎖獲取成功。

同時在第二個終端中輸入

SET lock:prod:order:order_id:0001:pay owner-B NX PX 10000

返回(nil),表示鎖已經被A持有,B獲取鎖失敗。

redis

十秒後,鎖過期了,B再次嘗試獲取鎖:

SET lock:prod:order:order_id:0001:pay owner-B NX PX 10000

返回OK,表示B獲取鎖成功。

錯誤的刪除鎖的方式

如果B現在持有鎖,A嘗試刪除鎖:

DEL lock:prod:order:order_id:0001:pay

這樣會導致B的鎖被誤刪,A應該先檢查鎖的持有者是否是自己:

EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:demo:payment:order:1001:pay owner-A

什么是BGP

· 4 min read

什么是BGP?

BGP(Border Gateway Protocol)是互聯網的路由大腦,負責告訴全球所有的路由器某個IP地址在哪裡,怎麼去。

當你在瀏覽器輸入google.com,你的ISP路由器查詢BGP路由表,找到去往google.com的最佳路徑,封包才能跨越數十甚至數百個自治系統(AS)到達Google的服務器。

BGP是唯一在互聯網規模下工作的協議,管理全球九十萬以上的路由前綴

AS自治系統

AS(Autonomous System)是BGP的基本單位,代表一個由單一機構管理的網路。

每個AS都有一個唯一的編號ASN,例如Google是ASN15196,AWS是ASN16509。 AS之間通過eBGP建立Peering,互相交換我能連結到哪些IP地址的路由資訊。

BGP路由機制

BGP的路由通知被叫做Update消息,每當有一個AS有新路由或者路由失效,就發送Update給所有的BGP鄰居。

關鍵機制:AS_PATH,每當一個AS宣告一個路由,它會把自己的ASN添加到AS_PATH中。這樣其他AS就知道這條路由經過了哪些AS,避免路由循環。

AS_PATH Prepend 就是利用這個機制:把自己的 ASN 重複追加幾次,讓路徑看起來更長,其他 AS 就會優先選更短的路徑(即 DX)

BGP路徑屬性

BGP 路徑屬性決定了當有多條路徑到達同一目的地時,路由器選哪條。理解這四個屬性是掌握混合雲路由控制的關鍵:

  • LOCAL_PREF:只在同一 AS 內有效。值越高,優先級越高。用來控制「本 AS 的流量從哪個出口出去」。
  • AS_PATH:路徑所經過的 AS 序列。越短越優先。跨 AS 可見,是影響對端選路的主要手段。
  • Community:可自定義的標籤。AWS 預定義了一些 Community 值,可以讓你告訴 AWS「這條路由請低優先/不要傳播」。

決策過程

當路由器收到多條去往同一目的地的路由時,按照嚴格的優先級順序逐條比較,找到第一個能分出勝負的屬性就停止。

生產中最常用的兩個手段:LOCAL_PREF 控制本端選路(出方向),AS_PATH Prepend 影響對端選路(入方向)。

iBGP和eBGP

eBGP(external BGP):不同 AS 之間的 BGP 會話。會自動把自己的 ASN 追加到 AS_PATH,修改 NEXT_HOP 為自己的地址,LOCAL_PREF 不會傳遞給對方。

iBGP(internal BGP):同一 AS 內部的 BGP 會話。不修改 AS_PATH,LOCAL_PREF 可以傳遞。但有個重要限制:iBGP 收到的路由不能再傳給其他 iBGP 鄰居(防環路),所以 AS 內部要麼全互聯,要麼用 Route Reflector 解決。

BGP在AWS中的應用

在 AWS 混合雲場景中,BGP 扮演三個角色:

  • 路由自動發現:機房路由器把機房網段(如 192.168.0.0/16)通過 BGP 告訴 AWS VGW,VGW 自動把這條路由注入 VPC Route Table,無需手動配置靜態路由。
  • 故障自動切換:DX 斷開時,BGP 會話超時,VGW 刪除從 DX 學到的路由,自動轉為使用從 VPN 學到的備份路由。
  • 路由策略控制:通過 LOCAL_PREF 和 AS_PATH Prepend 控制哪條路徑優先,實現精細的流量工程。

BGP為什麼是TCP連線

BGP工作在TCP 179端口,選擇TCP是因為BGP要保證路由更新消息一條不漏地傳遞,丟包就意味著路由表不一致,後果很嚴重。TCP 的可靠傳輸幫 BGP 省去了自己實現重傳的麻煩。這也意味著 BGP Peering 建立需要 TCP 三次握手,所以 Security Group 要放行雙向的 TCP 179。

AWS AS 7224

這是AWS為Direct Connect和VPN分配的公有ASN,代表了AWS這一側的網路身分。當你的機房路由器和 AWS VGW 建立 BGP 會話,對端 ASN 就填 7224。有趣的是,AWS 在不同服務裡有不同的 ASN:DX 和 VPN 用 7224,AWS Transit Gateway 可以自定義 ASN(預設 64512),而面向公網的 AWS IP 地址段則用 AS16509。

一條奇怪的CNAME記錄

· 3 min read

一條奇怪的CNAME記錄

在CloudFlare的DNS管理界面上,有一條奇怪的CNAME記錄:

Type: CNAME
Name:_222ec04618be45...
Content: _b8c270d13e0adf5a673...

另外一條則是由qingshiyuu.com指向cloudfront.net的CNAME記錄。

這條CNAME有什麼用呢?

CA與SSL證書

當你在瀏覽器地址欄看到qingshiyuu.com那把綠色鎖頭,你其實在信任一個承諾。

這個承諾是:有一個受信任的第三方替這個網站擔保,確認你現在連接的 qingshiyuu.com,確實是 qingshiyuu.com 的主人運營的,不是別人偽裝的。

這個第三方就是CA(Certificate Authority),AWS的ACM(AWS Certificate Manager)就是一個CA。

此時的問題是如果任何人都有資格申請任何域名的證書,我就能申請一張google.com的證書,然後冒充google.com來騙取用戶的信任。

所以,在CA頒布證書值錢,必須先問一個問題,你能證明你是這個域名的主人嗎?

驗證的邏輯非常簡單,只有能修改DNS記錄的人才能證明他是域名的主人。

道理很簡單——DNS 記錄只有登錄域名管理後台才能修改,這個後台只有域名的購買者才有訪問權限。所以如果你能在 DNS 裡加一條特定的記錄,就等於你在向全世界說:我能控制這個域名,這個後台在我手上。

ACM 用的就是這個邏輯:它給你一個只有它自己知道的隨機字符串,讓你把它加進 DNS。它去查,查到了,就知道你是域名的主人,然後才頒發證書。

這條記錄,就是在 Cloudflare 後台看到的那條奇怪的 CNAME。

申請的四個階段

階段一:申請證書

ACM生成一對全局唯一的隨機字符串,告訴我把其中一個字符串(_222ec04618be45...)加到DNS裡,然後它會去查這個記錄的值。

階段二:DNS驗證

我把這個字符串加到DNS裡,ACM去查這個記錄的值,查到了,就知道我能控制這個域名,然後才頒發證書。

階段三:ACM掃描

DNS查詢那條字符串。

返回的值和ACM生成的值一樣。

驗證通過,證書狀態從Pending變為Issued。

階段四:證書頒發。

可以在cloudfront中使用證書了。

總結

這整個過程展示了如何通過DNS記錄來驗證域名所有權,並最終獲得SSL證書。這不僅保護了用戶的數據安全,也增強了網站的可信度。

為什麼我的網站qingyuu刷新後後會出現403錯誤?

· 2 min read

起因

近日我在瀏覽我的個人網站qingshiyuu.com時候,發現每次刷新頁面都會出現403 Access Denied錯誤,在搜索了相關資料後,發現這是因為CloudFront的配置問題。

原因

這是我Docusaurus Build出來後的文件結構:

錯誤

docs/intro 這個路由對應的實際文件是 docs/intro/index.html,當我在首頁點擊intro鏈接之後,Docusaurus的React路由在前端接管了導航,沒有發送HTTP請求給CloudFront。

但是當我刷新頁面之後,頁面會發送實際的HTTP請求到CloudFront,請求的路徑是 /docs/intro,CloudFront會去S3桶裡尋找這個路徑對應的文件,但是S3桶裡沒有 /docs/intro 這個文件,只有 /docs/intro/index.html,所以CloudFront返回403 Access Denied錯誤。

在使用 OAC(Origin Access Control)私有訪問的情況下,S3 對於找不到的對象返回的是 403 Access Denied,而不是直觀的 404 Not Found。這是因為從 S3 的角度看,它無法區分「對象不存在」和「你沒權限訪問」——統一都用 403 來回應,避免洩露 bucket 裡的對象信息。

解決方案一

最簡單的方案是告訴 CloudFront:遇到 403/404 時,把 /index.html 返回給用戶,讓前端路由接管。

custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}

custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}

解決方案二

更好的做法是在請求到達S3之前,就把URL自動改寫為正確的格式。CloudFront Function可以在邊緣節點執行輕量級的JavaScript代碼,適合做這件事。

resource "aws_cloudfront_function" "rewrite_uri" {
name = "rewrite-uri"
runtime = "cloudfront-js-2.0"
publish = true
code = <<-EOT
function handler(event) {
var request = event.request;
var uri = request.uri;

if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}

return request;
}
EOT
}

邏輯很簡單,如果請求的路徑是以 /docs/intro 結尾的話,就自動加上 /index.html,這樣就能正確地找到對應的文件了。

然後在cahce_behavior裡面把這個function掛上去:

function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.rewrite_uri.arn
}

配置完後,清除一下CloudFront的緩存,刷新頁面就不會再出現403錯誤了。

aws cloudfront create-invalidation \
--distribution-id YOUR_DISTRIBUTION_ID \
--paths "/*"

Lambda Snapshot機制:冷啟動黑科技

· 5 min read

Lambda Snapshot機制

目前的問題

Lambda冷啟動的延遲主要來自Init階段,下載程式碼、啟動Runtime、執行初始化邏輯。對Node.js/python來說可能只需要幾百毫秒,但是對於java來說,JVM需要啟動,類加載器需要跑、Spring Boot要掃描一堆Bean、反射、DI容器初始化。

一個典型的Spring Boot Lambda,Init Duration會達到5-10秒。用戶第一個請求等待7秒,這在API場景下完全不可以接受。

Snapstart的核心機制: Firecracker Snapshot/Restore

SnapStart的本質就是一句話:把Init從每次冷啟動做變成發版時候只做一次,之後從快照恢復。

具體流程:

  • 當你啟用SnapStart並且發布一個新的函數版本時候,Lambda會觸法快照流程:先運行函數的完整初始化階段。
  • 然後對已經初始化的執行環境拍攝一個不可變的、加密的Firecracker microVM快照,包含記憶體和磁碟的完整狀態,再將快照分快緩存以供快速取回。
  • 之後冷啟動就不需要再走Init了,Lambda直接從緩存的快照恢復新的執行環境,而不是從頭初始化,用一個更快的resume階段取代了原本最耗時的初始化階段。

快照的存儲機制

Lambda將快照存儲在S3中,分割成512KB的Chunk以優化取回延遲。由於S3的取回需要數百毫秒,Lambda使用兩層緩存加速:L2是專門構建的分布式緩存艦隊,每個AZ都有一份獨立副本,Chunk從L2取回的性能通常在個位數毫秒級別。

這意味著恢復一個快照並不是去S3拉一整個大文件,而是按需、分塊、多層緩存地載入。

CRaC 讓應用層也能配合快照

Firecracker層面的snapshot/restore解決了VM狀態保存和恢復,但應用層也有自己的問題:數據庫連線、打開的socket、隨機數種子、臨時憑證等,這些東西被放進快照之後,恢復出來可能就已經失效了。

這就是CRaC發揮作用的地方。

CRaC在Java應用生命週期引入了新的before-checkpoint和after-restore階段。與非協調式的checkpoint/restore不同,協調式的做法允許被恢復的Java應用根據執行環境的變化做出不同的反應。

它提供兩個核心hook:

  • beforeCheckpoint() --快照拍攝前調用,你在這裡關閉數據庫連線、釋放文件句柄、清除臨時狀態
  • afterRestore() --快照恢復時候調用,你在這裡重新建立連線、刷新憑證、重新播種隨機數生成器
public class MyHandler implements RequestHandler<Event, Response>, Resource {

private Connection dbConn;

public MyHandler() {
// 這段在 Init 階段執行,會被快照捕獲
Core.getGlobalContext().register(this);
dbConn = DriverManager.getConnection(DB_URL);
}

@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// 快照前:關閉連線(因為恢復後可能已過期)
dbConn.close();
}

@Override
public void afterRestore(Context<? extends Resource> context) {
// 恢復後:重新建立連線
dbConn = DriverManager.getConnection(DB_URL);
}
}

Spring Boot、Quarkus、Micronaut 等主流框架已經內建了 CRaC 支援,多數情況下開發者不需要手動改程式碼就能使用。

類加載時機對快照的影響 - Priming

在初始化未被執行的代碼路徑--比如透過依賴注入延遲加載的類--不會被包含在快照之中。

這意味著假設你有一個Lambda handler裡用了10個Service類,但只有3個在Init階段被觸及,剩下7個要等到第一次請求進來時才會被JVM類加載器載入。那些7個類的加載器就會落在快照恢復後的第一次請求上。

解法是Priming預熱:在beforeCheckpoint階段主動觸發延遲加載的類

@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// 主動觸發類加載和 JIT,讓它們被快照捕獲
new ObjectMapper().readValue("{\"test\":1}", Map.class);
// 甚至可以做一次模擬調用
handleRequest(dummyEvent, dummyContext);
}

唯一性陷阱

因為SnapStart 用同一份快照作為多個執行環境的初始狀態,所以在初始化階段生成的「唯一」內容被快照捕獲後,恢復到多個實例中就不再唯一了。 Amazon Web Services 典型翻車場景:

  • Init 時生成了一個 UUID 作為實例 ID → 所有從同一快照恢復的實例拿到相同的 UUID
  • Init 時用 SecureRandom 播了種 → 所有實例的隨機數序列完全一致,在加密場景下是致命漏洞
  • Init 時拿了一個 STS 臨時憑證 → 恢復時可能已經過期

解法:所有需要唯一性的內容,必須從 Init 階段移到 handler 中,或者放到 afterRestore() hook 裡生成。

發版時(一次性):
Code Deploy → Init(JVM 啟動、類加載、DI、連線建立)

CRaC beforeCheckpoint() → 關閉外部資源

Firecracker snapshot → 記憶體 + 磁碟狀態 → 加密 → 分成 512KB chunks

S3(持久層)→ L2 分布式緩存 → L1 Worker 本地緩存

每次冷啟動(毫秒級):
請求進來 → 無暖實例可用

從緩存拉取 chunks → 恢復 Firecracker microVM

CRaC afterRestore() → 重建連線、刷新憑證、重新播種

Handler 執行 → 回應請求

將Lambda放到VPC中,發生了什麼?

· 5 min read

為什麼把Lambda放進VPC後冷啟動會從毫秒級變成十幾秒?

為什麼Lambda函數需要走進VPC?

預設的情況下,Lambda函數運行在AWS託管的網絡空間中,可以存取公網的網際網路端點,但是無法直接存取你VPC內部的私有資源,例如RDS、ElastiCache等。如果你的Lambda函數需要訪問這些私有資源,就必須將它放入VPC中。

一旦你為Lambda設定中指定了VPC、子網與安全群組,AWS就會為這個函數建立Elastic Network Interface,讓它伸出一隻手伸進VPC。而這隻手就是問題的根源。

現在矛盾的點就是,Lambda追求毫秒級的啟動,但是建立ENI是一個VPC層級的控制平面操作,天生就非常慢。

舊時代(2019年前):每次呼叫都會卡ENI

原始流程

在2019年9月之前,VPC Lambda冷啟動流程如下:

  • 請求建立ENI:Lambda服務向EC2控制平台發出CreateNetworkInterface API請求,要求在指定子網中創建一個ENI。
  • 分配私有IP:從你指定的子網CIDR中取得一個可用的私有IP地址。
  • 附加安全組:從你指定的Security Group規則套用到這張ENI上。
  • Attach 到 Lambda MicroVM:ENI被連接到Firecracker MicroVM的虛擬網卡上。
  • 函數啟動:Runtime初始化,載入你的handler,開始處理請求。

Firecracker MicroVM是什麼?

Firecracker是AWS開源的一套輕量級虛擬機器監控器VMM,專門為serverless場景設計。它用Rust編寫,基於Linux KVM來建立和運行所謂的microVM。

為什麼需要它?

Lambda是多租戶環境,你的函數和其他人的函數跑在同一台實體服務器上。AWS需要在隔離性和啟動速度之間取得平衡:

  • 傳統VM(類似QEMU)隔離性強,但啟動要好幾秒,而且模擬了一堆Lambda不需要的硬體--USB、顯示卡、音效卡、PCI
  • 容器(類似Docker)啟動快,但容器共享宿主機的kernel,在多租戶環境中安全風險較高。

AWS最早採用傳統VM來跑Lambda,有安全性,但是效能和密度不好,於是開發了Firecra--結合了VM的硬體級隔離和容器的資源效率與快速啟動。

Firecracker精簡程度

Firecracker的每個microVM記憶體開銷低於5MiB,啟動時間最快只要125毫秒,單台伺服器每秒可建立最多150個microVM。之所以能這麼快,是因為完全不實作BIOS、PCI等傳統硬體模擬,只透過精簡的virtio介面和guest kernel溝通。

和ENI之間的關係

每次Lambda冷啟動,AWS會啟動一個Firecracker microVM來執行代碼,如果你的Lambda配置了VPC,這個microVM就需要一張ENI來接入你的VPC網路,在舊架構下,每啟動一個microVM就要即時建立一張ENI,這就是冷啟動卡住的原因。

架構

┌──────────────────────────────────────────────────────────┐
│ 你的 VPC ─ Subnet: 10.0.1.0/24 (可用 IP: 251) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ENI-001 │ │ ENI-002 │ │ ENI-003 │ ... │
│ │ 10.0.1.5 │ │ 10.0.1.6 │ │ 10.0.1.7 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
└───────┼──────────────┼──────────────┼─────────────────────┘
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Lambda #1 │ │ Lambda #2 │ │ Lambda #3 │
│ (MicroVM) │ │ (MicroVM) │ │ (MicroVM) │
└───────────┘ └───────────┘ └───────────┘

三大問題

IP地址耗盡

一個/24子網只有251個可用IP,扣掉AWS保留的五個,如果你有多個Lambda函數共用相同的子網,並發量大了之後,ENI佔滿IP後新的呼叫會收到ENIlimitException。

ENI數量上限

每個Region/Account預設的ENI上限大約在5000左右,當你有多個高併發的VPC Lambda,加上其他EC2實例也在消耗ENI,很容易達到上限。

安全組規則變化

有人修改了Lambda使用的SG,加了一條規則,Lambda下次冷啟動才會套用新規則,而已經運行的實例仍使用舊規則--導致行為不一致。

新時代:AWS Hyperplan架構

2019年9約,AWS宣佈了VPC Lambda的重大架構--引入Hyperplan ENI(也稱為VPC-to-VPC NAT)。

核心變化:ENI在函數建立/更新時預建

新架構下,ENI不再是呼叫時才建立,而是在你Create/Update Function的時候就預先建立好,這些ENI被稱為Hyperplan ENI,它們是共享的--同一個函數的多個併發實例可以共用一張ENI。

┌──────────────────────────────────────────────────────────┐
│ 你的 VPC ─ Subnet: 10.0.1.0/24 │
│ │
│ ┌──────────────────────┐ │
│ │ Hyperplane ENI │ ← 函數部署時就建好 │
│ │ 10.0.1.5 │ ← 多個實例共用 │
│ └──────────┬───────────┘ │
│ │ │
│ ┌────────┴─────────┐ │
│ │ VPC-to-VPC NAT │ ← AWS Hyperplane 服務 │
│ │ (Mapping Table) │ │
│ └────────┬─────────┘ │
│ │ │
└─────────────┼────────────────────────────────────────────┘

┌─────────┼─────────────────────┐
│ │ Lambda Service │
│ ┌─────▼─────┐ ┌───────────┐│
│ │ Lambda #1 │ │ Lambda #2 ││
│ │ (MicroVM) │ │ (MicroVM) ││ ... 可能上百個
│ └───────────┘ └───────────┘│
└───────────────────────────────┘

由於ENI已經存在,冷啟動時不需要走CreateNetworkInterface,Lambda實例通過Hyperplan的NAT mapping就能進入VPC,時間大幅度下降。

Hyperplan也不是完美的

部署時候ENI建立仍然需要時間

當你第一次部署VPC Lambda或更新其子網/安全群組設定時,AWS仍然需要建立Hyperplan ENI。這個過程需要時間,這個期間函數會處於Pending狀態。

需要足夠的IAM權限

Lambda執行角色需要EC2網路相關權限,如果你使用自定的IAM policy,確認包含以下權限:

{
"Effect": "Allow",
"Action": [
"ec2:CreateNetworkInterface",
"ec2:DescribeNetworkInterfaces",
"ec2:DeleteNetworkInterface",
"ec2:AssignPrivateIpAddresses",
"ec2:UnassignPrivateIpAddresses"
],
"Resource": "*"
}

最簡單的方式是附加AWS託管策略AWSLambdaVPCAccessExecutionRole,它包含了上述權限。

子網規劃仍然重要

雖然IP消耗大幅度降低,但是如果你子網仍然很少,加上其他服務也在使用,仍可能出現問題,建議至少使用/24子網。

跨AZ的高可用配置

設定Lambda VPC,應選擇至少兩個不同的AZ子網,如果某個AZ出問題,Lambda可以自動使用另一個AZ的ENI,確保可用性。

VPC Lambda要存取公網資源,需要搭配NAT Gateway(放在public subnet)Lambda自己所在的private subnet路由表要將0.0.0.0/0指向NAT Gateway,不然Lambda放進VPC就會出不去了。

參考文檔