Published on

Kubernetes 사이드카 종료 순서 버그 해결 가이드

Authors

서비스를 Kubernetes에 올리면 사이드카 패턴(프록시, 로그 수집기, 에이전트 등)은 거의 기본값이 됩니다. 문제는 Pod 종료(롤링 업데이트, 노드 드레인, HPA 축소) 순간에 종종 터집니다. 트래픽이 빠지는 와중에 메인 컨테이너가 사이드카에 의존하고 있는데, 사이드카가 먼저 내려가 버리면 메인은 마지막 요청 처리나 종료 훅 실행 중에 갑자기 네트워크/로깅/메트릭이 끊기며 에러를 뿜습니다.

대표 증상은 아래와 같습니다.

  • Envoy/istio-proxy가 먼저 종료되어 애플리케이션이 connection reset 또는 upstream connect error로 마지막 요청을 실패
  • 로그 수집 사이드카가 먼저 내려가 애플리케이션의 종료 직전 로그가 유실
  • OpenTelemetry Collector 같은 사이드카가 먼저 종료되어 trace/span 일부가 드롭
  • 종료 중 헬스체크가 흔들리며 Pod가 CrashLoopBackOff처럼 보이는 2차 증상(재시작 루프)으로 번짐

CrashLoopBackOff 자체의 일반 원인/대응은 별도 글에서 정리했지만, 종료 순서 이슈는 "정상 종료인데도 실패처럼 보이는" 케이스가 많습니다. 필요하면 K8s Pod CrashLoopBackOff 원인 7가지와 해결도 함께 확인해 두면 진단 속도가 빨라집니다.

이 글에서는 "사이드카가 먼저 죽는 종료 순서 버그"를 실무 관점에서 재현하고, Kubernetes에서 통제 가능한 해결책을 단계적으로 정리합니다.

문제의 본질: Pod 종료는 컨테이너 순서를 보장하지 않는다

Kubernetes에서 Pod가 종료되면 kubelet은 Pod 내 컨테이너들에 SIGTERM을 보내고, terminationGracePeriodSeconds 동안 기다린 뒤 남아 있으면 SIGKILL로 강제 종료합니다.

중요 포인트는 "여러 컨테이너의 종료 순서가 기본적으로 보장되지 않는다"는 점입니다. 즉 애플리케이션 컨테이너가 종료 훅에서 마지막 flush를 하거나, 프록시를 통해 마지막 응답을 보내야 하는데 프록시(사이드카)가 먼저 내려가면 실패합니다.

특히 아래 조합에서 자주 터집니다.

  • 메인 컨테이너가 사이드카를 통해서만 egress 가능(서비스 메시)
  • 로그/트레이스가 사이드카로만 나감(Collector/Fluent Bit)
  • preStop에서 외부 API 호출 또는 flush 수행
  • 짧은 terminationGracePeriodSeconds

빠른 진단 체크리스트

종료 순서 이슈는 "배포/스케일다운/드레인" 시점에만 발생하므로, 재현과 증거 수집이 중요합니다.

1) 이벤트와 종료 코드 확인

다음으로 Pod 이벤트와 종료 코드를 확인합니다.

kubectl describe pod -n myns mypod
kubectl get pod -n myns mypod -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.state.terminated.reason}{"\t"}{.state.terminated.exitCode}{"\n"}{end}'

사이드카가 먼저 Completed 또는 Error로 끝나고, 직후 메인이 네트워크 에러/flush 실패를 남기면 가능성이 큽니다.

2) 종료 타임라인 로그 확보

컨테이너별로 종료 직전 로그를 비교합니다.

kubectl logs -n myns mypod -c app --previous
kubectl logs -n myns mypod -c sidecar --previous

--previous는 재시작이 있었던 경우에 특히 유용합니다.

3) 드레인/롤링 업데이트 때만 실패하는지 확인

다음처럼 의도적으로 종료를 걸어 재현합니다.

kubectl delete pod -n myns mypod --grace-period=30

