배포

Sophonz SDK를 쓰는 Python 서비스를 운영 환경에서 실행하기 — 코드의 init()과 opentelemetry-instrument, 환경 변수와 시크릿 구성, 비루트 사용자와 읽기 전용 루트 파일시스템 컨테이너, Kubernetes 매니페스트, 정상 종료.

운영 환경에서는 SDK 설정을 거의 전부 환경으로 넘깁니다. 서비스 이름과 엔드포인트는 일반 설정에, API 키는 시크릿에 둡니다. 이 페이지는 Sophonz 샘플 서버의 구성을 따릅니다. 샘플 서버는 Flask와 Django를 gunicorn으로, FastAPI를 uvicorn으로 Kubernetes에서 실행하며, 비루트 사용자와 읽기 전용 루트 파일시스템을 사용합니다.

프로그래밍 방식과 코드 변경 없는 방식

코드의 init()opentelemetry-instrument
실행 명령gunicorn app:appopentelemetry-instrument gunicorn app:app
설정인자, 폴백으로 환경 변수환경 변수만
계측별 설정instrumentations={...}SOPHONZ_PYTHON_INSTRUMENTATIONS (JSON)
처리되지 않은 예외 캡처켜짐(init() 기본값)SOPHONZ_PYTHON_EXPERIMENTAL_EXCEPTION_CAPTURE=true가 아니면 꺼짐
프레임워크 헬퍼(sophonz_flask_middleware, SophonzMiddleware 등)사용 가능사용 가능
import 순서진입점에서 프레임워크 로드 전에 init() 호출 필요런처가 처리
DjangoDJANGO_SETTINGS_MODULE 설정 후 init() 호출환경에 DJANGO_SETTINGS_MODULE 설정

두 방식 모두 같은 SDK를 시작합니다. 주석 옵션, 샘플러 객체, 추가 계측기처럼 설정이 코드와 함께 있어야 한다면 init()을 쓰세요. 기존 이미지에 코드 변경 없이 SDK를 넣으려면 opentelemetry-instrument를 쓰세요. 한 프로세스에서 둘을 함께 쓰지 마세요.

gunicorn에서 opentelemetry-instrument는 --preload 여부와 상관없이 워커를 fork하기 전에 마스터 프로세스에서 SDK를 시작합니다. 배치 프로세서는 각 워커에서 전송 스레드를 다시 시작하며, flush는 여전히 worker_exit에서 합니다.

# gunicorn.conf.py
def worker_exit(server, worker):
    from sophonz.opentelemetry import shutdown
 
    shutdown()

환경

샘플 서버의 시크릿이 아닌 설정입니다.

OTEL_SERVICE_NAME=checkout-api
OTEL_EXPORTER_OTLP_ENDPOINT=https://in.sophonz.ai
OTEL_PROPAGATORS=tracecontext,baggage
OTEL_RESOURCE_ATTRIBUTES=service.namespace=shop
OTEL_METRICS_EXPORTER=none
SOPHONZ_STARTUP_LOGS=false
변수이유
OTEL_SERVICE_NAME없으면 스팬이 unknown_service:python으로 도착하고 SDK가 경고를 남깁니다.
OTEL_EXPORTER_OTLP_ENDPOINT이미 기본값이지만 값이 보이도록 명시하거나, 자체 컬렉터를 가리키게 합니다.
OTEL_PROPAGATORS이미 기본값이지만, 누군가 tracecontext로 줄여 session.id를 잃지 않도록 명시합니다(브라우저-백엔드 트레이싱 참고).
OTEL_RESOURCE_ATTRIBUTESservice.namespace는 브라우저 앱의 project와 같아야 합니다. SOPHONZ_SERVICE_NAMESPACE=shop도 같은 효과입니다.
OTEL_METRICS_EXPORTER=nonehttps://in.sophonz.ai는 /v1/traces와 /v1/logs를 받고, /v1/metrics는 404를 응답합니다. 설정하지 않으면 메트릭 익스포터가 60초마다 전송 실패를 기록합니다.
SOPHONZ_STARTUP_LOGS=false배포가 정상임을 확인한 뒤 시작 요약을 끕니다. 첫 배포에서는 켜 두세요.

환경과 릴리스로 텔레메트리를 구분한다면 SOPHONZ_DEPLOYMENT_ENVIRONMENT=production(deployment.environment.name)과 OTEL_SERVICE_VERSION을 추가하세요.

