Skip to main content

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

我發現網路上一些對於軟體工程範式 (Paradigm) 的討論,對於上下文邊界的設定十分模糊,於是稍微整理一下我看待這個問題的模型。

如何撰寫程式語言?​

了解如何撰寫程式碼語法,例如以下是「如何撰寫 C++?」

#include <iostream>

int main() {
std::cout << "Hello, world!\n";
}

如何使用程式語言?​

了解特定程式語言的工具鏈,例如以下是「如何使用 Javascript?」

1. Javascript 有至少兩種 runtime:Node.js 和 Web
2. 想要使用套件必須透過 npm, pnpm, yarn 從 `registry.npmjs.org` 下載。
3. 使用 nvm 管理不同的 Node.js。
4. 使用諸如 vite 或 webpack 之類的工具打包專案。

如何組織 (organize) 程式語言?​

了解如何切割程式碼,例如:

  • 設計模式
    • 工廠模式
    • 管線模式
    • Facade 模式
    • Adapter 模式
    • etc
  • OOP (Object-oriented programming)
  • ECS (Entity component system)
  • Rich Domain Model
  • Anemic Domain Model
  • ORM (Object-Relational Mapping)

即處理「程式碼等級的架構問題」。

如何編排 (orchestration) 軟體?​

了解如何切割軟體模組,例如:

  • 前後端分離
  • 職責分離成多個函式庫
  • Server-Client 架構
  • Controller-Worker 架構

即處理「軟體如何被使用、佈署、運行的架構問題」。

如何驅動軟體開發?​

了解「產生程式碼」其實處於軟體工程的下游,並且決定要用什麼東西當作上游的「單一事實來源」來驅動軟體開發,例如:

  • TDD (Test-Driven Development)
  • BDD (Behavior-Driven Development)
  • DDD (domain-driven design)
  • SDD (Specification-Driven Development)

這個軟體需求是否存在已知供給?​

了解到解決問題最好的方法可能不是寫程式(開發軟體),面對需求時先問一個問題:

這個需求是否已經被解決過了?

E2E 解決方案​

市場或生態系上是否已經存在完整的 E2E 解決方案?例如:

  • 電子商務 -> Shopify 或其他類似方案。
  • CMS (content management system) -> WordPress 或其他類似方案。
  • 試算表軟體 -> LibreOffice 或其他類似方案。
  • 專案管理 -> OpenProject 或其他類似方案。
  • etc.

軟體解決方案​

市場或生態系上是否已經存在解決部份問題的軟體(E2E 以下;函式庫以上)?例如:

  • RDBMS (relational database management system) -> MySQL 或其他類似軟體。
  • NoSQL 資料庫 -> MongoDB 或其他類似軟體。
  • Authentication -> Keyclock 或其他類似軟體。
  • Authorization -> OpenFGA 或其他類似軟體。
  • 影音編解碼 -> FFmpeg 或其他類似軟體。
  • etc.

函式庫解決方案​

市場或生態系上是否已經存在程式碼實作?例如:

  • Material Design -> mui/material-ui 或其他類似函式庫。
  • 影音編解碼 -> GStreamer
  • WebGL 抽象化 -> Three.js
  • etc.

部份引用​

即便不直接使用上述解決方案,依然可以參考該解決方案的已經存在的:

  • 領域模型
  • 程式碼
  • 界面設計
  • 軟體架構
  • 程式碼架構

Wei Ji

最近在整理腦中對於儲存的觀念,試著用「如果儲存是 RPG 等級制」的概念寫了一段虛構的過程,不完全嚴謹,但是應該有助於初學者建立觀念。

Level 1​

你安裝了 Linux 作業系統,在安裝過程把硬碟格式化成 ext4 並切割了磁區:

  • /boot/efi: fat32
  • /boot: ext4
  • /home: ext4
  • 剩下的給 /: ext4

安裝給系統的檔案會到 /,使用者各自囤積的檔案則會儲存在 /home。

但是用著用著,你發現 /home 快滿了但是 / 還很空。

Level 2​

你安裝了 Linux 作業系統,這次你在安裝過程先對硬碟建立 LVM(Logical Volume Manager):

  • /boot/efi: fat32
  • /boot: ext4
  • VG (Volume Group)
    • /home LV (Logical Volume): ext4
    • 剩下的給 / LV: ext4

但是用著用著,你發現 /home 快滿了但是 / 還很空,於是你利用 LVM 的特性修改 /home 和 / 這兩個 LV 的大小。

