Author Avatar

Raiven Kao

a piece of me
k3s Ingress Controller 從 Nginx 遷移到 Traefik 同時整合 CrowdSec

k3s Ingress Controller 從 Nginx 遷移到 Traefik 同時整合 CrowdSec

TL;DR 馬上就看到有小王八蛋想壞壞 前言 2025 年 11 月,Kubernetes SIG Network 發布了一篇公告:ingress-nginx 將正式退役。 這個消息讓不少人震驚,但嚴格來說有個細節需要釐清。目前市場上有兩個長得很像的 nginx ingress controller: kubernetes/ingress-nginx:由 Kubernetes SIG Network 維護,這次宣布 EOL 的就是它。 nginx/kubernetes-ingress(又稱 NKS):由 F5 / NGINX Inc. 維護,目前仍持續開發中。 我的 homelab k3s cluster 一直以來用的是 F5 維護的版本,嚴格來說不在這次 EOL 的範圍內。但這個公告還是給了我一個動力,與其繼續追蹤兩個版本之間的差異、擔心哪天又有什麼變化,不如趁這個機會把遷移一次做完。 遷移目標選定 Traefik v3,原因有兩個: k3s 內建 Traefik 作為預設 Ingress Controller,1.32 版本後升級為 Traefik v3,搭配 HelmChartConfig 管理,和 cluster 的整合度最高。 順手引入 CrowdSec 主動防護——長期以來我的服務靠著 Cloudflare proxy 當唯一守門人,但我一直想要在 Ingress 層就能即時判斷並封鎖攻擊者 IP,而不只是依賴前端的 CDN 過濾。 為什麼不選 Cilium Gateway API 在決定遷移到 Traefik 之前,我也閱讀了 Gene 的告別 ingress-nginx:Cilium Gateway API 遷移筆記,評估過 Cilium Gateway API 方案。Cilium 在 CNI 層整合了 Gateway API,理論上能提供更底層、效能更好的流量控制,且完全符合 Kubernetes Gateway API 的標準規範。 ...

用 Hetzner + Proxmox Backup Server 實踐 3-2-1 備份原則的異地備援

用 Hetzner + Proxmox Backup Server 實踐 3-2-1 備份原則的異地備援

前言 如果你有認真對待自己的資料,應該聽過 3-2-1 備份原則: 3 份備份 2 種不同的儲存媒介 1 份異地備援 前兩項在家中的 Homelab 環境相對容易做到,但「異地」這個字往往是大多數人的盲點。硬碟壞掉?用 Proxmox Backup Server (PBS) 還原。系統崩潰?從 PBS 拉一份回來。但若是家裡發生火災、淹水,或是更平凡的情況——你不小心把整台 Proxmox VE 的 NVMe 連資料一起刪掉——這時候同一個屋簷下的所有備份都會跟著陪葬。 我的 Homelab 環境是一台 Proxmox VE 搭配一台 Proxmox Backup Server,跑著包含 k3s、Vaultwarden 等各種服務。在研究了很久的異地備援方案後,我決定在歐洲架設一台 PBS,讓它透過 WireGuard 隧道主動把家裡的備份「拉」過去,讓 321 中的那個「1」真正落地。 這篇文章會記錄完整的架構決策過程,從為什麼選 Hetzner、選哪個方案、到實際的設定步驟,以及中間踩過的坑。 為什麼選 Hetzner Hetzner 是一間位於德國的基礎設施供應商,在評估異地備援的雲端供應商時,我的優先考量是:便宜、隱私安全、技術友善。 GCP / AWS 的問題 第一個念頭當然是最熟悉的 GCP 或 AWS,畢竟都是主流雲端。但把兩者丟進計算機後,答案很快就出來了: 以我「最終」需要的規格: 2 vCPU / 4GB RAM VM 80GB 高速 Storage 用於 PBS 備份 500GB 慢速 Storage 用於 NAS 備份 方案 預估月費 Hetzner- CX23- 80GB Volume- BX11 Storage Box 約 $15.73 USD AWS 同級配置- t3.medium- gp3 80GB- S3 500GB 約 $50 USD GCP 同級配置 相近 光是這個差距就已經結案了。AWS 和 GCP 的小型運算實例本身就已經比 Hetzner 貴好幾倍,Block Storage 的單價更是幾乎翻倍,再加上對外流量費用(下載資料還要另外收),災難復原時光是把 500GB 拉回來就可能要多付幾十美元。 ...

