13년차의 서버실

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

[태그:] DevOps

  • [k8s] Helm 차트 개발 및 배포 모범 사례: 효율적인 쿠버네티스 애플리케이션 관리

    [k8s] Helm 차트 개발 및 배포 모범 사례: 효율적인 쿠버네티스 애플리케이션 관리

    Helm 차트 개발 및 배포 모범 사례: 효율적인 쿠버네티스 애플리케이션 관리

    안녕하세요, 13년차의 서버실입니다. 오늘은 쿠버네티스(Kubernetes) 환경에서 애플리케이션을 효율적으로 관리하는 핵심 도구인 Helm(헬름) 차트 개발과 배포 모범 사례에 대해 이야기해보려 합니다. 혹시 쿠버네티스에 배포할 애플리케이션이 늘어나면서 수많은 YAML 파일을 일일이 관리하는 데 어려움을 겪고 계신가요? 저도 처음엔 Deployment, Service, Ingress(인그레스, 외부 트래픽 진입점) 등 수많은 YAML 파일을 만들고, 애플리케이션을 업데이트할 때마다 모든 파일을 수정하느라 삽질 좀 했습니다 ㅎㅎ. Helm은 이런 복잡성을 해결해주는 정말 강력한 패키지 매니저(Package Manager)거든요. 마치 리눅스에서 APT나 YUM으로 소프트웨어를 설치하듯이, 쿠버네티스에서는 Helm으로 애플리케이션을 손쉽게 배포하고 관리할 수 있으니까요.

    이번 글을 통해 Helm 차트 개발의 기초부터 실제 운영에서 제가 겪었던 경험과 팁까지 모두 알려드릴게요. 드디어 YAML 지옥에서 벗어날 때가 온 거죠!

    Helm의 기본적인 작동 방식을 보여주는 아키텍처 다이어그램입니다. Helm 3부터는 Tiller가 사라져 더 간소화되었죠.

    Helm, 왜 필요할까요? (개념 설명)

    Helm은 쿠버네티스용 패키지 매니저라고 쉽게 생각하시면 됩니다. 애플리케이션을 구성하는 모든 쿠버네티스 리소스(Resource)들을 차트(Chart)라는 하나의 묶음으로 정의하고, 이 차트를 통해 배포, 업그레이드, 롤백(Rollback) 등 라이프사이클(Lifecycle) 관리를 자동화하죠. 쉽게 말해, 복잡한 쿠버네티스 애플리케이션을 마치 하나의 설치 파일처럼 다룰 수 있게 해주는 거예요.

    Helm의 주요 구성 요소

    • Chart (차트): 하나의 쿠버네티스 애플리케이션을 정의하는 파일들의 묶음입니다. Docker 이미지, 환경 변수, 서비스, 인그레스 등 모든 구성 요소를 담고 있죠.
    • Release (릴리즈): 쿠버네티스 클러스터에 배포된 차트의 인스턴스를 말합니다. 하나의 차트로 여러 개의 릴리즈를 생성할 수 있습니다. 예를 들어, Nginx 차트 하나로 개발 환경용 Nginx와 운영 환경용 Nginx를 각각 다른 릴리즈로 배포할 수 있거든요.
    • Repository (레포지토리): 차트들을 저장하고 공유하는 공간입니다. Artifact Hub나 개인 S3 버킷 등을 활용할 수 있습니다.

    Helm 차트의 핵심 구성 요소

    Helm 차트는 여러 파일과 디렉토리로 구성되지만, 특히 중요한 몇 가지가 있습니다.

    구성 요소 설명
    Chart.yaml 차트의 이름, 버전, 설명 등 메타데이터(Metadata)를 정의합니다. 차트의 ‘신분증’ 같은 거죠.
    values.yaml 차트 템플릿에 주입할 기본 설정 값들을 정의합니다. 배포 환경에 따라 달라지는 값들을 외부에서 쉽게 변경할 수 있게 해줍니다. 제가 제일 많이 만지는 파일이기도 해요.
    templates/ 쿠버네티스 리소스 정의(Deployment, Service 등) 파일들이 들어있는 디렉토리거든요. Go Template 문법을 사용해서 values.yaml의 값들을 동적으로 주입합니다.
    _helpers.tpl 재사용 가능한 템플릿 조각이나 함수들을 정의하는 파일입니다. 차트가 복잡해질수록 코드 중복을 줄이고 가독성을 높이는 데 정말 유용하더라고요.

    실전! Helm 차트 개발 및 배포

    이제 실제로 간단한 Nginx 애플리케이션을 배포하는 Helm 차트를 만들어보겠습니다. 제가 직접 해보니 이렇게 단계별로 따라 하는 게 가장 이해하기 쉽더라고요.

    1단계: 차트 초기화

    Helm CLI를 사용하면 기본적인 차트 구조를 자동으로 생성해줍니다. 정말 편하죠!

    helm create my-nginx-chart

    이 명령어를 실행하면 my-nginx-chart라는 디렉토리 안에 기본적인 차트 파일들이 생성됩니다. 이 구조를 기반으로 우리 애플리케이션에 맞게 수정하는 거죠.

    2단계: values.yaml 설정

    생성된 my-nginx-chart/values.yaml 파일을 열어 Nginx 이미지와 서비스 포트 등을 설정해봅시다. 기본적으로 생성된 내용이 많지만, 간단하게 Nginx 관련 부분만 수정할게요.

    # my-nginx-chart/values.yaml
    replicaCount: 1
    
    image:
      repository: nginx
      pullPolicy: IfNotPresent
      # Overrides the image tag whose default is the chart appVersion.
      tag: "latest"
    
    service:
      type: ClusterIP
      port: 80
    
    # ... (나머지 부분은 기본값 유지 또는 주석 처리)
    

    여기서 image.repository와 image.tag를 Nginx 이미지로 설정하고, service.port를 80으로 지정했습니다. 나중에 배포할 때 이 값들을 변경하고 싶으면 helm install이나 helm upgrade 명령어에서 --set 옵션을 사용하면 돼요.

    3단계: 템플릿 수정 (Deployment, Service)

    templates/ 디렉토리 안에는 deployment.yaml, service.yaml 등이 있습니다. 이 파일들을 values.yaml에 정의한 값들을 사용하도록 수정해야 합니다. {{ .Values.image.repository }}와 같은 Go Template 문법으로 값을 가져올 수 있거든요.

    my-nginx-chart/templates/deployment.yaml 파일의 image 부분을 이렇게 수정합니다.

    # my-nginx-chart/templates/deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: {{ include "my-nginx-chart.fullname" . }}
      labels:
        {{- include "my-nginx-chart.labels" . | nindent 4 }}
    spec:
      replicas: {{ .Values.replicaCount }}
      selector:
        matchLabels:
          {{- include "my-nginx-chart.selectorLabels" . | nindent 6 }}
      template:
        metadata:
          {{- with .Values.podAnnotations }}
          annotations:
            {{- toYaml . | nindent 8 }}
          {{- end }}
        labels:
          {{- include "my-nginx-chart.selectorLabels" . | nindent 6 }}
        spec:
          containers:
            - name: {{ .Chart.Name }}
              image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
              imagePullPolicy: {{ .Values.image.pullPolicy }}
              ports:
                - name: http
                  containerPort: 80
                  protocol: TCP
              # ... (나머지 부분은 기본값 유지)
    

    그리고 my-nginx-chart/templates/service.yaml 파일의 port 부분을 이렇게 수정합니다.

    # my-nginx-chart/templates/service.yaml
    apiVersion: v1
    kind: Service
    metadata:
      name: {{ include "my-nginx-chart.fullname" . }}
      labels:
        {{- include "my-nginx-chart.labels" . | nindent 4 }}
    spec:
      type: {{ .Values.service.type }}
      ports:
        - port: {{ .Values.service.port }}
          targetPort: http
          protocol: TCP
          name: http
      selector:
        {{- include "my-nginx-chart.selectorLabels" . | nindent 4 }}
    

    이렇게 하면 values.yaml에서 정의한 이미지와 포트가 Deployment와 Service에 동적으로 적용됩니다. 코드를 보면 {{ include "my-nginx-chart.fullname" . }} 같은 구문이 보이는데, 이건 _helpers.tpl에 정의된 재사용 가능한 템플릿 함수거든요. 이런 식으로 차트의 가독성과 유지보수성을 높일 수 있더라고요.

    Helm 차트의 기본적인 구조와 개발, 배포 과정을 한눈에 볼 수 있는 워크플로우입니다.

    4단계: 차트 배포

    이제 우리가 만든 차트를 쿠버네티스 클러스터에 배포해봅시다. helm install 명령어를 사용합니다.

    helm install my-nginx ./my-nginx-chart
    • my-nginx는 우리가 배포하는 릴리즈(Release)의 이름입니다.
    • ./my-nginx-chart는 우리가 개발한 차트가 있는 로컬 경로를 의미합니다.

    명령어를 실행하면 Helm이 차트 템플릿을 렌더링(Rendering)하여 쿠버네티스 API 서버에 리소스들을 생성합니다. 🎉 드디어 Nginx 애플리케이션이 클러스터에 배포된 거예요!

    5단계: 차트 업데이트

    만약 Nginx 버전을 바꾸거나 Replica 수를 늘리고 싶다면 어떻게 할까요? values.yaml을 수정하고 helm upgrade 명령어를 사용하면 돼요.

    예를 들어, my-nginx-chart/values.yaml에서 replicaCount: 3으로 변경한 후:

    helm upgrade my-nginx ./my-nginx-chart

    이렇게 하면 Helm이 변경된 내용만 감지하여 기존 릴리즈를 안전하게 업데이트합니다. 정말 편리하죠? 롤백(Rollback)도 쉽게 할 수 있어서 운영 환경에서 장애 발생 시 빠르게 이전 버전으로 돌아갈 수 있습니다.

    ⚠️ 삽질 방지! Helm 차트 트러블슈팅 팁

    제가 직접 Helm 차트를 개발하면서 가장 많이 겪었던 삽질 중 하나는 바로 values.yaml과 templates 간의 변수 매핑(Mapping) 오류였습니다. YAML 문법은 들여쓰기(Indentation) 하나만 잘못돼도 바로 에러를 뿜어내거든요. 특히 복잡한 구조의 값을 참조할 때 경로를 잘못 지정하거나, 타입(Type)이 맞지 않아 문제가 생기곤 했습니다.

    주요 트러블슈팅 팁

    • helm template --debug --dry-run . 활용: 이 명령어는 실제로 배포하지 않고 차트가 렌더링(Rendering)된 최종 YAML 파일을 보여줍니다. 어디서 어떤 값이 잘못 들어갔는지, 템플릿 문법 오류는 없는지 확인하는 데 정말 큰 도움이 돼요. 제가 가장 많이 쓰는 디버깅(Debugging) 도구입니다.
    • --set 옵션으로 값 오버라이딩(Override): 배포 전에 특정 values.yaml 값을 테스트하고 싶을 때, helm install/upgrade --set image.tag=1.21 my-nginx ./my-nginx-chart 처럼 명령줄에서 값을 직접 오버라이딩해서 테스트해볼 수 있습니다.
    • YAML 문법 검사기 사용: Visual Studio Code 같은 IDE에서 YAML 린터(Linter) 확장을 사용하면 문법 오류를 실시간으로 잡을 수 있거든요.
    • _helpers.tpl 디버깅: _helpers.tpl에 복잡한 로직을 넣었다면, 해당 헬퍼를 사용하는 템플릿 파일에 임시로 {{ include "my-chart.myHelper" . | toYaml }}처럼 넣어서 출력 결과를 확인해보세요.

    배포 확인 및 결과 검증

    차트 배포가 성공적으로 완료되었다면, 이제 쿠버네티스 클러스터에서 Nginx 애플리케이션이 잘 실행되고 있는지 확인해야겠죠?

    배포된 릴리즈 확인

    helm list 명령어로 현재 클러스터에 배포된 모든 Helm 릴리즈를 확인할 수 있습니다.

    helm list
    NAME        NAMESPACE   REVISION    UPDATED                               STATUS      CHART               APP VERSION
    my-nginx    default     1           2023-10-27 10:00:00.123456 +0900 KST deployed    my-nginx-chart-0.1.0 1.25.1
    

    쿠버네티스 리소스 확인

    kubectl 명령어를 사용해서 실제 쿠버네티스 리소스들이 잘 생성되었는지 확인합니다.

    kubectl get pods -l app.kubernetes.io/instance=my-nginx
    NAME                          READY   STATUS    RESTARTS   AGE
    my-nginx-my-nginx-chart-xyz   1/1     Running   0          5m
    
    kubectl get svc -l app.kubernetes.io/instance=my-nginx
    NAME                TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
    my-nginx-my-nginx   ClusterIP   10.X.X.X     <none>        80/TCP    5m
    

    이렇게 Nginx Pod와 Service가 정상적으로 실행되는 것을 볼 수 있습니다. 만약 Ingress를 설정했다면 외부에서 접속도 가능하겠죠!

    쿠버네티스 대시보드에서 my-nginx 릴리즈로 배포된 Nginx 애플리케이션의 Pod와 Service 상태를 확인하는 모습입니다.

    Helm 차트 개발 및 배포 모범 사례

    Helm 차트를 효과적으로 사용하려면 몇 가지 모범 사례(Best Practices)를 따르는 것이 좋습니다. 제가 13년 동안 인프라를 만지면서 느낀 점은, 잘 만들어진 템플릿 하나가 수많은 야근을 줄여준다는 거예요 ㅎㅎ. 다음은 제가 꼭 지키려고 노력하는 모범 사례들입니다.

    • values.yaml을 통한 설정 외부화: 환경별로 달라지는 값들은 반드시 values.yaml에 정의하고, 템플릿에서는 이 값들을 참조하도록 만듭니다. 하드코딩(Hard-coding)은 피해야 합니다.
    • 템플릿 분리 및 재사용: 복잡한 템플릿은 _helpers.tpl에 함수 형태로 분리하여 재사용성을 높이고 가독성을 확보합니다.
    • Semantic Versioning(시맨틱 버저닝): Chart.yaml에 정의된 차트 버전은 MAJOR.MINOR.PATCH 규칙을 따르는 것이 좋습니다. 변경 사항을 명확히 하고 롤백 시 혼란을 줄일 수 있거든요.
    • Liveness/Readiness Probes (활성/준비 프로브) 설정: 애플리케이션의 헬스 체크(Health Check)를 위한 프로브를 반드시 설정하여 안정적인 서비스 운영을 돕습니다.
    • Resource Limits (리소스 제한) 명시: Pod가 사용할 CPU, 메모리 리소스를 제한하여 클러스터의 안정성을 확보합니다. 의도치 않은 리소스 고갈을 방지할 수 있거든요.
    • README.md 문서화: 차트의 사용법, 설정 옵션, 예시 등을 README.md 파일에 상세히 작성하여 다른 사용자들이 쉽게 이해하고 활용할 수 있도록 합니다.
    • 보안 고려: 민감한 정보는 Secret(시크릿)으로 관리하고, RBAC(Role-Based Access Control) 설정을 통해 최소 권한 원칙을 지킵니다.

    Helm 차트 개발 및 배포 시 고려해야 할 핵심 모범 사례들을 인포그래픽으로 정리해봤습니다.

    마무리하며: 효율적인 쿠버네티스 관리의 시작

    오늘은 Helm 차트 개발부터 배포, 그리고 몇 가지 모범 사례까지 쭉 훑어봤습니다. Helm은 쿠버네티스 애플리케이션 배포와 관리를 정말 쉽고 효율적으로 만들어주는 강력한 도구입니다. 처음엔 낯설 수 있지만, 한 번 익숙해지면 수많은 YAML 파일 지옥에서 벗어날 수 있을 거예요. 저도 처음엔 이게 뭔가 싶었는데, 지금은 없으면 허전할 정도로 잘 쓰고 있습니다.

    Helm을 잘 활용하면 CI/CD 파이프라인(Pipeline)과도 쉽게 통합하여 배포 자동화를 구축할 수 있습니다. 여러분의 쿠버네티스 환경이 더욱 스마트해지고 안정적으로 운영되는 데 Helm이 큰 역할을 할 거라고 확신합니다. 다음 글에서는 Helm 차트를 OCI 레지스트리(Registry)에 저장하고 관리하는 방법에 대해 다뤄볼 예정이니, 기대해주세요! 😊

  • [Cloud] GitHub Actions 모노레포: 매트릭스 빌드로 CI/CD 최적화하기

    모노레포에서 CI/CD, 이게 생각보다 복잡하더라고요

    처음 팀에서 모노레포(Monorepo)로 전환했을 때 솔직히 자신 있었거든요. “뭐, CI/CD 파이프라인 하나 잘 짜면 되는 거 아니야?” 했는데… 현실은 달랐습니다. 프론트엔드, 백엔드 API, 공통 라이브러리가 한 레포에 다 들어가 있으니까 서로 상관없는 변경사항인데도 전체 빌드가 돌고, 빌드 시간은 점점 길어지고. 결국 개발자들이 “PR 올리면 20분 기다려야 해요”라고 불만을 터뜨리기 시작했죠.

    그때 제대로 파고든 게 GitHub Actions 모노레포 환경에서의 매트릭스 빌드(Matrix Build) 전략이었습니다. 인프라 업무를 오래 하면서 CI/CD 툴을 여럿 써봤는데, GitHub Actions의 매트릭스 전략은 모노레포 환경에서 정말 강력하더라고요. 오늘은 그 경험을 바탕으로 실전에서 바로 쓸 수 있는 가이드를 공유해 드리려 합니다.

    ▲ GitHub Actions 모노레포 환경의 전체 CI/CD 파이프라인 흐름 — 변경된 패키지만 선택적으로 빌드/테스트하는 구조

    GitHub Actions 모노레포와 매트릭스 빌드, 쉽게 이해해 봅시다

    모노레포(Monorepo)란?

    쉽게 말해, 여러 개의 프로젝트나 패키지를 하나의 Git 저장소에서 관리하는 방식입니다. 예전에는 프로젝트마다 레포를 따로 만드는 멀티레포(Multi-repo) 방식이 흔했는데, 요즘은 Google, Meta, Microsoft 같은 대형 기업들도 모노레포를 적극 활용하고 있죠.

    • ✅ 코드 공유와 재사용이 쉬움
    • ✅ 의존성 관리가 일원화됨
    • ✅ 변경 사항의 영향 범위를 한눈에 파악 가능
    • ⚠️ CI/CD 파이프라인 설계가 까다로워짐
    • ⚠️ 레포 크기가 커질수록 빌드 시간이 늘어날 수 있음

    GitHub Actions 매트릭스 빌드(Matrix Build)란?

    GitHub Actions의 strategy.matrix 기능은 여러 변수 조합에 대해 병렬로 잡(Job)을 실행하는 기능입니다. 예를 들어 Node.js 16, 18, 20 버전에서 동시에 테스트를 돌린다거나, 여러 패키지를 동시에 빌드할 때 쓰죠. 모노레포에서는 이걸 “변경된 패키지 목록”과 조합해서 쓰면 진짜 강력해집니다.

    방식 빌드 대상 빌드 시간 장단점
    전체 빌드 (Naive) 모든 패키지 길다 설정 단순, 시간 낭비 큼
    매트릭스 + 변경 감지 변경된 패키지만 짧다 설정 복잡, 효율 극대화
    캐시 + 매트릭스 변경된 패키지만 매우 짧다 가장 최적화된 방식

    GitHub Actions 모노레포 CI/CD 실전 구현: 단계별로 따라해 보세요

    1단계: 레포 구조 잡기

    먼저 제가 실제로 운영하는 모노레포 구조를 보여드릴게요. 완벽할 필요는 없고, 이런 식으로 패키지가 분리되어 있으면 됩니다.

    my-monorepo/
    ├── .github/
    │   └── workflows/
    │       ├── ci.yml          # 메인 CI 워크플로우
    │       └── detect-changes.yml
    ├── packages/
    │   ├── frontend/           # React 프론트엔드
    │   │   ├── package.json
    │   │   └── src/
    │   ├── api-server/         # Node.js API 서버
    │   │   ├── package.json
    │   │   └── src/
    │   └── shared-lib/         # 공통 라이브러리
    │       ├── package.json
    │       └── src/
    ├── package.json            # 루트 package.json (워크스페이스 설정)
    └── pnpm-workspace.yaml     # pnpm 워크스페이스 설정

    2단계: 변경된 패키지 감지하기

    여기가 핵심입니다. 어떤 패키지가 바뀌었는지 감지해야 매트릭스를 동적으로 구성할 수 있거든요. 저는 git diff와 간단한 스크립트를 조합해서 씁니다.

    # .github/workflows/ci.yml
    name: Monorepo CI
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    jobs:
      # 1. 변경된 패키지 목록을 동적으로 생성
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - name: Checkout
            uses: actions/checkout@v4
            with:
              fetch-depth: 0  # 전체 히스토리 필요 (diff 비교용)
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              # PR이면 base 브랜치와 비교, push면 이전 커밋과 비교
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              CHANGED_PACKAGES='[]'
    
              # packages/ 하위 디렉토리를 순회하며 변경 여부 확인
              for pkg_dir in packages/*/; do
                pkg_name=$(basename $pkg_dir)
                changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                if [ -n "$changed" ]; then
                  CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                fi
              done
    
              echo "Changed packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
    
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT

    💡 팁: fetch-depth: 0 설정이 없으면 git diff가 제대로 안 됩니다. 처음에 이거 빠뜨려서 삽질 좀 했어요. 얕은 클론(shallow clone)에서는 비교 기준이 되는 커밋을 못 찾거든요.

    3단계: 매트릭스 빌드 잡 구성

    이제 감지된 패키지 목록으로 매트릭스를 구성합니다. needs 키워드로 앞 단계에 의존하게 만들고, if 조건으로 변경 사항이 없을 때는 스킵하게 하면 됩니다.

      # 2. 변경된 패키지만 병렬로 빌드 & 테스트
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false  # 하나 실패해도 나머지는 계속 실행
        steps:
          - name: Checkout
            uses: actions/checkout@v4
    
          - name: Setup Node.js
            uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install pnpm
            uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          # 캐시 설정 — 빌드 시간 단축에 핵심!
          - name: Cache build artifacts
            uses: actions/cache@v4
            with:
              path: packages/${{ matrix.package }}/.next
              key: ${{ runner.os }}-${{ matrix.package }}-${{ hashFiles('packages/${{ matrix.package }}/package.json') }}
              restore-keys: |
                ${{ runner.os }}-${{ matrix.package }}-
    
          - name: Build package
            run: pnpm --filter ${{ matrix.package }} build
    
          - name: Run tests
            run: pnpm --filter ${{ matrix.package }} test
    
          - name: Upload test results
            if: always()  # 테스트 실패해도 결과는 업로드
            uses: actions/upload-artifact@v4
            with:
              name: test-results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/

    ▲ 매트릭스 빌드가 실행될 때 GitHub Actions 화면 — 변경된 패키지들이 병렬로 동시에 빌드되는 것을 확인할 수 있음

    4단계: 의존성 있는 패키지 처리

    근데 여기서 문제가 하나 생깁니다. shared-lib가 변경되면 이걸 쓰는 frontend나 api-server도 다시 빌드해야 하거든요. 이 의존성 체인을 처리하는 게 모노레포 CI/CD의 진짜 어려운 부분입니다.

    #!/bin/bash
    # scripts/detect-affected.sh
    # 변경된 패키지 + 그에 의존하는 패키지까지 모두 감지
    
    CHANGED_DIRECT=$1  # 직접 변경된 패키지 (JSON 배열 문자열)
    AFFECTED_PACKAGES=$CHANGED_DIRECT
    
    # 각 패키지의 package.json을 읽어서 의존성 체인 추적
    for pkg_dir in packages/*/; do
      pkg_name=$(basename $pkg_dir)
      pkg_json="$pkg_dir/package.json"
    
      if [ -f "$pkg_json" ]; then
        # 이 패키지가 변경된 패키지에 의존하는지 확인
        for changed in $(echo $CHANGED_DIRECT | jq -r '.[]'); do
          deps=$(cat $pkg_json | jq -r '.dependencies // {} | keys[]' 2>/dev/null)
          if echo "$deps" | grep -q "$changed"; then
            # 의존성이 있으면 affected 목록에 추가
            AFFECTED_PACKAGES=$(echo $AFFECTED_PACKAGES | jq --arg p "$pkg_name" '. + [$p] | unique')
          fi
        done
      fi
    done
    
    echo $AFFECTED_PACKAGES

    이 스크립트를 detect-changes 잡에서 호출하면 됩니다. 물론 Nx나 Turborepo 같은 모노레포 전용 툴을 쓰면 이런 의존성 그래프를 자동으로 처리해 주기도 하죠. 규모가 커지면 Turborepo로 마이그레이션하는 걸 고려하고 있어요.

    ⚠️ 실제로 겪은 GitHub Actions 모노레포 트러블슈팅

    문제 1: BASE_SHA가 비어있는 경우

    첫 번째 커밋이거나 새 브랜치를 push할 때 github.event.before가 0000000000000000000000000000000000000000(제로 SHA)로 오는 경우가 있습니다. 이때 git diff가 에러를 뱉거든요.

          - name: Detect changed packages
            id: set-matrix
            run: |
              # 제로 SHA 처리
              BASE_SHA=${{ github.event.before }}
              ZERO_SHA="0000000000000000000000000000000000000000"
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                # 첫 커밋이면 모든 패키지를 빌드 대상으로
                echo "First push or new branch — building all packages"
                ALL_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
                echo "matrix={\"package\":$ALL_PACKAGES}" >> $GITHUB_OUTPUT
                echo "has-changes=true" >> $GITHUB_OUTPUT
              else
                # 기존 로직 실행
                # ... (이전 코드)
                :
              fi

    문제 2: 매트릭스가 빈 배열일 때 워크플로우 에러

    변경된 패키지가 없어서 매트릭스가 빈 배열 []이 되면 GitHub Actions가 에러를 냅니다. has-changes 출력값으로 if 조건을 걸어두는 게 중요한 이유가 여기 있어요. 또한 최종 상태 체크 잡을 별도로 두는 것도 좋은 패턴입니다.

      # 모든 빌드 완료 후 최종 상태 확인 잡
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Check all jobs status
            run: |
              if [ "${{ needs.detect-changes.result }}" != "success" ]; then
                echo "detect-changes job failed"
                exit 1
              fi
    
              # build-and-test가 스킵됐거나 성공한 경우 모두 OK
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Some builds failed!"
                exit 1
              fi
    
              echo "All checks passed! 🎉"

    문제 3: pnpm 워크스페이스에서 filter가 안 먹힐 때

    패키지 이름이 package.json의 name 필드와 달라서 --filter가 안 먹히는 경우가 있었습니다. 디렉토리명으로 필터하려면 --filter ./packages/패키지명 형식을 써야 합니다.

          - name: Build package
            run: |
              # 디렉토리 경로로 필터 (이름 불일치 문제 방지)
              pnpm --filter ./packages/${{ matrix.package }} build

    전체 GitHub Actions 워크플로우 완성본

    지금까지 설명한 내용을 하나로 합친 완성본입니다. 실제로 제가 운영 중인 설정을 기반으로 정리했어요.

    # .github/workflows/ci.yml
    name: Monorepo CI/CD
    
    on:
      push:
        branches: [main, develop]
      pull_request:
        branches: [main, develop]
    
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true  # 같은 브랜치 중복 실행 방지
    
    jobs:
      detect-changes:
        runs-on: ubuntu-latest
        outputs:
          matrix: ${{ steps.set-matrix.outputs.matrix }}
          has-changes: ${{ steps.set-matrix.outputs.has-changes }}
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
    
          - name: Detect changed packages
            id: set-matrix
            run: |
              if [ "${{ github.event_name }}" = "pull_request" ]; then
                BASE_SHA=${{ github.event.pull_request.base.sha }}
              else
                BASE_SHA=${{ github.event.before }}
              fi
    
              ZERO_SHA="0000000000000000000000000000000000000000"
              CHANGED_PACKAGES='[]'
    
              if [ "$BASE_SHA" = "$ZERO_SHA" ] || [ -z "$BASE_SHA" ]; then
                CHANGED_PACKAGES=$(ls packages/ | jq -R -s -c 'split("\\n") | map(select(length > 0))')
              else
                for pkg_dir in packages/*/; do
                  pkg_name=$(basename $pkg_dir)
                  changed=$(git diff --name-only $BASE_SHA ${{ github.sha }} -- $pkg_dir | head -1)
                  if [ -n "$changed" ]; then
                    CHANGED_PACKAGES=$(echo $CHANGED_PACKAGES | jq --arg p "$pkg_name" '. + [$p]')
                  fi
                done
              fi
    
              echo "Affected packages: $CHANGED_PACKAGES"
    
              if [ "$CHANGED_PACKAGES" = "[]" ]; then
                echo "has-changes=false" >> $GITHUB_OUTPUT
              else
                echo "has-changes=true" >> $GITHUB_OUTPUT
              fi
              echo "matrix={\"package\":$CHANGED_PACKAGES}" >> $GITHUB_OUTPUT
    
      build-and-test:
        needs: detect-changes
        if: needs.detect-changes.outputs.has-changes == 'true'
        runs-on: ubuntu-latest
        strategy:
          matrix: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
          fail-fast: false
        steps:
          - uses: actions/checkout@v4
    
          - uses: pnpm/action-setup@v3
            with:
              version: 8
    
          - uses: actions/setup-node@v4
            with:
              node-version: '20'
              cache: 'pnpm'
    
          - name: Install dependencies
            run: pnpm install --frozen-lockfile
    
          - name: Cache build
            uses: actions/cache@v4
            with:
              path: |
                packages/${{ matrix.package }}/.next
                packages/${{ matrix.package }}/dist
              key: ${{ runner.os }}-build-${{ matrix.package }}-${{ github.sha }}
              restore-keys: |
                ${{ runner.os }}-build-${{ matrix.package }}-
    
          - name: Build
            run: pnpm --filter ./packages/${{ matrix.package }} build
    
          - name: Test
            run: pnpm --filter ./packages/${{ matrix.package }} test
    
          - name: Upload artifacts
            if: always()
            uses: actions/upload-artifact@v4
            with:
              name: results-${{ matrix.package }}
              path: packages/${{ matrix.package }}/test-results/
              retention-days: 7
    
      ci-success:
        needs: [detect-changes, build-and-test]
        if: always()
        runs-on: ubuntu-latest
        steps:
          - name: Final status check
            run: |
              if [ "${{ needs.build-and-test.result }}" = "failure" ]; then
                echo "Build failed!"
                exit 1
              fi
              echo "CI passed! 🎉"

    ▲ 완성된 CI/CD 파이프라인 실행 결과 — detect-changes → build-and-test (병렬) → ci-success 순서로 실행되는 것을 확인

    ✅ 결과: 빌드 시간이 얼마나 줄었을까요?

    이 방식을 적용하고 나서 체감이 꽤 컸습니다. 물론 레포마다 상황이 다르겠지만, 제 경우엔 이랬어요.

    • 📦 전체 패키지 수: 6개
    • ⏱️ 기존 전체 빌드 시간: 약 18~22분
    • ⚡ 매트릭스 빌드 적용 후 (1~2개 패키지 변경 시): 4~7분
    • 🎉 개발자들 PR 머지 속도 체감상 확 빨라짐

    특히 공통 라이브러리 변경이 없는 일반적인 기능 개발 PR에서 효과가 컸습니다. 프론트엔드만 건드렸을 때 백엔드 빌드까지 기다릴 필요가 없으니까요. 당연한 말 같지만, 이걸 자동화로 구현하는 게 핵심이죠.

    추가로 concurrency 설정으로 같은 브랜치에서 중복 실행을 방지한 것도 빌드 큐 낭비를 줄이는 데 도움이 됐습니다. GitHub Actions 무료 플랜은 동시 실행 잡 수에 제한이 있으니까요.

    💡 CI/CD 최적화 추가 팁

    캐시 전략으로 빌드 시간 단축

    • actions/cache의 key를 package.json 해시 기반으로 설정하면 의존성이 바뀔 때만 캐시가 무효화됩니다
    • 빌드 결과물(dist, .next)도 캐시하면 재빌드 시 시간 절약 가능
    • 캐시 hit rate는 Actions 실행 로그에서 확인 가능 — 이걸 모니터링하는 습관을 들이세요

    Turborepo와 함께 쓰기

    패키지가 10개 이상으로 늘어나면 Turborepo(터보레포) 같은 모노레포 빌드 시스템을 도입하는 게 좋습니다. 의존성 그래프 분석, 원격 캐시, 병렬 실행 최적화를 자동으로 해주거든요. 다음 글에서 Turborepo와 GitHub Actions를 함께 쓰는 방법을 다룰 예정입니다.

    Branch Protection Rules 연동

    앞서 만든 ci-success 잡을 GitHub 브랜치 보호 규칙(Branch Protection Rules)의 필수 상태 체크로 등록해 두세요. 매트릭스 빌드가 스킵된 경우에도 ci-success는 항상 실행되므로, PR 머지 조건으로 쓰기에 딱입니다.

    ▲ GitHub Actions 모노레포 CI/CD 최적화 전략 요약 — 변경 감지, 매트릭스 빌드, 캐시 전략의 세 가지 축

    자주 묻는 질문

    Q. Nx를 쓰면 이런 GitHub Actions 설정이 필요 없나요?

    Nx(엔엑스)를 쓰면 nx affected 명령어로 변경된 패키지 감지를 자동화할 수 있습니다. 다만 Nx 자체 학습 곡선이 있고, 기존 프로젝트에 도입하는 비용이 있어요. 팀 규모와 프로젝트 복잡도에 따라 선택하시면 됩니다. 오늘 설명한 방식은 외부 툴 없이 GitHub Actions만으로 구현할 수 있다는 게 장점이에요.

    Q. PR이 아닌 직접 push에서도 잘 되나요?

    네, 됩니다. 다만 github.event.before가 제로 SHA로 오는 케이스(새 브랜치 최초 push)를 반드시 처리해야 합니다. 위 코드에 그 처리가 포함되어 있으니 참고하세요.

    Q. 모노레포에서 Docker 이미지 빌드도 같은 방식으로?

    동일한 패턴으로 적용 가능합니다. 매트릭스의 각 항목에 대해 docker build를 실행하고 레지스트리에 push하면 되죠. 이 내용은 이전 글에서 다룬 Docker 멀티 스테이지 빌드 최적화와 함께 보시면 더 이해가 쉬울 거예요.

    마무리: GitHub Actions 모노레포 CI/CD, 처음엔 복잡해 보여도

    처음에 이 구조를 짤 때 “이게 맞나?” 싶은 순간이 여러 번 있었습니다. 특히 동적 매트릭스 생성 부분에서 fromJson이 제대로 안 먹혀서 한참 헤맸던 기억이 나네요. 근데 한번 제대로 잡아두면 이후에는 거의 손댈 일이 없어요.

    핵심을 정리하면 이렇습니다.

    1. 변경 감지: git diff로 변경된 패키지만 추려냄
    2. 동적 매트릭스: 감지 결과를 JSON으로 출력해서 매트릭스에 주입
    3. 병렬 빌드: strategy.matrix로 변경된 패키지들을 동시에 빌드
    4. 캐시 활용: actions/cache로 반복 빌드 시간 단축
    5. 안전장치: ci-success 잡으로 브랜치 보호 규칙과 연동

    혹시 레포 규모가 더 크거나, Turborepo/Nx 같은 툴과 함께 쓰는 방법이 궁금하신 분들은 댓글로 남겨주세요. 다음 글에서 다뤄볼게요. 오늘도 긴 글 읽어주셔서 감사합니다! 🎉

  • [Cloud] Terraform AWS EKS 모듈 활용: 프로덕션 클러스터 배포 및 관리 가이드

    [Cloud] Terraform AWS EKS 모듈 활용: 프로덕션 클러스터 배포 및 관리 가이드

    안녕하세요, 13년차 서버 인프라 엔지니어입니다. 오늘은 Terraform AWS EKS 모듈을 활용해서 프로덕션 환경에 바로 적용할 수 있는 쿠버네티스(Kubernetes) 클러스터를 배포하고 관리하는 방법을 나눠볼게요. 사실 저도 쿠버네티스를 처음 접했을 땐 ‘이걸 어떻게 프로덕션에 안정적으로 올리지?’ 고민이 정말 많았거든요. 수많은 설정과 의존성 때문에 삽질을 꽤 했었습니다. ㅎㅎ

    그런데 Terraform(테라폼)과 AWS EKS 모듈을 써보니까, 정말 복잡했던 배포 과정이 깔끔하게 정리되더라고요. 마치 복잡한 미로에서 벗어나는 기분이었죠. 오늘은 제가 직접 경험했던 노하우를 바탕으로, Terraform EKS 모듈이 왜 프로덕션 환경에 필수적인지, 그리고 어떻게 활용하는지 자세히 알려드릴게요.

    Terraform으로 관리되는 AWS EKS 클러스터의 일반적인 아키텍처입니다.

    개념 정리: 왜 Terraform과 EKS 모듈이 필요할까요?

    본격적인 실전으로 들어가기 전에, 핵심 개념들을 간단히 짚고 넘어갈게요. 이미 잘 알고 계신 분들도 있겠지만, 혹시 모르니까 멘토처럼 쉽게 설명해드릴게요.

    • IaC (Infrastructure as Code, 코드형 인프라스트럭처): 인프라를 코드로 관리한다는 뜻이에요. 예전에는 서버 한 대 놓으려면 직접 전산실 가서 설치하고 케이블 연결하고 그랬잖아요? 요즘은 그런 모든 과정을 코드로 정의하고 자동화합니다. Terraform이 바로 이 IaC를 구현하는 대표적인 도구거든요. 코드로 인프라를 관리하면 반복 가능하고, 버전 관리도 되고, 무엇보다 휴먼 에러가 확 줄어든다는 게 최고의 장점입니다. 제가 직접 써보니 정말 그렇더라고요!

    • AWS EKS (Amazon Elastic Kubernetes Service): AWS에서 제공하는 관리형 쿠버네티스 서비스예요. 쿠버네티스는 컨테이너화된 워크로드를 배포하고 관리하는 오픈소스 시스템인데, EKS는 이 쿠버네티스 컨트롤 플레인(Control Plane)을 AWS가 대신 관리해준다는 뜻입니다. 덕분에 우리는 마스터 노드 관리 부담 없이 워커 노드(Worker Node)에만 집중할 수 있죠. 프로덕션 환경에선 안정성이 생명인데, AWS가 관리해주니 정말 마음이 든든합니다.

    • Terraform AWS EKS 모듈: Terraform 오픈소스 커뮤니티가 만들어 놓은 ‘모듈(Module)’이 있습니다. 이 EKS 모듈은 AWS EKS 클러스터를 배포하는 데 필요한 모든 리소스(VPC, 서브넷, IAM 역할, EKS 클러스터 자체, 노드 그룹 등)를 미리 정의해둔 템플릿 모음이에요. 이걸 사용하면 수백 줄이 넘을 수 있는 Terraform 코드를 몇 줄로 줄일 수 있거든요. 처음엔 직접 다 만들었었는데, 이 모듈을 발견하고 나서는 ‘아, 이래서 다들 모듈을 쓰는구나!’ 하고 무릎을 탁 쳤습니다. 생산성 향상에 정말 최고예요. 🚀

    실전 구현: Terraform으로 EKS 클러스터 배포하기

    이제 실제로 Terraform AWS EKS 모듈을 사용해서 프로덕션용 EKS 클러스터를 배포해볼게요. 단계별로 차근차근 따라오면 됩니다. 제가 홈랩에서 여러 번 테스트해보고 가장 안정적인 방법을 알려드릴게요.

    1. 프로젝트 구조 및 초기 설정

    먼저 다음과 같은 디렉토리 구조를 만들어주세요.

    mydir/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf
    

    main.tf 파일에 AWS Provider(프로바이더)를 설정하고, Terraform EKS 모듈을 정의합니다.

    # main.tf
    
    terraform {
      required_version = ">= 1.0.0"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.0"
        }
      }
    }
    
    provider "aws" {
      region = var.aws_region
    }
    
    module "vpc" {
      source  = "terraform-aws-modules/vpc/aws"
      version = "~> 5.0"
    
      name = "${var.cluster_name}-vpc"
      cidr = "10.0.0.0/16"
    
      azs             = data.aws_availability_zones.available.names
      private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
      public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
    
      enable_nat_gateway = true
      single_nat_gateway = true
    
      tags = {
        "kubernetes.io/cluster/${var.cluster_name}" = "owned"
        "kubernetes.io/role/internal-elb"           = "1"
        "kubernetes.io/role/elb"                    = "1"
      }
    }
    
    module "eks" {
      source  = "terraform-aws-modules/eks/aws"
      version = "~> 20.0" # Terraform Registry에서 최신 안정 버전을 확인하세요!
    
      cluster_name    = var.cluster_name
      cluster_version = var.cluster_version
    
      vpc_id     = module.vpc.vpc_id
      subnet_ids = module.vpc.private_subnets
    
      # EKS 클러스터 로깅 활성화 (프로덕션 필수!)
      cluster_enabled_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
    
      # 관리형 노드 그룹 (Managed Node Groups)
      managed_node_groups = {
        general = {
          name            = "general-nodes"
          instance_types  = ["t3.medium"]
          min_size        = 2
          max_size        = 5
          desired_size    = 3
          disk_size       = 50
          labels          = { env = "production", role = "general" }
          capacity_type   = "ON_DEMAND"
          # EKS 노드에 SSH 접속이 필요하다면 아래 주석 해제 후 키 페어 이름 설정
          # key_name = "your-ssh-key-name"
        }
        # 추가 노드 그룹이 필요하면 여기에 정의
        # spot = {
        #   name          = "spot-nodes"
        #   instance_types  = ["t3.small", "t3.medium"]
        #   min_size        = 0
        #   max_size        = 10
        #   desired_size    = 1
        #   capacity_type   = "SPOT"
        #   disk_size       = 20
        # }
      }
    
      tags = {
        Project     = "EKS-Production"
        Environment = "Prod"
      }
    }
    
    data "aws_availability_zones" "available" {}
    

    ⚠️ 주의사항: t3.medium은 테스트용으로 괜찮지만, 실제 프로덕션 워크로드에는 더 큰 인스턴스 타입(예: m5.large, c5.large)을 고려하세요. 그리고 version = "~> 20.0" 부분은 항상 Terraform Registry에서 최신 안정 버전을 확인해서 사용하세요. Terraform AWS EKS 모듈이 워낙 빠르게 업데이트돼서 제가 작성한 시점과 다를 수 있거든요.

    variables.tf 파일에는 클러스터 이름, AWS 리전, EKS 버전 등 변경될 수 있는 값들을 정의합니다.

    # variables.tf
    
    variable "aws_region" {
      description = "AWS region."
      type        = string
      default     = "ap-northeast-2" # 서울 리전
    }
    
    variable "cluster_name" {
      description = "Name of the EKS cluster."
      type        = string
      default     = "my-prod-eks-cluster"
    }
    
    variable "cluster_version" {
      description = "Kubernetes version."
      type        = string
      default     = "1.28" # 사용 가능한 EKS 버전 확인 후 지정
    }
    

    outputs.tf 파일에는 배포 후 필요한 정보를 출력하도록 설정합니다. 클러스터 엔드포인트나 kubeconfig 명령어 같은 것들이죠.

    # outputs.tf
    
    output "cluster_endpoint" {
      description = "Endpoint for EKS Control Plane."
      value       = module.eks.cluster_endpoint
    }
    
    output "kubeconfig_command" {
      description = "Command to configure kubectl."
      value       = "aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}"
    }
    
    output "cluster_security_group_id" {
      description = "Security group ID of the EKS cluster."
      value       = module.eks.cluster_security_group_id
    }
    

    Terraform EKS 모듈을 위한 주요 구성 파일들의 역할과 관계를 보여줍니다.

    2. Terraform 명령 실행

    파일들을 모두 작성했다면, 터미널을 열고 해당 디렉토리로 이동해서 다음 명령어를 실행합니다.

    1. Terraform 초기화 (Initialize): 필요한 프로바이더와 모듈을 다운로드합니다.

      terraform init
      
    2. Terraform 실행 계획 (Plan): 실제로 어떤 리소스들이 생성/변경/삭제될지 미리 보여줍니다. 이 단계에서 항상 꼼꼼히 확인하는 습관을 들이셔야 해요. 저는 여기서 실수 몇 번 해보고 크게 깨달았습니다. 😅

      terraform plan
      
    3. Terraform 적용 (Apply): 계획대로 리소스를 AWS에 배포합니다. 시간이 좀 걸릴 수 있어요. 커피 한 잔 마시면서 기다려보세요. 😊

      terraform apply --auto-approve
      

      --auto-approve 옵션은 실제 프로덕션 환경에서는 신중하게 사용해야 합니다. 보통은 terraform apply만 실행해서 직접 승인하는 과정을 거치는 게 안전하거든요.

    ⚠️ 주의사항: 삽질 경험과 해결책

    제가 13년간 인프라 엔지니어로 일하면서 느낀 건, 아무리 잘 만들어진 도구라도 ‘삽질’은 피할 수 없다는 거예요. Terraform EKS 모듈도 마찬가지입니다. 몇 가지 흔한 문제와 제가 겪었던 해결책을 공유해드릴게요.

    • VPC Subnet Tag 누락: EKS 클러스터가 서브넷을 제대로 인식하지 못해서 배포가 실패하는 경우가 종종 있어요. 특히 다른 모듈로 VPC를 만들었거나 수동으로 서브넷을 구성했을 때 그렇더라고요. EKS는 워커 노드를 프로비저닝할 때 특정 태그(kubernetes.io/cluster/YOUR_CLUSTER_NAME과 kubernetes.io/role/internal-elb 또는 kubernetes.io/role/elb)가 있는 서브넷을 찾습니다. main.tf의 VPC 모듈 설정에서 태그를 꼭 넣어주세요.

      tags = {
        "kubernetes.io/cluster/${var.cluster_name}" = "owned"
        "kubernetes.io/role/internal-elb"           = "1"
        "kubernetes.io/role/elb"                    = "1"
      }
      
    • IAM 권한 부족: Terraform을 실행하는 IAM 사용자 또는 역할에 EKS, EC2, IAM, VPC 관련 권한이 충분히 부여되지 않으면 문제가 생길 수 있습니다. 특히 EKS Administrator와 유사한 관리자 권한을 가진 정책을 사용하거나, 필요한 최소 권한을 직접 설정해야 하죠. 저는 처음에 너무 최소 권한만 주려다가 여러 번 권한 에러를 만났습니다. 그럴 땐 일단 잠시 넓은 권한을 줘서 문제가 권한 때문인지 확인하고, 잘 되면 다시 최소 권한으로 조이는 방법을 썼어요.

    • 모듈 버전 충돌 또는 비호환성: Terraform AWS EKS 모듈은 빠르게 업데이트됩니다. 특정 Terraform 버전, AWS Provider 버전, EKS 클러스터 버전과의 호환성을 항상 확인해야 해요. Terraform Registry에서 해당 모듈의 Required Providers와 Requirements 섹션을 꼭 확인하세요. 버전이 맞지 않으면 예상치 못한 에러가 발생할 수 있거든요.

    • 노드 그룹의 인스턴스 타입/AMI 문제: EKS 워커 노드가 정상적으로 클러스터에 조인되지 않는 경우가 있습니다. 주로 EKS 버전과 호환되지 않는 AMI(Amazon Machine Image)를 사용했거나, 인스턴스 타입이 해당 리전에서 지원되지 않을 때 발생합니다. Terraform EKS 모듈은 기본적으로 EKS 최적화 AMI를 사용하지만, 사용자 지정 AMI를 사용할 경우 주의해야 합니다.

    검증 및 결과: 클러스터 확인하기

    Terraform apply가 성공적으로 완료되었다면, 이제 EKS 클러스터가 잘 배포되었는지 확인해볼 차례예요. 🎉

    1. Kubeconfig 설정

    먼저 outputs.tf에서 출력된 kubeconfig_command를 실행해서 kubectl이 EKS 클러스터에 접속할 수 있도록 설정합니다. 이 명령어를 실행하면 ~/.kube/config 파일이 업데이트될 겁니다.

    aws eks update-kubeconfig --region ap-northeast-2 --name my-prod-eks-cluster
    

    2. 노드 확인

    이제 kubectl 명령어로 클러스터 노드들을 확인해봅시다. 워커 노드들이 Ready 상태로 잘 올라와 있어야 합니다.

    kubectl get nodes
    
    NAME                                           STATUS   ROLES    AGE     VERSION
    ip-10-0-1-123.ap-northeast-2.compute.internal   Ready    <none>   5m20s   v1.28.x
    ip-10-0-2-234.ap-northeast-2.compute.internal   Ready    <none>   5m15s   v1.28.x
    ip-10-0-3-345.ap-northeast-2.compute.internal   Ready    <none>   5m10s   v1.28.x
    

    3. AWS Console에서 확인

    AWS Management Console(관리 콘솔)에 로그인해서 EKS 서비스로 이동하면, 방금 배포한 클러스터가 목록에 보일 거예요. 클러스터 이름을 클릭해서 상세 정보를 확인하고, 노드 그룹 탭에서 워커 노드들이 정상적으로 실행 중인지도 확인해볼 수 있습니다.

    AWS EKS 콘솔에서 배포된 클러스터와 노드 그룹의 상태를 확인하는 모습입니다.

    마무리, 그리고 다음 단계

    오늘은 Terraform AWS EKS 모듈을 활용해서 프로덕션 레디(Production-Ready) 쿠버네티스 클러스터를 배포하고 관리하는 방법을 자세히 알아봤습니다. 제가 직접 겪은 삽질 경험과 해결책도 함께 공유해드렸는데, 도움이 되셨으면 좋겠네요.

    Terraform EKS 모듈 덕분에 우리는 복잡한 EKS 인프라를 빠르고 안정적으로 구축할 수 있게 됐어요. IaC의 강력함을 다시 한번 느낄 수 있었던 경험이었죠. 처음엔 진입 장벽이 좀 있다고 느낄 수 있지만, 한번 익숙해지면 이만큼 편리한 게 없습니다. 정말 강력한 도구거든요.

    Terraform AWS EKS 모듈을 사용했을 때 얻을 수 있는 주요 이점들을 시각적으로 요약했습니다.

    다음 단계로는 이렇게 배포된 EKS 클러스터에 Ingress Controller(인그레스 컨트롤러, 외부 트래픽을 클러스터 내부 서비스로 라우팅), Cert-Manager(인증서 관리), Prometheus(프로메테우스, 모니터링 시스템) 같은 필수 애드온(Add-on)들을 Terraform으로 함께 배포하는 방법을 다뤄볼 예정이에요. 그리고 CI/CD 파이프라인(Continuous Integration/Continuous Deployment Pipeline, 지속적 통합/배포 파이프라인)과 연동해서 인프라 변경을 자동화하는 방법도 흥미로운 주제가 될 것 같습니다.

    궁금한 점이나 추가적인 삽질 경험이 있으시다면 댓글로 자유롭게 남겨주세요! 함께 고민하고 해결해나가는 게 인프라 엔지니어의 묘미 아니겠습니까? 😊

  • [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델과 절감 전략

    [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델과 절감 전략

    [클라우드 비용 관리] Terraform Cloud 비용 최적화: RUM 모델 이해 및 절감 전략

    안녕하세요, 13년차의 서버실 주인장입니다. 오늘은 인프라 자동화 좀 해봤다 하는 분들이라면 한 번쯤은 만나봤을 Terraform Cloud에 대한 이야기를 해볼까 합니다. 특히, "어? 이거 왜 이렇게 비용이 많이 나왔지?" 하고 고개를 갸웃하게 만드는 그 미스터리, 바로 RUM (Resource Usage Model) 모델과 그 비용을 최적화하는 전략에 대해 제 경험을 바탕으로 솔직하게 풀어보려고 합니다.

    처음 Terraform Cloud를 도입했을 때, 저는 그 편리함에 감탄했었죠. 원격 상태 관리(Remote State Management), 팀 협업, CI/CD 통합까지… IaC(Infrastructure as Code)의 생산성을 정말 한 단계 끌어올려 주더라고요. 근데 어느 날 청구서를 받아보니, 예상했던 것보다 훨씬 많은 금액에 깜짝 놀랐습니다. terraform apply 한두 번 했을 뿐인데 이게 무슨 일인가 싶었죠. 혹시 여러분도 이런 경험 없으신가요? ⚠️

    이건 바로 Terraform Cloud의 독특한 과금 방식인 RUM 모델 때문이거든요. 저도 삽질 좀 하면서 이 모델을 파고들었고, 그 결과 몇 가지 효과적인 비용 절감 전략을 찾을 수 있었습니다. 오늘은 그 노하우를 여러분과 공유해볼까 합니다. 자, 그럼 함께 Terraform Cloud 비용 청구의 비밀을 파헤쳐 볼까요?

    Terraform Cloud RUM 모델 개요: 리소스가 어떻게 비용으로 연결되는지 보여주는 다이어그램입니다.

    Terraform Cloud RUM (Resource Usage Model)이란 무엇인가요?

    Terraform Cloud의 비용 구조에서 가장 핵심적인 부분은 바로 RUM (Resource Usage Model, 리소스 사용 모델)입니다. 쉽게 말해, Terraform Cloud가 "관리하는 리소스의 개수"에 따라 비용을 청구하는 방식이에요.

    그럼 어떤 리소스가 RUM에 포함될까요? terraform state list 명령어를 실행했을 때 출력되는 모든 리소스가 RUM 카운트에 포함된다고 생각하시면 됩니다. 예를 들어, AWS EC2 인스턴스, S3 버킷, VPC, Subnet 등 resource "aws_instance" "my_server" 이런 식으로 HCL(HashiCorp Configuration Language)에 선언된 모든 것들이요. Terraform Cloud는 이런 리소스들을 워크스페이스(Workspace) 단위로 관리하고, 각 워크스페이스가 관리하는 리소스의 총합에 따라 과금하는 방식이에요.

    여기서 중요한 포인트는 💡 데이터 소스 (Data Source)나 로컬 리소스 (Local Resource)는 RUM 카운트에 포함되지 않는다는 점입니다! 즉, data "aws_ami" "latest"나 locals { ... } 블록은 아무리 많이 써도 비용에 영향을 주지 않아요. 이 점을 잘 활용하면 비용을 효과적으로 절감할 수 있거든요.

    Terraform Cloud 비용 절감 핵심 전략

    이제 본격적으로 Terraform Cloud 비용을 최적화하는 전략들을 알아볼 시간입니다. 제가 직접 써보고 효과를 본 방법들이니, 여러분 환경에도 적용해보시면 분명 도움이 될 거예요.

    1. 불필요한 워크스페이스 정리하기

    이건 정말 기본 중의 기본이자 가장 효과적인 방법입니다. 개발 초기 단계나 테스트 목적으로 만들었다가 방치된 워크스페이스, 혹은 더 이상 사용하지 않는 프로젝트의 워크스페이스가 있다면 과감히 정리해야 합니다. 각 워크스페이스는 관리하는 리소스 수에 따라 RUM 비용을 발생시키기 때문이죠. 😅

    저도 처음엔 테스트용으로 워크스페이스를 여러 개 만들었는데, 나중에 보니 수십 개의 워크스페이스가 활성 상태로 남아있더라고요. 이걸 정리했더니 월별 청구액이 꽤 많이 줄었습니다. 🎉

    워크스페이스를 정리할 때는 다음 단계를 따르세요:

    1. 상태 파일 백업: 혹시 모를 상황에 대비해 terraform state pull > my_backup.tfstate 명령어로 상태 파일을 로컬에 백업해두는 게 좋습니다.
    2. 리소스 삭제: 워크스페이스가 관리하는 실제 클라우드 리소스를 terraform destroy 명령어로 삭제합니다. 이 과정을 빼먹으면 클라우드 비용만 계속 나갑니다!
    3. 워크스페이스 삭제: Terraform Cloud UI나 API를 통해 워크스페이스를 삭제합니다.

    만약 수많은 워크스페이스를 일일이 확인하기 어렵다면, tfe-cli 같은 도구를 활용해서 스크립트로 자동화하는 것도 좋은 방법이에요.

    # 예시: 특정 태그를 가진 오래된 워크스페이스 목록 확인 (tfe-cli 예시)
    tfe workspace list --json | jq '.[] | select(.tags[] | contains("test")) | select(.updated-at < "2023-01-01T00:00:00Z")'
    
    # 삭제는 더 신중하게 접근해야 합니다.
    # tfe workspace delete [WORKSPACE_NAME]
    

    2. 리소스 설계 최적화: Data Sources 및 Locals 활용

    앞서 언급했듯이 Data Sources (데이터 소스)와 Locals (로컬 변수)는 RUM 카운트에 포함되지 않습니다. 이 점을 최대한 활용하여 resource 블록의 수를 줄이는 방향으로 설계를 최적화할 수 있어요.

    • Data Sources 활용: 이미 존재하는 리소스의 정보를 가져올 때 data "aws_vpc" "existing"처럼 데이터 소스를 사용하세요. 특히, 자주 바뀌지 않거나 다른 Terraform 스택에서 관리하는 리소스 정보를 가져올 때 유용합니다. 제가 해보니, AMI ID나 특정 보안 그룹 ID 같은 것들을 데이터 소스로 가져오면 resource 블록을 하나 줄일 수 있더라고요.
    • Locals 활용: 복잡한 표현식의 결과를 저장하거나, 여러 리소스에서 공통으로 사용되는 값을 정의할 때 locals 블록을 사용하면 코드 가독성도 높이고, 불필요한 리소스 선언을 피할 수 있어요.
    # RUM에 포함되지 않는 Data Source 예시
    data "aws_ami" "ubuntu" {
      most_recent = true
      filter {
        name   = "name"
        values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
      }
      owners = ["099720109477"]
    }
    
    # RUM에 포함되지 않는 Locals 예시
    locals {
      instance_type = "t3.micro"
      common_tags = {
        Project     = "MyService"
        Environment = "Development"
      }
    }
    
    # RUM에 포함되는 Resource 예시 (이것의 수가 과금의 기준이 됩니다)
    resource "aws_instance" "web_server" {
      ami           = data.aws_ami.ubuntu.id
      instance_type = local.instance_type
      tags          = local.common_tags
    }
    

    3. Sentinel Policy 활용하여 비용 낭비 방지

    Terraform Cloud의 Sentinel (센티넬)은 정책 기반 코드(Policy as Code)를 통해 인프라 변경 사항을 검증하는 강력한 도구입니다. 이 Sentinel을 활용하면 비용 낭비를 사전에 막을 수 있어요. 예를 들어:

    • 고가용성 리소스 제한: 특정 고가 리소스(예: 고성능 데이터베이스 인스턴스)의 생성을 제한하거나, 특정 환경(예: 개발 환경)에서는 특정 인스턴스 타입 이상을 생성하지 못하게 정책을 걸 수 있어요.
    • 리소스 개수 제한: 특정 타입의 리소스(예: EC2 인스턴스)가 한 워크스페이스 내에서 일정 개수 이상 생성되지 못하도록 막을 수 있거든요. 저도 실수로 count 값을 너무 높게 설정해서 수십 개의 인스턴스를 한 번에 배포할 뻔한 적이 있는데, Sentinel 덕분에 막을 수 있었죠. 휴~ 😮‍💨
    
    # 예시: EC2 인스턴스의 개수를 5개로 제한하는 Sentinel Policy (pseudo-code)
    
    # import "tfplan/v2" as tfplan
    
    # instance_count = length(tfplan.resource_changes as r, r.type is "aws_instance" and r.change.actions contains "create")
    
    # main = rule {
    #   instance_count <= 5
    # }
    

    실제 Sentinel 정책은 위 예시보다 더 복잡하지만, 핵심은 원하는 제약을 코드로 정의하여 terraform apply 전에 검증함으로써 불필요한 리소스 생성을 막는다는 거죠.

    Terraform Cloud 워크스페이스와 Sentinel 정책: 중앙 정책 적용으로 비용 낭비를 막는 방법을 보여줍니다.

    4. Run 실행 횟수 관리 (간접적 영향)

    RUM 모델 자체는 리소스 개수에 초점을 맞추지만, 상위 티어에서는 Run (실행) 횟수도 과금 요소가 될 수 있어요. 그리고 잦은 Run은 불필요한 리소스 변경으로 이어질 가능성을 높여 RUM 카운트에도 간접적으로 영향을 줄 수 있거든요.

    • 변경 사항 신중하게 검토: terraform plan 결과를 항상 꼼꼼하게 확인하고, 꼭 필요한 변경 사항만 apply 하세요.
    • CI/CD 파이프라인 최적화: 불필요한 트리거로 Run이 실행되지 않도록 CI/CD 파이프라인을 설계하는 게 중요합니다. 예를 들어, 모든 커밋마다 Run을 돌리기보다는, 특정 브랜치에 머지될 때만 실행되도록 설정하는 식이죠.

    비용 최적화 결과 확인하기

    위 전략들을 적용했다면, 이제 그 효과를 확인해야겠죠? Terraform Cloud는 자체적으로 Billing & Usage (청구 및 사용량) 대시보드를 제공합니다. 여기서 월별 RUM 사용량과 청구 금액을 확인할 수 있어요.

    제 경험상, 불필요한 워크스페이스를 정리하고 리소스 설계를 조금만 변경해도 눈에 띄게 RUM 카운트가 줄어드는 것을 볼 수 있었습니다. 처음엔 이게 뭔가 싶었는데, 막상 수치가 줄어드는 걸 보니 뿌듯하더라고요. ✅

    대시보드에서 Managed Resources (관리되는 리소스) 그래프를 꾸준히 모니터링하면서, 어떤 워크스페이스가 많은 리소스를 관리하고 있는지, 그리고 그 추이가 어떻게 변하는지 확인해보세요. 이걸 보면서 "아, 이 워크스페이스는 리소스가 너무 많네. 줄여야겠다" 같은 의사결정을 할 수 있거든요.

    Terraform Cloud Billing & Usage 대시보드: 비용 절감 전략 적용 후 월별 RUM 사용량이 감소하는 가상의 그래프입니다.

    마무리하며: 삽질을 줄이는 현명한 비용 관리

    오늘은 Terraform Cloud의 RUM 모델을 이해하고, 이를 바탕으로 비용을 최적화하는 여러 전략에 대해 이야기해봤습니다. 13년차 인프라 엔지니어로서 제가 직접 겪었던 "비용 폭탄" 경험과 그 해결 과정을 공유하면서, 여러분의 삽질을 조금이나마 줄여드리고 싶었어요. 💡

    핵심은 결국 "내가 무엇을 관리하고 있고, 그게 과금에 어떻게 영향을 미치는지 정확히 아는 것"이에요. Terraform Cloud는 정말 강력한 도구지만, 그만큼 현명하게 사용해야 예상치 못한 비용 문제로 당황하지 않을 수 있거든요.

    여러분도 이 글에서 소개한 전략들을 바탕으로 Terraform Cloud 비용을 최적화하고, 더 효율적인 IaC 환경을 구축하시길 바랍니다. 다음 글에서는 Terraform Cloud의 원격 실행 환경(Remote Operations)과 로컬 실행 환경(Local Operations)의 장단점을 비교해보는 시간을 가져볼게요. 기대해주세요! 😄

    Terraform Cloud 비용 절감 핵심 전략 요약: 주요 절감 팁을 한눈에 볼 수 있는 인포그래픽입니다.

  • [Cloud] Podman vs Docker: Rootless 컨테이너 보안 및 활용 가이드

    [Cloud] Podman vs Docker: Rootless 컨테이너 보안 및 활용 가이드

    Podman vs Docker: Rootless 컨테이너 보안 및 활용 가이드

    인프라 엔지니어의 고민: 더 안전한 컨테이너 운영을 찾아서

    안녕하세요, 13년차 서버실 지킴이입니다. 요즘 인프라 엔지니어라면 컨테이너(Container) 기술을 빼놓고 이야기하기 어렵죠. 저도 처음엔 도커(Docker)가 세상에 나왔을 때, ‘와, 이거 진짜 물건이다!’ 하면서 열광했던 기억이 납니다. 개발 환경부터 운영 환경까지 컨테이너 덕분에 정말 많은 게 편리해졌거든요. 그런데 말입니다, 편리함 뒤에는 늘 그림자가 따르기 마련이더라고요. 특히 보안(Security) 측면에서는 늘 마음 한편이 불안했어요.

    도커 데몬(Docker daemon)이 항상 루트(root) 권한으로 실행된다는 점, 그리고 컨테이너 탈출(Container Escape) 같은 잠재적인 보안 위협들은 저를 계속해서 고민하게 만들었죠. 홈랩(Home Lab)에서 다양한 서비스를 돌리면서 ‘어떻게 하면 좀 더 안전하게 컨테이너를 운영할 수 있을까?’ 하는 고민은 저만의 숙제는 아닐 겁니다. 혹시 여러분도 이런 고민 해보신 적 있으신가요? 이 지점에서 제가 눈여겨보게 된 것이 바로 Podman(팟맨)입니다.

    오늘 글에서는 컨테이너 보안의 새로운 대안으로 떠오른 Podman과 우리가 오랫동안 써왔던 Docker를 비교하고, 특히 Rootless 컨테이너(Rootless Container) 개념을 중심으로 Podman의 장점과 활용법을 자세히 알려드리려고 합니다. 제가 직접 삽질해가며 얻은 경험들을 바탕으로, 여러분의 컨테이너 운영에 도움이 될 만한 실질적인 팁들을 공유해볼게요! 💡

    Podman과 Docker 아키텍처 비교 다이어그램

    Podman과 Docker의 아키텍처 비교. Podman은 데몬 없이 사용자 프로세스로 실행되어 보안 이점을 가집니다.

    Rootless 컨테이너란 무엇인가?

    쉽게 말해, Rootless 컨테이너는 루트(root) 권한 없이, 일반 사용자(non-root user) 권한으로 실행되는 컨테이너를 말합니다. 기존 도커는 도커 데몬(Docker Daemon)이 호스트(Host) 시스템에서 루트 권한으로 실행되고, 이 데몬이 컨테이너를 관리하는 구조였죠. 이게 왜 문제가 되냐면, 만약 컨테이너에 보안 취약점이 있어서 외부 공격자가 컨테이너를 탈출(Escape)하는 데 성공하면, 호스트 시스템의 루트 권한까지 획득할 수 있는 심각한 상황이 발생할 수 있기 때문입니다. ⚠️

    Podman은 태생부터 이런 보안 문제를 염두에 두고 설계되었어요. Podman은 도커와 달리 데몬(Daemon)이 없거든요. 즉, 백그라운드에서 항상 루트 권한으로 돌아가는 프로세스가 없다는 뜻입니다. Podman 명령어를 실행하면, 해당 명령어는 일반 사용자 권한으로 컨테이너를 생성하고 관리하게 됩니다. 처음 이걸 알았을 땐 ‘아, 이거다!’ 싶었거든요. 이렇게 되면 컨테이너를 탈출하더라도 일반 사용자 권한까지만 접근할 수 있으니, 호스트 시스템의 피해를 최소화할 수 있습니다. 보안적인 측면에서 봤을 때 정말 큰 이점이라고 할 수 있어요.

    Podman vs Docker: 핵심 차이점 비교

    Podman과 Docker는 컨테이너를 관리하는 도구라는 공통점이 있지만, 내부적으로는 꽤 많은 차이가 있습니다. 제가 직접 써보면서 느낀 주요 차이점들을 표로 정리해봤어요.

    특징 Podman Docker
    아키텍처 (Architecture) 데몬리스 (Daemonless): 백그라운드 데몬 없음. 사용자 프로세스로 실행. 데몬 기반 (Daemon-based): Docker 데몬이 루트 권한으로 백그라운드 실행.
    루트 권한 (Root Privileges) 기본 Rootless: 일반 사용자 권한으로 컨테이너 실행. 데몬이 루트 권한으로 실행. Rootless 모드는 실험적 또는 부가 기능.
    보안 (Security) 호스트 시스템에 대한 공격 표면(Attack Surface) 감소. 데몬 취약점 발생 시 호스트 시스템 전체 위험.
    이미지 빌드 (Image Build) Buildah(빌다)를 내부적으로 활용 (podman build). Docker Build 엔진 사용 (docker build).
    오케스트레이션 (Orchestration) Kubernetes Pods(쿠버네티스 파드)와 높은 호환성. podman generate kube로 YAML 생성 가능. Docker Swarm(도커 스웜), Docker Compose(도커 컴포즈) 지원.
    명령어 호환성 (CLI Compatibility) 대부분의 docker 명령어와 유사 (podman으로 대체 가능). 표준 Docker CLI.
    Podman vs Docker 핵심 차이점 비교 인포그래픽

    Podman과 Docker의 주요 특징들을 한눈에 비교한 표입니다. 특히 데몬리스 아키텍처와 Rootless 기본 지원이 Podman의 가장 큰 차이점이죠.

    Podman으로 Rootless 컨테이너 실행하기

    그럼 이제 Podman으로 Rootless 컨테이너를 직접 실행해보면서 그 편리함을 느껴볼 시간입니다! 제가 홈랩에서 주로 쓰는 CentOS Stream 9나 Fedora, 또는 Ubuntu 22.04 이상 환경에서는 Podman 설치가 아주 쉬워요.

    1. 팟맨 설치하기

    저는 CentOS Stream 9에서 진행했습니다. 여러분의 OS에 맞게 설치해주세요.

    
    # CentOS/Fedora
    sudo dnf install podman -y
    
    # Ubuntu/Debian
    sudo apt update
    sudo apt install podman -y
    

    설치가 완료되면, podman info 명령어로 잘 설치되었는지 확인해볼 수 있습니다.

    
    podman info
    

    여기서 중요한 건, 루트 권한 없이 실행해도 잘 동작한다는 점이에요! 🎉

    2. Rootless 컨테이너 실행해보기

    가장 기본적인 컨테이너 실행부터 시작해볼까요? Alpine 리눅스 컨테이너를 실행해보겠습니다.

    
    podman run --rm -it alpine sh
    

    컨테이너 내부에서 whoami 명령어를 실행해보면, root로 나올 거예요. ‘어? Rootless라면서 왜 컨테이너 안에서는 root지?’ 하고 저처럼 당황하실 수 있는데, 이건 컨테이너 내부의 루트 사용자가 호스트 시스템의 일반 사용자에 매핑(mapping)되었기 때문입니다. 즉, 컨테이너 안에서는 여전히 강력한 권한을 가지지만, 호스트 시스템에는 아무런 영향을 주지 못하는 ‘가짜 루트’인 셈이죠.

    이제 Nginx 웹 서버를 Rootless로 띄워볼까요?

    
    podman run -d --name my-nginx -p 8080:80 nginx
    

    잘 실행되었는지 확인하고, 웹 브라우저나 curl로 접속해보세요.

    
    podman ps
    curl localhost:8080
    

    정상적으로 Nginx 초기 페이지가 보인다면 성공입니다! ✅

    3. Podman의 파드(Pod) 기능 활용해보기

    Podman은 쿠버네티스(Kubernetes)의 파드(Pod) 개념을 네이티브(Native)하게 지원합니다. 여러 컨테이너가 동일한 네트워크 네임스페이스(Network Namespace)와 저장소(Storage)를 공유해야 할 때 아주 유용하거든요. 제가 이걸 처음 써봤을 때, ‘홈랩에 간이 쿠버네티스를 구축한 느낌인데?’ 하면서 감탄했어요. 🤭

    먼저 파드를 생성합니다.

    
    podman pod create --name my-web-app-pod -p 8080:80
    

    이제 이 파드 안에 Nginx와 다른 컨테이너를 넣어보죠.

    
    podman run -d --pod my-web-app-pod --name webserver nginx
    podman run -d --pod my-web-app-pod --name data-logger alpine sleep 1h
    

    podman pod ps 명령어로 파드와 그 안에 있는 컨테이너들을 확인할 수 있습니다.

    
    podman pod ps
    

    4. 이미지 빌드하기

    Podman은 Dockerfile을 이용한 이미지 빌드도 완벽하게 지원합니다. docker build 대신 podman build를 사용하면 되죠. Dockerfile 예시를 하나 만들어볼게요.

    
    # Dockerfile
    FROM alpine:latest
    RUN apk add --no-cache nginx
    COPY ./index.html /usr/share/nginx/html/index.html
    EXPOSE 80
    CMD ["nginx", "-g", "daemon off;"]
    

    그리고 간단한 index.html 파일도 만들어줍니다.

    
    <!DOCTYPE html>
    <html>
    <head>
        <title>Hello Podman!</title>
    </head>
    <body>
        <h1>Welcome to my Rootless Nginx with Podman!</h1>
    </body>
    </html>
    

    이제 이 두 파일이 있는 디렉토리에서 이미지를 빌드합니다.

    
    podman build -t my-custom-nginx .
    

    빌드된 이미지를 확인하고 실행해보세요.

    
    podman images
    podman run -d --name my-custom-web -p 8080:80 my-custom-nginx
    curl localhost:8080
    
    Podman 이미지 빌드 프로세스 다이어그램

    Podman을 이용한 이미지 빌드 과정입니다. Docker와 거의 동일한 명령어로 편리하게 이미지를 만들 수 있어요.

    Podman 운영 시 주의사항

    제가 Podman을 처음 접하면서 겪었던 몇 가지 ‘삽질’과 그 해결책을 공유합니다. 저처럼 헤매지 마시길 바라요! ㅎㅎ

    1. 낮은 포트(Low Port) 바인딩 문제: Rootless 컨테이너는 기본적으로 1024번 이하의 포트(예: 80, 443)를 직접 바인딩(Binding)할 수 없습니다. 이는 보안상의 이유로 일반 사용자에게 허용되지 않는 권한이거든요. 처음 Nginx를 80번 포트에 바로 연결하려고 했을 때, ‘왜 안 되지?’ 하면서 한참을 헤맸습니다. 😩
      해결책: 가장 쉬운 방법은 -p 8080:80처럼 1024번보다 높은 포트에 바인딩하는 것입니다. 아니면 Nginx나 Apache 같은 리버스 프록시(Reverse Proxy)를 호스트에 설정해서 80번 포트로 들어오는 트래픽을 컨테이너의 8080번 포트로 포워딩(Forwarding)하는 방법도 있어요. (sysctl net.ipv4.ip_unprivileged_port_start=80 같은 설정을 할 수도 있지만, 보안상 권장하지는 않습니다.)

    2. 볼륨 마운트(Volume Mount) 권한 문제: Rootless 컨테이너에서 호스트의 특정 디렉토리를 볼륨으로 마운트할 때 권한 문제가 발생할 수 있어요. 컨테이너 내부의 UID/GID(User ID/Group ID)가 호스트 시스템의 UID/GID와 매핑되는 방식 때문에 생기는 문제인데, /etc/subuid와 /etc/subgid 파일에 정의된 보조 UID/GID 범위가 적절히 설정되어 있지 않으면 문제가 됩니다.
      해결책: 이 파일들을 수동으로 편집하거나, Podman이 자동으로 설정하도록 합니다. 대부분의 경우 Podman 설치 시 자동으로 설정되지만, 가끔 문제가 생기면 이 파일을 확인해보세요. 저도 특정 디렉토리에 로그를 남기려다가 권한 문제로 고생 좀 했어요.

    3. systemd 통합: Podman은 systemd와 아주 잘 통합됩니다. podman generate systemd 명령어를 사용하면 실행 중인 컨테이너나 파드를 systemd 서비스로 등록할 수 있는 유닛(Unit) 파일을 자동으로 생성해줍니다. 이걸 몰랐을 때는 직접 유닛 파일을 만들었는데, 나중에 이 기능을 알고 얼마나 편했는지 몰라요! 🤩

    검증: Rootless 컨테이너로 더 안전한 운영

    Podman으로 Rootless 컨테이너를 운영하면서 제가 가장 크게 느낀 점은 ‘마음의 평화’입니다. 😅 호스트 시스템에 대한 잠재적인 보안 위협이 크게 줄어들기 때문에, 훨씬 안심하고 컨테이너를 돌릴 수 있게 되었거든요. podman ps, podman inspect 같은 명령어로 컨테이너 상태를 확인해보면, 도커와 거의 동일한 방식으로 작동한다는 걸 알 수 있습니다. 단지 실행 주체가 루트가 아닌 일반 사용자라는 것만 다를 뿐이죠.

    특히 ~/.local/share/containers 경로를 확인해보면, 모든 컨테이너 관련 파일들이 일반 사용자 홈 디렉토리 아래에 생성된 것을 볼 수 있어요. 이게 바로 Rootless 컨테이너의 핵심 증거입니다. 👍

    Rootless 컨테이너 보안 이점 다이어그램

    Rootless 컨테이너는 호스트 시스템의 루트 권한으로부터 격리되어 보안을 강화합니다.

    마무리: 언제 Podman을 써야 할까?

    오늘 Podman과 Docker의 차이점, 그리고 Rootless 컨테이너의 중요성에 대해 이야기해봤습니다. Podman은 다음과 같은 상황에서 특히 강력한 대안이 될 수 있다고 생각해요.

    • 보안이 최우선 고려사항일 때: 특히 다중 사용자 환경이나, 호스트 시스템의 보안을 최대한 강화하고 싶을 때 Rootless Podman은 탁월한 선택입니다.
    • 쿠버네티스 환경을 준비 중일 때: Podman은 쿠버네티스 파드 개념을 네이티브하게 지원하고, podman generate kube 같은 명령어로 쉽게 YAML 파일을 생성할 수 있어 쿠버네티스 학습 및 연동에 유리합니다.
    • 경량화된 컨테이너 환경이 필요할 때: 백그라운드 데몬 없이 필요한 시점에만 프로세스가 실행되므로, 리소스 효율성 측면에서도 장점이 있어요.

    물론 Docker도 여전히 막강한 생태계와 성숙한 도구들을 가지고 있습니다. Docker Compose나 Docker Swarm 같은 기능은 아직 Podman보다 더 안정적이고 편리한 측면이 많죠. 따라서 어떤 도구를 선택할지는 여러분의 특정 요구사항과 환경에 따라 달라질 겁니다.

    저의 13년차 경험상, ‘만능 도구’는 없습니다. 하지만 각 도구의 장단점을 정확히 알고 상황에 맞게 사용하는 것이 진정한 엔지니어의 자세라고 생각합니다. Podman은 컨테이너 보안과 효율성을 한 단계 끌어올릴 수 있는 정말 매력적인 도구임은 분명해요. 다음번에는 Podman Compose를 이용한 다중 컨테이너 애플리케이션 배포에 대해 더 깊이 다뤄보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다! 👋

  • [Cloud] Pulumi vs Terraform 비교: IaC 도구 선택 가이드

    IaC 도구 선택, 왜 이렇게 어렵냐고요 😅

    팀에서 인프라 자동화 도구를 새로 도입하려고 할 때, 가장 먼저 나오는 질문이 있죠. “Terraform 쓸까요, Pulumi 쓸까요?”

    저도 몇 년 전에 이 선택 앞에서 한참 고민했거든요. 당시엔 Terraform이 거의 IaC(Infrastructure as Code, 코드로 인프라를 정의하고 관리하는 방식)의 표준처럼 여겨지던 시절이었는데, Pulumi라는 새로운 녀석이 등장하면서 “이거 써야 하나?” 싶었던 기억이 납니다.

    결론부터 말씀드리면 — 둘 다 써봤고, 둘 다 장단점이 뚜렷해요. Pulumi vs Terraform 비교는 단순히 “어느 게 더 좋냐”의 문제가 아니라, 팀 상황과 프로젝트 성격에 따라 달라지는 문제더라고요. 오늘은 13년 동안 인프라 엔지니어로 일하면서 직접 겪은 경험을 바탕으로, 이 두 IaC 도구를 제대로 비교해드리겠습니다.

    Pulumi와 Terraform의 전체적인 구조와 접근 방식 차이를 보여주는 개요 다이어그램

    Terraform과 Pulumi, 각각 어떤 도구인가요?

    Terraform — IaC의 베테랑

    Terraform은 HashiCorp에서 만든 오픈소스 IaC 도구로, 2014년에 처음 출시됐습니다. HCL(HashiCorp Configuration Language)이라는 자체 DSL(Domain-Specific Language, 특정 목적을 위해 만들어진 언어)을 사용하는 게 특징이에요.

    쉽게 말해서, “인프라를 선언적으로 정의”하는 방식입니다. “EC2 인스턴스 이렇게 만들어줘”라고 적어두면, Terraform이 알아서 현재 상태와 비교해서 필요한 작업만 수행하는 거죠.

    # Terraform 예시 — AWS EC2 인스턴스 생성
    resource "aws_instance" "web_server" {
      ami           = "ami-0c55b159cbfafe1f0"
      instance_type = "t3.micro"
    
      tags = {
        Name        = "web-server"
        Environment = "production"
      }
    }
    
    output "instance_ip" {
      value = aws_instance.web_server.public_ip
    }

    처음 보면 “이게 뭔 언어야?” 싶을 수 있는데, 몇 번 써보면 꽤 직관적이더라고요. 특히 인프라 구성을 “읽는” 관점에서는 진짜 편합니다.

    Pulumi — 개발자 친화적인 도전자

    Pulumi는 2018년에 등장한 비교적 젊은 IaC 도구입니다. 가장 큰 차별점은 실제 프로그래밍 언어를 그대로 사용한다는 점이에요. Python, TypeScript, Go, C#, Java 등을 지원하거든요.

    처음 이걸 봤을 때 “오, 이거 개발자들 좋아하겠다” 싶었어요. 인프라 엔지니어보다 소프트웨어 개발자 출신이 많은 팀이라면 특히요.

    # Pulumi 예시 — Python으로 AWS EC2 인스턴스 생성
    import pulumi
    import pulumi_aws as aws
    
    web_server = aws.ec2.Instance(
        "web-server",
        ami="ami-0c55b159cbfafe1f0",
        instance_type="t3.micro",
        tags={
            "Name": "web-server",
            "Environment": "production",
        }
    )
    
    pulumi.export("instance_ip", web_server.public_ip)

    같은 결과물인데, Python 개발자라면 아래 코드가 훨씬 익숙하게 느껴질 겁니다. 이게 Pulumi의 핵심 가치예요.

    Pulumi vs Terraform 핵심 차이점 비교

    자, 이제 본격적으로 비교해 봅시다. 제가 직접 두 도구를 써보면서 느낀 차이점들을 정리했어요.

    비교 항목 Terraform Pulumi
    언어 HCL (자체 DSL) Python, TypeScript, Go, C# 등
    학습 곡선 HCL은 쉽지만, 복잡한 로직은 어려움 언어는 익숙하지만 Pulumi 개념 학습 필요
    상태 관리 로컬 파일 또는 원격 백엔드 Pulumi Cloud 또는 자체 백엔드
    프로바이더 생태계 매우 방대 (성숙한 생태계) 성장 중 (Terraform 프로바이더 브리지 지원)
    테스트 제한적 (Terratest 등 별도 도구 필요) 언어 기본 테스트 프레임워크 활용 가능
    커뮤니티 매우 활발, 레퍼런스 풍부 성장 중
    라이선스 BSL 1.1 (v1.5.5부터 변경) Apache 2.0 (오픈소스)
    엔터프라이즈 기능 Terraform Cloud/Enterprise Pulumi Cloud

    ⚠️ 라이선스 변경 이슈 주의! Terraform은 2023년 8월에 라이선스를 MPL 2.0에서 BSL(Business Source License) 1.1로 변경했습니다. 이 때문에 OpenTofu라는 포크 프로젝트가 생겼을 정도로 커뮤니티에서 논란이 됐었어요. 상업적 목적으로 사용할 때는 라이선스 조건을 꼭 확인하세요.

    Terraform의 plan/apply 워크플로우와 Pulumi의 preview/up 워크플로우를 나란히 비교한 다이어그램

    실전에서 느끼는 차이 — 직접 써보니까요

    Terraform이 빛나는 순간들

    제가 Terraform을 처음 도입했을 때가 2019년쯤이었는데, 당시 팀에 인프라 엔지니어가 저 포함 3명이었어요. 개발팀과 협업할 일이 많았는데, HCL 파일을 코드 리뷰할 때 개발자들도 꽤 잘 읽더라고요. 선언적 문법이라 “이 리소스가 이렇게 생겼구나”를 직관적으로 파악하기 좋거든요.

    특히 이런 상황에서 Terraform이 강점을 발휘했어요:

    • 💡 팀에 인프라 전문가 비율이 높을 때 — HCL에 익숙해지면 오히려 더 깔끔하게 관리됨
    • 💡 레퍼런스가 중요할 때 — 거의 모든 클라우드 리소스에 대한 예제가 넘쳐남
    • 💡 안정성이 최우선일 때 — 10년 넘은 도구라 예측 가능한 동작이 보장됨
    • 💡 모듈 재사용이 핵심일 때 — Terraform Registry의 공개 모듈 생태계가 압도적
    # Terraform 모듈 활용 예시
    module "vpc" {
      source  = "terraform-aws-modules/vpc/aws"
      version = "~> 5.0"
    
      name = "my-vpc"
      cidr = "10.0.0.0/16"
    
      azs             = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
      private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
      public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
    
      enable_nat_gateway = true
    
      tags = {
        Environment = "production"
        Terraform   = "true"
      }
    }

    Terraform Registry에서 검증된 VPC 모듈 몇 줄로 완성이에요. 이런 거 보면 “역시 생태계가 갑이다” 싶죠.

    Terraform의 아픈 부분 😅

    근데 솔직히 말씀드리면, 복잡한 로직 처리할 때는 좀 힘들어요. 예를 들어 “조건에 따라 리소스 개수를 동적으로 결정”하려면…

    # Terraform에서 동적 리소스 생성 — 이게 직관적이지 않음
    resource "aws_security_group_rule" "ingress_rules" {
      count = length(var.ingress_ports)
    
      type              = "ingress"
      from_port         = var.ingress_ports[count.index]
      to_port           = var.ingress_ports[count.index]
      protocol          = "tcp"
      cidr_blocks       = ["0.0.0.0/0"]
      security_group_id = aws_security_group.main.id
    }
    
    # for_each를 쓰면 좀 낫지만, 여전히 복잡한 로직엔 한계가 있음
    resource "aws_instance" "servers" {
      for_each = var.server_configs
    
      ami           = each.value.ami
      instance_type = each.value.instance_type
    
      tags = {
        Name = each.key
      }
    }

    count, for_each 같은 메타 인수를 쓰다 보면 “이게 프로그래밍이 아니라 퍼즐 푸는 느낌”이 들 때가 있어요. 저도 처음엔 이게 뭔가 싶었는데, 익숙해지는 데 시간이 좀 걸렸습니다 ㅎㅎ

    Pulumi가 빛나는 순간들

    반면에 Pulumi는 개발자 팀에서 진가를 발휘하더라고요. 제가 사이드 프로젝트로 홈랩 인프라를 Pulumi(Python)로 관리해봤는데, 이런 점이 진짜 좋았어요:

    # Pulumi Python — 복잡한 로직도 그냥 Python으로
    import pulumi
    import pulumi_aws as aws
    
    # 환경별 설정을 딕셔너리로 관리
    env_configs = {
        "production": {"instance_type": "t3.medium", "count": 3},
        "staging":    {"instance_type": "t3.small",  "count": 1},
        "dev":        {"instance_type": "t3.micro",  "count": 1},
    }
    
    config = pulumi.Config()
    env = config.require("environment")
    current_config = env_configs[env]
    
    # 그냥 for 루프로 인스턴스 생성 — 이게 얼마나 자연스러운지!
    instances = []
    for i in range(current_config["count"]):
        instance = aws.ec2.Instance(
            f"web-server-{i}",
            ami="ami-0c55b159cbfafe1f0",
            instance_type=current_config["instance_type"],
            tags={
                "Name": f"web-server-{i}",
                "Environment": env,
            }
        )
        instances.append(instance)
    
    # 결과 출력도 Python 리스트 컴프리헨션으로
    pulumi.export("instance_ids", [inst.id for inst in instances])

    이거 처음 써봤을 때 “드디어 됐다!” 싶었어요. 프로그래밍 언어의 모든 기능을 그대로 쓸 수 있으니까, 복잡한 조건 처리나 반복 작업이 훨씬 자연스럽거든요.

    Pulumi가 특히 유리한 상황:

    • 💡 팀이 개발자 중심일 때 — TypeScript, Python 등 이미 아는 언어로 바로 시작 가능
    • 💡 인프라 로직이 복잡할 때 — 조건문, 반복문, 함수 등을 자유롭게 사용
    • 💡 테스트 자동화가 중요할 때 — pytest, Jest 등 기존 테스트 도구 그대로 활용
    • 💡 기존 코드베이스와 통합할 때 — 앱 코드와 인프라 코드를 같은 언어로 관리

    Pulumi의 아픈 부분 ⚠️

    근데 Pulumi도 완벽하진 않아요. 제가 겪은 몇 가지 불편한 점들:

    • 레퍼런스 부족 — 문제 생겼을 때 구글링해도 Terraform만큼 자료가 안 나와요. 특히 엣지 케이스는 직접 디버깅해야 하는 경우가 많았음
    • Pulumi Cloud 의존성 — 기본 상태 관리가 Pulumi Cloud를 통하는 방식이라, 자체 관리하려면 별도 설정이 필요
    • 언어별 지원 수준 차이 — TypeScript 지원이 가장 성숙하고, 다른 언어는 업데이트 시점이 조금씩 달라요
    • 팀 온보딩 — 인프라 엔지니어가 특정 프로그래밍 언어에 익숙하지 않으면 오히려 진입 장벽이 될 수 있음

    Pulumi Cloud 콘솔에서 스택 상태와 리소스 배포 결과를 확인하는 화면

    ⚠️ 실전에서 주의해야 할 것들

    Terraform 상태 파일(State File) 관리

    Terraform 쓰면서 가장 많이 삽질하는 부분이 상태 파일 관리예요. 처음에 로컬에 `terraform.tfstate` 파일 두고 팀에서 같이 쓰다가 충돌 났던 경험, 한 번쯤은 다들 있으실 거예요 ㅎㅎ

    # 반드시 원격 백엔드 설정하세요!
    terraform {
      backend "s3" {
        bucket         = "my-terraform-state"
        key            = "production/terraform.tfstate"
        region         = "ap-northeast-2"
        encrypt        = true
        dynamodb_table = "terraform-state-lock"  # 동시 수정 방지용 잠금
      }
    }
    

    DynamoDB로 상태 잠금(State Locking) 설정 안 해두면, 두 사람이 동시에 apply 실행할 때 상태 파일이 꼬일 수 있어요. 이거 한 번 겪어보면 절대 안 잊어버리게 됩니다 😅

    Pulumi 스택(Stack) 분리 전략

    Pulumi에서는 환경별로 스택을 분리하는 게 기본 패턴인데, 초반에 이걸 제대로 설계 안 하면 나중에 꽤 고생해요.

    # Pulumi 스택 생성 및 관리
    pulumi stack init production
    pulumi stack init staging
    pulumi stack init dev
    
    # 현재 스택 확인
    pulumi stack ls
    
    # 스택 전환
    pulumi stack select production
    
    # 배포 미리보기 (Terraform의 plan에 해당)
    pulumi preview
    
    # 실제 배포
    pulumi up

    💡 팁: Pulumi에서 환경별 설정값은 `pulumi config set`으로 관리하세요. 민감한 값은 `–secret` 플래그로 암호화해서 저장할 수 있어요.

    # 일반 설정값
    pulumi config set aws:region ap-northeast-2
    pulumi config set environment production
    
    # 민감한 정보는 암호화 저장
    pulumi config set --secret db_password "super-secret-password"

    Terraform 라이선스 변경 이후 고민

    앞서 언급했지만, 2023년 Terraform의 BSL 라이선스 전환은 꽤 큰 이슈였어요. 이 때문에 OpenTofu라는 오픈소스 포크가 Linux Foundation 산하에서 시작됐고, 많은 조직에서 마이그레이션을 고려하기 시작했죠. Pulumi로 넘어가는 팀들도 일부 있었고요.

    만약 상업적 사용이나 Terraform 기반 서비스 제공을 고려하고 있다면, BSL 1.1 조건을 법무팀과 함께 꼼꼼히 검토하시길 권장합니다.

    결국 뭘 선택해야 할까요? — 상황별 가이드

    제가 컨설팅이나 팀 내 논의에서 자주 받는 질문이 “그래서 뭐 써야 해요?”인데요. 정답은 없지만, 제 기준을 공유해 드릴게요.

    ✅ Terraform을 선택하면 좋은 경우

    • 팀에 인프라 전문가 비율이 높고, HCL에 거부감이 없을 때
    • 커뮤니티 레퍼런스와 안정성이 최우선일 때
    • Terraform Registry의 공개 모듈을 적극 활용하고 싶을 때
    • 기존 Terraform 코드베이스가 있는 팀에 합류할 때
    • 비교적 단순한 인프라 구성이 주를 이룰 때

    ✅ Pulumi를 선택하면 좋은 경우

    • 팀이 소프트웨어 개발자 중심이고, 특정 언어에 능숙할 때
    • 인프라 로직이 복잡해서 프로그래밍적 표현이 필요할 때
    • 인프라 코드에 유닛 테스트를 적용하고 싶을 때
    • 앱 코드와 인프라 코드를 같은 언어로 통합 관리하고 싶을 때
    • 라이선스 이슈에서 자유로운 완전 오픈소스 도구가 필요할 때

    팀 상황과 프로젝트 특성에 따른 Terraform vs Pulumi 선택 가이드 인포그래픽

    자주 묻는 질문 (FAQ)

    Q. Terraform에서 Pulumi로 마이그레이션할 수 있나요?

    네, Pulumi에서 공식적으로 변환 도구를 제공해요. `pulumi convert –from terraform` 명령으로 기본적인 리소스 변환이 가능합니다. 완벽하지는 않지만, 간단한 구성은 꽤 잘 변환되더라고요. 다만 복잡한 모듈이나 커스텀 프로바이더는 수동 작업이 필요할 수 있어요.

    Q. 둘 다 멀티 클라우드(Multi-Cloud)를 지원하나요?

    둘 다 AWS, Azure, GCP 등 주요 클라우드 프로바이더를 지원합니다. Terraform은 프로바이더 생태계가 더 방대하고, Pulumi는 Terraform 프로바이더를 브리지(Bridge)해서 쓸 수 있어서 커버리지 차이가 많이 줄어들었어요.

    Q. Pulumi는 무료인가요?

    Pulumi 자체는 오픈소스(Apache 2.0)로 무료입니다. Pulumi Cloud의 경우 개인 사용은 무료 플랜이 있고, 팀 협업 기능이나 고급 기능은 유료 플랜이 필요해요. 자체 백엔드(S3, Azure Blob Storage 등)를 사용하면 Cloud 없이도 운영 가능합니다.

    Q. 둘 다 Kubernetes(쿠버네티스) 관리에 적합한가요?

    네, 둘 다 Kubernetes 리소스 관리를 지원합니다. 다만 Kubernetes 전용으로는 Helm이나 Kustomize와의 조합이 더 일반적이에요. 클라우드 인프라(EKS 클러스터 생성 등)와 Kubernetes 리소스를 함께 관리할 때 IaC 도구가 유용하게 쓰입니다.

    마무리 — 결국 중요한 건 팀과 컨텍스트

    Pulumi vs Terraform 비교를 오래 해봤지만, 솔직히 말씀드리면 둘 다 훌륭한 IaC 도구입니다. 어느 쪽이 “객관적으로 더 좋다”라고 말하기가 어려워요.

    제 개인적인 포지션을 말씀드리면:

    • 🏢 엔터프라이즈 환경, 인프라 팀 주도 → Terraform (안정성, 레퍼런스, 생태계)
    • 🚀 스타트업, 개발자 팀, 복잡한 로직 → Pulumi (유연성, 언어 친숙도)
    • 🏠 홈랩, 개인 프로젝트 → 둘 다 배워보세요! 경험치 쌓기엔 최고

    저는 요즘 홈랩은 Pulumi(Python)로, 업무 환경은 Terraform으로 관리하고 있어요. 두 도구를 병행하면서 각각의 강점을 더 명확하게 느끼게 됐달까요.

    혹시 이미 둘 중 하나를 쓰고 계신 분들은, 반대편 도구도 간단한 사이드 프로젝트로 한번 써보시길 추천드려요. 비교해봐야 진짜 차이가 느껴지거든요.

    다음 글에서는 Terraform 상태 관리와 원격 백엔드 설정을 더 깊이 다뤄볼 예정이에요. Terraform 쓰다가 상태 파일로 삽질한 경험이 있으신 분들이라면 도움이 될 겁니다. 이전 글에서 다뤘던 클라우드 인프라 자동화 기초도 함께 참고해보세요!

    질문이나 의견은 댓글로 남겨주세요. 저도 아직 배우는 중이라, 여러분의 경험도 궁금합니다 😊

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    1. Terraform 설치

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

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

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

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

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

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

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

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

    프로젝트 디렉터리 구조

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Step 6: 배포 실행

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

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

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

    문제 1: Provider 버전 충돌

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

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

    문제 2: Azure Storage Account 이름 규칙

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

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

    문제 3: GCP API 활성화 누락

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

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

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

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

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

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

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

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

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

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

    Outputs으로 중요 정보 추출하기

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

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

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

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

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

    자주 묻는 질문 (FAQ)

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

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

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

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

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

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

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

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

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

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

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

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

  • [Cloud] Terraform 멀티 클라우드 IaC 자동화 가이드 — AWS/GCP/Azure 통합 관리

    [Cloud] Terraform 멀티 클라우드 IaC 자동화 가이드 — AWS/GCP/Azure 통합 관리

    멀티 클라우드, 왜 이렇게 복잡한 걸까요?

    솔직히 말씀드리면, 저도 처음 멀티 클라우드 환경을 맡았을 때 머리가 좀 아팠습니다. AWS 콘솔 따로, GCP 콘솔 따로, Azure 포털 따로… 각각 로그인하고, 각각 다른 방식으로 리소스 만들고, 나중에 뭘 어디에 만들었는지 파악도 안 되는 그 상황 말이에요. 혹시 이런 경험 있으신가요?

    13년 동안 인프라 엔지니어로 일하면서 클라우드 환경이 단일 벤더에서 멀티 클라우드로 넘어가는 걸 직접 겪었는데요. 이게 비즈니스 연속성 확보나 벤더 종속(Vendor Lock-in) 방지 측면에서는 분명히 좋은 전략이에요. 근데 관리가 지옥이 되기 시작하거든요.

    그 해결책이 바로 Terraform 멀티 클라우드 IaC 자동화입니다. 오늘은 Terraform을 이용해서 AWS, GCP, Azure를 하나의 코드베이스로 관리하는 방법을 실제 경험 기반으로 풀어드릴게요.

    Terraform 멀티 클라우드 아키텍처 다이어그램 — AWS, GCP, Azure 동시 관리

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

    Terraform IaC가 뭔지 먼저 짚고 넘어가요

    IaC(Infrastructure as Code, 코드로 인프라를 정의하는 방식)는 이름 그대로 서버, 네트워크, 데이터베이스 같은 인프라를 코드 파일로 정의하고 버전 관리하는 방식입니다. 쉽게 말해, 클릭클릭으로 콘솔에서 만들던 걸 코드로 적어두는 거예요.

    Terraform은 HashiCorp가 만든 오픈소스 IaC 도구인데요. 가장 큰 장점이 프로바이더(Provider) 개념입니다. AWS용 프로바이더, GCP용 프로바이더, Azure용 프로바이더를 각각 선언하면, 하나의 Terraform 코드베이스에서 세 클라우드를 동시에 다룰 수 있어요. 이게 진짜 강력한 포인트예요.

    비교 항목 콘솔 수동 관리 Terraform IaC 자동화
    반복 작업 매번 클릭 필요 코드 한 번 작성 후 재사용
    버전 관리 불가능 (변경 이력 추적 어려움) Git으로 완전한 이력 관리
    멀티 클라우드 각 콘솔 별도 접근 필요 단일 코드베이스로 통합 관리
    팀 협업 “누가 뭘 만들었는지” 파악 어려움 코드 리뷰로 변경 사항 투명 공유
    재현성 동일 환경 재현 어려움 동일 코드로 동일 환경 재현 보장

    프로젝트 구조 잡기 — 이게 제일 중요합니다

    처음 멀티 클라우드 Terraform을 짤 때 가장 많이 실수하는 게 디렉토리 구조거든요. 저도 처음엔 파일 다 때려넣고 나중에 엉망이 돼서 처음부터 다시 짠 적이 있습니다. 클라우드별로 분리하고, 환경(dev/prod)별로도 분리하는 구조를 추천드려요.

    multi-cloud-infra/
    ├── modules/                    # 재사용 가능한 모듈 모음
    │   ├── aws/
    │   │   ├── vpc/
    │   │   │   ├── main.tf
    │   │   │   ├── variables.tf
    │   │   │   └── outputs.tf
    │   │   └── ec2/
    │   │       ├── main.tf
    │   │       ├── variables.tf
    │   │       └── outputs.tf
    │   ├── gcp/
    │   │   ├── vpc/
    │   │   └── compute/
    │   └── azure/
    │       ├── vnet/
    │       └── vm/
    ├── environments/
    │   ├── dev/
    │   │   ├── main.tf
    │   │   ├── variables.tf
    │   │   └── terraform.tfvars
    │   └── prod/
    │       ├── main.tf
    │       ├── variables.tf
    │       └── terraform.tfvars
    └── backend.tf                  # 원격 상태 저장소 설정
    

    💡 팁: modules 디렉토리에 클라우드별로 공통 모듈을 만들어두면, 나중에 환경을 추가할 때 모듈만 호출하면 되니까 진짜 편해요. 처음 구조 잡는 데 시간 좀 써도 나중에 열 배로 돌아옵니다.

    실전 구현 — Terraform 멀티 클라우드 프로바이더 설정부터

    자, 이제 실제로 코드를 작성해볼게요. 먼저 세 클라우드의 프로바이더를 한 파일에 선언하는 것부터 시작합니다.

    1단계: 프로바이더(Provider) 설정

    # providers.tf
    
    terraform {
      required_version = ">= 1.5.0"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.0"
        }
        google = {
          source  = "hashicorp/google"
          version = "~> 5.0"
        }
        azurerm = {
          source  = "hashicorp/azurerm"
          version = "~> 3.0"
        }
      }
    
      # 원격 백엔드 (Remote Backend) — 팀 협업 시 필수!
      backend "s3" {
        bucket         = "my-terraform-state-bucket"
        key            = "multi-cloud/terraform.tfstate"
        region         = "ap-northeast-2"
        encrypt        = true
        dynamodb_table = "terraform-state-lock"  # 상태 잠금(State Locking)용
      }
    }
    
    # AWS 프로바이더
    provider "aws" {
      region = var.aws_region
    
      default_tags {
        tags = {
          ManagedBy   = "Terraform"
          Environment = var.environment
          Project     = var.project_name
        }
      }
    }
    
    # GCP 프로바이더
    provider "google" {
      project = var.gcp_project_id
      region  = var.gcp_region
    }
    
    # Azure 프로바이더
    provider "azurerm" {
      features {}
      subscription_id = var.azure_subscription_id
    }
    

    여기서 중요한 포인트! 원격 백엔드(Remote Backend)는 팀으로 작업할 때 절대 빠지면 안 됩니다. 상태 파일(State File)을 로컬에 두면 팀원끼리 충돌이 생기거든요. 저도 초반에 이걸 몰라서 상태 파일 날려먹은 적이 있습니다.

    2단계: 변수 파일 설정

    # variables.tf
    
    variable "environment" {
      description = "배포 환경 (dev, staging, prod)"
      type        = string
      default     = "dev"
    }
    
    variable "project_name" {
      description = "프로젝트 이름"
      type        = string
    }
    
    # AWS 관련 변수
    variable "aws_region" {
      description = "AWS 리전"
      type        = string
      default     = "ap-northeast-2"  # 서울 리전
    }
    
    # GCP 관련 변수
    variable "gcp_project_id" {
      description = "GCP 프로젝트 ID"
      type        = string
    }
    
    variable "gcp_region" {
      description = "GCP 리전"
      type        = string
      default     = "asia-northeast3"  # 서울 리전
    }
    
    # Azure 관련 변수
    variable "azure_subscription_id" {
      description = "Azure 구독 ID"
      type        = string
      sensitive   = true  # 민감 정보 마스킹
    }
    
    variable "azure_location" {
      description = "Azure 지역"
      type        = string
      default     = "Korea Central"
    }
    
    Terraform 멀티 클라우드 프로바이더 설정 코드 — AWS GCP Azure 동시 구성

    ▲ 세 클라우드의 프로바이더를 하나의 코드베이스에서 관리하는 실제 구성 예시

    3단계: AWS VPC 모듈 작성

    # modules/aws/vpc/main.tf
    
    resource "aws_vpc" "main" {
      cidr_block           = var.vpc_cidr
      enable_dns_hostnames = true
      enable_dns_support   = true
    
      tags = {
        Name = "${var.project_name}-${var.environment}-vpc"
      }
    }
    
    resource "aws_subnet" "public" {
      count             = length(var.public_subnet_cidrs)
      vpc_id            = aws_vpc.main.id
      cidr_block        = var.public_subnet_cidrs[count.index]
      availability_zone = var.availability_zones[count.index]
    
      map_public_ip_on_launch = true
    
      tags = {
        Name = "${var.project_name}-public-subnet-${count.index + 1}"
        Type = "Public"
      }
    }
    
    resource "aws_internet_gateway" "main" {
      vpc_id = aws_vpc.main.id
    
      tags = {
        Name = "${var.project_name}-igw"
      }
    }
    

    4단계: GCP VPC 네트워크 모듈

    # modules/gcp/vpc/main.tf
    
    resource "google_compute_network" "main" {
      name                    = "${var.project_name}-${var.environment}-vpc"
      auto_create_subnetworks = false  # 커스텀 서브넷 사용
      project                 = var.project_id
    }
    
    resource "google_compute_subnetwork" "main" {
      name          = "${var.project_name}-subnet"
      ip_cidr_range = var.subnet_cidr
      region        = var.region
      network       = google_compute_network.main.id
      project       = var.project_id
    
      # Private Google Access — GCP 내부 서비스 접근용
      private_ip_google_access = true
    }
    
    # 방화벽 규칙 (Firewall Rule)
    resource "google_compute_firewall" "allow_internal" {
      name    = "${var.project_name}-allow-internal"
      network = google_compute_network.main.name
      project = var.project_id
    
      allow {
        protocol = "tcp"
        ports    = ["0-65535"]
      }
    
      allow {
        protocol = "udp"
        ports    = ["0-65535"]
      }
    
      allow {
        protocol = "icmp"
      }
    
      source_ranges = [var.subnet_cidr]
    }
    

    5단계: Azure VNet 모듈

    # modules/azure/vnet/main.tf
    
    resource "azurerm_resource_group" "main" {
      name     = "${var.project_name}-${var.environment}-rg"
      location = var.location
    
      tags = {
        ManagedBy   = "Terraform"
        Environment = var.environment
      }
    }
    
    resource "azurerm_virtual_network" "main" {
      name                = "${var.project_name}-vnet"
      address_space       = [var.vnet_cidr]
      location            = azurerm_resource_group.main.location
      resource_group_name = azurerm_resource_group.main.name
    }
    
    resource "azurerm_subnet" "main" {
      name                 = "${var.project_name}-subnet"
      resource_group_name  = azurerm_resource_group.main.name
      virtual_network_name = azurerm_virtual_network.main.name
      address_prefixes     = [var.subnet_cidr]
    }
    
    # NSG (Network Security Group, 네트워크 보안 그룹)
    resource "azurerm_network_security_group" "main" {
      name                = "${var.project_name}-nsg"
      location            = azurerm_resource_group.main.location
      resource_group_name = azurerm_resource_group.main.name
    }
    

    6단계: 환경별 메인 파일에서 모듈 호출

    # environments/dev/main.tf
    
    # AWS 모듈 호출
    module "aws_vpc" {
      source = "../../modules/aws/vpc"
    
      project_name         = var.project_name
      environment          = var.environment
      vpc_cidr             = "10.0.0.0/16"
      public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
      availability_zones   = ["ap-northeast-2a", "ap-northeast-2c"]
    }
    
    # GCP 모듈 호출
    module "gcp_vpc" {
      source = "../../modules/gcp/vpc"
    
      project_name = var.project_name
      environment  = var.environment
      project_id   = var.gcp_project_id
      region       = var.gcp_region
      subnet_cidr  = "10.1.0.0/16"
    }
    
    # Azure 모듈 호출
    module "azure_vnet" {
      source = "../../modules/azure/vnet"
    
      project_name = var.project_name
      environment  = var.environment
      location     = var.azure_location
      vnet_cidr    = "10.2.0.0/16"
      subnet_cidr  = "10.2.1.0/24"
    }
    

    ⚠️ 삽질 포인트 — 이것만 조심하세요

    제가 멀티 클라우드 Terraform 구축하면서 진짜 고생했던 부분들을 공유드릴게요. 여러분은 같은 실수 안 하셨으면 해서요.

    문제 1: 인증 정보 관리

    각 클라우드마다 인증 방식이 달라서 처음엔 환경변수를 어디다 어떻게 설정해야 하는지 헷갈렸습니다. 절대 코드에 크리덴셜(Credential, 인증 정보)을 하드코딩하지 마세요!

    # AWS 인증 — AWS CLI 프로파일 사용 권장
    export AWS_PROFILE=my-profile
    # 또는 환경변수로
    export AWS_ACCESS_KEY_ID="your-access-key"
    export AWS_SECRET_ACCESS_KEY="your-secret-key"
    
    # GCP 인증 — 서비스 계정 키 파일 사용
    export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
    # 또는 gcloud CLI 인증
    gcloud auth application-default login
    
    # Azure 인증 — Service Principal 사용
    export ARM_CLIENT_ID="your-client-id"
    export ARM_CLIENT_SECRET="your-client-secret"
    export ARM_TENANT_ID="your-tenant-id"
    export ARM_SUBSCRIPTION_ID="your-subscription-id"
    

    💡 팁: CI/CD 파이프라인에서는 각 클라우드의 OIDC(OpenID Connect) 방식 인증을 사용하면 시크릿 관리가 훨씬 깔끔해집니다. GitHub Actions랑 연동하면 특히 좋아요.

    문제 2: 상태 파일(State File) 충돌

    팀원이 동시에 terraform apply 돌리면 상태 파일이 꼬입니다. 이게 진짜 무서운 상황이에요. DynamoDB 테이블로 상태 잠금(State Locking)을 반드시 설정하세요.

    # 상태 잠금용 DynamoDB 테이블 생성
    resource "aws_dynamodb_table" "terraform_state_lock" {
      name           = "terraform-state-lock"
      billing_mode   = "PAY_PER_REQUEST"
      hash_key       = "LockID"
    
      attribute {
        name = "LockID"
        type = "S"
      }
    
      tags = {
        Name = "Terraform State Lock Table"
      }
    }
    

    문제 3: 프로바이더 버전 충돌

    이거 진짜 골치 아팠는데요. required_providers에 버전 범위를 명확히 지정하지 않으면 팀원마다 다른 버전이 설치돼서 동작이 달라지는 일이 생겨요. 반드시 .terraform.lock.hcl 파일을 Git에 커밋하세요. 이게 npm의 package-lock.json 같은 역할을 합니다.

    # 프로바이더 초기화 및 잠금 파일 생성
    terraform init
    
    # 잠금 파일 확인
    cat .terraform.lock.hcl
    
    # 잠금 파일을 Git에 커밋
    git add .terraform.lock.hcl
    git commit -m "chore: update terraform provider lock file"
    

    배포 및 결과 검증

    드디어 실제 배포 단계입니다! Terraform의 기본 워크플로우는 init → plan → apply 순서로 진행됩니다.

    # 1. 초기화 (프로바이더 다운로드)
    terraform init
    
    # 2. 플랜 확인 — 실제로 뭐가 만들어질지 미리 보기
    terraform plan -var-file="terraform.tfvars" -out=tfplan
    
    # 3. 플랜 파일 내용 사람이 읽을 수 있게 출력
    terraform show -json tfplan | jq '.'
    
    # 4. 실제 적용!
    terraform apply tfplan
    
    # 5. 결과 확인
    terraform output
    
    # 6. 상태 파일에서 특정 리소스 확인
    terraform state list
    terraform state show module.aws_vpc.aws_vpc.main
    

    🎉 apply가 성공하면 이런 출력이 나옵니다:

    Apply complete! Resources: 15 added, 0 changed, 0 destroyed.
    
    Outputs:
    
    aws_vpc_id = "vpc-0a1b2c3d4e5f67890"
    aws_public_subnet_ids = [
      "subnet-0a1b2c3d4e5f67891",
      "subnet-0a1b2c3d4e5f67892",
    ]
    gcp_network_id = "projects/my-project/global/networks/myproject-dev-vpc"
    azure_vnet_id = "/subscriptions/.../resourceGroups/myproject-dev-rg/providers/Microsoft.Network/virtualNetworks/myproject-vnet"
    
    Terraform apply 성공 결과 화면 — 멀티 클라우드 리소스 동시 프로비저닝 완료

    ▲ terraform apply 성공 시 세 클라우드에 동시 프로비저닝된 결과 화면

    Terraform 멀티 클라우드 자동화 — 한 단계 더 나아가기

    여기까지 기본 구조를 잡았다면, 이제 실제 팀 환경에서 쓸 수 있는 수준으로 올려야죠. 제가 실제로 도입해서 효과 본 것들을 공유드릴게요.

    Terragrunt로 반복 코드 줄이기

    Terragrunt는 Terraform의 래퍼(Wrapper) 도구인데요. 환경별로 반복되는 backend 설정이나 공통 변수를 DRY(Don’t Repeat Yourself, 반복하지 않기) 원칙에 맞게 관리할 수 있게 해줍니다. 규모가 커지면 진짜 필요해져요.

    CI/CD 파이프라인 연동

    GitHub Actions나 GitLab CI와 연동해서 PR(Pull Request) 올릴 때 자동으로 terraform plan 결과를 코멘트로 달아주는 워크플로우를 구성하면, 코드 리뷰 단계에서 인프라 변경 사항을 팀 전체가 확인할 수 있어요. 이거 도입하고 나서 팀 내 사고가 확 줄었습니다.

    # .github/workflows/terraform.yml
    name: Terraform CI/CD
    
    on:
      pull_request:
        branches: [main]
      push:
        branches: [main]
    
    jobs:
      terraform:
        name: Terraform Plan & Apply
        runs-on: ubuntu-latest
        
        permissions:
          id-token: write   # OIDC 인증용
          contents: read
          pull-requests: write
    
        steps:
          - name: Checkout
            uses: actions/checkout@v4
    
          - name: Setup Terraform
            uses: hashicorp/setup-terraform@v3
            with:
              terraform_version: "1.6.0"
    
          - name: Configure AWS Credentials (OIDC)
            uses: aws-actions/configure-aws-credentials@v4
            with:
              role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
              aws-region: ap-northeast-2
    
          - name: Terraform Init
            run: terraform init
            working-directory: environments/dev
    
          - name: Terraform Plan
            id: plan
            run: terraform plan -no-color
            working-directory: environments/dev
    
          - name: Comment PR with Plan
            uses: actions/github-script@v7
            if: github.event_name == 'pull_request'
            with:
              script: |
                const output = `#### Terraform Plan 결과 🗺️
                \`\`\`\n${{ steps.plan.outputs.stdout }}\n\`\`\`
                `;
                github.rest.issues.createComment({
                  issue_number: context.issue.number,
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  body: output
                })
    
          - name: Terraform Apply
            if: github.ref == 'refs/heads/main'
            run: terraform apply -auto-approve
            working-directory: environments/dev
    
    Terraform 멀티 클라우드 IaC 자동화 베스트 프랙티스 — 코드에서 배포까지 워크플로우 요약

    ▲ Terraform 멀티 클라우드 IaC 자동화 베스트 프랙티스 요약 — 코드부터 배포까지의 전체 워크플로우

    자주 묻는 질문 (FAQ)

    Q. Terraform Cloud를 써야 하나요, 아니면 자체 백엔드로 충분한가요?

    소규모 팀이라면 S3 + DynamoDB 조합의 자체 백엔드로 충분합니다. 팀이 커지거나 RBAC(역할 기반 접근 제어), 정책 관리가 필요해지면 HCP Terraform(구 Terraform Cloud) 유료 플랜을 고려해볼 만해요.

    Q. 각 클라우드 인증 정보를 어떻게 안전하게 관리하나요?

    CI/CD 환경에서는 각 클라우드의 OIDC 연동을 추천드립니다. 로컬 개발 환경에서는 각 클라우드 CLI 도구의 프로파일 기능을 활용하고, 절대 코드나 tfvars 파일에 시크릿을 하드코딩하지 마세요.

    Q. 멀티 클라우드에서 네트워크를 연결하려면 어떻게 하나요?

    VPN Gateway나 클라우드 간 피어링 서비스를 사용해야 하는데, 이 부분은 별도 글에서 자세히 다룰 예정이에요. 각 클라우드의 VPN 리소스도 Terraform으로 코드화할 수 있습니다.

    마무리 — Terraform 멀티 클라우드, 이제 시작해보세요

    처음 멀티 클라우드 Terraform 구조를 잡을 때는 진짜 막막했는데, 이제 돌아보면 이게 없던 시절로 돌아가기 싫을 만큼 편해졌습니다. 코드로 모든 인프라가 관리되니까 감사 추적(Audit Trail)도 되고, 실수로 뭔가 지워도 코드로 복구할 수 있고, 팀 온보딩도 훨씬 수월해졌어요.

    오늘 다룬 내용을 정리하면:

    • ✅ 프로젝트 구조: 클라우드별, 환경별 디렉토리 분리
    • ✅ 프로바이더 설정: AWS, GCP, Azure를 단일 코드베이스에서 선언
    • ✅ 모듈화: 재사용 가능한 모듈로 반복 코드 제거
    • ✅ 원격 백엔드: 상태 파일 중앙화 및 잠금 설정
    • ✅ CI/CD 연동: GitHub Actions로 자동화 파이프라인 구성

    다음 글에서는 Terraform 모듈 레지스트리 활용법과 실제 프로덕션 환경에서 쓰는 보안 설정들을 다뤄볼 예정이에요. 이전 글에서 Kubernetes 클러스터 구성을 다뤘으니 함께 참고하시면 더 좋을 것 같습니다.

    궁금한 점이나 다른 삽질 경험 있으시면 댓글로 공유해주세요. 같이 고민해봐요! 🎉

  • [k8s] ArgoCD 멀티 클러스터 GitOps 배포 및 관리 전략

    [k8s] ArgoCD 멀티 클러스터 GitOps 배포 및 관리 전략

    클러스터가 하나일 때는 괜찮았는데…

    처음 쿠버네티스를 도입했을 때 저도 클러스터 하나로 시작했어요. 개발(dev), 스테이징(staging), 프로덕션(production) 환경을 네임스페이스(Namespace)로 분리해서 쓰는 방식이었는데, 솔직히 그때는 그게 맞다고 생각했거든요. 근데 조직이 커지고 팀이 늘어나면서 문제가 하나씩 터지기 시작했습니다.

    “개발팀이 실수로 프로덕션 네임스페이스에 배포했어요.” 이 한 마디에 심장이 철렁 내려앉은 적 있으신가요? 저는 있습니다 ㅎㅎ. 결국 클러스터를 분리하기로 결정했고, 그때부터 ArgoCD 멀티 클러스터 전략을 본격적으로 파고들었습니다.

    이 글에서는 ArgoCD를 허브(Hub) 클러스터에 설치하고, 여러 개의 쿠버네티스 클러스터를 GitOps 방식으로 배포 및 관리하는 전략을 단계별로 정리해 드리겠습니다. 저처럼 삽질하지 않도록요.

    ArgoCD 멀티 클러스터 Hub-and-Spoke 아키텍처 다이어그램 - 허브 클러스터에서 dev, staging, prod 클러스터로 GitOps 배포 흐름

    ▲ ArgoCD 허브 클러스터가 여러 대상 클러스터(dev, staging, prod)를 관리하는 전체 아키텍처 구조. Hub-and-Spoke 패턴의 핵심입니다.

    ArgoCD 멀티 클러스터란? 개념부터 잡고 가요

    GitOps가 뭔지 먼저 짚고 가면

    GitOps는 쉽게 말해 “Git 저장소가 인프라와 애플리케이션의 단일 진실 공급원(Single Source of Truth)이 되는 운영 방식”이에요. 배포하고 싶으면 kubectl로 직접 명령을 날리는 게 아니라, Git에 커밋하면 자동으로 클러스터에 반영되는 방식이죠.

    ArgoCD는 이 GitOps를 쿠버네티스 위에서 구현해주는 대표적인 오픈소스 도구입니다. Git 저장소를 지속적으로 감시하다가 변경이 감지되면 클러스터에 자동으로 동기화(Sync)해줘요.

    멀티 클러스터 관리, 왜 필요한가요?

    네임스페이스 분리 방식의 가장 큰 문제는 폭발 반경(Blast Radius)이 너무 넓다는 거예요. 클러스터 레벨의 장애가 모든 환경에 영향을 주고, 보안 격리도 완벽하지 않습니다. 반면 클러스터를 분리하면:

    • 환경 간 완전한 격리 — 개발 배포 실수가 프로덕션에 영향 없음
    • 클러스터별 독립적인 리소스 할당 및 스케일링
    • 규정 준수(Compliance) 요구사항 충족 용이
    • 팀별 독립적인 업그레이드 주기 관리

    그런데 클러스터가 여러 개가 되면 관리 포인트도 여러 개가 되잖아요. 각 클러스터에 ArgoCD를 따로 설치하는 방법도 있지만, 저는 Hub-and-Spoke 패턴을 선호합니다. 허브 클러스터 하나에 ArgoCD를 설치하고, 나머지 클러스터들을 원격으로 관리하는 방식이에요.

    방식 장점 단점 추천 상황
    클러스터별 ArgoCD 설치 독립성 높음, 장애 격리 관리 포인트 분산, 일관성 유지 어려움 팀/조직이 완전히 분리된 경우
    Hub-and-Spoke (중앙화) 단일 관리 포인트, 일관된 정책 적용 허브 클러스터 장애 시 배포 불가 중앙 플랫폼팀이 관리하는 경우

    ArgoCD 설치 및 멀티 클러스터 등록 — 실전으로 들어갑니다

    1단계: 허브 클러스터에 ArgoCD 설치

    저는 허브 클러스터로 별도의 관리 전용 클러스터를 사용합니다. 프로덕션 워크로드가 없는 클러스터에 ArgoCD를 올리는 게 안전하더라고요.

    # ArgoCD 네임스페이스 생성
    kubectl create namespace argocd
    
    # ArgoCD 공식 매니페스트로 설치
    kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
    
    # 설치 확인
    kubectl get pods -n argocd
    

    설치가 완료되면 argocd-server 파드가 Running 상태가 되는데, 처음엔 이미지 풀(Image Pull) 때문에 좀 기다려야 할 수도 있어요. 저도 처음에 “왜 안 되지?” 하고 5분 기다렸더니 그냥 올라왔습니다 ㅎㅎ.

    # 초기 admin 비밀번호 확인
    kubectl -n argocd get secret argocd-initial-admin-secret \
      -o jsonpath="{.data.password}" | base64 -d
    
    # ArgoCD CLI 설치 (macOS 기준)
    brew install argocd
    
    # ArgoCD 서버 포트포워딩 (로컬 접근용)
    kubectl port-forward svc/argocd-server -n argocd 8080:443
    
    # CLI 로그인
    argocd login localhost:8080 --username admin --password <위에서_확인한_비밀번호> --insecure
    

    2단계: 대상 클러스터(Spoke) 등록

    이 부분이 멀티 클러스터 설정의 핵심이에요. ArgoCD CLI로 대상 클러스터를 등록하면, ArgoCD가 해당 클러스터에 argocd-manager라는 서비스 어카운트(Service Account)를 만들고 필요한 RBAC 권한을 자동으로 설정해줍니다.

    # 현재 kubeconfig에 등록된 컨텍스트 확인
    kubectl config get-contexts
    
    # 예시 출력:
    # CURRENT   NAME            CLUSTER         AUTHINFO
    # *         hub-cluster     hub-cluster     hub-admin
    #           dev-cluster     dev-cluster     dev-admin
    #           staging-cluster staging-cluster staging-admin
    #           prod-cluster    prod-cluster    prod-admin
    
    # 개발 클러스터 등록
    argocd cluster add dev-cluster --name dev
    
    # 스테이징 클러스터 등록
    argocd cluster add staging-cluster --name staging
    
    # 프로덕션 클러스터 등록
    argocd cluster add prod-cluster --name prod
    
    # 등록된 클러스터 목록 확인
    argocd cluster list
    

    💡 팁: 클러스터 등록 시 --name 옵션으로 별칭을 지정하면 나중에 Application 설정에서 훨씬 읽기 편합니다. URL 대신 이름으로 참조할 수 있거든요.

    ArgoCD Settings Clusters 화면에서 dev, staging, prod 멀티 클러스터가 등록된 관리 UI 화면

    ▲ ArgoCD 웹 UI의 Settings > Clusters 화면. dev, staging, prod 클러스터가 각각 등록되어 연결 상태를 실시간으로 확인할 수 있습니다.

    Git 저장소 구조 설계 — 이게 진짜 중요합니다

    멀티 클러스터 GitOps에서 Git 저장소 구조를 어떻게 잡느냐가 나중에 관리 편의성을 크게 좌우해요. 제가 여러 방식을 시도해보고 정착한 구조를 공유합니다.

    모노레포(Monorepo) 방식 — 제가 선호하는 방식

    gitops-repo/
    ├── apps/                          # 애플리케이션 정의
    │   ├── base/                      # 공통 기본 설정 (Kustomize base)
    │   │   ├── my-app/
    │   │   │   ├── deployment.yaml
    │   │   │   ├── service.yaml
    │   │   │   └── kustomization.yaml
    │   └── overlays/                  # 환경별 오버레이
    │       ├── dev/
    │       │   └── my-app/
    │       │       ├── kustomization.yaml
    │       │       └── patch-replicas.yaml
    │       ├── staging/
    │       │   └── my-app/
    │       └── prod/
    │           └── my-app/
    ├── argocd/                        # ArgoCD 설정 자체도 Git으로 관리
    │   ├── projects/                  # ArgoCD Project 정의
    │   │   ├── dev-project.yaml
    │   │   ├── staging-project.yaml
    │   │   └── prod-project.yaml
    │   └── applications/              # ArgoCD Application 정의
    │       ├── dev/
    │       ├── staging/
    │       └── prod/
    └── clusters/                      # 클러스터 레벨 설정
        ├── dev/
        ├── staging/
        └── prod/
    

    이 구조의 핵심은 Kustomize(커스터마이즈)를 활용해서 base 설정을 공유하면서 환경별로 다른 값(레플리카 수, 리소스 제한, 이미지 태그 등)만 오버레이로 덮어쓰는 방식이에요.

    Kustomize 오버레이 예시

    # apps/base/my-app/deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: my-app
      template:
        metadata:
          labels:
            app: my-app
        spec:
          containers:
          - name: my-app
            image: my-registry/my-app:latest
            resources:
              requests:
                cpu: 100m
                memory: 128Mi
    
    # apps/overlays/prod/my-app/kustomization.yaml
    apiVersion: kustomize.config.k8s.io/v1beta1
    kind: Kustomization
    resources:
      - ../../../base/my-app
    patches:
      - path: patch-replicas.yaml
    images:
      - name: my-registry/my-app
        newTag: v1.2.3  # 프로덕션은 명시적 태그 사용
    
    # apps/overlays/prod/my-app/patch-replicas.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      replicas: 3  # 프로덕션은 3개로
    

    ArgoCD Application 및 AppProject 설정

    AppProject(앱 프로젝트)로 클러스터 접근 제어

    ArgoCD의 AppProject는 애플리케이션들을 논리적으로 그룹화하고, 어떤 Git 저장소에서 어떤 클러스터로 배포할 수 있는지 제한하는 역할을 합니다. 저는 이걸 환경별로 나눠서 씁니다.

    # argocd/projects/prod-project.yaml
    apiVersion: argoproj.io/v1alpha1
    kind: AppProject
    metadata:
      name: production
      namespace: argocd
    spec:
      description: Production environment project
      # 허용된 소스 저장소
      sourceRepos:
        - 'https://github.com/myorg/gitops-repo.git'
      # 배포 가능한 대상 클러스터와 네임스페이스
      destinations:
        - server: https://prod-cluster-api.example.com
          namespace: '*'
      # 클러스터 범위 리소스 생성 제한 (선택적)
      clusterResourceWhitelist:
        - group: ''
          kind: Namespace
      # RBAC 역할 정의
      roles:
        - name: prod-deployer
          description: Production deployment role
          policies:
            - p, proj:production:prod-deployer, applications, sync, production/*, allow
          groups:
            - myorg:platform-team
    

    Application 정의 — 멀티 클러스터 배포의 핵심

    # argocd/applications/prod/my-app.yaml
    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: my-app-prod
      namespace: argocd
      # 앱 삭제 시 리소스도 함께 삭제되지 않도록 (중요!)
      finalizers:
        - resources-finalizer.argocd.argoproj.io
    spec:
      project: production
      source:
        repoURL: https://github.com/myorg/gitops-repo.git
        targetRevision: main
        path: apps/overlays/prod/my-app  # 프로덕션 오버레이 경로
      destination:
        # 등록한 클러스터 이름 또는 API 서버 URL
        server: https://prod-cluster-api.example.com
        namespace: my-app
      syncPolicy:
        automated:
          prune: true       # Git에서 삭제된 리소스는 클러스터에서도 삭제
          selfHeal: true    # 클러스터 상태가 Git과 다르면 자동 복구
        syncOptions:
          - CreateNamespace=true  # 네임스페이스 없으면 자동 생성
          - PrunePropagationPolicy=foreground
        retry:
          limit: 5
          backoff:
            duration: 5s
            factor: 2
            maxDuration: 3m
    

    ApplicationSet으로 여러 클러스터에 한 번에 배포

    근데 여기서 더 편한 방법이 있어요. ApplicationSet(애플리케이션셋)을 쓰면 여러 클러스터에 대한 Application을 템플릿 하나로 자동 생성할 수 있거든요. 처음 이걸 알았을 때 “이게 왜 이렇게 편하지?” 싶었습니다.

    # argocd/applicationsets/my-app-all-clusters.yaml
    apiVersion: argoproj.io/v1alpha1
    kind: ApplicationSet
    metadata:
      name: my-app-all-clusters
      namespace: argocd
    spec:
      generators:
        - list:
            elements:
              - cluster: dev
                url: https://dev-cluster-api.example.com
                env: dev
                revision: HEAD
              - cluster: staging
                url: https://staging-cluster-api.example.com
                env: staging
                revision: main
              - cluster: prod
                url: https://prod-cluster-api.example.com
                env: prod
                revision: main
      template:
        metadata:
          name: 'my-app-{{env}}'
        spec:
          project: '{{env}}'
          source:
            repoURL: https://github.com/myorg/gitops-repo.git
            targetRevision: '{{revision}}'
            path: 'apps/overlays/{{env}}/my-app'
          destination:
            server: '{{url}}'
            namespace: my-app
          syncPolicy:
            automated:
              prune: true
              selfHeal: true
            syncOptions:
              - CreateNamespace=true
    

    💡 팁: ApplicationSet의 generators에는 List 방식 외에도 클러스터 레이블 기반으로 자동 감지하는 Cluster Generator도 있어요. 클러스터가 많아질수록 이쪽이 훨씬 강력합니다.

    ⚠️ 실제로 겪은 트러블슈팅 — 이거 꼭 읽으세요

    문제 1: 클러스터 등록 후 연결 끊김 (Connection Refused)

    클러스터를 등록했는데 ArgoCD UI에서 계속 “Unknown” 상태가 뜨는 경우가 있었어요. 원인을 파고들어보니 허브 클러스터에서 대상 클러스터의 API 서버로 직접 네트워크 연결이 안 되는 거였습니다. VPC 피어링이나 방화벽 규칙을 확인해야 해요.

    # 허브 클러스터에서 대상 클러스터 API 서버 연결 테스트
    kubectl run curl-test --image=curlimages/curl --rm -it --restart=Never -- \
      curl -k https://prod-cluster-api.example.com/healthz
    

    문제 2: prune 옵션으로 인한 의도치 않은 리소스 삭제

    이건 진짜 아찔했던 경험인데요. automated.prune: true를 켜놨는데, 실수로 Git에서 파일을 지웠다가 프로덕션 Deployment가 통째로 삭제된 적이 있었어요. 다행히 빠르게 복구했지만…

    이후로 저는 프로덕션 환경에는 Sync Windows(동기화 창)를 설정해서 업무 시간 외에는 자동 동기화가 안 되도록 했습니다.

    # AppProject에 Sync Window 추가
    spec:
      syncWindows:
        - kind: allow
          schedule: '0 9 * * 1-5'  # 평일 오전 9시에만
          duration: 8h
          applications:
            - '*'
          manualSync: true  # 수동 동기화는 항상 허용
    

    문제 3: Helm 차트 버전 충돌

    여러 클러스터에 같은 Helm 차트를 다른 버전으로 배포하다 보면 values 파일 구조가 버전마다 달라서 오류가 나는 경우가 있어요. 저는 이걸 해결하기 위해 클러스터별 values 파일을 명확하게 분리해서 관리하고 있습니다.

    # Helm 소스 예시 - 클러스터별 values 파일 분리
    source:
      repoURL: https://charts.example.com
      chart: my-chart
      targetRevision: 1.2.3
      helm:
        valueFiles:
          - values.yaml
          - values-prod.yaml  # 환경별 values 파일
    
    ArgoCD Applications 대시보드에서 멀티 클러스터 배포된 애플리케이션들이 모두 Synced Healthy 상태로 표시된 결과 화면

    ▲ ArgoCD 웹 UI의 Applications 화면. dev, staging, prod 클러스터에 배포된 my-app들이 모두 Synced/Healthy 상태로 표시되는 모습입니다.

    ✅ 배포 검증 및 운영 팁

    헬스 체크와 동기화 상태 모니터링

    # 전체 Application 상태 확인
    argocd app list
    
    # 특정 앱 상세 상태 확인
    argocd app get my-app-prod
    
    # 수동으로 동기화 실행
    argocd app sync my-app-prod
    
    # 동기화 상태 및 히스토리 확인
    argocd app history my-app-prod
    
    # 롤백 (이전 버전으로)
    argocd app rollback my-app-prod 
    

    Notification(알림) 설정으로 배포 상태 파악

    ArgoCD에는 argocd-notifications라는 컴포넌트가 있어서 Slack, 이메일, PagerDuty 등으로 배포 상태를 알림 받을 수 있어요. 저는 Slack 채널에 연동해서 배포 성공/실패를 실시간으로 받고 있는데, 이거 없으면 이제 못 살 것 같습니다 ㅎㅎ.

    RBAC으로 팀별 접근 제어

    # argocd-rbac-cm ConfigMap 예시
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: argocd-rbac-cm
      namespace: argocd
    data:
      policy.csv: |
        # 개발팀: dev 프로젝트만 접근 가능
        p, role:dev-team, applications, *, dev/*, allow
        p, role:dev-team, applications, sync, dev/*, allow
        
        # 플랫폼팀: 전체 접근 가능
        p, role:platform-team, applications, *, */*, allow
        p, role:platform-team, clusters, *, *, allow
        
        g, myorg:dev-team, role:dev-team
        g, myorg:platform-team, role:platform-team
      policy.default: role:readonly
    

    전략 정리 — 이것만 기억하세요

    ArgoCD 멀티 클러스터 GitOps 전략 핵심 구성요소 요약 인포그래픽 - AppProject, ApplicationSet, Kustomize, RBAC 관계도

    ▲ ArgoCD 멀티 클러스터 GitOps 전략의 핵심 요소를 한눈에 정리한 요약 다이어그램. Git 저장소 구조, ArgoCD 컴포넌트, 클러스터 관계를 보여줍니다.

    구성 요소 역할 핵심 포인트
    AppProject 배포 범위 및 권한 제한 환경별로 분리, Sync Window 설정
    Application 개별 앱 배포 정의 destination.server로 대상 클러스터 지정
    ApplicationSet 다중 클러스터 배포 자동화 템플릿 하나로 여러 클러스터에 배포
    Kustomize Overlay 환경별 설정 분리 base 공유, 환경별 차이만 patch
    RBAC 팀별 접근 제어 AppProject role + argocd-rbac-cm

    마무리하며 — 그래서 뭐가 달라졌냐면

    ArgoCD 멀티 클러스터 구성을 제대로 잡고 나서 가장 크게 달라진 건, 배포에 대한 불안감이 사라졌다는 거예요. 예전엔 “내가 지금 어떤 클러스터에 붙어있지?”를 항상 확인해야 했는데, 이제는 Git에 PR 올리고 머지하면 끝이거든요.

    물론 처음 설계할 때 Git 저장소 구조를 잘 잡는 게 제일 중요합니다. 나중에 구조를 바꾸려면 진짜 손이 많이 가거든요. 저도 한 번 갈아엎었습니다… ㅎㅎ 이 글을 보시는 분들은 처음부터 overlays 구조로 시작하시길 강력 추천합니다.

    다음 글에서는 ArgoCD Image Updater를 활용해서 컨테이너 이미지 태그 업데이트까지 완전 자동화하는 방법을 다뤄볼 예정이에요. CI 파이프라인에서 이미지 빌드되면 ArgoCD가 자동으로 Git 커밋하고 배포까지 이어지는 풀 사이클인데, 이거 진짜 편합니다.

    혹시 멀티 클러스터 구성하면서 막히는 부분 있으시면 댓글로 남겨주세요. 제가 겪어본 삽질이라면 같이 해결해 드릴 수 있을 것 같습니다 🎉

    자주 묻는 질문 (FAQ)

    Q. ArgoCD 자체가 설치된 허브 클러스터가 다운되면 어떻게 되나요?

    A. 허브 클러스터가 다운되면 새로운 배포는 불가능하지만, 이미 배포된 애플리케이션들은 각 클러스터에서 계속 정상 동작합니다. 이 때문에 허브 클러스터는 고가용성(HA) 구성으로 운영하는 것을 권장합니다.

    Q. ApplicationSet과 Application을 언제 선택해야 하나요?

    A. 동일한 앱을 여러 클러스터에 배포해야 한다면 ApplicationSet이 훨씬 효율적입니다. 클러스터마다 배포 설정이 크게 다르거나 배포 시점을 완전히 독립적으로 관리해야 한다면 개별 Application을 사용하는 게 맞습니다.

    Q. Helm과 Kustomize 중 어떤 걸 쓰는 게 좋나요?

    A. 서드파티 차트를 그대로 쓸 때는 Helm, 자체 매니페스트를 환경별로 관리할 때는 Kustomize가 더 직관적입니다. 둘을 혼합해서 쓰는 것도 가능하고, 저는 실제로 두 방식을 같이 씁니다.

  • [K8s] 쿠버네티스 Ingress Controller 비교: Nginx, Traefik, Gateway API 선택 가이드

    [K8s] 쿠버네티스 Ingress Controller 비교: Nginx, Traefik, Gateway API 선택 가이드

    쿠버네티스 Ingress Controller, 뭘 써야 할지 모르겠다면?

    쿠버네티스(Kubernetes)를 처음 도입할 때 가장 많이 막히는 부분 중 하나가 바로 Ingress Controller(인그레스 컨트롤러) 선택이에요. 저도 처음엔 “Nginx 쓰면 되는 거 아닌가?” 하고 무심코 설치했다가, 나중에 Traefik이 더 편하다는 걸 알고 갈아엎은 경험이 있거든요. 그리고 최근엔 Gateway API라는 새로운 표준까지 등장해서 선택지가 더 복잡해졌습니다.

    이 글에서는 쿠버네티스 클러스터 운영에서 가장 중요한 선택 중 하나인 Ingress Controller의 세 가지 주요 선택지를 실제 운영 경험을 바탕으로 비교해 드릴게요. 홈랩부터 프로덕션까지 다양한 환경에서 직접 써본 입장에서 솔직하게 이야기해 보겠습니다.

    쿠버네티스 Ingress Controller 전체 아키텍처 — 외부 트래픽이 인그레스 컨트롤러를 통해 내부 서비스로 라우팅되는 구조

    ▲ 쿠버네티스 클러스터에서 외부 트래픽이 Ingress Controller를 거쳐 내부 서비스로 전달되는 전체 흐름 다이어그램

    Ingress Controller가 뭔지부터 짚고 가자

    쉽게 말해서, Ingress(인그레스)는 외부에서 쿠버네티스 클러스터 안으로 들어오는 HTTP/HTTPS 트래픽을 어떻게 라우팅할지 정의하는 규칙이에요. 근데 이 규칙만 있다고 실제로 동작하는 게 아니에요. 규칙을 실제로 실행해 주는 주체가 바로 Ingress Controller(인그레스 컨트롤러)입니다.

    비유하자면, Ingress 리소스는 “1번 도메인은 A 서비스로, 2번 도메인은 B 서비스로 보내라”는 지시서고, Ingress Controller는 그 지시서를 읽고 실제로 트래픽을 처리하는 리버스 프록시(Reverse Proxy) 서버라고 보시면 됩니다.

    쿠버네티스 자체에는 Ingress Controller가 기본 내장되어 있지 않아요. 그래서 우리가 직접 선택해서 설치해야 합니다. 그리고 이 선택이 생각보다 중요한 결정이거든요.

    왜 선택이 중요한가요?

    • 운영 중에 교체하면 서비스 중단이 발생할 수 있어요
    • 각 컨트롤러마다 지원하는 기능과 설정 방식이 달라요
    • 팀의 기술 스택과 러닝 커브도 고려해야 합니다
    • 트래픽 규모와 패턴에 따라 성능 특성이 다르게 나타나요

    세 가지 선택지 한눈에 비교

    본격적인 비교 전에 전체 그림을 먼저 보시죠.

    항목 Nginx Ingress Controller Traefik Gateway API
    기반 기술 Nginx (리버스 프록시) Go 기반 자체 구현 표준 API (구현체 별도)
    설정 방식 Ingress + Annotation Ingress + CRD Gateway/HTTPRoute CRD
    자동 설정 갱신 지원 (일부 재시작 필요) ✅ 완전 동적 구현체에 따라 다름
    대시보드 별도 설치 필요 ✅ 내장 없음 (구현체 의존)
    학습 난이도 낮음 (Nginx 경험자) 중간 높음
    성숙도 매우 높음 높음 성장 중 (GA 달성)
    멀티 팀 지원 제한적 중간 ✅ 설계 목표
    커뮤니티/레퍼런스 매우 풍부 풍부 성장 중

    Nginx Ingress Controller — 검증된 베테랑

    솔직히 말씀드리면, 저도 처음 쿠버네티스 클러스터 구성할 때 고민 없이 Nginx Ingress를 선택했어요. 이유는 단순했습니다. Nginx를 이미 10년 넘게 써왔으니까요.

    Nginx Ingress Controller는 CNCF(Cloud Native Computing Foundation) 산하 프로젝트인 kubernetes/ingress-nginx와, Nginx Inc.에서 만든 nginxinc/kubernetes-ingress 두 가지가 있어요. 헷갈리시는 분들이 많은데, 커뮤니티에서 주로 쓰는 건 전자인 ingress-nginx입니다.

    Helm으로 설치하기

    # Nginx Ingress Controller Helm 설치
    helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
    helm repo update
    
    helm install ingress-nginx ingress-nginx/ingress-nginx \
      --namespace ingress-nginx \
      --create-namespace \
      --set controller.replicaCount=2

    기본 Ingress 리소스 예시

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: my-app-ingress
      annotations:
        nginx.ingress.kubernetes.io/rewrite-target: /
        nginx.ingress.kubernetes.io/ssl-redirect: "true"
    spec:
      ingressClassName: nginx
      tls:
      - hosts:
        - myapp.example.com
        secretName: myapp-tls
      rules:
      - host: myapp.example.com
        http:
          paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app-service
                port:
                  number: 80

    Nginx Ingress의 장단점

    ✅ 장점

    • 레퍼런스가 압도적으로 많아요. 구글에서 뭐든 찾을 수 있습니다
    • Nginx에 익숙한 분들은 Annotation으로 세밀한 튜닝이 가능해요
    • 안정성이 검증된 오랜 역사를 가지고 있습니다
    • 대규모 트래픽 처리에 강점이 있어요

    ⚠️ 단점

    • 설정 변경 시 nginx.conf를 reload해야 해서, 대규모 클러스터에선 부담이 될 수 있어요
    • Annotation이 너무 많아서 처음엔 뭘 써야 할지 헷갈립니다
    • 내장 대시보드가 없어서 별도로 Prometheus + Grafana를 구성해야 해요

    Traefik — 쿠버네티스 네이티브의 매력

    Traefik을 처음 접한 건 홈랩에서 Docker Compose로 운영하다가였어요. 당시에 “이거 레이블(Label) 하나 달면 자동으로 라우팅이 된다고?” 하면서 신기해했던 기억이 납니다. 쿠버네티스에서도 비슷한 경험을 할 수 있어요.

    Traefik의 핵심 철학은 동적 설정(Dynamic Configuration)이에요. 새로운 서비스가 배포되면 자동으로 감지해서 라우팅 규칙을 업데이트합니다. Nginx처럼 reload가 없어요.

    Traefik Helm 설치

    # Traefik Helm 설치
    helm repo add traefik https://helm.traefik.io/traefik
    helm repo update
    
    helm install traefik traefik/traefik \
      --namespace traefik \
      --create-namespace \
      --set dashboard.enabled=true \
      --set ingressRoute.dashboard.enabled=true

    Traefik IngressRoute CRD 예시

    Traefik은 표준 Ingress 리소스도 지원하지만, 자체 CRD인 IngressRoute를 쓰면 훨씬 풍부한 기능을 쓸 수 있어요.

    apiVersion: traefik.io/v1alpha1
    kind: IngressRoute
    metadata:
      name: my-app-ingressroute
      namespace: default
    spec:
      entryPoints:
        - websecure
      routes:
        - match: Host(`myapp.example.com`)
          kind: Rule
          services:
            - name: my-app-service
              port: 80
          middlewares:
            - name: my-ratelimit
      tls:
        certResolver: letsencrypt

    Middleware로 Rate Limiting 적용

    apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
      name: my-ratelimit
    spec:
      rateLimit:
        average: 100
        burst: 50

    💡 Traefik의 진짜 강점은 Let’s Encrypt 인증서 자동 발급이 내장되어 있다는 거예요. cert-manager를 별도로 설치하지 않아도 TLS 인증서를 자동으로 관리해 줍니다. 홈랩에서 이거 하나 때문에 Traefik으로 갈아탔을 정도로 편하더라고요.

    Traefik 내장 대시보드 화면 — 라우터, 서비스, 미들웨어 현황을 한눈에 확인하는 모니터링 인터페이스

    ▲ Traefik 내장 대시보드에서 라우터, 서비스, 미들웨어 현황을 한눈에 확인할 수 있는 화면

    Traefik의 장단점

    ✅ 장점

    • 동적 설정 갱신 — 서비스 변경 시 reload 없이 즉시 반영돼요
    • 내장 대시보드로 라우팅 현황을 바로 확인할 수 있어요
    • Let’s Encrypt 자동 인증서 발급/갱신 내장
    • Middleware로 인증, Rate Limiting, 헤더 조작 등을 깔끔하게 구성 가능

    ⚠️ 단점

    • CRD 방식이 Nginx Annotation과 달라서 러닝 커브가 있어요
    • Nginx에 비해 레퍼런스가 적습니다 (그래도 요즘은 많이 늘었어요)
    • 초대형 클러스터에서의 성능 검증 사례가 Nginx보다 적은 편이에요

    Gateway API — 쿠버네티스 네트워크의 미래

    처음 Gateway API 문서를 읽었을 때 솔직히 “이게 뭐가 다른 건데?” 싶었어요. 근데 알고 보니 기존 Ingress 리소스의 한계를 근본적으로 해결하려는 시도더라고요.

    Gateway API는 SIG Network(쿠버네티스 네트워킹 특별관심그룹)에서 만든 공식 표준이에요. 2023년에 v1.0으로 GA(Generally Available, 정식 출시)를 달성했습니다. Ingress 리소스를 대체하려는 게 아니라, 더 표현력 있고 역할 기반으로 분리된 네트워크 설정을 가능하게 하는 게 목표예요.

    Gateway API의 핵심 개념

    기존 Ingress는 개발자와 인프라 관리자가 같은 리소스를 수정해야 했어요. Gateway API는 역할을 명확히 분리합니다.

    • GatewayClass: 인프라 제공자가 정의 (“이 구현체를 쓸게”)
    • Gateway: 클러스터 운영자가 정의 (“이 포트, 이 프로토콜로 받을게”)
    • HTTPRoute: 개발자가 정의 (“이 경로는 내 서비스로 보내줘”)

    Gateway API 설치 (CRD 먼저)

    # Gateway API CRD 설치
    kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml
    
    # 구현체 예시: Nginx Gateway Fabric
    helm install ngf oci://ghcr.io/nginxinc/charts/nginx-gateway-fabric \
      --namespace nginx-gateway \
      --create-namespace

    Gateway와 HTTPRoute 설정 예시

    # Gateway 리소스 (운영자가 관리)
    apiVersion: gateway.networking.k8s.io/v1
    kind: Gateway
    metadata:
      name: main-gateway
      namespace: infra
    spec:
      gatewayClassName: nginx
      listeners:
      - name: https
        port: 443
        protocol: HTTPS
        tls:
          mode: Terminate
          certificateRefs:
          - name: main-tls-cert
        allowedRoutes:
          namespaces:
            from: Selector
            selector:
              matchLabels:
                gateway-access: allowed
    # HTTPRoute 리소스 (개발자가 관리)
    apiVersion: gateway.networking.k8s.io/v1
    kind: HTTPRoute
    metadata:
      name: my-app-route
      namespace: my-app-ns
    spec:
      parentRefs:
      - name: main-gateway
        namespace: infra
      hostnames:
      - myapp.example.com
      rules:
      - matches:
        - path:
            type: PathPrefix
            value: /api
        backendRefs:
        - name: api-service
          port: 8080
      - matches:
        - path:
            type: PathPrefix
            value: /
        backendRefs:
        - name: frontend-service
          port: 3000

    보이시나요? Gateway는 인프라팀이 관리하고, HTTPRoute는 각 개발팀이 자기 네임스페이스에서 독립적으로 관리할 수 있어요. 멀티 팀 환경에서 진짜 강력한 구조입니다.

    Gateway API의 장단점

    ✅ 장점

    • 역할 기반 분리로 멀티 팀 환경에 최적화되어 있어요
    • 표현력이 풍부해서 복잡한 라우팅 규칙도 명확하게 표현 가능
    • 쿠버네티스 공식 표준이라 장기적으로 생태계 지원이 확대될 거예요
    • 구현체를 교체해도 HTTPRoute 설정은 그대로 재사용 가능 (이식성)

    ⚠️ 단점

    • 개념이 많아서 처음 배우기가 쉽지 않아요. 저도 꽤 헷갈렸습니다
    • 구현체마다 지원하는 기능이 달라서 사전 확인이 필요해요
    • 소규모 팀이나 단순한 환경에선 오버엔지니어링이 될 수 있어요
    • 레퍼런스와 사례가 아직 Nginx/Traefik에 비해 적습니다

    ⚠️ 실제 트러블슈팅 경험담

    각 컨트롤러를 쓰면서 제가 직접 겪은 문제들을 공유할게요.

    Nginx Ingress — 413 Request Entity Too Large

    파일 업로드 기능이 있는 서비스에서 자꾸 413 에러가 났었어요. Nginx 기본 설정의 client_max_body_size 제한 때문이었는데, Annotation으로 해결했습니다.

    metadata:
      annotations:
        nginx.ingress.kubernetes.io/proxy-body-size: "50m"

    Traefik — 인증서가 갱신 안 되는 문제

    Let’s Encrypt 인증서를 Traefik이 자동으로 갱신해 주는 게 편한데, 어느 날 갑자기 인증서 만료 알림이 왔어요. 알고 보니 acme.json 파일이 저장된 PersistentVolume이 Pod 재시작 시 초기화되는 문제였습니다. acme.json은 반드시 영구 스토리지에 마운트해야 해요.

    # values.yaml에서 persistence 설정
    persistence:
      enabled: true
      storageClass: "longhorn"
      size: 128Mi

    Gateway API — 구현체 호환성 확인 필수

    HTTPRoute의 고급 기능(예: RequestRedirect, URLRewrite)을 사용했는데, 특정 구현체에서 지원하지 않아서 라우팅이 묵묵히 실패하는 경우가 있었어요. 구현체의 Conformance Report(적합성 보고서)를 미리 확인하는 게 중요합니다. Gateway API 공식 문서에서 구현체별 지원 기능표를 제공하고 있어요.

    쿠버네티스 Ingress Controller 모니터링 대시보드 — Prometheus와 Grafana를 활용한 요청 수, 에러율, 레이턴시 시각화

    ▲ Prometheus와 Grafana를 활용한 Ingress Controller 모니터링 대시보드 — 요청 수, 에러율, 레이턴시를 실시간으로 확인

    상황별 선택 가이드

    “그래서 뭘 써야 하나요?” 라고 물어보신다면, 상황에 따라 다르다고 답할 수밖에 없어요. 하지만 경험상 이런 기준으로 선택하시면 크게 후회는 없더라고요.

    Nginx Ingress Controller를 선택하세요, 만약…

    • 팀에 Nginx 경험자가 있고, 레퍼런스가 많은 안정적인 선택을 원할 때
    • 대규모 트래픽을 처리해야 하고 검증된 성능이 필요할 때
    • 기존 Nginx 설정을 쿠버네티스로 마이그레이션할 때
    • 복잡한 Nginx 설정(custom snippet 등)이 필요할 때

    Traefik을 선택하세요, 만약…

    • 홈랩이나 소규모 환경에서 편리하게 운영하고 싶을 때
    • Let’s Encrypt 자동 인증서 관리를 간단하게 하고 싶을 때
    • 내장 대시보드로 라우팅 현황을 빠르게 파악하고 싶을 때
    • 동적 환경에서 서비스 변경이 잦을 때

    Gateway API를 선택하세요, 만약…

    • 여러 팀이 각자의 라우팅 설정을 독립적으로 관리해야 할 때
    • 장기적으로 쿠버네티스 표준에 맞춰 가고 싶을 때
    • 복잡한 트래픽 분산(카나리 배포, A/B 테스트 등)이 필요할 때
    • 구현체 이식성을 확보하고 싶을 때
    쿠버네티스 Ingress Controller 비교 인포그래픽 — Nginx Ingress, Traefik, Gateway API 특징과 선택 기준 요약

    ▲ 환경과 요구사항에 따른 Ingress Controller 선택 가이드 요약 인포그래픽

    마무리 — 정답은 없지만, 기준은 있다

    13년 동안 인프라를 운영하면서 느낀 건, 기술 선택에 절대적인 정답은 없다는 거예요. 중요한 건 내 환경과 팀의 요구사항에 맞는 선택을 하는 겁니다.

    개인적으로는 이렇게 정리하고 싶어요.

    • 처음 시작하는 분들: Nginx Ingress로 시작하세요. 레퍼런스가 많아서 막힐 때 찾기 쉬워요
    • 홈랩이나 소규모 팀: Traefik을 강력 추천합니다. 편의성이 정말 좋아요
    • 엔터프라이즈/멀티 팀 환경: Gateway API를 진지하게 검토해 보세요. 미래 방향성이 여기 있거든요

    그리고 하나 더 — 저는 지금 홈랩에서 Traefik을 쓰고, 회사 프로덕션에서는 Nginx Ingress를 씁니다. 두 개 다 쓸 줄 알면 더 좋아요. 😄

    다음 글에서는 Traefik의 Middleware를 활용한 인증/인가(Authentication/Authorization) 설정을 더 자세히 다룰 예정이에요. cert-manager를 이용한 TLS 자동화 내용도 이전 글에서 다룬 적 있으니 참고해 보세요.

    자주 묻는 질문 (FAQ)

    Q. Nginx Ingress Controller와 Nginx Gateway Fabric은 다른 건가요?

    네, 다릅니다. ingress-nginx는 기존 Ingress API 기반이고, Nginx Gateway Fabric은 Gateway API 표준의 구현체예요. 용도와 설정 방식이 다릅니다.

    Q. 하나의 클러스터에 여러 Ingress Controller를 동시에 쓸 수 있나요?

    가능합니다. ingressClassName을 통해 어떤 컨트롤러를 사용할지 지정할 수 있어요. 다만 운영 복잡도가 올라가니 꼭 필요한 경우에만 권장합니다.

    Q. Gateway API는 Ingress를 완전히 대체하나요?

    장기적으로는 그 방향이지만, 현재는 공존하는 상태예요. 기존 Ingress 리소스가 deprecated되지는 않았고, Gateway API가 더 복잡한 요구사항을 위한 차세대 표준으로 자리잡고 있는 중입니다.