💡 Volledig werkend voorbeeld beschikbaar op GitHub:
sign-documents-in-docker-fonts-java

De Contractondertekeningsservice die negen maanden werkte

Container‑lettertype‑provisionering is de stap die bepaalt of een Java‑ondertekeningsservice in productie werkt of alleen in de tests die je toevallig hebt geschreven. Het is belangrijk omdat de fout gepland is: een JRE‑image geeft je genoeg lettertype‑dekking om er correct uit te zien, maar houdt de rest achter tot een specifiek document arriveert.

Beschouw de situatie. Een document‑workflow ondertekent contracten, gedeployed op eclipse-temurin:17-jre, en hij werkt. Negen maanden later ondertekent het bedrijf zijn eerste klant in Japan, de naam komt in de handtekeningtekst terecht, en de taak faalt met Specified font file was not found. Er is niets veranderd in de service. De image had nooit CJK‑dekking; geen enkel document had daarom gevraagd.

De technische oorzaak is kort. eclipse-temurin:17-jre bundelt 8 DejaVu‑lettertypebestanden voor AWT, die Latin, Greek en Cyrillic dekken. GroupDocs.Signature vervangt een ontbrekende familie niet, dus een verzoek om een Japans‑capabel lettertype faalt in plaats van te degraderen, en het weglaten van het lettertype helpt niet omdat de bibliotheek dan om Times New Roman vraagt, dat ook afwezig is.

Waarom dit erger is dan een lettertype‑loze image

De .NET‑ en Python‑basis‑images leveren nul lettertypen. Dat is een betere fout: de allereerste handtekening faalt, in de eerste testrun, en iemand repareert het voordat de service wordt uitgerold.

Een JVM‑image faalt gedeeltelijk, wat de dure versie is. De bug zit in code die al in productie is, wordt geactiveerd door klantdata in plaats van door iets in de deployment, en de persoon on‑call ziet een lettertype‑fout van een service die al maanden niet is aangeraakt. De incident‑kosten zijn niet de fix – de fix is één Dockerfile‑laag – maar het uur voordat iemand gelooft dat lettertypen betrokken zijn.

Die asymmetrie is het argument om lettertype‑dekking te behandelen als iets dat je bij het opstarten assert, in plaats van iets dat je ontdekt.

Het verandert ook wie betaalt. Een lettertype‑loze image kost een ontwikkelaar twintig minuten tijdens de setup. Een gedeeltelijk gedekte image kost een on‑call engineer een uur op een ongelegen moment, plus wat het vertraagde contract waard was, plus de review die volgt op een incident dat niemand kan toeschrijven aan een wijziging. Het technische verschil tussen de twee is vier pakketten in een Dockerfile.

Wat provisioning werkelijk kost

