Skip to main content

Wei Ji

背景

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

電池充電上限

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

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

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

https://github.com/makifdb/lenopow

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

筆電螢幕問題

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

HandleLidSwitch=ignore

執行指令重啟服務:

systemctl restart systemd-logind.service

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

GRUB_CMDLINE_LINUX_DEFAULT 插入 consoleblank=60

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

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

DAS 掛載

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

自動化載

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

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

修改後執行:

systemctl daemon-reload
info

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

設定仰賴

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

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

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

DAS SMART 檢查

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

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

Footnotes

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

Wei Ji

安裝

懶人安裝法:

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

檢查一下有沒有正常:

sudo k3s kubectl get node 

設定

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

info

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

Footnotes

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

Wei Ji

Pod

聲明一個 Pod:

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

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

kubectl create -f sample.yaml

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

kubectl port-forward awesome-pod 3001:8080

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

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

info

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

kubectl delete -f sample.yaml

Service

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

ClusterIP

一個 Service 大概長得像這樣:

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

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

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

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

NodePort

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

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

kubectl create -f sample.yaml

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

info

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

Deployment

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

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

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

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

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

Footnotes

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

Wei Ji

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

Namespace

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

Wei Ji

作為一個網頁前端導向的開發者,接觸 Systemd 的角度較資深一輩的工程師(或正規 CS (Computer Science) 路線出生的人)不太一樣,當前輩們已經在進行「Systemd 大一統」跟「No Systemd」之間的宗教戰爭的時候,我才在 Docker 容器內接觸內接觸第一個行程的問題 (PID 1 Problem)。

這裡紀錄並整理一下自己在這條路上的一些經歷和觀察。

info

正確的術語應該是 OCI (Open Container Initiative) 容器,不過為了方便溝通本文會視情況使用俗稱的 Docker。另一方面,筆者在學習過程確實是先使用 Docker,使用 Docker 以外的 Runtime 其實也只是不久之前的事。

Dockerfile 中的 ENTRYPOINTCMD

實際上 ENTRYPOINT 才決定了第一個行程 (Process) 是誰,CMD 只是後面被帶進去的參數。

有沒有經歷過 docker compose down 但是某個服務 (service) 卻遲遲沒有被關閉,整個過程停頓了一會兒才把整套服務簇釋放掉?

這十之八九是因為 ENTRYPOINT 沒有處理 SIGTERM 的能力造成的,開發環境或許不打緊,但是在生產環境如果有請求 (request) 掛著還沒處理完,然而容器被更新強制關閉的話,帶來的問題是十分致命的。

容器編排 (Container Orchestration)

在 Docker Compose 允許用 YAML 將多個容器組織起來構成服務簇,並且使用 depends_on 來設定仰賴關係並控制容器的啟動順序、restart 來設定重啟策略、healthcheck 判斷容器狀態。

info

一個 service 其實可以設定多個容器(透過 replicas 或是 scale),不過為了簡化描述我在上面並沒有直接指出。

多個 Process 的容器

info

以下不少經驗來自工作上面對的問題,專案細節就不透漏了。

第一次遇到這樣的需求來自業務上的要求:

將調用 SideFX Houdini 的 Python 伺服器容器化

它要求運行一個授權伺服器 (License Server),透過 Python 載入的 SDK 和這個伺服器之間會定時檢查彼此的存在。當時簡單使用 entrypoint.sh& 語法和 sleep 把兩個行程跑了起來。


後來遇到另外一個需要解決的問題:

LibreOffice 作為一個無頭 (headless) 伺服器運作。 Python 程式使用 UNO (Universal Network Objects) 界面與之溝通。 佈署在單一容器的 Serverless 雲環境,因此只能有一個容器。

這次我使用了 Supervisor 來解決問題1


接下來的案例是真正讓我將 「Docker 內的 PID 1 問題」與「主機上的 PID 1 問題」連在一起的關鍵:LinuxServer.io。

Blender 是一個面向 3D 設計師的軟體,它是 Maya 或 3ds Max 這類軟體的開源競爭者。LinuxServer.io 使用了某種黑魔法允許 Blender 被運行在容器內,使用者則透過去網頁瀏覽器與之互動,並且 Blender 並不是唯一被這樣操作的軟體,除此之外 LinuxServer.io 還封裝了各種 GUI 軟體到 Dokcer 容器內。

