Skip to main content

Wei Ji

前情提要

作為一個閃亮事物症候群工程屍,挖坑從來不手軟,TiddlyRAG 是一個新坑,主要是關於 RAG (Retrieval-augmented generation),於是我想先調查一下市面上的開源專案怎麼呈現 RAG 的。

找著找著發現一個不錯的清單,於是想說從中把能跑得都跑過一遍吧!話雖如此,對我而言有幾個前提條件:

  1. 是 Web App

基於 HTTP 的應用程式對我而言才有參考價值,因此桌面應用程式 (Desktop App) 或終端機應用 (TUI) 不在評估範圍內。

  1. 有預編 OCI (Open Container Initiative) 映像檔

我的標準環境是 Dcoker/kubernetes,並且我也不想額外自行編譯映像檔,因此:只支援透過 uv/pip 安裝的 Python 軟體或是有提供 Dockerfile 但是沒有預編的方案同樣不考慮。

  1. 有 RAG 機制

RAG 是我這次主要想觀察的功能,也就是匯入/上傳檔案、嵌入、檢索...,其他類型的 LLM 應用程式我暫時不列入考慮。話是這麼說,不過要是我下載之後才發現不具備 RAG 功能,還是會寫個簡單的紀錄。

評測與調查重點

以上是大前提,接著是評估的面向:

  1. OCI 層分析

因為我的無線網路環境有點惡劣,根據經驗單層超過 1GB 的 OCI 映像檔幾乎都拉不下來。另外如果單一映像檔過大,在微服務架構下的自動擴展機制會不夠友善,因為載入與啟動時間比較長。所以 OCI 大小以及分層尺寸是我會考慮的其中一點。

info

實際上還是可以透過 regclientregctl 指令修改 chunk 大小下載下來,只是會繞過我的 Homelab 本地快取/鏡像機制,所以視同拉不下來。

  1. 微服務編排與重用

雲原生環境會透過切割 OCI 的方式實現職責分離,並且往往會重複使用一些組件,例如:SQL 資料庫、S3 實例、記憶體快取...。一方面是透過職責分離,確保使用的是足夠成熟的方案,而不是自行研發;二方面是透過特定的界面整合實現解偶,可以視情況抽換實做(如:自用輕量 vs 商用可靠)。這個 LLM 應用程式是屬於微服務架構還是單體式架構也是我的觀察重點之一。

  1. 嵌入資料可維護性

就算不談 RAG 這樣的現代系統,在傳統 ETL (Extract, Transform, Load) 的領域中,資料的可追朔、可審計是基本中的基本。更別提對 RAG 這樣的系統而言,可靠度高度受到資料庫的品質影響。

  1. 提示詞與 LLM 呼叫策略

關於 LLM 可觀測性 (Observability),也就是觀察應用程式的提示詞,過去我寫了幾篇相關的文章提及:

不過一直沒有系統性的紀錄下來,趁這個機會好好的寫下來吧。

AnythingLLM

OCI 構成

podman image tree
podman image tree docker.io/mintplexlabs/anythingllm:1.11.0
Image ID: ff8367ba40cb
Tags: [docker.io/mintplexlabs/anythingllm:1.11.0]
Size: 3.162GB
Image Layers
├── ID: e8bce0aabd68 Size: 80.64MB
├── ID: 9e7e2ecd31b0 Size: 1.024kB
├── ID: 3228a4f46016 Size: 1.179GB
├── ID: 7282d320f8f9 Size: 24.58kB
├── ID: c5ee10132db1 Size: 4.608kB
├── ID: 90a5b49ea903 Size: 3.584kB
├── ID: 42577cf50556 Size: 17.92kB
├── ID: 14c973612c97 Size: 3.584kB
├── ID: b6a11ed79c58 Size: 1.024kB
├── ID: d7f4147261e4 Size: 1.024kB
├── ID: d35130223185 Size: 2.908MB
├── ID: 5a8262e26991 Size: 1.024kB
├── ID: 7c2038cbfeb5 Size: 964.3MB
├── ID: 8f041fd68349 Size: 1.024kB
├── ID: ab54a4950e52 Size: 484.4kB
├── ID: 647fb5404ce3 Size: 1.024kB
├── ID: 40c5efdad8a9 Size: 921.2MB
├── ID: 63204dd64df9 Size: 1.024kB
├── ID: 58d9f478d9ba Size: 1.024kB
└── ID: 42428f8f7df8 Size: 12.48MB Top Layer of: [docker.io/mintplexlabs/anythingllm:1.11.0]

