본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 21. 09:05

실전에서의 Pod별 NetworkPolicy: 하루 만에 5개의 에이전트 마이그레이션하기

요약

Kubernetes의 NetworkPolicy가 Pod 단위로 적용되어 사이드카와 메인 컨테이너의 네트워크 권한을 분리할 수 없는 구조적 한계를 해결하기 위한 마이그레이션 사례를 다룹니다. 기존의 인-팟(in-pod) 사이드카 모델에서 방화벽을 별도의 Pod로 분리하는 별도-Pod(separate-pod) 모델로 전환하여 보안을 강화하는 과정을 설명합니다.

핵심 포인트

  • NetworkPolicy는 Pod 내 특정 컨테이너가 아닌 Pod 전체에 적용되므로 컨테이너 간 네트워크 격리가 불가능함
  • 인-팟 사이드카 모델은 보안 정책의 모순(에이전트 차단 vs 사이드카 허용)을 야기할 수 있음
  • 보안 강화를 위해 방화벽 역할을 하는 사이드카를 별도의 Pod로 분리하는 구조적 해결책이 필요함
  • 마이그레이션 시 검증 프로브를 포함한 단계별 접근과 워크로드 특성을 고려한 순차적 적용이 중요함

운영 중인 클러스터에서는 5개의 AI 에이전트가 실행되고 있었으며, 각 에이전트는 트래픽을 스캔하는 인-팟(in-pod) Pipelock 사이드카(sidecar)를 보유하고 있었습니다. 경계는 실재했지만 권고 수준에 불과했습니다. Pod 내의 모든 컨테이너는 네트워크 네임스페이스(network namespace)를 공유하므로, "이 Pod에 대한 인터넷 접속 차단"이라고 명시된 NetworkPolicy는 사이드카에도 동일하게 적용됩니다. 에이전트의 이그레스(egress, 나가는 트래픽)를 제한하려면 사이드카의 이그레스를 완화해야 했고, 이는 원래의 목적을 무색하게 만들었습니다.

이 포스트는 해당 에이전트들을 인-팟 사이드카 모델에서, 방화벽이 자체적인 NetworkPolicy를 가진 별도의 Pod에 존재하고 에이전트 Pod는 방화벽 외에는 어디로도 경로를 가질 수 없는 별도-Pod(separate-pod) 모델로 마이그레이션한 현장 보고서입니다. 마이그레이션에는 약 하루의 작업 시간이 소요되었으며, 기록할 가치가 있는 6가지 주의사항(gotchas)이 발견되었습니다.

NetworkPolicy를 분리해야 하는 구조적 이유는 그것이 Pod 단위이기 때문입니다. Kubernetes 네트워킹 사양은 NetworkPolicy가 Pod 내의 특정 컨테이너로 범위를 제한하는 것을 허용하지 않습니다. 왜냐하면 정책은 Pod를 식별 단위로 보는 CNI 플러그인에 의해 강제되기 때문입니다. 하나의 Pod에 있는 세 개의 컨테이너는 CNI의 관점에서 하나의 Kubernetes "자아(you)"입니다. 이는 인-팟 사이드카 아키텍처에 모순이 내재되어 있음을 의미합니다. 에이전트 컨테이너는 인터넷이 차단되어야 하지만, 사이드카 컨테이너는 에이전트의 출구 역할을 하기 때문에 인터넷이 필요합니다. 둘 다 동일한 Pod에 있습니다. NetworkPolicy는 둘 모두에게 동일하게 적용됩니다. Pod 전체의 통신을 허용하거나, 사이드카를 포함한 Pod 전체를 차단해야만 합니다.

실무에서는 에이전트 컨테이너에 HTTPS_PROXY 설정을 적용하고 Pod에 광범위하게 열린 NetworkPolicy를 사용하는 방식으로 이 문제를 임시방편으로 해결하곤 합니다. 사이드카는 에이전트가 프록시를 통해 보내는 모든 것을 스캔할 수 있습니다. 에이전트는 서브프로세스(subprocess)에서 HTTPS_PROXY를 해제하고 직접 연결을 시도할 수 있으며, Pod의 NetworkPolicy가 인터넷 접속을 허용하고 있기 때문에 커널은 이를 통과시켜 줍니다. 해결책은 구조적인 것입니다. 방화벽을 별도의 Pod로 이동하십시오. 에이전트 Pod의 NetworkPolicy를 강화하여 방화벽 Pod의 서비스로 향하는 이그레스만 허용하도록 설정하십시오.

