Skip to main content

16 posts tagged with "homelab"

View All Tags

Wei Ji

前情提要

最近在寫一些小工具,涉及比較敏感的資料,所以不能直接用雲端的 LLM,要用地端/本地的 LLM 處理。過程中遇到一點問題想記錄下來,但是背景有點複雜,建議先閱讀前一篇文章:

佈署 LLM 的方法 (2026-06-24)

預計開發的小工具是這樣的:

  1. 透過 inotify 偵測特定資料夾(我是用 Python 的套件 watchfiles)。
  2. 使用 GNOME Document Scanner 將我從 EPSON ES-50 掃描的信用卡簽單 pdf 丟進該資料夾。
  3. 檔案的變化事件觸發歸檔作業。
  4. 先用 OCR 抽取簽單資訊。
  5. 再用 LLM 根據嵌端資訊推論歸檔路徑與檔名。
  6. 完成歸檔(例如指令路徑:YYYY/MM/YYYY-MM-DD_{STORENAME}_{AMOUNT}NTD.pdf)並刪除原始檔案。

不過隨著佈署 LLM 和 OCR 的過程中,謎團越來越多,我只好中止開發先來搞清楚究竟是怎麼回事。

無線網路瓶頸

因為我的對外網路是(有限流方案) 4G 無線網路,除了下載速度很感人以外,根據經驗從 Docker Hub 拉取超過 1 GB 的映像檔很高機率會失敗,像這樣:

$ podman pull ghcr.io/ggml-org/llama.cpp:full-vulkan-b9776
Trying to pull ghcr.io/ggml-org/llama.cpp:full-vulkan-b9776...
Getting image source signatures
Copying blob 4f4fb700ef54 skipped: already exists
Copying blob d1f56e4c7f2f skipped: already exists
Copying blob d4a637e5a2c3 [=========>----------------------------] 130.0MiB / 497.0MiB | 458.8 KiB/s
Copying blob 534ac9e261fe done |
Copying blob d2bfdcbbb65a done |
Copying blob a92b05b64059 [===================>------------------] 120.0MiB / 223.3MiB | 105.4 KiB/s
Copying blob 81e2f2053c8f skipped: already exists
Error: copying system image from manifest list: writing blob: storing blob to file "/var/tmp/container_images_storage936046123/2": happened during read: Digest did not match, expected sha256:a92b05b640591907c483d83fccf883b9f5d2b4d90369cd44cca5be58bbf7ea8e, got sha256:38656550ac584269f4bff1c74b238fea1d77c997d8439b484f75d803e7952012

llama.cpp 越來越胖了

原本使用 llama.cpp 的原因之一就是它比 ollama 瘦很多,很適合作為微服務使用,不過最近觀察到它越來越胖了:

$ podman images | grep llama.cpp
ghcr.io/ggml-org/llama.cpp server-vulkan-b9755 40547717e769 2 days ago 905 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b9737 274d563f216f 3 days ago 905 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b9102 74d080b604c3 6 weeks ago 538 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b8496 33a2ea3a7ef4 3 months ago 530 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b8248 954707a036be 3 months ago 502 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b8234 4af8b883a4d8 3 months ago 501 MB
ghcr.io/ggml-org/llama.cpp server-vulkan-b7129 35763dc5caad 7 months ago 428 MB

所以我有點擔心它會在不久的將來觸及到我的網路環境極限。無線路由器的升級計畫以及調高無線網路資費方案已經排入待辦事項。

Gemma 4 很肥

想說現在是 2026 年了,試著用用看性價比評價很高的 Gemma 4 看看,但是即便是 Q8_0 量化的 gemma-4-E2B-it 也有 5GB 左右。

後來試著抓了 ggml-org/SmolLM3-3B-GGUF 來用,Q4_K_M 只要 2GB 左右,但是沒開 thinking 的話會笨笨的,連「strawberry 有幾個 r」都會回答錯...