映像檔整體約為 3GB,但是單層不超過 1GB。

簡單對話

預設系統提示詞是可以修改的:

URL 訪問能力

第一次測試是失敗的,

不知道為什麼沒有回應正確的格式:

失敗的後台紀錄:
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::openai/gpt-oss-20b] Untooled.stream - will process this chat completion.
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::openai/gpt-oss-20b] Invalid function tool call: Missing name or arguments in function call..
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::openai/gpt-oss-20b] Will assume chat completion without tool call inputs

openai/gpt-oss-20b 的性能太差勁還是提示詞下太爛?

是說 OpenAI API 明明就支援直接傳入工具,不知道開發者在想什麼。

這是目前 Agentic Programing (俗稱 Vibe Coding) 的標準實現方式的說。


換成貴一點的模型(qwen/qwen3.5-35b-a3b)就可以運作了:

主要分成兩次呼叫,我不知道為什麼其中一個重複了兩次,

系統提示詞:

系統提示詞一:

You are a program which picks the most optimal function and parameters to call.
DO NOT HAVE TO PICK A FUNCTION IF IT WILL NOT HELP ANSWER OR FULFILL THE USER'S QUERY.
When a function is selection, respond in JSON with no additional text.
When there is no relevant function to call - return with a regular chat text response.
Your task is to pick a **single** function that we will use to call, if any seem useful or relevant for the user query.

All JSON responses should have two keys.
'name': this is the name of the function name to call. eg: 'web-scraper', 'rag-memory', etc..
'arguments': this is an object with the function properties to invoke the function.
DO NOT INCLUDE ANY OTHER KEYS IN JSON RESPONSES.

Here are the available tools you can use an examples of a query and response so you can understand how each one works.
-----------
Function name: rag-memory
Function Description: Search against local documents for context that is relevant to the query or store a snippet of text into memory for retrieval later. Storing information should only be done when the user specifically requests for information to be remembered or saved to long-term memory. You should use this tool before search the internet for information. Do not use this tool unless you are explicitly told to 'remember' or 'store' information.
Function parameters in JSON format:
{
"action": {
"type": "string",
"enum": [
"search",
"store"
],
"description": "The action we want to take to search for existing similar context or storage of new context."
},
"content": {
"type": "string",
"description": "The plain text to search our local documents with or to store in our vector database."
}
}
Query: "What is AnythingLLM?"
JSON: {"name":"rag-memory","arguments":{"action":"search","content":"What is AnythingLLM?"}}
Query: "What do you know about Plato's motives?"
JSON: {"name":"rag-memory","arguments":{"action":"search","content":"What are the facts about Plato's motives?"}}
Query: "Remember that you are a robot"
JSON: {"name":"rag-memory","arguments":{"action":"store","content":"I am a robot, the user told me that i am."}}
Query: "Save that to memory please."
JSON: {"name":"rag-memory","arguments":{"action":"store","content":"<insert summary of conversation until now>"}}
-----------
-----------
Function name: document-summarizer
Function Description: Can get the list of files available to search with descriptions and can select a single file to open and summarize.
Function parameters in JSON format:
{
"action": {
"type": "string",
"enum": [
"list",
"summarize"
],
"description": "The action to take. 'list' will return all files available with their filename and descriptions. 'summarize' will open and summarize the file by the a document name."
},
"document_filename": {
"type": "string",
"x-nullable": true,
"description": "The file name of the document you want to get the full content of."
}
}
Query: "Summarize example.txt"
JSON: {"name":"document-summarizer","arguments":{"action":"summarize","document_filename":"example.txt"}}
Query: "What files can you see?"
JSON: {"name":"document-summarizer","arguments":{"action":"list","document_filename":null}}
Query: "Tell me about readme.md"
JSON: {"name":"document-summarizer","arguments":{"action":"summarize","document_filename":"readme.md"}}
-----------
-----------
Function name: web-scraping
Function Description: Scrapes the content of a webpage or online resource from a provided URL.
Function parameters in JSON format:
{
"url": {
"type": "string",
"format": "uri",
"description": "A complete web address URL including protocol. Assumes https if not provided."
}
}
Query: "What is anythingllm.com about?"
JSON: {"name":"web-scraping","arguments":{"url":"https://anythingllm.com"}}
Query: "Scrape https://example.com"
JSON: {"name":"web-scraping","arguments":{"url":"https://example.com"}}
-----------


