Skip to main content

9 posts tagged with "homelab"

View All Tags

Wei Ji

前情提要

最近其實處於多開戰線的狀態:

Homelab 遷移大致完成,目前只剩下一個服務,想說該回來灌溉一下 RAG 專案了。

TiddlyRAG

關於目前整個生態系的亂象,我在之前的文章已經全部噴過一遍了,接下來就是採取一些行動了,關於這個專案的細節請見:

TiddlyRAG 計畫

細節我就不贅述,簡單來說就是我發現 TiddlyWiki 本身自帶 Chunk 、 Graph 、人類可讀可編輯和可攜性...等特性,在 LLM 時代應該是一個很好用的軟體,這也不是什麼新點子,想法去年 (2025) 十月就有了:

一種人類友善 llms.txt 構想

info

上面很多 Side Project 寫了一堆 TiddlyWiki 其實和 TiddlyRAG 是相關的,除了實驗一些 DDD 流程以外,也算是準備一些資料方便日後做 RAG 的測試。

嵌入伺服器與選擇

TiddlyRAG 的 POC 架構如下:

可以說有三個大題目:

  • 向量資料庫 (Vector database)
  • MCP (Model Context Protocol)
  • 資料嵌入 (Embedding)

向量資料庫之前工作寫小工具有接觸過了,MCP 的部份也稍微查過資料,剩下比較陌生的是資料嵌入的部份。

開放權重、OpenAI API 兼容、OCI (Open Container Initiative) 佈署、nvidia 解偶,這幾個算是我對於嵌入方案的基本要求。

首先是大名鼎鼎的 Ollama,不過很快我就發現幾個問題:

  • 它的映像檔過於肥大,單層超過 1GB 的映像檔在我的可憐網路下是拉不下來的。
  • 它只支援 nvidia 的 CUDA 和 AMD 的 ROCm,不夠通用。
  • 就算只使用 CPU 模式,映像檔是跟 nvidia 函式庫綁定的。

好吧,下一個看看以「性能優雅」出名的 llama.cpp。

斯巴拉西!

映像檔案小、支援多種 GPU 後端,看來就決定是你了!

llama.cpp

下載了 Ubuntu x64 (Vulkan)

./llama-server \
--hf-repo Qwen/Qwen3-Embedding-8B-GGUF \
--hf-file Qwen3-Embedding-8B-Q6_K.gguf \
--embeddings --pooling mean \
-c 4096 -ub 4096 -ngl 999 --no-mmap -fa on --no-webui

指令是參考別人的1,模型會下載到 ~/.cache/llama.cpp/,不知道能不能改路徑。

接著用 Typescript 戳一下:

const url = "http://localhost:8080/v1/embeddings"
const headers = {
"Authorization": `Bearer ANY`,
"Content-Type": "application/json"
}
const payload = {
"model": "ANY",
"input": "Your text string goes here",
"encoding_format": "float"
}

const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
});

const result = await response.json()
console.log(JSON.stringify(result, undefined, 2));

GPU 有在運作,不錯不錯。

模型下載的一些問題

本來想說 llama.cpp 是精簡出名的,根據職責分離原則它可能不會實做下載的部份,所以先試著用 Huggingface 的 CLI 跑跑看了,

hf download \
--local-dir models \
Qwen/Qwen3-Embedding-8B-GGUF \
Qwen3-Embedding-8B-Q6_K.gguf \

不過還蠻不穩定的,不知道為什麼。

也有試過另外一個 llama.cpp 的指令:

