💡 전체 작동 예제는 GitHub에서 확인할 수 있습니다:
python-linux-container-pdf-signing

소개

스크립트는 로컬에서 정상적으로 동작합니다. 이를 python:3.11-slim 이미지에 컨테이너화하면 import groupdocs.signature 단계에서 실패합니다. 이를 해결하고 다시 실행하면 첫 번째 서명 단계에서 또다시 실패합니다. 두 오류 모두 실제로 무엇이 누락되었는지 알려주지 않습니다.

Python으로 컨테이너 서명을 수행하는 것은 GroupDocs.Signature 워크플로우이며, 하나가 아닌 두 개의 프로비저닝 레이어가 필요합니다: 바인딩이 구축된 .NET 런타임 라이브러리와 텍스트 서명을 렌더링하는 데 필요한 폰트입니다. 이 튜토리얼에서는 두 레이어를 모두 구축하고, 폰트 패밀리를 하드코딩하지 않고 런타임에 해결하는 스크립트를 작성합니다. 따라서 동일한 코드를 컨테이너와 개발 머신 모두에서 사용할 수 있습니다.

두 레이어가 중요한 이유

GroupDocs.Signature for Python은 .NET 바인딩이므로 libicu와 OpenSSL 1.1 호환 라이브러리가 있어야 import가 성공합니다. 이것이 첫 번째 레이어이며, Running in Docker 문서에 잘 설명되어 있습니다.

두 레이어가 혼동되는 이유는 둘 다 import와 관련된 시점에 실패하고, 오류 메시지가 원인을 명시하지 않기 때문입니다. libssl1.1이 없으면 공유 객체에 대한 로더 오류가 발생하고, 폰트가 없으면 프록시 예외에 감싸인 서명 오류가 발생합니다. 어느 쪽도 “베이스 이미지가 너무 작다”는 메시지를 제공하지 않으며, 실제로 두 경우 모두 그 의미입니다.

두 번째 레이어는 폰트이며, 사람들을 가장 놀라게 하는 부분입니다. python:3.11-slim에는 폰트 파일이 전혀 포함되어 있지 않습니다. GroupDocs.Signature은 누락된 패밀리를 자동으로 대체하지 않으며, 설치되지 않은 패밀리를 지정하면 예외가 발생하고 아무 것도 기록되지 않습니다. 폰트를 비우는 것도 해결책이 되지 않는데, 라이브러리가 자체 기본값을 요청하면서 동일하게 실패하기 때문입니다. 폰트가 없는 이미지에서는 텍스트 서명이 불가능합니다.

전제 조건

Python 3.11 (휠이 CPython 3.14 이하에서만 지원) 및 groupdocs-signature-net==26.1. Docker는 두 가지 실패 상황을 직접 확인하고 싶을 때 사용하면 좋으며, 약 10분 정도 소요됩니다.

설치

pip install groupdocs-signature-net==26.1

단계 1 - .NET 레이어 빌드

libssl1.1은 bookworm에 포함되지 않으므로 고정된 Debian 스냅샷에서 가져옵니다:

ENV SNAPSHOT_DATE=20220328T000000Z
RUN echo "deb [trusted=yes] http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} bullseye main" \
        > /etc/apt/sources.list.d/debian-archive.list \
    && apt-get -o Acquire::Check-Valid-Until=false update \
    && apt-get install -y --no-install-recommends \
        libicu67 \
        libssl1.1 \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

핵심 포인트:

  • 이 레이어는 import가 동작하도록 할 뿐이며, 폰트와는 무관합니다.
  • 스냅샷 날짜를 고정하면 아카이브가 이동해도 빌드 재현성을 유지할 수 있습니다.

단계 2 - 폰트 레이어 빌드

네 개의 패키지를 별도 레이어로 유지하여, 실패 재현을 위해 주석 처리할 수 있도록 합니다:

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

fontconfig는 폰트 탐색기이며 fc-list 명령을 제공합니다. fonts-dejavu-core는 라틴, 그리스, 키릴 문자에 필요한 최소 폰트입니다. fonts-liberation은 Arial이나 Times New Roman을 이름으로 참조하는 문서를 지원합니다. fonts-noto-cjk는 중국어, 일본어, 한국어를 커버합니다.

단계 3 - 라이브러리에게 사용할 수 있는 패밀리를 물어보기

/usr/share/fonts에서 파일명을 스캔하는 방식은 겉보기와 달리 다릅니다. fonts-noto-cjk는 NotoSansCJK-Regular.ttc를 설치하고, 해당 패밀리 이름은 Noto Sans CJK JP입니다. 휴대용 해결책은 실제 서명을 임시 파일에 시도해 보고, 실패를 값으로 변환하는 프로브입니다:

with signature.Signature(source_path) as sign:
    options = TextSignOptions()
    options.text = "probe"
    options.left = 10
    options.top = 10
    options.width = 60
    options.height = 20
    font = SignatureFont()
    font.family_name = family_name
    font.size = 10.0
    options.font = font
    sign.sign(scratch, [options])
return None

font.size = 10.0에 주목하세요. 바인딩은 크기를 .NET float으로 매핑하고, int가 들어오면 numeric argument expected, got 'int' 오류를 반환합니다. 이 과정이 프로브 내부에서 일어나면 모든 후보 패밀리가 실패하고, 출력은 폰트가 없는 이미지와 동일하게 보입니다. 저는 이미 모든 폰트가 설치된 이미지에 세 개의 폰트 패키지를 추가한 뒤 이 현상을 발견했습니다.

해결 로직은 다음과 같은 루프가 됩니다:

for candidate in candidates:
    if try_family(source_path, candidate) is None:
        return candidate
return None

단계 4 - 해결된 것을 서명하고, 서명한 것을 검증하기