Now pick a function if there is an appropriate one to use given the last user message and the given conversation so far.

系統提示詞二:

Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.

關於呼叫工具,兩次 LLM 的回應一次給 「包含 JSON Code 的 Markdown」另外一次給「JSON」,不知道是不是重複呼叫的原因。

後台紀錄:
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b] Untooled.stream - will process this chat completion.
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b] Valid tool call found - running web-scraping.
[anythingllm] | [backend] info: [AgentHandler] [debug]: @agent is attempting to call `web-scraping` tool {
[anythingllm] | "url": "https://flyskypie.github.io/posts/2026-02-26_storage-levels/"
[anythingllm] | }
[anythingllm] | [backend] info: [EncryptionManager] Loaded existing key & salt for encrypting arbitrary data.
[anythingllm] | [collector] info: -- Working URL https://flyskypie.github.io/posts/2026-02-26_storage-levels => (captureAs: text) --
[anythingllm] | [collector] info: -- URL determined to be text/html (web) --
[anythingllm] | [backend] info: [TokenManager] Initialized new TokenManager instance for model: tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b] Untooled.stream - will process this chat completion.
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b] Cannot call web-scraping again because an exact duplicate of previous run of web-scraping.
[anythingllm] | [backend] info: [AgentLLM - tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b] Will assume chat completion without tool call inputs.
[anythingllm] | [backend] info: [TELEMETRY SENT] {"event":"agent_chat_sent","distinctId":"9d1b8903-e002-42b7-8ea2-222fedeec43e","properties":{"runtime":"docker"}}
[anythingllm] | prisma:info Starting a sqlite pool with 25 connections.
[anythingllm] | [backend] info: [113:248]: No direct uploads path found - exiting.
[anythingllm] | [bg-worker][cleanup-orphan-documents] info: [113:248]: No direct uploads path found - exiting.
[anythingllm] | [backend] warn: Child process exited with code 0 and signal null
[anythingllm] | [backend] info: Worker for job "cleanup-orphan-documents" exited with code 0
[anythingllm] | [backend] info: Client took too long to respond, chat thread is dead after 300000ms
[anythingllm] | [backend] info: [AgentHandler] End 7f77b729-bd0c-4f8d-8cb1-df5eeed30117::generic-openai:tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b

嵌入文件

為了開箱即用,AnythingLLM 內建了向量資料庫跟嵌入模型(檔案依然需要從 Hugging Face 下載)的功能。

嵌入相關的 UI 非常簡陋,甚至連自己的分頁都沒有,只有彈出視窗,可見 AnythingLLM 是一款十分 Chat 本位的應用程式:

只有觸發向量索引才會顯示部份的切塊:

沒有找到界面可以瀏覽或編輯已經被嵌入的資料。

系統提示詞
Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.
Context:
[CONTEXT 0]:
<document_metadata>
sourceDocument: Manual.pdf
published: 3/14/2026, 8:13:12 AM
</document_metadata>

Chapter 3
The Console...........................................32
Chapter 4
Component Reference..............................46
Weapon Statistics.................................61
Chapter 5
Credits..................................................62Chapter 1
4
Chapter 1
Introduction
MindRover: The Europa Project
Welcome to Europa, land of ice and more ice. With
Jupiter constantly hovering on the horizon, we've
found that homesickness among new arrivals is
common, so let's just get started.
Your time here will present you with a new type of
challenge -- one that matches the excitement of an
action game, the planning of a strategy game and
the intense thinking required in a puzzle game.
Your goal is to create robotic vehicles using a wide
array of different components, program their
behavior, then set them free to compete with each
other. Your progress through the levels will depend
on cleverness, innovation, and even deception as
[END CONTEXT 0]