Vier Debian‑pakketten in de runtime‑stage:

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/*

Image‑grootte is de gebruikelijke bezwaar, en het is de moeite waard om specifiek te zijn: het CJK‑pakket is het grote, de andere drie zijn klein, en geen van hen is optioneel als je documenten niet‑Latijnse namen kunnen bevatten. Installeer wat je documentenset daadwerkelijk nodig heeft en verifieer met een read‑back in plaats van op gevoel te trimmen.

fontconfig is de resolver plus fc-list voor debugging. fonts-dejavu-core dupliceert wat de JRE al bundelt, wat opzettelijk is: het houdt de image eerlijk als de basis‑image verandert. fonts-liberation is belangrijk omdat documenten die op Windows zijn gemaakt Arial en Times New Roman per naam refereren en een metrisch‑compatibele weergave verwachten. fonts-noto-cjk is het pakket dat het bovenstaande incident nodig had.

Een familie oplossen in plaats van er één te benoemen

Provisioning alleen is niet genoeg, want de code moet nog steeds een bestaande familie benoemen. De draagbare manier is de bibliotheek te vragen: probeer een wegwerphandtekening per kandidaat, houd de eerste die geen uitzondering gooit.

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

De probe zelf is een gewone sign‑call naar de tijdelijke map, waarbij de fout wordt omgezet in een waarde in plaats van een uitzondering:

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

Bestandsnaamdetectie is de snelkoppeling die er gelijk uitziet maar dat niet is. Debian’s fonts-noto-cjk installeert NotoSansCJK-Regular.ttc, waarvan de familienaam Noto Sans CJK JP is, dus het matchen van bestandsnamen mist zowel lettertypen als rapporteert families die niet zullen resolven.

Eerlijk degraderen

Met resolutie in place scheiden de twee foutklassen zich netjes. Geen Latin‑familie betekent dat de image helemaal niet kan ondertekenen, wat de container moet stoppen. Geen CJK‑familie betekent dat één handtekening wordt overgeslagen en de run doorgaat met een waarschuwing:

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);

Het onderscheid is operationeel van belang. Een container die bij opstarten afsluit met “no usable font family” is een deployment‑probleem, opgemerkt door degene die hem heeft uitgerold. Een handtekening die stilletjes ontbreekt in een geleverd document is een compliance‑probleem, opgemerkt door de ontvanger. Het fatal‑geval naar een non‑zero exit leiden houdt fouten in de eerste categorie.

Lees daarna het resultaat terug, want een CJK‑handtekening geschreven zonder CJK‑dekking kan renderen als lege vakjes zonder iets te melden:

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

Waar laat dit een team dat al heeft uitgerold achter?

Voeg de lettertype‑laag toe, voeg resolutie toe bij opstarten, en log beide resultaten op de eerste regel van de service, waar de volgende engineer ze daadwerkelijk zal zien. De wijziging is een Dockerfile‑edit plus ongeveer dertig regels, en zet een klant‑geactiveerd incident om in een container die ofwel start met bekende dekking of weigert te starten. Bestaande ondertekende documenten blijven onaangetast; alleen nieuwe krijgen het CJK‑pad.

Een image die je al draait controleren

Voordat je iets verandert, is het de moeite waard om te weten wat je huidige image bevat. Twee commando’s beantwoorden dit van buitenaf:

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"

De eerste lijst de lettertype‑bestanden, de tweede de familienamen die een resolver zou teruggeven, en de kloof daartussen is de reden waarom bestandsnaammatching faalt. Als fc-list ontbreekt, is dat op zich al een antwoord: fontconfig is niet geïnstalleerd, en elke familielookup gebeurt blind.

Binnen de service hoort de equivalente controle in de opstart‑log naast de geresolveerde families. Een regel als fonts on disk: 8, latin: DejaVu Sans, cjk: (none) vertelt de volgende persoon precies wat deze container wel en niet kan ondertekenen, wat nuttiger is dan elke uitzondering die ze anders om drie uur ’s ochtends zouden lezen.

Het JVM‑detail dat niemand verwacht

Nog één ding dat specifiek op Java bijt, en het gaat niet om lettertypen. Het GroupDocs Maven‑artifact is een ondertekende fat jar. Het opnieuw verpakken tot een shaded jar veroorzaakt NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions, en de gebruikelijke remedie van het verwijderen van META-INF/*.SF|RSA|DSA is onvoldoende: MANIFEST.MF draagt 19 MB aan per‑entry digests en moet ook worden ingekort tot de hoofdsectie. Het voorbeeld vermijdt het probleem door tegen een gewone classpath met een dependency/‑directory te draaien in plaats van iets te shaden.

Ik noem het omdat beide – de gedeeltelijke lettertype‑dekking en de ondertekende jar – eenzelfde vorm delen: het JVM‑pad faalt op een manier die op jouw code lijkt maar dat niet is. Beide zijn bovendien goedkoop te verdedigen zodra ze benoemd zijn: pin de classpath‑lay‑out die je weet dat werkt, en assert lettertype‑dekking bij opstarten in plaats van te vertrouwen op de basis‑image. Geen van beide kost een redesign, en beide verwijderen een klasse van incidenten die anders ononderscheidbaar is van een applicatie‑bug.

Conclusie

Een Java‑ondertekeningsservice in een container is één Dockerfile‑laag en één opstart‑check verwijderd van voorspelbaarheid. Installeer fontconfig, DejaVu, Liberation en Noto CJK; los de familie op door te proberen in plaats van te veronderstellen; sla over wat niet kan worden ingebed; verifieer door terug te lezen. Het voorbeeld‑repository levert beide images, zodat het verschil tussen dekking en geen dekking twee builds kost om te zien in plaats van één incident om te leren.

Aanvullende bronnen