💡 Полный рабочий пример доступен на 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);

Это различие имеет операционное значение. Контейнер, который завершается при старте с сообщением «no usable font family», — это проблема развертывания, её замечает тот, кто развёртывает образ. Подпись, тихо отсутствующая в готовом документе, — это проблема соответствия, её замечает получатель. Привязка фатального случая к ненулевому коду выхода сохраняет сбои в первой категории.

Затем прочитайте результат обратно, потому что CJK‑подпись, записанная без покрытия 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, и это не про шрифты. Maven‑артефакт GroupDocs — это подписанный fat‑jar. Перепаковка его в shaded‑jar приводит к NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions, а обычное решение — удалить META-INF/*.SF|RSA|DSA — недостаточно: MANIFEST.MF содержит около 19 МБ дайджестов для каждой записи и также должен быть усечён до основной секции. Пример избегает этой проблемы, запускаясь от обычного classpath с каталогом dependency/, а не используя shading.

Я упоминаю это, потому что обе проблемы — частичное покрытие шрифтами и подписанный jar — имеют одну форму: путь JVM падает так, как будто это ваш код, но на самом деле нет. Обе проблемы также дешево предотвратить, как только их назовут: зафиксировать известную рабочую структуру classpath и проверять покрытие шрифтами при старте, а не доверять базовому образу. Ни одна из них не требует полной переработки, и обе устраняют класс инцидентов, который иначе неотличим от багов приложения.

Заключение

Java‑сервис подписания в контейнере — это один слой Dockerfile и одна проверка при старте от предсказуемости. Установите fontconfig, DejaVu, Liberation и Noto CJK; определяйте семейство пробой, а не предположением; пропускайте то, что нельзя встроить; проверяйте, читая обратно. Примерный репозиторий поставляет оба образа, поэтому разницу между покрытием и его отсутствием можно увидеть за два билда, а не за один инцидент.

Дополнительные ресурсы