LinuxServer.io 使用已經存在的實作 (KasmVNC) 來達成這些目標,不過魔法的關鍵在於:

使用 s6-overlay 運行與管理包含 X Server 在內的多個程式。

s6-overlay 不只能夠啟動多個程式,還能聲明程式與程式之間的仰賴關係,來依序啟動,甚至區分了「執行一次」和「持續在背景運作」的程式。

info

因為不是本文重點,所以我沒有細談整個過程,大致上就是根據那個 Blender 容器的文件與原始碼找到實作的方式,留下 repo 供參考:

嵌入式 Linux 中的 /etc/init.d/rcS

我曾經在嵌入式 Linux 看到這樣的東西:

$ ls /etc/init.d
rcK rcS S01syslogd S02klogd S02sysctl

當時我也根據需求寫了一個簡單的 S03sdmnt 腳本來掛載 SD 卡。

後來才知道這是根據 System V Init 簡化後的設計。

info

完整的故事跟細節我有寫另外一篇文章描述:

https://flyskypie.github.io/blog/2022-12-03_3ds_linux_javascript_bad_apple/

Systemd 口味的容器編排軟體

Quadlet 是我試著轉向使用 Podman 得知的東西,雖然最後我並沒有用過就了,而是使用 Podman Compose,省得還要另外維護一份語法不一樣的東西。

下面是 Quadlet 聲明一個服務的例子2

[Unit]
Description=A minimal container

[Container]
# Use the centos image
Image=quay.io/centos/centos:latest

# In the container we just run sleep
Exec=sleep 60

[Service]
# Restart service when sleep finishes
Restart=always

[Install]
# Start by default on boot
WantedBy=multi-user.target default.target

從領域驅動(Domain-driven)的角度來看這件事

不難發現上述不同的工具都在解決幾個類似的問題:

  • 系統初始化(啟動與關機),如:掛起背景程式。
  • 根據背景程式的仰賴關係依序掛起,如:應用程式仰賴資料庫。
  • 處理背景程式的異常狀態,如:崩潰重啟。

這些問題構成的問題領域 (Problem Domain) 可以被定位為系統初始化 (System Init) 和行程管理 (Process Supervision)。

而上述工具則是這個 Problem Domain 對應的解決方案領域 (Solution Domain)。

Systemd 則是這個 Solution Domain 中最有名的工具之一。

Footnotes

  1. 使用 Supervisor 來管理程式 | 《Docker —— 從入門到實踐》正體中文版. Retrieved 2025-12-29, from https://philipzheng.gitbook.io/docker_practice/cases/supervisor

  2. 在 Podman 管理多個 container • 鰭狀漏斗. Retrieved 2025-12-29, from https://vrabe.tw/blog/manage-multi-containers-in-podman/

Wei Ji

最近想學 K8s 然後陸續把 Homelab 的服務從 Docker Swarm 移過去,不過一直靜不下心好好讀它,思緒糊成一團,於是想說先把一些已經知道的事情整理一下。

動機

info

因為我實際上還未實際使用 K8s,所以以下可以當成「對 K8s 的美好幻想」。

簡單紀錄一下幾個讓我想學 K8s 的動機。

Image Volume

前一陣子在鼓搗 Keyclaok,並且外掛了兩個組件:

Keycloak 的插件機制是將 *.jar 檔案放到 /opt/keycloak/providers/ 下,待啟動時被載入。

在 OCI (Open Container Initiative) 佈署下處理這種這整合有兩種方法:

  • 建置階段:直接在 Dockerfile 用 COPY 完成組裝。
  • 佈署階段:透過 Volume 將 jar 檔案掛載進容器。

前者缺乏彈性;後者 Docker 不支援,但是 K8s 支援1

info

話說回來這幾天在讀 Docker Compose 的 spec 看到這東西(image):

// service.volumes.type
"type": {
"type": "string",
"enum": ["bind", "volume", "tmpfs", "cluster", "npipe", "image"],
"description": "The mount type: bind for mounting host directories, volume for named volumes, tmpfs for temporary filesystems, cluster for cluster volumes, npipe for named pipes, or image for mounting from an image."
},