./llama-server \
-hf Qwen/Qwen3-Embedding-8B-GGUF:Q6_K \
--embeddings --pooling mean \
-c 4096 -ub 4096 -ngl 999 --no-mmap -fa on --no-webui
load_backend: loaded RPC backend from /home/not-important/llama-b8267/libggml-rpc.so
ggml_vulkan: Found 1 Vulkan devices:
ggml_vulkan: 0 = Intel(R) Iris(R) Xe Graphics (RPL-P) (Intel open-source Mesa driver) | uma: 1 | fp16: 1 | bf16: 0 | warp size: 32 | shared memory: 65536 | int dot: 1 | matrix cores: none
load_backend: loaded Vulkan backend from /home/not-important/llama-b8267/libggml-vulkan.so
load_backend: loaded CPU backend from /home/not-important/llama-b8267/libggml-cpu-alderlake.so
common_download_file_single_online: no previous model file found /home/not-important/.cache/llama.cpp/Qwen_Qwen3-Embedding-8B-GGUF_preset.ini
common_download_file_single_online: HEAD failed, status: 404
no remote preset found, skipping
error from HF API (http://huggingface.mirrors.solid.arachne/v2/Qwen/Qwen3-Embedding-8B-GGUF/manifests/Q6_K), response code: 404, data: {"error":"Sorry, we can't find the page you are looking for."}

大概是因為 Olah 不支援 v2 API 吧。

info

我在 LAN 的模型快取策略是由 Homelab 處理,HF 原本用本地資料夾的「快取」我並不在乎。

GGUF

在上述範例中,我們看到了 Qwen3-Embedding-8B-Q6_K.gguf 這樣的檔案,它是什麼意思?

Qwen3-Embedding-8B 自然是模型本身的名稱以及它的參數量,.gguf 則是一種能夠儲存模型權重的檔案,並且 GGUF 是 GGML 的後繼者。

GGML 這個格式則是從 GGML (Georgi Gerganov Machine Learning Tensor library) 這個函式庫而來的,Georgi Gerganov 則是作者的名字。

GGUF 比較正確的全名其實是 "GGML Unified Format",關於它的名稱這裡有一篇文章在討論它:

What does GGUF stand for? A "Guide" : r/LocalLLaMA

Q6_K 則是量化參數,量化是一種用更小的資料型態來儲存模型權重的技術,可以減少模型儲存的大小與推論時需要的記憶體數量。Q6 代表模型主要使用 6bit 的資料來儲存權重,後面的數字或編號代表各種不同的混合量化策略,具體差異對於調用者而言不是特別重要,重要的是量化同時也會造成模型性能下降:

Perplexity 是一種相對指標,簡單來說準備一個資料集(例如維基百科),給定一些文字,讓模型預測下一個詞,並紀錄不正確性。我們可以看到 Q2_K 量化的 65B 模型退化到接近 30B 未量化模型的水準。

圖表來自 llama.cpp 的 GitHub,它使用了 Q2_K, Q3_K_S, Q3_K_M, Q3_K_L, Q4_K_S, Q4_K_M, Q5_K_S, Q5_K_M, Q6_K 這幾種量化參數並與 F16 進行比較,完整報告請見:

k-quants by ikawrakow · Pull Request #1684 · ggml-org/llama.cpp

這就是為什麼我選擇 Q6_K 量化的嵌入模型測試,因為它顯著的降低記憶體但是性能可能不會太顯著的下降。

info

Perplexity 是相對指標,Perplexity 指標沒有顯著下降不代表模型的其他性能沒有明顯下降。

Footnotes

  1. embedding with llama.cpp server : r/LocalLLaMA. https://www.reddit.com/r/LocalLLaMA/comments/1nqyi1x/comment/ngacugv/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

Wei Ji

前情提要

我完成了 1.3TB 的資料遷移,但是 Jellyfin 服務依然有東西還沒完成配置。

關於資料遷移的細節請見前一篇文樁:

Homelab 資料遷移筆記 (2026-03-05)

GPU 與硬體加速

在遷移前的配置中有這麼一段設定:

services:
jellyfin-server:
image: jellyfin/jellyfin:10
devices:
- /dev/dri/:/dev/dri/

原因是當瀏覽器不支援直接播放原本儲存的檔案格式時,Jellyfin 需要先編碼再串流給瀏覽器,而這個過程如果不透過硬體加速會非常慢,因此它不像一般的雲端程式只要 CPU 跟 RAM 資源就能運作,還需要訪問 GPU 資源。

然後在 K8s 內實現這件事稍微有點複雜,因為在 K8s 的世界,永遠要考慮多節點情況,而多節點代表著:

  • 節點上不見得有 GPU
  • 節點上有 GPU 但是硬體規格可能是 Intel, AMD, nvidia...
  • 不同硬體的驅動程式與實做不盡相同。

K8s 的高度抽象化固然支援處理這樣的問題,不過它不是開箱即用的,至少對於自己架的 K8s 來說不是。

解決方案

廢話少說,先說結論,背景知識等等再補,以下是整個過程大致上需要的步驟:

  1. 安裝 NFD (Node Feature Discovery)
helm install \
-n node-feature-discovery \
--create-namespace \
nfd oci://registry.k8s.io/nfd/charts/node-feature-discovery \
--version 0.18.3
  1. 安裝 cert-manager
helm install \
cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.19.4 \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
  1. 安裝 Intel 的 device-plugin-operator
helm install device-plugin-operator intel/intel-device-plugins-operator \
--namespace intel-device-plugins-gpu \
--create-namespace \
--version 0.35.0
  1. 安裝 Intel 的 gpu-device-plugin
helm install gpu-device-plugin intel/intel-device-plugins-gpu \
--namespace intel-device-plugins-gpu \
--create-namespace \
--version 0.35.0
  1. 使用 resources 標籤
    spec:
containers:
- image: docker.io/jellyfin/jellyfin:10
resources:
requests:
gpu.intel.com/i915: "1"
limits:
gpu.intel.com/i915: "1"

requests 是要求最小裝置數量,limits 是聲明最大的資源用量。

整個安裝過程參考網路上的兩篇文章12

K8s Operator

K8s Operator 本質上是一層聲明式與指令式的橋樑,目的是讓運維人員透過聲明式組態來操作經過封裝的指令式實做。

https://blog.container-solutions.com/hs-fs/hubfs/kubernetes_operators_diagram1.png?width=750&name=kubernetes_operators_diagram1.png

Operator 的運作方式大致為:程式觀察聲明式宣告的某種組態或資源,對 K8s 進行操作試圖使實際狀態與聲明狀態同步。換言之,當 K8s 發生某種變化,如:服務異常、失效,Operator 也會操作 K8s 試圖使其回到原本符合聲明的狀態。

https://blog.container-solutions.com/hs-fs/hubfs/kubernetes_operators_diagram2.png?width=750&name=kubernetes_operators_diagram2.png

info

本段落的圖片出自:

Kubernetes Operators Explained

類似的行為可以從 K8s 原本的設計就觀察到:

使用者聲明 Deployment 資源,K8s 再配置對應的 Pod,如果你手動把 Pod 刪除,K8s 會試著把 Pod 補回去。

K8s Device Plugin

K8s Device Plugin 的基本概念如下:

  • kubelet 暴露了 Unix Socket 供其他人連線。
  • 第三方程式能夠透過這個 Socket 註冊包含 GPU 在內的各種裝置。
  • 每個 K8s Worker Node 上有 kubelet。
  • K8s 暴露了一種操作方式 DaemonSet,它能在 K8s Cluster 內的每一個 Node 配置 Pod。
  • K8s Device Plugin 能夠透過 DaemonSet,佈署特定裝置的橋接器,當裝置存在就向 K8s 註冊裝置。
  • 如此一來服務就能佈署到特定符合裝置條件的節點並使用該裝置。

NFD (Node Feature Discovery)

安裝前的資訊:

kubectl get nodes -o json

$ kubectl get nodes -o json | \
jq '.items[] | {name: .metadata.name, labels: .metadata.labels}'
{
"name": "arachne-node-delta",
"labels": {
"beta.kubernetes.io/arch": "amd64",
"beta.kubernetes.io/instance-type": "k3s",
"beta.kubernetes.io/os": "linux",
"kubernetes.io/arch": "amd64",
"kubernetes.io/hostname": "arachne-node-delta",
"kubernetes.io/os": "linux",
"node-role.kubernetes.io/control-plane": "true",
"node-role.kubernetes.io/master": "true",
"node.kubernetes.io/instance-type": "k3s"
}
}

安裝後的資訊:

kubectl get nodes -o json

kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, labels: .metadata.labels}'
{
"name": "arachne-node-delta",
"labels": {
"beta.kubernetes.io/arch": "amd64",
"beta.kubernetes.io/instance-type": "k3s",
"beta.kubernetes.io/os": "linux",
"feature.node.kubernetes.io/cpu-cpuid.ADX": "true",
"feature.node.kubernetes.io/cpu-cpuid.AESNI": "true",
"feature.node.kubernetes.io/cpu-cpuid.AVX": "true",
"feature.node.kubernetes.io/cpu-cpuid.AVX2": "true",
"feature.node.kubernetes.io/cpu-cpuid.AVXVNNI": "true",
"feature.node.kubernetes.io/cpu-cpuid.BHI_CTRL": "true",
"feature.node.kubernetes.io/cpu-cpuid.CETIBT": "true",
"feature.node.kubernetes.io/cpu-cpuid.CETSS": "true",
"feature.node.kubernetes.io/cpu-cpuid.CMPXCHG8": "true",
"feature.node.kubernetes.io/cpu-cpuid.FLUSH_L1D": "true",
"feature.node.kubernetes.io/cpu-cpuid.FMA3": "true",
"feature.node.kubernetes.io/cpu-cpuid.FSRM": "true",
"feature.node.kubernetes.io/cpu-cpuid.FXSR": "true",
"feature.node.kubernetes.io/cpu-cpuid.FXSROPT": "true",
"feature.node.kubernetes.io/cpu-cpuid.GFNI": "true",
"feature.node.kubernetes.io/cpu-cpuid.HRESET": "true",
"feature.node.kubernetes.io/cpu-cpuid.HYBRID_CPU": "true",
"feature.node.kubernetes.io/cpu-cpuid.IA32_ARCH_CAP": "true",
"feature.node.kubernetes.io/cpu-cpuid.IA32_CORE_CAP": "true",
"feature.node.kubernetes.io/cpu-cpuid.IBPB": "true",
"feature.node.kubernetes.io/cpu-cpuid.IDPRED_CTRL": "true",
"feature.node.kubernetes.io/cpu-cpuid.LAHF": "true",
"feature.node.kubernetes.io/cpu-cpuid.MD_CLEAR": "true",
"feature.node.kubernetes.io/cpu-cpuid.MOVBE": "true",
"feature.node.kubernetes.io/cpu-cpuid.MOVDIR64B": "true",
"feature.node.kubernetes.io/cpu-cpuid.MOVDIRI": "true",
"feature.node.kubernetes.io/cpu-cpuid.OSXSAVE": "true",
"feature.node.kubernetes.io/cpu-cpuid.PMU_FIXEDCOUNTER_CYCLES": "true",
"feature.node.kubernetes.io/cpu-cpuid.PMU_FIXEDCOUNTER_INSTRUCTIONS": "true",
"feature.node.kubernetes.io/cpu-cpuid.PMU_FIXEDCOUNTER_REFCYCLES": "true",
"feature.node.kubernetes.io/cpu-cpuid.PSFD": "true",
"feature.node.kubernetes.io/cpu-cpuid.RRSBA_CTRL": "true",
"feature.node.kubernetes.io/cpu-cpuid.SERIALIZE": "true",
"feature.node.kubernetes.io/cpu-cpuid.SHA": "true",
"feature.node.kubernetes.io/cpu-cpuid.SPEC_CTRL_SSBD": "true",
"feature.node.kubernetes.io/cpu-cpuid.STIBP": "true",
"feature.node.kubernetes.io/cpu-cpuid.STOSB_SHORT": "true",
"feature.node.kubernetes.io/cpu-cpuid.SYSCALL": "true",
"feature.node.kubernetes.io/cpu-cpuid.SYSEE": "true",
"feature.node.kubernetes.io/cpu-cpuid.VAES": "true",
"feature.node.kubernetes.io/cpu-cpuid.VMX": "true",
"feature.node.kubernetes.io/cpu-cpuid.VPCLMULQDQ": "true",
"feature.node.kubernetes.io/cpu-cpuid.WAITPKG": "true",
"feature.node.kubernetes.io/cpu-cpuid.X87": "true",
"feature.node.kubernetes.io/cpu-cpuid.XGETBV1": "true",
"feature.node.kubernetes.io/cpu-cpuid.XSAVE": "true",
"feature.node.kubernetes.io/cpu-cpuid.XSAVEC": "true",
"feature.node.kubernetes.io/cpu-cpuid.XSAVEOPT": "true",
"feature.node.kubernetes.io/cpu-cpuid.XSAVES": "true",
"feature.node.kubernetes.io/cpu-cstate.enabled": "true",
"feature.node.kubernetes.io/cpu-hardware_multithreading": "true",
"feature.node.kubernetes.io/cpu-model.family": "6",
"feature.node.kubernetes.io/cpu-model.id": "186",
"feature.node.kubernetes.io/cpu-model.vendor_id": "Intel",
"feature.node.kubernetes.io/cpu-pstate.scaling_governor": "powersave",
"feature.node.kubernetes.io/cpu-pstate.status": "active",
"feature.node.kubernetes.io/cpu-pstate.turbo": "true",
"feature.node.kubernetes.io/kernel-config.NO_HZ": "true",
"feature.node.kubernetes.io/kernel-config.NO_HZ_FULL": "true",
"feature.node.kubernetes.io/kernel-version.full": "6.8.0-101-generic",
"feature.node.kubernetes.io/kernel-version.major": "6",
"feature.node.kubernetes.io/kernel-version.minor": "8",
"feature.node.kubernetes.io/kernel-version.revision": "0",
"feature.node.kubernetes.io/memory-swap": "true",
"feature.node.kubernetes.io/pci-0300_8086.present": "true",
"feature.node.kubernetes.io/pci-0300_8086.sriov.capable": "true",
"feature.node.kubernetes.io/storage-nonrotationaldisk": "true",
"feature.node.kubernetes.io/system-os_release.ID": "ubuntu",
"feature.node.kubernetes.io/system-os_release.VERSION_ID": "24.04",
"feature.node.kubernetes.io/system-os_release.VERSION_ID.major": "24",
"feature.node.kubernetes.io/system-os_release.VERSION_ID.minor": "04",
"feature.node.kubernetes.io/usb-ef_27c6_609c.present": "true",
"feature.node.kubernetes.io/usb-ff_0bda_8156.present": "true",
"kubernetes.io/arch": "amd64",
"kubernetes.io/hostname": "arachne-node-delta",
"kubernetes.io/os": "linux",
"node-role.kubernetes.io/control-plane": "true",
"node-role.kubernetes.io/master": "true",
"node.kubernetes.io/instance-type": "k3s"
}
}