[CONTEXT 1]:
<document_metadata>
sourceDocument: Manual.pdf
published: 3/14/2026, 8:13:12 AM
</document_metadata>

Some scenarios may ask you to build a vehicle to
complete a series of simple tasks. Others might ask
you to program a set of vehicles that work together
to defeat another team.
You can equip your vehicles with everything from
rocket launchers to radars to speakers. You can
program them to do anything from following a
track, to finding a path through a maze, to seeking
and destroying other vehicles. The behaviors you
can create are limitless -- and the game will grow
with your abilities.
There are five basic steps in playing MindRover.Chapter 2
8
Choose a
Scenario
First, choose a scenario or challenge. Each one has
a different task or competition, and MindRover
supports several different styles of scenario.
Choose a
Vehicle
Next, you choose a chassis on which you will place
the components for your vehicle. There are
wheeled, treaded and hovercraft type chassis in
varying sizes.
Add
Components
Next you load up your vehicle with the components
[END CONTEXT 1]

[CONTEXT 2]:
<document_metadata>
sourceDocument: Manual.pdf
published: 3/14/2026, 8:13:12 AM
</document_metadata>

you tackle some of the more challenging scenarios.
Share your successes, get advice, download new
challenges and compete with others by visiting the
MindRover website at www.mindrover.com.
MindRover probably isn't quite like anything you've
seen before, so please give yourself a chance to
learn it. Go through the in game tutorials and use
the F1 key for help along the way.
Ready? Free your mind, grab your mouse, and
enter into the world of MindRover!Introduction
5
Quick Start
For the fastest introduction to MindRover, follow
these steps:
Create a new user name and log in. Your user
name will be used to help identify the vehicles
you build.
Go through the first 2 or 3 tutorials in the game
following the tutorial prompts.
Click on Sports category, and try Sumo Hover.
There is a tutorial vehicle (half-built) available
to get you started or you can start with an
empty chassis.
After that you should have a pretty good idea of
how to go off and build your own rovers.
[END CONTEXT 2]

[CONTEXT 3]:
<document_metadata>
sourceDocument: Manual.pdf
published: 3/14/2026, 8:13:12 AM
</document_metadata>

Don’t forget to visit www.mindrover.com for hints,
tips, and competitors. You’ll find an active and
growing MindRover community.Chapter 1
6
Using This Manual
ConceptsThe Concepts section describes essential MindRover
concepts in some detail. You will learn about
scenarios, vehicles, components, wiring, and
competitions. You can read this chapter before you
play to get a good feel for all aspects of the game.
But if you like to jump right in and get started, just
go to the first tutorial and come back to this chapter
later.
ConsoleThe Console section goes into detail on each of the
user interface screens. You can read it before you
start, or just use it as a reference after you have
started playing the game.
ComponentsThis chapter gives you specific information on each
component in the game, listed alphabetically.
Within the game, click on a component and press
F1 to get more details and examples.
Start with the
tutorials
[END CONTEXT 3]

編排與構成

這個是開箱即用的單服務模式:

docker-compose.yaml
services:
anythingllm:
image: docker.io/mintplexlabs/anythingllm:1.11.0
ports:
- 3001:3001
volumes:
- anythingllm-data:/app/server/storage
environment:
- SERVER_PORT=3001
- STORAGE_DIR=/app/server/storage
- UID=1000
- GID=1000
- LLM_PROVIDER=generic-openai
- GENERIC_OPEN_AI_BASE_PATH=http://tensorzero.api.gas.arachne/openai/v1
- GENERIC_OPEN_AI_MODEL_PREF=tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b
- GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=4096
- GENERIC_OPEN_AI_API_KEY=ANY

volumes:
anythingllm-data:

接著是把嵌入跟向量資料庫打散的微服務模式:

docker-compose.yaml
services:
anythingllm:
image: docker.io/mintplexlabs/anythingllm:1.11.0
restart: always
ports:
- 3001:3001
volumes:
- anythingllm-data:/app/server/storage
environment:
- SERVER_PORT=3001
- STORAGE_DIR=/app/server/storage
- UID=1000
- GID=1000
- LLM_PROVIDER=generic-openai
- GENERIC_OPEN_AI_BASE_PATH=http://tensorzero.api.gas.arachne/openai/v1
- GENERIC_OPEN_AI_MODEL_PREF=tensorzero::model_name::openrouter::qwen/qwen3.5-35b-a3b
- GENERIC_OPEN_AI_MODEL_TOKEN_LIMIT=4096
- GENERIC_OPEN_AI_API_KEY=ANY