curl Post
{
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"role": "assistant",
"content": "To find the number of r's in the word \"strawberry,\" let's break it down letter by letter:\n\ns-t-r-a-w-b-e-r-r-y\n\nNow, we can look for the letter 'r':\n\n1. The first 'r' appears at the third position.\n2. The second 'r' appears at the eighth position.\n\nSo, there are two 'r's in the word \"strawberry.\""
}
}
],
"created": 1782281452,
"model": "SmolLM3-Q4_K_M.gguf",
"system_fingerprint": "b8234-213c4a0b8",
"object": "chat.completion",
"usage": {
"completion_tokens": 89,
"prompt_tokens": 82,
"total_tokens": 171
},
"id": "chatcmpl-dQKDcL7Torw1Y2hGtyKJr9OtD7L0lpHM",
"timings": {
"cache_n": 81,
"prompt_n": 1,
"prompt_ms": 98.934,
"prompt_per_token_ms": 98.934,
"prompt_per_second": 10.10774860007682,
"predicted_n": 89,
"predicted_ms": 6249.039,
"predicted_per_token_ms": 70.2139213483146,
"predicted_per_second": 14.242189879115813
}
}

開了 reasoning 可以改善這個問題,不過因為輸出的 token 量變多,所以反應時間會變長:

curl Post
{
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"role": "assistant",
"content": "The word **\"strawberry\"** contains **3** instances of the letter **\"r\"**. \n\nBreaking it down:\n- **Position 3**: \"R\"\n- **Position 8**: \"R\"\n- **Position 9**: \"R\"\n\nSo the total number of r's is **3**. \n\n**Answer:** 3",
"reasoning_content": "\nOkay, let me try to figure out how many times the letter 'r' appears in the word \"strawberry.\" First, I need to write out the word and look at each letter one by one. Let me start by spelling it out: S-T-R-A-W-B-E-R-R-Y. \n\nAlright, starting from the beginning, the first letter is 'S'. Not an 'r'. Next is 'T', still not 'r'. Then the third letter is 'R'. So that's one 'r'. Let me keep going. After the 'R', we have 'A', then 'W', 'B', 'E', and the next letter is another 'R'. So that's the second 'r'. Then there's another 'R' right after that, making it the third 'r'. \n\nLet me double-check. The word is S-T-R-A-W-B-E-R-R-Y. Breaking it down:\n\n1. S\n2. T\n3. R\n4. A\n5. W\n6. B\n7. E\n8. R\n9. R\n10. Y\n\nSo positions 3, 8, and 9 are 'R's. That would make three 'r's in total. Wait, is that right? Let me count again. Starting from the first letter:\n\n1. S\n2. T\n3. R (1)\n4. A\n5. W\n6. B\n7. E\n8. R (2)\n9. R (3)\n10. Y\n\nYes, that's three 'r's. Did I miss any? Let me visualize the word again. \"Strawberry\" – maybe there's a different way to spell it? No, the correct spelling is S-T-R-A-W-B-E-R-R-Y. The 'r' after the 'w' and the two after the 'e' make three. I don't see any other 'r's in there. So the answer should be three.\n"
}
}
],
"created": 1782354762,
"model": "SmolLM3-Q4_K_M.gguf",
"system_fingerprint": "b8496-312d870a8",
"object": "chat.completion",
"usage": {
"completion_tokens": 491,
"prompt_tokens": 262,
"total_tokens": 753,
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"id": "chatcmpl-J02gP2dc5X4IxzJcadTUDxZAOZWpGnvb",
"timings": {
"cache_n": 0,
"prompt_n": 262,
"prompt_ms": 1341.488,
"prompt_per_token_ms": 5.12018320610687,
"prompt_per_second": 195.30551149171666,
"predicted_n": 491,
"predicted_ms": 42248.081,
"predicted_per_token_ms": 86.04497148676171,
"predicted_per_second": 11.621829640025545
}
}

要留意,根據官方文件說明,要使用 thinking/reasoning 必須給予 --jinja 參數1, 但是較舊的 llama.cpp 似乎不支援這個參數,如:ghcr.io/ggml-org/llama.cpp:server-vulkan-b8248,關於 llama.cpp 版本的坑後面還會提到更多。

llama.cpp 呼叫 Hugging Face 的行為

大約在 b8496 (2026-04-24) 和 b9102 (2026-05-11) 兩個版本之間,llama.cpp 從 Hugging Face 上拉取模型的行為發生了變化,具體是哪個版本我懶得調查了。

當使用這樣的參數時:

    environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF
- LLAMA_ARG_MODEL=SmolLM3-Q4_K_M.gguf

b8496 以前的版本會使用像這樣的路徑下載:

/ggml-org/SmolLM3-3B-GGUF/resolve/main/SmolLM3-Q4_K_M.gguf

b9102 以後則是會先去呼叫這個路徑:

/api/models/ggml-org/SmolLM3-3B-GGUF/refs

/refs 這個 API 在我使用的鏡像方案 Olah 並未實做。

