Skip to main content

Wei Ji

我在工作上通常會用 Emoji 代表「已讀」,包含我日常使用通訊軟體也有這個習慣,前一陣子 Discord 幫我的帳號做的年度總結剛好紀錄了我這個習慣:

G 胖 (Gabe Newell) 曾經對「遊戲的樂趣」做出見解:「遊戲環境應該對玩家的行為做出反應,所以當你射擊牆壁時,它因此留下了彈孔」。

因此我認為「代表已讀的 Emoji」就是對方訊息的彈孔,不要讓對方產生「訊息投入虛空之中」的挫折感。

從另外一個角度來看,職場是一個需要確定性、可靠性的環境,即時通訊軟體的溝通更是如此,這是一個關於 TCP/UDP 通訊協定的笑話:

在 TCP 通訊協定中,雙方需要多次的 ACK (Acknowledge),確保封包有妥善送達而沒有遺失,如果有丟失的狀況就要重送來建立一個可靠的訊息交付管道;反觀 UDP 則是射後不理。

「代表已讀的 Emoji」就是一個 ACK,確保對方交待的事項你已經收到,並且開始承擔收到之後的責任。

你其實可以在無線電通訊觀察到類似的模式:「I COPY」「抄收」。

Wei Ji

前情提要

info

日期是根據 Git Commit 紀錄跟螢幕截圖時間推論的,但是不是真正精準的時間,因為 Git Commit 可能延後一段時間才推送,截圖的問題則是不是每一個當下都有截圖。

2025-10-04 左右: LiteLLM 和 Langfuse 上線。 2025-10-11 左右: Local Deep Research 上線。

2025-10-14 左右: 開始比較頻繁使用 Local Deep Research。

2025-10-22 左右:Homelab 開始出現當機異常。

  • 圖表的斷層就是伺服器完全當機讓資料採集的機能癱瘓造成的。
  • 可以觀察到 CPU 跟 RAM 異常使用是 Langfuse 服務簇的 ClickHouse 造成的。

2025-10-23 的紀錄:

2025-11-01 左右:連帶 Grafana 服務簇也失效,失效後就把 Langfuse 關掉然後沒有修復它了,直到最近 (2026-01) 佈署 K8s 才準備把服務遷移過去。

Grafana 失效前我有關查到 Docker 在重新拉取 image,推測是因為我忘記鎖定 image 版本造成的。


整個過程中出現過幾種不同的情況:

  • 電源鍵強制關機後恢復正常。
  • 電源鍵強制關機後 LiteLLM 異常。
    • Stack 刪掉後重新佈署後恢復正常。
  • 電源鍵強制關機後 Langfuse 異常。
    • 根據 clickhouse log 找到有問題的 block,刪除後重新佈署恢復正常(如下)。
2025.11.01 11:00:20.208590 [ 652 ] {c14fc20e-f2b0-45cb-939c-0fe47de9811c::202510_16681_17212_76} <Error> virtual bool DB::MergePlainMergeTreeTask::executeStep(): Exception is in merge_task.: Code: 40. DB::Exception: Checksum doesn't match: corrupted data. Reference: 52f594fc9d7f3ee0495f8f089d3882cf. Actual: c92bc03d0da25742da61b67ce5764aac. Size of compressed block: 3640. The mismatch is caused by single bit flip in data block at byte 2581, bit 2. This is most likely due to hardware failure. If you receive broken data over network and the error does not repeat every time, this can be caused by bad RAM on network interface controller or bad controller itself or bad RAM on network switches or bad CPU on network switches (look at the logs on related network switches; note that TCP checksums don't help) or bad RAM on host (look at dmesg or kern.log for enormous amount of EDAC errors, ECC-related reports, Machine Check Exceptions, mcelog; note that ECC memory can fail if the number of errors is huge) or bad CPU on host. If you read data from disk, this can be caused by disk bit rot. This exception protects ClickHouse from data corruption due to hardware failures: While reading or decompressing /var/lib/clickhouse/store/c14/c14fc20e-f2b0-45cb-939c-0fe47de9811c/202510_16681_17060_75/data.bin (position: 824782, typename: DB::ReadBufferFromFilePReadWithDescriptorsCache, compressed data header: <uninitialized>): (while reading column ProfileEvent_ReadBufferFromFileDescriptorRead): (while reading from part /var/lib/clickhouse/store/c14/c14fc20e-f2b0-45cb-939c-0fe47de9811c/202510_16681_17060_75/ in table system.metric_log (c14fc20e-f2b0-45cb-939c-0fe47de9811c) located on disk default of type local, from mark 2 with max_rows_to_read = 916, offset = 0): While executing MergeTreeSequentialSource. (CHECKSUM_DOESNT_MATCH), Stack trace (when copying this message, always include the lines below):
  • 電源鍵強制關機後 Langfuse 異常。

    • 根據 clickhouse log 找到有問題的 block,刪除後重新佈署依然無法恢復。
    • 把 clickhouse 建立全新的 Volume 後恢復正常。
  • 有幾次伺服器是完全沒螢幕反應,有反應的幾次可以在終端觀察到 CPU Lock:

watchdog: BUG: soft lockup - CPU#* stuck for **s!

仰賴鏈

  1. Biomes 重構的進展目前面臨大量的 Schema 型別修復工作,我需要借助 LLM 之力來解決。

  1. 我拒絕使用基於聊天或 IDE Agent 的 LLM 方案。
  2. 我想使用 ComfyUI 來構造專門用來處理 Schema 修復問題的 pipeline。
  3. 我在本地使用 LLM 工具我要求必須在有 LLM Observability 的前提下使用,所以我必須先修復我的 LLM Observability。
  4. 在有伺服器崩潰的前車之鑑,我要求必須在有 Observability 的前提下佈署 LLM Observability,所以我必須先修復我的 Observability。
  5. 但是我不想在現有的節點上修復 Observability,而是在新的節點使用 K8s 並佈署 Observability,因此我要先學習使用 K8s。

Observability 重建的當前狀態

到昨天 (2026-01-06) 為止,我已經:

  1. 設定新的節點,安裝 K3s。(2025-12-24)
  2. 整理之前跟 K8s 有關的知識點。(2025-12-28~2026-01-02)
  3. 釐清 Ingress/Load Balancer 的概念。(~2026-01-05)
  4. 釐清 PV/PVC 的概念。(~2026-01-05)
  5. 用 Helm 安裝 Longhorn。(2026-01-05)
  6. 用 Helm 安裝 Kube-Prometheus-Stack。(2026-01-06)

用 K8s 內的 Prometheus 和 Grafana 實例取代原本的,原本的兩張 Dashboard 則是本來就有 JSON 檔直接移過去。原本的伺服器指保留 Cadvisor 供新的 Prometheus 採集資料。

目前關於容器的 Dashboard 是處於兩個節點資料混在一起的狀態,暫時先不處理 Query 需要更新的問題:

Node Dashboard 則是直接接軌新的 Node Exporter 的資料,暫時也先不處理舊節點的資料:

info

Kube-Prometheus-Stack 其實有提供不少 Dashboard 用來觀察新節點,我就不在這邊一一列舉了。

Prometheus 安裝筆記

跟 Longhorn 一樣直接使用 Helm 安裝:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

helm repo update

helm show values prometheus-community/kube-prometheus-stack --version 51.6.0 >values.yaml

helm install prometheus-stack prometheus-community/kube-prometheus-stack \
-f values.yaml \
-n prometheus-stack \
--create-namespace \
--version 51.6.0

values.yaml 倒是有做一些調整:

  • 時區
  • 適應當前 K8s (K3s) 的 Ingress 為 Traefik
  • 對內網 DNS 暴露 Grafana
  • 適應當前 K8s 持久化實例為 Longhorn
  • 從舊 Node 的 Cadvisor 採集資料
grafana:
defaultDashboardsTimezone: Asia/Taipei
ingress:
ingressClassName: traefik
hosts:
- grafana.observable.gas.arachne

