Observability as Code: 분산 시스템을 위한 종합적인 Telemetry 파이프라인 구축 방법 사례

Observability 파이프라인을 코드로 배포하여 번거로운 수작업 없이도 시스템을 즉시 명확하게 파악할 수 있는 방법을 공유합니다.

Observability as Code배포 방식을 혁신했던 Infrastructure as Code 원칙을 그대로 적용Telemetry 파이프라인을 정의하고, 배포하고, 관리하는 접근 방식입니다.

# Observability의 세 가지 핵심

본격적인 구현으로 들어가기 전에, 현대 Observability를 구성하는 요소를 간단히 짚어보겠습니다.

  • Metrics: 시간에 따라 시스템 동작을 수치로 나타내는 데이터 포인트
  • Logs: 애플리케이션 내에서 발생한 이벤트를 기록한 상세한 기록
  • Traces: 분산 서비스 전반에 걸친 요청 흐름에 대한 정보

# Observability 도구를 위한 Infrastructure as Code

우선 Terraform을 이용해 Observability 인프라를 배포하는 것부터 시작하겠습니다. 다음은 AWS EKS에 OpenTelemetry Collector를 설정하는 예시입니다.

resource "kubernetes_namespace" "observability" {
  metadata {
    name = "observability"
  }
}

resource "helm_release" "opentelemetry_collector" {
  name       = "opentelemetry-collector"
  repository = "https://open-telemetry.github.io/opentelemetry-helm-charts"
  chart      = "opentelemetry-collector"
  namespace  = kubernetes_namespace.observability.metadata[0].name
  
  values = [<<EOF
mode: deployment
config:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318
    
  processors:
    batch:
      timeout: 1s
      send_batch_size: 1024
    
  exporters:
    awsxray:
      region: "${var.aws_region}"
    awsemf:
      region: "${var.aws_region}"
      namespace: "EKSObservability"
    
  service:
    pipelines:
      traces:
        receivers: [otlp]
        processors: [batch]
        exporters: [awsxray]
      metrics:
        receivers: [otlp]
        processors: [batch]
        exporters: [awsemf]
EOF
  ]
  
  depends_on = [
    kubernetes_namespace.observability
  ]
}

Datadog 연동을 위해서는 다음과 같은 설정을 추가할 수 있습니다.

resource "helm_release" "datadog" {
  name       = "datadog"
  repository = "https://helm.datadoghq.com"
  chart      = "datadog"
  namespace  = kubernetes_namespace.observability.metadata[0].name
  
  set {
    name  = "datadog.apiKey"
    value = var.datadog_api_key
  }
  
  set {
    name  = "datadog.apm.enabled"
    value = "true"
  }
  
  set {
    name  = "datadog.logs.enabled"
    value = "true"
  }
  
  set {
    name  = "datadog.logs.containerCollectAll"
    value = "true"
  }
  
  set {
    name  = "datadog.otlp.receiver.protocols.grpc.endpoint"
    value = "0.0.0.0:4317"
  }
}

# Telemetry 파이프라인 아키텍처

잘 설계된 Telemetry 파이프라인은 다음과 같은 주요 구성 요소를 포함합니다.

  1. Collection: 다양한 소스로부터 Telemetry 데이터를 수집
  2. Processing: 데이터를 필터링, 변환, 보강
  3. Routing: 데이터를 적절한 저장 및 분석 시스템으로 전달
  4. Storage: 이후 분석을 위해 데이터 저장
  5. Visualization: 데이터를 접근 가능하고 활용 가능하게 시각화

OpenTelemetry는 이러한 파이프라인을 구축하기 위한 표준화된 접근을 제공합니다. 다음은 세 가지 Telemetry 유형을 모두 처리하는 OpenTelemetry Collector 설정 예시입니다.

apiVersion: opentelemetry.io/v1alpha1
kind: OpenTelemetryCollector
metadata:
  name: telemetry-pipeline
  namespace: observability
spec:
  mode: deployment
  config: |
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318
      
    processors:
      batch:
        timeout: 1s
        send_batch_size: 1024
      memory_limiter:
        check_interval: 1s
        limit_mib: 1000
      resourcedetection:
        detectors: [env, eks]
        timeout: 2s
      
    exporters:
      jaeger:
        endpoint: jaeger-collector.observability.svc.cluster.local:14250
        tls:
          insecure: true
      prometheus:
        endpoint: 0.0.0.0:8889
      logging:
        loglevel: info
      
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, batch, resourcedetection]
          exporters: [jaeger]
        metrics:
          receivers: [otlp]
          processors: [memory_limiter, batch, resourcedetection]
          exporters: [prometheus]
        logs:
          receivers: [otlp]
          processors: [memory_limiter, batch]
          exporters: [logging]