故障排除

安裝過程有遭遇一點問題:

$ kubectl logs pod/intel-gpu-plugin-gpudeviceplugin-sample-79cwr -n intel-device-plugins-gpu
I0306 14:06:45.387882 1 gpu_plugin.go:843] GPU device plugin started with none preferred allocation policy
I0306 14:06:45.388177 1 gpu_plugin.go:530] GPU (i915/xe) resource share count = 1
I0306 14:06:45.442369 1 gpu_plugin.go:548] GPU scan update: 0->1 'i915_monitoring' resources found
I0306 14:06:45.442389 1 gpu_plugin.go:548] GPU scan update: 0->1 'i915' resources found
I0306 14:06:46.444272 1 server.go:288] Start server for i915_monitoring at: /var/lib/kubelet/device-plugins/gpu.intel.com-i915_monitoring.sock
I0306 14:06:46.444396 1 server.go:288] Start server for i915 at: /var/lib/kubelet/device-plugins/gpu.intel.com-i915.sock
I0306 14:06:46.844746 1 server.go:306] Device plugin for i915_monitoring registered
I0306 14:06:46.844752 1 server.go:306] Device plugin for i915 registered
E0306 14:06:46.844852 1 manager.go:146] Failed to serve gpu.intel.com/i915_monitoring: too many open files
Failed to create watcher for /var/lib/kubelet/device-plugins/gpu.intel.com-i915_monitoring.sock
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin.watchFile
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin/server.go:328
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin.(*server).setupAndServe
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin/server.go:310
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin.(*server).Serve
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin/server.go:226
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin.(*Manager).handleUpdate.func1
github.com/intel/intel-device-plugins-for-kubernetes/pkg/deviceplugin/manager.go:144
runtime.goexit
runtime/asm_amd64.s:1693

