💡 Полный рабочий пример доступен на 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; определяйте семейство пробой, а не предположением; пропускайте то, что нельзя встроить; проверяйте, читая обратно. Примерный репозиторий поставляет оба образа, поэтому разницу между покрытием и его отсутствием можно увидеть за два билда, а не за один инцидент.