vatt'ghern jaskier's ballads

Slack 管理數以萬計的 EC2 instance,但在 Shipyard 底下最常態的操作不是「登進去改設定」,而是「把整台機器直接換掉」——連工程師手動連進一台 production node 這個動作本身,都會被系統當成一個該儘快汰換掉的訊號。

Shipyard:Slack 用不可變映像重建 EC2 平台

Slack 過去的作法是把設定檔案持續往長命的 EC2 instance 上推——用 Chef 收斂設定、疊更安全的 rollout 流程、加更強的 guardrail。這條路走到了盡頭:服務層部署變得棘手,infrastructure drift 難以避免,跨層改動的複雜度不斷往上疊。Shipyard 的答案是反過來——不去修 instance,而是把整個基礎設施當成版本化、可部署的 artifact,跟應用程式的部署走同一套邏輯,重心從「設定管理」搬到「build pipeline、可部署 artifact、自動化安全機制」。

Chef 的收斂模型本身不是不管用——它會定期重新套用設定,把偏離的地方修回來。問題出在「定期」這兩個字:兩次收斂之間有一段空窗,這段時間裡機器的實際狀態可以跟宣告的設定不一致,而且沒有機制保證下一次收斂真的能把每一種手動改動都修乾淨。合理的推測是,這也是為什麼即使 Slack 已經有更安全的 rollout、更好的 orchestration、更強的 guardrail,舊模型還是撞到了極限——這幾樣工具處理的都是「怎麼更安全地改」,沒有一樣改變「機器持續被改」這個根本形狀。Shipyard 的做法不是把收斂做得更勤,而是把「修」換成「換」:instance 不會被就地修改,壽命到了或狀態不對了就整台換掉,新機器直接從烘好的映像長出來,不存在「跟原本設定漸行漸遠」這種中間狀態。

點擊任一階段查看該階段的責任邊界 · 4 個階段

Shipyard 的四個階段

bake → build → deploy → reap 烘焙 slack-zero Image Builder SSM + EventBridge service AMI 各團隊 pipeline 疊在基底上 Gondola 部署 ASG Refresh Karpenter Reaper 汰換 tainted 訊號 超齡檢查

click a stage above

① 烘焙 slack-zero

AWS Image Builder 自帶 lifecycle management,舊 AMI 自動清掉;發佈前先起臨時 instance 跑驗證測試。每次更新同步刷新 SSM parameter,EventBridge 加 Lambda 自動觸發下游 service pipeline 重建。

② 疊上 service AMI

Base 層(全域基礎設施元件、安全修補、必要設定)歸 Compute、Security、Monitoring 團隊管;service 層(自己的軟體、自己的設定)歸各服務團隊管——base 更新後,service team 有責任把新版併回自己的 AMI。

③ Gondola 部署

ASG executor 更新 launch template、把 Chef code 包進 S3,新 instance 用內建 bootstrapper 抓 artifact 跑 recipe;Kubernetes worker fleet 則是把同一份 AMI 與設定 metadata 交給 Karpenter。

④ Reaper 汰換

內建 rate limiting 讓服務 owner 依 service、region、AZ 分別限制單次替換規模;緊急時可在 S3 放一個控制物件當「大紅按鈕」,全域暫停 Reaper 活動。

下面四個階段,對應 request 在 Shipyard 裡實際走過的順序:先有黃金基底(slack-zero),才有服務自己的 AMI;有了 AMI 才能靠 Gondola 把它跟一份綁定 commit 的 Chef artifact 一起送出去;送出去的 instance,活到超過壽命或被標記「不對勁」的那一刻,就被 Reaper 排入汰換。點擊上面任一階段,看每個階段各自對安全、速度、責任邊界做了什麼取捨。

slack-zero:黃金基底的烘焙管線

slack-zero 是整個系統的地基——Slack Compute Platform Team 建置、跟 security、monitoring 團隊共同維護的核心機器映像。裡面裝的是每台機器都該有的東西:作業系統基礎與 hardening、network 與 service discovery 設定、監控與安全 agent、共用工具。過去 Slack 用 Packer 建這類映像,Shipyard 換成 AWS Image Builder——換工具的理由很具體:Image Builder 自帶 lifecycle management,舊 AMI 用完自動清掉、省儲存成本;發佈前它會自己起臨時 instance,跑一輪驗證測試再放行。slack-zero 每次更新,會同步更新一個 SSM parameter,指向該帳號目前最新的 AMI 版本——service pipeline 讀這個 parameter,保證自己永遠是拿最新的基底在建置。真正讓這條鏈自動接起來的是 EventBridge 加 Lambda:slack-zero 烘焙成功的那一刻,EventBridge 抓到事件,Lambda 自動觸發下游、各服務自己帳號裡的 pipeline,把依賴 slack-zero 的映像通通重新烘一輪。