也許 Docker 也支援了說不定。

depends_on

Docker Compose 使用 depends_on 來延遲一些容器的啟動,避免仰賴其他容器的服務過早的啟動,例如:後端嘗試對還沒準備完成的資料庫進行連線。

Docker Swarm 則不支援這個功能,它的邏輯是透過 health check:反正有仰賴的容器連線失敗會 health check 失敗,直接重啟容器直到成功為止。但是這樣的設計無法運行相對複雜的服務,例如:我嘗試在 Swarm 模式下運行 GVM (OpenVas) 並沒有成功過。

而 K8s 則是透過 initContainers 的機制來處理這類需求。

Serverless

工作時使用雲端的 Serverless 的服務 (GCP Cloud Run),因此我對於自架 (selfhosted) 方案很感興趣,也有找到一些開源方案,不過它們多基於 K8s,例如:

info

我這裡是指基於 OCI 的 Serverless,非 OCI 的 "Function based" Serverless 方案通常都有緊支援特定程式語言的限制。

K3s 與遠端訪問

前幾天 (2025-12-23) 安裝完 K3s 設定遠端訪問時看到這個:

$ kubectl config view
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: DATA+OMITTED
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: DATA+OMITTED
client-key-data: DATA+OMITTED

雖然知道 certificate-authority-data 大概是為了 HTTPS 的加密機制而準備的,不過我其實不知所以然。

K8s 架構圖

查資料的時候看到這張架構圖:

我注意到 K8s 的組件之間通訊都是加密的,這讓我想到之前透過「Kubernetes The Hard Way」學習 K8s 的經驗。

以及某篇文章,有個老兄抱怨 K8s 難用,伺服器癱瘓的原因似乎是憑證過期,並且它們最後在 AWS 找到了歸屬。

Kubernetes The Hard Way

Kubernetes The Hard Way 是一個架設 K8s 的教學,但是不使用現成的安裝精靈,而是手動安裝與配置每一個組件。

去年(2024 年 1 月)我其實試著依照這份指南的步驟嘗試在一台 x86 32 位元筆電上建立 K8s,雖然最後沒有持續下去,但是我隱約記得過程中一直在生成金鑰跟簽署憑證。

info

我當時有試著將執行過得步驟寫成 Make/Ansible 腳本:

https://github.com/FlySkyPie/k8s-builder-and-installer-for-x86-single-node

正確打開 K8s 的方式...難道是密碼學?

作為一個以開發前端業務邏輯為重心的開發者,密碼學相關的基礎概念可以說是很容易被輕忽的部份。或許我該好好的梳理以下對於加密技術的基礎概念作為。

當然,如果我只是要作為 K8s 的使用者,這些知識似乎沒有這麼重要,不過我同時還是要在 Homelab 架設 K8s 的維護者,我不認為紮紮時時的把基礎打好有什麼壞處。

Footnotes

  1. Use an Image Volume With a Pod | Kubernetes. https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/

Wei Ji

知識邊界

Matt Might 的「圖解博士學位 (The illustrated guide to a Ph.D.)」主要是以視覺化的方式解釋「全人類的知識邊界」與博士學位之間關係。不過我認為這個概念在幫助人理解教育體系本身的設計以及自身的知識邊界非常有幫助。沒有看過得人可以點擊上面的連結或是這個中文翻譯

以下我會參考這個概念解釋,加上一點我的個人見解。

“As our circle of knowledge expands, so does the circumference of darkness surrounding it.”

-- 愛因斯坦

用圓圈來形容知識,Matt Might 並不是第一個人,不過視覺化的表達可能遠比名言金句更有力量。現在想像一個圓圈代表全人類知識體系已知的知識,而圓圈外則是未知:

當一個人接受了國民義務教育,它從整個知識體系中均衡的學習到了一點東西:

在 Matt Might 的圖解中,高中是另外一個均勻的知識圈圈,開始出現知識領域專業化是大學系所才發生的事情。不過在我國教育實務上有分成普通高中跟高職;普通高中又有分組(108 課綱之後為班群),而高職則是已經開始進行類似大學系所的專業化學習(理論與實務的差異先不談):