llama.cpp 的架構問題

在 Python 的體系中,應用程式和 LLM 模型之間還夾了 SDK、Hugging Face Transform、PyTorch,當某人發布了新的 LLM,它的架構會在 SDK 那層會處理。但是 llama.cpp 只有 C++ 實做和 GGUF 模型,所有架構都必須在 llama.cpp 的 C++ 實作內 hard coding。

這造成了 llama.cpp 支援模型的能力高度被 llama.cpp 的更新進度榜定,例如在 b8248 中試圖運行 Gemma 4的話會得到:

llm-1  | llama_model_load: error loading model: error loading model architecture: unknown model architecture: 'gemma4'
llm-1 | llama_model_load_from_file_impl: failed to load model
llm-1 | common_init_from_params: failed to load model '/root/.cache/llama.cpp/unsloth_gemma-4-E2B-it-GGUF_gemma-4-E2B-it-Q4_K_M.gguf'
llm-1 | srv load_model: failed to load model, '/root/.cache/llama.cpp/unsloth_gemma-4-E2B-it-GGUF_gemma-4-E2B-it-Q4_K_M.gguf'
llm-1 | srv operator(): operator(): cleaning up before exit...
llm-1 | main: exiting due to model loading error
llm-1 exited with code 0

Docker Registry API V2

b8496 以下的版本使用這個配置的話可以正常下載模型:

    environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF
- LLAMA_ARG_MODEL=SmolLM3-Q4_K_M.gguf

但是當需要使用像 PaddlePaddle/PaddleOCR-VL-1.5 這樣的模型時,下載一個檔案是不夠的,需要多個檔案才能讓模型在 llama.cpp 內運作:

  • PaddleOCR-VL-1.5.gguf
  • PaddleOCR-VL-1.5-mmproj.gguf
  • chat_template.jinja

這是因為它屬於多模態模型,而不是單純的 LLM,(應該)可以使用以下參數來來下載多個檔案:

    environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF:Q4_K_M

但是會出現以下錯誤:

