Skip to main content

18 posts tagged with "Homelab"

View All Tags

Wei Ji

我知道這個描述有點ㄎ一ㄤ,不過請聽我娓娓道來。

軟體壟斷 (Monopoly)

眾所周知,軟體壟斷是存在的,例如:Google 電子郵件帳號、Android 手機、微軟作業系統、臉書社交平台...。

使用軟體本身沒有問題,問題在於閉源軟體、或是軟體與平台實際上被特定的商業實體所掌控,形成供應商鎖定,例如:微軟在作業系統上強推 AI 功能、TPM 硬體;又或是 Google 在 Android 上強推集中化且侵入性的政策...。消費者總是處於權益受損的被動方。

因此這世界上有一些人是十分認真看待這件事的,它們追求去中心化、自由、平等的軟體使用體驗,我作為開源軟體的支持者多少是認同這種理念的,但是有時候更多是現實的無奈。

比如說臉書,現實就是我國有很高的基數在使用 Meta 體系的社交平台(臉書、IG、脆),而這種平台為了避免資訊外流,不僅 SEO 不友善,大部分資料都藏在 API 之後,還透過 EULA (End-User License Agreement) 保護,所以現實就是不使用就容易和周遭的人脫節。

又比如 Gmail,雖然 SMTP (Simple Mail Transfer Protocol) 本身是開放的,想要架設 Email 伺服器本身很簡單,但是想要跟其他人的 Gmail 通訊,還需要滿足 SPF、DKIM、DMARC、DNS...等等設定,即使滿足設定也要「養」IP 和 Domain 的信譽才能避免信件被這些寡頭過濾掉。

壟斷抽象層

好了,所以我使用這些商業軟體或平台是一個既定事實,並且也有需求而仰賴著它們。

事實是,受開源軟體所賜,大部分商業軟體或平台都有開源替代方案,真正的問題是這些軟體不會自己架好給人使用,而且軟體的品質參差不齊,需求不見得被滿足、bug 不見得有人修。不過沒關係,先讓我們假設有一個公司存在,它只使用開源軟體,然後我可以直接使用這個公司的服務而無須自己煩惱維護軟體的細節。

於是一個比 Google 更邪惡的公司誕生了,因為現在我使用的所有(開源)服務都集中在這間公司身上,形成壟斷。

這件事情也可以換一個描述方式:假設一個諸如保護傘公司、荒坂公司、東亞重工這種虛構作品中虛構的壟斷企業,然後假設我所使用的所有軟體或平台都來自這個企業,而不是來自 Google 或 Meta...。然後使用開源軟體來「實做」這個虛構的「壟斷抽象層」。

我壟斷我自己

開源軟體只是實做這個抽象層的要素之一,它依然需要勞動力跟決策領導者才能運作,於是我作為董事或員工進入這個企業。從而形成了「我壟斷我自己」的傑作。

這個架構能夠解決另外一個問題,如果我單純的以 side project 的方式維護 homelab,我實際上很難區分我作為自然人的休息資源以及我投入在 side project 上本質屬於勞動的資源,資源包含時間和資本,透過這個虛構的企業,我便能把自己作為消費者以及法人生產者的資源區分開來。

曌銕重工

這個蠢主意其實不是剛想到的,而是已經醞釀一陣子了,為了那個「虛構的法人」我甚至已經準備了:

曌銕重工 (Stellar Cation Heavy Industries) 組織章程(草案)

Details