但是用著用著,你發現硬碟滿了,於是你買了另外一個硬碟接上電腦,卻發現現在你有 sda 和 sdb 兩顆硬碟了,資料夾的安排變得很不方便。

Level 3​

你安裝了 Linux 作業系統,這次你在安裝過程先對硬碟建立 LVM(Logical Volume Manager):

  • /boot/efi: fat32
  • /boot: ext4
  • VG (硬碟A + 硬碟B)
    • /home LV (Logical Volume): ext4
    • 剩下的給 / LV: ext4

你把多個硬碟當成一個硬碟使用,用得很愉快,但是好景不常,外面發生了警匪槍戰,一顆子彈剛好打在你的其中一顆硬碟上,現在你所有的資料都沒辦法讀取了。

Level 4​

你根據教學 (https://std.rocks/gnulinux_mdadm_uefi.html) 折騰了一番,

  1. 先在每一個硬碟切出 EFI 和普通磁區
  2. 再用 mdadm 將磁區建立 raid 1 (或是 raid 1/0 或是 raid 5)
  3. 再把 mdadm 陣列格式化成 LVM
  4. 把 LVM 切成 / 和 /home 要用的邏輯磁區
  5. 最後再把 Linux 安裝進 /

用著用著,外面又發生了槍戰,你的其中一個硬碟又被打壞了,這次你有恃無恐的更換掉一顆硬碟。

但是重建速度太慢,重建完成之前第二顆硬碟又被子彈打中了,現在你的所有資料又丟失了。

Level 5​

你買了一張硬碟陣列卡,這次你直接在一張 SSD 安裝系統,陣列卡模擬的硬碟直接掛在 /home 下,你心想系統壞了就直接重灌就是了。

用著用著,外面又發生了槍戰,你的其中一個硬碟又被打壞了,這次你有恃無恐的更換掉一顆硬碟。

過了幾年外面又發生了槍戰,這次換你的硬碟陣列卡被打中,買不到相同型號的陣列卡,你的資料又報廢了。

Level 5-1​

在 Level 5 陣列卡被打壞後,你意識到 RAID 不是備份 (RAID is not Backup)。你開始實行 3-2-1 原則:

  • 3 份資料拷貝。
  • 2 種不同的存儲媒介。
  • 1 個異地存放
info

本段落由 Gemini fast 補充。

Level 6​

這次你決定安裝 ZFS 把多顆硬碟組成陣列,享受它帶來的冗餘與快照功能。

然後你發現你的硬碟數量受到主機板的 I/O 數量限制,雖然你可以用兩台電腦組合成更大的帳面容量,但是你面臨 Level 2 類似的情況,你有兩個檔案目錄不方便管理。

Level 6-1​

在使用 ZFS 後,你發現了「靜態資料損壞」的可怕。你開始定期進行 zpool scrub,確保即便子彈沒打中,背景輻射或硬體老化也不會悄悄吃掉你的位元。

info

本段落由 Gemini fast 補充。

Level 7​

你決定使用 GlusterFS,現在多個節點的儲存空間可以被視作一個資源池使用了。

Level 8​

你發現在 GlusterFS 的架構下還要為每個節點配置軟體陣列太麻煩了,SDS (Software-defined storage) 本身就有實作冗餘機制,於是你直接使用 JBOD (Just a Bunch Of Disks)。

但是你發現你依然需要管理「儲存空間」這個概念,為什麼我不能有一個「接近無限」的實體而無須在意有多少空間?

Level 9​

你配置了 MinIO,現在你要儲存資料不再關心磁區的概念,而是單純對檔案 CRUD。

你意識到你經歷三種儲存類型:

  • File
  • Block
  • Object

Level 9-1​

在進入分散式存儲後,你發現資料不再是「寫進去就在那裡」。你開始研究 CAP 定理:在網路斷開時,你的系統要選擇「一致性 (Consistency)」還是「可用性 (Availability)」。

info

本段落由 Gemini fast 補充。

Level 10​

你配置了 Ceph 處理了所有分散式儲存的需求,但是你發現你需要在每一個節點維護 Ceph 實例有點麻煩。

Level 11​

你使用了 Rook,現在透過 Kubernetes,不論是應用程式還是儲存都透過它管理,只需要維護並運行 K8s 節點/叢集即可。

Wei Ji

這篇筆記已經放在我的 CodiMD 很久了 (2024-11-02),想說整理一下發一篇廢文。

info

關於我如何獲得模型檔案,請見稍早的文章:從 Blender 開始的解剖學筆記 - 骨骼篇 - 啟程。

warning

本人非醫學背景,以下不專業筆記如有錯誤歡迎指出。

腳掌​

腳掌可以分成三個區塊1:

  • 趾骨骨群 (Phalanx)
  • 蹠骨骨群 (Metatarsus)
  • 跗骨骨群 (Tarsus)
info

留意用詞區分了兩種概念:

  • 某個骨群(單數)
  • 某骨頭們(複數)

例如:Tarsus 是指「腳跟那個骨群」和 Tarsals 是指「腳跟那些骨頭」。

跗骨 (Tarsals)​

跗骨 (Tarsals) 則由多個骨頭構成2:

  • A: 跟骨 (Calcaneus)
  • B: 距骨 (Talus bone)
  • C: Cuboid bone
  • D: 足舟骨 (Navicular bone)
  • E, F, G: 楔形骨 (Cuneiform bones)
    • Medial
    • Intermediate
    • Lateral

距骨 (Talus)​

"anklebone," 1690s, from Latin talus "ankle, anklebone, knucklebone" (plural tali), related to or a derivative of Latin taxillus "a small die, cube" (they originally were made from the knucklebones of animals), which is of obscure origin.3

talus 在拉丁文中有腳踝之意。

跟骨 (Calcaneus)​

足舟骨 (Navicular Bone)​

楔形骨 (Cuneiform)​

楔形骨從腳掌剖面看過去很像三角形的楔子:

Medial Cuneiform​

Intermediate Cuneiform​

Lateral Cuneiform​

骰骨 (Cuboid Bone)​

跟其他骨頭比起來,它的確蠻方的:

蹠骨(Metatarsus)​

Metatarsal 1​

Metatarsal 2​

Metatarsal 3​

Metatarsal 4​

Metatarsal 5​

近節趾骨 (Proximal phalanx)​

Proximal Phalange 1​

Proximal Phalange 2​

Proximal Phalange 3​

Proximal Phalange 4​

Proximal Phalange 5​

Phalanx​

有趣的是,英文 Phalanx 有方陣之意,趾骨排列的方式是不是很像方陣呢?

Middle phalanx (Intermediate phalanx)​

拇指沒有中節趾骨,手指也是一樣的情況。

Intermediate Phalange 2​

Intermediate Phalange 3​

Intermediate Phalange 4​

Intermediate Phalange 5​

Distal phalanx​

Distal Phalange 1​

Distal Phalange 2​

Distal Phalange 3​

Distal Phalange 4​

Distal Phalange 5​

Footnotes​

  1. File:Ospied-en.svg - Wikipedia. Mario modesto. Retrieved 2026-02-25, from https://en.wikipedia.org/wiki/File:Ospied-en.svg ↩

  2. Tarsus (skeleton) - Wikipedia. Retrieved 2024-11-04, from https://en.wikipedia.org/wiki/Tarsus_(skeleton) ↩

  3. talus | Etymology of talus by etymonline. Retrieved 2024-11-04, from https://www.etymonline.com/word/talus ↩

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 val2014、cv-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

家庭、公司、政府...是常見構成國家的「最小協作單元」,當政府要買東西的時,往往受到預算法、採購法...等等法律的約制,以及制度構成的嚴謹紀錄;公司(大型企業)要買東西時的情況也差不多,受到內部的採購制度約束,以及透過 ERP、POS 等資通訊系統追蹤。

家庭或個人在「消費行為」這件事情上相較之下就顯得缺乏紀律,可能隨意購買路邊攤的小吃、短期承租店面的「出清小店」,缺乏審計制度。如果買了一個不合用的拖把,可能也不會特意去紀錄品牌跟製造商,避免下一次再購買它。換言之,企業會極力的紀錄、分析消費者,但是消費者往往不會追蹤企業的供應鍊、仔細比對不同廠商之間的差異。

這種不對稱性使得自然人在面對巨靈時,往往處於弱勢的地位。


「舉證之所在,敗訴之所在」是一句法律圈的俗語,這點出了一個奇怪的現象,法律似乎假設了一種「超人」的存在,這個超人無所不知(不會出現記憶模糊)、無所不能(總是有證據),政府或企業透過嚴謹的制度與完善的基礎建設,能夠調閱報表、單據、POS、ERP 紀錄...作為證據,使其代表的法人能夠無限逼近這個「超人」,反觀憑感覺生活的家庭或個人在法律活動中往往處於弱勢。

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/ ↩