放棄 Electron 與 Tauri? 我用 Wails + HTMX 打造 3MB 的跨平台桌面應用

放棄 Electron 與 Tauri? 我用 Wails + HTMX 打造 3MB 的跨平台桌面應用

Web 仔要有 Web 仔的自覺 前言 前陣子收到了 Murphy 的需求,嘗試使用 Tauri 寫了一個桌面 App。Tauri 結合 Rust 與 React 的體驗相當不錯,打包出來的體積也十分理想。 但身為一個主要撰寫 Golang 的後端工程師,內心總有一個聲音:「如果能用 Go 來寫桌面應用,那該有多好?」 眾所周知,Golang 在原生的 Desktop GUI 框架上一直沒有特別強勢的殺手級專案(過去使用 Fyne 的經驗不算太好)。在環顧了一眾基於 Chromium 的 Electron 或是基於系統 WebView 的 Tauri 後,決定走一條稍微不一樣的「老路」:使用 Wails 搭配 HTMX 與 Templ。 最終的產物就是 Dub 一個跨平台的批次檔案重新命名工具。更甚,在 Linux 上打包出來的可執行檔只有約 3MB,並且完美支援 macOS 與 Linux,且幾乎沒有複雜的前端狀態管理負擔。 為什麼是 Wails + HTMX? 在選擇桌面應用程式的技術方案時,我們通常有幾個考量 Electron: 開發體驗極佳,生態系最豐富,但代價是動輒上百 MB 的體積與高昂的記憶體佔用。對於一個小工具來說,太過沉重。(這也是我優先選擇 Zed.dev 作為主要編輯器的原因) Tauri: 效能極佳,體積小,但後端需要寫 Rust。雖然 Rust 很香,但有時候只想用最熟悉的 Go 快速把邏輯刻出來。 Wails: 與 Tauri 類似,採用前端網頁技術結合系統原生 WebView,但後端語言是 Golang。 既然選了 Wails,前端該用什麼? 現代前端框架(React, Vue, Svelte)通常需要建置複雜的 SPA(Single Page Application),並透過 JSON API 與後端溝通。但對於一個狀態並不複雜的工具軟體,不想在前端再維護一份狀態。 ...

旅遊札記 - 2026 新年的關西遊

旅遊札記 - 2026 新年的關西遊

