13년차의 서버실

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

[태그:] Infrastructure as Code

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

  • [Cloud] Terraform으로 AWS/GCP/Azure 멀티 클라우드 환경 구축 가이드

    멀티 클라우드, 왜 지금 이 시점에 Terraform인가요?

    솔직히 말씀드리면, 저도 처음엔 멀티 클라우드가 ‘대기업 얘기’인 줄 알았거든요. AWS 하나만 잘 써도 충분하지 않냐고 생각했는데… 현실은 달랐습니다. 어느 날 갑자기 고객사가 “GCP도 써야 한다”고 했을 때, 그 막막함이란 😅

    요즘은 규모에 상관없이 AWS, GCP, Azure를 동시에 다뤄야 하는 상황이 생각보다 자주 옵니다. 벤더 종속(Vendor Lock-in)을 피하려는 전략적 이유도 있고, 각 클라우드의 강점을 조합하려는 경우도 많죠. ML/AI 워크로드는 GCP, 기업 솔루션은 Azure, 메인 인프라는 AWS처럼요.

    여기서 Terraform 멀티 클라우드 구성이 빛을 발합니다. 하나의 도구로 세 개의 클라우드를 동시에 관리할 수 있다는 건, 13년 동안 인프라 일을 해온 저한테도 꽤 충격적인 경험이었어요. 이 글에서는 제가 직접 홈랩에서 구성해보고 실무에 적용한 경험을 바탕으로, Terraform으로 AWS/GCP/Azure 멀티 클라우드 환경을 구축하는 방법을 단계별로 알려드릴게요.

    ▲ Terraform 하나로 AWS, GCP, Azure를 동시에 관리하는 멀티 클라우드 아키텍처 개요도

    Terraform 멀티 클라우드가 뭔지 쉽게 풀어볼게요

    Terraform은 HashiCorp에서 만든 IaC(Infrastructure as Code) 도구예요. 쉽게 말해, 클라우드 콘솔에서 마우스로 클릭클릭 하던 걸 코드로 대체하는 거죠. 인프라 구성을 코드로 정의하고 관리하는 방식이라고 보면 됩니다.

    핵심 개념 몇 가지만 짚고 넘어갈게요.

    • Provider(프로바이더): 각 클라우드 벤더와 통신하는 플러그인. AWS, GCP, Azure 각각 별도 프로바이더가 있어요.
    • Resource(리소스): 실제로 생성할 인프라 구성 요소. EC2 인스턴스, GCS 버킷, Azure VM 같은 것들이죠.
    • State(스테이트): Terraform이 현재 인프라 상태를 추적하는 파일. 멀티 클라우드에서 이게 특히 중요합니다.
    • Module(모듈): 재사용 가능한 Terraform 코드 묶음. 멀티 클라우드 구성에서 필수예요.
    • Workspace(워크스페이스): 동일한 코드베이스로 여러 환경(dev/staging/prod)을 관리하는 방법.

    멀티 클라우드 구성에서 핵심은 하나의 Terraform 프로젝트에 여러 프로바이더를 동시에 선언하는 거예요. 처음엔 이게 되나 싶었는데, 실제로 해보니까 생각보다 깔끔하게 동작하더라고요.

    단일 클라우드 vs 멀티 클라우드 Terraform 비교

    구분 단일 클라우드 멀티 클라우드
    Provider 수 1개 2개 이상
    State 관리 단일 backend 분리 또는 통합 backend
    인증 관리 단순 각 클라우드별 별도 인증 필요
    코드 복잡도 낮음 중~높음 (모듈화 필수)
    장점 단순, 빠른 구성 벤더 독립성, 최적 서비스 조합

    사전 준비: 환경 세팅부터 제대로

    본격적인 구성 전에 준비해야 할 것들이 있어요. 저도 이 부분에서 삽질을 좀 했습니다 ㅎㅎ. 특히 인증 부분에서요.

    1. Terraform 설치

    Terraform은 1.0 버전 이후로 꽤 안정화됐어요. 멀티 클라우드 구성을 처음 하신다면 최신 stable 버전을 사용하시길 권장합니다.

    # macOS (Homebrew)
    brew tap hashicorp/tap
    brew install hashicorp/tap/terraform
    
    # Ubuntu/Debian
    wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
    echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
    sudo apt update && sudo apt install terraform
    
    # 버전 확인
    terraform version

    2. 각 클라우드 CLI 설치 및 인증

    Terraform이 각 클라우드와 통신하려면 인증 정보가 필요해요. CLI를 통한 인증이 가장 깔끔합니다.

    # AWS CLI 인증
    aws configure
    # AWS Access Key ID, Secret Access Key, Region 입력
    
    # GCP 인증
    gcloud auth application-default login
    # 브라우저에서 Google 계정 인증
    
    # Azure CLI 인증
    az login
    # 브라우저에서 Microsoft 계정 인증

    💡 팁: 실무에서는 각 클라우드의 서비스 계정(Service Account) 또는 IAM Role을 사용하는 게 보안상 훨씬 좋아요. 개인 계정 인증은 개발/테스트 환경에서만 쓰세요.

    실전 구현: 멀티 클라우드 Terraform 프로젝트 구성

    자, 이제 본론입니다. 제가 실제로 구성한 디렉터리 구조부터 보여드릴게요. 처음에 플랫 구조로 했다가 나중에 너무 복잡해져서 모듈 구조로 전면 개편했던 경험이 있어서, 처음부터 제대로 된 구조로 시작하시길 권해드립니다.

    프로젝트 디렉터리 구조

    multi-cloud-infra/
    ├── main.tf              # 메인 진입점, 프로바이더 설정
    ├── variables.tf         # 공통 변수 정의
    ├── outputs.tf           # 출력값 정의
    ├── terraform.tfvars     # 실제 변수값 (git에 올리지 마세요!)
    ├── backend.tf           # State 저장소 설정
    └── modules/
        ├── aws/
        │   ├── main.tf
        │   ├── variables.tf
        │   └── outputs.tf
        ├── gcp/
        │   ├── main.tf
        │   ├── variables.tf
        │   └── outputs.tf
        └── azure/
            ├── main.tf
            ├── variables.tf
            └── outputs.tf

    Step 1: 멀티 프로바이더 선언 (main.tf)

    여기가 핵심이에요. 세 개의 프로바이더를 동시에 선언합니다. alias(별칭)를 사용하면 같은 프로바이더를 여러 리전에서 사용할 수도 있어요.

    terraform {
      required_version = ">= 1.3.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.0"
        }
        google = {
          source  = "hashicorp/google"
          version = "~> 5.0"
        }
        azurerm = {
          source  = "hashicorp/azurerm"
          version = "~> 3.0"
        }
      }
    }
    
    # AWS 프로바이더
    provider "aws" {
      region = var.aws_region
      # 환경변수 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY 사용 권장
    }
    
    # GCP 프로바이더
    provider "google" {
      project = var.gcp_project_id
      region  = var.gcp_region
      # GOOGLE_APPLICATION_CREDENTIALS 환경변수 사용 권장
    }
    
    # Azure 프로바이더
    provider "azurerm" {
      features {}
      subscription_id = var.azure_subscription_id
      # az login으로 인증된 정보 사용
    }

    Step 2: 변수 정의 (variables.tf)

    variable "aws_region" {
      description = "AWS 배포 리전"
      type        = string
      default     = "ap-northeast-2"  # 서울 리전
    }
    
    variable "gcp_project_id" {
      description = "GCP 프로젝트 ID"
      type        = string
    }
    
    variable "gcp_region" {
      description = "GCP 배포 리전"
      type        = string
      default     = "asia-northeast3"  # 서울 리전
    }
    
    variable "azure_subscription_id" {
      description = "Azure 구독 ID"
      type        = string
      sensitive   = true
    }
    
    variable "environment" {
      description = "배포 환경 (dev/staging/prod)"
      type        = string
      default     = "dev"
    }
    
    variable "project_name" {
      description = "프로젝트명 (리소스 태그에 사용)"
      type        = string
    }

    ▲ 모듈화된 Terraform 프로젝트 구조 — 각 클라우드별 모듈이 독립적으로 관리되는 모습

    Step 3: 각 클라우드 모듈 구현

    이제 각 클라우드별 리소스를 모듈로 만들어볼게요. 예시로 각 클라우드에 VPC/네트워크와 기본 컴퓨팅 리소스를 만드는 구성입니다.

    AWS 모듈 (modules/aws/main.tf)

    # AWS VPC 생성
    resource "aws_vpc" "main" {
      cidr_block           = var.vpc_cidr
      enable_dns_hostnames = true
      enable_dns_support   = true
    
      tags = {
        Name        = "${var.project_name}-vpc"
        Environment = var.environment
        ManagedBy   = "terraform"
      }
    }
    
    # 퍼블릭 서브넷
    resource "aws_subnet" "public" {
      vpc_id                  = aws_vpc.main.id
      cidr_block              = var.public_subnet_cidr
      availability_zone       = "${var.aws_region}a"
      map_public_ip_on_launch = true
    
      tags = {
        Name        = "${var.project_name}-public-subnet"
        Environment = var.environment
      }
    }
    
    # S3 버킷 (State 저장용 또는 앱 스토리지)
    resource "aws_s3_bucket" "app_storage" {
      bucket = "${var.project_name}-${var.environment}-storage"
    
      tags = {
        Name        = "${var.project_name}-storage"
        Environment = var.environment
      }
    }
    
    resource "aws_s3_bucket_versioning" "app_storage" {
      bucket = aws_s3_bucket.app_storage.id
      versioning_configuration {
        status = "Enabled"
      }
    }

    GCP 모듈 (modules/gcp/main.tf)

    # GCP VPC 네트워크
    resource "google_compute_network" "main" {
      name                    = "${var.project_name}-network"
      auto_create_subnetworks = false
      project                 = var.gcp_project_id
    }
    
    # GCP 서브넷
    resource "google_compute_subnetwork" "main" {
      name          = "${var.project_name}-subnet"
      ip_cidr_range = var.subnet_cidr
      region        = var.gcp_region
      network       = google_compute_network.main.id
      project       = var.gcp_project_id
    }
    
    # GCS 버킷 (Google Cloud Storage)
    resource "google_storage_bucket" "app_storage" {
      name          = "${var.project_name}-${var.environment}-gcs"
      location      = "ASIA"
      project       = var.gcp_project_id
      force_destroy = false
    
      versioning {
        enabled = true
      }
    
      labels = {
        environment = var.environment
        managed_by  = "terraform"
      }
    }

    Azure 모듈 (modules/azure/main.tf)

    # Azure 리소스 그룹
    resource "azurerm_resource_group" "main" {
      name     = "${var.project_name}-${var.environment}-rg"
      location = var.azure_location
    
      tags = {
        environment = var.environment
        managed_by  = "terraform"
      }
    }
    
    # Azure Virtual Network
    resource "azurerm_virtual_network" "main" {
      name                = "${var.project_name}-vnet"
      address_space       = [var.vnet_cidr]
      location            = azurerm_resource_group.main.location
      resource_group_name = azurerm_resource_group.main.name
    
      tags = {
        environment = var.environment
      }
    }
    
    # Azure 서브넷
    resource "azurerm_subnet" "main" {
      name                 = "${var.project_name}-subnet"
      resource_group_name  = azurerm_resource_group.main.name
      virtual_network_name = azurerm_virtual_network.main.name
      address_prefixes     = [var.subnet_cidr]
    }
    
    # Azure Storage Account
    resource "azurerm_storage_account" "main" {
      name                     = "${replace(var.project_name, "-", "")}${var.environment}sa"
      resource_group_name      = azurerm_resource_group.main.name
      location                 = azurerm_resource_group.main.location
      account_tier             = "Standard"
      account_replication_type = "LRS"
    
      tags = {
        environment = var.environment
      }
    }

    Step 4: 모듈 호출 (루트 main.tf에 추가)

    # AWS 모듈 호출
    module "aws_infra" {
      source = "./modules/aws"
    
      project_name       = var.project_name
      environment        = var.environment
      aws_region         = var.aws_region
      vpc_cidr           = "10.0.0.0/16"
      public_subnet_cidr = "10.0.1.0/24"
    }
    
    # GCP 모듈 호출
    module "gcp_infra" {
      source = "./modules/gcp"
    
      project_name    = var.project_name
      environment     = var.environment
      gcp_project_id  = var.gcp_project_id
      gcp_region      = var.gcp_region
      subnet_cidr     = "10.1.0.0/24"
    }
    
    # Azure 모듈 호출
    module "azure_infra" {
      source = "./modules/azure"
    
      project_name   = var.project_name
      environment    = var.environment
      azure_location = "koreacentral"
      vnet_cidr      = "10.2.0.0/16"
      subnet_cidr    = "10.2.1.0/24"
    }

    Step 5: State Backend 설정 (backend.tf)

    멀티 클라우드에서 State 관리는 정말 중요해요. 팀 작업을 한다면 로컬 state 파일은 절대 안 됩니다. 저는 AWS S3를 State 저장소로, DynamoDB를 락(Lock) 관리에 사용했어요.

    terraform {
      backend "s3" {
        bucket         = "your-terraform-state-bucket"
        key            = "multi-cloud/terraform.tfstate"
        region         = "ap-northeast-2"
        encrypt        = true
        dynamodb_table = "terraform-state-lock"
      }
    }

    Step 6: 배포 실행

    # 초기화 (프로바이더 플러그인 다운로드)
    terraform init
    
    # 변경사항 미리보기
    terraform plan -var-file="terraform.tfvars"
    
    # 실제 배포
    terraform apply -var-file="terraform.tfvars"
    
    # 특정 모듈만 배포하고 싶을 때
    terraform apply -target=module.aws_infra
    
    # 리소스 삭제
    terraform destroy -var-file="terraform.tfvars"

    ⚠️ 삽질 기록: 이런 문제들 겪었습니다

    제가 처음 멀티 클라우드 Terraform 구성할 때 겪었던 문제들이에요. 여러분은 같은 삽질 안 하셨으면 해서 솔직하게 공유합니다.

    문제 1: Provider 버전 충돌

    terraform init 할 때 프로바이더 버전 충돌 에러가 났어요. ~> 연산자로 버전 범위를 지정하면 대부분 해결됩니다. 너무 엄격하게 고정하면 나중에 업그레이드할 때 고생해요.

    # 이렇게 하면 마이너 버전 업그레이드는 허용
    version = "~> 5.0"   # 5.x 버전 허용, 6.0은 안 됨
    
    # 이렇게 하면 패치 버전만 허용
    version = "~> 5.1.0" # 5.1.x만 허용

    문제 2: Azure Storage Account 이름 규칙

    Azure Storage Account 이름은 소문자와 숫자만 되고, 하이픈(-) 사용이 불가예요. 이거 모르고 프로젝트명에 하이픈 넣었다가 에러 폭탄 맞았습니다… replace() 함수로 해결했어요.

    # 하이픈 제거
    name = "${replace(var.project_name, "-", "")}${var.environment}sa"

    문제 3: GCP API 활성화 누락

    GCP는 사용하려는 API를 사전에 활성화해야 해요. Terraform으로 리소스 만들려는데 API가 비활성화 상태면 에러가 납니다. 이것도 Terraform으로 관리할 수 있어요.

    resource "google_project_service" "compute" {
      project = var.gcp_project_id
      service = "compute.googleapis.com"
    
      disable_on_destroy = false
    }
    
    resource "google_project_service" "storage" {
      project = var.gcp_project_id
      service = "storage.googleapis.com"
    
      disable_on_destroy = false
    }
    
    # 리소스가 API 활성화 후 생성되도록 의존성 설정
    resource "google_compute_network" "main" {
      depends_on = [google_project_service.compute]
      # ...
    }

    문제 4: State 파일 잠금(Lock) 충돌

    팀원이랑 동시에 terraform apply를 실행했다가 State Lock 에러가 났어요. DynamoDB 락 테이블을 꼭 설정하세요. 혼자 작업해도 비정상 종료 후 락이 안 풀리는 경우가 있는데, 이때는 terraform force-unlock 명령어로 해결합니다.

    # 락 강제 해제 (Lock ID는 에러 메시지에서 확인)
    terraform force-unlock LOCK_ID

    검증: 제대로 만들어졌는지 확인하기

    드디어 됐다! 싶을 때 꼭 확인해야 할 것들이 있어요. terraform apply가 성공했다고 끝이 아니거든요.

    # 현재 State 확인
    terraform show
    
    # 특정 리소스 상세 확인
    terraform state show module.aws_infra.aws_vpc.main
    
    # 모든 리소스 목록 확인
    terraform state list
    
    # 출력값 확인
    terraform output

    각 클라우드 콘솔에서도 직접 확인해보세요. AWS 콘솔에서 VPC가 생겼는지, GCP 콘솔에서 네트워크가 보이는지, Azure 포털에서 리소스 그룹이 만들어졌는지 확인하는 거예요. 코드만 믿지 말고 눈으로 직접 확인하는 습관이 정말 중요합니다.

    ▲ terraform apply 완료 후 각 클라우드 콘솔에서 리소스가 정상 생성된 결과 확인 화면

    Outputs으로 중요 정보 추출하기

    # outputs.tf
    output "aws_vpc_id" {
      description = "생성된 AWS VPC ID"
      value       = module.aws_infra.vpc_id
    }
    
    output "gcp_network_name" {
      description = "생성된 GCP 네트워크 이름"
      value       = module.gcp_infra.network_name
    }
    
    output "azure_resource_group" {
      description = "생성된 Azure 리소스 그룹 이름"
      value       = module.azure_infra.resource_group_name
    }

    💡 실무에서 쓰는 Best Practice 몇 가지

    13년 동안 인프라 일 하면서 쌓인 노하우를 좀 더 공유할게요.

    • terraform.tfvars는 절대 Git에 올리지 마세요. .gitignore에 꼭 추가하고, 민감 정보는 환경변수나 Vault로 관리하세요.
    • 환경별 tfvars 파일을 분리하세요. dev.tfvars, staging.tfvars, prod.tfvars처럼요. -var-file 옵션으로 지정해서 사용합니다.
    • Terraform Cloud 또는 Atlantis 도입을 고려하세요. 팀 규모가 커지면 PR 기반 Terraform 실행 환경이 필요해집니다.
    • 모든 리소스에 태그/라벨을 달아두세요. 멀티 클라우드에서 비용 관리하려면 태그 전략이 필수예요. ManagedBy = terraform, Environment = dev 같은 공통 태그부터 시작하세요.
    • Plan 결과는 팀원과 공유하세요. terraform plan -out=plan.tfplan으로 저장하고, terraform show plan.tfplan으로 리뷰받는 프로세스를 만들면 좋아요.

    ▲ Terraform 멀티 클라우드 운영을 위한 Best Practice 요약 — 보안, 협업, 비용 관리 핵심 포인트

    자주 묻는 질문 (FAQ)

    Q. Terraform과 Pulumi 중 어떤 걸 써야 하나요?

    Pulumi는 일반 프로그래밍 언어(Python, TypeScript 등)로 인프라를 정의할 수 있어서 개발자 친화적이에요. 반면 Terraform은 HCL이라는 전용 언어를 쓰지만 생태계가 훨씬 크고, 커뮤니티 모듈이 풍부하거든요. 처음 IaC를 시작한다면 Terraform을 추천해요.

    Q. 멀티 클라우드 구성에서 State를 어디에 저장해야 하나요?

    저는 AWS S3 + DynamoDB 조합을 주로 사용합니다. Terraform Cloud를 쓰면 State 관리, 락, 협업 기능을 한 번에 해결할 수 있어요. 팀 규모가 있다면 Terraform Cloud 무료 플랜부터 시작해보세요.

    Q. 멀티 클라우드 비용이 너무 많이 나올 것 같아요.

    맞아요, 이게 현실적인 고민이죠. 각 클라우드의 프리 티어(Free Tier)를 최대한 활용하고, 개발 환경은 작업이 끝나면 terraform destroy로 날리는 습관을 들이세요. 저도 홈랩에서는 퇴근 전에 꼭 destroy 합니다 😅

    마무리: 멀티 클라우드는 복잡하지만, Terraform이 있으면 달라요

    처음에 AWS IaC 하나 배우는 것도 버거웠는데, GCP IaC, Azure IaC까지 동시에 다루게 될 줄은 몰랐어요. 근데 Terraform 멀티 클라우드 구성을 제대로 한 번 해놓으면, 그다음부터는 정말 편하더라고요. 새로운 리소스 추가할 때 코드 몇 줄 추가하고 apply만 하면 되거든요.

    이 글에서 다룬 내용을 정리하면:

    1. Terraform에서 여러 Provider를 동시에 선언해 멀티 클라우드 관리 가능
    2. 모듈(Module) 구조로 각 클라우드 코드를 분리해 유지보수성 향상
    3. State는 반드시 원격 Backend(S3 + DynamoDB 등)에 저장
    4. 인증 정보, 민감 변수는 절대 코드에 하드코딩 금지
    5. 태그 전략을 처음부터 설계해야 멀티 클라우드 비용 관리가 됨

    다음 글에서는 Terraform과 GitHub Actions를 연동해서 CI/CD 파이프라인을 구축하는 방법을 다룰 예정이에요. Pull Request가 올라오면 자동으로 terraform plan 결과를 코멘트로 달아주는 그 워크플로우요. 꽤 실용적이니까 기대해주세요!

    질문이나 다른 삽질 경험 있으시면 댓글로 남겨주세요. 같이 고민해봐요 😊