Files
project-infra/docs/examples/infra/workload-selection.md
T

32 KiB

workload selection 예시

모든 YAML은 kubectl apply --server-side --dry-run=server 통과. PodSecurity restricted 호환. 각 예시는 namespace 하나에 그대로 붙여 넣을 수 있는 self-contained 단위.


좋은 예시 1: auth-server Deployment (tier-1 prod, 완전 동반 리소스)

apiVersion: v1
kind: ServiceAccount
metadata:
  name: auth
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/version: "1.24.3"
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
    example.com/slo-tier: tier-1
spec:
  replicas: 6
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  selector:
    matchLabels:
      app.kubernetes.io/name: auth
      app.kubernetes.io/instance: auth-prod
      app.kubernetes.io/component: api
  template:
    metadata:
      labels:
        app.kubernetes.io/name: auth
        app.kubernetes.io/instance: auth-prod
        app.kubernetes.io/component: api
        app.kubernetes.io/part-of: identity-platform
        app.kubernetes.io/managed-by: argocd
        example.com/environment: prod
        example.com/slo-tier: tier-1
    spec:
      serviceAccountName: auth
      automountServiceAccountToken: false
      priorityClassName: tier-1-critical
      terminationGracePeriodSeconds: 45
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: auth
              app.kubernetes.io/instance: auth-prod
              app.kubernetes.io/component: api
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: auth
              app.kubernetes.io/instance: auth-prod
              app.kubernetes.io/component: api
      containers:
        - name: auth
          image: registry.example.com/auth@sha256:f1a2b3c4d5e6f7081920aabbccddeeff00112233445566778899aabbccddeeff
          imagePullPolicy: IfNotPresent
          ports:
            - { name: http, containerPort: 8080, protocol: TCP }
            - { name: management, containerPort: 8081, protocol: TCP }
          resources:
            requests: { cpu: 500m, memory: 1Gi }
            limits:   { cpu: "2",   memory: 2Gi }
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 5
            failureThreshold: 30
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: management }
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ "ALL" ]
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 64Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: auth
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/component: api
  ports:
    - { name: http, port: 8080, targetPort: http }
    - { name: management, port: 8081, targetPort: management }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: auth
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/slo-tier: tier-1
spec:
  minAvailable: 50%
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app.kubernetes.io/name: auth
      app.kubernetes.io/instance: auth-prod
      app.kubernetes.io/component: api
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: auth
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: auth
    app.kubernetes.io/instance: auth-prod
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: auth
  minReplicas: 6
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
    - type: Resource
      resource:
        name: memory
        target: { type: Utilization, averageUtilization: 80 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - { type: Percent, value: 25, periodSeconds: 60 }
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }
        - { type: Pods,    value: 4,   periodSeconds: 30 }
      selectPolicy: Max

왜 좋은가:

  • stateless 장기 실행 → Deployment 정답
  • PDB + HPA + topologySpread (zone+hostname) 모두 tier-1에 맞게 동반
  • digest pinning, restricted PodSecurity 호환, preStop sleep으로 graceful drain
  • selector에는 불변 3종만 (version/environment 없음)

좋은 예시 2: PostgreSQL StatefulSet (operator 없는 fallback 케이스, 완전 schema)

apiVersion: v1
kind: Service
metadata:
  name: postgres-identity-headless
  namespace: prod-data-postgres
  labels:
    app.kubernetes.io/name: postgres
    app.kubernetes.io/instance: postgres-identity
    app.kubernetes.io/component: database
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: postgres
    app.kubernetes.io/instance: postgres-identity
    app.kubernetes.io/component: database
  ports:
    - { name: postgres, port: 5432, targetPort: postgres }