API 키

SOPHONZ_API_KEY는 등록한 앱 키(sk_...)입니다. authorization 헤더와 service.key 리소스 속성으로 전송되며, 컬렉터는 service.key로 텔레메트리가 어느 앱의 것인지 판단합니다.

다음 두 경우는 겉보기에 정상인 파드처럼 보입니다.

  • 키가 없음. SDK가 OpenTelemetry SDK initialization skipped를 한 번 남기고, 애플리케이션은 텔레메트리 없이 실행됩니다.
  • 컬렉터가 모르는 키. 전송은 성공하지만 컬렉터가 데이터를 버립니다.

키는 시크릿에 두고, 시크릿이 없으면 파드가 시작되지 않도록 필수로 지정하고, 첫 배포 후 Sophonz에서 서비스가 보이는지 확인하세요.

컨테이너 이미지

FROM python:3.13-slim
 
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1
 
RUN groupadd --system --gid 1001 app \
 && useradd --system --uid 1001 --gid app --no-create-home \
      --home-dir /nonexistent --shell /usr/sbin/nologin app
 
COPY requirements.txt /tmp/requirements.txt
RUN python -m venv /opt/venv \
 && /opt/venv/bin/pip install --requirement /tmp/requirements.txt \
 && rm /tmp/requirements.txt
 
WORKDIR /app
COPY gunicorn.conf.py ./
COPY myservice/ ./myservice/
# 빌드 시점에 바이트 컴파일합니다: 런타임에는 루트 파일시스템이 읽기 전용입니다.
RUN python -m compileall -q /app
 
ENV PATH=/opt/venv/bin:$PATH
USER 1001:1001
EXPOSE 8000
 
CMD ["gunicorn", "--config", "/app/gunicorn.conf.py", "myservice.wsgi:app"]
# requirements.txt
sophonz-opentelemetry[flask,psycopg]==0.1.0
flask
gunicorn
psycopg[binary,pool]

읽기 전용 루트 파일시스템에서는 빈 볼륨으로 마운트한 /tmp만 쓸 수 있습니다. 이를 위해 gunicorn에 두 설정이 필요합니다.

# gunicorn.conf.py
worker_tmp_dir = "/tmp"          # 워커 하트비트 파일
control_socket_disable = True    # 최신 gunicorn은 $HOME 아래에 제어 소켓을 만듭니다

FastAPI는 명령을 바꿉니다.

CMD ["uvicorn", "myservice.main:app", "--host", "0.0.0.0", "--port", "8000", \
     "--loop", "asyncio", "--proxy-headers", "--forwarded-allow-ips", "*", \
     "--timeout-graceful-shutdown", "20"]

Django는 ENV DJANGO_SETTINGS_MODULE=mysite.settings를 설정하고 gunicorn --config /app/gunicorn.conf.py mysite.wsgi로 실행합니다.

Kubernetes

일반 설정은 ConfigMap에, 키는 Secret에 둡니다.

kubectl create secret generic sophonz-service-key \
  --from-literal=SOPHONZ_API_KEY='sk_...'
apiVersion: v1
kind: ConfigMap
metadata:
  name: checkout-api-config
data:
  PORT: "8000"
  WEB_CONCURRENCY: "2"
  OTEL_SERVICE_NAME: checkout-api
  OTEL_EXPORTER_OTLP_ENDPOINT: https://in.sophonz.ai
  OTEL_PROPAGATORS: tracecontext,baggage
  OTEL_RESOURCE_ATTRIBUTES: service.namespace=shop
  OTEL_METRICS_EXPORTER: none
  SOPHONZ_STARTUP_LOGS: "false"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: checkout-api
  template:
    metadata:
      labels:
        app: checkout-api
    spec:
      automountServiceAccountToken: false
      # gunicorn graceful_timeout(20초)에 SDK flush(최대 5초)를 더한 값.
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: checkout-api
          image: registry.example.com/checkout-api:1.0.0
          ports:
            - name: http
              containerPort: 8000
          envFrom:
            - configMapRef:
                name: checkout-api-config
          env:
            # 일부러 필수로 둡니다: 키가 없으면 SDK는 경고만 남기고,
            # 파드는 텔레메트리 없이 실행됩니다.
            - name: SOPHONZ_API_KEY
              valueFrom:
                secretKeyRef:
                  name: sophonz-service-key
                  key: SOPHONZ_API_KEY
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: postgres-credentials
                  key: DATABASE_URL
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          readinessProbe:
            httpGet:
              path: /ready
              port: http
          livenessProbe:
            httpGet:
              path: /health
              port: http
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
  • SDK에는 쓰기 가능한 경로, 추가 권한(capability), 서비스 계정 토큰이 필요 없습니다.
  • 데이터베이스를 확인하는 readiness 프로브와 확인하지 않는 liveness 프로브를 두면, 데이터베이스를 잃은 파드는 재시작되지 않고 트래픽에서만 빠집니다.
  • 프로브 요청도 다른 요청처럼 서버 스팬을 만듭니다. 제외하려면 ConfigMap에 OTEL_PYTHON_EXCLUDED_URLS: health,ready를 추가하세요. Flask, FastAPI, Django 계측은 이 패턴 중 하나와 일치하는 URL을 건너뜁니다.
  • SDK는 HTTPS로 in.sophonz.ai에 전송합니다. NetworkPolicy로 egress를 제한한다면 이를 허용하세요.

