如同昨天所說,當硬體規格決定後,接下來就是要安裝作業系統、軟體工具和服務,測試 OK 後寄到美國去,在客戶的餐廳門市中運作起來。如果只是三五間店,當然是工程師自己在辦公室裝一裝,然後快遞到美國去就好了,但當每次出貨都是幾百台、一年要出上千台的時候,就不得不考慮全自動安裝、甚至要讓硬體廠商在生產線上直接幫我們把軟體也裝完,然後從工廠直接出貨到美國去。
我知道很多人這時候馬上想到:用 Clonezilla 或者 Ghost 之類的 Disk Image 工具就搞定了。但是,我們團隊的智慧財產有一大部分就在這台機器裡,而機器的所有權屬於客戶、實體又遠在美國,並不受我們直接管理,所以必須加密硬碟分割、甚至整顆硬碟來保護智財。硬碟加密不是什麼新鮮事,但解密的金鑰要放在哪裡?如果是個人電腦,還可以讓使用者在開機後輸入密碼,但我們的機器沒接鍵盤,當然也不可能請客戶在每次重開機後幫我們敲密碼;如果只加密主要分割、把密碼寫在未加密的啟動分割,那又形同把鑰匙直接掛在門上。這個問題我們當然不是第一個遇到的,Ubuntu 生態系也早有解決方案,就是 LUKS + TPM。
TPM 是一顆焊在主機板上 (或整合進 CPU) 的獨立安全晶片,它可以替你「封存」(seal) 一段秘密,而解封的條件不是密碼,而是「這次開機的過程必須跟封存當時一模一樣」。TPM 裡有一組叫 PCR (Platform Configuration Register) 的暫存器,開機時 UEFI 韌體會把每個載入的元件 (韌體本身、Secure Boot 狀態、bootloader、kernel、initramfs……) 算出 hash 後依序「延伸」進對應的 PCR。這個運算是單向的,任何一個環節被改動過,最後的 PCR 值就完全不同,也無法事後改回來。
LUKS 則是 Linux 標準的磁碟加密層:整個分割用一把隨機產生的 master key 加密,這把 master key 再被多個 key slot 各自包起來,任何一個 slot 打得開就能解出 master key。傳統做法是某個 slot 用人敲的密碼保護;我們要做的是多加一個 slot,把它的金鑰交給 TPM 封存,並綁定「PCR 必須等於出貨時的量測值」這個條件。
兩者串起來的開機流程是:韌體逐步量測並填入 PCR,接著 initramfs 裡的解密工具 (例如 systemd-cryptenroll 或 Clevis) 向 TPM 要求解封,TPM 比對目前的 PCR 與封存時的政策,一致就吐出金鑰、不一致就拒絕,最後用這把金鑰開啟 LUKS、掛載 root 分割,繼續開機。全程不需要任何人輸入,磁碟上也不存在任何明文密碼。
妙處在於:把 SSD 拔下來插到別台機器,因為沒有那顆 TPM,金鑰解不出來;就算在原機上,只要有人改了 bootloader 或 kernel 想偷金鑰,PCR 就對不上,TPM 同樣不開門。金鑰從頭到尾沒離開過那顆晶片,這就是它比「密碼藏在啟動分割」安全得多的原因。
附註:當 Ubuntu 在 23.10 推出 TPM-backed FDE (Full Disk Encryption)、24.04 LTS 延續提供時,我們也第一時間評估了這個官方的全盤加密方案,但仔細一看限制不少:它只出現在 Ubuntu Desktop 的圖形安裝程式裡,Ubuntu Server 的安裝程式根本沒有這個選項;整個功能在 24.04 仍標示為 experimental,前提是要有 TPM 2.0 並開啟 Secure Boot;而且為了讓開機鏈能被量測,kernel 和 bootloader 必須改用 Canonical 提供的 snap 版本,代價是 DKMS 這類第三方核心模組 (例如 NVIDIA 驅動) 當時並不支援,也不能在 TPM 之外再加一層開機密碼,只能靠 recovery key 救援。對一台不裝桌面環境、還要跑 GPU 的伺服器來說,這條路基本上走不通。
至於自動安裝,Ubuntu Server 從 20.04 開始就有 autoinstall 解決方案,GitHub 上也能找到適用各個版本 Ubuntu Server 的懶人包:Ubuntu 20.04 我們用這個 repo,24.04 則用這個 repo,礙於篇幅,我們只重點講三件事:
這三件事在產線上串成同一條流程:USB 開機後由 autoinstall 完成分割、加密與 OS 安裝,late-commands 接著離線裝好套件、放妥檔案;重開機之後 firstboot 接手,載入 Docker image 並完成 TPM 綁定;最後再重開機一次,做出貨前的驗收。以下三節依序展開。
autoinstall 的 packages: 區塊雖然可以列出要裝的 APT 套件,但它是安裝過程中直接連 APT repository 抓的,在工廠產線上這件事基本上做不到 — 接網路線會很麻煩,下載可能會很慢,整個安裝流程的時間不可控,更麻煩的是上游套件版本隨時會變,今天裝出來的機器和上個月出貨的可能不一樣。我們的做法是用 apt-offline 把「解析依賴」和「下載套件」這兩件事搬到辦公室先做完,產線上只做離線安裝。
第一步在一台跟出貨機同版本的參考機上產生簽章檔,描述我們想裝哪些套件 — 依賴解析參照的是這台機器當下已裝的套件狀態,所以參考機的系統版本必須跟出貨機一致;第二步在有網路的機器上照著簽章檔把所有 .deb (含依賴) 打包成一個 bundle:
# 參考機(離線):只解析需求,不下載
sudo apt-offline set /tmp/pkgs.sig \
--install-packages docker.io nvidia-driver-570 jq nvme-cli
# 有網路的機器:依簽章檔抓齊所有 .deb 並打包
apt-offline get /tmp/pkgs.sig --bundle apt-offline-bundle.zip
接著把 bundle 和 apt-offline 本身的 .deb 一起放進客製 ISO (例如 /extras/),再由 autoinstall 的 late-commands 用 curtin in-target 進到剛裝好的系統裡執行離線安裝:
late-commands:
- cp -r /cdrom/extras /target/var/tmp/extras
- curtin in-target --target=/target -- bash /var/tmp/extras/offline-apt.sh
#!/bin/bash
# /extras/offline-apt.sh
set -euo pipefail
cd /var/tmp/extras
# 先用 dpkg 裝 apt-offline 本體,解開「要裝工具卻沒網路」的死結
dpkg -i apt-offline_*.deb
# 把 bundle 裡的索引與 .deb 灌回 APT 的 cache
apt-offline install apt-offline-bundle.zip
# --no-download 確保只吃 cache,即使有網路也不會上網抓
apt-get install -y --no-download --fix-missing \
clevis-tpm2 clevis-luks clevis-initramfs initramfs-tools tss2 openvpn snapd python-is-python3 python3-pip ffmpeg docker.io docker-compose-v2
rm -rf /var/tmp/extras
這樣一來,同一份 ISO 在任何一條產線、任何時間裝出來的機器,套件版本都完全一致,也不需要為產線準備對外網路;要升級套件時就重新跑一次 apt-offline get、換掉 bundle、重新出一版 ISO,版本控制也順便有了。
我們的服務都跑在容器裡,但產線上同樣沒有網路可以 docker pull,而且就算有,也不希望出貨機器去碰 registry — 拉到的 image 會不會跟當初測過的那一版一模一樣,沒人敢保證。docker save / docker load 正好解決這件事:在辦公室把測試通過的 image 匯出成一份 tar,跟著 ISO 一起送到產線,裝機時直接載回本機的 image store。
匯出時直接指定多個 image,Docker 會把共用的 layer 收在同一份 tar 裡,比一個一個存省很多空間;tag 一定要鎖到明確版本,不要用 latest:
# 有網路的機器:拉到測試過的版本,一次打包
docker pull ghcr.io/berry-ai/inference:1.8.3
docker pull redis:7.2-alpine
docker save ghcr.io/berry-ai/inference:1.8.3 redis:7.2-alpine \
| gzip -1 > images.tar.gz
不過 docker load 需要 dockerd 正在運作,而在 autoinstall 執行的當下,新系統還沒有真正開機 — late-commands 只是 chroot 進剛裝好的檔案系統執行指令,裡面的 systemd 服務 (包括 docker.service) 都還沒啟動。所以我們把載入 image 這一步留到第一次正常開機之後:安裝階段只負責把檔案放定位,並在 cron 註冊 @reboot 任務,重開機後再由它接手完成載入。
#!/bin/bash
# /extras/offline-docker.sh,一樣由 late-commands 的 curtin in-target 呼叫
set -euo pipefail
install -D -m 644 /var/tmp/extras/images.tar.gz /opt/berry/images.tar.gz
install -D -m 644 /var/tmp/extras/docker-compose.yml /opt/berry/docker-compose.yml
install -D -m 755 /var/tmp/extras/firstboot.sh /opt/berry/firstboot.sh
# 把「重開機之後才能做的事」排進 cron 的 @reboot
cat > /etc/cron.d/berry-firstboot <<'EOF'
@reboot root /opt/berry/firstboot.sh >> /var/log/berry-firstboot.log 2>&1
EOF
chmod 644 /etc/cron.d/berry-firstboot
#!/bin/bash
# /opt/berry/firstboot.sh,重開機之後由 cron 接手
set -euo pipefail
# @reboot 有機會比 docker.service 早跑,所以先等 daemon 起來
for _ in {1..60}; do
docker info >/dev/null 2>&1 && break
sleep 2
done
docker load -i /opt/berry/images.tar.gz
docker compose -f /opt/berry/docker-compose.yml up -d
# 收尾:十幾 GB 的 tar 沒必要留在出貨機上,也把自己從 cron 拿掉
rm -f /opt/berry/images.tar.gz
rm -f /etc/cron.d/berry-firstboot
docker-compose.yml 裡記得把 image 寫成跟 docker save 時一模一樣的完整 tag,加上 pull_policy: never,避免哪天機器連上網路時偷偷去 registry 重拉;容器本身用 restart: always,之後每次重開機都由 Docker 自己拉起來,不必再靠 firstboot。產線裝完機後,只要 docker images 對一下 image ID,就知道出來的機器跟辦公室測過的是不是同一份。
前面兩件事都只是「把東西裝進去」,最後這件才是我們一開始的目的:讓整顆 SSD 拔走也讀不出東西。autoinstall 本身支援加密的 LVM,只要在 storage 區塊給一組密碼,裝出來的 root 分割就是 LUKS:
storage:
layout:
name: lvm
password: <安裝期間用的暫時密碼>
但這組密碼是明文寫在 user-data (autoinstall 的設定檔) 裡的,等於拿到 ISO 就看得到,所以它只能是「暫時」的 — 真正的目標是把金鑰交給 TPM,然後把這個 key slot 砍掉。這件事也只能在要被安裝的機器上做,因為 TPM 要比對的是這台機器最終的開機鏈,PCR 值在 ISO 上根本還不存在。綁定的工具我們選 Clevis 而非 systemd-cryptenroll:後者要到 systemd 248 才支援 TPM2,Ubuntu 20.04 的 systemd 245 上根本沒有這個工具,用 Clevis 可以讓 20.04 和 24.04 共用同一套流程。而流程中本來就安排了一次重開機,綁定就接在 firstboot.sh 的後半段:
# /opt/berry/firstboot.sh 後半段:把 LUKS 綁上 TPM
TMP_PASS='<跟 user-data 裡同一組暫時密碼>'
LUKS_DEV=$(lsblk -pnro NAME,FSTYPE | awk '$2=="crypto_LUKS"{print $1; exit}')
# 產生一把新金鑰封存進 TPM,解封條件是 PCR 7(Secure Boot 狀態與金鑰)
echo -n "$TMP_PASS" | clevis luks bind -d "$LUKS_DEV" tpm2 \
'{"pcr_bank":"sha256","pcr_ids":"7"}' -k -
# 讓 initramfs 帶上 clevis 的自動解鎖流程
update-initramfs -u -k all
# 確認 TPM 的 slot 真的建起來了,才把安裝用的暫時密碼移掉
clevis luks list -d "$LUKS_DEV" | grep -q tpm2
cryptsetup luksRemoveKey "$LUKS_DEV" <<<"$TMP_PASS"
pcr_ids 要綁哪幾個,是安全性和可維運性之間的取捨:
| 綁定的 PCR | 量測範圍 | 例行更新 (換 kernel、重建 initramfs) | 代價 |
|---|---|---|---|
| 7 | Secure Boot 狀態與金鑰 | 值不變,照常開機 | 防護範圍較窄 |
| 0、4、7 | 韌體、bootloader、Secure Boot | 值改變,必須先重新 bind | 更新必須與 re-bind 配套執行,中途失敗機器就開不起來 |
我們選擇只綁 PCR 7:對一台遠在美國、沒有鍵盤也沒有螢幕的機器來說,一次例行更新就變磚的代價太高。另外我們一定會再 enroll 一組 recovery 密碼,存在公司自己的保險庫裡,萬一主機板送修換掉 TPM,還有救回資料的機會。
最後一步是驗收:出貨前再重開一次機,確認整台機器可以在沒有人敲密碼的情況下開到底、服務也自己起來,再用 cryptsetup luksDump 看一眼 key slot 只剩 TPM 和 recovery 這兩個。到這裡,一支 USB 插上去、開機、等它自己跑完兩輪重開機,出來的就是一台軟體齊全、磁碟全加密、而且每一台都長得一模一樣的機器 — 這也是我們敢讓硬體廠直接從工廠出貨到美國的原因。
回頭看,整套做法就是 autoinstall 加上三個離線化的手段:APT 套件用 apt-offline 事先抓齊並鎖定版本,Docker image 用 docker save 事先打包,兩者都跟著 ISO 走,裝機過程完全不碰網路;至於 LUKS,安裝階段先用一組暫時密碼,第一次重開機後由 firstboot 把金鑰綁進 TPM、再移除暫時密碼 — PCR 值只存在於裝好的那台機器上,所以這一步也只能留到最後在機器上做。
當然,這套流程也有它的邊界。LUKS + TPM 守的是「機器被關機、SSD 被拔走」的情境;機器運轉中資料是解密狀態,作業系統層的防護一樣少不了。另外,ISO 終究只是出貨當下的快照,機器裝好、送進美國的門市之後,軟體更新、設定調整、故障排除,全都得靠遠端連線來做 — 但這些機器躲在客戶內網的深處,連一個對外的 IP 都沒有,要怎麼從台灣連進去呢?這就是明天的主題了。
本系列由 Berry AI 工程團隊出品。更多工程實戰紀錄都在 Berry AI 技術部落格。