在 2025 Recap 的展望中寫下了「與 +1 首次踏足國外」,沒想到兌現的速度比預期快得多,2026 年的第二天,我跟 Murphy 就站在了關西國際機場。 會選擇日本關西作為首次出國的目的地,原因很單純:我們都各自來過,作為初次日本自由行,熟悉的地方還是比較安心。加上 2025 年 10 月才從五十音開始學日文,正好拿這趟旅程來驗收 N87 的日文實力(? 五天的行程,從京都走到大阪,從零度的細雪走到通天閣的暖陽。這篇拖了快兩個月才動筆的遊記,就當作是對這趟旅程的交代吧。 Day 1(1/2):初雪與初売り 搭乘星宇航空 JX820 班次,Airbux A330-900neo 機型,這次旅行選擇星宇也是為了體驗 Airbus 的飛機。11:10 分落地後,11:20 分就開始排隊出關(這邊禁止拍照),11:40 順利出關拿完行李!原本預計出關會要約莫一個半小時,訂了 12:44 分的 JR Haruka 由於關西國際機場極高的效率讓我們在機場空等了一小時 XD。 由於吃了暈機藥,在 Haruka 時仍持續處於昏迷狀態,抵達京都時已經下午兩點,隨即轉乘烏丸線到烏丸御池站,出站走了一小段路就到了這兩晚的住所,Hotel Resol Trinity Kyoto(京都RESOL TRINITY酒店)。 我與 Murphy 在這次旅行的分工是,我負責京都部份,Murphy 負責大阪部份,而京都的住宿,我只花了半小時搜尋「京都 住宿 ptt」就搞定了,我並沒有過多研究 Hotel Resol 整體的歷史等等,單純是因為飯店的窗戶有一個紙門設計,我就被迷惑了。好在運氣始終不錯,當從京都的大馬路上轉進小巷子,一間外裝不太起眼的飯店映入眼前,進入飯店後裝潢與外裝截然不同,雖然知道他是ホテル但濃濃的りょかん味,特別是一樓還有大浴場,這兩天我都在這邊洗澡,所以並沒有感受到評價所說的「浴室很小」的感覺。由於是新年期間,附近的學校也跟著放假,大廳還提供了原先供應給學校的鮮乳,屬實幸運。 下榻的飯店距離錦市場只有十分鐘的步行距離,放完行李後,緊接著步行前往藤井大丸,體驗日本新年的「初売り」,此時室外的溫度零度,天空飄下了細雪,即便十分興奮,還是會擔心外套濕掉。我高估了我耐寒的能力,只穿了短袖、中層與棉質的薄外套就來了這零度的京都,Murphy 在大丸買了件新的羽絨外套後,我就把他身上的搶過來穿,撐過了這幾天。買完戰利品後先回到飯店放下,畢竟我們接下來還有行程,這就是飯店距離商圈近的好處。 ...

Clean Craftsmanship:在 LLM 時代重拾軟體工匠精神

Clean Craftsmanship:在 LLM 時代重拾軟體工匠精神

為什麼在這個時間點讀這本書 最近對於如何在 LLM 時代帶領團隊一起提昇生產力感到困惑。當 AI 能在幾秒鐘內生成幾百行程式碼,「寫程式」這件事的門檻似乎降到了前所未有的低點。但門檻低了,品質呢? 當「每個人」都在養龍蝦,有了生產力焦慮,TypeScript 寫的 OpenClaw 才剛出現,Python 的 NanoBot 馬上跟上,接著是 Golang 的 picoclaw,然後 Rust 的 ZeroClaw 也來了。 根據 ZeroClaw 做的 benchmark,這些 Agent 已經降到小於 5MB 的記憶體佔用與 10ms 的啟動時間,「種族」為人類的我們對於產出軟體來說還剩什麼? 帶著這個問題,我決定先從自身出發,找出在 LLM 時代還能保持軟體工程「工匠精神」的誘因。於是翻開了 Robert C. Martin 的 Clean Craftsmanship。 這本書分為三個部份:紀律、標準、道德。一半以上的篇幅在講述 TDD 這個老生常談的開發方式,但透過 TDD,我們更能知道何謂軟體的「品質」。以下是我特別書籤的幾個段落。 紀律 童子軍規則 離開營地時,要比你來時更乾淨。 這也是在 Clean Code 一書就提到的概念。每次微小的重構,都能小程度的減少技術債的產生。不需要一次大刀闊斧,只要每次經過一段程式碼時,順手讓它變得更好一點。 讓我想到 Claude is not a senior engineer (yet) 這篇文章中提到的 Sweeks——一位被稱為「園丁」的 distinguished engineer,他不斷地重寫、收緊抽象,讓經過他手的程式碼都變得更乾淨。我們都想成為 Sweeks,對吧? 在 LLM 時代,AI 擅長的是「組裝」現有解決方案,但它缺乏 Sweeks 那種「看到可以更好的地方就會忍不住動手」的靈魂。童子軍規則提醒我們:這份靈魂不能丟。 Test Doubles 的正名 書中透過實戰的例子講述了所有 test double:Dummy、Stub、Spy、Mock、Fake。 坦白說,我曾經在諸多 repo 中看到這些名詞卻沒有實際使用它們。頂多在 DI 時製作了一個「用於模擬 database repository 的 implement」,或是使用了 gomock 這種套件來產生 mock,然後把所有替身都統稱為 mock。 ...

Golang 1.25 testing/synctest 初體驗:告別在測試中寫 time.Sleep 的日子

前言 在我們目前的應用程式架構中,由於高度與 Kubernetes 耦合,服務啟動與運作期間需要頻繁地去讀取 K8s 中的 ConfigMap。為了達成配置熱更新(Hot Reload),我們引入了 Kubernetes client-go 中的 informers 機制來監聽 ConfigMap 的 CRUD 事件。 雖然 K8s 官方提供了 fake client 讓我們能測試 informers 的邏輯,但在 Service Code 的層級,我們往往需要封裝一層更適合業務邏輯的 ConfigWatcher。Golang 引以為傲的輕量級 Goroutine 與 Channel 搭配非常適合用來處理這種非同步的事件傳遞。 然而,一旦涉及到 Goroutine 的非同步測試,「時間」往往就成了最大的敵人。 遇到的問題:不穩定的測試與魔法數字 為了模擬 ConfigMap 的變更通知,我們定義了一個 ConfigMapWatcher 介面與對應的 Event 結構: const ( ConfigMapUpdateEventTypeAdded ConfigMapUpdateEventType = iota ConfigMapUpdateEventTypeModified ConfigMapUpdateEventTypeDeleted ) type ConfigMapUpdateEvent struct { Name string Type ConfigMapUpdateEventType Value map[string]string } type ConfigMapWatcher interface { Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error } 接著,我們很自然地在 testing 中實作了一個 fake 物件來模擬事件發送: type fakeConfigMapWatcher struct { injectCh chan ConfigMapUpdateEvent watchErr error watchOnce sync.Once } func newFakeConfigMapWatcher() *fakeConfigMapWatcher { return &fakeConfigMapWatcher{ injectCh: make(chan ConfigMapUpdateEvent), } } func (f *fakeConfigMapWatcher) sendEvent(event ConfigMapUpdateEvent) { f.injectCh <- event } func (f *fakeConfigMapWatcher) Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error { if f.watchErr != nil { return f.watchErr } f.watchOnce.Do(func() { go func() { for { select { case <-ctx.Done(): return case e := <-f.injectCh: eventCh <- e } } }() }) return nil } 問題來了,當我們在寫單元測試時,呼叫 sendEvent 將事件送入 channel 後,消費者端(也就是我們的業務邏輯 Goroutine)並不會「立刻」收到並處理完成。為了確保 assert 斷言執行時,業務邏輯已經跑完了,我們被迫在測試中加入 time.Sleep: ...

RouterOS v7.21 container + App 組合拳,你的下一台 Router 也可以是 All In One Homelab

RouterOS v7.21 container + App 組合拳,你的下一台 Router 也可以是 All In One Homelab

前言 自從 RouterOS 在 v7.13 引入 container 功能後,這為我們這些 Homelab 愛好者開啟了一扇新的大門。我們終於可以在路由器上原生執行 container,這意味著一些網路相關的基礎設施服務(Infrastructure Services)可以從原本的伺服器中遷移出來。 以我為例,過去我的 Adguard Home 是 deploy 在我的 Homelab 伺服器群中。然而,作為一個喜歡「折騰」的工程師,Homelab 難免會因為硬體更換、系統升級或是實驗新架構而需要重啟。每當這時候,家裡的網路 DNS 解析就會中斷,隨之而來的就是家人的抱怨。 為了解決這個單點故障(SPOF)的問題,我決定將 Adguard Home 改為 deploy 在 Router (RB5009UG) 上。這樣做不僅減少了物理上的 latency,更重要的是將「網路服務」與「應用服務」解耦,避免因為 Homelab 的維護而導致全家斷網。 這是遷移後的狀態: [user@RB5009UG] > container print Flags: R - RUNNING Columns: NAME, ROOT-DIR, INTERFACE, MOUNTLISTS, ENTRYPOINT # NAME ROOT-DIR INTERFACE MOUNTLISTS ENTRYPOINT 0 R b51e0424-8fce-4c3d-b4bb-6493f76aa24b /usb1/container/adguardhome adguardhome adguardhomeConfig /opt/adguardhome/AdGuardHome RouterOS v7.21 的新變革:App 而在最近發布的 RouterOS v7.21 中,Mikrotik 給我們帶來了更方便的 container 部署方式,他們稱呼為 App。 ...

Container Image Optimization 那些年我們寫錯的 Dockerfile

最近在檢視公司內部的專案時,針對其中一個 container image 進行了優化。在一個簡單的 commit 後,我們的 image size 從 1.82GB 修正到了 1.18GB。 透過 dive 查看,Image Efficiency 更是從不及格的 69% 飆升到了 99%。 這讓我回想起過去寫 Dockerfile 時,常常因為不了解 Docker Layer 的機制,或是為了寫起來「方便」,而踩到了許多效能與安全的地雷。 致命的 chown 這次優化的核心,其實源自於一個非常常見的操作:修改檔案權限。 在我們的案例中,Dockerfile 原本是這樣寫的: # Bad Practice: recursive chown after copy FROM ubuntu:22.04 WORKDIR /app COPY . . # ... install dependencies ... RUN dpkg -i packages/*.deb # Change ownership for security reasons RUN groupadd -r appuser && useradd -r -g appuser appuser RUN chown -R appuser:appuser /app 看起來邏輯很正確:把檔案複製進去,安裝套件,最後為了安全性將檔案權限交給非 root 使用者。 ...

Golang 1.26 新特性在數量上史無前例的多

隨著時間來到 2026 年初,Go 語言迎來了 1.26 版本的更新。如果說 Go 1.18 的泛型是語言層面的重大變革,那麼 Go 1.26 則是在「數量」與「廣度」上讓人感到驚艷的一次釋出。從語言特性的語法糖、標準庫的實用擴充,到 Runtime 效能的顯著提升(Green Tea GC),甚至是實驗性的 SIMD 支援,這次的更新內容豐富到讓人目不暇給。 本文將挑選其中幾個我認為對日常開發最重要、或最有趣的改動來進行介紹。 語言層面的改動 new(expr):終於不用再寫輔助函數了 在 Go 1.26 之前,如果我們想要取得一個基本型別(如 int, bool, string)的 pointer,通常需要宣告一個變數或者寫一個輔助函數。這在定義 struct 的 literal 時特別煩人,尤其是當 struct 欄位是 *bool 或 *int 用來區分「零值」與「未設定」的時候。 回顧過去,我們為了這個小需求付出了不少努力: 在 Go 1.18 泛型出現之前:我們經常需要定義一堆如 Int64Ptr(v int64) *int64 或 Float64Ptr(v float64) *float64 的輔助函數(AWS SDK 的使用者應該對此非常熟悉)。 匿名函數大法:如果不想要定義全域的輔助函數,有時甚至會看到像 enabled := func(b bool) *bool { return &b }(true) 這種冗長且難讀的寫法。 泛型時代:雖然可以用一個通用的 ptr[T] 解決,但還是需要額外的程式碼。 以前我們可能需要這樣做: func ptr[T any](v T) *T { return &v } type Config struct { Enabled *bool } conf := Config{ Enabled: ptr(true), } 在 Go 1.26 中,內建的 new 函數得到了增強,現在它不僅接受型別,還可以直接接受表達式(Expression)。 ...

從 GrapheneOS 回歸原廠:Google Pixel 5 刷機紀錄

最近整理抽屜時,翻出了這台陪伴我一段時間的 Google Pixel 5。 身為一個喜歡折騰的開發者,早在它退役成為備用機時,我就第一時間把它刷成了 GrapheneOS。不得不說,GrapheneOS 在隱私保護和安全性上做得真的很好,沒有 Google Play Services 的純淨體驗也別有一番風味(雖然依賴 Sandboxed Google Play 還是能解決大部分 App 的問題)。 但就在前幾天,或許單純是想念 Pixel 原生系統的那些獨家功能(原生相機等等),我決定讓它「回歸原廠」。 原本以為又要經歷一番 adb 和 fastboot 的指令轟炸,還要自己去 Google 官網下載好幾 GB 的 Factory Image 壓縮檔,解壓後再祈禱 flash-all.sh 不要噴錯。 沒想到現在 Google 官方提供了一個方便的網頁工具:Android Flash Tool。 這篇文章簡單紀錄一下如何從 GrapheneOS 這種 Custom ROM,透過官方工具刷回 Stock Firmware 的過程。 為什麼選擇 Android Flash Tool? 以前刷機的標準作業流程(SOP): 搜尋正確的 Factory Image(還要對型號,買到電信商鎖定版就哭哭)。 下載 Android SDK Platform-Tools。 解鎖 Bootloader。 執行 flash-all script。 遇到驅動程式問題、傳輸線問題、路徑問題… 現在的 Android Flash Tool 直接把這些步驟簡化了: WebUSB: 直接透過瀏覽器與手機溝通,不用煩惱太底層的驅動設定(至少在 macOS/Linux 上是這樣)。 Auto Detect: 自動偵測裝置型號,自動下載對應的最新版韌體。 Fool-proof: 圖形化介面引導你開啟 USB Debugging 和 OEM Unlocking。 事前準備 雖然工具很強大,但基本的準備還是要有的: ...