SSM Port Forwarding:不開任何 inbound,從本機測試私有子網裡的服務
TL;DR
- 情境:FastAPI 服務跑在私有子網的 ECS 上,環境不准開任何 inbound。
- 做法:SSM Port Forwarding,把筆電的
localhost:8888接到容器裡 uvicorn 監聽的8000。- 原理:筆電和容器各自向 SSM Service 外撥,SSM 在中間轉發;通道能不能開,由 IAM 決定。
1. 服務部署好了,卻打不進去
我們的系統部署在一家金融業客戶的 AWS 環境,環境不允許任何公網入口。後端是 FastAPI 服務,跑在 ECS 上,前面掛著 internal ALB,所有工作負載都在私有子網。沒有 internet-facing load balancer,也沒有跳板機或 VPN。
問題出在測試:服務部署好了,筆電卻送不出請求。
internal ALB 的 DNS 名稱從公網查得到,解析出來卻是私有 IP(本文的名稱與 IP 皆為示意):
1 | dig +short internal-my-alb-1234567890.us-east-1.elb.amazonaws.com |
flowchart LR
DEV[開發者筆電]
ALB[internal ALB<br>10.0.x.x 私有 IP]
SVC[ECS 服務<br>私有子網]
DEV -.->|私有 IP 公網不可路由<br>curl timeout| ALB
ALB --> SVC
這兩個 10.0.x.x 位址只能在私有網路裡路由。封包離開筆電後,根本找不到一條可以進入 VPC 的路,所以 curl 最後只會 timeout。
直覺會先去查 Security Group 有沒有放行 443。但 Security Group 要等封包抵達目標才檢查,這裡封包連 VPC 都沒進去。
沒有哪條規則設錯,是外面根本沒有路通到裡面。要從本機測,得先有一條通到私有子網的通道。
2. 五條路
常見做法有五種,差在怎麼開入口、要維護什麼、授權給到多大。
方案 A:把入口改成 internet-facing
最直接的方法,是把測試環境的 ALB 改成 internet-facing,再用 Security Group 限制來源 IP。
flowchart LR
DEV[開發者筆電] -->|443 inbound| PALB[internet-facing ALB<br>公網 IP + SG 白名單]
PALB --> SVC[私有子網服務]
不用額外工具,筆電確實打得到。但封閉的環境從此多了一個永久公網入口,就算有白名單,也要持續維護、過資安審查。我們的環境不准有公網入口,這條直接出局。
方案 B:Bastion 加上 SSH Tunnel
如果不能直接公開 ALB,另一種常見做法是在中間放一台跳板機。筆電先透過 SSH 連上跳板機,再用 ssh -L 將遠端服務的 port 帶回本機。
flowchart LR
DEV[開發者筆電] -->|SSH 22 inbound| BAS[跳板機 EC2<br>有公網 IP]
BAS -->|代為轉發| SVC[私有子網服務]
做法成熟、好懂,但要多養一台機器(更新、監控),多管一批 SSH key(發放、保存、回收),公網上還多一個長期開著 22 port 的目標。為了偶爾從本機測一支 API,太重了。
方案 C:Client VPN
Client VPN 解決問題的方式更完整。連線建立後,筆電就像被搬進 VPC,可以直接存取授權範圍內的內部資源。
flowchart LR
DEV[開發者筆電] -->|VPN 通道| VPNEP[Client VPN Endpoint]
VPNEP -->|筆電視同進入 VPC| SVC[私有子網服務]
代價是要設 endpoint、憑證和路由,授權單位通常是一整段網路。我只想測一個容器的一個 port,用不到這麼多。
方案 D:NAT Gateway,但它解決的是反方向
NAT Gateway 常被拿來討論,但看箭頭方向就知道它解的是另一個問題。
flowchart LR
SVC[私有子網服務] -->|由內而外 OK| NAT[NAT Gateway]
NAT --> NET[網際網路]
NET -.->|由外而內依然不通| SVC
NAT Gateway 允許私有子網裡的服務主動連向外部,例如拉取 image、呼叫 AWS API 或送出 log。它不會讓網際網路上的筆電反過來連進服務。
但它點出一件事:
「出得去」不代表「進得來」。私有網路通常允許 outbound,卻嚴格限制 inbound。
A、B、C 都在開一條由外而內的路,D 只有由內而外。換個想法:筆電和容器都往外連,再由第三方把兩條線接起來,就不用開 inbound。
方案 E:SSM Port Forwarding
AWS Systems Manager(SSM)是 AWS 管理主機的服務。它的 Session Manager 功能讓你從筆電經由 AWS 中轉,連進一台主機或一個容器,但前提是對方裡面必須跑著 SSM Agent,也就是 AWS 提供、負責和 SSM 溝通的一個小程式。Port Forwarding 是 Session Manager 能開的一種連線,把筆電上的一個 port 接到對方的一個 port。
在我們的情境裡,筆電上的 localhost:8888 會接到容器裡 uvicorn 監聽的 localhost:8000。本機程式只要呼叫 localhost:8888,請求就會沿著 tunnel 送進容器。
這條 tunnel 不是讓筆電直接連進 VPC:筆電上的 session-manager-plugin 和容器裡的 SSM Agent 各自向 SSM Service 外撥,再由 SSM 接起來。
flowchart LR
subgraph LT[開發者筆電]
PLG[session-manager-plugin<br>監聽 localhost:8888]
end
subgraph AWSX[AWS]
SSM[SSM Service<br>中繼站]
end
subgraph VPC[私有子網的 ECS Task]
AG[SSM Agent]
APP[服務 :8000]
end
PLG -->|① 外撥 TLS| SSM
AG -->|② 外撥 TLS| SSM
AG -->|③ 連自己機器的 8000| APP
所以不需要公網 IP,也不用新增 inbound 規則;session 一結束,通道就沒了。
把五種方案放在一起看
| 方案 | 新開 inbound 入口 | 新增維護資產 | 權限控管 | 主要成本 |
|---|---|---|---|---|
| A. internet-facing ALB | 有,長期存在 | 無 | SG 白名單 | 公網暴露與資安審查 |
| B. Bastion + SSH | 有,通常是 22 port | 跳板機與 SSH key | Key 發放 | 機器與維運成本 |
| C. Client VPN | 有,VPN endpoint | VPN 設定 | 憑證與網段級授權 | 建置與持續費用 |
| D. NAT Gateway | 無,但方向不符 | NAT Gateway | 不適用 | 時數、流量與公網 IPv4 費用 |
| E. SSM Port Forwarding | 無 | 無額外主機 | IAM 與 session 紀錄 | 已有 NAT 時只有少量流量;VPC Endpoint 另計 |
3. 為什麼最後選 SSM Port Forwarding?
我要的只是暫時連到一個容器的一個 port,而且不能開 inbound,SSM Port Forwarding 剛好是這個大小。要長期存取整段內網,選 Client VPN;要正式對外服務,internet-facing ALB 才是正規入口。
它在這個條件下的好處:
- 不改網路入口:只用 outbound,不替 VPC 開公網路徑。
- 不養主機:不用為了 tunnel 維護 Bastion,也沒有 SSH key 要管。
- 權限跟著身分走:能不能開通道由 IAM 決定,每次
StartSession都留下 CloudTrail event。 - 用完就沒:session 關掉,本機 port 就不再通往容器。
它也有代價:
- 流量需要經過 SSM 中繼,不適合壓力測試或大量資料傳輸。
- Session 不持久:閒置一段時間會自動中斷,要用時得重開。
- 通道依賴容器裡的 Agent。Task 正在替換或 Agent 狀態不正常時,連線可能無法建立。
- 流量直接進入容器,不會經過 ALB。它驗得了 application,驗不了正式入口(第 7 章)。
拿來臨時測試、除錯,這些限制都可以接受。
4. 為什麼不用 inbound 也能連?
SSM Port Forwarding 的連線方式,可以想成「兩通外撥電話,一個總機」。
兩個彼此不能直接通話的人,各自打電話給同一個總機。總機不需要主動打進任何一方,只要把已經接通的兩條線串在一起,雙方就能交換資料。
sequenceDiagram
participant CLI as 筆電 aws cli + plugin
participant SSM as SSM Service
participant AG as 容器內 SSM Agent
participant APP as 服務 :8000
Note over AG,SSM: task 啟動時,Agent 已外撥建立<br>控制通道(TLS 443)
CLI->>SSM: StartSession API(HTTPS,帶 IAM 簽章)
SSM-->>CLI: session ID + WebSocket URL + 一次性 token
CLI->>SSM: 外撥建立 WebSocket 資料通道
SSM->>AG: 經控制通道通知新的 session
AG->>SSM: 外撥建立另一條 WebSocket 資料通道
Note over CLI,AG: SSM 在兩條 WebSocket 之間<br>轉發應用層位元組
CLI->>SSM: curl 的 HTTP 位元組
SSM->>AG: 轉發
AG->>APP: 建立本地 TCP 連線並寫入 8000
APP-->>CLI: 回應沿原路返回
整個過程可以拆成四個階段:
- Agent 先掛在線上。 Task 啟動後,SSM Agent 主動向 SSM 建立一條 TLS 443 的長期連線,叫控制通道(control channel)。它只用來接收「有新的 session」這類通知,不傳實際資料。
- 操作者申請 session。 Session 指一次從筆電到容器的連線,從開啟到關閉為止。操作者用
aws ssm start-session呼叫StartSessionAPI,並指定一份 SSM document。Document 是 AWS 預先寫好的設定,用來告訴 Agent 這次 session 要做什麼,例如開 shell 或轉發 port。IAM 驗證通過後,CLI 會拿到 WebSocket URL 與一次性 token。WebSocket 是建立在 HTTPS 上的長期雙向連線(見附錄 A2),token 只對這次 session 有效。 - 兩端建立資料通道。 Plugin 與 Agent 各自向 SSM 外撥一條 WebSocket,叫資料通道(data channel),專門傳這次 session 的資料。從防火牆的角度來看,兩邊都是一般的 443 outbound 連線。
- SSM 開始轉發。 Plugin 收到本機資料後,經 SSM 送給 Agent。Agent 再建立一條本地 TCP 連線,把資料寫入容器的 8000 port。
控制通道跟著 Agent 長期存在,資料通道跟著 session 建立與消失。所以 Agent 必須先進入 RUNNING,也就是控制通道已經建好,操作者才開得了 session。
SSM 搬的是應用層位元組,TCP 連線每段各自建立,所以 application access log 看到的來源通常是 127.0.0.1,不是筆電的 IP(見附錄 A3)。
也因為只搬位元組,HTTP、SSE 等跑在 TCP 上的協定都能走這條 tunnel。
5. 前置設定
Port Forwarding 要接得上,容器裡必須先有 SSM Agent,但 ECS 預設不會放 Agent,所以要靠 ECS Exec 這個功能。ECS Exec 原本是用來讓人從筆電進到容器裡下指令(aws ecs execute-command),打開之後,ECS 會在 task 啟動時把 SSM Agent 放進容器,而 Port Forwarding 借用的正是這個 Agent。
前置設定共有五處:ECS Service、Task Role、容器的 outbound 路徑、操作者的 IAM 身分,以及本機 plugin。其中 Task Role 和操作者的 IAM 身分屬於權限設定。雖然整個過程沒有開 inbound,但這不代表任何人都能建立通道,因為把關的位置從網路移到了 IAM。Task Role 決定容器能不能接收 session,操作者的權限則決定誰可以開啟 session。
開啟 ECS Exec
ECS Service 必須啟用 enableExecuteCommand。Task 啟動時,ECS 會把 SSM Agent 的 binary 掛進容器,並以 ExecuteCommandAgent 的名稱回報狀態(ECS 把這類由平台代管的程式稱為 managed agent)。
設定只對新啟動的 task 生效:開了 enableExecuteCommand 要重新部署,既有 task 不會長出 Agent。
讓 Task Role 可以建立控制與資料通道
Task Role 是容器執行期間使用的 IAM 身分,容器裡的程式呼叫 AWS API 時都用它。Agent 也在容器裡執行,所以權限要加在 Task Role,不是 Execution Role(ECS 在 task 啟動前拉 image、寫 log 用的身分,比較見附錄 A5)。Task Role 需要以下四個 action:
1 | { |
四個 action 對應第 4 章的兩種通道,各分「登記」與「連上」兩步:
| Action | 發生時機 | 作用 |
|---|---|---|
ssmmessages:CreateControlChannel |
Task 啟動 | 向 SSM 登記控制通道 |
ssmmessages:OpenControlChannel |
Task 啟動 | 外撥 WebSocket,長期等待通知 |
ssmmessages:CreateDataChannel |
每次建立 session | 登記這次 session 的資料通道 |
ssmmessages:OpenDataChannel |
每次建立 session | 外撥 WebSocket,實際傳送資料 |
權限不足時,Agent 連不上 SSM,ExecuteCommandAgent 也不會進入 RUNNING。
讓容器能主動連向 SSM
Task Role 給的是權限,連線還要有網路路徑:Agent 得能從容器連到 SSM Messages Service,SSM 負責控制與資料通道的服務。
容器可以經 NAT Gateway 連到 AWS Service,也可以在 VPC 裡建立 com.amazonaws.<region>.ssmmessages endpoint。
VPC Endpoint 會在 VPC 內提供一個私有入口,讓容器不必經過公網便能連向 SSM Messages。對 ECS Exec 而言,只需要 ssmmessages endpoint(用自訂 KMS key 加密時再加 kms)。Systems Manager 文件替 EC2 列出的 ssm、ec2messages 等 endpoint,ECS 用不到。
允許操作者建立 session
操作者是要開 tunnel 的人,他的 IAM 身分要有 ssm:StartSession。AWS 的範例 policy 也會允許操作者管理自己的 session:
1 | [ |
StartSession 的 Resource 列了兩項:ECS task ARN 限制能連向哪些 task,document ARN 限制只能開 Port Forwarding。沒有權限時,API 直接回傳 AccessDenied,拿不到 WebSocket URL 和 token,資料通道也就建不起來。
Policy 裡之所以要列 document ARN,是因為 document 決定了 session 能做什麼。Document 是 AWS 預先寫好的設定,告訴 Agent 這次 session 要開 shell 還是轉發 port。如果 policy 沒有限定 document,操作者執行 ssm start-session 時只要不指定 document,就會用預設的 SSM-SessionManagerRunShell 開出一個 shell,直接進到容器裡打指令。這樣的 shell 不會被記錄,因為把指令與輸出存到 CloudWatch Logs 或 S3 的紀錄功能只掛在 aws ecs execute-command 上,事後也就查不到操作者在容器裡做了什麼。AWS 的 ECS Exec 文件因此建議直接禁止對 ECS task 執行 ssm:StartSession,此外這類 session 也會占用 session 數量上限。
本文選擇限定 document,而不是全面禁止。這樣一來,操作者只開得了 Port Forwarding,開不了 shell;而 Port Forwarding 傳的是 HTTP 請求,本來就沒有指令可記。CloudTrail(AWS 記錄 API 呼叫的服務)仍會留下每次 StartSession、ResumeSession 與 TerminateSession 的操作者、時間、來源 IP、目標與 document,要查哪些欄位見附錄 A4。
安裝本機 plugin
最後,本機要安裝 session-manager-plugin。AWS CLI 負責呼叫 StartSession API,真正監聽本機 port 並轉發資料的是這個 plugin。
如果不確定前面的條件缺了哪一項,可以使用 AWS 提供的 amazon-ecs-exec-checker 檢查 ECS Exec 設定。
6. 真正把 tunnel 開起來
開 tunnel 的指令是 aws ssm start-session,它需要兩項資訊:要連到哪個容器(target),以及這次 session 要做什麼(document)。
先看 target。SSM 用一串固定格式的字串指認 ECS 容器:
1 | ecs:<cluster>_<task-id>_<container-runtime-id> |
這串字由三段組成:容器所在的 cluster、所屬 task 的 ID,以及容器本身的 runtime ID。之所以要細到容器一層,是因為一個 task 裡可以同時跑好幾個容器,例如主程式旁邊再掛一個收 log 的 sidecar(輔助容器)。Cluster 名稱是已知的,task ID 可以用 aws ecs list-tasks 查到;runtime ID 則要先指定容器名稱,再用 aws ecs describe-tasks 查出來。
再看 document。AWS 文件針對 ECS target 明確列出的是 AWS-StartPortForwardingSessionToRemoteHost,所以這裡用它。這份 document 需要三個參數:host 是 Agent 要連去的主機,設成 localhost 代表連容器自己;portNumber 是那台主機上要連的 port,我們的 image 預設讓 uvicorn 聽 8000,所以填 8000;localPortNumber 則是 plugin 在筆電上監聽的 port,這裡用 8888。
以下指令把這兩部分串起來:先找出一個正在執行的 task,確認 Agent 已經啟動,再查出 runtime ID 組成 target,最後開 session,把容器的 8000 映射到筆電的 8888(cluster、service、容器名稱皆為示意):
1 | CLUSTER=my-cluster |
如果成功,這個 terminal 會保持在前台。只要程序仍在執行,另一個 terminal 就能透過本機 port 發送請求:
1 | curl -i http://localhost:8888/health |
這裡的兩個 localhost 指的是不同機器。localhost:8888 位於筆電,由 plugin 監聽;document 裡的 host=localhost 則是容器自己,application 在那裡監聽 8000。
因為這條路直接進入容器,沒有經過 ALB 做 TLS termination(由 ALB 負責 HTTPS 的加解密),所以測試網址通常是 http://,而不是 https://。
幾個常見的卡點
- Agent 狀態是空的。 最常見的原因是開啟
enableExecuteCommand後沒有重新部署 task。只有新 task 才會啟動 managed agent。重新部署後仍是空的,再查版本:Fargate 需要 platform version 1.4.0 以上,EC2 launch type 需要 ECS agent 1.50.2 以上。 - 查不到 runtime ID。 如果 task 裡有 sidecar,
CONTAINER必須指向正確的容器。容器名稱不一定等於 ECS service 名稱。
RemoteHost 不只能連 localhost
同一個做法也能連到容器以外的主機。私有子網裡的 RDS(AWS 的託管資料庫)從筆電一樣連不進去,但容器連得到它。AWS-StartPortForwardingSessionToRemoteHost 的 host 不一定要是 localhost,換成 RDS 的位址,Agent 就會改連 RDS,同一個 ECS task 成了通往 RDS 的中繼點。
另一份 AWS-StartPortForwardingSession 的通道終點是 SSM target 本身。它實務上也能直接對 ECS target 使用,但這個組合沒有寫在官方 ECS 文件裡:
1 | aws ssm start-session \ |
7. Tunnel 通了,不代表正式路徑也沒問題
tunnel 測過的,只有流量實際經過的那幾層。
SSM Port Forwarding 直接把流量送進容器,因此可以驗證 application 邏輯、API 回應與容器 port。正式請求還會經過 DNS、TLS、ALB Listener 與 Target Group,這些層都沒有出現在 tunnel 路徑裡。
以下問題即使在 tunnel 測試完全通過,走正式路徑時仍然可能發生:
- 憑證過期或網域不符,導致 TLS handshake 失敗。
- Security policy 不接受特定 TLS 版本或 cipher。
- Listener rule 配錯,或 application 對
X-Forwarded-*header 的處理不同。 - ALB idle timeout 切斷長時間沒有資料的串流連線。
- Target Group health check 將正常服務判定為 unhealthy,讓正式請求收到 502。
- 正式路徑的負載均衡行為與 ALB access log 沒有被驗證。
例如,SSE 經過 tunnel 時可以持續接收資料,走 ALB 時卻可能被 idle timeout 切斷。
8. 同樣的形狀:reverse tunnel
這個做法不是 AWS 獨有,通稱 reverse tunnel,更廣義的說法是 broker pattern。
共同特徵都是內部 agent 先主動連到中繼服務。外部指令或資料再透過這條既有連線傳回內部,因此不必替每個內部端點開放 inbound。
類似的形狀還包括:
- GitHub Actions self-hosted runner 主動向 GitHub 取得 job,而不是讓 GitHub 連進 build 環境。
- Cloudflare Tunnel 與 ngrok 由本機 agent 主動連向中繼服務,再把公網流量反向送入本機。
- AWS IoT 裝置位於家用 NAT 後方,主動建立 MQTT 長連線並掛在 broker 上。
- 遠端桌面軟體讓兩台位於 NAT 後方的電腦各自連向中繼服務。
- Tailscale 先透過 DERP 協助建立連線,無法直連時便繼續由中繼服務轉送。
SSM 自己也在同一條管線上提供不同功能。aws ecs execute-command 傳送的是終端機的輸入輸出(也就是 shell);aws ssm start-session --target i-xxxx 可以建立 EC2 session;send-command 則用來對 SSM 管理的主機執行命令。
附錄
A1. Timeout 與 connection refused 有什麼不同?
timeout通常表示封包沒有得到回應。可能是路由不存在、防火牆丟棄封包,或目的地無法抵達。connection refused表示已經碰到主機,但目標 port 沒有程序監聽,作業系統因此回傳 RST。
例如,tunnel 開啟時,筆電上真正有人監聽的是 localhost:8888。如果改打筆電的 localhost:8000,而本機沒有服務使用這個 port,得到的通常是 connection refused。
A2. WebSocket 的「升級」是什麼?
WebSocket 會先送出普通的 HTTPS request,並帶上 Upgrade: websocket header。伺服器回覆 101 Switching Protocols 後,同一條 TCP 連線會從 HTTP 的一問一答,切換成持久的雙向通道。
連線一開始是標準 TLS 443 流量,因此在防火牆看來,它仍然是一條普通的 outbound HTTPS 連線。
A3. 搬的是位元組,不是連線
可以把應用資料想成一封信,把 TCP header 想成信封。
- 信裡的內容,是
curl送出的 HTTP 位元組。這些內容會一路被轉發到 application。 - 信封則只在一段連線裡有效。
curl ↔ plugin、plugin ↔ SSM、Agent ↔ SSM與Agent ↔ application都是不同的連線。 - 每到一段連線的終點,舊信封就會被拆掉。進入下一段時,再換上新的信封。
因此,application 看到的是 Agent 新建的本地 TCP 連線,來源通常是 127.0.0.1。如果 application 只監聽 IPv6,則可能看到 ::1。
A4. CloudTrail 要查哪些欄位?
可以在 CloudTrail Event history 使用 Event name = StartSession 篩選。單筆事件中比較有用的欄位包括:
userIdentity.arn:哪個 IAM 身分建立了 session。eventTime與sourceIPAddress:建立時間與來源 IP。requestParameters.target:連線目標,格式為ecs:<cluster>_<task-id>_<runtime-id>。requestParameters.documentName:使用的是 Port Forwarding、RemoteHost 或其他 document。
另外也可以查 TerminateSession 與 ResumeSession,確認 session 何時由誰關閉或恢復。
A5. Task Role 與 Execution Role 的差別
taskRoleArn是容器執行期間使用的身分。Application 與 SSM Agent 呼叫 AWS API 時,都使用這個 role。executionRoleArn是 ECS 平台在 task 啟動前使用的身分,例如拉取 image、建立 log stream,或從 Secrets Manager 讀取資料並注入容器。
可以把 Execution Role 想成房東佈置房間時使用的權限,把 Task Role 想成住戶入住後使用的身分。SSM Agent 在容器裡執行,所以使用的是 Task Role。
留言