普通高中是綠色的比較均勻圈圈,高職則是以較偏頗的虛線,看過銀之匙(荒川弘)這部作品的讀者可能會對這種差異更有感覺,作品中普通升學體系的主角進入技職體系所受到的衝擊就是來自於兩種體系對於知識邊界預期不太一樣。

如果你繼續留在學術界,完成學士、碩士的訓練;並且持續閱讀論文、學習往特定的知識領域前進;最後在人類的知識邊界挖出一角,那個成果將讓你成為博士。

Matt Might 花比較多篇幅在醞釀這個小角,因為擴展人類知識邊界的博士並不容易,不過這並不是我今天要談的重點,所以容我快速帶過,但是到這裡為止,你應該對「知識邊界」的概念有比較清晰的想像。

另外在我看來,知識邊界拓展的方式從微觀尺度來講,更像是「劈裂」過去的,當每你經歷一個得以觸動的知識點時,知識邊界會以閃電一般的路徑劈裂過去。

觸動的與否取決於該知識點與知識邊界的距離,以及原本「裂痕的方向」有關,這是為什麼同樣的事件、同樣的體驗可以成為某些人的知識點並拓展它的知識邊界,對某些人而言卻只是成為被忘卻的記憶。

專題式自學法

專題導向學習 (Project-Based Learning) 似乎是一種教育科學的方法論,並且在我國被提倡也只是最近(108 課綱)的事情而已。而我有幸在成長過程自然的捕捉到這個方法論的脈絡。


「想做遊戲」,於是有了:

飛貓工作室

但是實際上沒有完成任何遊戲。


「看著 LEGO® NXT 2.0,心想自己造一個」,於是有了:

電機開發平台 (MMFEDP, Modular Multi-Faceted Electrical Develop Platform) 專案

但是實際上沒有完成任何跟可程式化有關的東西或是足夠實用的減速機。


「看著機器人比賽,對著那種與他人合作打造機器人的憧憬,心想自己造一個」,於是有了:

獨立性無人地面載具 (ITUGV, Independent Task Unmanned Ground Vehicle)

但是實際上沒有實現遠端遙控的機器人。


「YouTube 的『homemad』影片往往包含了車銑床之類的加工方式,但是我想自己製作機器人零件,那就用鑄造的好了」,於是有了:

土砲熔爐

但是實際上沒有用它製作過機器人零件。


「想和同儕分享寫程式的樂趣」,於是有了:

程式蠱

但是實際上沒有讓其他人參與過。


「想做 2.5D RPG」,於是有了:

VB.NET 土砲 2.D 遊戲

但是實際上沒有實現遊戲該有的抽象化與職責分離。


「看著 KSP 遊戲但是無法合法的擁有,心想自己造一個」,於是有了:

VB.NET 太空軌道模擬遊戲

但是實際上停留在 2D 模擬,而且沒有更進一步的多組件編輯機制。


「心想開發一個多人連線 FPS」,於是有了:

香巴朵 Online

但是實際上 3D 射擊的部份並不是使用 GPU 繪圖,而連線功能也僅停留在 2D 的實驗性開發 (prototype)。


「想用論壇機制來解決抽象的『自造者銀行』」,於是有了:

C 幣論壇

但是實際上交易功能並沒有投入使用。


「既然我已經做過熔爐了,這次順便解決廢氣問題好了」,於是有了:

畢業專題-熔爐

但是實際上廢氣處理系統並沒有得到足夠多的關注與測試。


當我說:

我有「閃亮事物症候群」

不是在開玩笑的。

當我說:

「智者從歷史中學習,愚者從錯誤中學習」,而我是愚者。

不只是說說而已。