정상 종료

Kubernetes는 파드를 멈출 때 SIGTERM을 보내고 terminationGracePeriodSeconds만큼 기다린 뒤 SIGKILL을 보냅니다. SIGKILL 시점에 버퍼에 남은 텔레메트리는 사라지므로, 전체 과정이 유예 시간 안에 끝나야 합니다.

gunicorn(Flask, Django). 각 워커가 SIGTERM을 받습니다. SDK 핸들러가 flush하고(최대 6초) 시그널을 gunicorn에 넘기면, gunicorn은 새 연결을 받지 않고 진행 중인 요청을 graceful_timeout까지 기다립니다. 이어서 worker_exit가 shutdown()을 호출해 그 요청들이 기록한 것을 내보냅니다.

uvicorn(FastAPI). SDK 핸들러가 flush하고 uvicorn의 정상 종료로 넘기면, uvicorn은 --timeout-graceful-shutdown까지 요청을 마무리하고 lifespan 종료를 실행합니다. SophonzASGIMiddleware는 lifespan 종료가 완료되면 uvicorn이 프로세스를 끝내기 전에 flush합니다.

유예 시간을 graceful timeout + 10초로 잡으면 두 flush가 들어갈 여유가 있습니다. 위 기본값이라면 20 + 10 = 30초입니다.

배치는 프로세스가 실행되는 동안에도 주기적으로 전송됩니다. 스팬은 기본 5초마다(OTEL_BSP_SCHEDULE_DELAY), 로그는 자체 주기(OTEL_BLRP_SCHEDULE_DELAY)로 전송되므로, flush 없이 강제 종료된 프로세스도 잃는 것은 마지막 몇 초뿐입니다.

프로세스 관리자가 이미 시그널을 처리하고 shutdown()을 직접 호출하고 싶다면 stop_on_termination_signals=False(또는 SOPHONZ_PYTHON_STOP_ON_TERMINATION_SIGNALS=false)를 전달하세요.

배포 확인하기

  1. 시작. 시작 로그를 켜 두면 프로세스마다 [Sophonz] Service name is configured to be "checkout-api", [Sophonz] Sending traces to "https://in.sophonz.ai/v1/traces", [Sophonz] OpenTelemetry SDK initialized successfully 같은 줄을 출력합니다. gunicorn에서는 워커마다 한 벌씩 나옵니다.
  2. 건너뜀. OpenTelemetry SDK initialization skipped가 들어간 줄은 키가 프로세스에 전달되지 않았다는 뜻입니다.
  3. 페이로드. 파드 하나에 SOPHONZ_DEBUG_PAYLOAD=true를 주면 전송하는 모든 스팬과 로그 레코드가 stdout에 출력되어, 프로세스에서 무엇이 나가는지 정확히 볼 수 있습니다.
  4. 도착. 요청을 보내고 Sophonz에서 서비스를 찾으세요. 스팬이 출력되는데도 도착하지 않는다면 키가 등록된 앱 키인지 확인하세요.

다음 단계

  • 설정 — 위에서 쓴 모든 변수.
  • 예제 — 이미지를 빌드할 전체 서버 예제.

TIP — 이제 시작입니다

이제 앱을 배포하세요. Sophonz가 실사용자가 겪는 문제를 포착하고, 당신의 앱은 스스로 진화합니다.