또는 배포 롤링 업데이트 중에만 발생한다면, readiness 전환과 종료 훅 타이밍이 엮였을 가능성이 높습니다.

해결 전략 개요

해결은 크게 3단계로 나뉩니다.

  1. 메인 컨테이너가 "먼저" 트래픽에서 빠지고, 종료 준비를 하게 만든다(readiness, preStop)
  2. 사이드카가 "나중"에 종료되도록 지연시키거나, 최소한 메인 종료 동안 살아 있게 만든다(preStop, grace period)
  3. 가능하면 Kubernetes가 제공하는 "사이드카로 취급되는" 컨테이너 메커니즘을 사용해 종료 순서 자체를 보장한다(사이드카 컨테이너 패턴)

환경에 따라 1과 2만으로도 충분하지만, 서비스 메시나 텔레메트리처럼 의존도가 높으면 3을 적극 검토하는 게 안전합니다.

1) preStop으로 종료 타이밍을 설계하기

핵심은 "메인이 종료 준비를 하는 동안 사이드카가 살아 있어야 한다"입니다.

메인 컨테이너: preStop에서 먼저 드레인

메인은 preStop에서 다음 중 하나를 수행합니다.

  • 애플리케이션에 드레인 신호(예: SIGTERM 처리 로직에서 신규 요청 거부)
  • 로드밸런서 관점에서 빠르게 readiness를 false로 만들기(대개는 SIGTERM 처리와 함께)
  • 처리 중 요청을 마무리하고 flush

예시:

apiVersion: v1
kind: Pod
metadata:
  name: demo
spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: app
      image: myorg/app:1.0
      ports:
        - containerPort: 8080
      lifecycle:
        preStop:
          exec:
            command:
              - /bin/sh
              - -c
              - |
                echo "preStop: start draining";
                # 예: 앱에 드레인 엔드포인트가 있다면 호출
                wget -qO- http://127.0.0.1:8080/admin/drain || true;
                # 처리 중 요청 마무리 시간을 확보
                sleep 10;
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 8080
        periodSeconds: 3
        failureThreshold: 1

여기서 중요한 건 sleep 자체가 목적이 아니라, "새 요청 유입 차단 후 처리 중 요청을 끝낼 시간"을 확보하는 것입니다.

사이드카: preStop으로 더 오래 버티기

사이드카에도 preStop을 넣어, 메인보다 늦게 내려가도록 설계합니다. 예를 들어 프록시라면 drain을 걸고, 로그/텔레메트리라면 flush 시간을 확보합니다.

    - name: sidecar
      image: myorg/sidecar:1.0
      lifecycle:
        preStop:
          exec:
            command:
              - /bin/sh
              - -c
              - |
                echo "sidecar preStop: keep alive for app shutdown";
                # 메인이 종료 작업을 끝낼 때까지 버팀
                sleep 25;

이 방식은 "종료 순서"를 보장하진 않지만, 실무에서는 grace period와 조합하면 상당히 안정적으로 동작합니다.

2) terminationGracePeriodSeconds를 현실적으로 잡기

기본값이 너무 짧거나, 조직 표준이 30초로 고정되어 있는 경우가 많습니다. 사이드카가 있는 Pod는 다음을 고려해야 합니다.

  • 메인 컨테이너의 최대 처리 시간(최대 요청 latency, 배치 flush 시간)
  • 서비스 메시 프록시 drain 시간
  • 로그/트레이스 flush 시간

예를 들어 메인 preStop 10초, 사이드카 preStop 25초면 최소 35초 이상이 필요합니다. 여기에 여유를 두고 60초로 잡는 식입니다.

spec:
  terminationGracePeriodSeconds: 60

만약 이 값을 늘렸는데도 마지막에 SIGKILL이 찍힌다면, 종료 훅이 블로킹되거나 외부 의존성(API, DNS)이 종료 중에 불안정한 것입니다. 종료 훅에서는 네트워크 의존을 최소화하고, 반드시 타임아웃을 두세요.

3) "사이드카 컨테이너" 패턴으로 종료 순서 보장하기