prometheus:
prometheusSpec:
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
additionalScrapeConfigs: |
- job_name: beta-cadvisor
static_configs:
- targets:
- web.cadvisor.arachne

LLM Observability 重建的當前狀態

LiteLLM 雖然在 OpenRouter 排行榜蟬聯前幾名,但是實際使用下來給我的感覺不太穩定(經歷過加密 Bug、當機後無法正常復位)。

另一方面,Langfuse 給我的感覺則是相對肥大(服務簇需要同時配置 ClickHouse、MinIO、Redis、PostgresSQL),但是我又沒有使用到裡面的每一個功能。更別提 ClickHouse 就是表面上這次事故的主因。

因此我打算評估其他 LLM Gateway/Observability 方案,目前看上 Bifrost,試用了一下感覺不錯,就是它的資料庫配置稍微有點麻煩,這個部份可能之後再提。

Wei Ji

K8s 不是一個完整解決方案,K8s 只是框架。

驚不驚喜?意不意外?

當你安裝了 Docker 之後就可以開始打包 image 並運行程式,甚至可以使用 Dokcer Compose 指令開始進行容器編排 (Orchestration)。

反觀,對於一個 K8s 使用者而言,生態系內有很多「口味」供你選擇:Minikube、MicroK8s、K3s、GKE、EKS...但是當你選擇原味 (Vanilla)時,你可能會發現歷經一番波折安裝完成之後,你還是什麼都不能幹:不能正常建立網路連線、不能正常的掛載持久化實體 (Volume)...

為什麼?因為 K8s 本身不是開箱即用解決方案,它只是一種框架。

不是開箱即用的網路

info

這個段落會多次提及 OSI 的 L4 和 L7,對兩者差異不清楚的讀者建議先去查點資料,不然閱讀起來可能會有點吃力。

我在前一篇文章中介紹了 Service,以及內網模式 ClusterIP 與節點開埠 NodePort,即便是 NodePort 也不像是標準佈署使用的模式,因為它只能開奇怪的埠號。這是因為 Service 其實還有兩個生產環境在使用的模式:LoadBalancer 模式或是加掛一個 Ingress。

以 ServiceLB (LoadBalancer) 為例,K8s 只提供一個 L4 的抽象層,具體的實做依然需要別人來完成:

info

LB (LoadBalancer) Controller 是一個方便讀者理解的稱呼,實際情況比較複雜,有其他專門的術語用來稱呼這個東西。

在雲端環境(如 Google, AWS)這是由雲端供應商實作的,可能是一團閉源商業軟體和硬體級 LB 整合而成;在 K3s 中這是由 klipper-lb 透過 hostPort 實作的。

Ingress 也是相同的狀況,K8s 只提供一個 L7 的抽象層,具體的實做依然需要別人來完成:

實作可以是 Nginx 也可以是 Traefik。

不是開箱即用的持久化儲存

K8s 在設計上就是一個分散式運行的框架,當 Pod 可能分散在多個不同的主機(節點)時,我們就不能像 Docker 那樣直接用某個路徑當作持久化實體。

在 K8s 的世界中,Volume 通常是一個 SDS (Software-defined storage); 在 K8s 的世界中,網路線就是 SATA 線。

細節我不在此解釋,簡單來說在 K8s 要幫 Container 掛載 Volume 需要經過層層抽象,並且最後實際的儲存實體並不在 K8s 內實作:

info

你也可以在 K8s 設定 storageClassName: local-storage 的 Volume,但是這樣你可能需要額外配置讓 Pod 只能佈署在特定的 Node 上。

info

Storage Backend 同樣是一個方便讀者理解的稱呼,並不是 K8s 內的標準術語。

Wei Ji

Longhorn 可以用很多方式安裝,包含最基本的 kubectl 指令:

kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.10.1/deploy/longhorn.yaml

不過等等!不要急著下指令!讓我們瞄一眼裡面有些什麼:

wget https://raw.githubusercontent.com/longhorn/longhorn/v1.10.1/deploy/longhorn.yaml

