Files
project-infra/docs/examples/infra/security-hardening.md
T

14 KiB

security hardening 예시

이 문서의 모든 YAML은 kubectl apply --dry-run=server -f - 기준 clean을 목표로 한다. 1000+ 서비스 운영 클러스터의 auth-server namespace를 기준 예시로 사용한다.


좋은 예시 1: Namespace에 Pod Security Admission 라벨 enforce

apiVersion: v1
kind: Namespace
metadata:
  name: auth-prod
  labels:
    app.kubernetes.io/part-of: identity-platform
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.29
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: v1.29
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: v1.29
  annotations:
    platform.example.com/owner: identity-team
    platform.example.com/adr: ADR-0017-psa-restricted-baseline

왜 좋은가:

  • 운영 namespace의 PSA 기본값을 restricted로 enforce. violation Pod는 API server 단에서 reject된다.
  • version을 pin해 Kubernetes 업그레이드 시 silent behavior drift를 방지한다.
  • audit/warn을 함께 붙여 위반을 audit log와 kubectl warning으로 수집한다.

좋은 예시 2: Restricted 프로파일을 완전히 만족하는 Deployment

apiVersion: v1
kind: ServiceAccount
metadata:
  name: auth-server
  namespace: auth-prod
  labels:
    app.kubernetes.io/name: auth-server
    app.kubernetes.io/instance: auth-server
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth-server
  namespace: auth-prod
  labels:
    app.kubernetes.io/name: auth-server
    app.kubernetes.io/instance: auth-server
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/version: 1.42.0
    app.kubernetes.io/managed-by: argocd
spec:
  replicas: 6
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  selector:
    matchLabels:
      app.kubernetes.io/name: auth-server
      app.kubernetes.io/instance: auth-server
  template:
    metadata:
      labels:
        app.kubernetes.io/name: auth-server
        app.kubernetes.io/instance: auth-server
        app.kubernetes.io/component: api
        app.kubernetes.io/part-of: identity-platform
        app.kubernetes.io/version: 1.42.0
        app.kubernetes.io/managed-by: argocd
    spec:
      serviceAccountName: auth-server
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        fsGroupChangePolicy: OnRootMismatch
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: auth-server
      containers:
        - name: auth-server
          image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
            - name: metrics
              containerPort: 9090
              protocol: TCP
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
          envFrom:
            - secretRef:
                name: auth-server-db
          resources:
            requests:
              cpu: 200m
              memory: 512Mi
            limits:
              cpu: "2"
              memory: 1Gi
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 5
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            failureThreshold: 30
            periodSeconds: 5
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 10001
            allowPrivilegeEscalation: false
            privileged: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
            seccompProfile:
              type: RuntimeDefault
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: workdir
              mountPath: /workspace
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 64Mi
        - name: workdir
          emptyDir:
            sizeLimit: 256Mi
      imagePullSecrets:
        - name: registry-example-com

왜 좋은가:

  • restricted 프로파일의 전 필드(runAsNonRoot, numeric UID/GID, fsGroup, seccompProfile, allowPrivilegeEscalation, readOnlyRootFilesystem, drop ALL capabilities)를 Pod+컨테이너 양쪽에 일관 명시한다.
  • image는 digest pin. mutable tag에 의존하지 않는다.
  • ServiceAccount는 전용 SA + automountServiceAccountToken: false.
  • writable 경로는 emptyDir로 분리해 root FS는 read-only 유지.

나쁜 예시 1: Restricted 프로파일 위반 Pod

apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth-server
  namespace: auth-prod
spec:
  replicas: 1
  selector:
    matchLabels:
      app: auth-server
  template:
    metadata:
      labels:
        app: auth-server
    spec:
      containers:
        - name: auth-server
          image: auth-server:latest
          securityContext:
            privileged: true

문제:

  • privileged: true는 baseline조차 위반. PSA enforce=restricted namespace에서는 API server가 reject한다.
  • runAsNonRoot, allowPrivilegeEscalation, capabilities.drop, seccompProfile, readOnlyRootFilesystem 전부 누락.
  • image tag latest는 digest 고정 없이 rolling silently breaks.
  • SA 미지정 → default SA가 토큰 자동 마운트.

좋은 예시 3: Default-deny + DNS + Ingress + DB + Prometheus allow NetworkPolicy 세트

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: auth-prod
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: auth-prod
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-from-ingress-traefik
  namespace: auth-prod
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: auth-server
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-traefik
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik
      ports:
        - protocol: TCP
          port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-to-postgres
  namespace: auth-prod
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: auth-server
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: data-prod
          podSelector:
            matchLabels:
              app.kubernetes.io/name: postgres
              app.kubernetes.io/instance: identity-postgres
      ports:
        - protocol: TCP
          port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-metrics-scrape-from-prometheus
  namespace: auth-prod
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: auth-server
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
          podSelector:
            matchLabels:
              app.kubernetes.io/name: prometheus
      ports:
        - protocol: TCP
          port: 9090

