💡 完整可執行範例可在 GitHub 取得: sign-documents-in-docker-fonts-java

能運作九個月的合約簽署服務

容器字型供應是決定 Java 簽署服務是能在正式環境運作,還是只能在你偶然寫的測試中運作的關鍵步驟。這很重要,因為失敗是「排程」好的:JRE 映像提供足夠的字型覆蓋讓畫面看起來正確,卻把其餘的字型保留,直到特定文件出現時才會缺失。

先想像一下情境。文件工作流程簽署合約,部署在 eclipse-temurin:17-jre 上,且能正常運作。九個月後,公司在日本簽下第一位客戶,客戶名稱被寫入簽名文字,工作失敗,顯示 Specified font file was not found。服務本身沒有任何變動。映像從未包含 CJK(中日韓)字型覆蓋;也沒有文件要求過它。

技術原因很簡單。eclipse-temurin:17-jre 只捆帶 8 個 DejaVu 字型檔案供 AWT 使用,覆蓋拉丁、希臘與西里爾字母。GroupDocs.Signature 不會自動替代缺失的字型族群,因此對日文可用字型的請求會失敗,而不是退化;而且若字型未設定,庫會改為請求 Times New Roman,這同樣不存在。

為何這比「沒有字型」的映像更糟

.NET 與 Python 基礎映像根本不帶任何字型。這是一種較好的失敗方式:第一個簽名在第一次測試執行時就失敗,開發者會在服務上線前修正它。

JVM 映像則是「部分」失敗,這是更昂貴的版本。錯誤出現在已經上線的程式碼中,由客戶資料觸發,而非部署本身;值班人員看到的是一個好幾個月沒人碰過的服務拋出的字型錯誤。事件成本不在於修復——修復只需要一層 Dockerfile——而在於在任何人相信字型與問題無關之前的那一小時。

這種不對稱性正是主張在啟動時斷言字型覆蓋,而非等到發現問題的理由。

它同時改變了誰要付出代價。沒有字型的映像只會讓開發者在設定階段多花二十分鐘。部分覆蓋的映像則會讓值班工程師在不方便的時間花上一小時,加上因延遲合約而產生的損失,以及事後的檢討。兩者的技術差異僅是 Dockerfile 中多了四個套件。

供應實際需要多少成本

執行階段需要安裝四個 Debian 套件:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

映像大小常被當作反對理由,我們在此具體說明:CJK 套件體積較大,其他三個套件體積小,且如果文件可能包含非拉丁字元,這三個套件都不是可選的。請根據實際文件需求安裝,並透過讀回驗證,而不是憑直覺裁剪。

fontconfig 提供解析器以及 fc-list 用於除錯。fonts-dejavu-core 重複了 JRE 已經捆帶的字型,這是刻意為之:若基礎映像變更,仍能保持映像的正確性。fonts-liberation 很重要,因為在 Windows 上編寫的文件會以名稱引用 Arial 與 Times New Roman,並期待相容的度量渲染。fonts-noto-cjk 正是上面事件所需的套件。

解析字型族群而非直接指定名稱

僅供應字型還不夠,程式仍必須指定一個已存在的字型族群。可移植的做法是向庫詢問:對每個候選字型族群嘗試一次拋棄式簽名,保留第一個不拋例外的族群。

for (String candidate : candidates) {
    if (tryFamily(sourcePath, candidate) == null) {
        return candidate;
    }
}
return null;

探測本身就是一次普通的簽名呼叫,寫入暫存目錄,失敗會以回傳值而非例外拋出:

SignatureFont font = new SignatureFont();
font.setFamilyName(familyName);
font.setSize(10);
options.setFont(font);
signature.sign(scratch.getAbsolutePath(), options);
return null;

檔名偵測看起來是捷徑,實際上卻不可靠。Debian 的 fonts-noto-cjk 會安裝 NotoSansCJK-Regular.ttc,其字型族名稱是 Noto Sans CJK JP,因此僅比對檔名既會錯過字型,又會報告無法解析的族群。

誠實的退化處理

有了解析機制後,兩種失敗類別可以乾淨分離。若找不到任何拉丁字型族群,映像根本無法簽署,應直接停止容器。若找不到 CJK 字型族群,則跳過該筆簽名,繼續執行並給予警告:

List<SignOptions> options = new ArrayList<>();
options.add(buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (cjkFamily != null) {
    options.add(buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

SignResult result = signature.sign(outputPath, options);

這個區別在營運上很重要。容器在啟動時因「沒有可用的字型族群」而退出,屬於部署問題,會被部署者即時發現。若文件中靜默缺少簽名,則屬於合規問題,會被收件人發現。將致命情況映射為非零退出碼,可將失敗限制在第一類別。

接著讀回結果,因為沒有 CJK 覆蓋的簽名可能會呈現為空方框卻不拋例外:

TextSearchOptions options = new TextSearchOptions();
options.setAllPages(true);
List<TextSignature> found = signature.search(TextSignature.class, options);

已經上線的團隊該怎麼做?

加入字型層、在啟動時加入解析,並在服務第一行日誌中同時記錄兩個結果,讓後續工程師能直接看到。變更僅是 Dockerfile 的一次編輯,加上大約三十行程式碼,便能把客戶觸發的事故轉變為「要麼以已知覆蓋啟動,要麼拒絕啟動」的容器。既有的已簽文件不受影響,只有新文件會走 CJK 路徑。

檢查已在執行的映像

在做任何變更之前,先了解目前映像內有哪些字型。以下兩個指令可從外部快速檢查:

docker run --rm your-image sh -c "ls -R /usr/share/fonts | head"
docker run --rm your-image sh -c "fc-list : family | sort -u | head -20"

第一個列出字型檔案,第二個列出解析器會回傳的字型族名稱,兩者之間的差距即是檔名匹配失敗的原因。若 fc-list 不存在,說明 fontconfig 未安裝,任何字型族查詢都會盲目進行。

在服務內部,等同的檢查應放在啟動日誌中,緊跟解析出的字型族群。例如:

fonts on disk: 8, latin: DejaVu Sans, cjk: (none)

這行資訊讓下一位接手的人一眼就能知道此容器能簽署什麼、不能簽署什麼,比凌晨三點才看到的例外訊息更有用。

JVM 細節:沒有人預期的陷阱

還有一件特別針對 Java 的問題,且與字型無關。GroupDocs Maven 套件是一個已簽名的 fat jar。若將其重新打包成 shaded jar,會出現 NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions,而僅刪除 META-INF/*.SF|RSA|DSA 並不足以解決:MANIFEST.MF 內含約 19 MB 的每個條目摘要,也必須截斷至主段。範例透過在普通 classpath 下使用 dependency/ 目錄,而非 shading 任何內容,避免了此問題。

提及此點是因為上述兩個問題——部分字型覆蓋與已簽名 jar——都有相同的形態:JVM 路徑以看似程式碼本身的方式失敗。兩者都可以在命名後以低成本防禦:固定已驗證可用的 classpath 佈局,並在啟動時斷言字型覆蓋,而非信任基礎映像。這兩項改動都不需要重新設計,卻能消除一類原本難以與應用程式錯誤區分的事故。

結論

只要在容器中加入 fontconfig、DejaVu、Liberation 與 Noto CJK,並在啟動時透過探測方式解析字型族群、跳過無法嵌入的字型、最後以讀回驗證,即可讓 Java 簽署服務變得可預測。範例倉庫同時提供兩種映像,讓「有覆蓋」與「無覆蓋」的差異只需要兩次建置即可看出,而不必等到一次事故才學到教訓。

其他資源