每一個 Side Project 其實都源自微不足道的願望,並且乍看之下所有專案都以失敗告終,不過我尚未提起我在當中獲得了什麼:

  • 「飛貓工作室」:我當過傲慢毫無能力(不論是領導能力或是技術能力)的籌備者;這讓我每次在團體或團隊中手握權力時,不斷提醒著自身與他人之間的關係。
  • 「MMFEDP」、「ITUGV」:我已經成長到不必透過 NXT 2.0 這樣的東西,而是使用 Arduino、樹莓派來開發機電玩具的程度了。
  • 「土砲熔爐」、「畢業專題-熔爐」:它帶給我「無法製造」的無能感是引導我前往材料系的因素之一,如今我已經能夠使用車床、銑床、鉗工、手工電弧焊、3D 列印...等等方式打造我想要的東西。
  • 「程式蠱」:Zero-player game 的概念至今仍然在影響著我學習的方向,實作 2.0 的時候更是直接點開 dlopen 的使用經驗。
  • 「VB.NET *」:雖然現在我已經不使用 VB 了,但是第一次使用參考(指標)以及從靜態記憶體到動態記憶體的過程依然是不可多得的體驗。
  • 「香巴朵 Online」:它對我提供了一個很強烈的 TCP Socket 的記憶點,這讓我在使用諸如 HTTP 其他網路連線機制的時候很有幫助。
  • 「C 幣論壇」:使用 Laravel 的經驗可以說是幫我在後端軟體的開發經驗上打下非常堅硬的基礎。

專題與知識邊界

是的,大部分的專題目標都在我當下的知識邊界之外,這也是為什麼它們大多數都會失敗的原因。

我們選擇在這個十年登上月球,並完成其他的事,不是因為它們很簡單,而是因為它們很困難。

-- 約翰.甘迺迪(John F. Kennedy)

每一個 Side Project 都是對於「博士挑戰人類知識邊界」的微小仿作,對我而言知識不是一個被好像很偉大的人站在教室裡授予的東西,而是在知識邊界之外,透過一個又一個知識點劈開未知獲得的東西。

如此不斷的前進,過個幾年回過頭來看,可能會驚訝於那些曾經遙不可及的目標不知不覺已經處於自己的身後;又或是它已經處於自己當下的知識邊界觸手可及的地方。

Wei Ji

Assets

這個部份算是上一次的研究筆記漏掉了,這個模組沒什麼特別的,就是一堆放在 Git LFS 的檔案,只是我抽出來單獨放在一個 repository 內。

做這件事的時候,順便參考了幾個開源遊戲,看看它們處理遊戲素材的:

Cayley.js

Cayley.js 是一個 Cayley WASM 的再封裝,除了實做了一些東西以外,還提供一些單元測試跟 Benchmark 測試。

處理它的時候算是踩了 Javascript 生態系一個很經典的小坑,原本我打算用 Vitest 取代原本的 AVA 單元測試框架,但是我忘記了 Vite 工具鏈是 ESM 本位主義,因此單元測試會 import Web 版本的 WASM 載入器。

然而 Web 與 Node.js 是屬於兩個不同的 Javascript Runtime,對於「如何載入 WASM」的方式也不同;Web 是使用 fetch 從網路下載 WASM;Node.js 則是使用 fs 從檔案系統讀取 WASM。

使用 Vitest 呼叫 fetch 會觸發 "Not implemented yet" 之類的錯誤訊息。最後是使用 Jest 取代 AVA,慶幸的是已經有人寫了遷移指南1,照著做就完成了。

Benchmark 那邊則是用 tsx 取代 ts-node

Zod

Zod 是一個方便開發者在 Runtime 對 JSON 資料進行 Schema 驗證的函式庫,我其實不確定 Biomes 為什麼要刻意 fork 出一個版本,最明顯的差異是 Biomes 的版本多了這個方法:

z.object().annotate(Symbol.for("some"), true)

並且 Symbol 並不在 Zod 的支援路線上:

Symbols aren't considered literal values, nor can they be simply compared with ===. This was an oversight in Zod 3.2

考慮到這並不屬於 JSON 解析的範疇,想想也合理,不過 Biomes 專案本身已經用下去了,我也不能在不熟悉的專案的情況下貿然移除這個客製化 Zod 轉而使用原本的。Biomes 原本是直接從 GitHub 安裝,我 fork 之後做了兩件事:

  • 將原本應該被 gitignore 的發布路徑 (/lib) 從 Git 紀錄中清除。
  • 將打包結果發布到 NPM。

Shared

ecsshared 之間有很嚴重的循環仰賴,無奈之下只好用 PNPM 的 workspace 處理。ecsserver 有輕微的仰賴,我直接複製那部份的程式碼進入 ecs,在我看來這裡解偶的效益遠比 DRY 還重要。