是的,這是一個有四千多行的 YAML,善於偷懶的聰明開發者們當然不會想直接跟這團東西打交道。

info

這個數字可以當作一個參考,一個完整的雲原生軟體佈署到 K8s 需要的聲明的資訊大概會達到這個量體。

Helm

就像 Longhorn 需要四千多行的 YAML 一樣,還有許許多多的雲原生應用軟體實際都是由大量的 K8s 資源或實體交錯編排而成,於是就有了 Helm 這個工具的誕生:一個 K8s 世界的 APT (Advanced Packaging Tool)。

在 Helm 的世界,這一堆聲明的 YAML 被包裝成一個稱作 Chart 的東西,「安裝」(佈署應用程式到 K8s)大概像這樣:

helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn \
--namespace longhorn-system \
--create-namespace \
--version 1.10.1

Helm 還有另外一個重要的功能:對 Chart 傳入變數,也是今天我要使用 Helm 的主要原因。

用 Helm 安裝 Longhorn

info

這個段落的內容主要參考 Longhorn 的官方文件1

首先下載參數檔:

curl -Lo values.yaml https://raw.githubusercontent.com/longhorn/longhorn/refs/tags/v1.10.1/chart/values.yaml

根據需求修改 values.yaml,以我目前的需求為例,分別是:

  • 持久化須儲存到外部 DAS 的掛載點。
  • 目前只有一個工作節點,所以副本只有一份。
defaultSettings:
defaultDataPath: /mnt/das-storage
defaultReplicaCount: 1

安裝 Longhorn 到 K8s:

helm install longhorn longhorn/longhorn \
--namespace longhorn-system \
--create-namespace \
--version 1.10.1 \
--values values.yaml

拉取 Image 需要時間,過程中可以使用指令檢查是不是所有 Pod 已經就位:

$ kubectl -n longhorn-system get pod
NAME READY STATUS RESTARTS AGE
engine-image-ei-3154f3aa-tsq6p 0/1 ContainerCreating 0 23s
longhorn-driver-deployer-58768fb7fd-ktpq8 1/1 Running 0 2m58s
longhorn-manager-bsqms 2/2 Running 2 (44s ago) 2m58s
longhorn-ui-7b9c99fd9-j8bxb 1/1 Running 0 2m58s
longhorn-ui-7b9c99fd9-w2l6b 1/1 Running 0 2m58s

執行 Tunnel 連線到 Longhorn 的 Dashboard2

kubectl port-forward \
service/longhorn-frontend \
-n longhorn-system 3002:80 \
--address 0.0.0.0

Footnotes

  1. Longhorn | Documentation. Retrieved 2026-01-05, from https://longhorn.io/docs/1.10.1/advanced-resources/deploy/customizing-default-settings/

  2. Longhorn 部署筆記 | 翠鳥圖書館 Project Halcyon Library. Retrieved 2026-01-05, from https://wiki.pha.pub/books/109-TAs/page/longhorn

Wei Ji

接續我在前一篇貼文指出的:K8s 本身只是框架,並沒有包含 SDS (Software-defined storage) 的具體實作:

Longhorn 則是其中一種 SDS 實作:

Longhorn 巧妙的運用 K8s 作為基礎設施,將 SDS 運行在 Cluster 內部,並且利用 K8s 的多節點特性運行多個實例,並在多個實例上建立資料冗餘實現分散式副本:

Wei Ji

前一陣子跟一個硬體工程師聊天,然後他順便推銷他們公司出的桌面燈,因為是他經手過的產品。老兄,不是我要貶低你的努力,只是我的消費水準是隨便找個鋁擠、幾顆 M4 螺絲、一支 USB 燈條就處理掉桌面照明的人,找我推銷桌面燈是真的找錯人了。

就算真的有興趣想買的,大概也是手術燈那種東西,畢竟在做事的時候光源被手或是頭擋住真的很煩。

Wei Ji

背景

