Kubernetes 클러스터 보안 강화: RBAC, Pod Security Standards, Network Policy, Secrets, 감사 로그

이 글의 핵심

RBAC·ServiceAccount, Pod Security Standards, Network Policy, Secrets(Vault·Sealed Secrets), 이미지 스캔·Admission Control, 감사 로그·모니터링까지 운영 환경에서 적용할 수 있는 Kubernetes 보안 강화 로드맵입니다.

Kubernetes는 강력한 오케스트레이션 기능을 제공하지만, 기본 설정만으로는 네트워크 경계, 신원, 비밀, 런타임이 한꺼번에 노출될 수 있습니다. 이 글은 운영 클러스터에서 반복적으로 마주치는 보안 요구사항을 정책, 구성, 관측 가능성의 세 축으로 나누어 살펴보고, 각 영역에서 쓸 수 있는 패턴과 흔한 함정을 설명합니다.


위협 모델과 설계 원칙

클러스터 보안은 단일 도구가 아니라 다층 방어(defense in depth)입니다. 일반적인 공격 경로는 취약한 컨테이너 이미지나 과도한 권한으로 워크로드에 침투하고, ServiceAccount 토큰이나 Secret을 탈취해 API 서버에 접근하고, 네트워크 정책이 없는 점을 이용해 내부 이동(lateral movement)을 시도하는 흐름입니다. 그래서 최소 권한, 기본 거부(default deny) 네트워크, 비밀의 외부화와 짧은 수명, 배포 전 정책 검증을 함께 갖추는 것이 목표입니다.

운영 관점에서는 한 번에 모든 것을 막기보다 네임스페이스와 환경(dev/stage/prod)별로 단계적으로 강화하는 편이 현실적입니다. 예를 들어 개발 클러스터에서는 PSS를 baseline으로 시작하고, 프로덕션에서는 restricted와 Network Policy 기본 거부를 조합하는 식입니다.


RBAC와 ServiceAccount

RBAC의 역할

Kubernetes는 Role/ClusterRole에 API 그룹, 리소스, 동사(verb)의 조합을 정의하고, RoleBinding/ClusterRoleBinding으로 이를 사용자, 그룹, ServiceAccount에 부여합니다. RBAC은 허용 규칙만 있고 거부 규칙이 없으므로, 권한은 부여한 바인딩의 합집합이 됩니다. 보안 강화의 출발점은 cluster-admin 바인딩을 줄이고 가능한 한 네임스페이스 범위의 Role을 쓰는 것입니다.

아래는 특정 네임스페이스에서 Deployment와 Pod를 읽기만 할 수 있게 제한하는 예시입니다.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: payments
  name: deploy-reader
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: deploy-reader-binding
  namespace: payments
subjects:
  - kind: ServiceAccount
    name: cicd-deployer
    namespace: payments
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: deploy-reader

이 구성은 CI/CD 파이프라인이 해당 네임스페이스의 배포 상태와 로그만 확인할 수 있게 합니다. create, patch, delete를 열어 두면 파이프라인이 필요 이상으로 워크로드를 바꿀 수 있으므로, 실제 배포 자동화에는 동사를 나눈 별도 Role을 만들고 파이프라인 단계별로 다른 ServiceAccount를 쓰는 편이 안전합니다.

권한이 의도대로 잡혔는지는 kubectl auth can-i로 확인할 수 있습니다. 예를 들어 kubectl auth can-i delete pods -n payments --as=system:serviceaccount:payments:cicd-deployer가 no를 반환해야 합니다. --list를 붙이면 해당 주체가 가진 권한 전체를 볼 수 있습니다. 또 Secret에 대한 list/watch는 사실상 모든 Secret 값을 읽는 권한이고, Pod create 권한이 있으면 그 네임스페이스의 Secret과 ServiceAccount를 마운트한 Pod를 띄울 수 있다는 점도 권한을 설계할 때 고려해야 합니다.

ServiceAccount와 토큰