error from HF API (http://huggingface.mirrors.solid.arachne/v2/ggml-org/SmolLM3-3B-GGUF/manifests/Q4_K_M), response code: 404, data: {"error":"Sorry, we can't find the page you are looking for."}

因為這是 Docker Registry API,而 Olah 沒有實作。

OCI AI Payload

恩?明明是從 Hugging Face 下載模型,怎麼連 Docker Registry API 都來參一腳了?

稍微搜尋了一下發現這個東西:

https://hub.docker.com/r/ai/gemma4

而且它不能直接用 Podman 下載:

$ podman pull docker.io/ai/gemma4:E2B
Trying to pull docker.io/ai/gemma4:E2B...
Error: parsing image configuration: unsupported image-specific operation on artifact with type "application/vnd.cncf.model.manifest.v1+json"

原因是這是特殊的 OCI 封裝格式2,據我所知是因為它沒有 roofs 的資訊,必須使用像這樣的指令拉取:

podman unshare skopeo copy \
--insecure-policy \
docker://harbor.mirrors.liquid.arachne/docker-hub-proxy/ai/gemma4:E2B \
dir:./

簡單來說,Hugging Face API 目前雖然作為實質產業標準,但是 CNCF 有意對「怎麼佈署與傳輸類神經模型檔案」訂出標準。

llama.cpp 也支援該界面:

ArgumentExplanation
-dr, --docker-repo [<repo>/]<model>[:quant]Docker Hub model repository. repo is optional, default to ai/. quant is optional, default to :latest.
example: gemma3
(default: unused)
(env: LLAMA_ARG_DOCKER_REPO)

如果這個方案可以普及,我的 Olah 就可以退役了,轉而使用更可靠的 Harbor。不過目前只有 Docker Hub 在使用,而 Docker Hub 的流量限制本身就是害我拉大檔案拉不下來的因素之一。

OCR 模型表現不穩定

[ocr]        | srv  params_from_: Chat format: peg-native
[ocr]        | slot get_availabl: id  3 | task -1 | selected slot by LRU, t_last = -1
[ocr]        | slot launch_slot_: id  3 | task -1 | sampler chain: logits -> ?penalties -> ?dry -> ?top-n-sigma -> top-k -> ?typical -> top-p -> min-p -> ?xtc -> temp-ext -> dist 
[ocr]        | slot launch_slot_: id  3 | task 0 | processing task, is_child = 0
[ocr]        | slot update_slots: id  3 | task 0 | new prompt, n_ctx_slot = 4096, n_keep = 0, task.n_tokens = 1179
[ocr]        | slot update_slots: id  3 | task 0 | n_tokens = 0, memory_seq_rm [0, end)
[ocr]        | slot update_slots: id  3 | task 0 | prompt processing progress, n_tokens = 5, batch.n_tokens = 5, progress = 0.004241
[ocr]        | slot update_slots: id  3 | task 0 | n_tokens = 5, memory_seq_rm [5, end)
[ocr]        | srv  process_chun: processing image...
[ocr]        | encoding image slice...
[ocr]        | image slice encoded in 21944 ms
[ocr]        | decoding image batch 1/5, n_tokens_batch = 256
[ocr]        | image decoded (batch 1/5) in 197 ms
[ocr]        | decoding image batch 2/5, n_tokens_batch = 256
[ocr]        | image decoded (batch 2/5) in 576 ms
[ocr]        | decoding image batch 3/5, n_tokens_batch = 256
[ocr]        | image decoded (batch 3/5) in 335 ms
[ocr]        | decoding image batch 4/5, n_tokens_batch = 256
[ocr]        | image decoded (batch 4/5) in 412 ms
[ocr]        | decoding image batch 5/5, n_tokens_batch = 142
[ocr]        | image decoded (batch 5/5) in 428 ms
[ocr]        | srv  process_chun: image processed in 23892 ms
[ocr]        | slot init_sampler: id  3 | task 0 | init sampler, took 0.01 ms, tokens: text = 13, total = 1179
[ocr]        | slot update_slots: id  3 | task 0 | prompt processing done, n_tokens = 1179, batch.n_tokens = 8
[ocr]        | slot print_timing: id  3 | task 0 | 
[ocr]        | prompt eval time =   24747.40 ms /  1179 tokens (   20.99 ms per token,    47.64 tokens per second)
[ocr]        |        eval time =   93429.63 ms /  2917 tokens (   32.03 ms per token,    31.22 tokens per second)
[ocr]        |       total time =  118177.03 ms /  4096 tokens
[ocr]        | slot      release: id  3 | task 0 | stop processing: n_tokens = 4095, truncated = 1
[ocr]        | srv  update_slots: all slots are idle
[ocr]        | srv          stop: cancel task, id_task = 0
[ocr]        | srv  update_slots: all slots are idle
[ocr]        | srv    operator(): got exception: {"error":{"code":500,"message":"Failed to parse input at pos 0: TQ;的既然既然  既然既然 称即作四作作作�는戒инин 교 교浦に��집집집 அமை作 (ӀэӀэӀэӀэ的的��ده层透明透明ண்டுண்டு羡羡 the the\n\n\n裔5�(,,,�輒放放��貲支����ver.جی顯顯由於宇丁ine  的有เพ阵年是 ilesuணணஸஸ�興�����。那那那ӀэӀэӀэ蚚��图的疾 ..,то角角���ололол���浆â�,,,的的��,,,,的的�,,場从shshdd�,0,,贺的的的的的的\n\n的的200,,,,,,禹.\nmff..,只有在y���转  .,,,争www-----��      iew徒��\n\n\n\n\nimetையில்ையில்ையில்ையில்友ssss ,,,,,,,,,的的���,,的 war and and and and andsw81生\n({{\\市ne季季食生生生生�清清��在在评ans芽lan\n\n\n\n        ���,,,的的的��,,,,,���,,,,,�����-,((���ek of     的的的���,,,00,,,的的的失失失���,,的的的���,,,,,的��,,,99  简称oofCCèге�顯顯韓韓韓韓韓友���的\n�夫夫��与非与非与非与非————��ataadyofofofofof涓,,,niluoppnnnn..,,,,���,,,的的成,ansuu�,,,,,,��,,,\n‌00ofn�.,,,,ிறிறிறிறிற\n1ไ\n\n\n\n\n\n r.�,,,评\n\n\nbu-..-.-首位ofuns党 from\n评gg\n\n\nss\n\n\n\n\n W I I  \n\n\n   \n,.\n\n               in in in in in in in in in e sl  el��      \n\n\n1  ไ\n�.,,,,的��否o�----08��,,\n\n\n\n\n\n1情  \n 妇\n�,,,,,,��,,, or     1 and and and and\n\n\nlot\nر  \n\n\n۹   \n\n\n\n\n\n,,,的���,,,,回归--..,\n\n\n   �,,,的的的�蛛��--         in in\n\n fa以下的    \n ofans\n       F F E E E E连连连新新 F的的的的的的的的 W Group\\.��âè以下的以下的以下的以下的类和�� स्�}}) ,,,,,的wy��域intLLLLLau ,  江江\n\n\n�,,,的的的�� of of of欢欢欢欢G� of of������ 相ǎ� ,,,的的的 I   的的 of\n\n\n\n\nl ,------1111 from,,,, of江,,,,,�u\n\n\n\n\n的的的的1���,,的的的的的��艺� (\n Ю�\n of of\n\n\n\n\nзна�\n\n\n\n\n\n\n W资�, Sc6\n\n\n\n的 मा \n\n\n\n\n\n\nssss的�\n江66666是�和在在在在在在在在       …. or or和111在�n�.., or\n\n决定场场场场生, (\n\n\n\n\n\n         的的��   G G G G,天天天6的的的的的 ( (学习方法omuuu\n11111111���,९和\n\n\n\n\n\n S S C C C C C C C C C C۳\n\n\n\n\n\n\n\n\n\ncc1��,,,,的的的�,的的的的�的的的的的的的的的的的的在在在在恢复正常\n\n1111111��,,,,,的的�不可oooooââ�\n�\n    .222,,,行文文文生生�文\n��连         请\n\n\n\n\n\n在在在�\n\n\n\n\n\n (�\n�大有有有的的的的���,,,,,的的的���,,,,的的法�\n\n\n\n\n\n的�� \n\n\n\n\n\n\n\n\n\\\\\\\\\\\\��,�阵战委天天天天内内非LL在在在在在在在在在在的\n\n的的的的的的的的的�غ行\n\n\n\n\n\n            �����,�1nn0,,,,,的的的 R\n�清有的的的的的的的的的������,,, (\n境\n���\n\n\n C�\n\n and��的的的的的晙�\n\n掉\n\n\n\n\n\n C C C C C C C C C C C C C C C C C C C C C C C C C C C C政政政政政生、,\n天天�掉\n�\n6000,,,,,,的的���, Wh W\n G G G G C C C C C迁迁迁迁 of,    ofลง�WW�WL��\nand暚\n\n\n非非 G Out Out Out未婚汲汲汲汲汲諾諾諾�\n\n\n\nC在在在在在          \n大大的\n\n领域的�\n�,,\n\n\n\n\n\n\n\n\n C C C C C C\n\n\n,,,,,\n\n\n\n\n\n\n\n��0,,,,,,��,,,,\n\n\n\n\n\naaaasssss\n�城amâéâââ以下的以下的以下的以下的法和类和 स् WIลงорт政政质质质�\n\nSکن�\n\n\n\n\nandR�.生生\n\n\n\n\n\n\ngauauorotimesotimesotimes.—.—.—諾諾諾諾諾â��,, and天天\n\n�0,,,,,,,\nธ� .,,,,,,的 of of of ofofofofofof灰灰灰\nand��0,\n\nandand�艺\n\n\n\n\nandR�生失失\n\n\n\n\n\n\n in in in in in in in in in in\n\n\n\n\n的的的的的在在在在在前前前前前在âi�n�,,,,资格\nธâl�.艺\n\n���000,,,\n\n\n\n\nธR .��财非非非非非非非非非\n生生\n\n��0,,,,,�ab99�ââ�,,,生生生生生城\n\n\n\n\n\n\naaaaaa C C C C C e e e e e e e评,\n\n\n\n\n\n\n\n6R政\n\n\n\n 함 함 함 함 of of of ofofofofofofofofer\n\nWGGGLL\n\n\n\nGâéâè以下的以下的以下的法和\n\n\nธ城城�开工� and���iideideideideideideararauâ\n\n\n\n\n\n\n\n在在在在在����,ans\n\n\n\nâââ以下的以下的以下的类和类和类和 Duranteवन\n\n\n\nââ\n\n\n\n\n0、生行\n�â�以下的以下的ons域域域000\n\n\n\n\n\n\n\n旧旧旧旧otimes���er掉\nâand and\n\n�âant and and,�行�ââân�,,,的�\n\n\n\n\naa000I的的的的的的的的的的的的的的的的���नानाना新新ererer低估低估汲汲汲汲\n\n\n\n\n\n\n C C C C C C C C C C C C C C���兽骏骏 함 함 함 함 함secsecsecsec骏骏骏 文\n�â�\n\n\n\n\n�R���\n\n\n\n跨跨跨跨��\n\n\n\naaaaaarerereotimes汲汲ना\n (院\n\n\n\n\n\n11111���\n\n�����\n\n\n\n\n\n\n中的的的的的�â�\n\n�\n\n�\n\n\n0���(\n\n\n\n\n\n\n\n\n\n\n\n\n\n在在在在在在在在在在的的的的��and�\n\n\n\n\n\n\nG新新在在在在在在在在在的1���RR2�,\n�\n\n\n\n\n\n\n\n\n  ��,,,,,\n\n\n\n\n\n\n\n12��,,,,,的的的�\n\n\n\n\n\nG,,,,,\n\n(R�\n\n\n\n\n\n\n非非非非非icic,�âââ以下的以下的以下的以下的以下的类和类和� Durante\n�0,,,,,,���\n\n\n\n\n\n,1�\n\n\n\n\n���90,,,,的的的的�\n\n\n\n\n�RRRnnnnnnnRFnnn,,,,,,的的���,,,,,的�\n\n\n\n\n\n (RRR者\n\n\n\n\ng新在在在�\n\n\n\n\n\n\n\n在在�\n��111\n\nธ�大大的的的在龙\n\n\n\n\n\n (\n���有有有有境言go\nate\n\n\n\n\n\nL1111���,\n�\n�,\n\n�有\n�\n�要要要要nnnF在在在FF1��,,,,的的���,,,,�\n\n\n\n\n\n\n\n\n在在在在在在在在在在在在在在在在在在在在在1���,,,言言L�i�âââââ  âand櫫��ââ�\n���âââ\n\n\n\n\nG,,,۹�艺艺艺����������城城�\n������������言境\n66��â��,\n\n�\n�艺�\n���,,,,,的����â\n\n�éâ以下的以下的以下的一向�\n\n\n�\n��\n�������������â��,,","type":"server_error"}}
[ocr]        | srv  log_server_r: done request: POST /v1/chat/completions 10.89.62.26 500

有的時候 PaddleOCR-VL-1.5-GGUF 會異常,原因未知。

llama.cpp 快取失蹤

這個參數不會下載到 /root/.cache/llama.cpp,而是直接放在 /app 之下:

    environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF
- LLAMA_ARG_MODEL=SmolLM3-Q4_K_M.gguf

這個才會,volume 設定才有用:

    environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF
- LLAMA_ARG_HF_FILE=SmolLM3-Q4_K_M.gguf
volumes:
- data:/root/.cache/llama.cpp

模型下載問題

前面講的多模態檔案可以用以下配置解決:

services:
downloader:
image: docker.io/huggingface/downloader:0.17.3
entrypoint: ["sh", "-ec"]
command:
- |
echo "Download model... ";
huggingface-cli download "PaddlePaddle/PaddleOCR-VL-1.5-GGUF" \
--include "PaddleOCR-VL-1.5-mmproj.gguf" "PaddleOCR-VL-1.5.gguf" "chat_template.jinja" \
--cache-dir=/hf-cache \
--local-dir=/data/PaddleOCR-VL-1.5-GGUF \
--local-dir-use-symlinks=False
volumes:
- model-cache:/data
- hf-cache:/hf-cache
ocr:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b8248
restart: always
devices:
- /dev/dri/:/dev/dri/
ports:
- 8080:8080
entrypoint: /app/llama-server
volumes:
- model-cache:/data
environment:
- LLAMA_ARG_MODEL=/data/PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5.gguf
- LLAMA_ARG_MMPROJ=/data/PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5-mmproj.gguf
- LLAMA_ARG_CHAT_TEMPLATE_FILE=/data/PaddleOCR-VL-1.5-GGUF/chat_template.jinja
- LLAMA_ARG_WEBUI=disabled
- LLAMA_ARG_N_GPU_LAYERS=all
- LLAMA_ARG_CTX_SIZE=20000
- LLAMA_ARG_TEMP=0
- LLAMA_ARG_JINJA=1
- LLAMA_ARG_FIT=off
depends_on:
downloader:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 20s
retries: 3

volumes:
model-cache:
hf-cache:

一個容器專門跑下載,另外一個容器專門運行模型。不過這個配置有兩個問題:

  • 在 docker compose 可以正常運作,但是 podman compose 對於 depends_on 的支援度不夠高因此不會按照預期的順序啟動容器。
  • 我可能還沒找到正確配置 huggingface-cli 指令的方式,即便檔案已經存在它也不會自動忽略而是重新下載一次。

Footnotes

  1. ggml-org/SmolLM3-3B-GGUF · Hugging Face. https://huggingface.co/ggml-org/SmolLM3-3B-GGUF

  2. model-spec/docs/spec.md at main · modelpack/model-spec https://github.com/modelpack/model-spec/blob/main/docs/spec.md

Wei Ji

前情提要

最近在寫一些小工具,涉及比較敏感的資料,所以不能直接用雲端的 LLM,要用地端/本地的 LLM 處理。不過過程中遇到一點問題,想留個紀錄,但是背景有點複雜,於是想說單獨寫一篇解釋一下我目前的各種技術決策。

雲端 LLM

  • Application 端: OpenAI-Compatible API
  • 可觀測端:TensorZero
  • 模型端:Open Weight Models
warning

TensorZero 已經於 2026-06-12 停止維護。

OpenAI-Compatible API 基本上實質產業標準了,不過僅限 LLM 體系,非 LLM 的模型(例如影像或音訊處理)生態比較複雜,沒辦法被這種 API 設計完全覆蓋了。

LLM 可觀測的用途是可以紀錄各種模型、Token 量、費時提示詞以及 reasoning(一般不會直接顯式在應用程式上)。當時選擇 TensorZero 的原因是相對於 Langfuse 和 LiteLLM 的組合更輕量,而且有故障的經驗(雖然事後得知是 ClickHouse 配置失誤的原因)。

模型端則是只使用開放權重的模型,避免供應商鎖定。

地端 LLM

  • Application 端: OpenAI-Compatible API
  • GPU 端:Vulkan
  • 模型運行端:GGUF
  • Runtime 佈署端:OCI (Open Container Initiative)
  • OCI 佈署端:Docker Registry API V2
  • 模型佈署端:Hugging Face API
  • 模型快取/鏡像:Olah
  • OCI 快取/鏡像:Harbor
  • OCI 編排端:Podman-Compose

應用程式同樣使用 OpenAI-Compatible API 使用 LLM。

使用 llama.cpp 運行 LLM,因為它支援多種 GPU 後端(包含主流的 CUDA、ROCm、SYCL...),特別是其中的 Vulkan 可以避免供應商鎖定。其使用的 GGUF 檔案則是針對量化的支援度很高,使用量化模型在該生態系可以說是預設行為,生態系中的模型大多經過量化,量化後的模型較小,可以節省傳輸流量以及運行時的 VRAM 消耗。同時官方提供可以開箱即用的預建置 OCI (Open Container Initiative) 映像檔,對雲原生環境十分親和。

使用 Podman Compose 運行 OCI 容器。使用 Podman 是因為想要試著脫離 Docker 的體系,並且嘗試 Rootless/Daemonless 的方案。Podman 生態系的 Dokcer Compose 對標容器編排方案是 Quadlet;一個 systemd 風味的方案,但是這樣做會偏離主流開發者太遠,所以我使用 Podman Compose 來繼續使用 docker-compose.yaml

我的 homelab 對外網路是 4G 無線路由器加上方案有被限流,所以大部分涉及「套件下載」的東西我都有架設本地鏡像/快取來節省流量,包含:apt、npm、pypi、OCI、Hugging Face。同時把快取的職責甩給 homelab,工作電腦就可以經常清掉暫時用不到的快取,有必要再直接從 homelab 拉下來用。

如此一來就可以在本地快速建立一個 LLM 實例供開發測試使用:

services:
llm:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b8496
restart: always
devices:
- /dev/dri/:/dev/dri/
ports:
- 8080:8080
entrypoint: /app/llama-server
environment:
- LLAMA_ARG_HF_REPO=ggml-org/SmolLM3-3B-GGUF
- LLAMA_ARG_MODEL=SmolLM3-Q4_K_M.gguf
- LLAMA_ARG_WEBUI=disabled
- LLAMA_ARG_N_GPU_LAYERS=all
- LLAMA_ARG_CTX_SIZE=20000
- LLAMA_ARG_TEMP=0
- HF_ENDPOINT=http://huggingface.mirrors.solid.arachne
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 20s
retries: 3

後記

問題的部份下一篇文章再提。

Wei Ji

前情提要

我在 Homelab 使用 Longhorn 作為儲存後端,並且其實已經經歷過幾次調整 PVC (Persistent Volume Claim) 容量了,分別是 PyPi 和 Hugging Face 本地鏡像。

info

「儲存後端」在 K8s 的正確術語為 Storage Provider 或是理解成 CSI(Container Storage Interface) 實作,我稱為儲存後端只是為了通俗理解與方便不熟的讀者閱讀。

最近則是 S3 實例 (MinIO) 快滿了,想說這次擴展的時候順便寫個筆記紀錄一下。

調整大小

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
labels:
io.kompose.service: minio-data
name: minio-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 250Gi
kubectl apply -k .

然後...就可以了...

配額已達上限

算是小插曲,原本想從 200Gi 翻成兩倍的,但是看來總配額已經滿了:

kubectl apply -k .
service/minio-service unchanged
statefulset.apps/minio unchanged
ingress.networking.k8s.io/minio unchanged
Error from server (Forbidden): error when applying patch:
{"metadata":{"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"kind\":\"PersistentVolumeClaim\",\"metadata\":{\"annotations\":{},\"labels\":{\"io.kompose.service\":\"minio-data\"},\"name\":\"minio-data\",\"namespace\":\"minio-stack\"},\"spec\":{\"accessModes\":[\"ReadWriteOnce\"],\"resources\":{\"requests\":{\"storage\":\"400Gi\"}}}}\n"}},"spec":{"resources":{"requests":{"storage":"400Gi"}}}}
to:
Resource: "/v1, Resource=persistentvolumeclaims", GroupVersionKind: "/v1, Kind=PersistentVolumeClaim"
Name: "minio-data", Namespace: "minio-stack"
for: ".": error when patching ".": admission webhook "validator.longhorn.io" denied the request: error while CheckReplicasSizeExpansion for volume pvc-2fad0e19-2f42-4dd2-8dc7-4e705f227432: cannot schedule 214748364800 more bytes to disk b08f46da-f50f-481d-8eff-de1e605e7859 with &{DiskUUID:b08f46da-f50f-481d-8eff-de1e605e7859 StorageAvailable:1702363136000 StorageMaximum:3936770629632 StorageReserved:214748364800 StorageScheduled:3625277456384 OverProvisioningPercentage:100 MinimalAvailablePercentage:25}