我的 Homelab 是透過筆電和 USB 外接 DAS (Direct Attached Storage) 建構的,並且採過一些小坑。最近在建置新的節點時,發現我把相關的資訊分散在不同的筆記和 IaC (Infrastructure as Code) 裡面,於是想說趁這個機會整理一下資訊。

電池充電上限

Framework 的話可以在 BIOS 設定充電上限:

(從 Framework 的論壇借用螢幕截圖

另外一台 Lenovo 的筆電則是靠這個設定:

https://github.com/makifdb/lenopow

我會把把充電上限設在 80% 避免筆電作為伺服器長時間插著充電造成電池膨脹。

筆電螢幕問題

讓筆電不會因為螢幕蓋上而進入休眠模式,編輯 /etc/systemd/logind.conf

HandleLidSwitch=ignore

執行指令重啟服務:

systemctl restart systemd-logind.service

但是上述設定會造成另外一個問題:螢幕蓋著還是繼續發光,於是還需要一個步驟,編輯 /etc/default/grub

GRUB_CMDLINE_LINUX_DEFAULT 插入 consoleblank=60

這會讓螢幕在 60 秒後熄滅。

完整 Ansible Playbook
- name: Setup laptop
hosts:
- arachne-node-beta
tasks:
- name: check if consoleblank is configured in the boot command
ansible.builtin.lineinfile:
backup: true
path: /etc/default/grub
regexp: '^GRUB_CMDLINE_LINUX_DEFAULT=".*consoleblank=60'
state: absent
check_mode: true
register: grub_cmdline_check
changed_when: false
- name: insert consoleblank if missing
ansible.builtin.lineinfile:
backrefs: true
path: /etc/default/grub
regexp: '^(GRUB_CMDLINE_LINUX_DEFAULT=".*)"$'
line: '\1 consoleblank=60"'
when: grub_cmdline_check.found == 0
notify: update grub
- name: Set HandleLidSwitch
ansible.builtin.lineinfile:
backrefs: true
path: /etc/systemd/logind.conf
regexp: "#?(HandleLidSwitch=)(?:.*)$"
line: '\1ignore'
notify: Restart logind
handlers:
- name: update grub
ansible.builtin.command: update-grub
- name: Restart logind
ansible.builtin.systemd_service:
name: systemd-logind
state: restarted

DAS 掛載

當服務透過 Docker 跑在容器內,持久化資料卻儲存在透過 USB 連線的外部儲存中,這個仰賴關係需要額外處理。

自動化載

編輯 /etc/fstab 加入以下內容:

# DAS
UUID=7f858b7e-f942-45b3-92dd-8c99b497b6a4 /mnt/das-storage ext4 defaults,nofail,x-systemd.automount 0 2

修改後執行:

systemctl daemon-reload
info

UUID 可以透過 lsblk -f 之類的指令獲得。

設定仰賴

新增檔案 /etc/systemd/system/docker.service.d/override.conf

[Unit]
After=mnt-das\\x2dstorage.automount
ConditionPathExists=/mnt/das-storage
systemctl restart docker.service

確保 Docker Daemon 在 DAS 掛載後才運行。

DAS SMART 檢查

因為電腦沒有直接和硬碟建立連線而是隔著一層 DAS,因此不能使用普通的 smartctl 指令確認,而必須使用額外的參數1

smartctl -a -d jmb39x-q,0 /dev/sdd
smartctl -a --device jmb39x-q,1 /dev/sdd
smartctl -a -d jmb39x-q,2 /dev/sdd
smartctl -a --device jmb39x-q,3 /dev/sdd

Footnotes

  1. QNAP External RAID Manager - Export SMART Data - QNAP NAS Community Forum. Retrieved 2026-01-03, from https://forum.qnap.com/viewtopic.php?p=873575&sid=573c6149a1276e4ab8d9f4785f1c6029#p873575

Wei Ji

安裝

懶人安裝法:

curl -sfL https://get.k3s.io | sh - 

檢查一下有沒有正常:

sudo k3s kubectl get node 

設定

從安裝 K3s 的機器複製 /etc/rancher/k3s/k3s.yaml 到想要遠端連線的機器(Client 端)的 ~/.kube/config1

info

要留意 K8s 的憑證會過期這件事。

Footnotes

  1. Cluster Access | K3s. Retrieved 2026-01-02, from https://docs.k3s.io/cluster-access

Wei Ji

Pod

聲明一個 Pod:

apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080

根據聲明建立資源(在這個例子中是建立 Pod):

kubectl create -f sample.yaml

接著使用這個指令建立隧道 (tunnel):

kubectl port-forward awesome-pod 3001:8080

接著就可以透過這個連結訪問剛剛建立的 Pod 了: http://localhost:3001

這個通道是臨時的,中止指令就會消滅通道,使用過 ngrok 的人或許會對這種感覺不陌生,它就像是「反向的 ngrok」,暫時將 Cluster 內特定的資源暴露到本機上。

info

玩耍結束後不要忘記銷毀資源:

kubectl delete -f sample.yaml

Service

Pod 在 K8s 的世界裡其實是雜魚、消耗品,可能會故障然後被清除掉之後被新建的 Pod 取代,又或是為了負載需求而被複製成一堆一樣參數的 Pod,Pod 的 IP 也因此是浮動的,實際使用你不會直接連線到特定的 Pod,而是透過一層抽象找到「在運行這種服務的 Pod」,那個抽象就是 Service。這個抽象不只是用於外部訪問,也包含 Pod 對 Pod 的內部連線。

ClusterIP

一個 Service 大概長得像這樣:

apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: ClusterIP
ports:
- protocol: TCP
port: 3001
targetPort: 8080
完整的檔案如下: sample.yaml
apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: ClusterIP
ports:
- protocol: TCP
port: 3001
targetPort: 8080
# 用一樣的指令把服務帶起來:
kubectl create -f sample.yaml

# 開 tunnel
kubectl port-forward service/awesome-service 3002:3001

只是這次我們連線的目標不是 Pod 而是 Service: http://localhost:3002

ClusterIP 尚未真正對外暴露我們的服務,這個模式主要給 Pod 內部訪問用(路徑3→2)1

NodePort

apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: NodePort
ports:
- protocol: TCP
port: 3001
targetPort: 8080
nodePort: 30390

接著用一樣的指令把服務帶起來:

kubectl create -f sample.yaml

這次從 Node 的 IP 訪問(例如: http://192.168.0.123:30390

info

Service 除了 ClusterIP 和 NodePort 以外,還有其他種類,不過在此不做過多的解釋,因為不是本文重點。

Deployment

Pod 在 K8s 的世界裡其實是雜魚、消耗品

是的,在 K8s 我們一般不會直接佈署 Pod,而是透過 Deployment 組件(或是其他類似功能的組件)來間接的佈署 Pod:

apiVersion: apps/v1
kind: Deployment
metadata:
name: awesome-deployment
spec:
replicas: 3
template:
metadata:
labels:
app: awesomeApp
spec:
containers:
- name: kubernetes-demo-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
selector:
matchLabels:
app: awesomeApp
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: NodePort
ports:
- protocol: TCP
port: 3001
targetPort: 8080
nodePort: 30390

你可以用瀏覽器打開它(例如: http://192.168.0.123:30390), 不過你可能只會看到相同的 os.hostname,試試多執行幾次 curl

curl http://192.168.0.123:30390/ | jq .os.hostname

Footnotes

  1. Kubernetes Service 概念詳解 | Kubernetes. Retrieved 2026-01-01, from https://tachingchen.com/tw/blog/kubernetes-service/

Wei Ji

  • Node
    • 一個安裝 K8s 的實體機器或虛擬機器。
  • Cluster
    • 多個 Node 構成的叢集。
  • Pod
    • K8s 的最小佈署單位。
    • 通常包含一個 Container。
    • 視情況可以包含多個 Container。
  • Container
    • OCI (Open Container Initiative) 容器。

Namespace

在同一個 Cluster 下可以再設定一個 Namespace 來隔離資源,知道有這個東西就好,初學者可以先不管它的存在。