---
apiVersion: v1
kind: Service
metadata:
  name: postgres-identity
  namespace: prod-data-postgres
  labels:
    app.kubernetes.io/name: postgres
    app.kubernetes.io/instance: postgres-identity
    app.kubernetes.io/component: database
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: postgres
    app.kubernetes.io/instance: postgres-identity
    app.kubernetes.io/component: database
  ports:
    - { name: postgres, port: 5432, targetPort: postgres }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-identity
  namespace: prod-data-postgres
  labels:
    app.kubernetes.io/name: postgres
    app.kubernetes.io/instance: postgres-identity
    app.kubernetes.io/version: "16.3"
    app.kubernetes.io/component: database
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
    example.com/slo-tier: tier-1
    example.com/data-classification: confidential
spec:
  serviceName: postgres-identity-headless
  replicas: 3
  podManagementPolicy: OrderedReady
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain
    whenScaled: Retain
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app.kubernetes.io/name: postgres
      app.kubernetes.io/instance: postgres-identity
      app.kubernetes.io/component: database
  template:
    metadata:
      labels:
        app.kubernetes.io/name: postgres
        app.kubernetes.io/instance: postgres-identity
        app.kubernetes.io/component: database
        app.kubernetes.io/part-of: identity-platform
        app.kubernetes.io/managed-by: argocd
    spec:
      serviceAccountName: postgres-identity
      automountServiceAccountToken: false
      priorityClassName: tier-1-critical
      terminationGracePeriodSeconds: 120
      securityContext:
        runAsNonRoot: true
        runAsUser: 999
        runAsGroup: 999
        fsGroup: 999
        seccompProfile:
          type: RuntimeDefault
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: postgres
              app.kubernetes.io/instance: postgres-identity
              app.kubernetes.io/component: database
      containers:
        - name: postgres
          image: registry.example.com/postgres@sha256:aabbccddeeff00112233445566778899aabbccddeeff0011223344556677abcd
          imagePullPolicy: IfNotPresent
          ports:
            - { name: postgres, containerPort: 5432, protocol: TCP }
          env:
            - name: POSTGRES_DB
              value: identity
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef: { name: postgres-identity-creds, key: username }
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef: { name: postgres-identity-creds, key: password }
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          resources:
            requests: { cpu: "2", memory: 4Gi }
            limits:   { cpu: "4", memory: 8Gi }
          startupProbe:
            exec:
              command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
            periodSeconds: 5
            failureThreshold: 60
          livenessProbe:
            exec:
              command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"]
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
          securityContext:
            runAsNonRoot: true
            runAsUser: 999
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ "ALL" ]
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
            - { name: tmp,  mountPath: /tmp }
            - { name: run,  mountPath: /var/run/postgresql }
      volumes:
        - name: tmp
          emptyDir: {}
        - name: run
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: data
        labels:
          app.kubernetes.io/name: postgres
          app.kubernetes.io/instance: postgres-identity
          app.kubernetes.io/component: database
          app.kubernetes.io/part-of: identity-platform
          app.kubernetes.io/managed-by: argocd
      spec:
        accessModes: [ "ReadWriteOnce" ]
        storageClassName: longhorn-replicated
        resources:
          requests:
            storage: 200Gi

왜 좋은가:

  • headless Service + clusterIP Service 양쪽 선언 (peer discovery + 일반 client)
  • persistentVolumeClaimRetentionPolicy: {whenDeleted: Retain, whenScaled: Retain} 명시 (GA 1.27)
  • podManagementPolicy: OrderedReady + updateStrategy.partition: 0 (canary 시 1씩)
  • zone topologySpread DoNotSchedule로 강제 (DB는 AZ 분산이 강건성 핵심)
  • Secret 외부 참조 (External Secrets로 관리 가정)
  • StorageClass longhorn-replicated (local-path 금지)

실전 주의: 1000-서비스 스케일에서는 raw StatefulSet 대신 CloudNativePG Operator 사용을 강력 권장. 이 예시는 operator 불가 케이스의 reference.


좋은 예시 3: fluent-bit DaemonSet (로그 shipper)