這條鏈把責任切成清楚的兩層——base 層(全域基礎設施元件、安全修補、必要設定)歸 Compute、Security、Monitoring 團隊管;service 層(自己的軟體、自己的設定)歸各服務團隊管。但責任邊界不是單向的:每次 Compute team 推出安全修補、監控 agent 更新或網路變更,service team 就有義務把新版基底併回自己的 AMI——共享基礎設施換來的是一致性,代價是下游必須主動跟進版本。這個「代價」原本是這種分層模型最大的風險:如果併回新版基底全靠 service team 自己記得,一份安全修補發出去之後,實際生效的時間就取決於幾十個團隊各自的行事曆。EventBridge 加 Lambda 的自動觸發機制,補的正是這個洞——slack-zero 一烘好,下游 pipeline 立刻被動觸發重建,不必等 service team 主動想起這件事,把「發佈新基底」跟「下游真正用上新基底」之間的落差,從人為排程壓成自動化流程。

點擊任一層看它是在 bake 時凍住,還是開機後仍能動 · 3 層

runtime secrets · Consul Template
service AMI
slack-zero 基底

click a layer above

基底層 · 在 bake 完就凍住

OS baseline、network/service discovery 設定、監控與安全 agent,全部在建 slack-zero 映像的當下就定案,寫進 AMI 裡。這層在整個 instance 存活期間不會被就地修改。

service 層 · 一樣在 bake 完凍住

各服務團隊自己的軟體與設定,在建 service AMI 的階段裝好、鎖進去。跟基底層一樣,活著的 instance 不會被拿來就地改這層——要改就是重建一版新 AMI、重新走一次部署。

secrets 層 · 開機後仍可以動

每台 instance 跑著 Consul Template,可以把 Vault 裡更新過的 secrets 動態捲出去,不必為了換一把金鑰重建整個 fleet。Shipyard 把這個狀態稱為「semi-immutable」——核心層凍結、secrets 層可以按需安全更新。

bake 與 provision 分工:把重活挪到開機之前

service AMI 疊上去之後,真正決定「開機要多久」的是 bake 跟 provision 這兩個階段怎麼分工。bake 階段做的是跨環境都一樣的重活——裝套件、寫進跟環境無關的設定,全部在建映像的時候就做完、鎖進 AMI 裡。provision 階段反過來,刻意做得很輕——只處理跟環境相關的東西:secrets、region 設定、deployment metadata,在 instance 真正開機那一刻才套用,通常就只剩下丟設定檔、抓 secrets、啟動服務這幾件事。把套件安裝這種重工作挪出 hot path 之後,效果很直接——原文的講法是「instances can become operational in seconds rather than minutes」,沒有給出更精確的秒數,但方向很明確:重工作挪到 bake、輕工作留給 provision。這個時間差背後的機制其實很直觀:套件安裝、依賴解析這類 bake 階段的工作,本質上是磁碟 I/O 跟網路下載主導,同一份套件清單、同一個環境,不管烘幾次結果都一樣——與其讓每一台新開的 instance 在開機當下重跑一次這段耗時的工作,不如烘一次、把結果凍進 AMI,之後每一次開機都直接繼承。provision 階段留下的 secrets、region 設定、deployment metadata,則是那種「非做不可但做起來很快」的事——寫幾個設定檔、打一兩支 API 抓 secrets、啟動 process,這些操作量體小,自然快。

點底線詞看該階段實際做了什麼 · 2 個階段、4 個詞

bake 階段 在映像裡定案

套件安裝During the bake phase, we install packages and include configuration that is consistent across environments.跨環境一致設定跟環境無關、每個環境都一樣的設定,在建映像時就寫進 AMI,不用每次開機重做。 都在這裡做完,寫死進 AMI。

provision 階段 開機那一刻才做

只剩下 secrets / region / metadataEnvironment-specific settings, such as secrets, regional configuration, or deployment metadata, are applied during the provisioning phase when the instance boots.啟動服務provisioning 階段 intentionally lightweight,typically involves only dropping configuration, retrieving secrets, and starting services。 這幾件事,刻意做得很輕。

Peekaboo:盤點系統接手 source of truth