- EMBEDDING_ENGINE=generic-openai
- EMBEDDING_MODEL_PREF=ANY
- EMBEDDING_MODEL_MAX_CHUNK_LENGTH=1024
- EMBEDDING_BASE_PATH=http://llama-cpp:8080/v1
- GENERIC_OPEN_AI_EMBEDDING_API_KEY='ANY'
- GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=4
- GENERIC_OPEN_AI_EMBEDDING_API_DELAY_MS=100

- VECTOR_DB=milvus
- MILVUS_ADDRESS=http://milvus:19530
depends_on:
- llama-cpp
- milvus

llama-cpp:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan
restart: always
devices:
- /dev/dri/:/dev/dri/
ports:
- 8080:8080
entrypoint: /app/llama-server
environment:
- HF_ENDPOINT=http://huggingface.mirrors.solid.arachne
volumes:
- llama-cpp-cache:/root/.cache/llama.cpp
command:
- --hf-repo
- Qwen/Qwen3-Embedding-8B-GGUF
- --hf-file
- Qwen3-Embedding-8B-Q6_K.gguf
- --embeddings
- --pooling
- mean
- --ctx-size
- "2048"
- --batch-size
- "1024"
- --ubatch-size
- "2048"
- --gpu-layers
- "999"
# - --no-mmap
- --flash-attn
- on
- --no-webui
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 20s
retries: 3

etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.25
restart: always
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- etcd-data:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3

minio:
container_name: milvus-minio
image: docker.io/minio/minio:RELEASE.2024-12-18T13-15-44Z
restart: always
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- minio-data:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3

milvus:
container_name: milvus-standalone
image: docker.io/milvusdb/milvus:v2.6.11
command: ["milvus", "run", "standalone"]
restart: always
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
MQ_TYPE: woodpecker
volumes:
- milvus-data:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"

attu:
image: docker.io/zilliz/attu:v2.6
restart: always
environment:
- MILVUS_URL=http://milvus:19530
ports:
- 8090:3000
depends_on:
- milvus

volumes:
anythingllm-data:
llama-cpp-cache:
minio-data:
milvus-data:
etcd-data:

使用 llama.cpp 和 Qwen/Qwen3-Embedding-8B-GGUF 嵌入模型,並且用 Milvus 作為向量資料庫,順手測了一下雙語索引:

透過 Attu 就能瀏覽 Milvus 內儲存的資料了:

可以發現 AnythingLLM 是直接嵌入一個 JSON 資料:

實作程序關閉

是否有實作 Graceful Shutdown? 否。

如果程式有實作 Graceful Shutdown,它會監聽 SIGTERM 訊號,並且在收到後開始進入資源釋放流程;反之,如果沒有實做就會觀察到「下達容器關閉指令沒有反應,直到超時被服務強制中止」:

exit code: 137

WARN[0010] StopSignal SIGTERM failed to stop container anythingllm_anythingllm_1 in 10 seconds, resorting to SIGKILL

雜談

本來標題是想起個「評測」之類的,只是感覺這代表覆蓋的面向要足夠多,還要有可以量化的指標 (benchmark) 之類的,但是我只是想根據自己自己的需求「簡單翻閱一下」。

加上我在意的面向通常也不是一般使用者會在意的部份,如果看到「OOXX 評測」開開心心的點進來文章卻發現跟想像的不一樣這樣失望的話,那會有一點對不起讀者,所以最後給了一個「不正經」的標題,畢竟以一般 LLM 使用者的角度,我調查的點的確蠻不正經的。

另外,評測應該要給個總結,不過我這邊先不這樣做,因為沒有其他參照對象,也不知道 AnythingLLM 的表現是好是壞,大概等我手邊累積多一點資訊才會對各個應用程式做評分之類的總結。

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&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

我發現網路上一些對於軟體工程範式 (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 的大小。

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

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

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

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

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


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