Kubernetes는 사이드카 패턴을 더 안전하게 만들기 위해 "사이드카로 간주되는 컨테이너"를 지원하는 방향으로 발전해왔습니다. 실무적으로는 다음 아이디어가 핵심입니다.

  • 사이드카를 일반 컨테이너가 아니라 initContainers에 두되, 종료되지 않고 계속 실행되게 구성
  • 이렇게 하면 kubelet이 해당 컨테이너를 사이드카로 인식해, 메인 컨테이너 종료 후에 사이드카가 정리되는 흐름을 만들 수 있음

다만 이 동작은 Kubernetes 버전 및 기능 게이트, 런타임 동작에 따라 차이가 있을 수 있습니다. 운영 클러스터에서 적용 전, 현재 버전에서 지원 여부를 반드시 확인해야 합니다.

예시: initContainers를 이용한 사이드카 형태(개념)

아래 예시는 "사이드카가 먼저 떠 있고, 메인 종료 이후까지 살아 있게" 만들기 위한 형태를 보여줍니다.

apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  terminationGracePeriodSeconds: 60
  initContainers:
    - name: proxy-sidecar
      image: myorg/proxy:1.0
      restartPolicy: Always
      command:
        - /bin/sh
        - -c
        - |
          echo "proxy sidecar running";
          exec /usr/local/bin/proxy
  containers:
    - name: app
      image: myorg/app:1.0
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh","-c","sleep 10"]

포인트는 initContainersrestartPolicy: Always를 주는 구성인데, 이는 일반적인 init 컨테이너와 달리 "계속 실행되는" 사이드카로 취급되도록 하는 패턴입니다.

운영에선 이 방식을 적용하기 전에 반드시 다음을 확인하세요.

  • 사용 중인 Kubernetes 버전에서 해당 동작이 정식 지원인지
  • Admission Controller나 정책(OPA Gatekeeper, Kyverno)이 해당 스펙을 차단하지 않는지
  • 사이드카가 init 단계에서 뜨므로, 실패 시 Pod가 Ready로 가지 못하는 영향이 허용되는지

서비스 메시(Envoy/Istio)에서 특히 중요한 설정

서비스 메시 프록시가 사이드카인 경우, 종료 순서 이슈는 단순 sleep으로 가려지지 않는 경우가 있습니다. 이유는 "네트워크 경로 자체"가 프록시에 종속되기 때문입니다.

권장 접근은 다음 조합입니다.

  • 메인 컨테이너: SIGTERM 수신 즉시 readiness false, 신규 요청 거부
  • 프록시: drain을 수행하고 일정 시간 연결 유지
  • grace period: drain과 처리 시간을 커버

Istio를 사용한다면 프록시 종료/드레인 관련 옵션들이 별도로 존재합니다. 환경별로 값이 다르므로, 먼저 현재 프록시가 종료 시 어떤 로그를 남기는지 확인하고 조정하세요.

로그/트레이싱 사이드카에서의 데이터 유실 방지

로그 수집기(Fluent Bit 등)나 OpenTelemetry Collector가 사이드카인 경우, 종료 순서가 꼬이면 "마지막 N초" 데이터가 유실됩니다. 관측 가능성(Observability) 관점에서 이 구간은 장애 원인 분석에 가장 중요한 구간이기도 합니다.

  • 사이드카 preStop에서 flush 또는 잠깐 대기
  • 메인 종료 preStop에서 마지막 로그를 남기고 종료
  • 가능하면 애플리케이션도 stdout flush, 버퍼링 최소화

분산 트레이싱을 운영 중이라면, 사이드카/에이전트 종료 타이밍 때문에 span이 끊기는지 점검해보세요. 관련해서는 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 글이 전체 파이프라인 이해에 도움이 됩니다.

재현용 미니 실험: 사이드카가 먼저 죽으면 어떤 일이 생기나