舊模型底下,Chef Server 兼職做了盤點系統的角色。Peekaboo 直接換掉這個角色:用 AWS EventBridge、OpenSearch、Lambda 這三樣東西組出來,直接接雲端事件跟 instance metadata,對整個 fleet 有更即時的 telemetry。它甚至能追蹤不是走 Shipyard 部署出來的 instance——這代表 Peekaboo 給的是整個 fleet(不只是 Shipyard 管的那部分)唯一一份完整檢視。合理的推測是,這也是為什麼 Slack 需要一個跟部署機制脫鉤、自己接雲端事件的獨立盤點系統:只靠部署工具回報,天生就看不到部署工具管不到的那些機器。介面上它同時給了三種入口:給人看的 UI 可以直接瀏覽 fleet、給系統串接的 API、還有給工程師在終端機裡快速查一台機器狀態的 CLI——三種入口對應的是三種不同的使用情境:排查事故時開 UI 看全貌,寫自動化時走 API,手邊沒瀏覽器時用 CLI 查一台機器。

Gondola:把 AMI 與 Chef artifact 打包成一個部署單位

Gondola 負責把「要部署什麼」這件事變成一個具體、可追溯的產物。它產出的部署包永遠是兩塊東西合在一起:要推上 fleet 的 AMI,還有裝著版本化 recipe、綁定一個特定 git commit 的 Chef artifact——Gondola 把這兩塊當成單一可部署單位處理,不會讓 AMI 版本跟 Chef 版本各走各的。落地機制依 fleet 型態分兩條路:走 Auto Scaling Group 的,executor 更新 launch template 上的 AMI 跟設定,Chef code 包進 S3,新開的 instance 用內建的 bootstrapper 去抓正確的 artifact、跑對應的 recipe;走 Kubernetes worker 的,executor 直接告訴 Karpenter 該用哪個 AMI,附上同一份設定 metadata。兩條路徑的差異可以概括成「誰主動抓」:ASG 那邊是新開的 instance 自己靠內建 bootstrapper 去抓 artifact 來跑;Karpenter 那邊反過來,是 Gondola 主動把該用的 AMI 告訴 Karpenter,instance 自己不用找——這個對比是兩段原文各自描述的機制合起來看的結果,原文本身沒有直接點名這個差異。艦隊層級的滾動更新,ASG 那邊靠 AWS Instance Refresh 逐批換,Kubernetes worker fleet 則交給 Karpenter 做 lifecycle-driven 的汰舊換新。合理的推測是,需要兩種 executor 的根本原因,是這兩種艦隊本來就是不同的管理模型——ASG 是一群共用同一份 launch template 的傳統 EC2 instance,「滾動更新」的意思是照批次把舊 instance 換成新設定;Karpenter 管的是跟著 pod 排程動態長出來的 Kubernetes worker node,node 的生死本來就綁在叢集排程需求上,不是靠一份固定的 launch template 驅動。Gondola 把這兩種完全不同的底層機制,都包進同一個「執行一次部署」的介面裡,服務團隊面對的永遠是同一套操作,不需要自己搞懂 ASG 跟 Karpenter 各自的細節。對有特殊部署需求的服務,Gondola 把加新 executor 這件事做得容易——不需要為了一種特例改動核心邏輯。

Reaper:用「重新部署」取代「就地修改」

Shipyard 把「不可變」落實成一個持續運作的系統,而不是一次性的部署動作,靠的就是 Reaper。Reaper 收兩種輸入來決定要不要換掉一台 instance:一種是外部系統丟過來的訊號——安全工具或 AWS EC2 事件回報這台機器可能已經偏離期望狀態,被標記成「tainted」;另一種是它自己排程檢查,揪出活得比允許壽命還久的 instance。任一條件成立,這台 instance 就依照該服務的政策排入替換。這個機制連「人手動連進去」這個動作都算進去——緊急狀況下工程師還是可以手動遠端登入 production-class node,但這個動作本身會產生一個訊號,把該 instance 標記成該淘汰,呼應「基礎設施靠重新部署更新、不是就地修改」這個原則。這正好呼應開頭那個反直覺的畫面——連工程師自己手動連進去看一眼,都會被系統記一筆、當成該汰換的理由。不是因為連進去做了什麼壞事,而是因為「被人手動碰過」這件事本身,就違反了不可變基礎設施的假設。合理的推測是,背後的邏輯是——一台機器一旦被手動改動過,它跟「這版 AMI 應該長什麼樣子」之間的對應關係就不再可信,乾脆換一台重新從映像長出來,比去驗證這台機器現在到底處在什麼狀態要簡單。Slack 自己的講法是這套做法「helps reduce configuration drift, limit exposure to vulnerabilities」——用的是「helps」,不是宣稱能徹底消除。

直接放手讓 Reaper 全速汰換有明顯風險——同一批次換太多台,容量會出問題。內建的 rate limiting 讓服務 owner 自己定義單次最多能同時換幾台,而且可以按 service、region 或 availability zone 分開設定上限,避免一次性的容量衝擊。緊急時還有一個「big red button」——在 S3 放一個控制物件,就能讓整個 fleet 的 Reaper 活動全部暫停。