Pod는 ServiceAccount 토큰으로 API 서버에 접근합니다. Kubernetes 1.22 이후에는 만료 시간과 대상(audience)이 있고 Pod에 묶인 projected 토큰이 기본으로 마운트되며, 1.24부터는 ServiceAccount마다 만료 없는 토큰 Secret을 자동으로 만들지 않습니다. 그래도 토큰을 쓰지 않는 Pod에 토큰이 마운트되어 있으면 컨테이너가 침해됐을 때 그대로 탈취됩니다. 워크로드별 전용 ServiceAccount를 만들어 필요한 RoleBinding만 부여하고, API 서버를 호출하지 않는 애플리케이션에는 automountServiceAccountToken: false를 설정해 토큰 마운트를 끄는 것이 좋습니다.

운영자 개인 접근은 OIDC 연동으로 신원을 클러스터 외부 IdP에 두고, 짧은 만료 시간의 자격 증명과 impersonation을 조합하는 패턴이 많이 쓰입니다. 정적 클라이언트 인증서는 Kubernetes에 인증서 폐기(revocation) 수단이 없어, 유출되면 만료 전까지 막을 방법이 사실상 CA 교체뿐이므로 사람 계정에는 피하는 편이 좋습니다.


Pod Security Standards

Pod Security Standards(PSS)는 privileged, baseline, restricted 세 수준으로 Pod와 컨테이너 스펙을 제한합니다. 네임스페이스에 pod-security.kubernetes.io/enforce 같은 레이블을 붙이면 내장 Pod Security admission이 생성 단계에서 위반 Pod를 거부합니다.

baseline은 특권 컨테이너, 호스트 네임스페이스(hostNetwork, hostPID, hostIPC), hostPath 볼륨, 위험한 capability 추가처럼 알려진 권한 상승 경로를 막습니다. restricted는 여기에 더해 runAsNonRoot: true, allowPrivilegeEscalation: false, 모든 capability 제거(drop: ["ALL"]), seccompProfile을 RuntimeDefault 또는 Localhost로 지정할 것을 요구합니다. 읽기 전용 루트 파일시스템은 restricted에 포함되지 않으므로, 필요하다면 Kyverno나 Gatekeeper 같은 정책 엔진으로 따로 강제해야 합니다.

apiVersion: v1
kind: Namespace
metadata:
  name: app-prod
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

위처럼 enforce는 baseline으로 두고 audit과 warn을 restricted로 올려 두면, 배포는 계속 허용되면서 restricted 위반 사실이 감사 로그와 kubectl 경고로 남습니다. 레거시 이미지가 root로 실행되는 등 restricted를 바로 만족하지 못할 때 이렇게 위반 목록을 모은 뒤, 이미지와 Helm 차트를 정리하고 enforce를 restricted로 올리는 순서가 무난합니다. PSS는 잘못된 Pod 스펙을 막는 장치일 뿐 애플리케이션 자체의 취약점을 대신 막아 주지는 않습니다.


Network Policy

Network Policy가 하나도 없으면 Kubernetes는 모든 파드 간 통신을 허용합니다. NetworkPolicy는 레이블 셀렉터로 수신(ingress)과 송신(egress) 트래픽을 정의하며, 실제 차단은 CNI 플러그인이 수행하므로 Calico나 Cilium처럼 정책을 구현하는 CNI가 필요합니다.

정책은 허용 목록 방식입니다. 어떤 파드를 선택하는 정책이 하나라도 생기면, 그 정책의 policyTypes에 해당하는 방향은 명시적으로 허용한 트래픽만 통과합니다. 그래서 네임스페이스마다 먼저 기본 거부 정책을 두고 필요한 연결을 하나씩 여는 방식이 안전합니다.

# shop 네임스페이스의 모든 파드: 수신·송신 기본 거부
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

다음 예시는 app=frontend 파드의 송신 트래픽을 같은 네임스페이스의 app=backend 8080 포트와 클러스터 DNS로만 제한합니다.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-egress
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 8080
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

policyTypes에 Ingress를 넣고 ingress 규칙을 비워 두면 그 파드로 들어오는 트래픽이 전부 막힌다는 점에 주의해야 합니다. 이 예시에서 Ingress까지 넣었다면 프런트엔드가 사용자 요청을 받지 못하게 됩니다. 기본 거부 정책을 둔 상태라면 백엔드 쪽에도 app=frontend에서 8080으로 들어오는 수신을 허용하는 정책이 따로 있어야 연결이 됩니다. 송신과 수신은 양쪽 파드의 정책을 모두 통과해야 하기 때문입니다. DNS는 응답이 커지면 TCP로 재시도하므로 UDP와 TCP 53을 함께 열어 둡니다.