왜 좋은가:

  • namespaceSelectorpodSelector동일 from 엔트리 안에 있으므로 AND(교집합): monitoring namespace 안의 Prometheus Pod만 9090 scrape 허용된다.
  • default-deny + minimum allow 세트로 ingress/egress 모두 통제.
  • DNS는 kube-systemk8s-app=kube-dns Pod로 한정, egress 전체를 열지 않음.

나쁜 예시 2: NetworkPolicy AND/OR 혼동

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: broken-scrape
  namespace: auth-prod
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: auth-server
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: prometheus
      ports:
        - protocol: TCP
          port: 9090

문제:

  • namespaceSelectorpodSelector별도 엔트리(두 개의 -) → OR로 해석된다.
  • 결과: ① monitoring namespace의 모든 Pod가 허용되고, ② auth-prod namespace의 label app.kubernetes.io/name=prometheus를 가진 아무 Pod도 허용된다.
  • 의도했던 "monitoring의 Prometheus만 허용"이 아니라 훨씬 넓은 경로가 열린다. 실제 클러스터에서 NetworkPolicy 버그의 1순위.

좋은 예시 4: Namespace-scoped RBAC (Role + RoleBinding)

apiVersion: v1
kind: ServiceAccount
metadata:
  name: auth-server-secret-rotator
  namespace: auth-prod
automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: auth-server-secret-reader
  namespace: auth-prod
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames:
      - auth-server-db
      - auth-server-oidc-client
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: auth-server-secret-reader
  namespace: auth-prod
subjects:
  - kind: ServiceAccount
    name: auth-server-secret-rotator
    namespace: auth-prod
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: auth-server-secret-reader

왜 좋은가:

  • namespace 경계 안에서 특정 Secret 이름 2개만 get. list/watch 미부여.
  • SA/Role/RoleBinding 모두 같은 namespace에 명시. ---로 분리된 다중 리소스 문서.
  • system:masterscluster-admin 같은 전능 role과 무관.

나쁜 예시 3: cluster-admin ClusterRoleBinding 남용

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: auth-server-admin
subjects:
  - kind: ServiceAccount
    name: auth-server
    namespace: auth-prod
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io

문제:

  • 단일 SA가 모든 namespace의 모든 리소스(Secret, Node, CRD)를 수정할 수 있다.
  • 앱 노드 1개가 compromise되면 전체 클러스터가 compromise된다.
  • least privilege 원칙의 정반대.

좋은 예시 5: Private registry ImagePullSecret

apiVersion: v1
kind: Secret
metadata:
  name: registry-example-com
  namespace: auth-prod
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5leGFtcGxlLmNvbSI6eyJ1c2VybmFtZSI6ImNpLWJvdCIsInBhc3N3b3JkIjoiPFJFREFDVEVEPiIsImF1dGgiOiI8UkVEQUNURUQ+In19fQ==
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: auth-server
  namespace: auth-prod
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-example-com

왜 좋은가:

  • type이 kubernetes.io/dockerconfigjson으로 정확. kubelet이 이 포맷만 pull credential로 인식한다.
  • SA에 imagePullSecrets를 묶어 Deployment 마다 반복 선언 불필요.
  • 실제 운영에서는 이 Secret 자체도 VSO로 Vault → K8s로 sync(config-and-secrets 문서 참고).

나쁜 예시 4: 정책 없는 운영 namespace

apiVersion: v1
kind: Namespace
metadata:
  name: auth-prod

문제:

  • PSA 라벨 없음 → privileged Pod도 통과.
  • NetworkPolicy 없음 → ingress/egress 모두 allow-all. 침해 시 lateral movement 자유.
  • ResourceQuota/LimitRange 없음 → 한 Deployment가 namespace CPU/memory 전부 점유 가능.
  • 1000-서비스 운영에서 이런 namespace는 허용되지 않는다.

좋은 예시 6: ResourceQuota + LimitRange 묶음

apiVersion: v1
kind: ResourceQuota
metadata:
  name: auth-prod-quota
  namespace: auth-prod
spec:
  hard:
    requests.cpu: "50"
    requests.memory: 100Gi
    limits.cpu: "100"
    limits.memory: 200Gi
    pods: "200"
    services.loadbalancers: "0"
    services.nodeports: "0"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: auth-prod-defaults
  namespace: auth-prod
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      max:
        cpu: "4"
        memory: 4Gi

왜 좋은가:

  • services.loadbalancers=0, services.nodeports=0으로 namespace 내 외부 노출 Service 생성을 금지(ingress 경유 강제).
  • LimitRange로 컨테이너별 default request/limit을 보장해 limit 누락 Pod를 예방.