拖曳滑桿看 rate limit 怎麼卡住同時替換的規模 · 36 台示範艦隊

15%

目前設定下,5 / 36 台示範艦隊會同時進入替換佇列——數字是示意用來說明 rate limit 的效果,不是 Slack 實際配置的門檻;真正的上限是按 service、region、AZ 各自設定的。

「不可變」在這裡有一個明確的例外:secrets。每台 instance 都跑著 Consul Template 服務,可以把 Vault 裡更新過的 secrets 動態捲出去,不必為了換一把金鑰重建整個 fleet。Shipyard 自己把這個狀態稱為「semi-immutable」——core system 跟 service 層維持一致、凍結在 bake 當下的狀態,但關鍵的執行期 secrets 可以按需安全更新。合理的推測是,這個例外是「不得不」的妥協——secrets 輪替本身通常是安全維運裡的高頻動作,如果連換一把金鑰都得整個 fleet 重新烘、重新部署一輪,不可變模型的維運成本會高到讓 secrets 輪替被拖延,反而製造出新的安全風險。把 secrets 單獨切成一層、交給 Consul Template 動態推送,讓「其他一切都不可變」跟「secrets 要能隨時安全更新」這兩個互相拉扯的目標,各自留在自己的層裡處理。

Ship Quick 與規模:cookbook 怎麼在合併前先跑一次

service team 想確認自己改的 Chef cookbook 有沒有問題,不必等真的部署上 production 才發現。Ship Quick 給的是一個快速回饋迴路:開發者在 cookbook repo 裡跑一個 CLI 指令,YAML 檔案定義好測試案例,Ship Quick 把 cookbook 打包、上傳到 S3,送一則工作訊息進 queue。接手工作的是一批 worker instance,由一個叫 Longshoremen 的輕量 process 管理——它把自己從所屬的 Auto Scaling Group 摘掉、跑 Chef workflow、把 log 即時串回 CLI,跑完就終止,除非開發者選擇留著除錯。合理的推測是,先摘掉 ASG 這一步是為了不讓 ASG 自己的健康檢查跟自動擴縮邏輯,誤把一台正在跑測試用 cookbook、狀態本來就不穩定的 worker 當成異常實例處理。Longshoremen 分成兩支獨立的 worker fleet,原因是 bootstrap 順序的物理限制:vanilla Ubuntu fleet 專門烘焙跟測試 slack-zero 本身——它不能疊在自己身上,一定得從乾淨的 Ubuntu AMI 開始;slack-zero fleet 服務各團隊的 cookbook,這些 cookbook 本來就依賴已經烘好的 slack-zero AMI。

五個元件的定位、技術堆疊、跟它取代或對應的舊機制。
元件定位技術堆疊取代 / 對應機制
slack-zero黃金基底映像AWS Image Builder + SSM + EventBridge/Lambda取代舊有的 Packer 建置流程
Peekaboofleet 盤點與 source of truthAWS EventBridge + OpenSearch + Lambda取代 Chef Server 兼職的盤點角色
Gondola部署編排ASG Instance Refresh / Karpenter executor把 AMI 與 Chef artifact 綁成單一部署單位
Reaper生命週期管理tainted 訊號 + 排程壽命檢查 + S3 控制物件用重新部署取代就地修改
Ship Quickcookbook 快速測試CLI + S3 + queue + Longshoremen worker讓改動在合併前先跑一輪,不必等到部署上線

整套系統目前撐起的是「tens of thousands of EC2 instances」——Slack 自己的說法是這讓他們在這個規模下拿到遠比過去更高的可靠性與操作控制力,但文章沒有給出更精確的數字,也沒有公布延遲、失敗率或成本方面的具體資料。架構上 Shipyard 從一開始就設計成同時支援多種 CPU 架構——AMD64 跟 ARM 架構的 Graviton instance 都在內,作業系統也涵蓋 Ubuntu、RHEL、Amazon Linux 多種選擇。這套模型特別適合那些沒辦法搬進容器的工作負載——基礎設施元件本身、Kubernetes worker node,還有 Slack 自己的 egress network stack。合理的推測是,這種「先分層、再自動汰換」的模式之所以能撐住數以萬計的 instance,關鍵不在單一元件有多聰明,而在 Peekaboo、Gondola、Reaper 三者各自只做一件事、彼此靠事件跟 metadata 鬆耦合串起來——任何一層出問題,換掉那一層的實作都不必牽動其他兩層。

不可變帶來的能力:當 instance 不再是拿來慢慢調整的東西,而是每次都從版本化的映像重新長出來,fleet 就能用「重新部署」取代「就地修改」來處理漂移、修補與汰舊——一台機器該不該活著,變成一個可以被系統持續檢查的問題,而不是靠人記得。