第一章 總則

  1. 本組織名稱為曌銕重工(以下簡稱本會)。
    1. 讀作「ㄓㄠˋㄊ一ㄝˇ」。
    2. 英文名稱為 Stellar Cation Heavy Industries。
  2. 本會不以獲取商業營利為最終目的,亦不進行盈餘分配;其內部治理與成員權利義務悉依本章程之約定。
  3. 本會為開源、技術探索與自由創作之非營利虛擬實體。
  4. 本會以地球本體、近地空間(Near-Earth Space)及地月系統(Earth-Moon System)之全部宙域為核心組織與治理區域。
  5. 本會以維持運作之伺服器設備與資訊基礎設施所在之虛擬網路空間為核心會址。鑑於基礎設施之物理位置具備動態移轉性,本會不設實體會址,其行政作業與通訊通聯之核心網際網路平台,授權由理事會決議並公告之。
  6. 本會之任務如下:
    1. 虛擬法人實體之營運與技術與非技術項目研究與開發之管理。

第二章 會員

  1. 本會會員分下列一種:
    1. 個人會員:凡贊同本會宗旨之自然人,填具入會申請書,經理事會審定通過,並繳納入會費後,為個人會員。
    2. 團體會員:凡贊同本會宗旨之外部專案機構或團體,填具入會申請書,經理事會通過,並繳納入會費後,為團體會員,並推派自然人代表一人行使權利。
  2. 會員(會員代表)有違反本章程或不遵守會員大會決議時,得經理事會決議,予以警告或停權處分,其危害團體情節重大者,得經會員大會決議予以除名。
  3. 會員有下列情事之一者,為出會:死亡、喪失會員資格者、或經會員大會決議除名者。
  4. 會員得以書面並敘明理由向本會聲明退會。
  5. 會員經出會或退會,已繳納之各項費用不予退還。
  6. 會員(會員代表)有表決權、選舉權、被選舉權與罷免權。每一會員為一權。
  7. 會員有遵守本會章程、決議及繳納會費之義務。連續一年未繳納會費者,視為自動退會。

第三章 組織及職權

  1. 本會以會員(會員代表)大會為最高權力機構;理事會為執行機構。
  2. 會員(會員代表)大會之職權如下:
    1. 訂定與變更章程。
    2. 選舉或罷免理事。
    3. 議決入會費、常年會費之數額及方式。
    4. 議決年度工作計畫、報告及預算、決算。
    5. 議決會員(會員代表)之除名處分。
    6. 議決財產之處分。
    7. 議決團體之解散。
    8. 與會員權利義務有關之其他重大事項之議決。
  3. 本會置理事一人,由會員(會員代表)選舉之,分別成立理事會。 遇理事出缺時,依序遞補,以補足原任者餘留之任期為限。理事之當選名次,依得票多寡為序,票數相同時,以抽籤定之。
  4. 理事會之職權如下:
    1. 議決會員(會員代表)大會之召開事項。
    2. 審定會員(會員代表)之資格。
    3. 選舉或罷免常務理事、理事長。
    4. 議決理事、常務理事或理事長之辭職。
    5. 聘免工作人員。
    6. 擬定年度工作計畫、報告及預算、決算。
    7. 其他應執行事項。
  5. 理事會置常務理事一人,由理事互選之,並由理事就常務理事中選舉一人為理事長。 理事長對內綜理會務,對外代表本會,並擔任會員(會員代表)大會、理事會主席。 理事長應視會務需要到會辦公,其因故不能執行職務時,應指定常務理事一人代理之,不能指定時,由常務理事互推一人代理之。
  6. 理事之任期十年,連選得連任,理事長之連任,不設上限。
  7. 理事為無給職。
  8. 理事有下列情事之一者,應即解任:
    1. 喪失會員(會員代表)資格者。
    2. 因故辭職經理事會決議通過者。
    3. 被罷免或撤免者。
    4. 受停權處分期間逾任期二分之一者。
  9. 本會置總幹事(或秘書長)一人,其他工作人員若干人;依資格條件遴選工作人員,提經理事會通過後聘僱之,解聘時亦同。
  10. 本會理事得兼任會務工作人員。
  11. 本會設立分支機構,其組織簡則由理事會擬定,載明設立依據、組成、任務、經費來源等,提經會員(會員代表)大會通過後行之。
  12. 本會得由理事會聘請名譽理事長一人,名譽理事一人,顧問一人(均為義務職),其聘期與理事之任期同。

