13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] K8s

  • [K8s] Pod Security Admission 1년 사용 후기 및 실수담

    [K8s] Pod Security Admission 1년 사용 후기 및 실수담

    K8s Pod Security Admission, 1년 사용 후기 및 실수담

    안녕하세요, 13년차 서버실 주인장입니다. 오늘은 Kubernetes(이하 K8s) 환경에서 보안을 한층 강화해주는 Pod Security Admission(PSA)에 대해 이야기해보려 합니다. 제가 운영하는 홈랩 환경에서 PSA를 도입한 지 어느덧 1년이 되었는데요, 처음에는 조금 헷갈렸지만 지금은 없어서는 안 될 기능이 되었답니다. 오늘은 1년 동안 PSA를 사용하면서 겪었던 시행착오와 함께, 어떻게 하면 좀 더 효율적으로 PSA를 활용할 수 있을지에 대한 경험을 솔직하게 공유해 드릴게요. 혹시 K8s 보안에 대해 고민하고 계셨다면, 오늘 제 이야기가 작게나마 도움이 되기를 바랍니다. 😊

    Kubernetes 클러스터의 전반적인 아키텍처와 Pod Security Admission이 어떤 위치에 있는지 보여주는 다이어그램

    K8s Pod Security Admission, 왜 필요할까요?

    K8s는 유연하고 강력한 컨테이너 오케스트레이션 도구이지만, 그만큼 보안 설정에 신경 써야 할 부분이 많습니다. 특히 Pod(파드)는 K8s의 가장 기본적인 배포 단위인데요, 이 Pod가 실행될 때 어떤 보안 정책을 따라야 하는지 명확하게 제어하지 않으면 예상치 못한 보안 취약점이 발생할 수 있습니다. 예를 들어, 특권(privileged) 컨테이너가 실수로 실행되거나, 호스트 파일 시스템에 접근하는 등의 위험한 상황이 생길 수 있죠.

    이런 문제를 해결하기 위해 K8s에서는 Pod Security Admission(PSA)이라는 기능을 제공합니다. 쉽게 말해, Pod를 생성하거나 업데이트할 때 미리 정의된 보안 기준에 부합하는지 자동으로 검사하고, 기준에 맞지 않으면 해당 Pod의 생성을 막아주는(Admission Controller) 기능이라고 생각하시면 됩니다. 💡

    Pod Security Admission, 어떻게 작동하나요?

    PSA는 K8s API 서버의 Admission Controller 중 하나로 작동합니다. Pod 생성 요청이 API 서버에 전달되면, PSA는 해당 Pod가 특정 Pod Security Standards(PSS)를 준수하는지 확인합니다. PSS에는 크게 세 가지 레벨이 있습니다:

    • Restricted (제한): 가장 엄격한 보안. Pod는 격리되고 제한된 권한만 가지며, 최신 보안 기능(예: Seccomp, AppArmor, SELinux)을 사용해야 합니다.
    • Baseline (기준): 최소한의 보안을 보장. 알려진 취약점을 악용하기 어려운 수준으로, 대부분의 워크로드에 권장됩니다.
    • Privileged (특권): 가장 낮은 수준의 보안. 컨테이너가 호스트 시스템에 대한 거의 모든 권한을 갖게 되므로, 매우 신중하게 사용해야 합니다.

    이 세 가지 레벨 외에도, 각 네임스페이스(Namespace)별로 Enforce, Audit, Warn 모드를 설정할 수 있습니다.

    • 1. Enforce (강제): 보안 정책을 위반하는 Pod의 생성을 차단합니다. 가장 강력한 모드죠.
    • 2. Audit (감사): 정책을 위반하더라도 Pod 생성을 차단하지는 않지만, 해당 이벤트를 로그로 기록합니다. 보안 감사에 유용합니다.
    • 3. Warn (경고): 정책을 위반하는 Pod에 대해 사용자에게 경고 메시지를 보냅니다. Pod 생성은 허용됩니다.

    실전! Pod Security Admission 설정하기 (제 경험담)

    제가 처음 PSA를 도입할 때 가장 헷갈렸던 부분이 바로 이 네임스페이스별 정책 설정이었습니다. 저희 홈랩에는 개발용, 테스트용, 운영용 등 다양한 환경의 네임스페이스가 있었기 때문에, 각기 다른 보안 정책을 적용하고 싶었거든요.

    K8s 클러스터 전체에 PSA를 활성화하는 것은 API 서버 설정에서 간단히 할 수 있습니다. 하지만 실제로는 각 네임스페이스별로 정책을 세밀하게 조정하는 것이 중요합니다. 저는 다음과 같은 방식으로 설정을 진행했습니다.

    1단계: 네임스페이스별 레이블 설정

    먼저, 각 네임스페이스에 어떤 보안 정책을 적용할지 명시하는 레이블을 붙여줍니다. 예를 들어, 보안이 중요한 운영 네임스페이스에는 pod-security.kubernetes.io/enforce=baseline과 같이 설정할 수 있습니다.

    kubectl label --overwrite ns production pod-security.kubernetes.io/enforce=baseline
    kubectl label --overwrite ns production pod-security.kubernetes.io/audit=baseline
    kubectl label --overwrite ns production pod-security.kubernetes.io/warn=baseline
    
    kubectl label --overwrite ns development pod-security.kubernetes.io/enforce=privileged
    kubectl label --overwrite ns development pod-security.kubernetes.io/audit=baseline
    kubectl label --overwrite ns development pod-security.kubernetes.io/warn=baseline
    
    네임스페이스별 Pod Security Admission 정책 설정 예시

    각 네임스페이스에 할당된 Pod Security Standards 레벨과 모드를 보여주는 예시 화면

    2단계: PSA 동작 확인 (Audit 모드 활용)

    처음부터 enforce 모드로 설정하면, 예상치 못한 Pod 배포 실패로 인해 서비스 장애가 발생할 수 있습니다. 그래서 저는 먼저 audit 모드를 활용하여 어떤 Pod들이 보안 정책을 위반하는지 파악하는 단계를 거쳤습니다. K8s API 서버 감시 로그를 주기적으로 확인하면서, 어떤 Pod가 어떤 이유로 위반되는지 분석했죠.

    💡 팁: kube-apiserver의 감시 로그(audit log)를 통해 PSA 관련 감사 정보를 확인할 수 있습니다. 로컬 클러스터에서는 kubectl describe nodes 또는 클러스터 설정에 따라 로그 파일에서 감시 로그를 조회할 수 있습니다.

    3단계: Enforce 모드로 전환 및 검증

    Audit 모드를 통해 문제를 파악하고 수정했다면, 이제 enforce 모드로 전환할 차례입니다. enforce 모드로 전환한 후, 중요한 워크로드부터 순차적으로 배포해보면서 문제가 없는지 꼼꼼하게 검증했습니다.

    kubectl label --overwrite ns production pod-security.kubernetes.io/enforce=baseline
    

    Pod Security Admission 정책을 위반했을 때 API 서버 로그에 기록되는 내용

    1년간 사용해보니… 삽질과 깨달음

    PSA를 1년 가까이 사용하면서 정말 많은 것을 배웠습니다. 처음에는 단순히 PSS 레벨을 설정하면 되는 줄 알았는데, 실제로는 Pod의 설정 하나하나가 PSS와 충돌할 수 있다는 것을 깨달았죠.

    가장 흔하게 겪었던 실수담은 바로 privileged: true 설정이었습니다. 저희 팀에서 사용하는 특정 사이드카(Sidecar) 컨테이너가 네트워킹 관련 기능을 위해 이 설정을 필요로 했는데, restricted 또는 baseline PSS에서는 당연히 막히더라고요. 처음에는 왜 Pod 생성이 안 되는지 한참을 헤맸습니다. 😂

    이런 문제를 해결하기 위해 다음과 같은 방법을 사용했습니다:

    • Pod Security Admission 이해하기: Pod Security Policies(PSP)는 K8s 1.25에서 deprecated 되었고 1.28에서 완전히 제거되었습니다. 대신 PSA로 전환하는 과정에서, PSA는 훨씬 간결하고 명확하게 정책을 적용할 수 있다는 걸 체감했습니다.
    • Pod Spec 검토 및 수정: 사이드카 컨테이너의 Pod Spec을 면밀히 검토하여, privileged: true 설정 없이도 동일한 기능을 구현할 수 있는지 확인했습니다. 결국, 필요한 권한만 최소한으로 부여하도록 수정하여 baseline PSS를 만족시킬 수 있었습니다.
    • Audit 모드를 적극 활용: 새로운 애플리케이션을 배포하거나 기존 설정을 변경할 때, 바로 enforce 모드로 전환하기보다는 audit 모드로 먼저 테스트하면서 잠재적인 충돌을 미리 파악하는 습관을 들였습니다.

    PSA는 K8s 클러스터의 보안 수준을 크게 향상시켜주는 강력한 기능입니다. 하지만 모든 Pod가 완벽하게 PSS를 준수하는 것은 아니므로, Service Account, RBAC(Role-Based Access Control)와 함께 종합적으로 고려하여 보안 전략을 수립하는 것이 중요합니다.

    Pod Security Admission과 Pod Security Policy의 주요 차이점 비교

    Pod Security Admission과 이전의 Pod Security Policy의 주요 차이점을 비교한 표

    결론: K8s 보안, PSA로 한 단계 업그레이드하세요!

    Pod Security Admission은 K8s 환경의 보안을 강화하는 데 필수적인 요소입니다. 1년간의 경험을 통해 PSA가 단순히 복잡한 설정이 아니라, 실질적인 보안 위협을 줄여주는 강력한 방어선이라는 것을 체감했습니다.

    물론 초기 설정 과정에서 시행착오를 겪을 수 있습니다. 하지만 audit 모드를 활용하고, Pod Spec을 꼼꼼히 검토하는 습관을 들인다면, 충분히 안정적으로 PSA를 운영할 수 있더라고요.

    혹시 아직 PSA를 도입하지 않으셨다면, 지금 바로 시작해보시는 것을 강력히 추천합니다! 먼저 개발 네임스페이스부터 baseline PSS로 설정하고 audit 모드로 운영해보면서 점차 확대해나가는 것을 권장합니다.

    다음 글에서는 K8s 네트워크 보안의 또 다른 핵심 요소인 Network Policy에 대해 다뤄볼 예정이니, 많은 기대 부탁드립니다! 😉

  • [Kubernetes] OpenTelemetry K8s 분산 추적: 13년차 삽질 & 활용 사례

    [Kubernetes] OpenTelemetry K8s 분산 추적: 13년차 삽질 & 활용 사례

    [Kubernetes] OpenTelemetry K8s 분산 추적: 13년차 삽질 & 활용 사례

    안녕하세요, 13년차의 서버실 주인장입니다. 오늘은 마이크로서비스 아키텍처(MSA)를 운영하시는 분들이라면 한 번쯤은 머리 싸매고 고민했을 주제, 바로 분산 추적(Distributed Tracing)에 대해 이야기해보려고 해요. 특히 Kubernetes(쿠버네티스) 환경에서 OpenTelemetry(오픈텔레메트리)를 활용해서 어떻게 이 복잡한 문제를 풀어갈 수 있었는지, 제가 직접 삽질했던 경험들을 솔직하게 공유해볼게요.

    요즘 애플리케이션들은 웬만하면 다 마이크로서비스 형태로 쪼개져 있잖아요? 서비스 간 호출이 꼬리에 꼬리를 물고 이어지다 보니, ‘도대체 이 에러가 어디서부터 시작된 거야?’ 하고 원인을 찾기 시작하면 정말 막막할 때가 많더라고요. 저도 처음엔 로그만 가지고 씨름했는데, 시간이 갈수록 이건 아니다 싶었어요. 그래서 자연스럽게 분산 추적에 관심을 가지게 되었고, 홈랩에서 이것저것 실험하다가 OpenTelemetry라는 보석 같은 녀석을 만나게 되었죠. 🎉

    이 글을 통해 여러분도 저처럼 서비스 트러블슈팅 시간을 확 줄이고, 시스템 전체를 한눈에 파악하는 데 큰 도움을 받으실 수 있을 거예요. 자, 그럼 함께 시작해볼까요?

    OpenTelemetry와 Kubernetes 환경에서 분산 추적이 어떻게 작동하는지 보여주는 전체 아키텍처 다이어그램입니다.

    OpenTelemetry, 분산 추적, 그리고 Observability: 개념 정리

    본격적인 설정에 들어가기 전에, 몇 가지 핵심 개념을 짚고 넘어가야겠죠? 제가 처음 이 분야를 접했을 때 가장 헷갈렸던 부분들이거든요.

    • Observability (옵저버빌리티, 관측 가능성): 시스템 내부 상태를 외부에서 추론할 수 있는 능력이에요. 단순히 ‘이상이 있다/없다’를 넘어 ‘왜 이상이 발생했는지’까지 파악할 수 있는 거죠. 보통 Logs(로그), Metrics(메트릭), Traces(추적) 세 가지 기둥으로 구성돼요.
    • Distributed Tracing (분산 추적): 마이크로서비스 아키텍처에서 사용자 요청이 여러 서비스를 거쳐 처리될 때, 그 전체 흐름을 따라가며 추적하는 기술입니다. 각 서비스 간 호출이 하나의 Trace(트레이스, 추적)로 묶이고, 그 안에서 각 작업 단위는 Span(스팬, 구간)으로 표현돼요. 쉽게 말해, 요청의 여권을 만들어서 각 서비스마다 도장을 찍고 다니게 하는 거라고 보면 돼요.
    • OpenTelemetry (오픈텔레메트리): CNCF(Cloud Native Computing Foundation)에서 주도하는 오픈소스 프로젝트예요. 벤더에 종속되지 않고, 모든 Observability 데이터를 수집하고 내보내는 표준화된 방법을 제공합니다. 애플리케이션 코드에 SDK(Software Development Kit)를 넣어 계측(Instrumentation)하고, OpenTelemetry Collector(컬렉터)를 통해 데이터를 수집하고 원하는 백엔드(Jaeger, Prometheus, Zipkin 등)로 전송할 수 있어요.

    처음엔 이 용어들이 다 비슷비슷하게 느껴졌는데, 결국 OpenTelemetry는 Observability를 구현하기 위한 도구고, 그중 분산 추적은 특히 마이크로서비스 환경에서 빛을 발하는 핵심 기능이라고 이해하면 편해요.

    실전 구현: K8s 환경에서 OpenTelemetry Collector 설정하기

    자, 이제 직접 Kubernetes 클러스터에 OpenTelemetry를 설정해볼 시간입니다. 저는 주로 Helm(헬름)을 사용해서 배포하는 편인데, 이게 관리하기도 편하고 설정도 직관적이더라고요.

    1. OpenTelemetry Collector 배포 전략 선택

    OpenTelemetry Collector는 데이터를 수집하고 처리해서 백엔드로 보내는 역할을 합니다. K8s 환경에서는 보통 두 가지 방식으로 배포할 수 있어요.

    1. Agent (DaemonSet) 모드: 각 노드에 하나씩 Collector를 배포해서, 해당 노드에서 실행되는 애플리케이션들의 트레이스/메트릭을 수집합니다. 노드별로 리소스를 분리할 수 있고 안정적이거든요. 저는 주로 이 방식을 선호해요.
    2. Gateway (Deployment) 모드: 클러스터 내부에 중앙 Collector를 배포해서 모든 트래픽을 한곳으로 모읍니다. 관리는 편하지만, 부하가 한곳에 집중될 수 있고 네트워크 경로가 복잡해질 수 있죠.

    이 글에서는 DaemonSet 모드로 Agent를 배포하는 방법을 기준으로 설명드릴게요. 각 노드에서 효율적으로 데이터를 수집하는 데 유리하거든요.

    2. Helm으로 OpenTelemetry Collector 배포

    먼저 OpenTelemetry Helm Chart를 추가하고 업데이트합니다.

    
    helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
    helm repo update
    

    다음으로, values.yaml 파일을 만들어서 Collector 설정을 커스터마이징해요. 여기서는 Jaeger(예거) 백엔드로 트레이스를 내보내는 설정을 해볼 거고, Jaeger는 분산 추적 데이터를 시각화하는 데 정말 유용한 도구예요.

    
    # otel-collector-values.yaml
    agent:
      mode: "daemonset"
      config:
        receivers:
          otlp:
            protocols:
              grpc:
              http:
        processors:
          batch:
        exporters:
          jaeger:
            endpoint: "jaeger-collector.monitoring.svc.cluster.local:14250" # Jaeger Collector 서비스 주소
            tls:
              insecure: true # 실제 프로덕션 환경에서는 TLS 설정 필요
        service:
          pipelines:
            traces:
              receivers: [otlp]
              processors: [batch]
              exporters: [jaeger]
    
    # Jaeger도 함께 배포한다고 가정합니다. (별도 Helm Chart 사용)
    # jaeger:
    #   agent:
    #     strategy: "daemonset"
    #   collector:
    #     ingress:
    #       enabled: true
    #       hosts:
    #         - jaeger.mydomain.com
    

    위 values.yaml 파일로 Helm을 이용해 Collector를 배포해요.

    
    helm install otel-collector open-telemetry/opentelemetry-collector -f otel-collector-values.yaml -n monitoring --create-namespace
    

    -n monitoring은 monitoring 네임스페이스에 배포하겠다는 의미예요. --create-namespace로 없으면 새로 만들어줍니다. 이 과정이 끝나면 각 노드에 OpenTelemetry Collector Agent가 실행될 거고요.

    Kubernetes 클러스터 내 OpenTelemetry Collector DaemonSet 배포 구성도

    Kubernetes 클러스터 내에서 OpenTelemetry Collector Agent가 DaemonSet으로 배포되어 각 노드의 애플리케이션 트레이스를 수집하는 구성도입니다.

    3. 애플리케이션 계측 (Instrumentation)

    Collector가 준비되었으니, 이제 애플리케이션에서 트레이스를 생성해서 Collector로 보내야 해요. OpenTelemetry는 다양한 언어별 SDK를 제공하거든요. 예를 들어 Python 애플리케이션이라면 다음과 같이 계측할 수 있어요.

    
    from opentelemetry import trace
    from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
    from opentelemetry.sdk.resources import Resource
    from opentelemetry.sdk.trace import TracerProvider
    from opentelemetry.sdk.trace.export import SimpleSpanProcessor
    
    # 리소스 설정 (서비스 이름 등)
    resource = Resource.create({"service.name": "my-python-service"})
    
    # TracerProvider 생성
    tracer_provider = TracerProvider(resource=resource)
    trace.set_tracer_provider(tracer_provider)
    
    # OTLP 익스포터 설정 (Collector Agent의 주소)
    # Kubernetes 내부에서 서비스 이름으로 접근 가능합니다.
    # otel-collector는 'otel-collector-agent' 서비스로 노출됩니다.
    exporter = OTLPSpanExporter(endpoint="otel-collector-agent.monitoring.svc.cluster.local:4317")
    
    # SpanProcessor 등록
    tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
    
    # Tracer 가져오기
    tracer = trace.get_tracer(__name__)
    
    # 트레이스 생성 예시
    with tracer.start_as_current_span("my-operation") as span:
        span.set_attribute("http.method", "GET")
        span.set_attribute("http.route", "/data")
        # ... 여기에 실제 비즈니스 로직
        print("Doing some work...")
        with tracer.start_as_current_span("sub-operation"):
            print("Doing sub-work...")
    
    print("Trace sent!")
    

    otel-collector-agent.monitoring.svc.cluster.local:4317이 여기서 핵심인데요, 이는 Kubernetes 서비스 디스커버리를 통해 monitoring 네임스페이스의 otel-collector-agent 서비스(OpenTelemetry Collector Agent가 노출하는 gRPC 포트)로 트레이스를 보낸다는 의미예요.

    ⚠️ 삽질 & 트러블슈팅 경험

    제가 이 과정을 진행하면서 겪었던 몇 가지 삽질과 해결책을 공유해 드릴게요. 여러분은 저처럼 헤매지 마시길!

    1. Collector Agent 포드 상태 불량: kubectl get pods -n monitoring으로 확인했을 때 Collector Agent 포드가 CrashLoopBackOff 상태인 경우가 있었어요. kubectl logs <pod-name> -n monitoring으로 로그를 확인해보니, 설정 파일에 오타가 있거나 리소스 부족으로 인해 시작되지 못하는 경우가 많더라고요. 특히 config: 아래 들여쓰기(indentation) 오류가 잦았습니다.

    2. 네트워크 연결 문제: 애플리케이션에서 Collector로, Collector에서 Jaeger로 트레이스가 전송되지 않는 문제가 있었어요. 이럴 땐 다음을 확인해보세요.

      • Kubernetes Service Name: 애플리케이션에서 Collector로 트레이스를 보낼 때, otel-collector-agent.monitoring.svc.cluster.local:4317 주소가 올바른지 확인해야 합니다. 서비스 이름이 잘못되었거나 네임스페이스가 다르면 연결이 안 되죠.
      • Port: OTLP gRPC 기본 포트인 4317이 맞는지, 또는 OTLP HTTP 기본 포트인 4318이 맞는지 확인하세요.
      • Network Policy: 혹시 Kubernetes Network Policy(네트워크 정책)가 설정되어 있다면, 애플리케이션 파드에서 Collector 파드로의 4317/4318 포트 통신이 허용되어 있는지 확인해야 해요. 저도 이 정책 때문에 한참을 헤맸거든요.
      • Firewall: 클러스터 외부와 통신하는 경우, 방화벽 규칙도 확인해야 합니다.
    3. Jaeger UI에서 트레이스 안 보임: Collector는 잘 동작하는데 Jaeger UI에서 아무것도 안 보이는 경우가 있었어요. 이건 주로 Collector의 exporters 설정 문제이거나 Jaeger Collector 서비스 주소가 잘못된 경우였거든요. Jaeger Collector의 포트(gRPC는 14250, HTTP는 14268)가 맞는지, 서비스 이름이 정확한지 꼼꼼히 확인해야 해요. 💡

    이런 문제들을 해결할 때 가장 중요한 건 로그(Logs)를 꼼꼼히 확인하고, kubectl describe pod 명령어로 파드의 이벤트와 환경 변수를 살펴보는 거예요. 그리고 curl이나 telnet 같은 간단한 도구로 포트 연결 테스트를 해보는 것도 큰 도움이 됩니다.

    OpenTelemetry Collector 트러블슈팅 과정에서 로그를 분석하는 엔지니어의 모습

    OpenTelemetry Collector 설정 중 문제가 발생하여 로그와 Kubernetes 이벤트를 분석하며 트러블슈팅하는 인프라 엔지니어의 모습입니다.

    결과 확인 및 활용 사례

    모든 설정이 완료되고 애플리케이션에서 트레이스를 보내기 시작하면, 이제 Jaeger UI 같은 백엔드에서 멋진 시각화된 트레이스를 확인할 수 있어요. 저도 처음 성공했을 때 ‘드디어 됐다!’ 하고 외쳤던 기억이 나네요. 🎉

    Jaeger UI에 접속해서 서비스 이름을 선택하고 검색하면, 아래와 같이 요청의 전체 흐름을 시각적으로 볼 수 있습니다. 각 스팬이 어떤 서비스에서 얼마나 시간을 소모했는지, 어떤 에러가 발생했는지 한눈에 파악할 수 있어요.

    • 성능 병목 지점 파악: 특정 서비스나 함수 호출에서 유독 시간이 오래 걸린다면, 그 부분이 성능 병목 지점일 가능성이 높아요. 트레이스를 통해 정확히 어디서 시간이 지연되는지 찾아낼 수 있죠.
    • 에러 원인 분석: 에러가 발생한 스팬을 클릭하면 관련 로그나 태그(tags) 정보를 확인하여 문제의 근본 원인을 빠르게 파악할 수 있어요. 수많은 로그를 뒤지는 것보다 훨씬 효율적이죠.
    • 서비스 의존성 파악: 복잡한 마이크로서비스 간의 호출 관계를 시각적으로 이해하는 데 큰 도움이 돼요. 새로운 팀원이 합류했을 때 시스템 구조를 설명하는 자료로도 활용할 수 있거든요.
    Jaeger UI에서 분산 추적 데이터가 성공적으로 시각화된 대시보드 스크린샷

    Jaeger UI에서 애플리케이션의 분산 추적 데이터가 성공적으로 수집되어 시각화된 대시보드 스크린샷입니다. 서비스 간 호출 흐름과 각 스팬의 지연 시간을 보여줍니다.

    마무리하며: OpenTelemetry, 선택이 아닌 필수

    OpenTelemetry를 Kubernetes 환경에서 설정하고 활용하는 과정이 처음엔 다소 복잡하게 느껴질 수 있어요. 하지만 한 번 구축하고 나면 얻을 수 있는 이점은 정말 상상 이상이라고 생각합니다. 특히 복잡한 마이크로서비스 아키텍처에서는 분산 추적이 선택이 아닌 필수가 되고 있거든요.

    저도 13년 동안 수많은 인프라 환경을 운영하면서, 이렇게 시스템의 속을 들여다볼 수 있는 도구의 중요성을 절실히 느꼈어요. OpenTelemetry는 단순히 에러를 찾는 것을 넘어, 시스템의 건강 상태를 지속적으로 모니터링하고 최적화하는 데 큰 인사이트를 제공해줍니다.

    다음 글에서는 OpenTelemetry를 활용해서 메트릭(Metrics)과 로그(Logs)를 수집하고 시각화하는 방법에 대해 좀 더 깊이 있게 다뤄볼 예정이에요. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요!

    OpenTelemetry가 제공하는 Observability의 세 가지 핵심 기둥인 Logs, Metrics, Traces를 시각적으로 요약한 인포그래픽입니다.

  • [k8s] 쿠버네티스 스토리지: CSI 드라이버를 활용한 영구 스토리지 관리 및 최적화

    [k8s] 쿠버네티스 스토리지: CSI 드라이버를 활용한 영구 스토리지 관리 및 최적화

    안녕하세요, 13년차의 서버실입니다!

    저는 인프라 엔지니어로 일하면서 수많은 서버실을 드나들었고, 홈랩에서도 다양한 기술을 직접 실험해보고 있거든요. 특히 쿠버네티스(Kubernetes)를 운영하면서 가장 머리 아팠던 부분 중 하나가 바로 스토리지(Storage) 문제였습니다.

    컨테이너는 Stateless(무상태)여야 한다지만, 실제 애플리케이션은 데이터가 필요하잖아요? DB나 파일 서버 같은 것들 말이죠. 컨테이너가 죽거나 재시작하면 데이터가 홀라당 날아가 버리는 경험… 혹시 해보셨나요? 제가 그랬거든요, 처음엔. 😭

    그래서 오늘은 쿠버네티스 환경에서 데이터를 안전하게 보관하고 관리하는 핵심 기술인 CSI (Container Storage Interface) 드라이버를 활용한 영구 스토리지(Persistent Storage) 관리 및 최적화 방법에 대해 제 경험을 바탕으로 솔직하게 이야기해보려고 합니다. 💡

    쿠버네티스에서 스토리지가 어떻게 연결되는지 전반적인 아키텍처를 보여주는 다이어그램입니다. CSI 드라이버가 핵심 역할을 하는 것을 볼 수 있습니다.

    1. 쿠버네티스 영구 스토리지, 왜 필요하고 어떻게 작동하나요?

    쿠버네티스에서 애플리케이션이 데이터를 영구적으로 저장하려면 몇 가지 핵심 개념을 알아야 합니다. 쉽게 말해, 컨테이너는 휘발성(Ephemeral)이라서 데이터를 저장해도 컨테이너가 사라지면 데이터도 같이 사라져요. 그래서 컨테이너 외부에 데이터를 안전하게 보관할 수 있는 공간이 필요한데, 이걸 영구 스토리지(Persistent Storage)라고 부릅니다.

    PersistentVolume (PV, 영구 볼륨): 물리적인 스토리지 자원

    PV는 실제 물리적인 스토리지 공간, 예를 들면 네트워크 파일 시스템(NFS)의 특정 디렉터리나 클라우드 제공자의 디스크(AWS EBS, Google Persistent Disk 등)를 추상화한 겁니다. 클러스터 관리자가 미리 정의해두죠. 마치 서버실의 빈 하드디스크 같은 개념이랄까요? PV는 특정 스토리지 솔루션과 연결되어 실제 데이터를 저장하는 역할을 합니다.

    PersistentVolumeClaim (PVC, 영구 볼륨 요청): 애플리케이션의 스토리지 요구

    PVC는 Pod(파드)가 사용할 스토리지의 “요구 사항”을 선언하는 겁니다. “나는 10GiB(기가바이트) 용량의 ReadWriteOnce(한 번에 한 Pod만 쓰기 가능) 모드의 스토리지가 필요해!” 하고 요청하는 거죠. 사용자가 빈 하드디스크를 “내 거”라고 찜하는 것과 비슷합니다. Pod는 PV에 직접 접근하는 대신 PVC를 통해 스토리지를 요청하고 사용합니다.

    StorageClass (스토리지 클래스): 스토리지 프로비저닝 자동화

    여기서부터 좀 더 편리해집니다. StorageClass는 스토리지의 “종류”를 정의하는 템플릿이라고 보시면 돼요. 예를 들어, “빠른 SSD 스토리지”, “저렴한 HDD 스토리지”, “백업용 스토리지” 같은 거죠. StorageClass를 사용하면 PVC가 요청할 때 PV를 자동으로 생성(Dynamic Provisioning)해줍니다. 제가 처음엔 PV를 일일이 만들었었는데, StorageClass 덕분에 삽질을 훨씬 줄일 수 있었어요. 이거 진짜 편하더라고요! ✅

    StorageClass는 어떤 CSI 드라이버를 사용할지, 어떤 볼륨 타입(SSD/HDD), 회수 정책(reclaim policy) 등을 정의합니다.

    CSI (Container Storage Interface) 드라이버: 쿠버네티스와 스토리지 연결 고리

    자, 오늘의 주인공입니다! CSI 드라이버는 쿠버네티스가 다양한 외부 스토리지 시스템(NFS, Ceph, AWS EBS, OpenEBS 등)과 통신할 수 있도록 표준화된 인터페이스를 제공합니다. 스토리지 벤더들은 이 CSI 표준에 맞춰 드라이버를 개발하고, 쿠버네티스는 이 드라이버를 통해 어떤 스토리지든 일관된 방식으로 사용할 수 있게 되는 거죠. 마치 USB 포트에 어떤 장치를 꽂아도 표준 드라이버 덕분에 작동하는 것과 비슷하다고 보시면 됩니다. 덕분에 쿠버네티스가 특정 스토리지 솔루션에 종속되지 않고 유연하게 확장될 수 있더라고요. 💡

    2. CSI 드라이버를 활용한 영구 스토리지 설정하기 (NFS CSI 드라이버 예시)

    제가 홈랩에서 가장 많이 쓰는 방식 중 하나가 바로 NFS (Network File System)를 이용한 영구 스토리지입니다. 간편하고 설정하기도 쉬워서 처음 시작하는 분들께도 추천드려요. 여기서는 NFS CSI 드라이버를 예시로 들어볼게요.

    (⚠️ 사전에 NFS 서버가 구성되어 있고, 쿠버네티스 클러스터에서 접근 가능해야 합니다.)

    2.1. NFS CSI 드라이버 배포

    먼저 NFS CSI 드라이버를 쿠버네티스 클러스터에 배포해야 합니다. 보통 Helm 차트나 manifests 파일을 통해 배포하죠. 저는 안정적인 버전의 Helm 차트를 선호하는 편입니다.

    저는 보통 프로젝트 공식 저장소에서 제공하는 Helm 차트 가이드를 따라 설치합니다. Kubernetes-CSI 프로젝트의 NFS CSI 드라이버를 설치하는 방법을 보여드릴게요.

    
    helm repo add csi-driver-nfs https://kubernetes-csi.github.io/csi-driver-nfs
    helm repo update
    helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs --namespace kube-system
    

    배포가 완료되면, 다음과 같이 CSI 드라이버 관련 Pod들이 잘 올라왔는지 확인해볼 수 있습니다.

    
    kubectl get pods -n kube-system -l app.kubernetes.io/name=csi-driver-nfs
    

    2.2. StorageClass 생성

    이제 이 CSI 드라이버를 사용할 StorageClass를 정의합니다. NFS 서버의 주소와 공유할 경로를 지정해줘야 해요.

    
    # nfs-storageclass.yaml
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: nfs-csi-storage
    provisioner: nfs.csi.k8s.io # CSI 드라이버의 이름
    parameters:
      server: 192.168.1.100 # 여러분의 NFS 서버 IP 주소로 변경하세요!
      share: /mnt/nfs_share # NFS 서버의 공유 경로로 변경하세요!
    reclaimPolicy: Delete # Pod 삭제 시 PV도 함께 삭제 (Retain으로 하면 수동 삭제 필요)
    volumeBindingMode: Immediate
    mountOptions:
      - hard
      - nfsvers=4.1
    

    이 파일을 적용하면 <code>nfs-csi-storage라는 이름의 StorageClass가 생성됩니다.

    
    kubectl apply -f nfs-storageclass.yaml
    

    StorageClass를 정의하는 YAML 파일의 예시입니다. NFS CSI 드라이버를 이용해 스토리지 클래스를 생성하는 과정을 보여줍니다.

    2.3. PersistentVolumeClaim (PVC) 생성

    이제 애플리케이션이 스토리지 1GiB를 요청할 PVC를 만들어봅시다.

    
    # my-pvc.yaml
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: my-nfs-pvc
    spec:
      accessModes:
        - ReadWriteOnce # 한 Pod만 읽기/쓰기 가능
      storageClassName: nfs-csi-storage # 위에서 생성한 StorageClass 이름 지정
      resources:
        requests:
          storage: 1Gi # 1 기가바이트 스토리지 요청
    

    적용하면 자동으로 PV가 프로비저닝됩니다. 정말 편해졌죠?

    
    kubectl apply -f my-pvc.yaml
    kubectl get pvc my-nfs-pvc
    kubectl get pv
    

    PVC가 Bound 상태가 되고, 그에 맞는 PV가 생성된 것을 확인할 수 있을 겁니다. 🎉

    2.4. Pod에서 PVC 사용

    마지막으로, 이 PVC를 사용할 Pod를 생성합니다. 간단한 Nginx Pod를 예시로 들어볼게요.

    
    # nginx-pod-with-pvc.yaml
    apiVersion: v1
    kind: Pod
    metadata:
      name: nginx-with-nfs
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
          volumeMounts:
            - name: nfs-storage
              mountPath: /usr/share/nginx/html # Nginx의 웹 루트에 마운트
      volumes:
        - name: nfs-storage
          persistentVolumeClaim:
            claimName: my-nfs-pvc # 위에서 생성한 PVC 이름 지정
    

    이제 이 Pod를 배포하고, /usr/share/nginx/html 경로에 파일을 생성해보세요. Pod가 재시작되어도 데이터가 유지되는 것을 확인할 수 있을 겁니다!

    
    kubectl apply -f nginx-pod-with-pvc.yaml
    kubectl exec -it nginx-with-nfs -- bash
    echo "Hello from NFS CSI!" > /usr/share/nginx/html/index.html
    exit
    # Pod를 삭제했다가 다시 만들어도 데이터가 유지되는지 확인해보세요.
    kubectl delete pod nginx-with-nfs
    kubectl apply -f nginx-pod-with-pvc.yaml
    kubectl exec -it nginx-with-nfs -- cat /usr/share/nginx/html/index.html
    

    정상적으로 “Hello from NFS CSI!”라는 문구가 보인다면 성공입니다! 드디어 됐다! 🎉

    3. 삽질 경험담: CSI 드라이버 사용 시 주의사항과 트러블슈팅

    제가 이 과정을 거치면서 몇 번이고 머리를 쥐어뜯었던 경험이 있습니다. 여러분은 저 같은 삽질을 하지 마시라고 몇 가지 팁을 드릴게요.

    3.1. ⚠️ Access Modes (접근 모드) 이해하기

    PVC를 만들 때 accessModes를 지정하는데, 이게 꽤 중요합니다.

    • ReadWriteOnce (RWO): 단일 Pod만 읽기/쓰기 가능. (가장 흔함)
    • ReadOnlyMany (ROX): 여러 Pod가 읽기만 가능.
    • ReadWriteMany (RWX): 여러 Pod가 읽기/쓰기 가능. (NFS 같은 공유 파일 시스템에서 주로 사용)

    만약 RWX가 필요한데 RWO로 설정하면, 다른 Pod에서 접근이 안 돼서 문제가 생길 수 있습니다. 특히 클라우드 볼륨(EBS 같은)은 대부분 RWO만 지원하므로, RWX가 필요하면 NFS나 CephFS 같은 공유 파일 시스템 기반 CSI 드라이버를 고려해야 해요. 제가 이 부분에서 많이 헷갈렸었죠.

    3.2. ⚠️ Reclaim Policy (회수 정책) 신중하게 설정하기

    StorageClass에 reclaimPolicy를 Delete로 설정하면, PVC가 삭제될 때 연결된 PV와 실제 스토리지 볼륨도 함께 삭제됩니다. 개발 환경에서는 편하지만, 실제 운영 환경에서는 데이터가 날아갈 수 있으니 주의해야 합니다!

    저는 처음에 이걸 모르고 테스트용 DB를 날려먹을 뻔했어요… 다행히 백업이 있었지만요. 😅

    운영 환경에서는 Retain으로 설정해서 PVC 삭제 시 PV만 삭제하고, 실제 볼륨은 수동으로 관리하는 것을 고려해봐야 합니다. 아니면 백업 정책을 철저히 해야겠죠.

    3.3. 네트워크 연결 문제 (NFS 예시)

    NFS CSI 드라이버를 사용할 경우, 쿠버네티스 노드들이 NFS 서버에 접근할 수 있는지 확인해야 합니다. 방화벽 문제나 네트워크 경로 설정 문제로 연결이 안 되는 경우가 많거든요. showmount -e [NFS 서버 IP]나 mount 명령어로 직접 마운트 테스트를 해보는 것이 가장 확실합니다.

    3.4. CSI 드라이버 설치 버전 호환성

    가끔 CSI 드라이버 버전과 쿠버네티스 클러스터 버전 간의 호환성 문제로 말썽을 일으킬 때가 있습니다. CSI 드라이버의 공식 문서를 참조하여 호환되는 버전을 사용하는 것이 중요해요. 최신 버전이 무조건 좋은 건 아니더라고요.

    4. CSI 드라이버를 통한 영구 스토리지 관리의 성과

    이렇게 CSI 드라이버를 통해 영구 스토리지를 구성하고 나면, 다음과 같은 이점을 얻을 수 있습니다.

    • 데이터 영속성 (Data Persistence) 확보: Pod가 죽거나 재시작되어도 데이터는 안전하게 유지됩니다. DB나 상태를 가지는 애플리케이션 운영에 필수적이죠.
    • 스토리지 관리의 유연성 (Flexibility): 특정 스토리지 벤더에 종속되지 않고, 다양한 스토리지 솔루션을 쿠버네티스에서 일관된 방식으로 사용할 수 있습니다.
    • 운영 효율성 (Operational Efficiency) 증대: StorageClass를 통한 동적 프로비저닝(Dynamic Provisioning) 덕분에 스토리지 할당 및 관리가 자동화되어 운영 부담이 크게 줄어듭니다. 제가 직접 PV를 일일이 만들던 시절을 생각하면 정말 격세지감이죠. 😮
    • 확장성 (Scalability): 필요한 만큼 스토리지를 손쉽게 확장하거나 축소할 수 있게 됩니다.

    실제로 제가 운영하는 서비스에서도 CSI 드라이버 덕분에 안정적으로 데이터를 관리하고, 필요에 따라 스토리지를 유연하게 변경하거나 확장할 수 있게 되었어요. K8s 스토리지 최적화의 첫걸음이라고 할 수 있습니다.

    쿠버네티스 클러스터에서 PV, PVC, StorageClass 등의 스토리지 리소스 상태를 모니터링하는 대시보드의 예시입니다.

    5. 마무리하며: 쿠버네티스 스토리지, CSI 드라이버로 마스터하기

    오늘은 쿠버네티스 환경에서 영구 스토리지를 관리하고 최적화하는 데 필수적인 CSI 드라이버에 대해 제 경험을 바탕으로 이야기해보았습니다. PersistentVolume(PV), PersistentVolumeClaim(PVC), StorageClass, 그리고 CSI 드라이버라는 핵심 개념들이 처음에는 복잡하게 느껴질 수 있지만, 몇 번 직접 구성해보면 금방 익숙해지실 거예요.

    특히 StorageClass와 CSI 드라이버를 활용한 동적 프로비저닝은 쿠버네티스 운영의 편의성을 극대화시켜주는 정말 강력한 기능이라고 생각합니다. 저의 삽질 경험담이 여러분의 K8s 스토리지 여정에 작은 도움이 되었으면 좋겠네요. 🤝

    다음번에는 CephFS나 Rook-Ceph 같은 분산 스토리지 솔루션을 CSI 드라이버와 함께 사용하는 방법에 대해서도 다뤄볼 기회가 있었으면 좋겠습니다. 궁금한 점이나 공유하고 싶은 경험이 있다면 댓글로 남겨주세요! 저는 13년차의 서버실이었습니다. 감사합니다!

    다양한 쿠버네티스 영구 스토리지 솔루션(NFS, Ceph, 클라우드 볼륨 등)의 특징과 장단점을 비교하는 인포그래픽입니다.