정책을 디버깅할 때는 kubectl describe networkpolicy로 셀렉터가 의도한 파드를 고르는지 먼저 확인하고, CNI가 제공하는 도구(예: Cilium의 Hubble)로 실제로 어떤 패킷이 버려지는지 보는 것이 빠릅니다.


Secrets 관리: etcd 암호화, Vault, Sealed Secrets

etcd와 Kubernetes Secret

Secret 매니페스트의 data 필드가 base64로 표현되는 것은 바이너리 값을 YAML/JSON에 담기 위한 형식일 뿐 암호화가 아닙니다. 별도 설정이 없으면 Secret은 etcd에 평문으로 저장되므로, etcd 백업이나 디스크에 접근할 수 있는 주체는 모든 Secret을 읽을 수 있습니다. API 서버의 EncryptionConfiguration으로 저장 시 암호화(encryption at rest)를 켜는 것이 기본선이며, 키를 KMS에 두는 KMS 프로바이더를 쓰면 키와 데이터를 분리할 수 있습니다. 설정을 켠 뒤에도 기존 Secret은 다시 쓰여야 암호화되므로, 모든 Secret을 한 번 갱신해야 합니다. 이와 함께 RBAC으로 Secret의 get·list·watch를 꼭 필요한 주체에게만 허용해야 합니다.

HashiCorp Vault

Vault는 동적 자격 증명, 짧은 TTL, 감사 로그, KV 시크릿 엔진 등으로 비밀의 수명주기를 중앙에서 관리합니다. Kubernetes와 연동하는 방법으로는 Vault Agent Injector가 사이드카를 주입해 공유 볼륨에 시크릿 파일을 써 주는 방식, Secrets Store CSI 드라이버로 볼륨에 마운트하는 방식, Vault Secrets Operator가 Kubernetes Secret으로 동기화하는 방식이 있습니다. 앞의 두 방식은 평문 Secret을 Git이나 etcd에 두지 않고 Pod가 실행될 때만 비밀을 노출합니다.

도입할 때는 Vault 클러스터 자체의 고가용성, 백업, unseal 절차와 Kubernetes 인증 방법(Vault role과 ServiceAccount 토큰의 매핑)을 운영 문서로 남겨 두어야 합니다. Vault가 멈추면 새 Pod가 비밀을 받지 못해 기동하지 못하므로, Vault는 클러스터의 핵심 의존성이 됩니다.

Sealed Secrets

Bitnami Sealed Secrets는 클러스터에 컨트롤러를 두고, 컨트롤러의 공개 키로 암호화한 SealedSecret 매니페스트만 Git에 저장합니다. 클러스터 안의 컨트롤러만 개인 키로 복호화해 일반 Secret을 만들기 때문에 GitOps 흐름과 잘 맞습니다. 다만 컨트롤러의 개인 키를 잃으면 Git에 있는 모든 SealedSecret을 복호화할 수 없게 되므로 키 백업이 필수이고, 원본 비밀값 자체를 교체하는 일은 키 갱신과 별개로 따로 해야 합니다.


이미지 스캔과 Admission Control

이미지 스캔

컨테이너 이미지는 공급망의 일부입니다. 레지스트리나 CI에서 Trivy, Grype, Clair 등으로 CVE를 스캔하고, 심각도 임계값을 넘으면 빌드를 실패시키는 정책을 두는 것이 일반적입니다. 스캔은 이미지를 digest로 고정해 배포할 때 의미가 커집니다. latest 같은 가변 태그는 스캔한 이미지와 실제로 실행되는 이미지가 같다는 보장이 없고, 재현성과 감사 추적도 해칩니다. 배포 후에 새로 공개되는 CVE도 있으므로 실행 중인 이미지를 주기적으로 다시 스캔해야 합니다.

Admission Control