apiVersion: v1
kind: ServiceAccount
metadata:
  name: fluent-bit
  namespace: prod-platform-observability
  labels:
    app.kubernetes.io/name: fluent-bit
    app.kubernetes.io/instance: fluent-bit-prod
    app.kubernetes.io/component: log-shipper
    app.kubernetes.io/part-of: observability-platform
    app.kubernetes.io/managed-by: argocd
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: prod-platform-observability
  labels:
    app.kubernetes.io/name: fluent-bit
    app.kubernetes.io/instance: fluent-bit-prod
    app.kubernetes.io/version: "3.1.7"
    app.kubernetes.io/component: log-shipper
    app.kubernetes.io/part-of: observability-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: fluent-bit
      app.kubernetes.io/instance: fluent-bit-prod
      app.kubernetes.io/component: log-shipper
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 10%
  revisionHistoryLimit: 5
  template:
    metadata:
      labels:
        app.kubernetes.io/name: fluent-bit
        app.kubernetes.io/instance: fluent-bit-prod
        app.kubernetes.io/component: log-shipper
        app.kubernetes.io/part-of: observability-platform
        app.kubernetes.io/managed-by: argocd
    spec:
      serviceAccountName: fluent-bit
      automountServiceAccountToken: true
      priorityClassName: system-node-critical
      hostNetwork: false
      terminationGracePeriodSeconds: 30
      tolerations:
        - operator: Exists
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: fluent-bit
          image: registry.example.com/fluent-bit@sha256:112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00
          imagePullPolicy: IfNotPresent
          ports:
            - { name: metrics, containerPort: 2020, protocol: TCP }
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits:   { cpu: 500m, memory: 256Mi }
          livenessProbe:
            httpGet: { path: /, port: metrics }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /api/v1/health, port: metrics }
            periodSeconds: 5
            failureThreshold: 3
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ "ALL" ]
              add: [ "DAC_READ_SEARCH" ]
          volumeMounts:
            - { name: varlog, mountPath: /var/log, readOnly: true }
            - { name: varlibdockercontainers, mountPath: /var/lib/docker/containers, readOnly: true }
            - { name: config, mountPath: /fluent-bit/etc }
      volumes:
        - name: varlog
          hostPath: { path: /var/log, type: Directory }
        - name: varlibdockercontainers
          hostPath: { path: /var/lib/docker/containers, type: DirectoryOrCreate }
        - name: config
          configMap: { name: fluent-bit-config }

왜 좋은가:

  • DaemonSet으로 모든 노드에 정확히 1 Pod
  • tolerations: Exists로 control-plane taint 포함 모든 노드 커버
  • priorityClassName: system-node-critical로 eviction 방지
  • root 필요(hostPath 로그 읽기) 하지만 capabilities는 DAC_READ_SEARCH만 추가하고 나머지 drop
  • updateStrategy.rollingUpdate.maxUnavailable: 10%로 대규모 클러스터 rolling 안정화

Bad counterpart (같은 역할을 Deployment로)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: fluent-bit
spec:
  replicas: 10

문제: Deployment는 특정 노드에 Pod가 없을 수 있고, 같은 노드에 여러 Pod가 떠서 로그 중복 수집. DaemonSet만 "노드당 정확히 1"을 보장.


좋은 예시 4: flyway-migrate Job (ArgoCD PostSync hook)

apiVersion: batch/v1
kind: Job
metadata:
  name: flyway-migrate-identity-1-24-3
  namespace: prod-identity-auth
  labels:
    app.kubernetes.io/name: flyway
    app.kubernetes.io/instance: flyway-identity-1-24-3
    app.kubernetes.io/version: "1.24.3"
    app.kubernetes.io/component: schema-migration
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        app.kubernetes.io/name: flyway
        app.kubernetes.io/instance: flyway-identity-1-24-3
        app.kubernetes.io/component: schema-migration
        app.kubernetes.io/part-of: identity-platform
        app.kubernetes.io/managed-by: argocd
    spec:
      restartPolicy: Never
      serviceAccountName: flyway-identity
      automountServiceAccountToken: false
      priorityClassName: tier-1-critical
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: flyway
          image: registry.example.com/flyway@sha256:ccddeeff0011223344556677889900aabbccddeeff0011223344556677889900
          imagePullPolicy: IfNotPresent
          args: [ "migrate" ]
          env:
            - { name: FLYWAY_URL,      valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: url } } }
            - { name: FLYWAY_USER,     valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: username } } }
            - { name: FLYWAY_PASSWORD, valueFrom: { secretKeyRef: { name: flyway-identity-creds, key: password } } }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { cpu: "1",  memory: 512Mi }
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ "ALL" ]
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 32Mi }