解決方法:

# 編輯檔案
sudo nano /etc/sysctl.conf

# 加入以內容
# fs.inotify.max_user_instances = 256

sudo sysctl -p

Footnotes

  1. Intel GPU acceleration on Kubernetes – Jonathan Gazeley. Retrieved 2026-03-10, from https://jonathangazeley.com/2025/02/11/intel-gpu-acceleration-on-kubernetes/

  2. Plex on Kubernetes with intel iGPU passthrough - Small how to : r/selfhosted. Retrieved 2026-03-10, from https://www.reddit.com/r/selfhosted/comments/121vb07/plex_on_kubernetes_with_intel_igpu_passthrough/

Wei Ji

前情提要

我正在把 Homelab 的服務從一台機器的 Docker Swarm 遷移到另外一台機器的 Kubernetes,其中比較棘手的服務之一是 Jellyfin,因為這個服務包含了 1.3TB 的資料。

為什麼 1.3TB 是個問題?可以見前一篇文章:

Homelab 遷移近況 (2026-02-25)

結論

過程中其實有遇到一些挫折,不過我先講結論,過程等等提。

我使用了以下指令完成遷移:

ssh -A -R localhost:50000:192.168.0.138:32222 root@arachne-node-beta \
'rsync -avh --info=progress2 --info=name0 --delete --bwlimit=20m -e "ssh -p 50000" -vuar /mnt/das-storage/volumes/jellyfin_media-data/ linuxserver.io@localhost:/config/data/media-data/'

指令本身我是參考網路上的。arachne-node-beta 是我 homelab 內部使用的 hostname,之後簡稱 Beta 節點。

-R

-R localhost:50000:192.168.0.138:32222

這段參數的意思是,把 192.168.0.138:32222 接到 localhost:50000 去(對 Beta 節點而言),所以在 Beta 節點上訪問 localhost:50000 時實際上會連到 192.168.0.138:32222 去。

info

可以用 "SSH Remote Port Forwarding" 之類的關鍵字搜尋這個 flag 相關的資訊與用法。

-A

透過 ssh-agent 建立一個代理,把遠端的認證丟回本機處理,這樣就不用在 Beta 節點設定對目標(在這個案例中是 192.168.0.138)的金鑰。

rsync

指令參數看起來有點髒的原因是混合了我平時自己備份資料常用的:

-avh --info=progress2 --info=name0 --delete

和網路上找到的:

-e "ssh -p 50000" -vuar

--bwlimit=20m 則是為了處理 I/O 背壓 (Backpressure) 問題, 稍後解釋。

/mnt/das-storage/volumes/jellyfin_media-data/ 
linuxserver.io@localhost:/config/data/media-data/

分別是來源跟目標。

準備工作

先在 K8s 佈署 OpenSSH 的 Pod:

statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
io.kompose.service: openssh
name: openssh
spec:
replicas: 1
selector:
matchLabels:
io.kompose.service: openssh
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
template:
metadata:
labels:
io.kompose.service: openssh
spec:
containers:
- image: docker.io/linuxserver/openssh-server:latest
name: openssh
env:
- name: PASSWORD_ACCESS
value: "true"
- name: PGID
value: "1000"
- name: PUID
value: "1000"
- name: TZ
value: Asia/Taipei
- name: USER_PASSWORD
value: password
ports:
- containerPort: 2222
protocol: TCP
volumeMounts:
- mountPath: /config/data/jellyfin-cache
name: jellyfin-cache
- mountPath: /config/data/jellyfin-config
name: jellyfin-config
- mountPath: /config/data/media-data
name: media-data
restartPolicy: Always
volumes:
- name: jellyfin-cache
persistentVolumeClaim:
claimName: jellyfin-cache
- name: jellyfin-config
persistentVolumeClaim:
claimName: jellyfin-config
- name: media-data
persistentVolumeClaim:
claimName: media-data
---
apiVersion: v1
kind: Service
metadata:
labels:
io.kompose.service: openssh
name: openssh-service
spec:
type: NodePort
selector:
io.kompose.service: openssh
ports:
- protocol: TCP
port: 2222
targetPort: 2222
nodePort: 32222

這邊是使用 linuxserver/openssh-server 這個別人做好的 image。我是使用獨立的 Pod 而不是和 Jellyfin 共用 Pod 因為這樣 YAML 比較乾淨。

這邊是用 NodePort 處理,因為我暫時還不想煩惱設定 LoadBalancer。

作為 OpenSSH 伺服器的 Container 還需要完成幾件事:

  • 配置和本地對應的 public key
  • 安裝 rsync

在本地則是:

  • 使用 ssh-add 把要用來訪問 OpenSSH 伺服器的 private key 放進 SSH Agent 內。

方案的選擇

一開始其實有考慮過另外一個方案:掛載 SDS (Software-defined storage)。

Kubernetes 本身就支援將外部的 NFS 或是 iSCSI 之類的東西掛載成 Volume,或是 StorageClass 把網路上的各種實例當成 Volume 使用。因此理論上只要在 Beta 節點上套一層 SDS,就能讓 Pod 掛載它的 Volume,接著就能在 Pod 內進行資料資料轉移。

不過這個方案代表需要讓 Beta 節點暴露給網路做讀寫,在 LAN 內問題不大,但是考量「生產條件」的話這似乎不是一個標準的解決方式。所以最後選擇走基於 SSH 的方案,至少在正確使用方式下它是足夠安全的。

歷程

接著來談過程中遇到的挫折,反覆嘗試了幾種方式:

  • Rclone 傳輸,兩端為 SFTP 對 SFTP。
  • Rclone 傳輸,其中一端為 SSHFS 掛載本地,另外一端為 SFTP。
  • Rclone 傳輸,兩端皆為 SSHFS 掛載本地。
  • rsync 傳輸,兩端皆為 SSHFS 掛載本地。
  • rsync 傳輸,其中一端 SSHFS 掛載本地。
info

rsync 不支援兩端同時為 remote。

過程中都會突然停止傳輸(網路流量歸零),我原本以為是花式傳輸造成的某種鎖,或是 SSH 死掉之類的,但是就算用上 Port Forwarding 這個理應最穩定的方式還是會出現,而且它有很明顯的間歇性。雖然放著不管應該最後還是可以傳輸完畢,但是總覺得還是應該要試著解決它一下。