이제 에이전트 Pod는 방화벽을 통하지 않고서는 인터넷 경로를 가질 수 없으며, 방화벽 Pod는 필요한 인터넷 접속 권한을 가지고 있고, NetworkPolicy (네트워크 정책)가 두 형태 모두를 올바르게 강제합니다. 이것이 Pod별 모델입니다. 5개의 에이전트와 마이그레이션 순서: 플릿(Fleet)에는 5개의 별도 에이전트 배포(Deployment)가 있었습니다. 각각은 수개월 동안 Pod 내부의 사이드카 (sidecar)와 함께 실행되어 왔습니다. 마이그레이션 순서: 우선 한 개의 에이전트를 대상으로, Kubernetes에 맞춰 조정된 3-UID 격리 플레이북 (containment playbook)의 바이패스 폐쇄 검증 프로브 (bypass-closure verification probes)를 포함하여 완전한 엔드-투-엔드 (end-to-end) 과정을 수행했습니다. 그다음 동일한 패턴을 적용하여 세 개의 에이전트를 병렬로 진행했습니다. 다섯 번째 에이전트는 가장 마지막에 진행했는데, 이는 해당 에이전트에 별도의 처리가 필요한 워크로드 특유의 기이한 점(아래의 안티 봇 스크래핑 참조)이 있었기 때문입니다. 에이전트별 단계는 다음과 같았습니다: 동일한 네임스페이스 (namespace) 내에 pipelock-companion Deployment + Service를 생성합니다. 해당 Pod 셀렉터 (selector)에 대해서만 TCP 443/80을 허용하는 범위가 제한된 pipelock-companion-egress NetworkPolicy를 생성합니다. 에이전트 Deployment를 업데이트하여 Pod 내부의 Pipelock 사이드카를 제거합니다. 에이전트의 환경 변수를 업데이트하여 HTTPS_PROXY가 컴패니언 서비스 (companion service)를 가리키도록 설정합니다. 에이전트 Pod의 기본 NetworkPolicy를 강화하여 광범위한 인터넷 이그레스 (egress) 규칙을 제거하고 컴패니언 서비스로 향하는 이그레스만 유지합니다. 일반적인 매니페스트 파이프라인 (manifest pipeline)을 통해 변경 사항을 배포합니다. 바이패스 폐쇄 프로브 (bypass-closure probes)를 실행합니다: raw TCP dial, env-clear subprocess, NO_PROXY 도메인 매칭. 첫 번째 에이전트는 초기화 컨테이너 (init container) 이그레스에서의 두 차례 시행착오를 포함하여 4시간이 소요되었습니다. 네 번째 에이전트는 1시간 미만이 걸렸습니다. "첫 번째는 고전하지만, 나머지는 순조롭게 진행된다"는 패턴이 유지되었습니다. 주의사항 1: subPath ConfigMap 마운트는 핫 리로드 (hot-reload)되지 않음. 마이그레이션 중 발생한 가장 큰 예상치 못한 마찰이었습니다. Pipelock은 설정 파일에 대해 fsnotify 핫 리로드를 지원합니다. Kubernetes의 ConfigMap 업데이트는 마운트된 파일로 전파되어야 합니다. 디렉토리 마운트의 경우 전파되지만, subPath를 사용한 단일 파일 마운트의 경우에는 전파되지 않습니다. 마운트 경로는 Pod 생성 시의 불변(immutable) 파일 핸들(file-handle)을 계속 가리키게 됩니다. 몇몇 네임스페이스의 Pipelock 설정이 subPath를 사용하여 마운트되어 있었습니다.

여러 번의 커밋을 통해 삭제 허용 목록(redaction allowlist) 항목들이 추가되었으나, 이들은 클러스터의 ConfigMap 객체에는 반영되었지만 실행 중인 Pipelock 인스턴스에는 도달하지 못했습니다. 해결 방법은 kubectl delete pod를 실행하여 강제로 새로운 마운트(mount)를 수행하는 것이었습니다. 구조적인 해결책은 가능한 한 ConfigMap을 디렉토리로 마운트하는 것입니다. 자세한 내용은 'subPath ConfigMap Mounts Don't Hot-Reload'에 기술되어 있습니다. 이번 마이그레이션에서는 마운트 형태가 수정될 때까지 "Pipelock 설정을 변경한 후에는 항상 Pod를 재시작한다"는 임시 방편(workaround)을 사용했습니다.

주의사항 2: 인터넷에서 데이터를 가져오는 초기화 컨테이너 (init containers)
여러 에이전트 배포(deployment)에는 메인 컨테이너가 시작되기 전 Pipelock 바이너리를 가져오는 초기화 컨테이너(init container)가 포함되어 있었습니다. 해당 패턴은 다음과 같습니다:

initContainers :
  - name : install-pipelock
    image : alpine
    command : [ " sh" , " -c" ]
    args :
      - | 
        wget -O /opt/bin/pipelock https://github.com/luckyPipewrench/pipelock/releases/... 
        chmod +x /opt/bin/pipelock

이 방식은 Pod의 네트워크 정책(NetworkPolicy)이 광범위한 이그레스(egress)를 허용했을 때는 잘 작동했습니다. 하지만 보안 강화(lockdown) 이후, Pod의 NetworkPolicy가 직접적인 인터넷 접속을 차단함에 따라 초기화 컨테이너의 wget이 가장 먼저 실패하는 문제가 발생했습니다. 해결 방법은 초기화 컨테이너가 동반 Pod(companion pod)에서 사용하는 것과 동일한 이미지를 사용하여 바이너리를 복사하도록 만드는 것이었습니다. 이렇게 하면 네트워크가 전혀 필요하지 않습니다.

initContainers :
  - name : install-pipelock
    image : pipelock:VERSION
    command : [ " sh" , " -c" ]
    args : [ " cp /usr/local/bin/pipelock /opt/bin/pipelock && chmod +x /opt/bin/pipelock" ]

이 이미지는 동반 Pod가 실행하는 것과 동일한 이미지이므로, 바이너리는 실행 중인 동반 Pod와 바이트 단위로 완전히 일치합니다. 이것이 모든 에이전트 Pod에 적용되는 표준 패턴(canonical pattern)이 되었습니다.

주의사항 3: TLS 중간자 공격(interception)을 허용하지 않는 브라우저 자동화
에이전트 중 하나에는 중간자 공격(MITM) 환경에서 실패하는 워크로드(workload)를 위한 브라우저 드라이버(browser-driver) 컨테이너가 있었습니다. 브라우저 스택은 자체적으로 TLS 처리를 수행하기 때문에 중간자 공격이 발생하면 작동이 중단되었습니다. Pipelock은 해당 대상들에 대해 MITM을 건너뛰도록 passthrough_domains를 설정할 수 있었지만, 브라우저의 이그레스(egress) 역시 프록시로의 루프백(loopback)이 아닌 직접적인 인터넷 접속이 필요했습니다.

이 구조적 해결책은 방화벽 분리와 동일한 방식을 따랐습니다. 스크래핑 도구(scraping tool)를 별도의 디플로이먼트(Deployment)로 이동시켰으며, TCP 443/80에 대해 직접적인 이그레스(egress)를 허용하는 자체 NetworkPolicy를 적용했습니다. 에이전트(agent)의 NetworkPolicy에는 스크래핑 서비스로 향하는 트래픽을 위한 추가 허용 규칙(allow rule)이 추가되었습니다. 에이전트는 클러스터 서비스(cluster service)를 통해 스크래퍼(scraper)를 호출하고, 스크래퍼는 스크래핑 작업을 위해 인터넷에 직접 접속하며, 이제 해당 트래픽 경로에 Pipelock이 위치하지 않습니다. 이는 Pod별 모델이 수용하는 아키텍처 측면의 타협점입니다. 중간자 공격(MITM) 환경 뒤에서 근본적으로 작동할 수 없는 도구들은 자체적인 이그레스를 가진 별도의 Pod를 갖게 됩니다. Pipelock은 스크래핑 트래픽에 대한 가시성을 잃게 되지만, 에이전트가 스크래퍼로 전달하는 URL은 여전히 Pipelock의 MCP-stdio 스캐너를 통과하므로 호출 측의 공격 표면(surface)은 여전히 보호됩니다.

주의사항 4: 공유 네임스페이스에서의 ID 바인딩(identity binding). 두 개의 에이전트가 동일한 네임스페이스(namespace)에 거주하며 PVC와 ConfigMap을 공유했지만, Pipelock 컴패니언(companion)에는 서로 다른 ID를 바인딩했습니다. 단일 컴패니언 디플로이먼트는 두 개의 ID를 바인딩할 수 없습니다. 컴패니언 설정에는 하나의 default_agent_identity 필드만 존재하기 때문입니다. 두 가지 옵션이 있습니다: 에이전트당 하나씩 두 개의 컴패니언을 배포하거나, Pod IP와 매칭하기 위해 Pro agents.*.source_cidrs 기능을 사용하는 것입니다. 마이그레이션 과정에서는 두 개의 컴패니언을 선택했습니다. 비용은 대략 추가적인 Pod 하나 분량의 메모리(컴패니언당 약 50 MiB)입니다. 이점은 모든 에이전트 ID가 하나의 컴패니언 디플로이먼트에 매핑되어, 네임스페이스의 매니페스트(manifest) 세트를 플릿 일관성(fleet-consistent) 있게 유지할 수 있다는 것입니다. Pro 버전의 source_cidrs 기능이 더 우아하겠지만, 내부 테스트(dogfood-validate)를 수행하기에 더 복잡하며 이번 테스트 경로에는 포함되지 않았습니다. 단일 테넌트(single-tenant) 네임스페이스의 경우 하나의 컴패니언이면 충분합니다. 각 테넌트가 별도의 ID를 바인딩하는 공유 네임스페이스의 경우, 테넌트별 컴패니언을 사용하는 것이 더 깔끔합니다.