故障排除

這次過程蠻順利的,但是之前擴展的時候其實遇過 PVC 改大小是卡住的,但是那個時候沒截圖。故障排除的方式是需要把有使用 PVC 的 Pod (StatefulSet) 暫時移除並且 Detach PVC1

後記

順便補一下一些跟 PVC 有關的東西。

PV 持久化設定

PV (Persistent Volume) 有一個叫做 reclaimPolicy 的設定,如果沒有設定為 Retain 的話 (預設值為 Delete),在 PVC 資源移除時對應的 PV 會被系統自動清除。

更新系統層級的設計,日後新的 PVC 會遵守該設定2

kubectl -n longhorn-system edit configmaps longhorn-storageclass
# 找到 `reclaimPolicy` 欄位設定成 `Retain`

若要更新已經建立的 PV,則使用以下指令:

for pv in $(kubectl get pv -o jsonpath='{.items[*].metadata.name}'); do     kubectl patch pv "$pv" -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'; done

PVC Dashboard

平常是使用 Prometheus Helm 內建的 Grafana Dashboard (Kubernetes/Persistent Volumes),雖然可以單獨檢查 PVC 但是缺乏快速對所有 PVC 有一個總覽可以快速掌握的功能。於是我安裝了另外一個 Dashboard 來做這件事:

只是不知道為什麼有一點瑕疵:

  • 其中一個 PVC 的數字異常。
  • 使用百分比的進度條顯示異常。

只是目前暫時懶得故障排除。

Footnotes

  1. How can I resize the longhorn volume? The new size should be shown in both rancher&longhorn UI · Issue #2263 · longhorn/longhorn. Retrieved 2026-04-01 from https://github.com/longhorn/longhorn/issues/2263#issuecomment-935739738

  2. How to change reclaimPolicy without reinstalling everything? · longhorn/longhorn · Discussion #10102. Retrieved 2026-04-01 from https://github.com/longhorn/longhorn/discussions/10102#discussioncomment-14380415

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&amp;name=kubernetes_operators_diagram1.png

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

https://blog.container-solutions.com/hs-fs/hubfs/kubernetes_operators_diagram2.png?width=750&amp;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

前情提要

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,試用了一下感覺不錯,就是它的資料庫配置稍微有點麻煩,這個部份可能之後再提。