왜 좋은가:

  • restartPolicy: Never + backoffLimit: 2 → migration 실패는 debug 가능하게 노출
  • activeDeadlineSeconds: 600 → 무한 lock 방지
  • ttlSecondsAfterFinished: 86400 → 24시간 후 자동 정리 (1000-서비스 스케일 필수)
  • ArgoCD PostSync hook으로 Deployment rollout 이후 실행
  • 이름에 버전 suffix (-1-24-3) → 같은 이름 Job 재생성 충돌 방지

좋은 예시 5: postgres-backup CronJob (timezone + concurrencyPolicy)

apiVersion: batch/v1
kind: CronJob
metadata:
  name: postgres-identity-backup
  namespace: prod-data-postgres
  labels:
    app.kubernetes.io/name: postgres-backup
    app.kubernetes.io/instance: postgres-identity-backup
    app.kubernetes.io/component: backup
    app.kubernetes.io/part-of: identity-platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
spec:
  schedule: "0 */6 * * *"
  timeZone: "Asia/Seoul"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 1
      activeDeadlineSeconds: 3600
      template:
        metadata:
          labels:
            app.kubernetes.io/name: postgres-backup
            app.kubernetes.io/instance: postgres-identity-backup
            app.kubernetes.io/component: backup
            app.kubernetes.io/part-of: identity-platform
            app.kubernetes.io/managed-by: argocd
        spec:
          restartPolicy: Never
          serviceAccountName: postgres-identity-backup
          automountServiceAccountToken: false
          securityContext:
            runAsNonRoot: true
            runAsUser: 999
            runAsGroup: 999
            fsGroup: 999
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: pgbackup
              image: registry.example.com/pgbackup@sha256:ddeeff00112233445566778899aabbccddeeff00112233445566778899aabbcc
              imagePullPolicy: IfNotPresent
              env:
                - { name: PGHOST,     value: postgres-identity.prod-data-postgres.svc.cluster.local }
                - { name: PGUSER,     valueFrom: { secretKeyRef: { name: pgbackup-creds, key: username } } }
                - { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: pgbackup-creds, key: password } } }
                - { name: S3_BUCKET,  value: pg-backups-prod }
                - { name: S3_PREFIX,  value: identity }
              resources:
                requests: { cpu: 200m, memory: 256Mi }
                limits:   { cpu: "1",  memory: 512Mi }
              securityContext:
                runAsNonRoot: true
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop: [ "ALL" ]
              volumeMounts:
                - { name: tmp, mountPath: /tmp }
          volumes:
            - name: tmp
              emptyDir: { sizeLimit: 2Gi }

왜 좋은가:

  • timeZone: Asia/Seoul (v1.25+) → DST 안정
  • concurrencyPolicy: Forbid → 이전 백업이 돌고 있으면 skip
  • startingDeadlineSeconds: 600 → 노드 장애 후 미싱 누적 제한
  • history limit으로 완료 Job 정리 (successfulJobsHistoryLimit: 3, failedJobsHistoryLimit: 5)
  • CronJob의 jobTemplate에는 ttlSecondsAfterFinished를 설정하지 않음 — history limit과 중복/충돌 방지 (standalone Job에서만 사용)

실전 주의: CloudNativePG ScheduledBackup CRD를 쓰면 이 CronJob을 operator가 대체한다.