주의사항 5: 투영된 시크릿 볼륨 모드(projected secret volume modes). Pipelock 컴패니언은 권한이 없는 UID로 실행됩니다. TLS-MITM CA는 컴패니언 Pod에 마운트된 Kubernetes Secret을 통해 전달됩니다.

매니페스트의 첫 번째 버전은 UID와 상관없이 컴패니언(companion)이 파일을 읽을 수 있을 것이라 생각하여 볼륨 모드(volume mode)를 0444(전체 읽기 가능)로 설정했습니다. 하지만 Pipelock이 시작을 거부했습니다. Pipelock의 검증기(internal/config/validate.go)는 0o137을 기준으로 마스킹하여, 모드가 전체 읽기(world-read), 전체 쓰기(any-write), 또는 전체 실행(any-execute)을 허용하는 CA 키 파일을 거부합니다. 이 방식은 0o600(소유자 읽기)과 0o640(소유자 읽기 및 그룹 읽기)은 허용하지만, 0o444와 0o644는 거부합니다.

두 가지 패턴이 작동합니다:

  1. 0o600 및 일치하는 소유자: Pod의 runAsUser(또는 runAsNonRoot와 명시적인 runAsUser 조합)를 파일 소유자와 일치하는 UID로 설정합니다. Kubernetes Secret 볼륨은 Pod에 securityContext.runAsUser가 설정되지 않으면 파일 소유자의 기본값을 root로 설정하므로, 이 경로를 사용하려면 해당 필드와 Pipelock 컨테이너를 위한 일치하는 runAsUser가 필요합니다.
  2. 0o640 및 fsGroup: 볼륨의 defaultMode를 0o440 또는 0o640으로 설정하고, Pod의 securityContext.fsGroup을 설정합니다. Kubernetes는 Secret 파일의 소유권을 fsGroup으로 변경(chown)하고, 컨테이너의 보조 그룹(supplementary groups)에 fsGroup을 추가합니다. 컨테이너는 어떤 UID로 실행되든 그룹 비트(group bits)를 통해 파일을 읽을 수 있습니다. 컴패니언 배포(deployment)는 runAsNonRoot: true 및 임의의 컨테이너 UID와 깔끔하게 조합되는 fsGroup 패턴을 사용합니다.

주의해야 할 함정은 0o400 및 fsGroup 조합입니다. 파일 모드에 그룹 읽기 비트가 없기 때문에 보조 그룹이 권한을 부여하지 못합니다. 유일한 읽기 권한자는 파일 소유자뿐인데, Secret 볼륨은 기본적으로 컨테이너 UID를 소유자로 설정하지 않습니다. 이 패턴은 현재 모든 Pipelock 컴패니언 배포에 일관되게 적용되어 있지만, 처음 이 문제가 나타났을 때는 "왜 읽어야 할 유일한 컨테이너가 마운트된 Secret을 읽지 못하는가"를 디버깅하며 혼란스러운 30분을 보내야 했습니다.

주의사항 6: 컴패니언 기동 중 VPN 사이드카(sidecar) 불안정성
한 네임스페이스(namespace)의 기존 인-포드(in-pod) 사이드카 설정은 몇 달 동안 아무 문제 없이 VPN 사이드카를 실행해 왔습니다. 하지만 새로운 컴패니언 Pod가 새로운 VPN 사이드카와 함께 처음 기동되었을 때, 터널이 안정화되기 전까지 몇 분 동안 사이클링(cycling) 현상이 발생했습니다.