過程中用 LLM 做故障排除,最後試著把 iostat 資訊餵給 LLM 得到的階段性結論是硬碟的 I/O 瓶頸,加上個 --bwlimit=20m 限制傳輸流量之後,那個間歇性停止的問題就消失了,掛著跑了幾個小時終於把資料傳完了。

Node 回顧

info

這個回顧使用的 Dashboard 可以在這裡找到:

https://github.com/rfmoz/grafana-dashboards

前段不穩定的部份就是我嘗試各種方案,並且傳輸時觀察到間歇性停止的部份。

在這張圖很明顯看到硬碟的 I/O 已經吃滿了:

因此傳輸過程的間歇性停止其實是硬碟瓶頸,網路傳輸很快的把資料填進去 Buffer,Buffer 滿了硬碟來不及消化就讓網路傳輸暫停,寫入等待時間最高甚至超過一分半:

這裡也可以看到很多 I/O Wait:

Cluster 回顧

info

這個回顧使用的 Dashboard 是 kube-prometheus-stack 這個 Helm 的一部分。

可以觀察到相同的模式,沒什麼特別的資訊,不過機會難得(?)順便曬一下從 Kubernetes 的角度看過去的 Dashboard 長怎樣。

Wei Ji

最近在陸續把服務從運行在一台主機 Docker Swarm 遷移到另外一台主機的 Kubernetes 中,

一直到回老家過年前 (2026-02-15) 已經完成大部分服務的遷移,這裡紀錄一下剩餘的服務以及還沒完成遷移的原因。

簡單遷移流程

因為我使用 Longhorn 作為 Volume Provider,資料並不是直接寫在 host 的檔案系統內的,而是寫在類似虛擬機硬碟映像檔的東西內,

$ ll
total 14945644
drwx------ 2 root root 4096 Feb 20 07:01 ./
drwxr-xr-x 22 root root 4096 Feb 24 12:16 ../
-rw-r--r-- 1 root root 21474836480 Feb 25 00:56 volume-head-000.img
-rw-r--r-- 1 root root 126 Jan 24 11:55 volume-head-000.img.meta
-rw-r--r-- 1 root root 143 Feb 20 07:01 volume.meta

因此不能直接單純的把資料從一個主機的硬碟複製到另外一個主機硬碟,而是需要經過一層 K8s 把資料寫入 Volume 內。目前使用的遷移步驟大致如下。

1. 起草 K8s YAML

使用 Kompose 將 Docker Swarm 的 YAML 轉換成 K8s 資源,並且進行適當的修飾(例如:有狀態的服務從 Deployment 改成 StatefulSet)。

並且在目標 Pod 掛上臨時的 container,用於提供 PVC 寫入的 runtime,同時先註解掉真正的服務,如下:

K8s YAML
apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
io.kompose.service: pinry
name: pinry
spec:
replicas: 1
selector:
matchLabels:
io.kompose.service: pinry
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
template:
metadata:
labels:
io.kompose.service: pinry
spec:
containers:
# - image: docker.io/getpinry/pinry:2.1.13
# name: pinry
# ports:
# - containerPort: 80
# protocol: TCP
# volumeMounts:
# - mountPath: /data
# name: pinry-data

# Used to do data migration
- image: docker.io/library/busybox:latest
name: busybox
command:
- sleep
- "3600"
volumeMounts:
- mountPath: /data
name: pinry-data
restartPolicy: Always
volumes:
- name: pinry-data
persistentVolumeClaim:
claimName: pinry-data

2. 確認原始 Volume 大小

du -s -h

3. 遷移

3.1 直接 cp,適用小量遷移

先遷移到本地:

rsync -avh --info=progress2 --info=name0 --delete \
root@arachne-node-beta:/mnt/das-storage/volumes/pinry_data/ \
./pinry_data/

之後上傳到 Pod:

kubectl cp -n pinry-stack ./pinry_data/ pinry-0:/pinry_data

移動檔案到 PV 掛載的路徑:

kubectl exec -n pinry-stack --stdin --tty pinry-0 -- /bin/sh
cp -rf  /pinry_data/* /data/.
info

需要拆分兩個步驟是因為 kubectl cp 指令只能複製資料夾,不能在兩個資料夾之間直接同步內容。

3.2 tar 打包後 cp,適用小量遷移

步驟同上,只是多了一個打包/解包的步驟1

# 打包壓縮
tar -czf gitea.tar.gz <PATH>

# 解壓縮解包
tar -xzf gitea.tar.gz

3.3 hostPath,適用中量遷移

上述方法對於容量小的遷移尚可處理,但是我的 ArchiveBox 有 12 GB 的資料,kubectl cp 傳輸過程會遇到以下問題:

error: unexpected EOF

於是我在 Deployment 上加掛一個 hostPath Volume:

K8s YAML
apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
io.kompose.service: archivebox
name: archivebox
spec:
replicas: 1
selector:
matchLabels:
io.kompose.service: archivebox
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
template:
metadata:
labels:
io.kompose.service: archivebox
spec:
containers:
# - name: archivebox
# image: docker.io/archivebox/archivebox:0.7.3
# ports:
# - containerPort: 8000
# protocol: TCP
# volumeMounts:
# - mountPath: /data
# name: archivebox-data

# Used to do data migration
- image: docker.io/library/busybox:latest
name: busybox
command:
- sleep
- "3600"
volumeMounts:
- mountPath: /data
name: archivebox-data
- mountPath: /archivebox_data
name: tmp-archivebox-data
restartPolicy: Always
volumes:
- name: archivebox-data
persistentVolumeClaim:
claimName: archivebox-data

# Used to do data migration
- name: tmp-archivebox-data
hostPath:
path: /mnt/archivebox_data

先把資料從一台主機移到另外一台主機,再進入容器中把資料從 hostPath Volume 移到 Longhorn 去。

4. 切換 container

將遷移用暫時性的 container 註解並且把真實服務掛回。

剩餘服務

以下是過年前尚未完成遷移的服務,主要是因為有額外的雜務需要處理,不適用上述簡單遷移方法。

MinIO

MinIO 的遷移比較特別,一來是已經停止維護了2,雖然作為封閉的地端使用情況並不需要太過擔心安全性問題, 可以繼續使用已經存在的 OCI (Open Container Initiative) 映像檔,不過依然可能要物色一下其他替代方案。

二來是 MinIO 是一個 S3 (Simple Storage Service) 實例,本身就有 CRUD (Create, read, update and delete) 的 API,因此資料遷移時無須考慮 Volume 層級的問題,只要用 mc 指令同步兩個在不同實例上的 Bucket 即可。

Harbor

Harbor 本身就有 Helm 可以使用,但是因為我是客製化 docker-compose.yaml 的情況,考慮資料遷移的複雜性可能不能直接使用 Helm。

加上 Harbor 的微服務結構跟我 selfhosted 的其他服務相比複雜得多,翻譯成 K8s 的過程會比較麻煩。

Jellyfin

資料比較多 (1.3TB),即便是 hostPath 方案也必須在新的主機上消費兩倍的硬碟空間,因此 hostPath 不適合用來遷移這種規模的資料。

我腦海浮現兩種解決方法,第一個是直接在舊將資料封裝成 SDS (Software-defined storage),然後在新節點上作為 Volume 掛載後進行資料轉移。

第二個方法是把 Volume 在新節點上掛給一個 SSH 容器,由 SSH 完成資料轉移。

Gitea

Gitea 因為需要使用 SSH (Git over SSH),無法透過 Ingress 處理,而必須設定 Load Balancer。

info

Ingress 是 L7 的 HTTP 反向代理,SSH 是建立在 L4 的 TCP 連線上,因此需要 L4 的 Gateway (即 Load Balancer) 處理。

AptCacherNg

AptCacherNg 雖然是使用 HTTP,但是 apt 指令的實做似乎不會帶上 hostname 之類的資訊,因此服務不能運行在反向代理之後,在 Docker Swarm 的舊節點我是直接找個 port 暴露出去。

在 K8s 則是類似於 Gitea 的情況,必須要用 Load Balancer 額外設定。

Dashy

Dashy 的運作方式是每次容器啟動時,都會根據配置檔「編譯」一份靜態網站。我有點懷疑在 K8s 這種「容器是經濟動物;隨便新增隨便刪除」的哲學下,這種運作是否恰當,視情況可能需要找其他的 homepage 替代方案。

Footnotes

  1. Remco Kersten - Importing Data into Longhorn. aspberry Pi 5 for 4K Gaming - Jeff Geerling. Retrieved 2026-02-25, from https://www.remcokersten.nl/posts/import-data-into-longhorn/

  2. MinIO 已死,MinIO 復生 - 知乎. Retrieved 2026-02-25, from https://zhuanlan.zhihu.com/p/2008215929461445776

Wei Ji

在 Homelab 的 S3 中我有幾個 Bucket:

3D 模型

理想上我是希望有一個「開源自架的 Sketchfab」,來儲存這類檔案,不過目前還沒找到合適的方案因此就先放在 S3 內,具體是哪種檔案呢?例如:

素材

跟 3D 模型類似,理想上我是希望有一個「開源自架的 itch.io/opengameart.org」但是因為目前沒有所以先找個地方塞。

資料集

可能跟機器學習有關的資料集,這種資料集通常動輒數 GB,為了節省網路流量,我收錄了幾個有興趣的在 homelab 裡,以備不時之需(例如:COCO dataset val2014cv-corpus-15.0-2023-09-08...)。

等距長方投影 (Equirectangular)

之前經手過處理 Equirectangular 相關的專案,跟資料集的情況差不多,8K 影片的話動輒數 GB,手邊存幾份樣本方便日後處理類似題目的時候有檔案可以用。

機器學習模型

之前隨手開的 Bucket,目前已經有 Huggingface 的鏡像站 (Olah)了,之後用途可能不大了。或許可以用來儲存那種沒有被上傳到 Huggingface 的野雞模型。

作業系統映像檔

這應該不用解釋吧...?安裝 Linux 的時候手邊存一份備著。

SDK

不少軟體的 SDK 非常的肥大,為了避免日後需要花時間重複下載,手邊備一份。

Windows 應用程式

我開始使用 Linux 以前囤積的軟體。

遷移過程

整個 S3 的遷移過程大致如下:

  1. 安裝 mc 指令:
curl https://dl.min.io/client/mc/release/linux-amd64/mc \
--create-dirs \
-o $HOME/.local/bin/mc

chmod +x $HOME/.local/bin/mc
  1. 分別設定新/舊的 S3 實例:
$ mc config host add minio-server http://localhost:9000
Enter Access Key:
Enter Secret Key:
  1. 搬遷檔案:
mc mirror minio-server/3d-models rustfs-server/3d-models

RustFS

程式碼https://github.com/rustfs/rustfs
星數22.3k

因為 MinIO 官方不再維護1,RustFS 是一個倍受推崇的替代方案,於是我便嘗試了一下。

然後遷移過程遇到以下問題:

mc: <ERROR> Failed to copy `http://s3.minio.arachne/sdk/cuda-repo-ubuntu2204-13-0-local_13.0.1-580.82.07-1_amd64.deb`. Put "http://s3.apps.liquid.arachne/sdk/cuda-repo-ubuntu2204-13-0-local_13.0.1-580.82.07-1_amd64.deb?partNumber=6&uploadId=YzRiMmE0YTgtN2JlOC00ZjY4LTlmZjUtYmVkYzY0NGI4NTg4LjZhNTMzZDU2LWZiZGMtNDk3OS05ZDI5LTY4ZjVhZDllMGNjYngxNzcxOTI2ODgzMDU2MzY3MTc3": http: ContentLength=16777216 with Body length 14680064
mc: <ERROR> Failed to copy `http://s3.minio.arachne/sdk/cuda-repo-ubuntu2204-13-0-local_13.0.1-580.82.07-1_amd64.deb`. Put "http://s3.apps.liquid.arachne/sdk/cuda-repo-ubuntu2204-13-0-local_13.0.1-580.82.07-1_amd64.deb?partNumber=6&uploadId=YzRiMmE0YTgtN2JlOC00ZjY4LTlmZjUtYmVkYzY0NGI4NTg4LmM5MWU3NjMzLTk2ZTYtNDNkYy1iMDg0LWNlYzI5YjgyNzMzZHgxNzcxOTI3MzIwNjk0MjE5MjYy": http: ContentLength=16777216 with Body length 14680064
mc: <ERROR> Unable to list comparison retrying.. context canceled

不過我對 CUDA SDK 沒什麼留念,刪除之後剩下的檔案都順利完成遷移了。

接著映入眼簾的是永遠在轉圈圈的 Bucket 大小:

不過還好,這也只是錦上添花的功能,但是 Bucket 的設定頁面也一直在轉圈圈是怎麼回事?

後來近一步調查:

Be ware of the recent RustFS CVE2 because a static key was vibe coded into the product… even though they mitigated the issue, my confidence dropped severely because of this. 3

This project looks mostly vibecoded, after a quick review I have found a dozen of obvious problems4

很好,RustFS 的嘗試到此為止,繼續使用 MinIO;推移更新 S3 實作的計畫。

info

我使用的 RustFS 是 docker.io/rustfs/rustfs:1.0.0-alpha.83,供參考。

info

另外我有評估過 Garage,不過它有兩個問題:

  • 設定稍微複雜一點,它需要設定 domain name,似乎難以在單純的環境 (localhost) 中測試。
  • 不像 MinIO 有開箱即用 Web UI。

MinIO

不幸的是即便我使用 mc 進行 MinIO 到 MinIO 的遷移依然遇到諸如以下的錯誤:

mc: <ERROR> Failed to copy `http://s3.minio.arachne/os-image/lubuntu-24.04.4-desktop-amd64.iso`. You did not provide the number of bytes specified by the Content-Length HTTP header.
mc: <ERROR> Failed to copy `http://s3.minio.arachne/os-image/2018-11-13-raspbian-stretch-full.img`. Resource requested is unreadable, please reduce your request rate

最後是使用 Rclone 解決。

Footnotes

  1. MinIO 已死,MinIO 復生 - 知乎. Retrieved 2026-02-25, from https://zhuanlan.zhihu.com/p/2008215929461445776

  2. Update your RustFS immediately - Hardcoded token with privileged access (CVE-2025-68926) : r/selfhosted. Retrieved 2026-02-25, from https://www.reddit.com/r/selfhosted/comments/1q432iz/update_your_rustfs_immediately_hardcoded_token/

  3. What is the Best MiniO Alternative Right Now, RustFS, Garage or SeaweedFS ? : r/selfhosted. Retrieved 2026-02-25, from https://www.reddit.com/r/selfhosted/comments/1qcm5r5/comment/nzjbtcc/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

  4. What is the Best MiniO Alternative Right Now, RustFS, Garage or SeaweedFS ? : r/selfhosted. Retrieved 2026-02-25, from https://www.reddit.com/r/selfhosted/comments/1qcm5r5/comment/nzo0ez8/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

Wei Ji

背景知識

要明白我要表達什麼,需要具備一些先驗知識,包含:Dyason Swarm, Starlink 和 樹莓派叢集,我會先做一些簡單的解釋幫對這方面概念比較缺乏的讀者能夠搭上我的思路。

戴森雲 (Dyason Swarm)

戴森雲是一種科幻構想,簡單來說就是:

用大量的人造衛星圍繞並運行在太陽附近來採集太陽能源。

更具體的介紹可以觀看 Kurzgesagt 的影片,我就不在這邊細談了:

info

Kurzgesagt 的影片算是高度簡化版本,例如有人指出「把人造衛星降到靠近太陽的軌道這件事本身就需要消費不少能源」1

然後我的立場是:

太酷了!我想蓋!

不過我看完 Kurzgesagt 的第一個想法是,它一定需要有一個資料中心來遙測與控制這麼多的衛星。

星鏈是由接近一萬顆人造衛星構成的網路系統,讓消費者不用透過海底電纜就能使用高速的無線網路。尤其適合有線網路基礎設施不夠發達的國家,其他使用情境包含空運、海運等位於海洋中間或在深山之類缺乏基地台地方。

請特別留意 Starlink 佈署時堆疊在火箭內的樣子:

https://www.space.com/spacex-starlink-falcon-9-rocket-fairing-video

樹莓派叢集

樹莓派 (Raspberry Pi) 是原先出於教育目的而被設計出來的單板電腦,它就像一個手長大小的電路板,卻具備電腦的完整功能。

預留腳位的設計讓它能夠作為開發板被客製化裝上各種感測器或擴充電路。其中一個例子是把它跟獨立顯示卡裝在一起2

樹莓派叢集則是將多個樹莓派透過網路線連接在一起構成一個運算叢集:

https://dev.to/fredinono/from-nas-to-kubernetes-setting-up-a-raspberry-pi-cluster-with-synology-nas-57f4

是不是跟堆疊的衛星有幾分神似?

現實

現在讓我把眼光從戴森雲、星鏈拉回我那 4.3 坪的租屋處,身處於寸土寸金的大台北,五年網頁前端開發經驗,混雜一些 3D、後端、Docker...的經驗。

這是我手上有的牌,接下來我該如何玩這個名為現實的遊戲呢?

Kubernetes

操作著數以百計的容器

閱讀 K8s (Kubernetes) 相關的文章很常會看到類似的描述。

「它一定需要有一個資料中心來遙測與控制這麼多的衛星」

它跟 Dyason Swarm 的影子在我面前重疊,「Dyason Swarm 的科技樹上必然有 K8s 的存在」我如此確信。

Longhorn

Longhorn 是基於 K8s 的分散式儲存,它可以和 K8s 以類似共生的關係運作在一起:透過 K8s 的叢集架構 Longhorn 可以運作在每一個節點上實現多節點冗餘,而當 K8s 內佈署的服務請求儲存區 (Persistent Volumes) 時,則由 Longhorn 直接向應用程式提供儲存空間。

因此一顆硬碟只要掛上一個堪用的電腦(比如一塊樹莓派),丟進 K8s 內就能讓它成為儲存資源池的一部分,多餘的運算能力則可以「順便」運行一些服務分攤叢集的負載。

基於筆電的 K8s 叢集

筆電有一些工程特性是一般桌機以及伺服器運算不會考量的:省電,即能源效率以及體積和輕量化,為了方便攜帶,可靠性也不會做得太脆弱。我認為這在台灣這種地狹人綢以及寸土寸金的都市中是十分重要的因素,我最為租屋族不太可能負擔得起充足的空間建設伺服器機櫃,遷移的頻率也遠比居住在大陸上的歐美人士來得高。

並且根據我的主觀體驗,很容易低成本獲得二手筆電,加上 K8s 和 Longhorn 的架構下,數個無須十分強大的節點就能提供儲存冗餘,從而解決基於筆電的設備無法使用硬碟陣列卡的問題;又或是避免管理軟體陣列帶來額外的麻煩,只要是能夠安裝並運行 K8s 節點的設備都能對這個叢集進行水平拓展。拓展叢集的關鍵零件,交換器本身又不會太昂貴。

筆電或是單板電腦的尺寸較小,比起機架伺服器或是一般的桌機來得更有空間彈性,必要時甚至可以把叢集拆成數個不同大小的單元分散在空間之中。

當然,要把二手筆電當作一個儲存節點,可能需要透過 USB 外掛硬碟,二手筆電低階的 CPU、低階的 USB 埠口、低階的乙太網路埠口都會成為系統的瓶頸。不過這種事情等遇到再說吧,瓶頸就是用來感受的,畢竟我都經歷過直接把 OS 灌在 DAS (Direct-attached storage) 外部儲存、最後 I/O await 高到會讓 Docker 不穩定才把系統裝回 SSD 了。

Footnotes

  1. The Dyson Swarm – A Personal Perspective - JMORE. Retrieved 2026-02-04, from https://jmoreliving.com/2020/06/08/the-dyson-swarm-a-personal-perspective/

  2. Use an External GPU on Raspberry Pi 5 for 4K Gaming - Jeff Geerling. Retrieved 2026-02-04, from https://www.jeffgeerling.com/blog/2024/use-external-gpu-on-raspberry-pi-5-4k-gaming/

Wei Ji

今天 (2026-01-12) 處理了一些 YaCy 跟 SearXNG 的故障排除,做個紀錄。

前情提要

故事得從我最近愛用的 LLM 工具 LDR (Local Deep Research) 說起,LDR 能夠根據使用者的輸入拆分成數個 LLM 任務:

其中一個任務長得像這樣:

將使用者原始輸入轉換成精簡的搜尋引擎關鍵字之後,呼叫 SearXNG 來取得搜尋結果。

SearXNG 本身不實做搜尋引擎,而是作為代理去呼叫多個搜尋引擎,諸如 Google, Bing, DuckDuckGo...

於是乎有時後會發生這種事情:

呼叫太頻繁被外部的搜尋引擎封鎖,造成有的時候 LDR 會「研究失敗」,因為沒有可靠的外部資訊來源。

我架設 LDR 時使用官方的預設組態,沒有特別設定 SearXNG,於是想說趁這個機會改善這個問題。以上圖為例, 我被 Brave 搜尋引擎擋了。

天下沒有白吃的午餐

老實說我不會特別怪罪搜尋引擎投放廣告、過濾內容...,畢竟:

當使用者沒有為服務付費,使用者本身就是產品,而不是消費者。

搜尋引擎自己有自己的營利模型,而 SearXNG 的使用者幾乎就是繞過這些措施白嫖搜尋引擎,即便搜尋引擎供應商本身有它的原罪,我也不認為應該正當化白嫖的行為。

找來找去,YaCy 似乎是比較合理的方案,它是一個去中心化的搜尋引擎,能夠透過 P2P (Peer-to-peer) 的方式分享索引資訊跟分散爬蟲任務。

網際網路巨大無比,只靠自己建立與維護索引資料根本癡心妄想,YaCy 在這方面就相對理想,讓一群人分散 Google 這種資訊巨獸才能建立的資料庫難度。

話雖如此,架設過程才發現要對外暴露埠才能對整個 YaCy 網路進行貢獻:

目前我的 Homelab 出於安全性考慮是沒有對外暴露的,不過我依然可以在本地建立自己的索引資料庫,至於「做出貢獻」這件事大概要緩緩以後再來處理了。

K8s 故障排除

架設的過程有遇到一點問題,在這邊紀錄一下問題跟解決辦法。

$ kubectl logs yacy-0 -n search-stack
****************** YaCy Web Crawler/Indexer & Search Engine *******************
**** (C) by Michael Peter Christen, usage granted under the GPL Version 2 ****
**** USE AT YOUR OWN RISK! Project home and releases: https://yacy.net/ ****
** LOG of YaCy: DATA/LOG/yacy00.log (and yacy<xx>.log) **
** STOP YaCy: execute stopYACY.sh and wait some seconds **
** GET HELP for YaCy: join our community at https://community.searchlab.eu **
*******************************************************************************
[ YaCy v1.940 by Michael Christen / www.yacy.net ]
-------------------------------------------------------------------------------
Jan 12, 2026 11:49:04 AM net.yacy.cora.util.ConcurrentLog enQueueLog
WARNING: * could not create directories /opt/yacy_search_server/DATA/LOG
could not copy yacy.logging: /opt/yacy_search_server/DATA/LOG/yacy.logging (No such file or directory)
STARTUP: Trying to load logging configuration from file /opt/yacy_search_server/DATA/LOG/yacy.logging
could not find logging properties in homePath=/opt/yacy_search_server
Jan 12, 2026 11:49:04 AM net.yacy.cora.util.ConcurrentLog enQueueLog
WARNING: * java.io.FileNotFoundException: /opt/yacy_search_server/DATA/LOG/yacy.logging (No such file or directory)
java.io.FileNotFoundException: /opt/yacy_search_server/DATA/LOG/yacy.logging (No such file or directory)
at java.base/java.io.FileInputStream.open0(Native Method)
at java.base/java.io.FileInputStream.open(Unknown Source)
at java.base/java.io.FileInputStream.<init>(Unknown Source)
at net.yacy.cora.util.ConcurrentLog.configureLogging(ConcurrentLog.java:385)
at net.yacy.yacy.startup(yacy.java:180)
at net.yacy.yacy.main(yacy.java:832)

Jan 12, 2026 11:49:05 AM net.yacy.cora.util.ConcurrentLog enQueueLog
SEVERE: * FATAL ERROR: Permission denied
java.io.IOException: Permission denied
at java.base/java.io.UnixFileSystem.createFileExclusively0(Native Method)
at java.base/java.io.UnixFileSystem.createFileExclusively(Unknown Source)
at java.base/java.io.File.createNewFile(Unknown Source)
at net.yacy.yacy.startup(yacy.java:194)
at net.yacy.yacy.main(yacy.java:832)

Jan 12, 2026 11:49:05 AM net.yacy.cora.util.ConcurrentLog shutdown
INFO: shutdown of ConcurrentLog.Worker void because it was not running.

典型的容器權限問題,在 K8s 可以透過 securityContext 處理:

apiVersion: apps/v1
kind: StatefulSet
spec:
template:
spec:
securityContext:
runAsUser: 100
runAsGroup: 1000
fsGroup: 1000

但是該如何找到正確的 UID 和 GID 呢?OCI image 調查神器:dive

SearXNG 也有類似的問題:

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

最近在 Homelab 架了一個 PyPI 快取伺服器,選擇使用 devpi

Docker 映像檔則是使用:

然後遇到了一個怪問題,在這邊隨便紀錄一下:

INFO  [IDX] Committing 2500 new documents to search index.
[IDX] Error during indexing in /usr/local/lib/python3.13/site-packages/devpi_web/whoosh_index.py:401:handler whoosh.index.LockError

症狀就是服務長時間佔用 CPU 長達一、兩天,連進去容器下 top 看到一堆像是殭屍的 process(實際上暫用 CPU 的只有一個)。

最後只保留 /+files 這個資料夾、把其他快取的檔案刪掉再啟動伺服器就正常了。看起來像是 devpi 會下載整個 PyPI 的索引資料,但是同步完成前有什麼錯誤造成檔案損壞後無法從錯誤中恢復過來,所以一直保持「下載與處理索引」的狀態佔用 CPU。