13년차의 서버실

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

[태그:] 인프라 자동화

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

  • [k8s] ClusterAPI 도입 성공 사례: 온프레미스 Kubernetes 클러스터 자동화 구축기

    [k8s] ClusterAPI 도입 성공 사례: 온프레미스 Kubernetes 클러스터 자동화 구축기

    [인프라] ClusterAPI 도입으로 온프레미스 자동화 성공하기

    온프레미스 Kubernetes 환경을 오래 운영하다 보면 결국 같은 고민을 하게 됩니다. 클러스터 하나 만들 때마다 문서 열고, kubeadm 옵션 다시 확인하고, 네트워크랑 인증서 맞추고, 노드 추가할 때마다 사람 손이 들어가죠. 저도 그랬습니다. 그래서 이번 글에서는 제가 실제로 정리해 본 ClusterAPI 도입 사례를 중심으로, 온프레미스 Kubernetes 환경에서 클러스터 생성과 확장을 어떻게 자동화했는지 풀어보려고 합니다. 특히 여러 팀이 같은 기준으로 클러스터를 만들고 싶을 때, 이 접근이 왜 먹히는지 말씀드릴게요.

    처음엔 솔직히 이게 뭔가 싶었어요. Kubernetes 자체도 복잡한데 관리 클러스터까지 따로 둔다니 더 어려워 보였거든요. 근데 한 번 구조가 잡히고 나니까, “아 이건 사람 손을 줄이기 위한 운영 도구구나” 싶더라고요. 혹시 지금도 새 클러스터 만들 때마다 체크리스트부터 찾고 계신다면, 여기서 중요한 포인트를 같이 보시면 됩니다.

    ClusterAPI 도입 사례를 설명하는 온프레미스 Kubernetes 전체 아키텍처 이미지

    Management Cluster(관리 클러스터), Workload Cluster(운영 클러스터), 인프라 제공자와 GitOps 흐름을 한눈에 보여주는 아키텍처 이미지입니다.

    1. ClusterAPI란 무엇인가요?

    Cluster API(CAPI, 클러스터 수명주기 관리 프로젝트)를 쉽게 말하면, Kubernetes 방식으로 Kubernetes 클러스터를 만드는 도구예요. Deployment(디플로이먼트)를 선언적으로 관리하듯이, 클러스터 자체를 YAML로 선언하고 생성, 확장, 업그레이드까지 다루는 거죠.

    핵심은 세 가지입니다.

    • Management Cluster: 다른 클러스터를 생성하고 관리하는 기준 클러스터입니다.
    • Workload Cluster: 실제 애플리케이션이 올라가는 대상 클러스터입니다.
    • Provider: vSphere(브이스피어, 가상화 플랫폼), OpenStack(오픈스택, 프라이빗 클라우드 플랫폼), Bare Metal(베어메탈, 물리 서버) 같은 인프라와 연결해 주는 구성입니다.

    제가 직접 해보니 중요한 건 “클러스터 생성 절차를 코드로 옮긴다”는 점이었어요. 이게 되면 담당자가 바뀌어도 운영 방식이 흔들리지 않거든요. ClusterAPI 도입 사례가 주목받는 이유도 결국 여기 있습니다.

    2. 왜 온프레미스 Kubernetes 자동화에 잘 맞았나

    클라우드는 원래 API가 잘 되어 있어서 자동화가 비교적 쉬워요. 반면 온프레미스는 가상화 플랫폼, 네트워크, IP 할당 정책, 이미지 관리, 인증서 처리까지 다 제각각인 경우가 많습니다. 그래서 클러스터 자동화가 더 절실한데, 아이러니하게도 더 어렵죠.

    제가 보기에 Cluster API가 온프레미스에서 특히 좋았던 이유는 아래와 같았습니다.

    구분 기존 수동 방식 ClusterAPI 방식
    클러스터 생성 문서 따라 수동 실행 YAML 기반 선언형 생성
    확장 노드별 개별 작업 replica 수 조정 중심
    표준화 작업자 숙련도 영향 큼 템플릿 재사용 가능
    변경 이력 위키나 메모 의존 Git 기반 추적 가능
    복구 재현 어려움 동일 매니페스트 재적용 가능

    특히 여러 환경을 동시에 굴릴 때 차이가 컸어요. 개발, 스테이징, 운영이 따로 있으면 사람이 일일이 맞추다가 꼭 어긋나거든요. 반면 CAPI 구축을 해두면 템플릿 하나에서 변수만 바꿔 환경을 복제할 수 있어서 훨씬 안정적이었습니다.

    3. 제가 잡았던 목표와 설계 기준

    이번 ClusterAPI 도입 사례에서 제가 세운 기준은 과하지 않게 명확했어요.

    1. 클러스터 생성 절차를 문서가 아니라 Git 저장소 기준으로 관리할 것
    2. Control Plane(컨트롤 플레인, 클러스터 제어 영역)과 Worker Node(워커 노드, 실제 워크로드 실행 서버) 확장을 분리할 것
    3. 온프레미스 Kubernetes 환경에서도 동일한 템플릿을 재사용할 것
    4. 장애가 나더라도 다시 만들 수 있는 구조를 우선할 것

    여기서 중요한 포인트는 “처음부터 모든 걸 자동화하려고 하지 않는다”는 점이에요. 저도 처음엔 네트워크, 스토리지, 로드밸런서까지 한 번에 묶으려다가 삽질 좀 했습니다 ㅎㅎ 결국 성공한 방식은 클러스터 수명주기부터 먼저 자동화하고, 그 다음에 GitOps와 운영 도구를 붙이는 순서였어요.

    4. CAPI 구축 전 준비: Management Cluster부터 정리

    실전 구현은 예시로 vSphere 기반 온프레미스 환경을 기준으로 설명드릴게요. 다만 구조 자체는 다른 인프라 제공자에도 비슷하게 적용됩니다. 준비 단계에서 필요한 건 대략 이 정도입니다.

    • kubectl(쿠버네티스 CLI), clusterctl(Cluster API CLI)
    • 부트스트랩용 Kubernetes 클러스터 1개
    • vSphere API 접근 정보 또는 사용하는 온프레미스 인프라 자격 정보
    • 템플릿에서 참조할 VM 이미지와 네트워크 정책
    • 로드밸런서 또는 API 서버 엔드포인트 전략

    저는 먼저 management cluster를 아주 작게 올리고, 여기에 Cluster API Controller(컨트롤러, 상태를 맞추는 구성요소)를 설치했어요. 이 관리 클러스터가 있어야 이후 workload cluster를 선언형으로 찍어낼 수 있거든요.

    export CLUSTER_TOPOLOGY=true
    export EXP_CLUSTER_RESOURCE_SET=true
    
    clusterctl init --infrastructure vsphere
    clusterctl version
    kubectl get pods -A

    이 단계에서 pod가 전부 Running 상태로 올라오는지 꼭 확인하셔야 합니다. 컨트롤러가 불안정하면 뒤 단계에서 증상이 꼬여서 원인 파악이 더 어려워져요.

    5. 실전 구현: 온프레미스 Kubernetes 클러스터 자동화 흐름

    이제 본격적으로 CAPI 구축을 진행합니다. 제 경험상 아래 순서가 가장 덜 꼬였습니다.

    1. 클러스터 템플릿 생성
    2. 환경 변수 주입
    3. 매니페스트 검토
    4. 적용 후 상태 확인
    5. 노드 확장 및 업그레이드 테스트
    export VSPHERE_SERVER=vc.example.local
    export VSPHERE_USERNAME=svc_clusterapi
    export VSPHERE_PASSWORD='change-me'
    export VSPHERE_DATACENTER=DC1
    export VSPHERE_DATASTORE=datastore01
    export VSPHERE_NETWORK=VM_Network
    export VSPHERE_RESOURCE_POOL=/DC1/host/Cluster/Resources
    export VSPHERE_FOLDER=/DC1/vm/k8s
    export VSPHERE_TEMPLATE=ubuntu-k8s-template
    export KUBERNETES_VERSION=v1.29.0
    export CONTROL_PLANE_MACHINE_COUNT=3
    export WORKER_MACHINE_COUNT=3
    export CLUSTER_NAME=onprem-prod-01
    
    clusterctl generate cluster ${CLUSTER_NAME} \
      --kubernetes-version ${KUBERNETES_VERSION} \
      --control-plane-machine-count=${CONTROL_PLANE_MACHINE_COUNT} \
      --worker-machine-count=${WORKER_MACHINE_COUNT} \
      > ${CLUSTER_NAME}.yaml

    생성된 YAML은 바로 적용하지 말고 한 번 읽어보시는 걸 권장합니다. 처음엔 귀찮아 보여도, 여기서 네트워크 이름이나 템플릿 참조가 틀린 걸 많이 잡더라고요.

    ClusterAPI 도입 사례의 템플릿 생성과 컨트롤러 배포 흐름 이미지

    clusterctl로 템플릿을 생성하고, management cluster에서 이를 해석해 workload cluster를 만드는 흐름을 설명하는 이미지입니다.

    apiVersion: cluster.x-k8s.io/v1beta1
    kind: Cluster
    metadata:
      name: onprem-prod-01
    spec:
      clusterNetwork:
        pods:
          cidrBlocks:
            - 192.168.0.0/16
        services:
          cidrBlocks:
            - 10.96.0.0/12
      controlPlaneRef:
        apiVersion: controlplane.cluster.x-k8s.io/v1beta1
        kind: KubeadmControlPlane
        name: onprem-prod-01-control-plane
      infrastructureRef:
        apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
        kind: VSphereCluster
        name: onprem-prod-01
    kubectl apply -f onprem-prod-01.yaml
    kubectl get cluster
    kubectl get machines
    kubectl get kubeadmcontrolplane
    kubectl describe cluster onprem-prod-01

    여기서 진짜 편한 점이 나와요. 워커 노드 늘릴 때도 별도 설치 문서가 아니라 replica 값을 조정하는 식으로 접근하게 되거든요. 사람이 SSH 들어가서 하나씩 붙이는 방식에서 벗어나는 거죠.

    apiVersion: cluster.x-k8s.io/v1beta1
    kind: MachineDeployment
    metadata:
      name: onprem-prod-01-md-0
    spec:
      replicas: 5

    이런 식으로 선언 상태를 바꾸면 컨트롤러가 맞춰 줘요. 실제로 써보니까 운영자가 해야 할 일이 “절차 수행”에서 “원하는 상태 정의”로 바뀌더라고요.

    6. ⚠️ 트러블슈팅: 제가 실제로 막혔던 지점들

    자동화라고 해서 처음부터 매끈하진 않았어요. 오히려 수동 설치보다 어디서 실패했는지 감이 안 잡혀서 더 답답할 때도 있었죠. 특히 아래 세 가지는 자주 만났습니다.

    6-1. VM은 생성됐는데 Kubernetes 노드로 안 붙는 문제

    이건 대부분 템플릿 이미지 문제였어요. cloud-init(클라우드 이닛, 초기 설정 자동화) 또는 kubeadm bootstrap 과정이 템플릿과 안 맞는 경우가 있더라고요. 저는 골든 이미지 재생성 후 해결했습니다.

    kubectl get kubeadmconfigs
    kubectl describe machine <machine-name>
    kubectl logs -n capv-system deployment/capv-controller-manager

    6-2. API 엔드포인트는 열렸는데 Control Plane이 Ready로 안 가는 문제

    로드밸런서 health check 조건과 kube-apiserver 준비 상태가 안 맞으면 이런 현상이 생겨요. 겉으로는 포트가 열려 보여도 Cluster API 쪽에서는 준비가 덜 된 걸로 판단할 수 있습니다.

    6-3. 머신 삭제가 느리거나 걸리는 문제

    finalizer(파이널라이저, 정리 작업 보장 메커니즘)와 인프라 자원 정리가 꼬일 때가 있어요. 이때는 무작정 리소스부터 지우지 말고, 어떤 컨트롤러가 막고 있는지 이벤트를 먼저 봐야 합니다.

    • ⚠️ 경고: 수동으로 VM만 먼저 삭제하면 CAPI 상태와 실제 인프라 상태가 어긋날 수 있습니다.
    • 💡 팁: 문제 상황은 이벤트, controller log, Machine 상태를 함께 봐야 빨리 풀려요.
    • 💡 팁: 운영 전 테스트 환경에서 scale-out, scale-in, control plane 교체를 꼭 해보세요.

    저도 처음엔 “왜 선언형인데 선언대로 안 되지?” 싶었는데, 결국 선언형도 기반 인프라와 부트스트랩 준비가 맞아야 돌아가더라고요. 이건 정말 직접 해봐야 감이 와요.

    7. 검증과 결과: 운영 방식이 어떻게 달라졌나

    검증은 단순히 클러스터가 떠 있느냐만 보면 부족합니다. 저는 아래 순서로 확인했어요.

    1. Control Plane 3대가 정상 Ready 상태인지 확인
    2. Worker Node 증설/축소가 선언대로 반영되는지 확인
    3. kubeconfig 추출 후 실제 워크로드 배포 테스트
    4. 클러스터 재생성 시 동일한 결과가 나오는지 확인
    clusterctl get kubeconfig onprem-prod-01 > onprem-prod-01.kubeconfig
    export KUBECONFIG=$(pwd)/onprem-prod-01.kubeconfig
    kubectl get nodes
    kubectl create deployment nginx --image=nginx
    kubectl get pods -o wide

    이 검증이 끝나고 나면 체감이 확 와요. 새 환경이 필요할 때 “며칠 잡아야 하나”가 아니라 “템플릿 변수 뭘 바꿔야 하나”로 대화가 바뀌거든요. 이게 제가 느낀 가장 큰 성과였어요. 숫자를 과장할 필요도 없이, 운영 피로도가 확실히 줄었습니다.

    ClusterAPI 도입 사례에서 생성된 온프레미스 Kubernetes 클러스터 검증 결과 이미지

    노드가 Ready 상태로 정렬되고 테스트 워크로드가 배포된 모습을 보여주는 결과 검증 이미지입니다.

    🎉 성과 정리

    • 클러스터 생성 절차가 사람 의존에서 Git 기반 표준 절차로 바뀜
    • 온프레미스 Kubernetes 환경에서도 재현 가능한 배포 흐름 확보
    • 운영팀 간 작업 편차 감소
    • 추후 GitOps, 모니터링, 정책 관리 연계가 쉬워짐

    8. 정리: ClusterAPI 도입 사례에서 배운 점

    이번 ClusterAPI 도입 사례를 통해 제가 가장 크게 배운 건, 자동화의 핵심은 도구보다 기준이라는 점이었어요. Cluster API는 만능 마법봉은 아니지만, 클러스터 생성과 확장을 운영 표준으로 묶는 데는 정말 강력했습니다. 특히 클러스터 자동화를 사람 손이 아니라 선언 상태 중심으로 옮긴다는 점에서, 장기 운영에 큰 차이를 만들더라고요.

    다만 처음부터 모든 걸 얹지는 마세요. Management cluster 안정화, 템플릿 검증, 네트워크 정책 정리, 골든 이미지 검증 순으로 가는 게 좋아요. 저도 처음엔 욕심냤다가 다시 돌아왔거든요. 근데 그렇게 한 바퀴 돌고 나니 구조가 훨씬 단단해졌습니다.

    ClusterAPI 도입 사례의 도입 전후 운영 방식 비교 이미지

    수동 구축과 선언형 자동화의 차이를 한눈에 보여주는 비교 이미지입니다.

    다음 글에서는 ClusterClass(클래스 기반 클러스터 템플릿)와 GitOps를 묶어서 운영 표준화를 더 밀어붙이는 방법도 다뤄볼 예정입니다. 이전 글에서 정리한 kubeadm 기반 수동 설치 방식과 비교해서 보시면 왜 CAPI 구축이 가치 있는지 더 선명하게 보이실 겁니다.

    FAQ. ClusterAPI 도입 시 자주 묻는 질문

    Q1. 작은 팀도 Cluster API가 필요할까요?

    클러스터 수가 1개뿐이면 당장 절실하지 않을 수 있어요. 다만 환경이 늘어날 계획이 있거나 표준화가 필요하다면 일찍 검토할 가치가 있습니다.

    Q2. 베어메탈에서도 가능한가요?

    가능합니다. 다만 사용하는 인프라 제공자와 네트워크 자동화 수준에 따라 난이도가 확 달라져요. 그래서 처음엔 가상화 환경에서 개념을 먼저 익히는 걸 추천드립니다.

    Q3. 업그레이드도 선언형으로 할 수 있나요?

    네, Kubernetes 버전과 관련 리소스 정의를 통해 관리하는 방식이 일반적이에요. 다만 운영 적용 전에는 테스트 클러스터에서 롤링 동작을 꼭 검증해 보셔야 합니다.

    ✅ 한 줄 정리: 온프레미스 Kubernetes를 반복해서 만들고 운영해야 한다면, Cluster API는 복잡함을 늘리는 도구가 아니라 반복 작업을 구조화하는 도구예요.

  • [Proxmox] Proxmox Cloud-Init으로 VM 배포 자동화 사례 연구

    [Proxmox] Proxmox Cloud-Init으로 VM 배포 자동화 사례 연구

    Proxmox Cloud-Init으로 VM 배포 자동화 사례 연구

    홈랩을 오래 굴리다 보면 결국 부딪히는 문제가 하나 있습니다. VM은 금방 늘어나는데, 매번 설치 ISO를 붙이고 네트워크 설정하고 SSH 키 넣고 패키지 업데이트까지 손으로 반복하자니 시간이 너무 아깝더라고요. 저도 처음엔 “한두 대 정도야 괜찮지” 싶었는데, 쿠버네티스 실습용 노드나 테스트 서버를 자주 만들기 시작하니까 이게 진짜 일처럼 느껴졌습니다. 그래서 제가 정착한 방식이 바로 Proxmox Cloud-Init 기반 자동화였습니다. 쉽게 말해, VM의 초기 설정을 템플릿에 묶어두고 필요할 때 바로 복제해서 쓰는 방식인데요. 실제로 써보니까 랩 환경 구축 속도가 눈에 띄게 빨라졌고, 실수도 많이 줄었습니다.

    이번 글에서는 제가 홈랩에서 Proxmox Cloud-Init를 어떻게 써서 VM 자동 배포를 구성했는지, 어떤 부분에서 삽질했는지, 그리고 결과적으로 어떤 운영 습관이 자리 잡았는지를 사례 중심으로 정리해보겠습니다. 비슷하게 Proxmox 템플릿을 만들고 계신 분들이라면 꽤 바로 써먹을 수 있을 겁니다.

    Proxmox Cloud-Init 기반 홈랩 VM 자동 배포 아키텍처 다이어그램

    Proxmox 노드, 스토리지, 템플릿 VM, Cloud-Init 설정 흐름을 한눈에 보여주는 아키텍처 예시입니다.

    1. 왜 Proxmox Cloud-Init이 랩 환경 구축에서 중요한가

    랩 환경은 프로덕션(production, 실제 서비스 환경)보다 더 자주 만들고 더 자주 부숩니다. 문제는 그 과정이 반복적이라는 점이죠. 테스트용 Ubuntu VM 하나 만드는 데 10분, 세 대 만들면 30분, 거기에 네트워크나 사용자 설정이 조금씩 다르면 또 헷갈립니다. 저도 예전에 IP를 잘못 넣어서 “왜 SSH가 안 붙지?” 하면서 콘솔만 한참 들여다본 적이 있습니다. 삽질 좀 했습니다 ㅎㅎ

    여기서 중요한 포인트는 자동화가 거창할 필요가 없다는 겁니다. Ansible(앤서블, 에이전트 없이 원격 구성을 자동화하는 도구)이나 Terraform(테라폼, 인프라를 코드로 관리하는 도구)까지 가기 전에, Proxmox Cloud-Init만 잘 써도 기본 배포 속도와 일관성이 상당히 좋아져요.

    • 반복 작업 제거: 사용자 생성, SSH 키 배포, 네트워크 설정을 자동화합니다.
    • 일관성 확보: 매번 같은 기준으로 VM이 올라옵니다.
    • 템플릿 재사용: Proxmox 템플릿을 만들어두면 실습 환경을 빠르게 복제할 수 있습니다.
    • 실패 복구 용이: 문제가 생기면 지우고 다시 만드는 쪽이 더 빨라집니다.

    2. Cloud-Init 활용 사례를 쉽게 설명해보면

    Cloud-Init은 클라우드 이미지(cloud image, 자동 배포에 적합하게 최소 구성된 운영체제 이미지)가 처음 부팅될 때 초기 설정을 적용해주는 메커니즘입니다. 쉽게 말해 “이 VM은 처음 켜질 때 사용자 이름은 뭘 쓰고, 비밀번호는 어떻게 하고, IP는 뭘 받고, SSH 키는 뭘 넣을지”를 미리 주입하는 도구라고 보시면 됩니다.

    Proxmox에서는 이걸 꽤 편하게 감싸줍니다. 템플릿 VM 하나를 만들고, 거기에 Cloud-Init 디스크를 붙인 다음, VM별로 IP나 hostname 같은 초기값만 바꿔서 복제하면 돼요. 처음엔 이게 뭔가 싶었는데, 한 번 흐름을 이해하고 나니까 정말 편하더라고요.

    구분 수동 설치 방식 Proxmox Cloud-Init 방식
    OS 배포 ISO 부팅 후 직접 설치 클라우드 이미지 기반 템플릿 복제
    사용자 생성 로그인 후 수동 생성 초기 부팅 시 자동 적용
    SSH 키 배포 직접 복사 또는 스크립트 사용 Cloud-Init에 등록
    네트워크 설정 VM 내부에서 수동 변경 복제 시 IP/Gateway 지정 가능
    반복 배포 귀찮고 실수 많음 빠르고 일관적

    혹시 랩 환경에서 노드를 자주 만들었다 지우셨던 경험 있으신가요? 그런 패턴이라면 Cloud-Init은 거의 필수에 가깝니다.

    3. Proxmox 템플릿 준비: 제가 실제로 잡은 기본 구조

    제가 주로 쓰는 흐름은 이렇습니다. 먼저 공식 클라우드 이미지 중 널리 쓰이는 Ubuntu cloud image를 내려받고, Proxmox에 VM을 만든 뒤 디스크를 연결합니다. 그 다음 에이전트와 시리얼 콘솔 같은 기본 옵션을 잡고 템플릿으로 전환하거든요. 굳이 복잡하게 시작할 필요 없습니다. 핵심은 재사용 가능한 기준점을 만드는 겁니다.

    1. 클라우드 이미지 준비
    2. 기본 VM 생성
    3. Cloud-Init 디스크 추가
    4. 부팅 순서와 시리얼 콘솔 정리
    5. 템플릿 변환

    예시는 아래처럼 잡을 수 있습니다.

    wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
    
    qm create 9000 --name ubuntu-cloudinit-template --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
    qm importdisk 9000 jammy-server-cloudimg-amd64.img local-lvm
    qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-9000-disk-0
    qm set 9000 --ide2 local-lvm:cloudinit
    qm set 9000 --boot c --bootdisk scsi0
    qm set 9000 --serial0 socket --vga serial0
    qm set 9000 --agent enabled=1
    qm template 9000

    여기서 QEMU Guest Agent(게스트 에이전트, 하이퍼바이저와 게스트 간 상태 정보를 교환하는 도구)를 켜두는 건 꽤 중요해요. IP 확인이나 종료 처리 같은 부분에서 차이가 납니다. 물론 게스트 OS 안에도 패키지가 설치되어 있어야 제대로 동작하거든요.

    4. VM 자동 배포 실전: clone 이후 Cloud-Init 값 주입

    이제부터가 진짜 본론입니다. 템플릿만 있으면 VM 자동 배포는 훨씬 단순해집니다. 저는 보통 역할별로 VM ID 규칙을 먼저 정해둡니다. 예를 들어 91xx는 쿠버네티스 노드, 92xx는 모니터링, 93xx는 테스트 DB 같은 식이죠. 이런 식으로 정리해두면 나중에 훨씬 덜 헷갈립니다.

    복제 후에는 hostname, 사용자, SSH 공개키, 네트워크 값을 넣습니다. 아래 예시는 정적 IP를 쓰는 랩 환경 기준입니다.

    qm clone 9000 9101 --name k8s-node-01
    qm set 9101 --ciuser labadmin
    qm set 9101 --sshkey /root/.ssh/id_ed25519.pub
    qm set 9101 --ipconfig0 ip=192.168.10.101/24,gw=192.168.10.1
    qm set 9101 --nameserver 192.168.10.10
    qm set 9101 --searchdomain homelab.local
    qm start 9101

    여기서 중요한 포인트! Cloud-Init은 “처음 부팅될 때 적용되는 초기화”라는 성격이 강합니다. 즉, 템플릿 복제 후 값을 다 넣은 다음 부팅해야 깔끔합니다. 이미 한 번 부팅한 뒤에 설정을 바꾸면 기대한 대로 반영되지 않는 경우가 있거든요. 저도 이걸 모르고 먼저 켜버렸다가 “왜 사용자 계정이 안 생기지?” 하고 한참 봤었습니다.

    Proxmox Cloud-Init 템플릿 복제와 설정 흐름 이미지

    템플릿 복제 후 사용자, SSH 키, IP 정보를 주입하는 실전 흐름을 보여주는 이미지 위치입니다.

    4-1. 여러 대를 한 번에 만드는 간단한 패턴

    랩 환경 구축에서는 한 대보다 여러 대를 한 번에 만드는 경우가 많습니다. 이럴 때는 shell loop 정도만 써도 꽤 편하거든요.

    for i in 101 102 103; do
      vmid="9${i}"
      name="lab-node-${i}"
      ip="192.168.10.${i}/24"
    
      qm clone 9000 ${vmid} --name ${name}
      qm set ${vmid} --ciuser labadmin
      qm set ${vmid} --sshkey /root/.ssh/id_ed25519.pub
      qm set ${vmid} --ipconfig0 ip=${ip},gw=192.168.10.1
      qm start ${vmid}
    done

    이 정도만 해도 랩 환경 구축 속도가 확 달라집니다. 실제로 써보니까 테스트 클러스터 3대 띄우는 데 걸리는 체감 시간이 엄청 줄더라고요.

    4-2. Cloud-Init user-data를 더 쓰고 싶을 때

    조금 더 나아가면 패키지 설치나 파일 배포도 하고 싶어집니다. Cloud-Init의 user-data를 활용하면 첫 부팅 시점에 패키지를 설치하거나 간단한 명령을 실행할 수 있거든요. 다만 저는 여기서 선을 좀 긋는 편입니다. 초기 부팅 설정은 Cloud-Init, 서비스별 구성은 Ansible로 넘기는 편이 유지보수성이 더 좋았거든요.

    #cloud-config
    package_update: true
    packages:
      - qemu-guest-agent
      - curl
    users:
      - name: labadmin
        sudo: ALL=(ALL) NOPASSWD:ALL
        shell: /bin/bash
        ssh_authorized_keys:
          - ssh-ed25519 AAAA...examplekey
    runcmd:
      - systemctl enable qemu-guest-agent
      - systemctl start qemu-guest-agent

    이전 글에서 다뤘던 SSH 키 운영 방식이나, 다음 글에서 다룰 예정인 Ansible 초기 프로비저닝과 연결하면 훨씬 매끄럽게 이어집니다.

    5. ⚠️ 주의사항과 트러블슈팅: 제가 실제로 막혔던 지점들

    여기서는 진짜 경험담 위주로 말씀드릴게요. 문서만 보면 쉬워 보이는데, 막상 해보면 사소한 부분에서 자주 막힙니다.

    • 클라우드 이미지 내부에 게스트 에이전트가 없음
      Proxmox에서 agent 옵션을 켰다고 끝이 아닙니다. 게스트 OS 안에도 qemu-guest-agent가 있어야 합니다. 안 그러면 IP 조회가 비어 있거나 종료 상태가 어색할 수 있어요.
    • 복제 전에 템플릿 정리가 덜 됨
      불필요한 패키지나 임시 파일이 남아 있으면 그 상태가 계속 복제됩니다. 템플릿은 최대한 담백하게 유지하는 게 좋습니다.
    • 네트워크 설정 충돌
      Cloud-Init으로 IP를 주입했는데 OS 내부의 기존 netplan 설정과 충돌하는 경우가 있었습니다. 이럴 땐 템플릿 이미지의 기본 네트워크 동작을 먼저 확인해야 합니다.
    • SSH 키 경로 실수
      서버에 있는 공개키 파일 경로를 잘못 넣으면 당연히 접속이 안 됩니다. 근데 처음엔 다들 방화벽부터 의심하거든요. 저도 그랬습니다.
    • 한 번 부팅한 VM 재사용
      Cloud-Init 특성상 초기 부팅 타이밍이 중요합니다. 실험용으로 부팅한 VM을 다시 템플릿처럼 쓰면 예상과 다르게 꼬일 수 있어요.

    제가 정리한 체크리스트는 아래와 같습니다.

    qm config 9101
    qm guest cmd 9101 network-get-interfaces
    cloud-init status
    journalctl -u cloud-init
    ip a

    특히 journalctl -u cloud-init 로그는 꼭 보세요. 왜 설정이 안 먹었는지 생각보다 솔직하게 보여줍니다. 여기서 중요한 건 문제를 Proxmox 탓만 하지 않는 겁니다. 실제론 이미지 자체 설정, 네트워크 설정, 키 파일 경로 같은 기본기 문제인 경우가 많더라고요.

    6. 검증 방법: 배포가 끝났다고 바로 믿지 않는 이유

    자동화는 성공 메시지가 아니라 결과 일관성으로 검증해야 합니다. 저는 최소한 아래 항목은 항상 확인합니다.

    1. VM이 기대한 hostname으로 올라왔는지 확인
    2. 의도한 IP 주소가 붙었는지 확인
    3. SSH 키 기반 로그인만 가능한지 확인
    4. qemu-guest-agent가 정상 동작하는지 확인
    5. 필수 패키지와 초기 명령이 반영됐는지 확인
    ssh [email protected] hostname
    ssh [email protected] cloud-init status --wait
    ssh [email protected] systemctl status qemu-guest-agent --no-pager
    ssh [email protected] ip route

    드디어 됐다! 싶은 순간은 대개 이 검증 단계에서 옵니다. 템플릿 하나로 VM이 예상대로 척척 올라오면, 그 뒤부터는 운영 방식 자체가 바뀝니다. “고쳐서 오래 쓴다”보다 “문제 있으면 다시 배포한다”는 사고방식으로 넘어가게 되거든요.

    Proxmox Cloud-Init으로 자동 배포된 VM 검증 결과 이미지

    자동 생성된 VM들이 동일한 기준으로 정상 부팅되고 SSH 접속 가능한 상태임을 보여주는 결과 이미지 위치입니다.

    7. 결과와 체감 효과: 숫자보다 운영 방식이 달라졌습니다

    정확한 시간을 재서 벤치마크를 남기진 않았지만, 체감상 가장 큰 변화는 “새 VM을 만드는 부담”이 거의 없어졌다는 점이었습니다. 이전에는 테스트 하나 하려면 VM 생성 자체가 귀찮아서 미루는 경우도 있었는데, 지금은 템플릿 복제 후 Cloud-Init 값만 넣으면 되니까 훨씬 가볍게 시도하게 됩니다.

    • 테스트 환경 준비 속도가 빨라졌습니다.
    • 사용자/키/네트워크 설정 실수가 줄었습니다.
    • VM 이름 규칙과 역할 분리가 자연스럽게 정리됐습니다.
    • 다음 자동화 단계로 넘어갈 기반이 생겼습니다.

    특히 Cloud-Init 활용 사례에서 중요한 건, 처음부터 모든 걸 자동화하려고 하지 않는 겁니다. VM 배포 자동화만 안정적으로 만들어도 이미 절반은 끝난 거예요. 그 다음에 Ansible, Terraform, GitOps 같은 운영 자동화 레이어를 얹는 편이 훨씬 덜 꼬입니다.

    8. 정리와 FAQ: Proxmox Cloud-Init을 시작하려는 분께

    Proxmox Cloud-Init은 홈랩뿐 아니라 소규모 운영 환경에서도 꽤 실용적인 출발점입니다. 제가 직접 해보니 핵심은 세 가지였습니다. 첫째, 템플릿은 최대한 깔끔하게 만들 것. 둘째, 초기값은 부팅 전에 모두 넣을 것. 셋째, Cloud-Init이 맡을 역할과 이후 구성 관리 도구가 맡을 역할을 구분할 것. 이 세 가지만 지켜도 삽질이 많이 줄어듭니다.

    Proxmox Cloud-Init 도입 전후 비교 요약 이미지

    수동 배포와 자동 배포의 차이, 그리고 템플릿 재사용 이점을 요약한 비교 이미지 위치입니다.

    마지막으로 자주 받는 질문을 짧게 정리해보겠습니다.

    FAQ

    • Q. Cloud-Init만으로 모든 설정을 끝내야 하나요?
      아닙니다. 초기 부팅에 꼭 필요한 사용자, 키, 네트워크 정도만 넣고, 나머지는 구성 관리 도구로 넘기는 편이 보통 더 낫습니다.
    • Q. DHCP 환경에서도 쓸 수 있나요?
      네, 물론이죠. 다만 랩 환경에서는 정적 IP가 추적이 쉬워서 저는 정적 할당을 더 선호합니다.
    • Q. Proxmox 템플릿은 하나만 만들면 되나요?
      운영체제별로 최소 1개씩은 분리하는 게 좋습니다. Ubuntu 계열과 다른 배포판은 초기화 방식 차이가 있을 수 있거든요.

    이 글이 도움이 되셨다면, 다음 글에서 다룰 예정인 Ansible 연계 자동 프로비저닝도 같이 보시면 흐름이 더 잘 이어질 겁니다. 이미 Proxmox 템플릿을 운영 중이시라면, 지금 쓰는 방식에 Cloud-Init 한 단계만 붙여보세요. 생각보다 빨리 “왜 이제 했지?” 싶은 순간이 옵니다. 진짜 그렇습니다. 🎉

  • [Proxmox] Terraform Proxmox Provider 활용: VM 자동 프로비저닝 심층 분석

    [Proxmox] Terraform Proxmox Provider 활용: VM 자동 프로비저닝 심층 분석

    [인프라] Terraform Proxmox Provider 활용: VM 자동 프로비저닝 심층 분석

    홈랩을 조금만 오래 굴려보신 분들은 공감하실 겁니다. VM 하나는 금방 만들지만, 비슷한 설정의 VM을 여러 대 반복해서 만들기 시작하면 사람이 제일 큰 병목이 되더라고요. 저도 처음엔 Proxmox VE(프록스목스 가상화 플랫폼) 웹 UI에서 하나씩 클릭하면서 만들었었는데, 어느 순간부터는 이름 규칙이 꼬이고, CPU나 메모리 설정이 조금씩 달라지고, 네트워크 브리지도 실수로 잘못 붙이는 일이 생겼습니다. 그때 제대로 체감한 게 바로 Terraform Proxmox provider의 가치였습니다.

    Terraform Proxmox provider를 쓰면 Proxmox VM 자동화, IaC Proxmox, 프로비저닝 흐름을 코드로 관리할 수 있습니다. 쉽게 말해, “어떤 VM을 어떤 노드에 어떤 스펙으로 만들지”를 사람이 기억하는 게 아니라 코드가 기억하게 만드는 방식이죠. 실제로 써보니까 한 번만 템플릿과 변수 구조를 잘 잡아두면, 이후엔 VM 증설이 거의 배포 작업처럼 바뀝니다. 이거 진짜 편하더라고요.

    특히 테스트 서버, 쿠버네티스 노드, CI Runner(러너, 작업 실행기), 관제용 유틸리티 VM처럼 반복 생성되는 워크로드에는 효과가 큽니다. 반대로 템플릿 설계가 어설프면 삽질도 크게 옵니다. 저도 처음엔 Cloud-Init(클라우드 이닛, 최초 부팅 자동 설정)과 스토리지 설정 때문에 꽤 헤맸거든요. 이번 글에서는 그 과정을 최대한 실무 관점으로 풀어보겠습니다.

    Terraform Proxmox provider 기반 VM 자동화 아키텍처 이미지

    Terraform Proxmox provider로 템플릿 기반 VM이 자동 생성되는 전체 흐름을 보여주는 아키텍처 이미지입니다.

    왜 Terraform Proxmox provider가 중요한가

    핵심은 재현성(reproducibility, 동일한 결과를 반복 생성하는 성질)입니다. 사람이 수동으로 만든 VM은 겉보기엔 같아도 내부 설정이 미묘하게 다를 때가 많습니다. 그런데 Terraform(테라폼, 선언형 인프라 도구)은 원하는 상태를 선언하고, Provider(프로바이더, 특정 플랫폼과 통신하는 플러그인)가 그 상태를 실제 인프라에 반영합니다.

    쉽게 말해 이렇습니다.

    • Proxmox VE는 VM을 실행하는 플랫폼입니다.
    • Terraform은 “이런 VM을 만들어라”라고 선언하는 도구입니다.
    • Terraform Proxmox provider는 그 선언을 Proxmox API 요청으로 바꿔주는 다리 역할을 합니다.

    여기서 중요한 포인트! 수동 작업을 없애는 것도 좋지만, 더 중요한 건 변경 이력과 표준화입니다. 누가 언제 CPU를 2개에서 4개로 바꿨는지, 왜 디스크 타입을 SCSI(스카시, 저장장치 인터페이스)로 통일했는지, 네트워크 브리지를 왜 vmbr0로 고정했는지 코드와 Git 기록으로 남길 수 있거든요.

    수동 생성과 IaC Proxmox 방식 비교

    항목 수동 생성 IaC Proxmox
    반복 작업 사람이 매번 클릭 코드 재실행으로 반복
    설정 일관성 실수 발생 가능 동일 코드면 동일 결과
    변경 추적 메모 의존 Git으로 추적 가능
    대량 배포 시간 많이 소요 상대적으로 빠름
    복구 다시 손으로 작업 코드 기반 재생성 가능

    Terraform Proxmox provider의 동작 원리

    제가 직접 해보니 가장 헷갈리는 지점은 “Terraform이 VM 이미지를 직접 만드는 건가요?”라는 부분이었습니다. 사실은 그렇지 않습니다. 보통 흐름은 아래처럼 갑니다.

    1. Proxmox에 미리 VM 템플릿을 만들어 둡니다.
    2. Terraform이 Provider를 통해 Proxmox API에 접속합니다.
    3. 기존 템플릿을 Clone(클론, 복제)해서 새 VM을 만듭니다.
    4. CPU, 메모리, 디스크, 네트워크, IP 같은 값을 주입합니다.
    5. Cloud-Init으로 초기 사용자, SSH 키, 네트워크 정보를 반영합니다.

    즉, 템플릿 품질이 절반이고, Terraform 코드 구조가 나머지 절반입니다. 처음엔 이게 뭔가 싶었는데, 몇 번 구성해보니까 “템플릿은 골조, Terraform은 배포 정의서”라고 생각하면 이해가 빨랐습니다.

    실무에서 많이 쓰는 구성 요소

    • Template VM: Ubuntu 같은 게스트 OS를 미리 구성한 원본
    • Cloud-Init: 호스트명, 계정, SSH 키, IP 초기 설정
    • Bridge Network: vmbr0 같은 브리지 네트워크
    • Storage: local-lvm, zfs 같은 스토리지 대상
    • API Token: 자동화를 위한 인증 수단

    여기서 인증은 비밀번호보다 API Token(에이피아이 토큰, 자동화용 인증 토큰) 기반이 관리하기 더 낫습니다. 노출 범위를 줄이기도 좋고, 권한 분리도 더 명확하거든요.

    실전 구현: Proxmox VM 자동화 기본 구조

    이제 구현으로 들어가보겠습니다. 아래 예시는 가장 흔하게 쓰는 흐름인 “템플릿 복제 + Cloud-Init + 변수 기반 프로비저닝” 기준입니다. 다만 여기서 한 가지는 꼭 짚고 가야 합니다. Terraform Proxmox provider는 커뮤니티 제공 구현이 여럿 존재할 수 있고, 세부 인자명은 사용하는 provider 계열에 따라 조금씩 다를 수 있습니다. 그래서 아래 예시는 많이 쓰이는 패턴 중심으로 보시고, 실제 적용 전에는 사용 중인 provider 문서를 반드시 한 번 더 확인하시는 걸 권장합니다.

    1. 사전 준비

    1. Proxmox VE에 템플릿용 Linux VM을 하나 준비합니다.
    2. Cloud-Init이 활성화된 이미지 또는 패키지를 사용합니다.
    3. API Token을 생성합니다.
    4. Terraform 실행 환경에 인증 정보를 환경 변수로 넣습니다.

    제가 처음 삽질했던 부분은 템플릿을 그냥 “설치 완료된 VM” 정도로만 생각했던 점이었습니다. 근데 실제로는 템플릿 안에서 네트워크 초기화, SSH 접근, qemu-guest-agent 사용 여부까지 꽤 중요하더라고요.

    2. Provider 설정

    terraform {
      required_providers {
        proxmox = {
          source = "Telmate/proxmox"
        }
      }
    }
    
    provider "proxmox" {
      pm_api_url          = var.pm_api_url
      pm_api_token_id     = var.pm_api_token_id
      pm_api_token_secret = var.pm_api_token_secret
      pm_tls_insecure     = true
    }

    테스트 랩에서는 자체 서명 인증서 때문에 pm_tls_insecure = true를 쓰는 경우가 있습니다. 다만 운영 환경이라면 TLS 검증을 제대로 구성하는 쪽이 맞습니다. 홈랩에서는 편의상 넘어가도, 실무에선 보안 예외가 습관이 되면 좀 위험하거든요.

    3. 변수 정의

    variable "pm_api_url" {
      type = string
    }
    
    variable "pm_api_token_id" {
      type = string
    }
    
    variable "pm_api_token_secret" {
      type      = string
      sensitive = true
    }
    
    variable "target_node" {
      type    = string
      default = "pve01"
    }
    
    variable "template_name" {
      type    = string
      default = "ubuntu-cloudinit-template"
    }
    
    variable "vm_name" {
      type    = string
      default = "lab-app-01"
    }
    
    variable "vm_id" {
      type    = number
      default = 201
    }
    
    variable "vm_cores" {
      type    = number
      default = 2
    }
    
    variable "vm_memory" {
      type    = number
      default = 4096
    }
    
    variable "vm_ip" {
      type    = string
      default = "192.168.10.51/24"
    }
    
    variable "vm_gateway" {
      type    = string
      default = "192.168.10.1"
    }
    
    variable "ssh_public_key" {
      type = string
    }

    여기서는 일부러 값들을 분리했습니다. 이유가 있습니다. 처음엔 파일 하나에 다 때려 넣고 싶어지는데, VM 개수가 늘어나면 변수 분리가 안 되어 있는 구성이 유지보수 지옥으로 바뀝니다. 특히 이름, VM ID, IP는 충돌 관리 포인트라서 명시적으로 빼두는 게 좋습니다.

    Terraform Proxmox provider 설정과 템플릿 복제 흐름 이미지

    변수 파일, Provider, 템플릿 VM, Cloud-Init이 어떻게 연결되는지 설명하는 구성 이미지입니다.

    4. VM 리소스 정의

    resource "proxmox_vm_qemu" "app_vm" {
      name        = var.vm_name
      target_node = var.target_node
      clone       = var.template_name
      vmid        = var.vm_id
    
      cores   = var.vm_cores
      sockets = 1
      memory  = var.vm_memory
      agent   = 1
      onboot  = true
    
      os_type   = "cloud-init"
      bootdisk  = "scsi0"
      scsihw    = "virtio-scsi-pci"
    
      disk {
        slot    = "scsi0"
        size    = "20G"
        type    = "disk"
        storage = "local-lvm"
      }
    
      network {
        model  = "virtio"
        bridge = "vmbr0"
      }
    
      ipconfig0  = "ip=${var.vm_ip},gw=${var.vm_gateway}"
      ciuser     = "ubuntu"
      sshkeys    = var.ssh_public_key
    }

    이 구성이 기본 골격입니다. clone으로 템플릿을 복제하고, ipconfig0와 sshkeys로 초기 설정을 주입합니다. 실제로 써보니까 사람이 VM 만들고 SSH 키 복붙하고 IP 적어 넣던 시간을 거의 없애주더라고요. 드디어 됐다! 싶은 순간이 여기였습니다.

    5. 실행 명령

    export TF_VAR_pm_api_url="https://proxmox.example.local:8006/api2/json"
    export TF_VAR_pm_api_token_id="terraform@pve!iac"
    export TF_VAR_pm_api_token_secret="REDACTED"
    export TF_VAR_ssh_public_key="ssh-ed25519 AAAA... user@host"
    
    terraform init
    terraform plan
    terraform apply

    terraform plan 단계는 꼭 보셔야 합니다. 특히 VM ID, 스토리지 이름, 브리지 이름이 맞는지 여기서 1차로 걸러집니다. 저는 예전에 스토리지 이름을 기억으로 넣었다가 local-lvm 대신 다른 이름을 써서 실패했었는데, plan에서 못 알아차리고 바로 적용했다가 로그 뒤지느라 시간 꽤 썼습니다 ㅎㅎ

    여러 대를 한 번에 만드는 패턴

    한 대만 만들 거면 변수 몇 개로도 충분합니다. 그런데 홈랩이든 사내 테스트 존이든, VM이 3대 이상 넘어가면 반복 생성이 필요해집니다. 이때는 count 또는 for_each 패턴을 잡아두는 게 좋습니다.

    variable "vm_map" {
      type = map(object({
        vmid    = number
        ip      = string
        memory  = number
        cores   = number
      }))
    }
    
    resource "proxmox_vm_qemu" "node" {
      for_each    = var.vm_map
      name        = each.key
      target_node = var.target_node
      clone       = var.template_name
      vmid        = each.value.vmid
    
      cores   = each.value.cores
      sockets = 1
      memory  = each.value.memory
      agent   = 1
      onboot  = true
    
      os_type  = "cloud-init"
      bootdisk = "scsi0"
      scsihw   = "virtio-scsi-pci"
    
      disk {
        slot    = "scsi0"
        size    = "20G"
        type    = "disk"
        storage = "local-lvm"
      }
    
      network {
        model  = "virtio"
        bridge = "vmbr0"
      }
    
      ipconfig0 = "ip=${each.value.ip},gw=${var.vm_gateway}"
      ciuser    = "ubuntu"
      sshkeys   = var.ssh_public_key
    }

    이렇게 해두면 노드 이름과 IP를 맵으로 받아서 여러 대를 한 번에 만들 수 있습니다. 쿠버네티스 실습 클러스터 같은 데서 정말 유용합니다. 다음 글에서는 이 구성을 기반으로 Ansible(앤서블, 구성 자동화 도구)까지 연결하는 흐름도 다룰 예정입니다.

    ⚠️ 실제로 자주 만나는 문제와 해결법

    여기는 경험담 비중이 큽니다. 문서만 보면 금방 될 것 같았는데, 막상 해보면 안 되는 포인트가 몇 군데 있습니다.

    1. Cloud-Init이 적용되지 않는 문제

    증상은 이렇습니다. VM은 생성됐는데 호스트명, 사용자, SSH 키, IP가 안 들어갑니다. 보통 원인은 아래 중 하나였습니다.

    • 템플릿 자체가 Cloud-Init 준비가 안 되어 있음
    • 게스트 OS 내부 패키지 누락
    • 네트워크 인터페이스 이름이 템플릿 예상과 다름
    • 템플릿 변환 전에 초기화가 덜 끝남

    해결 팁: 템플릿에서 먼저 수동 부팅 테스트를 해보세요. SSH 키 주입과 네트워크가 정상 반영되는지 검증한 뒤 템플릿으로 바꾸는 게 낫습니다. 저도 이걸 건너뛰었다가 Terraform 탓인 줄 알고 한참 돌았었습니다.

    2. VM ID 충돌

    Proxmox는 VM ID가 겹치면 당연히 실패합니다. 작은 랩에서는 사람이 관리 가능하지만, 자동화가 늘어나면 금방 꼬입니다. 이럴 땐 ID 정책을 아예 정해두는 게 좋습니다. 예를 들어 200번대는 앱 서버, 300번대는 쿠버네티스 노드처럼요.

    3. 스토리지 이름과 디스크 타입 불일치

    문서 예제 그대로 복사했는데 안 되는 경우가 많습니다. 이유는 환경마다 스토리지 이름이 다르기 때문입니다. local-lvm, local, ZFS 풀 이름 등이 제각각이거든요. 여기서 중요한 포인트! 예제 코드는 참고용이고, 실제 환경 값은 Proxmox UI에서 다시 확인하셔야 합니다.

    4. 권한 부족 또는 API 토큰 범위 문제

    API Token을 만들었는데도 생성이 안 되는 경우가 있습니다. 이건 대개 토큰 자체보다 연결된 사용자 권한 범위 문제였습니다. 특히 특정 노드나 특정 스토리지에 대한 권한이 빠져 있으면 묘하게 일부만 되고 일부는 안 되는 식으로 보이더라고요.

    5. 병렬 생성 시 타이밍 이슈

    여러 대를 한 번에 만들면 템플릿 clone 이후 디스크나 네트워크 상태 반영이 느려서 간헐 실패하는 경우가 있습니다. 홈랩 장비 성능이 여유롭지 않으면 더 잘 보입니다. 이런 경우는 한 번에 너무 많이 생성하지 말고, 상태를 확인하면서 배치 크기를 조절하는 게 현실적이었습니다.

    검증: 프로비저닝 결과는 어떻게 확인하나

    자동화는 “생성됐다”보다 “원하는 상태로 생성됐다”가 중요합니다. 그래서 저는 적용 후 아래 순서로 꼭 확인합니다.

    1. Terraform state에 리소스가 정상 반영됐는지 확인
    2. Proxmox UI에서 VM 스펙과 노드 배치 확인
    3. IP 할당과 부팅 상태 확인
    4. SSH 접속 확인
    5. qemu-guest-agent 정보 반영 여부 확인
    terraform state list
    terraform show
    ssh [email protected]
    ping -c 3 192.168.10.51

    여기서 SSH 접속까지 되면 거의 끝난 겁니다. 실제로 써보니까 가장 기분 좋은 순간이 이때예요. 코드 한 번 실행했을 뿐인데 VM이 살아 있고, 네트워크도 붙고, 키 인증도 바로 되는 걸 보면 자동화 체감이 확 옵니다. 🎉

    Terraform Proxmox provider 적용 후 VM 생성 결과 이미지

    Terraform 적용 후 여러 VM이 정상 생성되고 실행 중인 결과를 시각적으로 보여주는 이미지입니다.

    결과 해석 포인트

    • Created만 보지 말고 실제 접속 가능 여부까지 확인합니다.
    • State drift(상태 드리프트, 코드와 실제 상태 불일치)가 없는지 주기적으로 봅니다.
    • 운영 전이라면 destroy까지 테스트해보는 게 좋습니다.

    이 마지막 포인트가 은근 중요합니다. 생성은 잘 되는데 삭제가 깔끔하지 않으면 나중에 리소스 찌꺼기가 남습니다. 홈랩은 그래도 괜찮은데, 운영성 테스트에서는 꼭 확인해보셔야 합니다.

    정리: Terraform Proxmox provider를 잘 쓰려면

    이번 내용을 한 줄로 요약하면 이렇습니다. Terraform Proxmox provider의 핵심은 템플릿 품질, 변수 설계, 검증 습관입니다. 도구 자체는 어렵지 않은데, 환경 차이 때문에 사소한 설정명이 계속 발목을 잡습니다. 저도 처음엔 “왜 문서대로 했는데 안 되지?”를 몇 번 겪었고, 결국 문제의 대부분은 템플릿 준비 부족이나 환경별 값 차이에서 나오더라고요.

    그래도 한 번 구조를 잡아두면 Proxmox VM 자동화는 정말 강력합니다. 테스트 서버를 빠르게 띄우고, 실습 클러스터를 반복 재현하고, IaC Proxmox 방식으로 변경 이력을 남길 수 있습니다. 사람이 기억하던 인프라를 코드가 기억하게 되는 거죠. 여기서부터 운영 수준이 확 달라집니다.

    실전 체크리스트

    • 템플릿 VM이 Cloud-Init 준비가 됐는지 확인
    • API Token 권한 범위를 최소 필요 수준으로 설계
    • 스토리지, 브리지, 노드 이름을 실제 환경 값으로 검증
    • VM ID 정책을 미리 정리
    • terraform plan 결과를 습관적으로 검토
    • 생성 후 SSH와 네트워크까지 확인

    자주 묻는 질문

    Q1. Terraform Proxmox provider만으로 모든 운영 자동화가 끝나나요?

    아닙니다. 보통은 VM 생성까지 Terraform이 맡고, 내부 패키지 설치나 서비스 배포는 Ansible 같은 도구와 함께 쓰는 경우가 많습니다.

    Q2. 운영 환경에서도 바로 써도 되나요?

    가능은 하지만, 홈랩 예제를 그대로 가져가면 안 됩니다. 인증서 검증, 권한 분리, 스토리지 정책, 백업 정책, 삭제 절차를 먼저 정리하셔야 합니다.

    Q3. 단일 VM보다 여러 대 자동화에서 더 효과가 큰가요?

    네, 효과 차이가 큽니다. 한 대만 만들면 수동도 가능하지만, 3대 이상 반복되면 프로비저닝 자동화 가치가 바로 보입니다.

    IaC Proxmox와 수동 VM 생성 비교 인포그래픽

    수동 작업과 Terraform 기반 IaC Proxmox 자동화의 차이를 한눈에 정리한 비교 이미지입니다.

    이전 글에서 다뤘던 네트워크 브리지 설계나 템플릿 표준화 내용을 함께 보시면 더 이해가 빠르실 겁니다. 다음 글에서는 이 구성을 바탕으로 멀티 VM 배포 뒤 Ansible로 후속 설정까지 이어붙이는 흐름을 정리해보겠습니다. 혹시 지금 Proxmox 환경에서 VM을 반복 생성하고 계신다면, 이번 기회에 정말 한 번 코드로 바꿔보세요. 처음엔 조금 귀찮아도, 나중엔 수동 클릭으로 돌아가기 어렵습니다. ✅

  • [인프라] Ansible을 활용한 프라이빗 네트워킹 자동화 구축 사례

    [인프라] Ansible을 활용한 프라이빗 네트워킹 자동화 구축 사례

    [인프라] Ansible 프라이빗 네트워킹 자동화 구축 사례

    Ansible 프라이빗 네트워킹 작업을 손으로 해보신 분들은 아마 비슷한 경험 있으실 겁니다. 서버 2~3대일 때는 상관없었는데, 노드가 늘어나고 서브넷이 여러 개로 갈라지기 시작하면 어느 순간부터 설정이 서로 조금씩 달라지더라고요. 저도 홈랩에서 서비스용 VM, 모니터링 노드, 백업 서버를 따로 굴리면서 프라이빗 네트워킹을 수동으로 맞추다가 꽤 크게 삽질했습니다. 처음엔 이게 뭔가 싶었는데, 결국 답은 Ansible(앤서블, 에이전트 없이 설정을 배포하는 자동화 도구)로 네트워크 상태를 코드로 관리하는 쪽이었어요. 이번 글에서는 제가 직접 구축했던 흐름을 바탕으로, Ansible 프라이빗 네트워킹을 어떻게 정리했고 어떤 문제를 만났는지, 그리고 왜 이 방식이 네트워크 자동화와 IaC 구축 사례로 꽤 실용적인지 풀어보겠습니다.

    핵심은 단순합니다. 네트워크 장비를 거창하게 바꾸는 게 아니라, 리눅스 서버들 사이의 프라이빗 네트워킹 규칙을 플레이북으로 통일하고, 터널 인터페이스와 라우팅, 방화벽 정책을 반복 가능하게 만드는 거거든요. 말은 쉬운데 실제로 해보면 변수 설계, 템플릿 관리, 검증 순서에서 많이 흔들립니다. 여기서 중요한 포인트! 처음부터 완벽하게 만들려고 하기보다, 재현 가능한 최소 구성부터 잡는 게 훨씬 중요해요.

    Ansible 프라이빗 네트워킹 전체 아키텍처를 보여주는 홈랩 다이어그램

    홈랩 서버, 관리 노드, 프라이빗 터널 구간이 한눈에 보이는 전체 구성 예시입니다.

    Ansible로 프라이빗 네트워킹을 관리하면 편한 이유

    쉽게 말해 수동 설정의 정반대 방식입니다. 예전에는 서버 한 대마다 <code>/etc 아래 설정 파일을 열고, 라우팅을 넣고, 방화벽 규칙을 맞추고, 서비스 재시작까지 사람이 기억에만 의존했거든요. 근데 이 방식은 꼭 한 군데가 빠집니다. 저도 실제로 써보니 어떤 노드는 MTU가 다르고, 어떤 노드는 AllowedIPs가 빠져 있고, 또 어떤 노드는 재부팅 후 설정이 안 살아나는 식으로 문제가 생겼더라고요.

    이걸 Infrastructure as Code(IaC, 인프라를 코드로 관리하는 방식)로 바꾸면 상황이 달라집니다.

    • Inventory(인벤토리, 관리 대상 목록)에 어떤 서버가 있는지 정의해요.
    • Variables(변수)로 사설 대역, 터널 주소, 허용 라우트를 분리하죠.
    • Template(템플릿)으로 노드별 설정 파일을 자동 생성합니다.
    • Playbook(플레이북)으로 같은 절차를 모든 노드에 반복 적용하죠.
    • Idempotency(멱등성, 여러 번 실행해도 결과가 같음)를 활용해 drift를 줄여요.

    즉, 네트워크 자동화를 하겠다는 말이 대단한 솔루션 도입이 아니라, 사람이 기억하던 설정을 코드로 옮기는 과정일 뿐입니다. 저도 처음엔 네트워크 자동화라고 하면 스위치나 라우터 자동화부터 떠올렸는데, 막상 운영에서는 서버 간 프라이빗 네트워킹 통일만 해도 체감 효과가 엄청 컸거든요.

    이번 IaC 구축 사례: 전제 조건과 구성

    이번 예시는 제가 홈랩에서 자주 쓰는 구조를 단순화한 형태입니다. 관리 노드 한 대에서 Ansible을 실행하고, 여러 리눅스 서버에 WireGuard(와이어가드, 경량 VPN 터널) 기반의 사설 통신 구간을 배포하는 방식이에요. 특정 벤더 장비에 종속되지 않아서 연습용으로도 좋고, 나중에 클라우드 VM과 온프레미스 서버를 함께 묶을 때도 응용하기 편하더라고요.

    항목 수동 구성 Ansible 활용
    터널 설정 배포 서버마다 직접 작성 템플릿으로 일괄 생성
    라우팅 반영 명령어 수동 입력 변수 기반 반복 적용
    방화벽 정책 누락 가능성 높음 태스크로 표준화
    검증 접속 후 개별 확인 애드혹 명령과 플레이북으로 점검
    변경 추적 문서 의존 Git 기반 이력 관리

    제가 실제로 정리한 목표는 아래 네 가지였습니다.

    1. 사설 터널 인터페이스 설정을 노드별로 자동 생성할 것
    2. 서브넷 광고와 허용 라우트를 변수로 분리할 것
    3. 방화벽 정책을 최소 허용 원칙으로 맞출 것
    4. 검증 명령까지 플레이북 운용 흐름에 포함할 것

    사실 여기까지만 정리해도 운영 난이도가 확 내려갑니다. 특히 장애가 났을 때 “이 서버는 누가 어떻게 바꿨지?”가 아니라 “현재 Git에 있는 정의와 실제 상태가 같은가?”로 질문이 바뀌는 게 큽니다.

    실전 구현 1: 디렉터리와 Inventory 설계

    제가 직접 해보니 제일 먼저 잡아야 할 게 파일 구조였습니다. 플레이북보다 구조가 먼저더라고요. 구조가 흔들리면 변수 이름이 꼬이고, 나중에는 어디를 고쳐야 하는지 찾느라 시간이 더 들어가거든요.

    ansible-private-network/
    ├── inventory/
    │   └── hosts.yml
    ├── group_vars/
    │   └── private_nodes.yml
    ├── host_vars/
    │   ├── node-a.yml
    │   ├── node-b.yml
    │   └── node-c.yml
    ├── templates/
    │   └── wg0.conf.j2
    └── playbooks/
        └── private-network.yml

    인벤토리는 이렇게 시작했습니다.

    all:
      children:
        private_nodes:
          hosts:
            node-a:
              ansible_host: 10.10.0.11
            node-b:
              ansible_host: 10.10.0.12
            node-c:
              ansible_host: 10.10.0.13

    그룹 변수에는 공통 속성을 넣어요.

    private_network_interface: wg0
    private_network_port: 51820
    private_network_cidr: 10.200.0.0/24
    private_network_mtu: 1380
    private_network_service_name: wg-quick@wg0
    private_network_allowed_udp_port: 51820

    그리고 호스트 변수에는 노드별 정보만 남깁니다.

    wireguard_address: 10.200.0.1/24
    wireguard_private_key: "{{ vault_node_a_private_key }}"
    wireguard_peers:
      - name: node-b
        public_key: "{{ vault_node_b_public_key }}"
        endpoint: "node-b.example.internal:51820"
        allowed_ips:
          - 10.200.0.2/32
      - name: node-c
        public_key: "{{ vault_node_c_public_key }}"
        endpoint: "node-c.example.internal:51820"
        allowed_ips:
          - 10.200.0.3/32

    여기서 중요한 포인트는 공통 값과 개별 값을 철저히 분리하는 거예요. 처음엔 저도 peers까지 그룹 변수에 밀어 넣었다가, 특정 노드만 광고해야 하는 라우트가 달라지면서 구조를 다시 뜯었거든요. 처음 설계가 깔끔하면 이후 네트워크 자동화가 훨씬 편해집니다.

    Ansible 프라이빗 네트워킹 인벤토리와 변수 구조를 설명하는 구성도

    인벤토리, 그룹 변수, 호스트 변수의 역할 분리를 설명하는 구성도입니다.

    실전 구현 2: 템플릿과 플레이북으로 프라이빗 네트워킹 배포

    이제 실제 설정을 만듭니다. 템플릿은 Jinja2(진자2, 변수 치환 템플릿 엔진)를 사용하고, 노드마다 다른 값만 렌더링되게 구성하죠.

    [Interface]
    Address = {{ wireguard_address }}
    ListenPort = {{ private_network_port }}
    PrivateKey = {{ wireguard_private_key }}
    MTU = {{ private_network_mtu }}
    
    {% for peer in wireguard_peers %}
    [Peer]
    PublicKey = {{ peer.public_key }}
    Endpoint = {{ peer.endpoint }}
    AllowedIPs = {{ peer.allowed_ips | join(', ') }}
    PersistentKeepalive = 25
    {% endfor %}

    플레이북은 아래처럼 구성했습니다.

    - name: Configure private networking with Ansible
      hosts: private_nodes
      become: true
      vars:
        wireguard_config_path: "/etc/wireguard/{{ private_network_interface }}.conf"
      tasks:
        - name: Install WireGuard package on Debian or Ubuntu
          ansible.builtin.apt:
            name: wireguard
            state: present
            update_cache: true
          when: ansible_facts['os_family'] == 'Debian'
    
        - name: Ensure wireguard config directory exists
          ansible.builtin.file:
            path: /etc/wireguard
            state: directory
            owner: root
            group: root
            mode: '0700'
    
        - name: Render wireguard configuration
          ansible.builtin.template:
            src: ../templates/wg0.conf.j2
            dest: "{{ wireguard_config_path }}"
            owner: root
            group: root
            mode: '0600'
          notify: restart wireguard
    
        - name: Allow WireGuard UDP port
          ansible.builtin.iptables:
            chain: INPUT
            protocol: udp
            destination_port: "{{ private_network_allowed_udp_port }}"
            jump: ACCEPT
            comment: Allow WireGuard
    
        - name: Enable IP forwarding for routed nodes
          ansible.posix.sysctl:
            name: net.ipv4.ip_forward
            value: '1'
            state: present
            reload: true
          when: routed_node | default(false)
    
        - name: Enable and start wireguard service
          ansible.builtin.systemd:
            name: "{{ private_network_service_name }}"
            enabled: true
            state: started
    
      handlers:
        - name: restart wireguard
          ansible.builtin.systemd:
            name: "{{ private_network_service_name }}"
            state: restarted

    실행은 단순합니다.

    ansible-playbook -i inventory/hosts.yml playbooks/private-network.yml --ask-vault-pass

    여기서 저는 Ansible Vault(앤서블 볼트, 민감정보 암호화 기능)를 꼭 같이 쓰는 편입니다. 프라이빗 키를 평문으로 저장해 두면 언젠가 반드시 문제가 돼거든요. 특히 홈랩이라고 방심하면 안 되더라고요. Git에 한 번 잘못 올라가면 정리도 번거롭고요.

    또 하나, 라우팅이 필요한 노드라면 단순 터널 생성만으로 끝나지 않습니다. 예를 들어 특정 노드가 192.168.50.0/24 대역 뒤에 있는 내부 자원을 광고해야 한다면, peer의 allowed_ips에 그 대역을 넣고 해당 노드에는 포워딩을 켜야 하죠. 이 부분이 빠지면 터널은 올라왔는데 실제 통신은 안 되는, 가장 답답한 상태가 돼요. 저도 여기서 한참 막혔었습니다.

    템플릿에서 실제 설정 파일이 생성되고 서비스가 재시작되는 흐름을 보여주는 다이어그램입니다.

    실전 구현 3: 운영 관점에서 꼭 넣어야 할 검증 절차

    제가 처음 만든 플레이북은 배포까지만 자동화하고 검증은 손으로 했는데, 그러면 반쪽짜리더라고요. 배포보다 더 중요한 게 결과 확인이거든요. 그래서 아래처럼 최소 검증 루틴을 꼭 넣었습니다.

    1. 서비스 상태 확인
    2. 인터페이스 주소 확인
    3. 피어 간 핑 테스트
    4. 필요 시 라우팅된 내부 대역까지 도달성 확인
    ansible private_nodes -i inventory/hosts.yml -m ansible.builtin.shell -a "systemctl is-active wg-quick@wg0"
    ansible private_nodes -i inventory/hosts.yml -m ansible.builtin.shell -a "ip addr show wg0"
    ansible private_nodes -i inventory/hosts.yml -m ansible.builtin.shell -a "wg show"
    ansible private_nodes -i inventory/hosts.yml -m ansible.builtin.shell -a "ping -c 2 10.200.0.1"

    조금 더 확실하게 하고 싶다면 검증용 태스크를 별도 태그로 분리하는 것도 좋습니다.

    - name: Validate private networking status
      hosts: private_nodes
      become: true
      tasks:
        - name: Check wireguard service state
          ansible.builtin.command: systemctl is-active wg-quick@wg0
          register: wg_service
          changed_when: false
    
        - name: Print wireguard service state
          ansible.builtin.debug:
            var: wg_service.stdout

    이렇게 해두면 변경 배포 직후에 같은 도구로 배포와 검증을 이어서 실행할 수 있어요. 운영에서는 이 연결감이 꽤 중요합니다. “설정은 들어갔습니다”와 “통신이 됩니다”는 완전히 다른 말이거든요.

    ⚠️ 트러블슈팅: 제가 실제로 막혔던 포인트들

    이 섹션은 꼭 넣고 싶었어요. 블로그 글은 대개 예쁘게 완성된 결과만 보여주는데, 실제 운영은 그 전에 삽질이 있거든요 ㅎㅎ 저도 처음엔 금방 끝날 줄 알았는데, 프라이빗 네트워킹은 생각보다 자잘한 함정이 많았습니다.

    1. CIDR(사이더, IP 대역 표기) 중복

    가장 먼저 만난 문제였습니다. 터널용 대역과 기존 내부망 대역이 겹치면 라우팅이 애매해져요. 특히 홈랩에서는 예전에 대충 잡아둔 10.x 대역이 여기저기 숨어 있는 경우가 많거든요. 해결은 단순하지만 중요합니다. 터널 대역은 기존 VLAN, 내부 서브넷과 절대 겹치지 않게 별도로 예약해야 하죠.

    2. MTU 불일치

    터널은 올라오는데 큰 패킷에서만 통신이 불안정한 경우가 있었습니다. 처음엔 방화벽 문제인 줄 알았는데, 실제로는 MTU가 맞지 않아서 단편화 이슈가 생긴 거였어요. 이럴 때는 템플릿에 MTU를 명시해서 노드별 편차를 없애는 게 편합니다. 환경마다 정답 값은 다를 수 있어서, 확신 없는 수치를 무작정 복붙하기보다는 현재 경로 MTU를 기준으로 조정하시는 게 좋습니다.

    3. AllowedIPs 설계 실수

    WireGuard에서 AllowedIPs는 단순 허용 목록이 아니라 사실상 라우팅 의도까지 담아요. 저는 처음에 모든 대역을 넓게 넣었다가 의도치 않은 경로 선점 때문에 통신이 꼬인 적이 있습니다. 여기서 중요한 포인트! peer마다 정말 필요한 대역만 최소한으로 선언해야 합니다.

    4. 키 관리

    프라이빗 키와 퍼블릭 키를 어떻게 배포할지 초반에 기준이 없으면 금방 혼란스러워집니다. 제가 추천하는 방식은 이겁니다.

    • 프라이빗 키는 Vault로 암호화
    • 퍼블릭 키는 host_vars 또는 별도 데이터 파일에 저장
    • 운영 환경과 실험 환경의 키를 분리
    • 키 재생성 절차를 문서화

    이걸 안 해두면 나중에 노드 교체할 때 “어느 키가 어디서 쓰였더라?” 하고 다시 추적하게 돼요. 실제로 한번 겪어보니 이거 진짜 번거롭더라고요.

    5. 서비스 재시작 타이밍

    설정 파일이 바뀔 때마다 바로 재시작하는 건 편하지만, 여러 태스크가 연달아 변경될 때 중간 상태로 서비스가 흔들릴 수 있어요. 그래서 handler로 재시작을 뒤로 모아두는 구조가 안정적이었습니다. 작은 차이 같아 보여도 운영 중엔 꽤 큽니다.

    Ansible 프라이빗 네트워킹 트러블슈팅 포인트를 보여주는 운영 분석 이미지

    CIDR 중복, MTU, 라우팅 설정 오류 같은 대표 장애 포인트를 정리한 체크리스트 이미지입니다.

    검증 결과: 수동 작업이 줄어든 체감이 확실했습니다

    구성을 정리하고 나서 가장 먼저 달라진 게 신규 노드 추가 속도였습니다. 예전에는 서버 한 대를 네트워크에 편입하려면 체크리스트를 보면서 한 줄씩 넣어야 했는데, 지금은 host_vars 추가하고 플레이북 돌린 뒤 검증만 하면 되거든요. 드디어 됐다! 싶은 순간이 여기였어요.

    또 좋았던 점은 운영 표준화였습니다. 이전에는 같은 역할 서버인데도 설정 파일이 미묘하게 달랐어요. 누가 언제 손봤는지 애매한 부분도 있었고요. 지금은 인벤토리와 변수 파일이 기준점이 되니, 장애 대응할 때도 시선이 한 군데로 모입니다. 이게 생각보다 큽니다.

    • 신규 노드 편입 절차가 단순해졌습니다.
    • 방화벽 규칙 누락 가능성이 줄었습니다.
    • 사설 통신 경로를 코드로 검토할 수 있게 됐습니다.
    • 변경 이력 추적이 쉬워졌습니다.
    • 다른 환경으로 복제하기 편해졌습니다.

    특히 Ansible 활용 관점에서 좋았던 게, 네트워크 설정과 운영 문서가 따로 놀지 않게 됐다는 점이에요. 플레이북 자체가 운영 절차 문서 역할을 하거든요. 이것도 꽤 실용적인 IaC 구축 사례라고 느꼈습니다.

    배포 완료 후 서비스 활성 상태와 피어 연결 상태를 검증하는 운영 화면 예시입니다.

    정리와 다음 단계

    이번 Ansible 프라이빗 네트워킹 구축에서 제가 배운 게 명확했어요. 네트워크 자동화는 거창한 출발이 아니라, 반복되는 수동 설정을 코드로 옮기는 것부터 시작한다는 점 말이죠. 처음엔 변수 분리나 템플릿 구조가 좀 귀찮게 느껴질 수 있는데, 노드가 늘어날수록 그 차이가 확실히 벌어집니다. 저도 처음엔 헷갈렸는데, 한 번 구조를 잡아두니 이후 확장은 훨씬 편했어요.

    만약 지금 수동으로 사설 터널, 라우팅, 방화벽을 맞추고 계시다면 이렇게 시작해 보세요.

    1. 대상 노드를 인벤토리로 정리합니다.
    2. 공통 변수와 개별 변수를 분리합니다.
    3. 설정 파일을 템플릿으로 바꿉니다.
    4. 서비스 적용과 검증을 플레이북에 함께 넣습니다.
    5. 민감정보는 Vault로 분리합니다.

    이 정도만 해도 운영 피로도가 꽤 줄어듭니다. 다음 글에서는 이번 구성을 이어서 멀티 서브넷 라우팅이나 Git 기반 변경 이력 관리 쪽까지 확장하는 방법도 다뤄볼 예정입니다. 이전 글에서 다뤘던 서버 표준화나 모니터링 자동화와 연결해서 보시면 흐름이 더 잘 보이실 겁니다.

    수동 구성과 자동화 구성의 차이, 운영 이점, 다음 확장 방향을 요약한 인포그래픽입니다.

    자주 묻는 질문

    Q1. 꼭 WireGuard를 써야 하나요?

    아니에요. 핵심은 특정 제품이 아니라 프라이빗 네트워킹 설정을 Ansible로 일관되게 관리하는 방식입니다. 다만 예제 설명에는 구조가 비교적 단순한 WireGuard가 잘 맞았거든요.

    Q2. 네트워크 장비 자동화와는 다른가요?

    조금 달라요. 이번 글은 서버 간 사설 통신 구성에 초점을 맞춘 사례입니다. 하지만 접근 방식 자체는 네트워크 자동화의 좋은 출발점이 되죠.

    Q3. 테스트 환경 없이 바로 운영에 넣어도 될까요?

    개인적으로는 권하지 않습니다. 저도 홈랩에서 먼저 검증하고 운영에 반영하는 편이거든요. 특히 라우팅과 방화벽은 예상과 다르게 동작할 수 있어서, 작은 테스트 환경이 큰 사고를 막아줘요.

    Q4. 어떤 부분부터 문서화해야 하나요?

    인벤토리 구조, 변수 규칙, 키 관리 방법, 검증 명령 순서부터 문서화하세요. 이 네 가지가 흔들리면 나머지도 금방 흔들립니다.

  • [Cloud] AWX를 활용한 클라우드 인프라 자동화 1년 회고: 운영 효율성과 도전 과제

    [Cloud] AWX를 활용한 클라우드 인프라 자동화 1년 회고: 운영 효율성과 도전 과제

    AWX 클라우드 인프라 자동화 1년 회고: 운영 효율성과 도전 과제

    안녕하세요, 13년차 서버실 지킴이입니다. 오늘은 제가 지난 1년간 AWX를 활용한 클라우드 인프라 자동화를 경험하며 느꼈던 점들을 솔직하게 회고해 보려고 해요. 클라우드 환경이 복잡해질수록 수동 작업의 한계를 뼈저리게 느끼곤 하잖아요? 저도 처음엔 정말 막막했습니다. 혹시 여러분도 이런 고민 해보신 적 있으신가요? “수동 작업 줄이고 싶은데, Ansible 플레이북은 많아지고 관리도 어렵고…” 딱 제가 그랬거든요. 그래서 이 녀석, AWX를 도입하게 됐습니다.

    AWX 클라우드 자동화 아키텍처 개요 다이어그램

    AWX 클라우드 자동화 아키텍처 개요 다이어그램

    AWX, 너는 누구니? (핵심 개념)

    자, 그럼 AWX가 뭔지부터 간단히 짚고 넘어갈게요. AWX는 Ansible Tower의 오픈소스 버전이라고 생각하시면 됩니다. 쉽게 말해, Ansible(앤서블) 플레이북(Playbook)을 웹 UI(User Interface)로 관리하고 실행할 수 있게 해주는 도구예요. 단순히 플레이북 실행뿐만 아니라, 인벤토리(Inventory, 관리 대상 호스트 목록), 크리덴셜(Credential, 자격 증명), 프로젝트(Project, 플레이북 저장소), 작업 템플릿(Job Template, 실행 단위), 그리고 워크플로우(Workflow, 여러 작업 템플릿 연결)까지 체계적으로 관리할 수 있게 해줍니다. 💡 특히 팀 단위로 Ansible을 활용할 때, 누가 어떤 작업을 언제 실행했는지, 성공 여부는 어땠는지 한눈에 파악할 수 있어서 정말 편하더라고요.

    AWX 도입 및 초기 셋업 경험

    저도 처음엔 “이거 설치부터 만만치 않겠는데?” 하고 살짝 긴장했었는데, 생각보다 어렵지 않았어요. 저는 주로 Docker Compose(도커 컴포즈)를 이용해서 컨테이너 환경에 AWX를 배포했습니다. 공식 문서에 잘 나와 있어서 따라 하는 데 큰 무리는 없었죠. 하지만 역시 초반 삽질은 피해 갈 수 없더라고요. 😅

    • 인벤토리 구성: 클라우드 환경이다 보니, 정적인 인벤토리보다는 동적 인벤토리(Dynamic Inventory) 스크립트를 활용해야 했습니다. AWS EC2 같은 경우는 플러그인을 활용해서 현재 떠 있는 인스턴스 목록을 자동으로 가져오게 설정했죠. 처음엔 필터링 규칙 잡는 게 좀 헷갈렸는데, 몇 번 해보니 감이 오더라고요.
    • 자격 증명(Credential) 관리: SSH 키나 클라우드 API 키 같은 민감 정보를 안전하게 관리하는 게 중요했습니다. AWX의 크리덴셜 기능을 사용해서 암호화된 형태로 저장하고, 필요한 작업 템플릿에만 연결해서 사용했습니다.
    • Git(깃) 연동: 저희 팀은 모든 Ansible 플레이북을 Git 저장소에 관리하고 있었거든요. AWX 프로젝트와 Git을 연동해서, Git에 푸시(Push)만 하면 AWX가 자동으로 최신 플레이북을 가져오도록 설정했습니다. 이 부분이 정말 편리했어요!

    클라우드 인프라 자동화, 이렇게 해봤습니다

    AWX를 도입하고 나서 저희 팀의 클라우드 인프라 자동화는 날개를 달았습니다. 지난 1년간 다양한 작업들을 AWX를 통해 자동화했는데요, 몇 가지 대표적인 사례를 소개해 드릴게요.

    1. EC2(혹은 VM) 프로비저닝 및 초기 설정 자동화: 새로운 서버를 띄울 때마다 수동으로 OS 설정, 보안 설정, 기본 에이전트 설치 등을 하는 게 큰일이었거든요. AWX에 작업 템플릿을 만들어두고, 인스턴스 ID만 입력하면 모든 초기 설정이 자동으로 완료되도록 했습니다.
    2. 보안 그룹(Security Group) 변경 자동화: 개발자들이 특정 IP를 열어달라고 요청할 때마다 일일이 AWS 콘솔에 들어가서 작업했었는데, 이젠 AWX 작업 템플릿에 IP와 설명을 입력하고 실행하면 끝! 휴먼 에러도 줄고, 작업 속도도 훨씬 빨라졌죠.
    3. 로드밸런서(Load Balancer) 대상 그룹(Target Group) 등록/해제: 배포 시 서비스 인스턴스를 로드밸런서에서 빼고 다시 넣는 작업을 AWX 워크플로우로 묶어 자동화했습니다.
    4. 주기적인 서버 패치 및 재부팅 관리: 매달 특정 요일에 정해진 서버 그룹에 OS 패치를 적용하고 필요시 재부팅하는 작업을 스케줄러(Scheduler) 기능을 활용해서 자동화했습니다.

    간단한 Ansible 플레이북 예시를 하나 보여드릴게요. 특정 EC2 인스턴스의 특정 포트를 보안 그룹에 추가하는 플레이북입니다.

    - name: Add a specific IP to security group
      hosts: localhost
      connection: local
      gather_facts: no
    
      vars:
        instance_id: 'i-xxxxxxxxxxxxxxxxx'
        port_to_open: 8080
        ip_to_allow: '192.168.1.1/32'
        description: 'Allow access from specific IP'
    
      tasks:
        - name: Get security group ID from instance
          amazon.aws.ec2_instance_info:
            instance_ids: "{{ instance_id }}"
          register: ec2_info
    
        - name: Set security group ID fact
          set_fact:
            sg_id: "{{ ec2_info.instances[0].security_groups[0].group_id }}"
    
        - name: Authorize ingress rule
          amazon.aws.ec2_group:
            group_id: "{{ sg_id }}"
            region: ap-northeast-2
            rules:
              - proto: tcp
                ports:
                  - "{{ port_to_open }}"
                cidr_ip: "{{ ip_to_allow }}"
                rule_desc: "{{ description }}"
            purge_rules: no
    

    이런 플레이북을 AWX에 등록하고, instance_id, port_to_open, ip_to_allow 같은 변수들만 작업 템플릿에서 설정하면 되는 거죠. 정말 편하더라고요! 🚀

    AWX 작업 템플릿 설정 화면 예시

    AWX 작업 템플릿 설정 화면 예시

    1년 회고: AWX가 가져온 운영 효율성

    지난 1년간 AWX를 운영하면서 가장 크게 체감한 건 바로 운영 효율성의 극대화였습니다. 🎉

    • 수동 작업 시간 획기적으로 감소: 과거에 1시간씩 걸리던 수동 설정 작업들이 이제는 AWX 작업 템플릿 한 번 클릭으로 5분 안에 끝나는 마법을 경험했습니다.
    • 휴먼 에러(Human Error) 감소: 정형화된 작업 템플릿을 사용하니, 사람이 실수할 여지가 거의 없어졌습니다. 특히 야간 작업이나 긴급 상황에서 빛을 발하더라고요.
    • 작업 표준화 및 가시성 확보: 모든 자동화 작업이 AWX를 통해 이루어지면서, 어떤 팀원이든 동일한 절차로 작업을 수행할 수 있게 되었고, 대시보드에서 모든 작업 이력을 한눈에 볼 수 있게 되었습니다.
    • 팀원 간 협업 용이: Ansible 플레이북을 공유하고, 각자의 권한에 맞춰 작업 템플릿을 실행할 수 있으니 팀원 간의 협업이 훨씬 부드러워졌어요.

    AWX 대시보드를 보면 성공률이 압도적으로 높은 걸 확인할 수 있는데, 정말 뿌듯하더라고요. 👍

    AWX 대시보드 자동화 작업 성공률 통계

    AWX 대시보드 자동화 작업 성공률 통계

    마주했던 도전 과제와 삽질 (Feat. 해결 과정)

    물론 AWX와 함께한 1년이 장밋빛만 있었던 건 아닙니다. 저도 꽤 많은 삽질을 했습니다. ⚠️ 특히 다음과 같은 부분에서 도전 과제가 있었어요.

    1. 자격 증명(Credential) 관리의 복잡성: 초기에는 AWS IAM(Identity and Access Management) 역할(Role)을 AWX에 연동하는 데 애를 먹었습니다. AWX가 EC2 인스턴스에서 실행될 때 IAM 역할을 상속받아 사용하는 방식과, 직접 AWS Access Key를 등록하는 방식 중 보안과 편리성을 저울질하는 데 시간이 걸렸죠. 결국, IAM 역할 기반 인증을 적극 활용하는 방향으로 정착했습니다.
    2. 동적 인벤토리(Dynamic Inventory) 스크립트 작성의 어려움: 클라우드 환경은 인스턴스가 수시로 뜨고 꺼지기 때문에, 항상 최신 인벤토리 정보를 유지하는 게 중요합니다. AWX에 내장된 클라우드 플러그인들을 최대한 활용했지만, 특정 태그(Tag)나 조건에 맞는 인스턴스만 필터링하는 복잡한 스크립트를 작성할 때는 시행착오가 많았습니다. 파이썬(Python)으로 직접 스크립트를 짜면서 디버깅하는 과정이 꽤 힘들었네요.
    3. 워크플로우(Workflow) 설계의 난이도: 여러 작업 템플릿을 연결해서 복잡한 배포 파이프라인(Pipeline)이나 운영 절차를 자동화할 때, 조건부 실행이나 실패 시 롤백(Rollback) 같은 로직을 AWX 워크플로우로 구현하는 게 생각보다 쉽지 않았습니다. 처음에는 너무 복잡하게 얽혀서 디버깅도 어려웠는데, 점차 작은 단위의 작업 템플릿으로 쪼개고, 명확한 성공/실패 조건을 정의하면서 해결해 나갔습니다.
    4. AWX 업그레이드의 험난함: 오픈소스 프로젝트이다 보니, 새로운 버전이 나올 때마다 업그레이드 가이드라인이 명확하지 않거나, 의존성 문제로 애를 먹는 경우가 있었습니다. 특히 컨테이너 환경에서 볼륨(Volume)이나 데이터베이스 마이그레이션(Migration) 과정에서 여러 번 백업/복구를 반복하며 땀 좀 흘렸습니다. 💦

    이 모든 과정들이 저를 더 단단하게 만들어주었네요. 역시 인프라는 삽질하면서 배우는 게 최고인 것 같습니다! ㅎㅎ

    AWX, 앞으로의 방향은? (마무리)

    지난 1년간 AWX 클라우드 자동화를 경험하면서 정말 많은 것을 배우고, 팀의 운영 효율성을 크게 향상시킬 수 있었습니다. 수동 작업을 줄이고, 휴먼 에러를 방지하며, 표준화된 절차를 통해 안정적인 서비스 운영을 할 수 있게 된 것이 가장 큰 성과라고 생각합니다.

    물론 아직 가야 할 길이 멀지만, 앞으로는 AWX를 CI/CD(Continuous Integration/Continuous Deployment) 파이프라인에 더욱 깊숙이 통합하고, GitOps(깃옵스) 철학을 도입하여 모든 인프라 변경 사항을 Git을 통해 관리하는 시스템을 구축하고 싶습니다. IaC(Infrastructure as Code, 코드형 인프라)를 넘어, 모든 것이 코드로 정의되고 자동으로 배포되는 이상적인 환경을 꿈꾸고 있거든요.

    혹시 여러분도 AWX를 활용한 클라우드 자동화 경험이 있으시다면, 어떤 점이 좋았고 어떤 어려움이 있었는지 댓글로 공유해 주시면 좋겠습니다. 다음 글에서는 AWX와 CI/CD 연동에 대한 좀 더 구체적인 내용을 다뤄볼까 합니다. 기대해주세요! 😊

    AWX와 클라우드 인프라 자동화의 미래 비전 인포그래픽

    AWX와 클라우드 인프라 자동화의 미래 비전 인포그래픽

  • [k8s] Kubernetes Operator 활용 사례: 데이터베이스 관리 자동화로 운영 효율 높이기

    [k8s] Kubernetes Operator 활용 사례: 데이터베이스 관리 자동화로 운영 효율 높이기

    Kubernetes Operator 활용 사례: 데이터베이스 관리 자동화로 운영 효율 높이기

    안녕하세요, 13년차 서버실 지킴이입니다. 오늘은 쿠버네티스(Kubernetes, 컨테이너 오케스트레이션 플랫폼) 환경에서 데이터베이스(Database, DB)를 운영하며 겪었던 삽질과 그 해결책, 바로 쿠버네티스 오퍼레이터(Kubernetes Operator) 활용 경험을 여러분과 공유해볼까 합니다. 특히 데이터베이스처럼 상태 저장 애플리케이션(Stateful Application)을 쿠버네티스 위에서 안정적으로 운영하는 건 정말 만만치 않은 일이거든요. 혹시 이런 경험 있으신가요? 😥

    저도 처음엔 단순히 컨테이너화해서 배포하면 다 될 줄 알았습니다. 그런데 막상 해보니 백업, 복구, 스케일링, 고가용성(High Availability, HA) 구성까지, 수동으로 하려니 손이 너무 많이 가더라고요. 야간에 장애라도 나면 식은땀이 줄줄 흘렀죠. 그러다가 우연히 오퍼레이터라는 개념을 접하게 됐고, ‘이거다!’ 싶어서 바로 홈랩에 적용해봤습니다. 결과는 대성공이었죠. 운영 효율이 정말 몰라보게 좋아졌어요! 🎉

    쿠버네티스 오퍼레이터가 데이터베이스를 자동 관리하는 아키텍처 다이어그램

    쿠버네티스 오퍼레이터는 사용자 정의 자원(Custom Resource)을 통해 복잡한 애플리케이션을 쿠버네티스 안에서 자동 관리하는 모습을 보여줍니다.

    1. 쿠버네티스 오퍼레이터, 쉽게 말해 뭐죠?

    오퍼레이터는 한마디로 쿠버네티스 API를 확장해서 특정 애플리케이션의 운영 지식(Operational Knowledge)을 소프트웨어로 자동화한 것이라고 보시면 됩니다. 마치 숙련된 인프라 엔지니어가 24시간 상주하면서 데이터베이스를 관리해주는 것과 같아요. 쿠버네티스의 컨트롤러(Controller) 패턴과 사용자 정의 자원(Custom Resource Definition, CRD)을 활용해서 만들어지죠.

    • CRD (Custom Resource Definition): 쿠버네티스에 없는 새로운 API 객체(Object)를 정의할 수 있게 해줍니다. 예를 들어, ‘PostgreSQL’이라는 새로운 자원을 정의하고 싶다면 CRD를 만들 수 있어요.
    • 컨트롤러 (Controller): 정의된 CRD의 상태를 지속적으로 감시하고, 사용자가 원하는 상태(Desired State)와 실제 상태(Actual State)를 맞춰주는 역할을 합니다. 예를 들어, 사용자가 PostgreSQL 인스턴스 3개를 요청하면, 컨트롤러가 알아서 Pod를 3개 띄우고 복제(Replication)까지 설정해주는 식이죠.

    이게 왜 중요하냐면, 데이터베이스처럼 복잡한 상태 저장 애플리케이션은 단순히 컨테이너화하는 것만으로는 부족하거든요. 스케일링, 백업, 복원, 업그레이드, 장애 조치(Failover) 같은 작업들은 애플리케이션의 특성을 깊이 이해하고 있어야 합니다. 오퍼레이터는 이런 운영 지식을 코드로 만들어서, 우리가 직접 손댈 필요 없이 쿠버네티스 플랫폼 위에서 자동으로 처리하게 해주는 겁니다. 정말 편하더라고요!

    2. 왜 데이터베이스 관리에 오퍼레이터가 필요할까요?

    쿠버네티스는 본질적으로 무상태(Stateless) 애플리케이션에 최적화되어 있습니다. Pod가 언제 죽고 다시 생성될지 모르니, 중요한 데이터는 외부에 저장하라는 철학이죠. 하지만 데이터베이스는 핵심 중의 핵심 상태 저장(Stateful) 애플리케이션입니다. 이런 DB를 쿠버네티스 위에서 운영하려면 다음과 같은 문제에 부딪히게 됩니다.

    • 백업 및 복구(Backup & Restore): 주기적인 백업과 장애 시 신속한 복구는 필수인데, 이를 쿠버네티스 환경에 맞춰 자동화하는 것이 어렵습니다.
    • 고가용성(High Availability): 마스터-슬레이브(Master-Slave) 구조나 클러스터 구성으로 장애에 대비해야 하는데, Pod의 라이프사이클에 맞춰 동적으로 관리하기가 복잡합니다.
    • 스케일링(Scaling): 트래픽 증가에 따라 데이터베이스 인스턴스를 늘리거나 줄이는 작업도 수동으로는 어렵습니다.
    • 업그레이드(Upgrade): 무중단 업그레이드를 하려면 데이터베이스 버전별 특성을 고려해야 해서 많은 노력이 필요합니다.

    이런 문제들을 해결하기 위해 제가 직접 스크립트를 짜고 CronJob을 돌려보고… 정말 삽질 많이 했습니다. 하지만 오퍼레이터를 도입하고 나서는 이런 고민이 상당 부분 사라졌어요. 오퍼레이터가 알아서 DB의 상태를 모니터링하고, 백업을 수행하며, 장애가 발생하면 자동으로 페일오버까지 처리해주니, 정말 든든하더라고요.

    3. 실전 구현: 가상의 데이터베이스 오퍼레이터로 관리하기

    이제 실제로 어떻게 오퍼레이터를 활용하는지 간단한 예시를 통해 보여드릴게요. 여기서는 특정 데이터베이스 오퍼레이터의 이름을 직접 언급하기보다는, 개념적인 MyDatabase 오퍼레이터를 사용하겠습니다. 실제로는 Percona Operator for MySQL, Crunchy Data’s PostgreSQL Operator 같은 검증된 솔루션들이 많이 있습니다.

    가장 먼저 오퍼레이터 자체를 쿠버네티스 클러스터에 배포해야 합니다. 대부분 Helm Chart나 YAML 파일을 제공하니 어렵지 않아요. 배포가 완료되면, 이제 우리가 원하는 데이터베이스 인스턴스를 사용자 정의 자원(Custom Resource) 형태로 정의할 수 있게 됩니다.

    apiVersion: mydatabase.example.com/v1alpha1 # CRD에서 정의한 API 버전
    kind: MyDatabase                     # CRD에서 정의한 Kind
    metadata:
      name: my-prod-db
    spec:
      version: "14.5"                      # 원하는 데이터베이스 버전
      replicas: 3                          # 복제본 수 (고가용성)
      storageSize: 100Gi                   # 데이터 저장 공간
      backup:
        enabled: true
        schedule: "0 2 * * *"            # 매일 새벽 2시 백업
        retentionDays: 7                   # 7일치 백업 보관
      monitoring:
        enabled: true
      # ... 그 외 데이터베이스별 세부 설정들
    

    위 YAML 파일을 my-prod-db.yaml로 저장하고 kubectl apply -f my-prod-db.yaml 명령을 실행하면, MyDatabase 오퍼레이터가 이 요청을 감지하고 다음과 같은 작업을 수행합니다.

    1. MyDatabase CR(Custom Resource)에 정의된 스펙(Spec)을 파싱합니다.
    2. 지정된 버전(14.5)과 복제본 수(3개)에 맞춰 Pod, PersistentVolumeClaim(PVC), Service 같은 표준 쿠버네티스 자원들을 생성합니다.
    3. Pod들 간에 데이터베이스 복제 구성을 자동으로 수행합니다.
    4. 백업 스케줄(매일 새벽 2시)에 맞춰 백업 Pod를 실행하고, 지정된 스토리지에 데이터를 저장합니다.
    5. 데이터베이스 상태를 지속적으로 모니터링하며, 문제가 발생하면 자동으로 재시작하거나 페일오버를 수행합니다.
    쿠버네티스 대시보드에서 데이터베이스 오퍼레이터가 관리하는 Pod 상태

    쿠버네티스 대시보드에서 데이터베이스 오퍼레이터가 배포한 여러 컴포넌트(Pod, Service, PVC 등)와 그 상태를 한눈에 확인할 수 있습니다.

    4. ⚠️ 주의사항 및 트러블슈팅: 삽질은 국룰이죠!

    물론 오퍼레이터가 만능은 아닙니다. 저도 처음엔 ‘이거 하나면 다 되겠지!’ 생각했지만, 몇 번의 삽질을 통해 중요한 교훈을 얻었죠.

    • CRD 스펙 이해 부족: 오퍼레이터마다 CRD의 스펙이 다릅니다. 제가 사용하려던 백업 설정이 사실은 다른 필드에 있었던 적도 있어요. 반드시 해당 오퍼레이터의 공식 문서를 꼼꼼히 읽어봐야 합니다. 특히 버전별로 스펙이 달라지는 경우도 있으니 주의하세요.
    • PersistentVolume (PV) 문제: 데이터베이스는 PV를 사용하는데, PV 프로비저닝(Provisioning)에 문제가 생기거나 스토리지 클래스(StorageClass) 설정이 잘못되면 Pod가 Pending 상태에 머무는 경우가 많습니다. kubectl describe pod <pod-name>과 kubectl get events로 이벤트를 확인해서 원인을 찾아야 합니다.
    • 네트워크 설정: 데이터베이스 클러스터 내 통신이나 외부 애플리케이션과의 통신을 위해 Service와 Ingress(인그레스, 외부 트래픽 진입점) 설정을 잘 해주어야 합니다. 특히 StatefulSet을 사용하는 경우 Pod의 고정된 네트워크 ID를 활용하는 경우가 많으니 이 점도 고려해야 합니다.
    • 로그 확인의 중요성: 오퍼레이터 컨트롤러 Pod의 로그(kubectl logs -f <operator-pod-name>)를 확인하면 어떤 작업을 수행 중인지, 어떤 에러가 발생했는지 상세히 알 수 있습니다. 문제가 생기면 제일 먼저 확인해야 할 곳이죠.

    한번은 백업 스케줄을 설정했는데 백업이 계속 실패하는 거예요. 알고 보니 스토리지 크레덴셜(Credential) 설정이 잘못되어 외부 S3 버킷에 접근을 못 하고 있었던 거죠. 오퍼레이터 로그를 보고 나서야 겨우 해결했습니다. 삽질은 국룰이더라고요! ㅎㅎ

    5. 검증 및 결과: 드디어 안정적인 DB 운영!

    이런 시행착오를 겪으며 오퍼레이터를 제대로 이해하고 적용하자, 정말 운영 환경이 안정적으로 변했습니다. kubectl get mydatabase 명령으로 제가 배포한 데이터베이스 인스턴스들의 상태를 한눈에 볼 수 있게 되었고, Health Status나 백업 상태 등 중요한 정보를 쉽게 확인할 수 있었어요. 🎉

    $ kubectl get mydatabase
    NAME         VERSION   REPLICAS   STATUS    BACKUP_LAST_SUCCESS   AGE
    my-prod-db   14.5      3          Running   2023-10-26T02:00:00Z  3d
    my-dev-db    12.7      1          Running   -
    

    가장 좋았던 점은 바로 심리적인 안정감이었습니다. 밤에 잠을 자다가도 ‘혹시 DB 장애 나지 않았을까?’ 하는 불안감이 사라졌거든요. 백업과 복구 테스트도 오퍼레이터 덕분에 훨씬 쉬워졌고, 새로운 데이터베이스 인스턴스를 띄우는 시간도 단축되었습니다. 개발팀에서도 ‘DB 요청이 이렇게 빨리 처리되다니!’ 하며 놀라더라고요.

    데이터베이스 오퍼레이터 모니터링 대시보드의 건강 및 백업 성공률 그래프

    오퍼레이터가 제공하는 모니터링 대시보드에서 데이터베이스 클러스터의 전반적인 건강 상태와 백업 성공률을 시각적으로 확인하며 안정적인 운영을 검증합니다.

    6. 운영 효율 비교: 수동 vs. 오퍼레이터

    제가 직접 경험한 바에 따르면, 데이터베이스 운영 방식에 따른 효율 차이는 극명했습니다. 아래 표를 보시면 그 차이를 더 확실히 느끼실 수 있을 거예요.

    항목 수동 데이터베이스 관리 쿠버네티스 오퍼레이터 활용
    배포 시간 며칠 ~ 몇 주 (수동 설치, 설정, 복제 구성 등) 몇 분 ~ 몇 시간 (CRD 정의 후 적용)
    고가용성 수동 구성 및 모니터링, 장애 시 수동 복구 자동 복제, 자동 장애 감지 및 페일오버
    백업/복구 수동 스크립트 작성, 스케줄링, 복구 절차 복잡 자동 백업 스케줄링, 간편한 복구 명령
    스케일링 수동 인스턴스 추가, 복제 설정 변경 CRD Spec의 replicas 필드 변경으로 자동 스케일링
    업그레이드 복잡한 무중단 업그레이드 절차, 다운타임 위험 자동 롤링 업그레이드, 다운타임 최소화
    운영 복잡성 높음 (전문 지식 및 지속적인 수동 작업 필요) 낮음 (선언적 관리, 운영 지식 내재화)

    7. 마무리: 자동화, 이제 선택이 아닌 필수!

    쿠버네티스 오퍼레이터를 활용한 데이터베이스 관리 자동화는 저에게 정말 신세계였습니다. 처음에는 학습 비용과 삽질이 있었지만, 장기적으로 봤을 때 운영팀의 업무 부담을 줄이고 서비스 안정성을 크게 높이는 데 결정적인 역할을 했습니다. 특히 데이터베이스처럼 중요하고 복잡한 애플리케이션을 쿠버네티스 위에서 운영해야 한다면, 오퍼레이터는 이제 선택이 아니라 필수라고 감히 말씀드리고 싶네요.

    물론 모든 오퍼레이터가 완벽한 건 아닙니다. 각자의 환경과 데이터베이스 종류에 맞는 검증된 오퍼레이터를 신중하게 선택하고, 충분히 테스트해보는 과정은 여전히 중요합니다. 저도 아직 탐험할 부분이 많거든요! 다음번에는 또 다른 쿠버네티스 활용 사례나 홈랩에서 얻은 재미있는 경험담으로 찾아오겠습니다. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요! 😉

    만족스러운 인프라 엔지니어와 안정적인 쿠버네티스 데이터베이스 클러스터

    인프라 엔지니어가 쿠버네티스 오퍼레이터 덕분에 안정적으로 운영되는 데이터베이스 클러스터를 보며 만족감을 느끼는 모습을 표현합니다.

  • [k8s] Helm 차트 관리의 진화: GitOps 시대의 베스트 프랙티스 분석

    [k8s] Helm 차트 관리의 진화: GitOps 시대의 베스트 프랙티스 분석

    안녕하세요, 13년차의 서버실입니다. 제가 처음 쿠버네티스(Kubernetes)를 접했을 때를 생각하면, 마치 새로운 대륙을 발견한 콜럼버스처럼 설레면서도 막막했던 기억이 나네요. 특히 애플리케이션 배포와 관리는 정말이지 끝없는 숙제 같았어요. 처음엔 kubectl apply -f 명령어로 YAML(야믈) 파일을 직접 하나하나 적용했었죠. 그러다 Helm(헬름, 쿠버네티스 패키지 매니저)을 만나고 나서야 비로소 숨통이 트이는 느낌이었습니다.

    Helm은 복잡한 쿠버네티스 애플리케이션을 패키징하고 배포하는 데 정말 혁신적인 도구였어요. 그런데 시간이 지나고 관리해야 할 차트(Chart)가 늘어나면서, 또 다른 고민이 생기더라고요. ‘이 많은 차트들을 어떻게 효과적으로 관리하고, 배포 이력을 추적하며, 안정적으로 운영할 수 있을까?’ 하는 문제였습니다. 수동으로 helm upgrade를 반복하는 건 실수 투성이였고, CI/CD(지속적 통합/지속적 배포) 파이프라인에 Helm 명령어를 넣는 것도 생각보다 손이 많이 갔거든요. ⚠️

    그러던 와중에 GitOps(깃옵스, Git 기반 운영 방식)라는 개념을 접하게 됐습니다. ‘아, 이거다!’ 싶었죠. 인프라와 애플리케이션의 상태를 Git 리포지토리(Repository)에 코드 형태로 정의하고, 이 Git을 유일한 진실의 원천(Single Source of Truth)으로 삼아 자동으로 클러스터 상태를 동기화하는 방식. 오늘은 이 GitOps 시대에 Helm 차트를 어떻게 하면 스마트하게 관리할 수 있는지, 제가 직접 홈랩에서 겪었던 경험과 함께 베스트 프랙티스(Best Practice)를 분석해 보려고 합니다. 여러분의 쿠버네티스 배포 여정에 이 글이 작은 등불이 되기를 바랍니다!

    GitOps 기반 Helm 차트 관리 아키텍처 개요 다이어그램

    GitOps 기반 Helm 차트 관리의 전체적인 아키텍처를 보여줍니다. 개발자가 Git에 변경사항을 푸시하면, GitOps 오퍼레이터(예: Flux CD)가 이를 감지하여 쿠버네티스 클러스터에 Helm 차트를 배포하는 흐름입니다.

    Helm과 GitOps, 그 관계를 파헤치다

    먼저 핵심 개념들을 잠시 짚고 넘어가 볼까요? 이미 잘 아시는 분도 많겠지만, 혹시 처음 접하는 분들을 위해 쉽게 설명해 드릴게요.

    • Helm (헬름): 쿠버네티스 패키지 매니저예요. 복잡한 애플리케이션을 차트(Chart)라는 패키지 형태로 정의하고, 이를 쉽게 설치, 업데이트, 삭제할 수 있도록 도와줍니다. 마치 리눅스의 apt나 yum처럼이죠. Deployment, Service, Ingress(인그레스, 외부 트래픽 진입점) 등 여러 쿠버네티스 리소스(Resource)들을 하나의 묶음으로 관리할 수 있게 해줘요.
    • GitOps (깃옵스): 쉽게 말해, Git을 중심으로 모든 것을 운영하는 방식입니다. 애플리케이션 코드뿐만 아니라, 인프라 구성(Infrastructure as Code)까지 전부 Git 리포지토리에 저장하고 관리합니다. 그리고 Git 리포지토리의 변경 사항이 감지되면, GitOps 에이전트(Agent)가 자동으로 쿠버네티스 클러스터에 배포하거나 상태를 동기화(Reconciliation)하는 구조예요. 사람이 직접 명령어를 입력할 필요 없이, Git에 커밋(Commit)만 하면 배포가 이뤄지는 거죠. 🎉

    그럼 Helm과 GitOps가 만나면 어떤 시너지를 낼까요? 기존에는 Helm 명령어를 CI/CD 파이프라인에서 실행하거나, 개발자가 직접 터미널에서 입력해서 배포했었죠. 하지만 GitOps를 도입하면, Helm 차트의 설정 파일(values.yaml)이나 차트 자체의 버전 변경을 Git에 커밋하는 것만으로 배포가 자동으로 트리거(Trigger)됩니다. ‘선언적(Declarative)’으로 상태를 정의하고, ‘자동으로 동기화(Automated Synchronization)’되는 거죠. 이 덕분에 배포의 투명성, 안정성, 감사(Audit) 용이성이 크게 향상됩니다. 제가 직접 해보니, 이게 진짜 편하더라고요!

    실전! GitOps 기반 Helm 차트 관리 구현: Flux CD를 중심으로

    홈랩에서 다양한 GitOps 툴을 실험해 봤는데, 개인적으로는 Flux CD(플럭스 CD)가 Helm 차트 관리에 참 잘 맞았어요. 물론 Argo CD(아르고 CD)도 훌륭한 대안이고요. 오늘은 Flux CD를 활용해서 GitOps 기반 Helm 차트 관리 환경을 구축하는 방법을 단계별로 설명해 드릴게요. 따라오시면 여러분도 쉽게 구현하실 수 있을 겁니다.

    1. Flux CD 설치 및 초기 설정

      먼저 쿠버네티스 클러스터(Cluster)에 Flux CD를 설치해야 합니다. Flux CLI(커맨드 라인 인터페이스)를 사용하면 정말 쉽게 설치할 수 있어요.

      
      # Flux CLI 설치 (macOS 기준, 다른 OS는 공식 문서 참고)
      brew install fluxcd/tap/flux
      
      # Flux CD 초기화 및 Git 리포지토리 연동
      # 여기서 your-git-repo는 GitOps 설정을 저장할 리포지토리 주소입니다.
      # --branch main은 기본 브랜치를 main으로 설정하는 것이고요.
      # --path clusters/my-cluster는 Flux가 모니터링할 Git 리포지토리 내 경로입니다.
      flux bootstrap git \
        --owner=<your-git-username> \
        --repository=<your-git-repo> \
        --branch=main \
        --path=clusters/my-cluster \
        --personal
              

      위 명령어를 실행하면 Flux CD가 필요한 컨트롤러(Controller)들을 쿠버네티스 클러스터에 설치하고, 지정된 Git 리포지토리와 연동합니다. 이제 Git 리포지토리 clusters/my-cluster 경로에 변경 사항이 생기면 Flux CD가 자동으로 감지하게 됩니다. ✅

    2. Git 리포지토리 구조 잡기

      GitOps의 핵심은 Git 리포지토리 구조를 잘 잡는 것입니다. 저는 보통 이런 식으로 구성합니다.

      
      .
      ├── clusters/
      │   └── my-cluster/ # Flux CD가 모니터링할 경로
      │       ├── flux-system/ # Flux CD가 생성한 리소스 (자동 관리)
      │       └── applications/ # 배포할 애플리케이션 HelmRelease 정의
      │           ├── nginx/
      │           │   └── nginx-helmrelease.yaml
      │           └── prometheus/
      │               └── prometheus-helmrelease.yaml
      └── helm-charts/ # 직접 만든 Helm 차트 (선택 사항, 외부 차트 사용 시 필요 없음)
          └── my-app/
              ├── Chart.yaml
              ├── values.yaml
              └── templates/
                  └── ...
              
    3. Helm 차트 정의 및 배포 (HelmRepository, HelmRelease)

      이제 애플리케이션을 배포해 볼까요? 예를 들어, 안정적인 Nginx(엔진엑스) Ingress Controller(인그레스 컨트롤러)를 배포한다고 가정해 봅시다. Flux CD에서는 HelmRepository와 HelmRelease라는 커스텀 리소스(Custom Resource)를 사용합니다.

      먼저 Helm 차트 저장소(Repository)를 정의합니다. Nginx Ingress Controller의 공식 Helm 저장소를 추가하는 HelmRepository 리소스입니다.

      
      # clusters/my-cluster/applications/nginx/nginx-helmrepository.yaml
      apiVersion: source.toolkit.fluxcd.io/v1beta2
      kind: HelmRepository
      metadata:
        name: ingress-nginx
        namespace: flux-system # Flux CD 시스템 네임스페이스
      spec:
        interval: 1h0m0s # 1시간마다 저장소 업데이트 확인
        url: https://kubernetes.github.io/ingress-nginx
              

      이 파일을 Git 리포지토리의 clusters/my-cluster/applications/nginx/ 경로에 저장하고 커밋(Commit)하면, Flux CD가 이를 감지하여 쿠버네티스 클러스터에 HelmRepository 리소스를 생성합니다. Flux CD는 이 저장소를 주기적으로 스캔하여 최신 차트 정보를 가져오게 됩니다. 💡

      Flux CD HelmRelease를 이용한 쿠버네티스 차트 배포 흐름

      Flux CD가 Git 리포토리의 HelmRelease 정의를 읽어 쿠버네티스 클러스터에 Helm 차트를 배포하는 과정을 시각화한 다이어그램입니다.

      다음으로, 실제로 Nginx Ingress Controller를 배포할 HelmRelease 리소스를 정의합니다. 여기에 어떤 차트를 어떤 버전으로, 어떤 값(values)으로 배포할지 상세하게 명시합니다.

      
      # clusters/my-cluster/applications/nginx/nginx-helmrelease.yaml
      apiVersion: helm.toolkit.fluxcd.io/v2beta1
      kind: HelmRelease
      metadata:
        name: ingress-nginx
        namespace: ingress-nginx # Nginx Ingress Controller가 배포될 네임스페이스
      spec:
        interval: 5m0s # 5분마다 상태 동기화 확인
        chart:
          spec:
            chart: ingress-nginx # HelmRepository에 정의된 차트 이름
            version: ">=4.0.0 <5.0.0" # 차트 버전 범위 지정
            sourceRef:
              kind: HelmRepository
              name: ingress-nginx # 위에서 정의한 HelmRepository 이름
              namespace: flux-system
        values: # values.yaml 내용과 동일
          controller:
            replicaCount: 2
            service:
              type: LoadBalancer
            nodeSelector:
              kubernetes.io/os: linux
          defaultBackend:
            enabled: true
              

      이 파일도 Git 리포지토리에 저장하고 커밋하면, Flux CD는 HelmRepository에서 차트를 가져와 이 HelmRelease 정의에 따라 Nginx Ingress Controller를 클러스터에 배포하게 됩니다. 와, 정말 깔끔하게 배포되는 거 보니까 속이 다 시원하더라고요! 이제 배포에 대한 모든 변경사항은 Git에서만 이뤄지게 됩니다. 🚀

    삽질 대잔치! 겪었던 문제와 해결 과정

    물론 이렇게 깔끔하게 설명했지만, 제가 이걸 처음 세팅할 때는 삽질 좀 했습니다 ㅎㅎ. 특히 몇 가지 문제로 머리를 싸맸던 기억이 나네요. 여러분은 저 같은 실수 하지 마시라고 몇 가지 팁을 공유해 드립니다. ⚠️

    • HelmRelease 동기화 실패:

      처음에는 Git에 커밋했는데도 클러스터에 아무런 변화가 없어서 당황했어요. 알고 보니 HelmRepository의 interval이나 HelmRelease의 interval 설정이 너무 길거나, Git 리포지토리 경로를 잘못 지정한 경우가 많았습니다. Flux CD의 로그를 꼼꼼히 확인하고, flux reconcile kustomization <kustomization-name> 명령어로 수동 동기화를 시도하면서 문제를 찾아냈습니다.

      
              # Flux CD Kustomization 상태 확인 (Git 리포지토리 동기화 상태)
              flux get kustomizations
      
              # Flux CD HelmRelease 상태 확인
              flux get hr -A # 모든 네임스페이스의 HelmRelease 확인
              
    • imagePullSecrets 적용 문제:

      프라이빗 레지스트리(Private Registry)에 있는 이미지를 사용해야 할 때, imagePullSecrets(이미지 풀 시크릿)을 ServiceAccount(서비스 어카운트)에 연결해야 하잖아요? 이걸 HelmRelease의 values에 직접 넣어도 되지만, 보안상 좋지 않거나 모든 ServiceAccount에 적용하고 싶을 때가 있습니다. 이럴 땐 Kustomize(커스터마이즈)를 활용하여 ServiceAccount에 imagePullSecrets를 패치(Patch)하는 방식으로 해결했습니다. Flux CD는 Kustomization 리소스도 지원해서, Helm과 Kustomize를 함께 사용하는 것도 가능합니다. (이건 다음 기회에 더 자세히 다뤄볼게요!)

    • 차트 버전 범위 지정 오류:

      HelmRelease에서 version: ">=4.0.0 <5.0.0"처럼 버전을 지정할 때, 세밀하게 지정하지 않으면 예상치 못한 마이너 버전 업데이트로 인해 문제가 생길 수 있습니다. 너무 넓은 범위보다는 안정성이 검증된 특정 버전이나, 패치 버전까지만 허용하는 것이 좋습니다.

    드디어! GitOps로 배포된 Helm 차트 확인

    이제 모든 설정이 완료되고 Git에 커밋까지 했다면, Flux CD가 자동으로 배포를 진행했을 겁니다. 배포가 제대로 되었는지 확인해 봐야겠죠? kubectl 명령어로 확인하면 됩니다.

    
    # Flux CD가 관리하는 Kustomization 리소스 상태 확인
    kubectl get kustomizations -n flux-system
    
    # Flux CD가 관리하는 HelmRelease 리소스 상태 확인
    kubectl get helmreleases -n ingress-nginx
    
    # Nginx Ingress Controller 파드(Pod) 확인
    kubectl get pods -n ingress-nginx
    

    helmreleases의 상태가 Ready이고, pods가 모두 Running 상태라면 성공입니다! 🎉 정말 깔끔하게 배포된 걸 보니까 속이 다 시원하더라고요. 이제 어떤 변경이든 Git 리포지토리에 반영하는 것만으로 배포가 자동으로 이뤄집니다. 롤백(Rollback)도 Git에서 이전 커밋으로 되돌리는 것만으로 가능하니, 얼마나 안정적이고 편리한지 몰라요.

    Kubernetes 클러스터에서 kubectl 명령어를 사용하여 Flux CD의 HelmRelease와 관련된 리소스들이 성공적으로 배포되고 실행 중인 상태를 보여주는 터미널 화면입니다.

    마무리하며: Helm GitOps, 더 나은 미래를 향해

    오늘은 13년차 인프라 엔지니어의 시선으로 Helm 차트 관리의 진화, 특히 GitOps 시대의 베스트 프랙티스를 Flux CD를 중심으로 알아봤습니다. 제가 직접 경험해 보니 GitOps는 단순히 도구를 사용하는 것을 넘어, 인프라 운영 방식 자체를 혁신하는 패러다임(Paradigm)이라는 생각이 들었습니다.

    GitOps 기반 Helm 차트 관리는 다음과 같은 장점들을 가져다줍니다.

    • 생산성 향상: 수동 작업 감소, 자동화된 배포.
    • 안정성 증대: Git을 통한 단일 진실의 원천, 쉬운 롤백.
    • 투명성 및 감사 용이성: 모든 변경 이력이 Git에 기록.
    • 협업 강화: 코드 리뷰(Code Review)를 통한 변경 사항 검증.
    전통적인 Helm 관리 방식과 GitOps 기반 Helm 관리 방식 비교표

    전통적인 Helm 관리 방식과 GitOps 기반 Helm 관리 방식을 주요 특징(자동화, 롤백, 투명성 등)별로 비교하는 인포그래픽 형태의 표입니다.

    물론 GitOps를 도입하는 것이 마냥 쉽지만은 않습니다. 초기 설정의 복잡성, Git 리포지토리 관리 전략 수립, 그리고 팀원들의 새로운 워크플로우(Workflow) 적응 등 넘어야 할 산들이 분명히 존재합니다. 하지만 그 모든 노력을 감수할 만큼의 가치가 충분하다고 저는 확신합니다. 결국 GitOps는 인프라 운영의 투명성과 안정성을 극대화하고, 개발팀과 운영팀 간의 협업을 더욱 매끄럽게 만드는 데 크게 기여할 겁니다.

    다음 글에서는 Flux CD의 고급 기능이나 다른 GitOps 툴인 Argo CD(아르고 CD)와의 비교, 또는 Kustomize를 활용한 Helm 차트 오버레이(Overlay) 방법에 대해서도 다뤄볼 예정입니다. 계속해서 “13년차의 서버실”에 많은 관심 부탁드립니다. 궁금한 점이나 여러분의 경험담이 있다면 댓글로 남겨주세요! 함께 고민하고 성장해 나가는 것이 저의 기쁨입니다. 😊

  • [k8s] AWX로 쿠버네티스 워크플로우 자동화: 실전 가이드

    [k8s] AWX로 쿠버네티스 워크플로우 자동화: 실전 가이드

    안녕하세요, 13년차 인프라 엔지니어 ’13년차의 서버실’ 주인장입니다. 오늘은 많은 분들이 고민하시는 쿠버네티스(Kubernetes) 워크플로우 자동화에 대해 이야기해보려고 해요. 특히 AWX를 활용해서 어떻게 복잡한 배포 과정을 효율적으로 관리할 수 있는지, 제가 직접 겪었던 경험과 실전 사례를 중심으로 풀어볼까 합니다. Ansible과 DevOps 문화에 관심 있는 분들이라면 이번 글이 정말 도움이 될 거예요.

    저도 처음엔 쿠버네티스 클러스터에 애플리케이션을 배포하는 과정이 꽤나 번거롭더라고요. 매번 kubectl apply -f 명령어를 입력하고, 버전 관리하고, 환경별로 설정 바꾸고… 반복적인 작업은 언제나 실수의 여지를 남기기 마련이잖아요. 그러다 보니 자연스럽게 자동화의 필요성을 느끼게 됐고, 홈랩에서 AWX(Ansible Workflow eXecutor)를 활용한 솔루션을 모색하게 됐습니다. 직접 삽질해가며 구축했던 과정, 지금부터 솔직하게 공유해볼게요.

    AWX와 쿠버네티스 연동을 통한 워크플로우 자동화 개요 아키텍처 다이어그램, AWX가 Git에서 Ansible Playbook을 가져와 쿠버네티스에 배포하는 과정을 보여줍니다.

    AWX와 쿠버네티스 연동을 통한 워크플로우 자동화 개요 아키텍처 다이어그램. AWX가 Git 저장소에서 Ansible Playbook을 가져와 쿠버네티스 API를 통해 리소스를 배포하는 과정을 보여줍니다.

    AWX와 쿠버네티스, 왜 함께 써야 할까요?

    먼저 핵심 개념부터 짚고 넘어갈게요. AWX는 Ansible Automation Platform의 업스트림 오픈소스 프로젝트로, 웹 기반 UI를 통해 Ansible Playbook을 실행하고 관리해주는 도구예요. 쉽게 말해, 터미널에서 명령어로 실행하던 Ansible 작업을 UI로 편하게 스케줄링하고, 권한을 관리하고, 실행 결과를 모니터링할 수 있다는 거죠. 제가 홈랩에서 다양한 자동화 실험을 할 때 정말 유용하게 써먹고 있는 녀석입니다.

    그리고 쿠버네티스(Kubernetes, K8s)는 컨테이너화된 워크로드와 서비스를 자동으로 배포, 스케일링 및 관리해주는 오픈소스 시스템이에요. 컨테이너 오케스트레이션(Container Orchestration)의 사실상 표준이 되었죠. 선언적(Declarative) 방식으로 원하는 상태를 정의하면, 쿠버네티스가 그 상태를 유지하도록 알아서 관리해줍니다. 저는 주로 Deployment(디플로이먼트)와 Service(서비스), Ingress(인그레스, 외부 트래픽 진입점) 같은 리소스들을 YAML 파일로 정의해서 사용하고 있어요.

    이 둘을 함께 쓰면 어떤 시너지가 생길까요? 바로 선언적 인프라(Declarative Infrastructure) 관리가 가능해진다는 거예요. Ansible Playbook으로 쿠버네티스 리소스의 최종 상태를 정의하고, AWX가 이 Playbook을 실행해서 클러스터에 배포하는 방식이죠. 이렇게 하면 수동 작업으로 인한 오류를 줄이고, 배포 과정을 표준화하며, 지속적인 통합/배포(CI/CD) 워크플로우를 구축하는 데 정말 큰 도움이 돼요. 실제 운영 환경에서 이 조합은 정말 강력하더라고요.

    실전 구현: AWX로 쿠버네티스 워크플로우 자동화하기

    자, 그럼 이제 제가 직접 구축했던 방법을 단계별로 보여드릴게요. 목표는 AWX를 통해 간단한 Nginx 웹 서버를 쿠버네티스 클러스터에 배포하고, 외부에 노출시키는 거예요.

    1단계: AWX 환경 설정

    1. 인벤토리(Inventory) 생성: AWX에서 Playbook을 실행할 대상(Host)을 정의합니다. 여기서는 쿠버네티스 클러스터의 API 서버에 접근해야 하므로, 로컬 호스트를 대상으로 하거나, 쿠버네티스 API 엔드포인트를 지정해요. 저는 주로 localhost를 대상으로 하고, kubeconfig 파일을 통해 클러스터에 접근하는 방식을 선호합니다.
    2. 자격 증명(Credential) 생성: 쿠버네티스 클러스터에 접근하기 위한 자격 증명이 필요해요. kubeconfig 파일을 AWX에 등록하는 방법이 가장 일반적입니다.
    # AWX Credential 설정 예시 (Kubeconfig Type)
    # --- Kubeconfig 내용 --- (실제 파일 내용)
    apiVersion: v1
    clusters:
    - cluster:
        certificate-authority-data: ...
        server: https://your-kubernetes-api-server:6443
      name: my-cluster
    contexts:
    - context:
        cluster: my-cluster
        user: my-user
      name: my-context
    current-context: my-context
    kind: Config
    preferences: {}
    users:
    - name: my-user
      user:
        client-certificate-data: ...
        client-key-data: ...
    

    2단계: Ansible Playbook 작성

    쿠버네티스 리소스를 배포하기 위한 Playbook을 작성해요. Ansible이 제공하는 kubernetes.core.k8s 모듈로 쿠버네티스 리소스 관리가 정말 쉬워진다니까요. 저는 Nginx Deployment와 Service를 배포하는 Playbook을 만들었어요.

    # playbook.yaml
    ---
    - name: Deploy Nginx to Kubernetes
      hosts: localhost
      connection: local
      collections:
        - kubernetes.core
    
      vars:
        namespace: default
        app_name: my-nginx
        image_version: 1.25.3
    
      tasks:
        - name: Ensure Namespace exists
          kubernetes.core.k8s:
            api_version: v1
            kind: Namespace
            name: "{{ namespace }}"
            state: present
    
        - name: Deploy Nginx Deployment
          kubernetes.core.k8s:
            state: present
            definition: |
              apiVersion: apps/v1
              kind: Deployment
              metadata:
                name: "{{ app_name }}-deployment"
                namespace: "{{ namespace }}"
                labels:
                  app: "{{ app_name }}"
              spec:
                replicas: 2
                selector:
                  matchLabels:
                    app: "{{ app_name }}"
                template:
                  metadata:
                    labels:
                      app: "{{ app_name }}"
                  spec:
                    containers:
                    - name: "{{ app_name }}"
                      image: nginx:"{{ image_version }}"
                      ports:
                      - containerPort: 80
    
        - name: Expose Nginx Service
          kubernetes.core.k8s:
            state: present
            definition: |
              apiVersion: v1
              kind: Service
              metadata:
                name: "{{ app_name }}-service"
                namespace: "{{ namespace }}"
                labels:
                  app: "{{ app_name }}"
              spec:
                selector:
                  app: "{{ app_name }}"
                ports:
                  - protocol: TCP
                    port: 80
                    targetPort: 80
                type: NodePort # 또는 LoadBalancer, ClusterIP 등 환경에 맞게
    

    이 Playbook을 Git 저장소(예: GitHub, GitLab)에 커밋해요. AWX가 이 저장소에서 Playbook을 가져와 실행할 거거든요.

    3단계: AWX 프로젝트 및 작업 템플릿(Job Template) 설정

    AWX UI에서 다음을 설정합니다.

    1. 프로젝트(Project) 생성: Git 저장소를 연결하여 Playbook을 가져와요.
    2. 작업 템플릿(Job Template) 생성: 이 템플릿에 위에서 만든 인벤토리, 자격 증명, 프로젝트, 그리고 실행할 Playbook 파일(playbook.yaml)을 연결합니다.
    AWX 작업 템플릿 설정 화면 스크린샷, Git 프로젝트, 인벤토리, 쿠버네티스 자격 증명, 실행할 Playbook 파일을 지정하는 모습

    AWX 작업 템플릿 설정 화면 스크린샷. Git 프로젝트, 인벤토리, 쿠버네티스 자격 증명, 실행할 Playbook 파일을 지정하는 모습을 보여줍니다.

    ⚠️ 삽질 경험 & 워크플로우 자동화 트러블슈팅

    제가 이 AWX와 쿠버네티스 자동화 과정을 진행하면서 겪었던 몇 가지 삽질과 그 해결책을 공유할게요. 혹시 비슷한 문제를 겪는 분들이 있다면 도움이 될 거예요.

    • 인증 오류 (Authentication Error): 가장 흔한 문제 중 하나예요. kubeconfig 파일의 권한 문제, 또는 파일 내용 자체가 잘못된 경우가 많았어요. 특히 client-certificate-data와 client-key-data가 정확한 base64 인코딩 값인지 여러 번 확인했거든요. AWX가 실행되는 컨테이너 환경에서 kubeconfig 파일에 접근 권한이 없어서 생기는 경우도 있었고요. 💡 팁: AWX 컨테이너 내부에서 kubectl get pods를 직접 실행해보며 인증 문제를 디버깅해보세요.
    • YAML 문법 오류: 쿠버네티스 리소스 정의는 YAML 파일로 하는데, 들여쓰기나 오타 하나로도 워크플로우가 실행 안 돼요. 특히 Ansible Playbook 내 definition 블록 안에 YAML을 넣을 때는 더욱 주의해야 합니다. ⚠️ 경고: definition: | 다음 줄부터는 반드시 두 칸 이상 들여쓰기 해야 합니다!
    • Ansible kubernetes.core.k8s 모듈 문제: 처음에는 이 모듈을 쓰는 게 익숙하지 않아서 헤맸어요. 특히 state: present로 리소스를 생성하고, state: absent로 삭제하는 방식에 익숙해지는 데 시간이 좀 걸렸거든요. 그리고 kubeconfig 파일 경로를 명시적으로 지정해야 하는 경우도 있었습니다 (kubeconfig: /path/to/kubeconfig).
    • 네트워크 연결 문제: AWX가 쿠버네티스 API 서버에 접근할 수 없는 네트워크 환경일 경우 당연히 실패하죠. 방화벽 규칙이나 네트워크 정책을 다시 확인해야 해요.

    ✅ 결과 검증 및 자동화의 힘!

    모든 설정이 끝나면, AWX UI에서 작업 템플릿을 실행(Launch)해요. Playbook이 성공적으로 실행되면 AWX 작업 로그에서 초록색 SUCCESS 메시지를 확인할 수 있어요. 저도 이 메시지를 처음 봤을 때, “드디어 됐다!” 하고 쾌재를 불렀던 기억이 생생해요.

    이제 쿠버네티스 클러스터에서 실제로 리소스가 배포되었는지 확인해볼 차례입니다.

    
    kubectl get deployments -n default
    # NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
    # my-nginx-deployment   2/2     2            2           2m
    
    kubectl get services -n default
    # NAME              TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
    # my-nginx-service   NodePort   10.96.123.456   <none>        80:3xxxx/TCP   2m
    

    정상적으로 Nginx Deployment와 Service가 생성된 걸 확인할 수 있어요. 이제 NodePort로 할당된 포트를 통해 Nginx 웹 서버에 접속하면 “Welcome to Nginx!” 페이지를 볼 수 있을 거예요. 🎉 정말 편리하지 않나요? 몇 번의 클릭만으로 쿠버네티스에 애플리케이션을 배포하는 워크플로우 자동화가 완성된 거죠.

    AWX 작업 성공 로그와 kubectl get pods/deployments 명령 결과 비교 화면, AWX의 성공적인 Task 완료와 쿠버네티스 배포 리소스 확인

    AWX 작업 성공 로그와 kubectl get pods/deployments 명령 결과 비교 화면. AWX의 Job Output에서 Task가 성공적으로 완료된 것을 보여주고, 터미널에서 kubectl 명령어로 배포된 리소스들을 확인하는 모습을 나란히 보여줍니다.

    마무리하며: 자동화, 멈추지 않는 여정

    이번 AWX와 쿠버네티스 연동 사례를 통해 워크플로우 자동화가 얼마나 강력한지 다시 한번 느꼈어요. 단순한 배포를 넘어, IaC(Infrastructure as Code, 코드형 인프라)의 개념을 실현하고, DevOps 파이프라인의 핵심 구성 요소로 활용할 수 있다는 것이 핵심이죠.

    인프라 엔지니어로서 제가 얻은 가장 큰 교훈은 “반복되는 작업은 무조건 자동화하라”는 거예요. 처음엔 자동화 스크립트 작성에 시간이 들지언정, 장기적으로는 시간과 노력을 아끼고, 휴먼 에러를 줄여준다는 걸 뼈저리게 경험했거든요. 이 경험이 여러분의 인프라 관리에도 작은 영감이 되기를 바랍니다.

    다음 글에서는 이 워크플로우를 확장해서 Ingress 컨트롤러를 배포하고, 외부 도메인으로 서비스에 접근하는 방법을 다뤄볼까 해요. 기대해주세요!

    AWX, Ansible, Kubernetes, DevOps 로고들이 원형으로 배치된 인포그래픽. 각 로고들이 서로 연결되어 워크플로우 자동화를 이루는 모습을 시각적으로 보여줍니다.

  • [Cloud] Pulumi vs Terraform: 멀티 클라우드 IaC 선택 가이드 및 실전 활용

    [Cloud] Pulumi vs Terraform: 멀티 클라우드 IaC 선택 가이드 및 실전 활용

    IaC(Infrastructure as Code)는 왜 필요할까요?

    안녕하세요, 13년차 서버실 지킴이입니다. 오늘도 여전히 복잡한 인프라 구성에 머리 싸매고 계신가요? 요즘 같은 멀티 클라우드(Multi-Cloud) 시대에는 온프레미스(On-Premise)와 여러 클라우드 벤더(Cloud Vendor)를 넘나들며 인프라를 관리해야 하는 경우가 흔하죠. 저도 처음엔 수동으로 서버 올리고 네트워크 설정하고… 휴, 생각만 해도 아찔하네요. 사람이 일일이 하다 보니 휴먼 에러(Human Error)는 기본이고, 속도는 느려터지고, 무엇보다 재현성이 없어서 문제였습니다.

    그래서 등장한 개념이 바로 IaC(Infrastructure as Code, 코드형 인프라)예요. 쉽게 말해, 인프라를 코드로 정의하고 관리하는 방식이죠. Git 같은 버전 관리 시스템으로 관리할 수 있고, 자동화는 물론 팀원들과의 협업도 훨씬 수월해집니다. 제가 홈랩에서 이것저것 테스트해볼 때도, IaC 덕분에 환경을 순식간에 구축하고 파괴하면서 실험할 수 있었거든요. 시간 절약이 어마어마해요!

    멀티 클라우드 환경에서 IaC를 사용하는 인프라 프로비저닝 개념 다이어그램

    Terraform vs Pulumi: 핵심 개념 파헤치기

    IaC 도구는 정말 다양합니다만, 현재 시장에서 가장 강력한 양대 산맥을 꼽으라면 단연 Terraform(테라폼)과 Pulumi(풀루미)일 겁니다. 두 도구 모두 클라우드 인프라를 코드로 정의하고 배포할 수 있게 해주지만, 접근 방식에 큰 차이가 있어요. 저도 처음엔 뭐가 뭔지 헷갈려서 한참을 삽질했었죠.

    Terraform (테라폼)

    • 선언적 언어(Declarative Language): Terraform은 HCL(HashiCorp Configuration Language)이라는 자체 언어를 사용해요. "최종 상태가 이렇게 되어야 한다"라고 선언하면, Terraform이 현재 상태와 비교해서 필요한 변경 사항을 알아서 적용해줍니다. 예를 들어, aws_instance 리소스 블록을 정의하면, Terraform이 AWS에 해당 인스턴스를 생성하거나 업데이트하죠.
    • 상태 파일(State File) 관리: Terraform은 배포된 인프라의 현재 상태를 .tfstate 파일에 기록해요. 이 상태 파일을 통해 다음에 어떤 변경 사항을 적용할지 판단하는데, 이 파일 관리가 꽤 중요하고 때론 골치 아울 때도 있어요. S3 같은 원격 스토리지(Remote Storage)에 저장하고 잠금(Locking) 기능을 사용하는 게 일반적이에요.
    • 모듈(Modules): 재사용 가능한 인프라 구성 단위를 모듈로 만들 수 있어서, 복잡한 인프라도 효율적으로 관리할 수 있어요.

    Pulumi (풀루미)

    • 범용 프로그래밍 언어(General-Purpose Programming Language): Pulumi의 가장 큰 특징은 Python, TypeScript, Go, C# 등 여러분이 익숙한 프로그래밍 언어를 그대로 사용한다는 점이에요. 이게 진짜 매력적입니다! 일반 애플리케이션 개발하듯이 인프라 코드를 작성할 수 있어요. 조건문, 반복문, 함수 등 프로그래밍 언어의 모든 기능을 활용할 수 있다는 것이 큰 장점이죠.
    • 상태 관리(State Management): Pulumi도 인프라 상태를 관리하지만, 기본적으로 Pulumi 서비스에서 관리해주거나 S3, Azure Blob Storage 등 다양한 백엔드(Backend)를 지원해요. Terraform처럼 로컬 .tfstate 파일에 얽매이지 않아서 좀 더 유연하더라고요.
    • 테스트 용이성(Testability): 프로그래밍 언어의 장점을 살려 단위 테스트(Unit Test), 통합 테스트(Integration Test) 등을 적용하기가 훨씬 쉬워요. 저도 TypeScript로 Pulumi 코드를 작성하면서 Jest 같은 프레임워크로 테스트를 붙여보니, 안정성이 확 올라가더라고요.

    멀티 클라우드 환경에서 Pulumi와 Terraform 비교 분석

    자, 그럼 두 도구를 멀티 클라우드 관점에서 비교해볼까요? 제가 여러 프로젝트에 적용해보면서 느낀 점들을 솔직하게 말씀드릴게요.

    특징 Terraform Pulumi
    언어 HCL (HashiCorp Configuration Language) Python, TypeScript, Go, C#, Java 등 범용 프로그래밍 언어
    학습 곡선 HCL 학습 필요 (비교적 쉬움) 선택 언어에 익숙하다면 낮음
    추상화 수준 선언적, 모듈 기반 프로그래밍 언어의 강력한 추상화 및 로직 구현 가능
    상태 관리 .tfstate 파일 (로컬/원격), 잠금 기능 필수 Pulumi 서비스 또는 다양한 백엔드 지원, 더 유연함
    멀티 클라우드 지원 각 클라우드 프로바이더(Provider) 플러그인 각 클라우드 SDK 기반 라이브러리 (더 깊은 통합 가능)
    테스트 terraform plan, 외부 도구 활용 프로그래밍 언어의 테스트 프레임워크 활용 용이
    확장성 프로바이더 개발, 프로비저너(Provisioner) 프로그래밍 언어의 모든 기능 활용, SDK 개발
    커뮤니티/생태계 오래되고 매우 활발함, 방대한 자료 빠르게 성장 중, 비교적 최신

    Terraform은 꽤 오랫동안 IaC 시장을 지배해왔기 때문에 커뮤니티 자료나 레퍼런스가 정말 많아요. 저도 처음엔 Terraform으로 시작했고요. 반면 Pulumi는 비교적 최근에 떠오른 도구지만, 프로그래밍 언어의 유연성 때문에 개발자 친화적이라는 평이 많습니다. 특히 복잡한 로직이 필요한 인프라 구성이나 기존 개발 워크플로우(Workflow)와 통합하고 싶을 때 Pulumi가 빛을 발하더라고요.

    실전 활용: 간단한 웹 서버 배포 (Terraform vs Pulumi)

    백문이 불여일견이죠! AWS에 간단한 EC2 인스턴스(Instance)를 배포하는 예제를 통해 두 도구의 차이를 직접 느껴보시죠. 제 홈랩에서도 늘 이렇게 시작하곤 합니다.

    Terraform 예제

    main.tf 파일에 다음과 같이 작성해요.

    # main.tf
    
    provider "aws" {
      region = "ap-northeast-2" # 서울 리전
    }
    
    resource "aws_instance" "web_server" {
      ami           = "ami-0a0c4fdd9d45e4895" # Ubuntu 20.04 LTS (서울 리전 기준)
      instance_type = "t2.micro"
      tags = {
        Name = "MyWebServer-Terraform"
      }
    }
    

    명령어는 간단해요.

    terraform init
    terraform plan
    terraform apply --auto-approve
    

    terraform init으로 프로바이더를 초기화하고, terraform plan으로 어떤 변경 사항이 생길지 미리 확인합니다. 마지막으로 terraform apply로 인프라를 배포하죠. 정말 직관적이에요!

    Pulumi 예제 (TypeScript)

    index.ts 파일에 다음과 같이 작성해요.

    // index.ts
    
    import * as aws from "@pulumi/aws";
    
    const webServer = new aws.ec2.Instance("web-server-pulumi", {
        ami: "ami-0a0c4fdd9d45e4895", // Ubuntu 20.04 LTS (서울 리전 기준)
        instanceType: "t2.micro",
        tags: {
            Name: "MyWebServer-Pulumi",
        },
    });
    
    export const instanceId = webServer.id;
    export const publicIp = webServer.publicIp;
    

    명령어는 다음과 같아요.

    pulumi stack init dev # 스택 초기화 (환경 분리)
    pulumi up --yes # 인프라 배포
    

    Pulumi는 스택(Stack) 개념이 있어서 개발, 스테이징, 프로덕션 환경을 깔끔하게 분리할 수 있어요. pulumi up 명령어로 배포하고, --yes 옵션으로 확인 과정을 생략할 수 있습니다. 프로그래밍 언어의 문법을 그대로 따르기 때문에, 개발자라면 훨씬 친숙하게 느껴지실 거예요.

    Terraform과 Pulumi로 생성된 AWS EC2 인스턴스 목록

    ⚠️ 제가 겪었던 삽질과 트러블슈팅 경험

    13년차 엔지니어도 삽질은 피할 수 없죠! 저도 이 두 도구를 쓰면서 여러 번 멘붕에 빠졌어요. 몇 가지 기억에 남는 삽질 경험과 해결법을 공유할게요.

    Terraform: 상태 파일 관리의 늪

    제일 많이 겪었던 건 상태 파일(State File) 충돌이에요. 팀원 여럿이 동시에 같은 인프라를 건드리다 보면 .tfstate 파일이 꼬이는 경우가 생기더라고요. 특히 협업 툴(Collaboration Tool)이나 CI/CD 파이프라인(Pipeline) 없이 수동으로 작업할 때 심했습니다. 상태 파일이 꼬이면 terraform plan 결과가 이상하게 나오거나, 심지어 실제 인프라와 상태 파일 간의 불일치(Drift)가 발생해서 엉뚱한 리소스가 삭제될 뻔한 적도 있었죠. 😱

    • 해결법: 항상 원격 백엔드(Remote Backend) (예: AWS S3 + DynamoDB Lock)를 사용하고, 상태 잠금(State Locking)을 활성화해야 해요. 그리고 CI/CD 파이프라인을 구축해서 모든 변경 사항은 파이프라인을 통해서만 적용되도록 강제하는 것이 가장 안전합니다.

    Pulumi: 언어 버전 의존성

    Pulumi는 범용 프로그래밍 언어를 사용하다 보니, 언어 버전이나 패키지 의존성(Package Dependency) 문제로 삽질한 적도 있어요. 예를 들어, 특정 Pulumi AWS 라이브러리가 특정 Node.js 버전에서만 제대로 동작한다든지, npm install 과정에서 에러가 나는 경우요. 이게 진짜 별것 아닌 것 같아도, 디버깅(Debugging)하다 보면 시간을 꽤 잡아먹어요. 😅

    • 해결법: package.json(TypeScript/Node.js)이나 requirements.txt(Python)에 정확한 버전 정보를 명시하고, nvm(Node Version Manager)이나 pyenv(Python Version Manager) 같은 도구로 개발 환경을 일관되게 유지하는 것이 중요해요. 그리고 Pulumi 관련 라이브러리들도 최신 버전으로 꾸준히 업데이트해주는 게 좋아요.

    공통: 리프레시 명령어의 함정

    가끔 실제 인프라가 변경되었는데 상태 파일에 반영이 안 되는 불일치가 생기면 terraform refresh나 pulumi refresh 명령어를 사용하게 되죠. 그런데 이 명령어를 너무 맹신하면 안 돼요. 특히 중요한 리소스에 대해서는 리프레시 전에 항상 백업(Backup)을 해두거나, plan 명령어로 예상 변경 사항을 꼼꼼히 확인하는 습관을 들이세요. 예전에 리프레시 한 번 잘못했다가 스테이징 환경이 날아갈 뻔한 아찔한 경험도 있습니다. ⚠️

    그래서, 어떤 IaC 도구를 선택해야 할까요?

    결론부터 말씀드리자면, "정답은 없다"예요! (싱거운가요? ㅎㅎ) 하지만 여러분의 상황에 맞는 최적의 선택은 분명히 있습니다.

    Terraform을 선택해야 하는 경우

    • 인프라 전문 팀/운영 팀이 주도적으로 IaC를 도입할 때: HCL은 인프라 관리에 특화되어 있어 직관적이에요.
    • 방대한 기존 레퍼런스와 안정적인 커뮤니티 지원이 중요할 때: 오래된 만큼 자료가 정말 많습니다.
    • 비교적 단순하고 선언적인 인프라 구성이 많을 때: 복잡한 로직 없이 리소스 정의만으로 충분한 경우요.
    • GitOps(깃옵스) 워크플로우를 강력하게 따르고자 할 때: Terraform Cloud/Enterprise 같은 솔루션과 통합이 잘 되어 있어요.

    Pulumi를 선택해야 하는 경우

    • 개발 팀이 직접 인프라를 관리하거나, 개발 워크플로우에 IaC를 통합하고 싶을 때: 익숙한 프로그래밍 언어로 개발 생산성을 높일 수 있어요.
    • 복잡한 로직이나 조건부 인프라 구성이 필요할 때: 프로그래밍 언어의 모든 기능을 활용하여 동적인 인프라를 만들 수 있습니다.
    • 인프라 코드에 단위 테스트/통합 테스트를 적극적으로 적용하여 안정성을 높이고 싶을 때요.
    • 서버리스(Serverless) 아키텍처나 컨테이너 기반 환경(Containerized Environment)에 더 깊이 통합하고 싶을 때: Lambda 함수나 Kubernetes 리소스를 직접 코드로 제어하기가 편해요.

    Terraform과 Pulumi 선택 가이드 요약 인포그래픽

    마무리하며: 13년차 인프라 엔지니어의 조언

    오늘은 Pulumi(풀루미)와 Terraform(테라폼), 두 가지 강력한 IaC 도구에 대해 깊이 있게 다뤄봤어요. 멀티 클라우드 환경에서 인프라를 효율적으로 관리하고 자동화하는 것은 이제 선택이 아닌 필수가 되었죠. 저도 처음엔 "이걸 언제 다 배우지?" 했었는데, 막상 익숙해지니 정말 편하더라고요. 삽질 끝에 드디어 원하는 대로 인프라가 배포될 때의 그 희열은 경험해본 사람만 알 겁니다! 🎉

    두 도구 모두 강력한 장점을 가지고 있으니, 여러분의 팀 구성원들이 어떤 언어에 더 익숙한지, 어떤 유형의 인프라를 주로 다루는지를 고려해서 현명하게 선택하시길 바랍니다. 추가로 어떤 수준의 추상화와 유연성이 필요한지도 함께 생각하면서요. 가능하다면 작은 프로젝트에 두 가지를 모두 적용해보면서 장단점을 직접 느껴보는 것도 좋은 방법입니다.

    인프라 코드를 작성하는 것은 마치 소프트웨어 개발과 같아요. 지속적으로 리팩토링(Refactoring)하고, 버전 관리를 철저히 하며, 테스트를 통해 안정성을 확보하는 것이 중요해요. 이 글이 여러분의 IaC 여정에 조금이나마 도움이 되었기를 바랍니다. 다음번에는 더 유익한 주제로 찾아올게요. 감사합니다!

    홈랩에서 IaC 코드를 작성하는 13년차 인프라 엔지니어