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

Додаткові ресурси