第四章 會議

  1. 會員(會員代表)大會,分定期會議與臨時會議二種,由理事長召集, 召集時應於十五日前以書面或電子通訊通知之。 定期會議每年召開一次;臨時會議於理事會認為必要,或經會員(會員代表)五分之一以上之請求時召開之。 會員(會員代表)大會得以視訊會議或其他經理事會公告之方式召集之,簽到及表決方式則配合電子化設備功能辦理。但涉及選舉、補選、罷免事項,應以實體或具備身分驗證之特定非視訊集會方式辦理。

  2. 會員(會員代表)不能親自出席會員(會員代表)大會時,得以書面或電子授權委託其他會員(會員代表)代理,每一會員(會員代表)以代理一人為限。

  3. 會員(會員代表)大會之決議,以會員(會員代表)過半數之出席,出席人數過半數或較多數之同意行之。但下列事項之決議以出席人數三分之二以上同意行之:

    1. 章程之訂定與變更。
    2. 會員(會員代表)之除名。
    3. 理事之罷免。
    4. 財產之處分。
    5. 團體之解散。
    6. 其他與會員權利義務有關之重大事項。
  4. 理事會每一個月召開一次,必要時得召開臨時會議。 前項會議召集時除臨時會議外,應於七日前通知,會議之決議,以理事過半數之出席,出席人數過半數或較多數之同意行之。 理事會議得以視訊會議或其他經理事會公告之方式召集之,簽到及表決方式則配合電子化設備功能辦理。但涉及選舉、補選、罷免事項,應以實體或具備身分驗證之特定非視訊集會方式辦理。

  5. 理事應親自出席理事會議,不得委託他人代理;連續二次無故缺席,視同辭職。

  6. (本條因原屬陳報主管機關之程序,於虛擬法人架構下無適用對象,故予以移除,保留編號以維繫章程結構)

第五章 經費及會計

  1. 本會經費來源如下:

    1. 入會費:會員入會時,應一次繳納新臺幣100元。
    2. 常年會費:每年新臺幣500元。
    3. 會員捐款,得區分為一般經常性捐款,或針對特定技術、創作項目之「專門指定捐款」。
    4. 委託收益。
    5. 基金及其孳息。
    6. 其他收入。
  2. 本會會計年度自每年一月一日起至十二月三十一日止。

  3. 本會每年於會計年度開始前由理事會編造年度工作計畫及收支預算表,並於會計年度終了後三個月內由理事會編造上年度工作報告、會計報告,連同當年度工作計畫及收支預算表,提經會員(會員代表)大會通過。會員(會員代表)大會因故未能及時召開時,可先經理事會通過,事後提報大會追認。

  4. 本會於解散後,剩餘財產之歸屬,由會員(會員代表)大會決議處置之。

第六章 附則

  1. 本章程未規定事項,由理事會依本會宗旨另訂內部規範辦理。
  2. 本會辦事細則,由理事會訂定之。
  3. 本章程經會員(會員代表)大會通過後施行,變更時亦同。

組織章程是拿「臺北市社會團體章程草案範例」改的,有趣的是命名時我其實面臨了抉擇,因為要法人化,因此不能使用情感連結太強的名稱,必須要是可割可棄的存在,不然到時候像賈伯斯被踢出去之後會很惱火,這麼一想,突然能理解為什麼一堆公司的名字都這麼菜市場了。

除了抽象的組織章程,落地方面, 我也準備將個人的 Side Project 重新編制方式:

曌銕重工實體與專案檢索表建置與維護作業辦法(草案)