아래는 "사이드카가 먼저 종료되었을 때 메인이 실패"하는 상황을 의도적으로 만드는 예시입니다.

  • 메인 컨테이너는 http://127.0.0.1:15000 같은 로컬 프록시를 통해서만 외부로 나간다고 가정
  • 종료 훅에서 외부 호출을 시도
  • 사이드카가 먼저 내려가면 호출이 실패
apiVersion: v1
kind: Pod
metadata:
  name: termination-order-repro
spec:
  terminationGracePeriodSeconds: 20
  containers:
    - name: app
      image: curlimages/curl:8.5.0
      command: ["/bin/sh","-c","sleep 3600"]
      lifecycle:
        preStop:
          exec:
            command:
              - /bin/sh
              - -c
              - |
                echo "app preStop: calling via sidecar";
                # sidecar가 먼저 죽으면 여기서 실패
                curl -fsS http://127.0.0.1:15000/health || echo "failed";
                sleep 5
    - name: sidecar
      image: hashicorp/http-echo:1.0
      args: ["-listen=:15000","-text=ok"]
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh","-c","echo sidecar stopping"]

위 Pod를 띄우고 삭제하면, 환경에 따라 sidecar가 먼저 내려가면서 앱 preStop의 curl이 실패하는 로그를 쉽게 볼 수 있습니다. 그 다음, sidecar preStop에 sleep을 추가하거나 grace period를 늘려 해결되는지 비교해보면 원리 이해가 빨라집니다.

운영 체크포인트: 롤링 업데이트와 PDB, 드레인

종료 순서 문제는 롤링 업데이트 때 더 잘 드러납니다. 다음도 함께 점검하세요.

  • Deployment의 maxUnavailable이 너무 커서 동시에 많은 Pod가 내려가지는 않는지
  • PodDisruptionBudget으로 최소 가용 Pod를 보장하는지
  • 노드 드레인 시 --grace-period가 너무 짧게 잡히지 않았는지

특히 드레인 자동화 스크립트에서 kubectl drain ... --grace-period=10처럼 짧게 주면, preStop이 있어도 의미가 없어집니다.

추천 조합(현실적인 템플릿)

대부분의 웹 API 서비스에서 무난한 조합은 다음입니다.

  • terminationGracePeriodSeconds: 60
  • 메인 preStop: 드레인 신호 후 5초에서 15초 대기
  • 사이드카 preStop: 메인보다 10초에서 30초 더 대기(또는 drain)
  • readinessProbe: failureThreshold를 과도하게 키우지 말고 빠르게 트래픽에서 빠지게 구성

예시:

spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: app
      image: myorg/app:1.0
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh","-c","wget -qO- http://127.0.0.1:8080/admin/drain || true; sleep 10"]
      readinessProbe:
        httpGet: { path: /health/ready, port: 8080 }
        periodSeconds: 3
        failureThreshold: 1
    - name: sidecar
      image: myorg/sidecar:1.0
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh","-c","sleep 30"]

이 템플릿은 "순서 보장"이 아니라 "시간적 완충"으로 안정성을 올리는 방식입니다. 클러스터 버전과 정책이 허용한다면, 앞서 언급한 사이드카 컨테이너 패턴까지 적용하는 것이 더 견고합니다.

마무리

Kubernetes에서 사이드카 패턴의 종료 순서 문제는 애플리케이션 버그처럼 보이지만, 실제로는 Pod 종료 시점의 컨테이너 간 의존성이 드러나는 설계 문제인 경우가 많습니다.

정리하면 다음 순서로 접근하는 것이 효율적입니다.

  • 증상 수집: 종료 시점 로그/이벤트/exit code로 "사이드카 선종료"를 확인
  • 1차 완화: 메인과 사이드카에 preStop을 설계하고 grace period를 충분히 확보
  • 근본 개선: 가능하면 Kubernetes의 사이드카 컨테이너 패턴을 검토해 종료 순서 자체를 안정화

마지막으로, 종료 구간의 트레이스/로그 유실은 장애 분석 난이도를 크게 올립니다. 관측 가능성 파이프라인을 운영한다면 종료 타이밍까지 포함해 설계를 점검해보세요.