shared 則是測試的部份對 server 仰賴,仰賴了 Voxeloo WASM loader 的部份,這一點我打算暫時忽略,之後再來處理,畢竟這個部份可以參考 Cayley 的處理方式。

這是我在 shared 上面臨另外一個比較麻煩的問題:

shared 使用 Zod 大量定義 Schema,而一些複雜的 Schema 在編譯 Typescript 型別檔 (.d.ts) 時,無法正常推論,因此必須透過手動聲明的方式來解決:

export const zChallengeCompleteMessage: z.ZodObject<{
kind: z.ZodLiteral<"challenge_complete">;
challengeId: typeof zBiomesId;
}> = z.object({
kind: z.literal("challenge_complete"),
challengeId: zBiomesId,
});

而且看起來跟前面提到的 Symbol 有關...F**k...

Footnotes

  1. Switching from Ava to Jest for TypeScript | by Gant Laborde | Red Shift. Retrieved 2025-12-24, from https://shift.infinite.red/a6dac7d1712f

  2. Migration guide | Zod. Retrieved 2025-12-24, from https://zod.dev/v4/changelog#drops-symbol-support

Wei Ji

前情提要

最近我在研究一個開源專案 (ill-inc/biomes-game),並且在 2025-12-14 算是完成了一個里程碑,我成功重建專案內的素材瀏覽器,將遊戲素材讀出並且播放動畫:

「這個是怎麼幫 Voxel 上動畫的?」一個工程師這個問我。

「我不知道」我回道。

雖然我有瞄過遊戲素材檔案一眼,知道裡面有 .vox 和 JSON 檔,但是我不知道它們在這個專案具體是怎麼組裝起來的。

.vox 這個檔案格式我其實不陌生,早在 2021 年 11 月的時候我就有一篇筆記紀錄關於各種 Voxel 儲存的檔案格式,不過並沒有整理成能夠拿給別人看的程度。於是我想說趁這個機會把東西整理出來發一篇廢文。

.gox (Goxel)

Goxel 是 一個 Voxel 編輯軟體,而 .gox 則是它的專有(專案)格式,檔案格式的 Spec 直接寫在程式碼的註解內:

/*
* File format, version 2:
*
* This is inspired by the png format, where the file consists of a list of
* chunks with different types.
*
* 4 bytes magic string : "GOX "
* 4 bytes version : 2
* List of chunks:
* 4 bytes: type
* 4 bytes: data length
* n bytes: data
* 4 bytes: CRC
*
* The layer can end with a DICT:
* for each entry:
* 4 byte : key size (0 = end of dict)
* n bytes: key
* 4 bytes: value size
* n bytes: value
*
* chunks types:
*
* IMG : a dict of info:
* - box: the image gox.
*
* PREV: a png image for preview.
*
* BL16: a 16^3 block saved as a 64x64 png image.
*
* LAYR: a layer:
* 4 bytes: number of blocks.
* for each block:
* 4 bytes: block index
* 4 bytes: x
* 4 bytes: y
* 4 bytes: z
* 4 bytes: 0
* [DICT]
*
* CAMR: a camera:
* [DICT] containing the following entries:
* name: string
* dist: float
* rot: quaternion
* ofs: offset
* ortho: bool
*
* LIGH: the light:
* [DICT] containing the following entries:
* pitch: radian
* yaw: radian
* intensity: float
*/

順便提一下,我有回報過 Issue,雖然不是我修的;其實不值得炫耀,不過回報 bug 也是 FOSS 的參與方式之一,對吧?