동일한 이미지, 동일한 제공업체, 동일한 설정이었습니다. 차이점은 서버 선택이었으며, 분명히 불안정한(flaky) 서버였습니다. 여기서 얻은 교훈은 다음과 같습니다. 에이전트(agent)가 출구 IP(exit-IP) 로테이션을 엄격하게 필요로 하지 않는다면, 컴패니언 포드(companion pod)에 VPN을 배치하지 마십시오. 에이전트 방화벽의 역할은 콘텐츠 스캐닝(content scanning)입니다. 이를 위해서는 클러스터의 일반적인 이그레스(egress)로도 충분합니다. 컴패니언 포드에는 VPN이 필요하지 않습니다. 기존 VPN은 다른 사이드카(sidecar)가 이에 의존하고 있는 특정 배포(deployment)를 위해 유지됩니다. 새로운 컴패니언 포드들은 VPN이 없습니다. 이를 통해 운영 환경(operational footprint)에서 움직이는 구성 요소를 하나 더 줄일 수 있었습니다.

그날의 결과물
일과가 끝날 무렵, 클러스터에는 다음과 같은 상태가 구축되었습니다:

  • 에이전트 ID당 하나씩, 총 5개의 컴패니언 포드 실행 중.
  • 직접적인 인터넷 이그레스(internet-egress) 규칙을 제거하기 위해 강화된 5개의 기본 네트워크 정책(NetworkPolicy).
  • 포드 내부의 Pipelock 사이드카를 제거한 5개의 에이전트 배포(deployment).
  • 에이전트의 네트워크 정책(NetworkPolicy)을 엄격하게 유지할 수 있도록 분리된 2개의 스크래핑(scraping) 배포.
  • 각각 명확한 롤백(rollback) 경로를 가진 소수의 매니페스트(manifest) 커밋.
  • 첫 번째 에이전트에서 검증 완료되고 나머지 4개에 대해 부분적으로 실행된 바이패스 폐쇄 프로브(bypass-closure probe) 세트(전체 스윕은 v2.4.x 후속 작업 예정).

이제 구조적 모델은 전체 플릿(fleet)에 걸쳐 일관성을 갖게 되었습니다: 에이전트 포드는 인터넷 접속이 불가능하고, 컴패니언 포드는 인터넷 접속이 가능하며, 네트워크 정책(NetworkPolicy)이 이 분리를 강제하고, 에이전트의 런타임(runtime) 선택 사항이 커널(kernel)에 도달하지 않습니다.

다음에 다르게 할 점
다음과 같은 마이그레이션을 수행할 때 적용할 세 가지 변경 사항입니다:

  1. 시작 전 ConfigMap 마운트 형태를 감사(Audit)하십시오. 핫 리로드(hot-reload)가 필요한 설정 파일에 사용된 모든 subPath: on은 향후 "왜 변경 사항이 전파되지 않는가"라는 디버깅 세션을 유발합니다. 처음부터 디렉터리 마운트(directory mounts)로 전환하십시오.
  2. 에이전트별로 마이그레이션 커밋을 미리 빌드하십시오. "사이드카 제거, 컴패니언 추가, 네트워크 정책(NetworkPolicy) 강화"라는 패턴은 모든 에이전트에 동일합니다. 첫 번째 에이전트는 맞춤형(bespoke)이지만, 나머지는 템플릿화할 수 있습니다. 작은 Kustomize 생성기를 사용했다면 에이전트 2~5번 작업 시간을 절약했을 것입니다.
  3. 모든 에이전트에서 전체 바이패스 폐쇄 프로브(bypass-closure probe) 세트를 실행하십시오. "패턴이 동일하다"는 이유로 에이전트 1번에서만 수행하고 2~5번을 건너뛰는 것은 결국 발목을 잡게 되는 종류의 자신감입니다.

이 프로브(probes)들은 상용구 코드(boilerplate)가 잡아낼 수 없는 일회성 회귀(regressions)를 포착합니다. v2.4.x 후속 작업은 모든 네임스페이스(namespace)가 프로브를 가질 수 있도록 프로브 세트를 CI 실행 가능한 아티팩트(artifact)로 만드는 것입니다. 화려하지는 않지만 중요한 교훈들이 쌓여갑니다. 이번 마이그레이션(migration)에서 발생한 마찰의 대부분은 아키텍처(architectural)의 문제가 아니라 운영(operational)의 문제였습니다. 아키텍처 모델(Pod별 분리)은 올바르고 안정적입니다. 하루를 다 잡아먹은 것은 운영상의 세부 사항들(마운트 형태, init container의 egress, secret 모드, identity 바인딩)이었습니다. 만약 귀하의 플릿(fleet)이 Pod 내부의 에이전트 방화벽 사이드카(sidecar)를 실행하고 있다면, Pod별 마이그레이션은 하루를 투자할 가치가 있습니다. 이러한 구조적 변화는 HTTPS_PROXY를 아무리 강화하더라도 막을 수 없는 일련의 우회(bypass) 경로를 차단합니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0