13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] Kubernetes 확장

  • [Cloud] Crossplane GitOps로 멀티 클라우드 인프라 운영 전략

    [Cloud] Crossplane GitOps로 멀티 클라우드 인프라 운영 전략

    Crossplane GitOps로 멀티 클라우드 인프라 운영 전략 잡기

    Crossplane GitOps를 붙이면 처음엔 다 비슷한 착각을 하더라고요. Git에 YAML만 넣으면 멀티 클라우드 운영이 정리될 거라고요. 그런데 실제 운영에선 YAML 문법보다 인터페이스를 어디까지 추상화할지, 어느 계정과 어느 클러스터에 누가 적용 권한을 가질지, 실패했을 때 어느 계층에서 멈췄는지 어떻게 판별할지가 훨씬 더 중요합니다.

    특히 AWS, GCP, Azure가 섞여 있고 환경마다 네트워크, 계정, 규정이 다르면 더 그렇습니다. 이때 Crossplane GitOps의 진짜 가치는 단순히 인프라를 Kubernetes 안으로 가져오는 데 있지 않습니다. 제가 현업에서 더 크게 느낀 건 개발팀이 보는 요청 인터페이스와 플랫폼 팀이 유지하는 구현 세부를 분리할 수 있다는 점이었어요. 멀티 클라우드 운영이 어려운 이유는 클라우드가 여러 개라서가 아니라, 요청 인터페이스와 실행 책임이 뒤엉키기 때문이거든요.

    그래서 이 글은 단순 설치 가이드로 가지 않겠습니다. Crossplane GitOps를 언제 쓰면 이득이 커지고, 언제는 오히려 운영 복잡도만 늘어나는지, 그리고 실제 설계에서 먼저 봐야 할 기준을 중심으로 정리해보겠습니다.

    Crossplane GitOps 기반 멀티 클라우드 관리 아키텍처 다이어그램

    Git 저장소, Argo CD, Crossplane, 여러 클라우드 또는 클러스터가 어떻게 연결되는지 한눈에 보여주는 아키텍처 이미지 위치입니다.

    Crossplane GitOps가 멀티 클라우드에서 유리한 이유, 그리고 아닌 경우

    제가 이 조합을 높게 보는 이유는 하나입니다. 요청은 공통 API로 받고, 실행은 환경별 구현으로 분기할 수 있기 때문입니다. 개발팀은 비슷한 형식의 리소스를 요청하고, 플랫폼 팀은 뒤에서 AWS용 Composition을 태울지 GCP용 Composition을 태울지 고릅니다. 이 구조가 잡히면 클라우드 차이가 사용자 경험으로 바로 새지 않아요.

    • 적합한 경우: 개발팀 셀프서비스가 필요하고, 플랫폼 팀이 공통 API를 설계할 역량이 있을 때
    • 적합한 경우: 이미 Argo CD나 Flux 같은 GitOps 흐름이 있고, 인프라도 같은 승인 체계로 묶고 싶을 때
    • 보류할 경우: 팀마다 요구사항 차이가 커서 공통 인터페이스가 거의 안 나올 때
    • 보류할 경우: 단일 클라우드 비중이 높고 Terraform 워크스페이스 수준으로도 운영이 충분히 정리될 때

    여기서 핵심 판단 기준은 반복되는 요청이 실제로 존재하느냐입니다. Namespace, Object Storage, 기본 권한 연결처럼 반복 요청이 많고 정책을 통일해야 하는 자원은 Crossplane GitOps와 잘 맞습니다. 반대로 일회성 예외 구성, 팀별 편차가 큰 네트워크 토폴로지, 사람 승인 없이는 못 건드리는 보안 자원은 억지로 추상화하면 디버깅 비용만 올라갑니다.

    Crossplane GitOps 설계 전에 먼저 못 박아야 하는 결정

    Crossplane GitOps가 꼬이는 팀은 대개 YAML을 늦게 배워서가 아니라 결정 순서가 뒤집혀서 그렇습니다. 이걸 정하지 않고 XRD부터 만들면 나중에 스키마를 몇 번씩 뜯어고치게 됩니다. 이거 생각보다 꽤 아픕니다.

    의사결정 항목 선택지 이럴 때 권장 트레이드오프
    제어면 토폴로지 중앙 Crossplane 1개 플랫폼 팀이 강하고 공통 정책을 한곳에서 관리해야 할 때 장애 반경이 커지고 ProviderConfig 권한 경계 설계가 더 중요해집니다.
    제어면 토폴로지 환경별 Crossplane 분리 계정·망 분리가 엄격하고 운영 책임이 환경별로 나뉠 때 중복 배포와 업그레이드 비용이 늘지만 장애 격리가 쉽습니다.
    API 노출 방식 Namespaced XR Crossplane v2 신규 설계를 시작할 때 최신 권장 방식이지만 기존 Claim 중심 운영 모델과는 설계 습관이 달라집니다.
    API 노출 방식 Claim v1 스타일 API를 이미 쓰고 있거나 호환 운영이 필요할 때 Crossplane v2 신규 기본 모델은 아니므로 장기적으로는 XR 중심 전환을 검토해야 합니다.
    추상화 단위 얇은 API 셀프서비스를 빨리 열고 장애 분석 시간을 줄여야 할 때 리소스 수는 늘지만 실패 지점이 선명합니다.
    클라우드 분기 방식 Composition 분리 AWS/GCP/Azure 구현 차이가 큰 경우 구현은 길어지지만 디버깅과 변경 영향도 파악이 쉽습니다.
    자격 증명 모델 ProviderConfig 환경별 분리 계정, 프로젝트, 구독이 다르고 감사 경계가 중요할 때 Secret 회전과 참조 관계를 별도 운영해야 합니다.

    제가 실제로는 이렇게 봅니다. 멀티 클라우드라고 해서 처음부터 공통 API를 넓게 잡지 마세요. 공통분모가 작으면 추상화도 작아야 합니다. Namespace, Bucket, 기본 IAM 연동처럼 공통성이 높은 것부터 묶고, 네트워크처럼 클라우드별 의미가 달라지는 영역은 늦게 가져오는 편이 훨씬 덜 아픕니다.

    실전 구현 1: 저장소 분리와 기본 설치는 이렇게 가져가면 덜 꼬입니다

    실무에서는 저장소 구조가 운영 모델을 거의 결정합니다. 저는 보통 platform, providers, claims-or-xrs를 분리합니다. 이유는 단순합니다. 변경 주기와 승인 권한이 다르기 때문입니다. 플랫폼 팀이 XRD와 Composition을 바꾸는 행위와, 서비스 팀이 요청 리소스 하나 추가하는 행위를 같은 PR 정책으로 묶으면 금방 병목이 생깁니다.

    1. platform: XRD, Composition, Function
    2. providers: Provider, ProviderConfig, Secret 참조 정책
    3. claims-or-xrs: 팀별 요청 리소스

    최신 Crossplane v2 기준으로는 Namespaced XR이 기본이고, Claim은 신규 기본 모델이 아닙니다. 다만 기존 v1 스타일 API를 유지하는 환경이라면 Claim 패턴을 계속 쓸 수는 있습니다. 그래서 신규 도입 팀이라면 XR 중심으로 시작하고, 기존 팀이라면 Claim을 호환 운영 대상으로 보는 편이 더 안전합니다.

    Crossplane 자체 설치는 현재 문서 기준으로 Helm chart가 가장 깔끔합니다. 그리고 Patch & Transform을 쓸 거라면 Composition Function도 같이 올려두는 편이 좋습니다. 나중에 예제를 확장할 때 진짜 편하더라고요.

    helm repo add crossplane-stable https://charts.crossplane.io/stable
    helm repo update
    
    helm install crossplane \
      --namespace crossplane-system \
      --create-namespace \
      crossplane-stable/crossplane
    
    kubectl get pods -n crossplane-system
    
    kubectl apply -f - <<'EOF'
    apiVersion: pkg.crossplane.io/v1
    kind: Function
    metadata:
      name: function-patch-and-transform
    spec:
      package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2
    EOF
    
    kubectl get functions

    GitOps 도구는 Crossplane를 애플리케이션 하나로 보지 않는 편이 좋습니다. 제가 권하는 최소 단위는 core, platform, workloads 세 층입니다. 이유는 순서와 장애 반경이 다르기 때문입니다. XRD가 아직 준비되지 않았는데 XR이나 Claim이 먼저 들어오면, YAML은 정상인데 동기화가 실패하는 황당한 상황이 바로 나옵니다.

    Argo CD를 쓰면 sync wave 또는 앱 분리로 순서를 고정하는 편이 안전합니다. 또 Crossplane 공식 가이드처럼 annotation 기반 리소스 추적을 쓰고, ProviderConfigUsage는 UI에서 제외하는 구성이 운영 노이즈를 줄이는 데 도움이 됩니다.

    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: crossplane-platform
      namespace: argocd
    spec:
      project: default
      source:
        repoURL: https://git.example.com/platform/infra.git
        targetRevision: main
        path: crossplane/platform
      destination:
        server: https://kubernetes.default.svc
        namespace: crossplane-system
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
          - ApplyOutOfSyncOnly=true
          - SkipDryRunOnMissingResource=true

    여기서 포인트는 세 가지입니다. CreateNamespace=true는 기본 편의고, SkipDryRunOnMissingResource=true는 CRD가 먼저 생기는 구조에서 불필요한 실패를 줄여줍니다. 그리고 앱이 커지기 시작하면 ApplyOutOfSyncOnly=true를 고려할 만합니다. Claim이나 XR 수가 많아질수록 매 sync마다 전체 리소스를 다시 건드리는 비용이 꽤 느껴지거든요.

    Crossplane GitOps 저장소 구조와 Argo CD 동기화 흐름 이미지

    플랫폼 정의, Provider 설정, Claim 또는 XR 배포가 어떤 순서로 Git에서 흘러가는지 보여주는 구성도 위치입니다.

    실전 구현 2: XR 하나로 클라우드별 구현 갈라타기

    최신 Crossplane v2 신규 설계라면 예시는 Claim보다 Namespaced XR로 잡는 편이 맞습니다. 여러 클러스터에 걸쳐 Namespace를 만드는 패턴은 작지만 실무적인 예제예요. 추상화가 맞는지 틀린지는 거대한 데이터베이스보다 이런 작은 자원에서 먼저 드러나는 경우가 많습니다.

    먼저 provider-kubernetes와 ProviderConfig를 준비합니다. 서로 다른 대상 클러스터에 apply하는 흐름이므로 kubeconfig Secret 경계를 분명히 두는 게 핵심입니다. 운영에서 제일 흔한 사고는 권한이 부족해서 실패하는 경우보다, 잘못된 대상 클러스터에 정상 적용되는 경우입니다. 그래서 ProviderConfig 이름에 환경과 대상 의미를 같이 넣는 걸 권합니다.

    apiVersion: pkg.crossplane.io/v1
    kind: Provider
    metadata:
      name: provider-kubernetes
    spec:
      package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.3.0
    ---
    apiVersion: kubernetes.m.crossplane.io/v1alpha1
    kind: ProviderConfig
    metadata:
      name: aws-dev-cluster
      namespace: crossplane-system
    spec:
      credentials:
        source: Secret
        secretRef:
          namespace: crossplane-system
          name: aws-dev-cluster-kubeconfig
          key: kubeconfig
    ---
    apiVersion: kubernetes.m.crossplane.io/v1alpha1
    kind: ProviderConfig
    metadata:
      name: gcp-prod-cluster
      namespace: crossplane-system
    spec:
      credentials:
        source: Secret
        secretRef:
          namespace: crossplane-system
          name: gcp-prod-cluster-kubeconfig
          key: kubeconfig

    그다음 XRD와 Composition입니다. 아래 예시는 사용자에게는 동일한 PlatformNamespace만 보이고, 실제 구현은 AWS용과 GCP용 Composition으로 갈라집니다. 만약 기존 운영이 Claim 중심이라면 이 패턴을 v1 스타일 API와 함께 유지할 수는 있지만, 신규 도입이라면 XR로 시작하는 편이 덜 헷갈립니다.

    apiVersion: apiextensions.crossplane.io/v2
    kind: CompositeResourceDefinition
    metadata:
      name: platformnamespaces.platform.example.org
    spec:
      scope: Namespaced
      group: platform.example.org
      names:
        kind: PlatformNamespace
        plural: platformnamespaces
      versions:
      - name: v1alpha1
        served: true
        referenceable: true
        schema:
          openAPIV3Schema:
            type: object
            properties:
              spec:
                type: object
                properties:
                  parameters:
                    type: object
                    properties:
                      name:
                        type: string
                      provider:
                        type: string
                        enum:
                        - aws
                        - gcp
                    required:
                    - name
                    - provider
                required:
                - parameters
    ---
    apiVersion: apiextensions.crossplane.io/v1
    kind: Composition
    metadata:
      name: platformnamespace-aws
      labels:
        provider: aws
    spec:
      compositeTypeRef:
        apiVersion: platform.example.org/v1alpha1
        kind: PlatformNamespace
      mode: Pipeline
      pipeline:
      - step: patch-and-transform
        functionRef:
          name: function-patch-and-transform
        input:
          apiVersion: pt.fn.crossplane.io/v1beta1
          kind: Resources
          resources:
          - name: namespace
            base:
              apiVersion: kubernetes.m.crossplane.io/v1alpha1
              kind: Object
              metadata:
                namespace: crossplane-system
              spec:
                forProvider:
                  manifest:
                    apiVersion: v1
                    kind: Namespace
                    metadata:
                      name: placeholder
                providerConfigRef:
                  name: aws-dev-cluster
            patches:
            - type: FromCompositeFieldPath
              fromFieldPath: spec.parameters.name
              toFieldPath: spec.forProvider.manifest.metadata.name
    ---
    apiVersion: apiextensions.crossplane.io/v1
    kind: Composition
    metadata:
      name: platformnamespace-gcp
      labels:
        provider: gcp
    spec:
      compositeTypeRef:
        apiVersion: platform.example.org/v1alpha1
        kind: PlatformNamespace
      mode: Pipeline
      pipeline:
      - step: patch-and-transform
        functionRef:
          name: function-patch-and-transform
        input:
          apiVersion: pt.fn.crossplane.io/v1beta1
          kind: Resources
          resources:
          - name: namespace
            base:
              apiVersion: kubernetes.m.crossplane.io/v1alpha1
              kind: Object
              metadata:
                namespace: crossplane-system
              spec:
                forProvider:
                  manifest:
                    apiVersion: v1
                    kind: Namespace
                    metadata:
                      name: placeholder
                providerConfigRef:
                  name: gcp-prod-cluster
            patches:
            - type: FromCompositeFieldPath
              fromFieldPath: spec.parameters.name
              toFieldPath: spec.forProvider.manifest.metadata.name
    ---
    apiVersion: platform.example.org/v1alpha1
    kind: PlatformNamespace
    metadata:
      namespace: platform-team
      name: observability
    spec:
      crossplane:
        compositionSelector:
          matchLabels:
            provider: aws
      parameters:
        name: observability
        provider: aws

    여기서 제가 꼭 보는 판단 기준이 있습니다. 분기 기준을 사용자에게 직접 노출할지, 운영 정책으로 숨길지입니다. 팀이 아직 멀티 클라우드 배치 정책을 이해하고 선택할 단계가 아니면 provider 같은 필드를 API에 열어주지 않는 편이 낫습니다. 반대로 플랫폼 팀이 모든 배치 결정을 대신하면서 병목이 심하면, 제한된 선택지를 노출하는 게 더 현실적일 때도 있어요.

    Crossplane GitOps의 Composition 선택과 멀티 클라우드 배포 흐름

    AWS용 Composition과 GCP용 Composition이 같은 요청 인터페이스를 받아 서로 다른 대상 클러스터로 배포하는 흐름을 설명하는 이미지 위치입니다.

    실전에서 자주 터지는 문제와 근본 원인

    운영에서 힘든 지점은 YAML 자체가 아닙니다. 대부분의 장애는 Composition 선택, ProviderConfig 인증, 외부 API 응답, GitOps 적용 순서에서 생깁니다. 저도 초기에 이 구간에서 제일 오래 헤맸습니다.

    1. CRD와 요청 리소스를 같은 배포 단위로 넣었더니 가끔만 성공

    증상: 어떤 날은 되고 어떤 날은 안 됩니다. Argo CD에서는 리소스가 다 보이는데, 실제 sync에서는 XR이나 Claim이 먼저 들어가며 the server could not find the requested resource 비슷한 오류가 납니다.

    근본 원인: 선언은 동시에 넣었지만, 컨트롤 플레인 입장에서는 XRD 등록과 이를 참조하는 리소스 생성이 완전히 다른 단계이기 때문입니다.

    실무 처방: 앱을 분리하거나 sync wave를 명시하세요. 이 문제는 설치 순서라기보다 배포 계약 문제에 가깝습니다.

    2. ProviderConfig 인증 문제인데 요청 리소스만 들여다봄

    증상: XR은 생성됐는데 Ready가 끝까지 올라오지 않습니다.

    근본 원인: kubeconfig Secret 참조 오류, 잘못된 컨텍스트, 대상 클러스터 RBAC 부족, 만료된 토큰 중 하나인 경우가 많습니다.

    제가 보는 순서는 위에서 아래가 아니라 아래에서 위입니다. 요청 리소스에서 시작해 결국 Managed Resource와 Provider 로그까지 내려가야 원인이 보입니다.

    kubectl get platformnamespaces.platform.example.org -A
    kubectl get objects.kubernetes.m.crossplane.io -A
    kubectl describe object.kubernetes.m.crossplane.io <object-name> -n crossplane-system
    kubectl describe providerconfig.kubernetes.m.crossplane.io aws-dev-cluster -n crossplane-system
    kubectl -n crossplane-system logs -l pkg.crossplane.io/provider=provider-kubernetes --tail=200
    • XR는 존재하는데 Object가 없으면 Composition 선택이나 함수 입력을 먼저 봅니다.
    • Object는 있는데 Ready=False면 외부 클러스터 적용 실패 가능성이 큽니다.
    • cannot get credentials, secret not found류 메시지는 거의 ProviderConfig 또는 Secret 참조 문제입니다.
    • forbidden가 보이면 kubeconfig는 읽었지만 대상 클러스터 RBAC가 부족한 경우가 많습니다.

    3. 요청 API를 너무 두껍게 만들어서 장애 반경이 커짐

    이건 구조적 문제입니다. Namespace, RoleBinding, NetworkPolicy, ExternalSecret, StorageClass 참조까지 한 API에 다 넣으면 처음엔 멋있어 보여요. 그런데 하나만 실패해도 전체 요청 실패로 뭉개집니다. 멀티 클라우드에서는 실패 원인이 클라우드별로 달라서, 한 API는 한 책임 원칙을 거의 고정으로 가져가는 편이 훨씬 낫습니다.

    • 네임스페이스 생성은 네임스페이스 생성
    • 권한 바인딩은 별도 API
    • 스토리지는 스토리지
    • 데이터 서비스는 데이터 서비스

    4. Composition 변경이 기존 XR 전체에 바로 전파됨

    증상: 플랫폼 팀이 Composition을 수정했는데, 의도하지 않게 기존 요청들도 동작이 바뀝니다.

    근본 원인: Crossplane는 Composition 변경을 revision으로 추적하고, XR은 기본적으로 최신 revision을 자동 추종할 수 있기 때문입니다.

    실무 처방: 변경 위험이 큰 리소스는 compositionUpdatePolicy: Manual을 검토하세요. 데이터 서비스, 네트워크, 권한 모델처럼 기존 인스턴스가 한꺼번에 바뀌면 안 되는 자원은 자동 추종보다 수동 승격이 훨씬 안전합니다.

    apiVersion: platform.example.org/v1alpha1
    kind: PlatformNamespace
    metadata:
      namespace: platform-team
      name: observability
    spec:
      crossplane:
        compositionUpdatePolicy: Manual
        compositionRef:
          name: platformnamespace-aws
      parameters:
        name: observability
        provider: aws

    5. Git에서 지웠더니 실제 리소스 삭제까지 이어져 버림

    GitOps를 인프라에 붙일 때 가장 민감한 건 생성보다 삭제입니다. Namespace나 버킷처럼 파급효과가 큰 자원은 PR 하나의 merge가 곧바로 파괴 작업으로 이어지지 않게 설계해야 합니다.

    실무 처방: 삭제 위험이 큰 자원은 저장소를 분리하거나, PR 승인 정책을 강화하거나, Argo CD의 삭제 확인 옵션을 검토하세요. Crossplane GitOps는 생성 자동화보다 삭제 통제를 먼저 설계한 팀이 훨씬 안정적이었습니다.

    검증 포인트: 무엇을 봐야 진짜 성공인지

    배포가 끝났다고 끝이 아닙니다. 저는 항상 세 층을 분리해서 봅니다. Git 상태, Crossplane 상태, 실제 대상 상태입니다. 이 셋 중 하나라도 빠지면 동기화는 됐는데 서비스는 안 되는 애매한 상태가 생깁니다.

    1. GitOps 도구에서 애플리케이션 sync와 health가 정상인지 확인
    2. XR, Composition, Managed Resource가 모두 생성됐는지 확인
    3. 조건 값에서 Synced와 Ready를 구분해 읽기
    4. 대상 클러스터 또는 클라우드에 실제 자원이 생겼는지 직접 검증
    kubectl get platformnamespace observability -n platform-team -o yaml
    kubectl get platformnamespaces.platform.example.org -A -o custom-columns=NAME:.metadata.name,SYNCED:.status.conditions[?(@.type=="Synced")].status,READY:.status.conditions[?(@.type=="Ready")].status
    kubectl get objects.kubernetes.m.crossplane.io -A -o custom-columns=NAME:.metadata.name,SYNCED:.status.conditions[?(@.type=="Synced")].status,READY:.status.conditions[?(@.type=="Ready")].status
    kubectl --kubeconfig ./aws-dev-cluster.kubeconfig get ns observability

    Argo CD가 Healthy여도 외부 클러스터 리소스 생성까지 보장하지는 않습니다. 반대로 Crossplane Object가 Synced라고 해도 대상 클러스터에서 admission webhook이나 정책 엔진이 막고 있을 수 있습니다. 그래서 대시보드도 정상 개수보다 Ready=False 목록과 최근 이벤트 위주로 보는 편이 훨씬 실용적입니다.

    Crossplane GitOps 운영 검증 체크리스트와 선택 가이드 인포그래픽

    검증 순서, 상태값 판독 기준, 상황별 추천 선택을 정리한 요약 인포그래픽 위치입니다.

    운영 권장안: 이런 팀이면 이렇게 가는 편이 낫습니다

    • 클라우드가 1~2개이고 플랫폼 팀이 작다: API는 얇게 두고, ProviderConfig만 환경별로 분리하세요. 초반에는 추상화의 아름다움보다 장애 분석 속도가 더 중요합니다.
    • 플랫폼 팀과 개발팀이 분리돼 있다: XRD 스키마부터 먼저 고정하세요. 요청 저장소와 platform 저장소 권한을 분리하는 게 운영 비용을 줄입니다.
    • 클라우드마다 자원 모델 차이가 크다: 억지 공통분모를 만들지 말고 Composition을 클라우드별로 나누세요. 구현 중복보다 잘못된 추상화가 더 비쌉니다.
    • 변경 승인과 감사 추적이 중요하다: 사용자 요청 merge와 Composition 변경을 같은 승인 흐름에 두지 마세요. 위험도가 다릅니다.
    • 삭제 사고가 치명적이다: GitOps 자동화 범위에서 삭제를 별도로 취급하세요. 생성 자동화보다 삭제 통제가 먼저입니다.

    반대로 요청 패턴이 아직 안정되지 않았거나, 예외가 표준보다 많거나, 운영팀이 Kubernetes CRD 디버깅 경험이 부족하다면 조금 멈추는 게 맞습니다. Crossplane가 만능 해답은 아니거든요.

    제가 실제로는 보통 이렇게 시작합니다. Namespace → Object Storage → 기본 권한 연결 → 데이터 서비스 순서입니다. 공통성이 높은 자원부터 열고, 실패 비용이 큰 자원은 뒤로 미루는 편이 운영 충격이 적었습니다.

    자주 묻는 질문과 마지막 판단 기준

    Crossplane GitOps가 Terraform을 완전히 대체하나요?

    항상 그렇지는 않습니다. 다만 플랫폼 API를 Kubernetes 안에 두고, 서비스 팀 요청을 PR 기반으로 받으며, 멀티 클라우드 구현을 뒤로 숨겨야 한다면 Crossplane GitOps가 훨씬 자연스럽습니다. 반대로 공통 API보다 프로젝트별 세밀한 조정이 더 중요하면 Terraform이 더 단순할 때도 많습니다.

    멀티 클라우드 운영을 위해 꼭 복잡한 추상화가 필요한가요?

    아닙니다. 오히려 복잡한 추상화를 기본값으로 두지 않는 편이 낫습니다. 반복되는 요청이 명확할 때만 추상화하고, 반복되지 않으면 직결 구성이 더 낫습니다.

    처음 도입하는 팀이라면 어디까지를 1차 목표로 잡는 게 좋을까요?

    제가 추천하는 1차 목표는 간단합니다. 사용자가 같은 형식의 XR 또는 Claim을 제출하고, 플랫폼 팀이 ProviderConfig와 Composition으로 대상 환경을 통제할 수 있는 상태까지입니다. 거기서 이미 운영 가치가 나옵니다. 그다음에 Composition Revision, Secret 회전, 환경 승격, 정책 검증을 붙이는 순서가 훨씬 안정적입니다.

    관련 글도 함께 보시면 흐름이 더 잘 잡힙니다. Argo CD sync wave 설계 가이드, Kubernetes GitOps 운영 체크리스트도 이어서 확인해보세요.

    마지막 판단 기준을 한 줄로 줄이면 이겁니다. Crossplane GitOps는 인프라 자동화 도구라기보다 플랫폼 인터페이스 설계 도구에 가깝습니다. AWS와 GCP를 함께 다루는 팀이라면 API는 좁고 명확하게, Composition은 클라우드별로 분리해서 시작하세요. 반대로 단일 클라우드에 가깝거나 반복 요청이 적다면 굳이 복잡한 추상화를 도입하지 않는 편이 더 낫습니다.

  • [Kubernetes] Kubernetes CRD 심층 분석: 커스텀 리소스 정의 및 활용 전략

    [Kubernetes] Kubernetes CRD 심층 분석: 커스텀 리소스 정의 및 활용 전략

    [Kubernetes] Kubernetes CRD 심층 분석: 커스텀 리소스 정의 및 활용 전략

    Kubernetes CRD는 쿠버네티스(Kubernetes, 컨테이너 오케스트레이션 플랫폼)를 단순히 “파드를 띄우는 도구”에서 끝내지 않고, 우리 조직의 운영 규칙을 클러스터 안으로 끌고 들어오게 만드는 핵심 확장 포인트입니다. 처음엔 저도 이게 그냥 YAML 하나 더 만드는 기능인가 싶었는데, 실제로 써보니까 운영 지식을 API로 승격시키는 도구에 가깝더라고요. 특히 반복되는 인프라 작업이 많거나, 특정 서비스 배포 규칙을 팀 표준으로 강제하고 싶을 때 Kubernetes CRD의 진가가 확실히 드러납니다.

    제가 홈랩과 실무 환경에서 Kubernetes를 오래 만지면서 느낀 건, 사람이 문서 보고 수동으로 맞추는 방식은 결국 어딘가에서 틀어진다는 점이었습니다. “이 서비스는 TLS가 꼭 있어야 한다”, “이 애플리케이션은 백업 정책이 필요하다”, “이 데이터베이스는 복구 절차가 정해져 있다” 같은 규칙을 문서로만 남겨두면요. 언젠가는 빠집니다. 근데 Kubernetes CRD와 Operator 패턴(Operator Pattern, 운영 자동화 패턴)을 같이 쓰면, 그 규칙을 선언형(Declarative, 원하는 상태를 선언하는 방식)으로 관리할 수 있거든요.

    쿠버네티스 API 서버, etcd, CustomResourceDefinition, 커스텀 컨트롤러의 관계를 한눈에 보여주는 개요 이미지 위치입니다.

    Kubernetes CRD란 무엇인가요?

    쉽게 말해 CRD(CustomResourceDefinition, 커스텀 리소스 정의)는 쿠버네티스에 새로운 리소스 타입을 추가하는 방법입니다. Deployment, Service, Ingress(인그레스, 외부 트래픽 진입점)처럼 원래 있던 리소스 말고, 예를 들어 Database, BackupPolicy, AppRelease 같은 우리만의 리소스를 만들 수 있는 거죠.

    여기서 사람들이 많이 헷갈리는 포인트가 하나 있습니다. CRD 자체는 결국 스키마(schema, 데이터 구조 정의)를 등록하는 일이거든요. 즉, API 서버가 “아, 이제 이런 모양의 리소스도 받을 수 있구나” 하고 이해하게 만드는 단계예요. 그런데 실제로 그 리소스를 보고 뭔가 동작하게 만드는 건 보통 컨트롤러(Controller, 상태를 감시하고 맞추는 제어 루프)가 담당합니다. 저도 처음엔 CRD만 만들면 자동으로 뭔가 굴러갈 줄 알았는데, 아무 일도 안 일어나서 삽질 좀 했습니다 ㅎㅎ

    기본 구성 요소

    • CRD: 새로운 리소스 타입의 정의
    • Custom Resource: 실제로 생성하는 인스턴스
    • Controller: 원하는 상태와 현재 상태를 맞추는 로직
    • Operator: 특정 운영 지식을 코드로 구현한 컨트롤러 묶음
    항목 역할 비유
    CRD 새 API 타입 등록 양식(template) 만들기
    Custom Resource 실제 선언 데이터 양식에 값 채워 제출
    Controller 선언을 실제 상태로 반영 담당자가 요청 처리
    Operator 도메인 운영 자동화 숙련 엔지니어의 절차를 자동화

    왜 Kubernetes 확장에 CRD를 쓰는가

    Kubernetes 확장이라고 하면 예전에는 API Aggregation(집계 API)처럼 더 무거운 접근도 있었지만, 대부분의 팀에게는 CRD가 훨씬 현실적입니다. 이유는 분명합니다.

    1. 기존 kubectl과 선언형 워크플로우를 그대로 활용할 수 있습니다.
    2. RBAC(Role-Based Access Control, 역할 기반 접근 제어), admission, GitOps와 잘 맞습니다.
    3. 도메인 개념을 YAML로 표준화할 수 있습니다.
    4. Operator 패턴과 결합하면 반복 작업을 코드로 고정할 수 있습니다.

    예를 들어 운영팀에서 매번 데이터베이스 인스턴스를 만들 때 스토리지 클래스, 백업 주기, 리소스 제한, 비밀 정보(secret) 연결을 공통 규칙으로 적용한다고 해보겠습니다. 이걸 문서로 적어두는 대신 Database라는 커스텀 리소스를 만들고, 컨트롤러가 이를 보고 StatefulSet, Service, Secret 참조, 백업 Job을 구성하게 만들 수 있습니다. 이게 바로 커스텀 리소스와 Operator 패턴이 실무에서 힘을 발휘하는 지점입니다.

    혹시 이런 경험 있으신가요? 배포 문서는 있는데 팀원마다 해석이 조금씩 달라서 결과가 달라지는 상황이요. 저는 그걸 몇 번 겪고 나서, 사람이 읽는 문서와 시스템이 읽는 선언을 분리해야겠다고 생각했습니다. Kubernetes CRD는 바로 그 중간을 메워줍니다.

    실전 구현 1: CRD 정의하기

    이제 가장 단순한 예제로 가보겠습니다. AppClaim이라는 커스텀 리소스를 만들어서, 애플리케이션 배포에 필요한 최소 정보를 선언한다고 가정해보죠. 여기서는 이해를 위해 구조를 단순화했습니다.

    1. API 그룹(group)과 버전(version)을 정합니다.
    2. 리소스 이름과 복수형(plural)을 정합니다.
    3. OpenAPI v3 스키마로 필드를 정의합니다.
    4. 추가 프린터 컬럼으로 kubectl 가독성을 높입니다.
    apiVersion: apiextensions.k8s.io/v1
    kind: CustomResourceDefinition
    metadata:
      name: appclaims.platform.example.com
    spec:
      group: platform.example.com
      scope: Namespaced
      names:
        plural: appclaims
        singular: appclaim
        kind: AppClaim
        shortNames:
          - ac
      versions:
        - name: v1alpha1
          served: true
          storage: true
          schema:
            openAPIV3Schema:
              type: object
              properties:
                spec:
                  type: object
                  required:
                    - image
                    - replicas
                  properties:
                    image:
                      type: string
                    replicas:
                      type: integer
                      minimum: 1
                    port:
                      type: integer
                      minimum: 1
                      maximum: 65535
                    expose:
                      type: boolean
                status:
                  type: object
                  properties:
                    phase:
                      type: string
                    readyReplicas:
                      type: integer
          subresources:
            status: {}
          additionalPrinterColumns:
            - name: Image
              type: string
              jsonPath: .spec.image
            - name: Replicas
              type: integer
              jsonPath: .spec.replicas
            - name: Phase
              type: string
              jsonPath: .status.phase

    이 YAML의 핵심은 spec과 status를 분리한 거예요. spec은 사용자가 원하는 상태이고, status는 컨트롤러가 관찰한 결과입니다. 이 구분이 흐려지면 나중에 운영이 엄청 꼬입니다. 저도 예전에 status 비슷한 값을 spec에 억지로 넣었다가 reconcile loop(리컨실 루프, 상태 동기화 루프)가 지저분해져서 다시 갈아엎은 적이 있습니다.

    kubectl apply -f appclaim-crd.yaml
    kubectl get crd appclaims.platform.example.com

    정상 적용되면 이제 클러스터는 AppClaim이라는 새 리소스 타입을 이해합니다. 여기서 중요한 포인트! 아직은 타입만 생긴 상태입니다. 배포는 자동으로 안 됩니다.

    Kubernetes CRD YAML을 작성하고 적용하는 실전 구성 이미지

    CRD YAML을 작성하고 kubectl로 적용하는 실전 구성 이미지를 넣는 위치입니다.

    실전 구현 2: 커스텀 리소스 생성과 컨트롤러 연결

    다음은 실제 인스턴스를 하나 만들어보겠습니다.

    apiVersion: platform.example.com/v1alpha1
    kind: AppClaim
    metadata:
      name: demo-web
    spec:
      image: nginx:stable
      replicas: 2
      port: 80
      expose: true
    kubectl apply -f demo-appclaim.yaml
    kubectl get appclaims
    kubectl describe appclaim demo-web

    이제 컨트롤러가 이 리소스를 감시하면서 Deployment와 Service를 생성하도록 만들 수 있습니다. 실제 프로덕션에서는 Go와 controller-runtime을 많이 쓰지만, 여기서는 흐름 이해가 목적이니 의사 코드 수준으로 보겠습니다.

    def reconcile(appclaim):
        desired_deployment = build_deployment(appclaim.spec)
        desired_service = build_service(appclaim.spec)
    
        apply_object(desired_deployment)
    
        if appclaim.spec.get("expose"):
            apply_object(desired_service)
    
        update_status(
            appclaim,
            phase="Ready",
            ready_replicas=get_ready_replicas(appclaim.metadata.name)
        )

    실제로 써보니까 이 구조가 왜 좋은지 금방 체감됩니다. 사용자는 AppClaim만 만들면 되고, 배포 세부 구현은 컨트롤러가 맡습니다. 즉, 사용자 인터페이스는 단순하게, 운영 로직은 중앙집중화되는 거죠. 팀 규모가 커질수록 이 차이가 큽니다.

    Operator 패턴과의 연결

    Operator 패턴은 단순 생성 자동화를 넘어섭니다. 설치, 업그레이드, 백업, 복구, 장애 대응 같은 운영 절차를 컨트롤러 안에 녹이는 방식입니다. 대표적으로 데이터베이스, 메시지 큐, 모니터링 스택 같은 상태 저장 워크로드에서 자주 쓰입니다. Kubernetes CRD는 이 Operator의 입력 포맷이 되는 경우가 많고요.

    만약 앱 수준이 아니라 데이터베이스 운영 자동화까지 가고 싶다면, 다음 글에서 StatefulSet과 Operator 설계 포인트도 이어서 다뤄볼 예정입니다. 이전 글에서 다룬 Helm 차트 구조화 내용이 있다면 그것도 같이 보시면 흐름이 더 잘 잡힙니다.

    ⚠️ 주의사항과 트러블슈팅

    여기서부터가 진짜 실무 구간입니다. CRD는 만들기보다 버전 관리와 호환성에서 더 많이 흔들립니다. 제가 직접 해보니 아래 이슈는 거의 한 번씩 다 밟게 되더라고요.

    1. v1alpha1을 너무 오래 끌고 가는 문제

    처음엔 빠르게 실험하려고 v1alpha1로 시작합니다. 저도 그랬고요. 근데 이 상태로 사용자 수가 늘어나면 필드 변경이 무서워집니다. 기존 리소스와의 호환성을 고민해야 하거든요. 가능하면 초기에 필드 의미를 명확히 하고, status 구조도 대충 넣지 말고 의도를 분명히 하세요.

    2. 스키마 검증을 느슨하게 잡는 문제

    “컨트롤러에서 알아서 처리하면 되지”라고 생각하고 schema를 대충 열어두면 나중에 이상한 값이 다 들어옵니다. port에 문자열이 들어오거나, replicas가 0이 되거나, 필수 필드가 비는 식이죠. 그러면 에러 위치가 API 서버가 아니라 애플리케이션 쪽으로 밀려서 디버깅이 더 어려워집니다.

    3. status 업데이트 충돌

    컨트롤러가 status를 자주 갱신하면 resourceVersion 충돌이 날 수 있습니다. 이럴 때는 status subresource를 쓰고, 재시도 로직과 idempotent(멱등성 있는) 설계를 같이 가져가야 합니다. “한 번 더 실행돼도 결과가 같아야 한다” 이 원칙이 정말 중요합니다.

    4. finalizer 처리 누락

    외부 리소스를 만들었다면 finalizer(파이널라이저, 삭제 전 정리 훅)를 빼먹으면 안 됩니다. 삭제 이벤트가 왔을 때 외부 DNS, 스토리지, 인증서 같은 부수 리소스를 정리하지 않으면 유령 자원이 남습니다. 저도 예전에 테스트 환경에서 볼륨이 계속 쌓여서 왜 그런가 봤더니 finalizer 처리 누락이 원인이더라고요.

    kubectl get appclaim demo-web -o yaml
    kubectl api-resources | grep appclaim
    kubectl explain appclaim.spec

    위 명령어 세 개는 문제 생겼을 때 정말 자주 씁니다. 특히 kubectl explain은 Kubernetes CRD가 의도한 구조대로 등록됐는지 빠르게 확인할 때 유용합니다. 💡 작은 팁인데, 추가 프린터 컬럼을 잘 만들어두면 운영 가시성이 크게 좋아집니다.

    Kubernetes CRD 트러블슈팅과 검증 흐름을 보여주는 이미지

    스키마 검증 오류, 컨트롤러 동기화, kubectl 진단 흐름을 설명하는 트러블슈팅 다이어그램 위치입니다.

    검증과 결과 확인

    그럼 무엇을 검증해야 “Kubernetes CRD가 잘 동작한다”고 볼 수 있을까요? 단순히 리소스가 생성됐다는 것만으로는 부족합니다. 저는 보통 아래 순서로 봅니다.

    1. CRD가 정상 등록됐는지 확인합니다.
    2. Custom Resource 생성이 스키마에 맞게 통과하는지 봅니다.
    3. 컨트롤러가 원하는 하위 리소스를 생성하는지 확인합니다.
    4. status가 실제 상태를 반영하는지 확인합니다.
    5. 삭제 시 정리 로직이 정상 동작하는지 검증합니다.
    kubectl get crd
    kubectl get appclaims
    kubectl get deployment,service
    kubectl describe appclaim demo-web
    kubectl delete appclaim demo-web

    정상 흐름이라면 AppClaim을 만들었을 때 Deployment와 Service가 생성되고, 준비가 끝나면 status.phase 같은 필드가 업데이트됩니다. 삭제 시에는 관련 리소스가 정리돼야 하고요. 이 지점에서 드디어 “아, 이제 사람이 수작업으로 안 해도 되네” 하는 느낌이 옵니다. 🎉 이거 진짜 편하더라고요.

    검증 항목 확인 방법 기대 결과
    CRD 등록 kubectl get crd 리소스 타입 노출
    스키마 검증 잘못된 필드로 생성 시도 API 서버에서 거부
    하위 리소스 생성 Deployment/Service 조회 의도한 객체 생성
    Status 반영 kubectl describe 상태 필드 갱신
    삭제 정리 delete 후 잔여 리소스 확인 누수 없이 정리

    커스텀 리소스 상태와 생성된 워크로드를 검증하는 결과 화면 이미지를 넣는 위치입니다.

    언제 CRD를 쓰고, 언제 쓰지 말아야 하나

    모든 문제를 CRD로 풀 필요는 없습니다. 이건 정말 중요합니다. 저도 한때 뭐든지 커스텀 리소스로 만들면 멋있어 보인다고 생각했었는데, 나중엔 운영 복잡도만 올라간 적이 있었습니다.

    • CRD를 쓰면 좋은 경우: 반복되는 운영 절차가 있고, 선언형 API로 표준화할 가치가 있을 때
    • CRD가 과한 경우: 단순 스크립트 한 번이면 끝나는 작업일 때
    • Operator가 필요한 경우: 설치 이후에도 지속적인 상태 관리, 복구, 업그레이드가 필요할 때
    • Helm만으로 충분한 경우: 정적 템플릿 배포가 중심이고 런타임 제어가 크지 않을 때

    한 줄로 정리하면 이렇습니다. 새로운 API가 팀의 언어가 될 수준인지 먼저 보세요. 그 정도가 아니면 단순화하는 편이 낫습니다.

    정리와 FAQ

    Kubernetes CRD는 단순 기능 추가가 아니라, 팀의 운영 지식을 쿠버네티스 API로 모델링하는 방법입니다. CRD, 커스텀 리소스, Kubernetes 확장, Operator 패턴을 한 세트로 이해하면 왜 많은 플랫폼 팀이 이 접근을 쓰는지 감이 오실 겁니다. 저도 처음엔 용어가 너무 많아서 복잡했는데, 결국 핵심은 하나였습니다. “원하는 상태를 잘 정의하고, 그걸 시스템이 계속 맞추게 하자.” 이 철학만 잡히면 훨씬 편해집니다. ✅

    다음 단계로는 컨트롤러 프레임워크(controller-runtime), 상태 전이 설계, CRD 버전 업그레이드 전략까지 이어서 보시면 좋습니다. 다음 글에서 다룰 예정입니다.

    Kubernetes CRD와 Operator 패턴 역할 비교 요약 이미지

    CRD, 커스텀 리소스, 컨트롤러, Operator의 역할을 요약 비교하는 인포그래픽 위치입니다.

    자주 묻는 질문

    Q. CRD만 만들면 자동화가 되나요?
    아닙니다. CRD는 타입 등록이고, 실제 자동화는 보통 컨트롤러가 담당합니다.

    Q. CRD와 Operator는 같은 건가요?
    같지 않습니다. CRD는 API 정의이고, Operator는 운영 지식을 담은 자동화 로직입니다. 둘이 함께 쓰이는 경우가 많습니다.

    Q. 기존 Deployment나 Helm으로 충분한데도 Kubernetes CRD가 필요할까요?
    반복 운영 규칙을 API로 표준화할 필요가 없다면 굳이 안 써도 됩니다. 과한 설계는 오히려 부담이 됩니다.

    Q. 처음 만들 때 가장 중요한 한 가지는 뭔가요?
    spec과 status의 역할을 명확히 나누고, 스키마 검증을 초기에 엄격하게 잡는 겁니다. 여기서 흔들리면 나중에 계속 고생합니다.