라틴 패밀리는 필수이며, CJK 패밀리는 선택 사항입니다:

with signature.Signature(source_path) as sign:
    options = [build_text_options(LATIN_TEXT, latin_family, 50)]
    if cjk_family:
        options.append(build_text_options(CJK_TEXT, cjk_family, 120))
    result = sign.sign(output_path, options)
    return len(result.succeeded)

그 다음 검증을 수행합니다. CJK가 빈 상자로 렌더링될 경우 예외가 발생하지 않기 때문입니다:

options = TextVerifyOptions()
options.text = expected_text
options.match_type = gsd.TextMatchType.CONTAINS
options.all_pages = True
result = sign.verify(options)

CONTAINS를 사용하는 이유는 평가 모드에서 라이브러리가 페이지에 시험 텍스트를 추가하고, 정확히 일치하는 경우 정상적인 문서가 실패로 보고될 수 있기 때문입니다.

문서에서 Python의 Linux 지원이 제한적이라고 말하는 것은 왜인가?

Running in Docker 페이지는 Linux‑ready Python 패키지를 나열하고 Signature를 제외합니다. groupdocs-signature-net==26.1을 사용하면 이 샘플이 python:3.11-slim 내부에서 CJK까지 정상적으로 서명·검증됩니다. 목록은 오래된 정보일 가능성이 높으니, 배포 전에 직접 사용 중인 버전으로 확인하는 것이 좋습니다.

실제 적용 사례

생성된 PDF에 승인 라인을 찍어야 하는 청구서 서비스는 정확히 이 구성이 필요합니다: .NET 레이어, 하나의 라틴 폰트, 그리고 시작 시점에 폰트 패밀리를 확인하는 로직. 이 검사는 배포가 실패해 컨테이너가 시작조차 못 하는 상황을 방지하고, 개별 청구서가 조용히 실패하는 것을 막아줍니다. 고객 이름을 어떤 스크립트로든 입력받아야 하는 문서 포털은 CJK 패키지와 검증 단계가 추가로 필요합니다. 검증 단계가 없으면 렌더링된 상자와 서명된 이름 사이의 차이를 구분할 수 없기 때문입니다.

해결 검사 위치

프로세스당 한 번만 실행되는 곳에 넣으세요: 모듈 레벨 호출, FastAPI lifespan 핸들러, Django AppConfig.ready, 혹은 워커 메인 함수의 첫 줄 등. 이 검사에서 두 값을 얻습니다—라틴 패밀리와 CJK 패밀리—그리고 두 값은 폰트 개수와 함께 시작 로그에 기록됩니다.

이 위치는 단순히 프로브 시간을 절약하는 것을 넘어섭니다. 오류를 요청 처리 단계에서 발생시키면 개별 고객의 문제이자 아무도 보지 않는 스택 트레이스로 전락합니다. 반면 시작 단계에서 오류가 발생하면 배포 자체가 올라오지 않았다는 신호가 되며, 이미 모니터링 중인 담당자가 즉시 인지할 수 있습니다. “사용 가능한 폰트 패밀리가 없으니 fonts-dejavu-core를 설치하세요”라는 메시지와 함께 컨테이너가 종료되면 별도의 디버깅이 필요 없습니다.

일반적인 문제 해결

import groupdocs.signature 실패
.NET 레이어가 없거나 스냅샷 저장소에 접근하지 못해 빌드가 중단된 경우입니다. 이는 첫 번째 레이어 문제이며 폰트와는 무관합니다. 서명 코드를 건드리기 전에 apt 단계 로그를 확인하세요. 스냅샷 가져오기에 실패해도 이미지 빌드는 계속될 수 있습니다.

모든 후보 폰트가 실패하지만 fc-list에 폰트가 표시됨
font.size가 int인지 확인하고, 필요하면 float(예: 10.0)로 수정하세요.

서명은 되지만 CJK 텍스트가 상자로만 보임
fonts-noto-cjk가 누락된 경우입니다. 서명은 해당 패밀리로 작성되었지만, 해당 코드 포인트에 대한 글리프가 없어 검증 단계에서 실패합니다.

두 이미지가 실제로 출력하는 내용

두 이미지를 모두 실행하고 처음 네 줄을 확인하세요. 폰트가 없는 이미지에서는 font files on disk: 0, 두 개의 해결 라인이 (none)으로 표시되고, 의도된 폰트 누락 오류가 출력된 뒤 종료 코드 3을 반환합니다. 폰트가 제공된 이미지에서는 폰트 개수가 0이 아니며, 라틴은 DejaVu Sans, CJK는 Noto Sans CJK JP가 표시되고, 두 개의 서명이 적용된 뒤 두 텍스트가 모두 검증됩니다.

이 출력 결과는 문서화할 가치가 있습니다. 배포 노트에 붙여두면 다음에 베이스 이미지를 교체하는 사람이 정상 컨테이너가 어떤 모습인지 바로 확인할 수 있어, fontconfig를 별도로 이해할 필요가 없습니다.

결론

두 레이어와 하나의 프로브만 있으면 됩니다. .NET 의존성을 설치하고, 최소 fontconfig와 DejaVu 폰트를 설치한 뒤, 패밀리를 가정하지 말고 물어보는 방식으로 해결하고, 작업이 끝나기 전에 결과를 검증하세요. 코드 양은 적고, 모든 로직은 사후에 명확해지는 흔한 실수이면서 트레이스백에서는 보이지 않는 부분입니다. 샘플 저장소에는 두 개의 Dockerfile이 모두 포함되어 있어, 정상 이미지와 깨진 이미지의 차이가 한 번의 빌드 차이임을 쉽게 확인할 수 있습니다.

추가 자료