13년차의 서버실

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

[태그:] Crossplane

  • [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은 클라우드별로 분리해서 시작하세요. 반대로 단일 클라우드에 가깝거나 반복 요청이 적다면 굳이 복잡한 추상화를 도입하지 않는 편이 더 낫습니다.

  • [인프라] Crossplane 장애 사례로 배우는 멀티 클라우드 인프라 관리

    [인프라] Crossplane 장애 사례로 배우는 멀티 클라우드 인프라 관리

    [인프라] Crossplane 장애 사례로 배우는 멀티 클라우드 인프라 관리

    Crossplane 장애 사례를 한 번이라도 겪어보신 분들은 아실 겁니다. 처음엔 “쿠버네티스(Kubernetes, 컨테이너 오케스트레이션 플랫폼)처럼 리소스를 선언형으로 관리하면 인프라도 깔끔해지겠네?” 싶거든요. 저도 홈랩하고 업무 환경에서 멀티 클라우드 관리 구조를 정리하려고 Crossplane을 붙였었는데, 막상 운영에 들어가니까 컨트롤 플레인(Control Plane, 전체 상태를 조정하는 중앙 제어 계층) 특성 때문에 장애가 생각보다 교묘하게 터지더라고요. 특히 인프라스트럭처 코드(Infrastructure as Code, 코드로 인프라를 정의하는 방식)와 쿠버네티스 API 감각이 섞이면서 원인을 잘못 짚으면 복구가 더 늦어집니다.

    이번 글은 제품 소개보다는 troubleshooting 중심입니다. 제가 직접 해보니 Crossplane은 잘만 쓰면 멀티 클라우드 관리 복잡도를 꽤 줄여주는데, 장애가 났을 때는 “어디서 상태가 꼬였는지”를 읽는 눈이 정말 중요하더라고요. 그래서 오늘은 Crossplane 장애 사례를 바탕으로 어떤 식으로 문제가 드러났고 어떻게 풀어갔는지, 그리고 운영하면서 꼭 챙겨야 할 포인트를 정리해보겠습니다.

    Crossplane 장애 사례를 이해하기 위한 멀티 클라우드 관리 아키텍처 개요

    Crossplane이 여러 클라우드 리소스를 쿠버네티스 컨트롤 플레인으로 관리하는 전체 흐름을 보여주는 이미지입니다.

    1. Crossplane을 왜 멀티 클라우드 관리에 쓰는가

    쉽게 말해 Crossplane은 쿠버네티스 API로 외부 인프라를 다루게 해주는 도구입니다. AWS, GCP, Azure 같은 퍼블릭 클라우드 자원을 쿠버네티스 리소스처럼 선언하고, 원하는 상태(desired state)와 실제 상태(actual state)를 맞추도록 계속 reconcile(리컨실, 상태를 일치시키는 반복 제어)하는 구조죠.

    이게 왜 좋냐면요. 클라우드마다 콘솔도 다르고 권한 체계도 다르고 Terraform 상태 파일 관리도 따로 고민해야 하는데, Crossplane은 적어도 운영 관점에서 제어면을 하나로 모으는 효과가 있습니다. 특히 팀 단위로 표준화된 Composite Resource(복합 리소스)나 Claim(클레임, 사용자 요청 객체)을 만들어두면 개발팀은 세부 클라우드 차이를 몰라도 공통 인터페이스로 인프라를 요청할 수 있거든요.

    • 장점 1: 멀티 클라우드 관리 진입점이 쿠버네티스로 통일돼요.
    • 장점 2: 인프라스트럭처 코드와 GitOps 흐름을 연결하기 좋습니다.
    • 장점 3: 플랫폼 팀이 정책과 표준 구성을 감싸서 제공하기 좋습니다.

    반대로 단점도 분명합니다. Crossplane 자체가 또 하나의 컨트롤 플레인이기 때문에 장애 포인트가 사라지는 게 아니라 다른 계층으로 이동하는 느낌이 있어요. 저도 처음엔 이게 뭔가 싶었는데, 실제로 써보니까 “리소스를 만드는 일”보다 “상태를 해석하는 일”이 더 중요하더라고요.

    2. 핵심 개념 정리: Provider, Composition, Reconciliation

    Crossplane 장애 사례를 이해하려면 구조를 먼저 아주 간단히 잡고 가는 게 좋아요.

    개념 쉽게 말하면 운영 시 체크 포인트
    Provider 클라우드 API와 통신하는 드라이버 인증 정보, 권한, CRD 설치 상태
    Managed Resource 실제 클라우드 리소스와 매핑되는 객체 Ready 조건, 외부 이름, 이벤트
    Composition 여러 리소스를 묶는 설계도 패치, 참조, 필드 연결 오류
    Claim 사용자가 요청하는 추상화된 리소스 상위 상태는 정상인데 하위가 실패할 수 있음
    Reconciliation 원하는 상태로 계속 맞추는 루프 반복 에러, 드리프트, 재시도 패턴

    여기서 중요한 포인트! 겉으로 보이는 Claim이 멀쩡해 보여도 하위 Managed Resource가 실패 중일 수 있어요. 반대로 하위 리소스 하나가 계속 에러를 내면서 전체 Composition이 완료되지 않는 경우도 흔합니다. 저는 초반에 상위 객체만 보고 “왜 안 되지?” 하다가 삽질 좀 했습니다 ㅎㅎ

    3. 실전 구현: 기본 구성과 관찰 포인트

    아래 예시는 개념 설명용으로 단순화한 구조입니다. 특정 클라우드 벤더 기능을 깊게 파기보다는 Crossplane troubleshooting 흐름을 보는 데 집중하시면 됩니다.

    3-1. Provider와 인증 Secret 준비

    1. Crossplane과 Provider가 설치되어 있는지 확인하세요.
    2. 클라우드 인증 정보가 담긴 Secret(시크릿, 민감 정보 저장 객체)을 만듭니다.
    3. ProviderConfig(프로바이더 설정)가 Secret을 올바르게 참조하는지 봅시다.
    kubectl get pods -n crossplane-system
    kubectl get providers
    kubectl get providerconfigs
    kubectl get secrets -n crossplane-system

    제가 실제로 써보니까 첫 장애는 생각보다 단순했어요. 리소스 생성 로직이 아니라 인증 Secret 네임스페이스(namespace, 쿠버네티스 논리적 격리 단위)가 어긋나 있었거든요. 이벤트를 보기 전까지는 Composition 문제인 줄 알았습니다.

    apiVersion: v1
    kind: Secret
    metadata:
      name: cloud-creds
      namespace: crossplane-system
    type: Opaque
    stringData:
      creds: |
        {
          "example": "replace-with-real-credentials"
        }
    ---
    apiVersion: pkg.crossplane.io/v1
    kind: Provider
    metadata:
      name: example-provider
    spec:
      package: xpkg.example/provider
    ---
    apiVersion: example.crossplane.io/v1beta1
    kind: ProviderConfig
    metadata:
      name: default
    spec:
      credentials:
        source: Secret
        secretRef:
          namespace: crossplane-system
          name: cloud-creds
          key: creds

    3-2. Composite Resource와 Claim 정의

    플랫폼 팀이 표준 리소스를 만들 때는 보통 Composition을 씁니다. 예를 들어 네트워크, 데이터베이스, 스토리지를 조합해서 하나의 “애플리케이션용 환경”처럼 제공하는 식이죠.

    apiVersion: apiextensions.crossplane.io/v1
    kind: Composition
    metadata:
      name: xappenvs.platform.example.org
    spec:
      compositeTypeRef:
        apiVersion: platform.example.org/v1alpha1
        kind: XAppEnv
      resources:
        - name: bucket
          base:
            apiVersion: storage.example.crossplane.io/v1beta1
            kind: Bucket
            spec:
              forProvider:
                region: us-east-1
              providerConfigRef:
                name: default
          patches:
            - fromFieldPath: "spec.parameters.region"
              toFieldPath: "spec.forProvider.region"
    apiVersion: platform.example.org/v1alpha1
    kind: AppEnv
    metadata:
      name: demo-appenv
    spec:
      parameters:
        region: us-east-1

    여기서부터는 단순 생성보다 관찰이 중요해요.

    kubectl get appenv
    kubectl describe appenv demo-appenv
    kubectl get managed
    kubectl get events --sort-by=.metadata.creationTimestamp
    Crossplane 장애 사례 분석용 Claim과 Managed Resource 연결 구조

    Claim에서 Composition을 거쳐 실제 Managed Resource가 생성되는 연결 관계를 보여주는 구성도입니다.

    4. Crossplane 장애 사례 1: 리소스는 생성됐는데 Ready가 안 올라오는 경우

    이 사례는 꽤 자주 봐요. 클라우드 콘솔에서는 리소스가 보이는데 쿠버네티스 쪽 상태는 계속 Creating 또는 NotReady에 머무는 거죠. 처음엔 “분명 만들어졌는데 왜 실패지?” 싶었습니다.

    제가 겪었던 원인은 크게 세 가지였어요.

    • 외부 리소스 식별자(external-name) 불일치
    • Provider 권한 부족
    • 후속 조회 API 실패

    Crossplane은 생성만 하는 게 아니라 이후에도 상태를 조회하고 맞춰야 해요. 그래서 create 권한만 있고 read 또는 describe 계열 권한이 빠져 있으면 리소스는 생겨도 Ready 조건이 정상으로 못 올라올 수 있거든요.

    kubectl describe <managed-resource-kind> <resource-name>
    kubectl get <managed-resource-kind> <resource-name> -o yaml

    이때 꼭 볼 부분은 아래입니다.

    1. status.conditions: Ready, Synced 상태가 어떻게 찍히는지
    2. metadata.annotations: external-name 같은 외부 식별자
    3. Events: API 호출 실패 메시지

    실제로 써보니까 “리소스가 존재하니 성공”이라고 보면 안 되더라고요. Crossplane은 상태 일치가 끝나야 진짜 성공이에요.

    5. Crossplane 장애 사례 2: Composition 패치 오류로 엉뚱한 값이 들어간 경우

    이건 진짜 많이 헷갈려요. Claim에 값을 넣었는데 하위 리소스에 반영이 안 되거나 전혀 다른 필드로 들어가는 경우죠. 문법 에러가 아니라서 더 무섭습니다. YAML은 적용됐는데 결과가 이상하거든요.

    제가 처음 삽질했던 포인트는 fromFieldPath와 toFieldPath 오타였어요. 한 글자만 틀려도 조용히 의도와 다르게 흘러갈 수 있습니다. 그리고 일부 필드는 하위 리소스 스키마에 실제로 존재해야 하니까 Crossplane 문제처럼 보여도 사실은 CRD 필드 구조를 잘못 이해한 경우도 많아요.

    kubectl get composition
    kubectl describe composition xappenvs.platform.example.org
    kubectl get xr
    kubectl describe xr <composite-resource-name>

    제가 정리한 확인 순서는 이렇습니다.

    1. Claim의 spec 값이 기대한 형태인지 확인하세요.
    2. Composite Resource(XR)에 값이 전달됐는지 봅시다.
    3. Managed Resource spec에 최종 반영됐는지 확인해요.
    4. 필드 타입이 문자열인지 배열인지, 맵인지 다시 봅시다.

    근데 여기서 중요한 건 Crossplane은 선언형이라 “중간 단계”를 하나씩 따라가야 한다는 점이에요. Terraform처럼 plan 출력 하나 보고 감 잡는 방식과는 결이 좀 다르더라고요.

    5-1. 문제를 줄이는 운영 팁

    • Composition 변수명 규칙을 팀 내에서 고정하세요.
    • region, size, class 같은 공통 필드는 네이밍을 통일해요.
    • 복잡한 패치는 처음부터 크게 만들지 말고 작은 단위로 검증합니다.
    • 변경 후에는 테스트용 Claim을 바로 적용해 이벤트를 확인하세요.
    Crossplane 장애 사례를 추적하는 이벤트 로그 기반 트러블슈팅 장면

    Crossplane 리소스 이벤트와 상태 조건을 보면서 원인을 추적하는 troubleshooting 상황을 표현한 이미지입니다.

    6. Crossplane 장애 사례 3: 멀티 클라우드 관리 환경에서 권한과 한도 이슈가 섞여 터진 경우

    멀티 클라우드 관리가 어려운 이유는 에러 형태가 벤더마다 다르게 보인다는 데 있어요. 어떤 곳은 권한 부족이 명확하게 찍히고, 어떤 곳은 rate limit(요청 제한)이나 quota(할당량) 문제처럼 보이다가 결국 재시도만 반복하기도 하거든요.

    제가 겪은 케이스는 이랬습니다. 한 클라우드에서는 리소스가 잘 만들어졌는데 다른 쪽은 동일한 Claim 패턴으로 계속 실패했어요. 처음엔 Composition 차이인 줄 알았는데 알고 보니 Provider가 쓰는 계정의 권한 범위가 환경마다 달랐습니다. 즉, 코드가 아니라 운영 계정 표준화가 문제였던 거죠.

    증상 겉으로 보이는 현상 실제 원인 후보
    계속 Pending 상위 Claim만 오래 대기 하위 Managed Resource 생성 실패
    반복 재시도 이벤트가 주기적으로 누적 권한 부족, API 제한, 잘못된 참조
    일부만 생성 네트워크는 되고 DB는 실패 Composition 내 특정 리소스 설정 누락
    삭제 지연 오브젝트는 지웠는데 외부 리소스 잔존 finalizer, 외부 API 에러, 종속성 문제

    이런 상황에서는 쿠버네티스 내부만 보면 안 돼요. 클라우드 측 감사 로그나 API 에러도 같이 봐야 합니다. 컨트롤 플레인이 하나라고 해서 장애 원인까지 하나로 줄어드는 건 아니더라고요. 이건 정말 운영하면서 체감했습니다.

    7. ⚠️ 삭제가 안 되는 장애: Finalizer와 외부 리소스 정리 실패

    개인적으로 제일 식은땀 나는 건 삭제 문제였어요. 리소스를 지웠는데 오브젝트가 계속 Terminating 상태로 남아 있고 외부 클라우드 리소스도 깔끔하게 정리되지 않는 경우요. 비용도 문제고 나중에 이름 충돌이나 의존성 꼬임으로 이어지기도 합니다.

    Crossplane은 finalizer(파이널라이저, 삭제 전에 정리 작업을 보장하는 메커니즘)를 사용해서 외부 리소스를 정리한 뒤 객체를 제거해요. 따라서 외부 API 호출이 실패하거나 참조 관계가 꼬이면 삭제가 길어질 수 있습니다.

    kubectl get <managed-resource-kind> <resource-name> -o yaml
    kubectl describe <managed-resource-kind> <resource-name>
    kubectl get events --sort-by=.metadata.creationTimestamp

    여기서 제가 배운 건 무작정 finalizer를 건드리면 안 된다는 점이에요. 물론 정말 예외적인 복구 상황은 있겠지만 먼저 확인해야 합니다.

    1. 외부 리소스가 실제로 삭제 가능한 상태인지 봅시다.
    2. 연결된 종속 리소스가 남아 있는지 확인하세요.
    3. Provider 권한이 delete와 observe까지 포함하는지 다시 봐요.
    4. 이벤트 로그에서 반복되는 에러 메시지를 확인하세요.

    ⚠️ 운영 팁: 삭제 장애는 생성 장애보다 복구 비용이 커요. 그래서 테스트 환경에서 생성뿐 아니라 삭제 시나리오까지 꼭 검증해야 합니다. 저도 예전엔 만드는 데만 집중했었는데 실제로는 지우는 흐름이 더 중요하더라고요.

    8. 검증과 결과: 어디까지 자동화됐는지 확인하는 방법

    문제를 고치고 나면 “이제 됐다”로 끝내면 안 돼요. 다시 같은 Crossplane 장애 사례가 반복되는지 봐야 하거든요. 저는 아래 체크리스트로 검증합니다.

    1. Claim 생성부터 Ready까지 걸리는 흐름을 확인하세요.
    2. 하위 Managed Resource가 모두 Synced 상태인지 봅시다.
    3. 외부 클라우드 콘솔에서도 리소스 속성이 기대값과 같은지 확인해요.
    4. 삭제 테스트까지 수행하세요.
    5. 이벤트 로그에 경고가 남는지 다시 봅니다.
    kubectl get appenv
    kubectl get xr
    kubectl get managed
    kubectl get events --sort-by=.metadata.creationTimestamp

    제가 직접 해보니 결과가 눈에 보이게 달라졌어요. 장애가 완전히 사라진다기보다 문제가 생겨도 어디를 봐야 하는지 감이 생긴다는 게 커요. 이건 운영 피로도를 많이 줄여줍니다. 특히 멀티 클라우드 관리 환경에서는 “도구를 더 넣는 것”보다 “상태를 읽는 기준을 팀이 공유하는 것”이 훨씬 중요하더라고요.

    Crossplane 장애 사례 해결 후 안정화된 멀티 클라우드 관리 결과

    문제 해결 후 Crossplane 리소스들이 Ready 상태로 정렬되고 운영 지표가 안정된 결과를 보여주는 이미지입니다.

    9. 정리: Crossplane은 편한 도구가 아니라, 잘 설계해야 편해지는 도구입니다

    정리해보면 Crossplane은 멀티 클라우드 관리와 인프라스트럭처 코드 표준화에 꽤 강력해요. 다만 처음 붙일 때 “쿠버네티스로 클라우드를 다룬다”는 멋진 그림만 보면 안 돼요. 실제 운영에선 Provider 인증, Composition 패치, 상태 조건, 삭제 흐름, 권한 범위 같은 현실 이슈가 계속 튀어나오거든요.

    저도 처음엔 Crossplane 장애 사례를 겪을 때마다 도구 자체를 의심했었는데 실제로 써보니까 대부분은 관찰 포인트 부족이나 운영 표준 미정리에서 시작하더라고요. 드디어 됐다! 싶은 순간도 있었고, 반대로 “왜 어제 되던 게 오늘 안 되지?” 하면서 로그만 한참 본 날도 있었어요. 근데 그런 삽질이 쌓이니까 구조가 보이더군요.

    • 💡 기억할 점 1: 상위 Claim만 보지 말고 XR과 Managed Resource까지 따라가세요.
    • 💡 기억할 점 2: 이벤트와 조건(status.conditions)은 가장 먼저 봐야 합니다.
    • 💡 기억할 점 3: 생성 성공보다 삭제 성공까지 확인해야 진짜 운영 준비가 끝납니다.
    • 💡 기억할 점 4: 멀티 클라우드 관리는 도구보다 표준화가 먼저에요.

    혹시 지금 Crossplane 장애 사례 때문에 막혀 계신가요? 그러면 가장 먼저 객체 계층을 위에서 아래로, 그리고 이벤트를 시간순으로 보시는 걸 추천드립니다. 이 흐름만 익혀도 troubleshooting 속도가 꽤 빨라집니다. 이전 글에서 다뤘던 쿠버네티스 운영 체크리스트와도 연결되는 이야기고 다음 글에서는 Composition 설계를 어떻게 단순화하면 장애를 줄일 수 있는지 이어서 다뤄볼 예정입니다.

    장애 원인 파악 순서와 운영 체크포인트를 한눈에 정리한 요약 인포그래픽 이미지입니다.