KVX, KV6 (Ken Silverman's Voxel file)

在介紹這些檔案格式以前,可能需要談談 Ken Silverman 這個人,他是 Build 遊戲引擎 的作者,並且他設計 KVX 檔案格式被用於 Shadow WarriorBlood 兩款遊戲。12

不過對我而言這些資訊並不是我認識他的原因,我是從這個影片得知這號人物存在的:

根據影片的說明,我們可以找到 Voxlap 引擎的說明頁面:

https://advsys.net/ken/voxlap.htm

接著我們可以在網頁中看到它指向一個名為 SLAB6 的工具:

https://advsys.net/ken/download.htm#slab6

這裡有一個 SLAB6 原始碼的備份:

https://github.com/vuolen/slab6-mirror

並且這裡有一個筆記是從 SLAB6 中抽出 slab6.txt 檔案格式的介紹整理而成的:

https://gist.github.com/falkreon/8b873ec6797ffad247375fc73614fd08

該文件提供了 VOX,KVX 和 KV6 三種檔案格式的詳細說明,因此我們可以知道:

  • VOX: 無壓縮的三維 RGB 資料。
  • KVX: Ken Silverman 比較早期 (1995 年) 設計的 Voxel 格式。
  • KV6: 在 Ken Silverman 比較後期 (2020 年) 設計的格式,原本屬於 SLAB6 這個軟體的一部分。
info

注意,這裡的 VOX 不要跟目前真正流行的 .vox (MagicaVoxel) 檔案格式搞混,我稍後會介紹。

為什麼要介紹這個看起來有點古老的格式?因為你依然可以在一些 Voxel 遊戲中看到它的蹤跡,例如: OpenSpades (Ace of Spades 的 Clone)

VXL

.vxl 是紅色警戒 2 用於繪製單位模型的格式,具體的檔案結構如以下文件所示:

This document describes the VXL format for storing voxel (volume pixels) models for the game Tiberian Sun by Westwood Studios. 3

Luanti (Minetest)

Minetest 是一個「很像 Minecraft」的開源遊戲引擎,並且地圖的資料是儲存在 SQLite 中,

CREATE TABLE `blocks` (
`x` INTEGER, `y` INTEGER, `z` INTEGER,
`data` BLOB NOT NULL,
PRIMARY KEY (`x`, `z`, `y`)
);

至於當中的 BLOB 是如何編碼的,則可以在 world_format.md 中找到4

Vox (MagicaVoxel)

MagicaVoxel 是一個閉源的免費 Voxel 編輯軟體。

.vox 檔案是 RIFF (Resource Interchange File Format) 風格的格式5,完整的文件和檔案 sample 可以在這裡找到:

https://github.com/ephtracy/voxel-model

BINVOX6

它是單色的 voxel 格式,檔案結構如下:

#binvox 1
dim 128 128 128
translate -0.120158 -0.481158 -0.863158
scale 7.24632
<data>

由文本的資訊與二進制的資料構成。

資料則是由數個 word 組成,一個 word 用來描述一段連續的 voxel:

  1. 0 or 1 用來表示實體或是空氣
  2. 1~255 表示前一個 byte 的資料要重複幾次

.qb (Qubicle Binary)

Qubicle 是一個付費的 Voxel 編輯器。檔案格式的內容可以在它舊的網站找到7

Minecraft 地圖格式

Named Binary Tag (NBT)

NBT 是一種 Minecraft 用於儲存資料的數據結構。而序列化的 NBT 則是 SNBT (stringified NBT)。

SNBT 有著類似於 JSON 的結構(與 JSON 並不兼容),比如:

{name1:123,name2:"sometext1",name3:{subname1:456,subname2:"sometext2"}}

從這段程式碼可以更好的理解 NBT 和 SNBT 的關係:8

    var tag1;
try {
tag1 = nbtlint.parse(input.value);
} catch (e) {
output.value = e.message;
}

// 這是 NBT
var tag2 = new nbtlint.TagCompound({
Score: new nbtlint.TagInteger(1500),
Pos: new nbtlint.TagList(nbtlint.TagDouble, [
new nbtlint.TagDouble(15.5),
new nbtlint.TagDouble(123),
new nbtlint.TagDouble(-491.77),
]),
SelectedItem: new nbtlint.TagCompound({
id: new nbtlint.TagString("minecraft:diamond_sword"),
}),
});

// 這是 SNBT
// {
// Score: 1500,
// Pos: [15.5d, 123d, -491.77d],
// SelectedItem: {
// id: "minecraft:diamond_sword"
// }
// }
console.log(nbtlint.stringify(tag2, "\t"));

「能夠儲存樹狀結構以及各種資料型別的定義」這個抽象概念本身就是 NBT,不論實作的語言是什麼;不論資料儲存在記憶體還是硬碟上。而 SNBT 就是序列化的 BNT。

不同版本的地圖資料

info

以下是四年前 (2021) 寫的筆記,而且我也退坑 Minecraft 一陣子了,不確定最新版本的 Minecraft 是否有所調整。

不同時期(版本)的 Minecraft 使用不盡相同的資料結構來儲存遊戲世界。

Server_level.dat9

  • 單一檔案
  • gzip 壓縮
  • 使用於 Classic

Java_Edition_Alpha_level_format10

  • 複數個檔案
  • 使用 NBT
  • 使用資料夾結構區分 chunk
  • GZip 壓縮
  • 使用於 Infdev 、 Alpha 和部份版本的 Beta

一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit:

  • ID: 8 bits
  • Data: 4 bits
  • Light: 4 bits
  • SkyLight: 4 bits

在 Infdev 版本中,一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit。10

Region file format 11

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (r.x.z.mcr)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Beta 1.3

Anvil File Format 12

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (a.x,z.mca)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Java Edition 1.2.1
  • 格式大致跟 Region file format 相同,只有描述 chunk 的 NBT 與部份 label 有進行調整。

Footnotes

  1. Ken Silverman's Projects Page. Retrieved 2025-12-24, from https://advsys.net/ken/download.htm#slab6

  2. RTCM - Files - General Tools - Voxel. Retrieved 2025-12-24, from https://web.archive.org/web/20200706184402/http://www.r-t-c-m.com/knowledge-base/downloads-rtcm/general-tools-voxel/

  3. VXL_Format.txt. Retrieved 2025-12-24, from http://xhp.xwis.net/documents/VXL_Format.txt

  4. luanti/doc/world_format.md. GitHub. Retrieved 2025-12-24, from https://github.com/luanti-org/luanti/blob/46436248de4d64887f5ee3f1005224495ede895c/doc/world_format.md

  5. MagicaVoxel-file-format-vox.txt. GitHub. Retrieved 2025-12-24, from https://github.com/ephtracy/voxel-model/blob/8044f9eb086216f3485cdaa525a52120d72274e9/MagicaVoxel-file-format-vox.txt

  6. BINVOX voxel file format. Retrieved 2025-12-24, from https://www.patrickmin.com/binvox/binvox.html

  7. Qubicle Binary (QB) | Qubicle 3.0 Documentation. Retrieved 2025-12-24, from https://web.archive.org/web/20250417030951/https://getqubicle.com/qubicle/documentation/docs/file/qb/

  8. AjaxGb/NBTLint: Quickly and easily validate the stringified NBT format (SNBT) used in Minecraft commands. Retrieved 2025-12-24, from https://github.com/AjaxGb/NBTLint

  9. server_level.dat – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Server_level.dat

  10. Java Edition Alpha level format – Official Minecraft Wiki. Retrieved 2021-02-28, from https://minecraft.gamepedia.com/Java_Edition_Alpha_level_format 2

  11. Region file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Region_file_format

  12. Anvil file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.gamepedia.com/Anvil_file_format

Wei Ji

Galois

上一篇已經說明過 Viewer/Editor 是以 Electron 實做,並且和一個副程式透過 stdio 通訊。

經過研究發現該副程式的工作依然是採取 request-response 模式,並沒有太複雜的非同步通訊行為,因此我把處理副程式的實做改到 Nest.js 建立的 API 伺服器內,並且把原本用 Electron 呈現的 Viewer/Editor 重構成單純的 Web 應用程式。

呈現結果如下:

viewer

editor

Editor 給我的感覺有點失望,因為它實際上並沒有編輯的能力,只是畫面更完整一點的 Viewer,並且 Block 類的素材是沒辦法讀取的,這方面的問題預計暫時先跳過,不過已知的資訊有:

  • Trace 到 voxeloo 實做,看起來是單純的斷言失敗,而失敗的原因看起來是缺少 texture 參數。
  • Viewer 那邊是可以渲染方塊的,只是兩邊的 query 不太一樣。

循環仰賴

Galois 和 shared 之間原本有循環仰賴的問題,我試著移除一些看起來不是核心功能的東西,並且把真的消除不掉的仰賴直接從 shared 內抽出來直接放進 Galois 內。