OPA Gatekeeper나 Kyverno 같은 정책 엔진은 admission webhook으로 동작해, 요청이 etcd에 저장되기 전에 검증하거나 변경(mutating)할 수 있습니다. 예를 들어 다음과 같은 규칙을 강제할 수 있습니다.

  • 모든 Pod에 resources.requests/limits 존재
  • 허용된 레지스트리 prefix만 사용
  • privileged: true 금지
  • 특정 레이블이 없으면 네임스페이스 생성 거부

PSS와 겹치는 부분이 있더라도 조직 규칙을 코드로 고정해 두고, 배포 파이프라인에서도 같은 정책으로 사전 검증하면 거부 사유를 배포 전에 알 수 있습니다.

# 허용된 레지스트리의 이미지만 허용하는 Kyverno 정책 예시
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: allowed-registries
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-image-registry
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "허용된 레지스트리만 사용하세요."
        pattern:
          spec:
            containers:
              - image: "corp.registry.io/* | ghcr.io/myorg/*"

이 정책은 containers만 검사하므로 initContainers와 ephemeralContainers에도 같은 패턴을 추가해야 우회 경로가 막힙니다. 또 최근 Kyverno 버전에서는 정책 전체의 validationFailureAction 대신 규칙별 validate.failureAction 필드로 옮겨 가고 있으므로, 사용하는 버전의 문서를 확인하고 적용하십시오. webhook의 failurePolicy를 Fail로 두면 정책 엔진이 죽었을 때 배포 전체가 막히고, Ignore로 두면 그동안 정책이 적용되지 않는다는 트레이드오프도 정해 두어야 합니다.


감사 로그와 모니터링

Kubernetes Audit

API 서버에 Audit Policy를 설정하면 요청을 None, Metadata, Request, RequestResponse 수준으로 기록할 수 있습니다. 대부분의 리소스는 Metadata(누가, 언제, 어떤 리소스에, 어떤 동사로) 수준에서 시작하고 필요한 리소스만 상세화하는 방식이 무난합니다. Secret, ConfigMap, TokenReview는 반대로 Metadata 이하로 제한해야 합니다. 이들을 Request나 RequestResponse로 기록하면 감사 로그에 비밀값이 그대로 남아, 로그 저장소가 새로운 유출 경로가 됩니다. 감사 로그는 중앙 로그 스택(Elasticsearch, Loki, 클라우드 로깅 등)으로 보내고 보존 기간과 접근 권한을 명시해야 합니다.

런타임·클러스터 모니터링

Falco 같은 런타임 보안 도구는 시스템 콜 등 커널 이벤트를 기반으로 컨테이너 안에서의 셸 실행, 민감 파일 읽기 같은 비정상 행위를 탐지합니다. Prometheus와 Grafana로 API 서버 지연, etcd 크기, 노드 상태를 모니터링하고, RBAC 변경이나 새 ClusterRoleBinding 생성 같은 감사 이벤트에 알림을 걸어 두면 권한 상승 시도를 빨리 알아챌 수 있습니다. 서비스 메시를 쓴다면 mTLS와 서비스 단위 인가 정책이 Network Policy 위에 추가 방어층이 됩니다.

이 밖에도 Kubernetes 버전 업그레이드 계획, 노드 OS 패치, etcd 백업 암호화, CIS Kubernetes Benchmark(kube-bench 등) 점검은 클러스터 자체를 유지하는 기본 작업입니다. 정책은 실제로 동작하는지 확인해야 의미가 있으므로, 스테이징에서 Network Policy와 admission 정책을 드라이런·테스트하고 백업 복원과 Vault unseal 같은 복구 절차도 주기적으로 연습해 두는 것이 좋습니다.


자주 묻는 질문 (FAQ)

Q. NetworkPolicy를 적용했는데 트래픽이 전혀 막히지 않는 이유는 무엇인가요?

NetworkPolicy는 API 오브젝트일 뿐이고 실제 차단은 CNI 플러그인이 수행하므로, 정책을 지원하지 않는 CNI에서는 만들어도 아무 효과가 없습니다. Calico나 Cilium처럼 NetworkPolicy를 구현하는 CNI를 쓰는지 먼저 확인합니다. 반대로 egress를 제한하는 정책을 넣은 뒤 모든 요청이 실패한다면, DNS(UDP/TCP 53)로 나가는 트래픽을 허용하지 않았을 가능성이 높습니다.


같이 보면 좋은 글