# 분산 트레이싱: Jaeger와 OpenTelemetry

분산 트레이싱은 마이크로서비스 전반의 요청 흐름을 이해하는 데 필수입니다. Jaeger를 OpenTelemetry와 함께 설정하는 방법은 다음과 같습니다.

resource "helm_release" "jaeger" {
  name       = "jaeger"
  repository = "https://jaegertracing.github.io/helm-charts"
  chart      = "jaeger"
  namespace  = kubernetes_namespace.observability.metadata[0].name
  
  set {
    name  = "provisionDataStore.cassandra"
    value = "false"
  }
  
  set {
    name  = "storage.type"
    value = "elasticsearch"
  }
  
  set {
    name  = "storage.elasticsearch.host"
    value = "elasticsearch-master.observability.svc.cluster.local"
  }
  
  set {
    name  = "storage.elasticsearch.port"
    value = "9200"
  }
}

애플리케이션 계측은 Python 예제로도 가능합니다.

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

resource = Resource(attributes={
    SERVICE_NAME: "order-service"
})

tracer_provider = TracerProvider(resource=resource)
otlp_exporter = OTLPSpanExporter(endpoint="opentelemetry-collector.observability.svc.cluster.local:4317")
span_processor = BatchSpanProcessor(otlp_exporter)
tracer_provider.add_span_processor(span_processor)
trace.set_tracer_provider(tracer_provider)

tracer = trace.get_tracer(__name__)

def process_order(order_id):
    with tracer.start_as_current_span("process_order") as span:
        span.set_attribute("order_id", order_id)
        validate_order(order_id)
        
def validate_order(order_id):
    with tracer.start_as_current_span("validate_order") as span:
        span.set_attribute("validation_method", "full")
       # Validation logic here

# Metrics 및 Logging 통합

Metrics와 Logs를 통합적으로 다루기 위해서는 OpenTelemetry Collector를 활용하여 둘 다 수집하고 적절한 Backend로 전송할 수 있습니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-agent-conf
  namespace: observability
