💡 完全な動作例はGitHubで入手できます:
sign-documents-in-docker-fonts-java

9か月間稼働した契約署名サービス

コンテナのフォントプロビジョニングは、Java 署名サービスが本番で動作するか、書いたテストだけで動作するかを決めるステップです。失敗がスケジュール通りに起きるため、JRE イメージは見た目が正しくなる程度のフォントカバレッジしか提供せず、残りは特定の文書が来るまで保留します。

形を考えてみましょう。文書ワークフローが契約書に署名し、eclipse-temurin:17-jre 上にデプロイされ、うまく動作しています。9か月後、会社は日本の最初の顧客と契約し、名前が署名テキストに入りますが、ジョブは Specified font file was not found で失敗します。サービス自体に変更はありません。イメージは CJK カバレッジを持っておらず、文書がそれを要求したこともありません。

技術的な原因は簡単です。eclipse-temurin:17-jre は AWT 用に 8 つの DejaVu フォントファイルをバンドルしており、ラテン文字、ギリシャ文字、キリル文字をカバーします。GroupDocs.Signature は欠落したファミリを代替しないため、日本語対応フォントの要求は劣化せずに失敗し、フォントが未設定のままではライブラリが Times New Roman を要求しますが、これも存在しません。

フォントなしイメージよりも悪い理由

.NET と Python のベースイメージはフォントをゼロで出荷します。これはより良い失敗です。最初の署名が最初のテスト実行ですぐに失敗し、誰かがサービスの出荷前に修正します。

JVM イメージは部分的に失敗し、これは高コストなバージョンです。バグはすでに本番にあるコードに潜んでおり、デプロイ時ではなく顧客データによってトリガーされ、オンコールの担当者は数か月触っていないサービスからフォントエラーを目にします。インシデントのコストは修正そのものではなく(修正は Dockerfile のレイヤー1つです)、「フォントが関係している」と誰も信じるまでの時間です。

この非対称性が、フォントカバレッジを起動時にアサートすべきものとし、発見すべきものではないという議論の根拠になります。

また、コストの支払者も変わります。フォントなしイメージは開発者にセットアップ時に 20 分のコストを課します。部分的にカバーされたイメージはオンコールエンジニアに 1 時間のコストを課し、さらに遅延した契約の価値や、誰の変更とも結びつかないインシデント後のレビューが発生します。技術的な違いは Dockerfile の 4 つのパッケージです。

プロビジョニングの実際のコスト

ランタイムステージでインストールする Debian パッケージは 4 つです。

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 パッケージが大きく、他の 3 つは小さく、文書がラテン文字以外の名前を含む場合はどれも必須です。実際に必要なフォントだけをインストールし、トリミングは感覚で行わずにリードバックで検証してください。

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 です。そのためファイル名でマッチさせるとフォントを見逃したり、解決できないファミリを報告したりします。

正直に劣化させる

解決ロジックがあると、2 つの失敗クラスはきれいに分離します。ラテン系ファミリがない場合、イメージは全く署名できず、コンテナは停止すべきです。CJK ファミリがない場合は 1 つの署名をスキップし、警告とともに処理を続行します。

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 カバレッジなしで書かれた CJK 署名は、何も例外を出さずに空白の四角で表示されることがあります。

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

すでに出荷済みのチームはどうすべきか

フォントレイヤーを追加し、起動時に解決ロジックを入れ、サービスの最初の行で両方の結果をログに出します。変更は Dockerfile の編集と約 30 行のコード追加だけで、顧客トリガーのインシデントを「既知のカバレッジで起動」または「起動拒否」へと変換します。既存の署名済み文書は影響を受けず、新規文書だけが CJK パスを得ます。

すでに実行中のイメージを確認する方法

何も変更する前に、現在のイメージに何が入っているかを把握しておく価値があります。外部から答えを得るコマンドは 2 つです。

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"

最初のコマンドはフォントファイルを一覧表示し、2 番目はリゾルバが返すファミリ名を一覧表示します。両者のギャップがファイル名マッチが失敗する理由です。fc-list が無ければそれ自体が答えです。fontconfig がインストールされておらず、ファミリ検索は盲目的に走ります。

サービス内部では、同等のチェックを起動ログに入れ、解決されたファミリの横に出力します。例えば fonts on disk: 8, latin: DejaVu Sans, cjk: (none) と表示すれば、次の担当者はこのコンテナが何を署名でき、何ができないかを正確に把握でき、深夜の例外スタックトレースよりも有用です。

JVM の意外な落とし穴

Java 固有で噛み合うもう一つの問題がありますが、フォントとは関係ありません。GroupDocs Maven アーティファクトは署名付きの fat jar です。これをシェード jar に再パッケージすると NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions が発生し、通常の対策である META-INF/*.SF|RSA|DSA の削除だけでは不十分です。MANIFEST.MF には 19 MB ものエントリごとのダイジェストが含まれており、メインセクションだけに切り詰める必要があります。サンプルは dependency/ ディレクトリを使ったプレーンなクラスパスで実行し、シェーディングを回避しています。

この点を挙げたのは、部分的なフォントカバレッジと署名付き jar の両方が「JVM パスがコードのように見えて実は違う」形を共有しているからです。どちらも名前が分かれば安価に防御できます。クラスパスレイアウトを固定し、ベースイメージを信頼せず起動時にフォントカバレッジをアサートすれば、再設計は不要で、アプリケーションバグと区別できないインシデントのクラスを排除できます。

結論

コンテナ内の Java 署名サービスは、予測可能になるまでに Dockerfile のレイヤー 1 つと起動チェック 1 つだけです。fontconfig、DejaVu、Liberation、Noto CJK をインストールし、ファミリは推測せずにプローブで解決し、埋め込めないものはスキップし、リードバックで検証してください。サンプルリポジトリは両方のイメージを提供しているので、カバレッジの有無の違いは 1 回のビルドで確認でき、インシデントで学ぶ必要はありません。

追加リソース