좋은 예시 6: Ingress controller Deployment + MetalLB (prod 기본)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress-nginx-public
  namespace: prod-platform-ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/instance: ingress-nginx-public-prod
    app.kubernetes.io/version: "1.11.2"
    app.kubernetes.io/component: controller
    app.kubernetes.io/part-of: platform
    app.kubernetes.io/managed-by: argocd
    example.com/environment: prod
    example.com/exposure: public
    example.com/slo-tier: tier-1
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
      app.kubernetes.io/instance: ingress-nginx-public-prod
      app.kubernetes.io/component: controller
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ingress-nginx
        app.kubernetes.io/instance: ingress-nginx-public-prod
        app.kubernetes.io/component: controller
        app.kubernetes.io/part-of: platform
        app.kubernetes.io/managed-by: argocd
    spec:
      serviceAccountName: ingress-nginx
      automountServiceAccountToken: true
      priorityClassName: system-cluster-critical
      terminationGracePeriodSeconds: 300
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        fsGroup: 101
        seccompProfile: { type: RuntimeDefault }
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
              app.kubernetes.io/instance: ingress-nginx-public-prod
              app.kubernetes.io/component: controller
      containers:
        - name: controller
          image: registry.example.com/ingress-nginx@sha256:eeff00112233445566778899aabbccddeeff00112233445566778899aabbccdd
          args:
            - /nginx-ingress-controller
            - --publish-service=$(POD_NAMESPACE)/ingress-nginx-public
            - --election-id=ingress-nginx-public-leader
            - --controller-class=k8s.io/ingress-nginx-public
            - --ingress-class=nginx-public
            - --configmap=$(POD_NAMESPACE)/ingress-nginx-public
          env:
            - { name: POD_NAMESPACE, valueFrom: { fieldRef: { fieldPath: metadata.namespace } } }
            - { name: POD_NAME,      valueFrom: { fieldRef: { fieldPath: metadata.name } } }
          ports:
            - { name: http,    containerPort: 80,   protocol: TCP }
            - { name: https,   containerPort: 443,  protocol: TCP }
            - { name: metrics, containerPort: 10254, protocol: TCP }
          resources:
            requests: { cpu: 500m, memory: 512Mi }
            limits:   { cpu: "2",  memory: 1Gi }
          livenessProbe:
            httpGet: { path: /healthz, port: 10254, scheme: HTTP }
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 5
          readinessProbe:
            httpGet: { path: /healthz, port: 10254, scheme: HTTP }
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/wait-shutdown"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 101
            capabilities:
              drop: [ "ALL" ]
              add:  [ "NET_BIND_SERVICE" ]
          volumeMounts:
            - { name: tmp,        mountPath: /tmp }
            - { name: nginx-etc,  mountPath: /etc/nginx }
            - { name: nginx-cache, mountPath: /var/cache/nginx }
      volumes:
        - name: tmp
          emptyDir: {}
        - name: nginx-etc
          emptyDir: {}
        - name: nginx-cache
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-public
  namespace: prod-platform-ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/instance: ingress-nginx-public-prod
    app.kubernetes.io/component: controller
    app.kubernetes.io/part-of: platform
    app.kubernetes.io/managed-by: argocd
  annotations:
    metallb.universe.tf/address-pool: prod-public-pool
    metallb.universe.tf/allow-shared-ip: "ingress-nginx-public"
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  selector:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/instance: ingress-nginx-public-prod
    app.kubernetes.io/component: controller
  ports:
    - { name: http,  port: 80,  targetPort: http,  protocol: TCP }
    - { name: https, port: 443, targetPort: https, protocol: TCP }

왜 좋은가:

  • Deployment + LoadBalancer (MetalLB L2/BGP) → HPA 가능, 노드 수 ≠ replica 수
  • externalTrafficPolicy: Local → source IP 보존 + 노드 horn-in 회피
  • priorityClassName: system-cluster-critical
  • NET_BIND_SERVICE capability만 추가 (80/443 바인딩), 나머지 drop
  • public / internal IngressClass 분리 가능 (별도 Deployment)

DaemonSet 선택이 맞는 케이스: bare-metal + 외부 LB 없음 + 모든 edge 노드가 고정 IP로 80/443 직접 노출. 이 경우 hostNetwork: true + DaemonSet + tolerations로 edge 노드만 label selector.


