# security hardening 예시 이 문서의 모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean을 목표로 한다. 1000+ 서비스 운영 클러스터의 auth-server namespace를 기준 예시로 사용한다. --- ## 좋은 예시 1: Namespace에 Pod Security Admission 라벨 enforce ```yaml 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 ```yaml 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 ```yaml 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 세트 ```yaml 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 ``` **왜 좋은가:** - `namespaceSelector`와 `podSelector`가 **동일 `from` 엔트리** 안에 있으므로 AND(교집합): monitoring namespace 안의 Prometheus Pod만 9090 scrape 허용된다. - default-deny + minimum allow 세트로 ingress/egress 모두 통제. - DNS는 `kube-system`의 `k8s-app=kube-dns` Pod로 한정, egress 전체를 열지 않음. --- ## 나쁜 예시 2: NetworkPolicy AND/OR 혼동 ```yaml 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 ``` **문제:** - `namespaceSelector`와 `podSelector`가 **별도 엔트리**(두 개의 `-`) → OR로 해석된다. - 결과: ① monitoring namespace의 **모든 Pod**가 허용되고, ② `auth-prod` namespace의 label `app.kubernetes.io/name=prometheus`를 가진 **아무 Pod**도 허용된다. - 의도했던 "monitoring의 Prometheus만 허용"이 아니라 훨씬 넓은 경로가 열린다. 실제 클러스터에서 NetworkPolicy 버그의 1순위. --- ## 좋은 예시 4: Namespace-scoped RBAC (Role + RoleBinding) ```yaml 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:masters`나 `cluster-admin` 같은 전능 role과 무관. --- ## 나쁜 예시 3: cluster-admin ClusterRoleBinding 남용 ```yaml 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 ```yaml 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 ```yaml 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 묶음 ```yaml 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를 예방.