Details
  1. 目的

    • 為規範本會行政助理於建置、更新「外部實體-專案檢索表」時之作業流程,確保資料登錄之準確性與即時性,以利日後行政查核時能快速透過實體名稱反查專案流水號,特訂定本辦法。
  2. 管理職責

    • 本辦法之主管機關為理事會,負責外部實體識別碼之核准、表單結構之變更及不定期抽檢。本會行政助理為「外部實體-專案檢索表」之直接維護與登錄人員,應負責日常資料之依據核准登錄、變更追蹤與定期備份。
  3. 檢索表建置媒介與存取權限

    1. 檢索表應建置於本會指定之試算表(Spreadsheet)或資料庫系統中。
    2. 本表之「編輯權限」僅限行政助理與理事會成員;本會其餘個人會員與團體會員僅開放「檢視(唯讀)權限」。
  4. 實體識別碼之編發與核發權責

    1. 實體識別碼由五碼組成,採「類別英文字母(一碼)」與「流水號(四碼)」之組合。
    2. 類別英文字母定義如下:
      • 「C」開頭:代表具備獨立法人資格之公司、行號、商業實體或官方機構(如 C0012)。
      • 「P」開頭:代表自然人、個人委託者或獨立開發者(如 P0005)。
      • 「G」開頭:代表外部開源社群、非正式組織、DAO 等外部協作群體。
      • 「I」開頭:代表本會內部之特定興趣小組(Interest Group)、委員會、或本會自發性、無外部委託對象之自有研發專案(RD 性質)。
      • 「U」開頭:代表未知或無法考證之歷史實體。
    3. 實體識別碼之核發遵循「唯一性」原則。同一識別碼一經核發即具備唯一性,不得重複核發予不同實體,亦不得刪除。
    4. 核發程序:
      • 實體或內部單位首次與本會建立合作或立案關係時,由行政助理依時間先後順序及本條第二款之分類預編識別碼,提報理事會審查。
      • 實體識別碼須經主管機關(理事會)審查並正式核發後,始生效力。
  5. 檢索表之常態登錄與建表作業

    1. 行政助理於接獲以下核准通知後,應將相關資料登錄至檢索表:
      • 通知一:主管機關(理事會)正式核發新「實體識別碼」之通知。
      • 通知二:依《研發專案字號編製作業要點》核准新「研發專案字號」之立案通知。
    2. 檢索表之表頭欄位應嚴格遵循以下結構建置:
      1. 【實體識別碼】:填寫經理事會正式核發之五碼代碼。
      2. 【實體全稱】:填寫公司法人登記全名、組織官方名稱、社群群體正式名稱或個人真實姓名。
      3. 【常用別名/社群ID】:填寫網路常用暱稱、Discord ID、GitHub 帳號或機構縮寫(如:Stellar Cation Heavy Industries 加註 SCHI),以利多元關鍵字搜尋。
      4. 【完整專案字號】:必須填寫包含後綴的完整字號(如 SCHI-2026-001-ODM)。
      5. 【專案流水號(核心)】:單獨列出專案核心流水號(如 2026-001),作為核心檢索錨點。
      6. 【專案性質】:依《研發專案字號編製作業要點》第三條第四款之分類,填寫 RD、ODM 或 OEM。
      7. 【專案名稱】:填寫該專案之正式名稱或開發代號。
      8. 【立案日期】:填寫專案正式啟動或合約簽署之西元日期(格式:YYYY-MM-DD)。
      9. 【結案狀態】:分為 執行中、已結案、暫停、註記作廢。
  6. 歷史專案與實體之補登程序

    1. 針對本會成立前或本辦法實施前之歷史舊案,行政助理應協同專案負責人考證歷史資料,提出溯及補登申請,經理事會審查通過後,於一個月內完成「實體-專案檢索表」之登錄。
    2. 若歷史專案之委託或合作對象已無法考證,經理事會核准後,【實體識別碼】欄位依序配發「U」開頭之未知實體代號(如 U0001),【實體全稱】欄位則註明「未知或無法考證之歷史實體」。
  7. 行政檢索操作指引(查找特定專案流水號)

    • 行政助理或會內成員於日後需要查找特定專案資訊時,應依下列步驟進行操作:
      1. 步驟一:開啟「外部實體-專案檢索表」,使用快捷鍵(如 Ctrl + F)開啟搜尋功能。
      2. 步驟二:輸入欲查找之實體名稱、別名或社群 ID。
      3. 步驟三:定位至該實體之資料列,即可直接於同列之【專案流水號(核心)】或【完整專案字號】欄位中,查得該特定專案的流水號。
      4. 步驟四:若欲查找該實體歷年來的所有合作,可直接對【實體識別碼】欄位啟用試算表之「篩選(Filter)」功能,即可一鍵列出該實體名下的所有歷史專案。
  8. 資料維護與更正機制

    1. 實體識別碼與專案字號一經核發並登錄,行政助理不得任意刪除或變更。
    2. 倘專案後續發生撤案或終止,助理應依據理事會之決議或通知,將【結案狀態】欄位更改為「註記作廢」,並於備註欄說明原因。
    3. 外部實體或內部單位更名時,助理應檢具更名證明文件提報理事會,經核准後更新【實體全稱】欄位,但原有的【實體識別碼】與其對應之歷史專案流水號一律保持不變,以維持歷史資料的真實性。
  9. 施行

    • 本辦法自發布之日起實施,修改時亦同。