data:
  otel-agent-config.yaml: |
    receivers:
      filelog:
        include: [ /var/log/pods/*/*/*.log ]
        exclude: [ /var/log/pods/*/kube-proxy/*.log ]
        start_at: end
        include_file_path: true
        include_file_name: true
        operators:
          - type: regex_parser
            regex: '^(?P<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z) (?P<level>\w+) (?P<message>.*)'
            timestamp:
              parse_from: time
              layout: '%Y-%m-%dT%H:%M:%S.%LZ'
      
      prometheus:
        config:
          scrape_configs:
            - job_name: 'kubernetes-pods'
              kubernetes_sd_configs:
                - role: pod
              relabel_configs:
                - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
                  action: keep
                  regex: true
    
    processors:
      batch:
        timeout: 1s
    
    exporters:
      otlp:
        endpoint: opentelemetry-collector.observability.svc.cluster.local:4317
        tls:
          insecure: true
    
    service:
      pipelines:
        logs:
          receivers: [filelog]
          processors: [batch]
          exporters: [otlp]
        metrics:
          receivers: [prometheus]
          processors: [batch]
          exporters: [otlp]

# Kubernetes와 AWS 환경에서의 구현

Kubernetes 전용 모니터링을 위해서는 OpenTelemetry Operator를 사용할 수 있습니다.

apiVersion: opentelemetry.io/v1alpha1
kind: OpenTelemetryCollector
metadata:
  name: k8s-collector
  namespace: observability
spec:
  mode: daemonset
  config: |
    receivers:
      kubeletstats:
        collection_interval: 10s
        auth_type: "serviceAccount"
        endpoint: "${env:K8S_NODE_NAME}:10250"
        insecure_skip_verify: true
      
    processors:
      resourcedetection:
        detectors: [env, eks]
        timeout: 2s
      
    exporters:
      awsemf:
        region: "${env:AWS_REGION}"
        namespace: "EKSObservability"
      
    service:
      pipelines:
        metrics:
          receivers: [kubeletstats]
          processors: [resourcedetection]
          exporters: [awsemf]
  env:
    - name: K8S_NODE_NAME
      valueFrom:
        fieldRef:
          fieldPath: spec.nodeName
    - name: AWS_REGION
      value: "us-west-2"

AWS 통합을 위해서는 적절한 IAM 권한을 보장해야 합니다.

resource "aws_iam_role" "otel_collector_role" {
  name = "otel-collector-role"
  
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRoleWithWebIdentity"
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.eks.arn
        }
        Condition = {
          StringEquals = {
            "${aws_iam_openid_connect_provider.eks.url}:sub": "system:serviceaccount:observability:opentelemetry-collector"
          }
        }
      }
    ]
  })
}

resource "aws_iam_policy" "otel_collector_policy" {
  name        = "otel-collector-policy"
  description = "Policy for OpenTelemetry Collector"
  
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "logs:PutLogEvents",
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:DescribeLogStreams",
          "logs:DescribeLogGroups",
          "xray:PutTraceSegments",
          "xray:PutTelemetryRecords",
          "cloudwatch:PutMetricData"
        ]
        Effect   = "Allow"
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "otel_collector_attachment" {
  role       = aws_iam_role.otel_collector_role.name
  policy_arn = aws_iam_policy.otel_collector_policy.arn
}

# Datadog 통합

더 깊이 있는 Datadog 통합을 위해, 커스텀 Metrics와 APM을 구성할 수 있습니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: datadog-agent-config
  namespace: observability
data:
  datadog.yaml: |
    api_key: ${DATADOG_API_KEY}
    site: datadoghq.com
    logs_enabled: true
    apm_config:
      enabled: true
    otlp_config:
      receiver:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
    
    logs_config:
      container_collect_all: true
    
    process_config:
      enabled: true
    
    dogstatsd_mapper_profiles:
      - name: "custom_metrics"
        prefix: "app."
        mappings:
          - match: "app.request.duration"
            name: "request.duration"
            tags:
              service: "$1"
              endpoint: "$2"

그리고 애플리케이션 계측을 Datadog으로 수행하는 예시는 다음과 같습니다.

from ddtrace import tracer, config
from ddtrace.propagation.http import HTTPPropagator

# Configure Datadog tracer
config.service = "order-service"
config.env = "production"
config.version = "1.0.0"

# Use the tracer in your application
@tracer.wrap(service="order-service", resource="process_order")
def process_order(order_id):
    # Add custom tags
    tracer.current_span().set_tag("order_id", order_id)
    # Your order processing logic here
    validate_order(order_id)

@tracer.wrap(service="order-service", resource="validate_order")
def validate_order(order_id):
    tracer.current_span().set_tag("validation_method", "full")
    # Validation logic here

# 실제 구현 예시

마이크로서비스 애플리케이션을 위한 완전한 예시로 모든 구성을 연결해 보겠습니다.

  • 인프라 배포:
module "observability_stack" {
  source = "./modules/observability"
  
  eks_cluster_name    = var.eks_cluster_name
  aws_region          = var.aws_region
  datadog_api_key     = var.datadog_api_key
  enable_jaeger       = true
  enable_prometheus   = true
  enable_opentelemetry = true
}
  • 서비스 계측(Node.js 예시):
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');

// Configure the tracer
const provider = new NodeTracerProvider({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'payment-service',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: 'production',
  }),
});

const exporter = new OTLPTraceExporter({
  url: 'http://opentelemetry-collector.observability.svc.cluster.local:4318/v1/traces',
});

provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register();

// Register auto-instrumentations
registerInstrumentations({
  instrumentations: [
    new HttpInstrumentation(),
    new ExpressInstrumentation(),
  ],
});

// Your application code follows
const express = require('express');
const app = express();

app.get('/process-payment', (req, res) => {
  // Your payment processing logic
  res.send('Payment processed');
});

app.listen(3000, () => {
  console.log('Payment service listening on port 3000');
});
  • Kubernetes 배포:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: payment-service
  template:
    metadata:
      labels:
        app: payment-service
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8888"
    spec:
      containers:
      - name: payment-service
        image: payment-service:latest
        ports:
        - containerPort: 3000
        - containerPort: 8888
          name: metrics
        env:
        - name: OTEL_EXPORTER_OTLP_ENDPOINT
          value: "http://opentelemetry-collector.observability.svc.cluster.local:4318"
        - name: OTEL_SERVICE_NAME
          value: "payment-service"
        - name: OTEL_RESOURCE_ATTRIBUTES
          value: "service.namespace=payment,service.version=1.0.0"

# 결론

Observability를 코드로 구현하면, Infrastructure as Code가 배포 프로세스에 가져온 것과 동일한 이점—일관성, 반복 가능성, 확장성—을 Telemetry 파이프라인에도 적용할 수 있습니다.

[출처] Observability as Code: Building Comprehensive Telemetry Pipelines for Distributed Systems | by Mohamed ElEmam | Towards AWS