나쁜 예시 1: auth-server를 StatefulSet으로

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: auth-server
spec:
  serviceName: auth-server
  replicas: 3
  selector:
    matchLabels: { app: auth-server }
  template:
    metadata: { labels: { app: auth-server } }
    spec:
      containers:
        - name: auth
          image: registry.example.com/auth:1.24.3

문제: stable identity/storage 요구가 없는 stateless 앱에 StatefulSet. rolling update가 OrderedReady로 느려지고, replica 증설 시 auth-server-2, auth-server-3 이름이 의미 없이 고정된다. 해결: Deployment + HPA.


나쁜 예시 2: postgres를 Deployment로

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: postgres
          image: postgres:16
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
      volumes:
        - name: data
          persistentVolumeClaim: { claimName: postgres-pvc }

문제: replica=1 Deployment + 단일 PVC는 rolling update 시 잠깐 새 Pod가 뜨면서 같은 PVC에 두 Pod가 붙으려다 RWO 충돌. StatefulSet이면 OrderedReady로 이전 Pod 완전히 내려간 다음 새 Pod가 뜬다. 해결: StatefulSet + volumeClaimTemplates + podManagementPolicy: OrderedReady.


나쁜 예시 3: fluent-bit을 Deployment로 배포

apiVersion: apps/v1
kind: Deployment
metadata:
  name: fluent-bit
spec:
  replicas: 10
  template:
    spec:
      containers:
        - name: fluent-bit
          image: fluent/fluent-bit:latest
          volumeMounts:
            - { name: varlog, mountPath: /var/log }
      volumes:
        - name: varlog
          hostPath: { path: /var/log }

문제: Deployment는 Pod 배치를 스케줄러에 맡김 → 어떤 노드에는 0 Pod(로그 유실), 어떤 노드에는 2 Pod(중복 수집). 노드 수가 바뀌면 수동으로 replicas 조정. 해결: DaemonSet.


나쁜 예시 4: flyway를 앱 startup에 포함

apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth
spec:
  template:
    spec:
      initContainers:
        - name: flyway-migrate
          image: registry.example.com/flyway:1.24.3
          args: ["migrate"]
      containers:
        - name: auth
          image: registry.example.com/auth:1.24.3

문제: Deployment scale-up 때마다 모든 새 Pod가 migration 시도 → DB lock 경합. migration 실패가 앱 부팅 실패로 섞여 debug 불가. rollback 시 downgrade migration 제어 불가. 해결: 독립 Job + ArgoCD PostSync hook.


나쁜 예시 5: CronJob에 concurrencyPolicystartingDeadlineSeconds 누락

apiVersion: batch/v1
kind: CronJob
metadata:
  name: backup
spec:
  schedule: "*/5 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: backup
              image: registry.example.com/backup:1.0.0

문제:

  1. concurrencyPolicy 미지정 → 기본 Allow → 이전 backup이 오래 걸리면 중복 실행, PVC lock 충돌
  2. startingDeadlineSeconds 미지정 → 노드 장애 후 수십 개 missed Job이 한꺼번에 생성
  3. history limit 미지정 → 완료 Job이 무한 누적

해결: 모든 prod CronJob에 concurrencyPolicy: Forbid + startingDeadlineSeconds: <short> + history limit.


나쁜 예시 6: StatefulSet에 persistentVolumeClaimRetentionPolicy 미지정 (prod)

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: vault
spec:
  serviceName: vault
  replicas: 3
  # persistentVolumeClaimRetentionPolicy missing
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ "ReadWriteOnce" ]
        storageClassName: longhorn-replicated
        resources:
          requests: { storage: 20Gi }

문제: 명시가 없으면 기본값 ({whenDeleted: Retain, whenScaled: Retain})이 적용되어 "동작은 맞지만" 의도가 코드에 드러나지 않는다. 팀원이 {Delete, Delete}인지 추측. 1000-서비스 스케일에서는 모든 StatefulSet이 이 필드를 명시해야 정책이 audit 가능. 해결: 항상 명시.