曌銕重工專案字號編製作業要點(草案)

Details
  1. 主管機關

    • 本作業要點主管機關為理事會。
  2. 專案立號義務

    • 凡本會之研發專案,均應依本要點之規定編製專案字號,以資識別、統計及檔案分類管理。
  3. 字號組成格式

    • 專案字號由「組織代碼」、「年份」、「年度序號」與「專案性質」組合而成,區分為四段式結構,各碼間以半形連接號(-)銜接。其標準格式如下:
      • SCHI-<年份>-<年度序號>-<專案性質> (範例:SCHI-2026-001-AD
    • 各區段定義如下:
      1. 組織代碼:固定為「SCHI」(Stellar Cation Heavy Industries),用以標示本會所屬專案。
      2. 年份:採西元紀年,以四位數計(如 2025),用以標示專案立案之年度。
      3. 年度序號:採三位數流水號(自 001 開始),不分專案性質,一律依專案申請登記之先後順序,依序編發。
      4. 專案性質:用以識別專案之運營模式與法律責任分類,分為以下四類:
        • RD(Research & Development):本會自有之核心技術探索、開源專案與自由創作項目。
        • ODM(Original Design Manufacturer):本會為主導規劃、設計並開發,但智慧財產權與實體成果歸屬外部委託方之專案。
        • OEM(Original Equipment Manufacturer):純屬承接外部明確規格、按圖施工之純技術勞務代工項目。
        • AD(Artistic Design):凡屬本會自製或受託承接之視覺傳達、平面設計、LOGO識別、多媒體美術創作等美術設計類項目。
  4. 序號更易與管理機制

    • 專案字號之年度序號遵循「每年更易一次」原則。每逢新結算年度(跨年)時,號碼應重新起算,自 001 開始編列,不得延續上一年度之流水號。
    • 專案字號之前置核心「SCHI-<年份>-<年度序號>」一經核發,即具備唯一性。同一年度序號不得重複核發予不同專案,亦不得因末端「專案性質」之不同而重複遞補使用(例如:已核發 SCHI-2025-001-RD 後,該年度即不得再出現 SCHI-2025-001-ODM)。倘專案後續發生變更、暫停或終止(撤案),該字號應予保留或註記作廢,不得刪除。
  5. 歷史專案之溯及編列與常態補登機制

    • 本要點實施前已存在、啟動或結束之歷史研發專案,應追溯納入本字號編製體系。
    • 歷史專案之字號編列原則如下:
      1. 年份認定基準:一律以該專案當初「實際發起、首次程式碼提交(Commit)、公開發表或首度執行」之西元年份為准。
      2. 歷史序號整編原則:歷史溯及專案之補登,於各該歷史年度內,仍應自 001 開始依實際發起時間之先後順序依序編列。於本要點通過後六個月之整編期內,專案管理人員得打破既有登記順序,對同一歷史年度之專案流水號進行調整與插編,以還原真實歷史排序。
      3. 外部專案與歷史代工移轉認列:
      • 凡個人會員或團體會員於入會前(或本會成立前)所獨立發展或承接之舊有項目,有助於彰顯成員技術履歷與本會歷史貢獻者,得申請追溯認列為本會歷史專案。
      • 前項歷史專案經理事會審查通過後,依其最初發起年份發給專案字號,並依第三條第四款之標準認定其專案性質,據以配發 RDODMOEM 之性質尾碼。
      1. 常態補登與歷史序號凍結限制:本要點通過實施屆滿六個月後,歷史專案之整編期即告結束,該歷史年度已存在之流水號序位予以凍結,不得再進行任何因應歷史排序之插編或變更。整編期結束後,會員仍得隨時申請補登歷史專案,主管機關應依其歷史年份予以認列,惟其流水號應承接於該歷史年度既有最後一號之後,依申請時間順序遞增編列。

本來是想把手邊的三台舊筆電用 Talos 拼成 Kubernetes 叢集運行 email 和 Kimai 作為曌銕重工的第一號財產,選擇的理由很簡單:

  • email 可以說是數位基礎建設,雖然跟 ID Provider 是兩回事,但是實務上它就是 ID 之源,其他帳號的密碼都可以忘,就是 email 的不行。對我而言它應該已經像是郵政服務一般的存在了,政府沒有在這方面努力對我而言算是十分的不解,不過這又是另外一個話題了。
  • Kimai 是用來「打卡上班」的基礎設施,也可以說是 HR 的自動化基礎設施,畢竟人力資源是生產要素基本中的基本。

至於這個規劃為什麼拖延了許久?說來話長。

原本選擇筆電叢集的原因之一是基於「這輩子大概跟(機架式)伺服器沒什麼緣份」的前提定調的,然而最近卻因緣際會跟它結了緣,現在上班都會有伺服器用它的風扇聲在我腳邊喵喵叫。總之,因為這樣我不得不經歷思想鬥爭:繼續往筆電叢集的道路前進,還是考慮比較能和職業結合的機架式伺服器體系。

另一方面是因為搬了家,沒有管理室幫忙收包裹的情況購買鐵力士層架這種大型物品簽收會變得很麻煩,目前房間又沒有比較合適的空間可以安置這三台筆電構成的叢集。

除此之外亦有來自週邊影響的「AI 焦慮」,不得不把一些精力和時間分配給相關主題...。

結論

總之,看到這裡,你應該對於「我想壟斷我自己」有比較具體的想像了。至於它究竟是一個神經病得幻想還是實際上有點意義的概念?

之後我們或許就知道了。

Wei Ji

最近開始試著使用 CLI/TUI 的 LLM Agent 工具,選擇了 crush

然後想把之前在 Local Deep Research 的使用習慣轉移過來,原因是 Local Deep Research 彈性略差,加上它的主要賣點之一是研究內容在伺服器端是加密的,但是這個設計造成 UX 上的缺陷:光是開啟清單頁面都需要運行解密而造成網頁會花不少時間在等待解密完成。

把 Local Deep Research 的提示詞抽出來隨便搓了一個 Skill:

https://github.com/FlySkyPie/simple-research-skills

但是發現了一個問題:

恩?為什麼我架的 SearXNG 看起來都搜不到東西?是 MCP 壞掉了嗎?

原來是被搜尋引擎擋了。

但是 crush 的內建工具為什麼可以運作?

翻了一下程式碼發現它是使用鴨鴨 (DuckDuckGo):

有趣的是,當時 DuckDuckGo 算是第一個在我的 SearXNG 會擋機器人的搜尋引擎,因此它很早之前就被我從 SearXNG 的設定中停用了。

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/