Terraform으로 Kubernetes YAML 파일을 코드로 관리하기

SeungHyuk Shin·2025년 10월 30일

들어가며

여러분의 팀은 몇 개의 환경(AS, HN, JP 등)에 동일한 애플리케이션을 배포하고 계신가요? 각 환경마다 거의 비슷하지만 약간씩 다른 YAML 파일을 복사-붙여넣기로 관리하고 계신가요?

저희 GOA 팀도 그랬습니다. POS Admin 백엔드 서비스를 AS, HN 등 여러 리전에 배포하면서, 수십 개의 YAML 파일을 수동으로 관리하는 것이 점점 부담이 되었습니다. 하나의 설정을 바꾸려면 모든 환경의 YAML을 일일이 수정해야 했고, 실수로 인한 배포 오류도 빈번했습니다.

이 글에서는 Terraform을 사용하여 Kubernetes 리소스를 코드로 관리하고, 멀티 환경 배포를 자동화한 경험을 공유합니다.

문제 상황

기존 방식의 한계

k8s/prod/
├── as-deployment.yaml
├── as-service.yaml
├── as-ingress.yaml
├── as-vip.yaml
├── hn-deployment.yaml
├── hn-service.yaml
├── hn-ingress.yaml
└── hn-vip.yaml
  • 중복 코드: 각 환경별로 거의 동일한 YAML 파일이 존재
  • 일관성 관리의 어려움: APM 설정, 리소스 제한 등 공통 설정 변경 시 모든 파일 수정 필요
  • 확장성 부족: 새로운 환경(예: JP) 추가 시 4개의 파일을 복사하고 수정해야 함
  • 휴먼 에러: 수동 작업으로 인한 오타나 누락 발생

해결 방안: Terraform + Template

Terraform의 templatefile 함수와 for_each 메타-인자를 활용하여 Infrastructure as Code를 구현했습니다.

핵심 아이디어

  1. 템플릿 분리: YAML을 .tftpl 템플릿 파일로 변환
  2. 변수 추상화: 환경별 차이점만 변수로 관리
  3. 공통 설정 집중화: locals.tf에 모든 환경에 적용되는 설정 정의
  4. 선언적 환경 정의: locals 블록에서 환경 목록을 맵으로 관리

구현 과정

1단계: 디렉토리 구조 설계

k8s/prod/terraform/
├── main.tf                          # 환경 정의 및 모듈 호출
├── modules/
│   └── app/
│       └── pos-admin/               # POS Admin 모듈
│           ├── variables.tf         # 입력 변수
│           ├── locals.tf            # 공통 설정
│           ├── main.tf              # 리소스 생성 로직
│           ├── deployment.yaml.tftpl
│           ├── service.yaml.tftpl
│           ├── ingress.yaml.tftpl
│           └── vip-service.yaml.tftpl
└── result/                          # 생성된 YAML 출력
    └── pos-admin/
        ├── as-deployment.yaml
        ├── as-service.yaml
        └── ...

인사이트: 처음에는 모든 것을 한 파일에 넣으려 했지만, 모듈을 분리하니 재사용성과 가독성이 크게 향상되었습니다.

2단계: 환경 정의 (main.tf)

locals {
  environments = {
    as = {
      node_group = "as-worker"
      replicas   = 5
    }
    hn = {
      node_group = "hn-worker"
      replicas   = 5
    }
  }
}

module "pos_admin_back" {
  for_each = local.environments

  source = "./modules/app/pos-admin"

  # Common
  app_name    = "pos-admin-${each.key}"
  app_image   = "idock.daumkakao.io/goa/store-api:admin-latest"
  replicas    = each.value.replicas
  node_selector = {
    "dkosv3.9rum.cc/node-group" = each.value.node_group
  }

  # Environment
  environment = each.key

  # Outputs
  output_deployment_yaml_path = "${path.module}/result/pos-admin/${each.key}-deployment.yaml"
  output_service_yaml_path    = "${path.module}/result/pos-admin/${each.key}-service.yaml"
  output_ingress_yaml_path    = "${path.module}/result/pos-admin/${each.key}-ingress.yaml"
  output_vip_yaml_path        = "${path.module}/result/pos-admin/${each.key}-vip.yaml"

  # Ingress-specific
  hostname              = "pos-admin.kakaosecure.net"
  tls_secret_name       = "2023-kakaosecure-net"
  frontend_service_name = "pos-admin-frontend"
  backend_service_name  = "pos-admin-${each.key}-service"
  ingress_class_name    = "${each.key}-ingress"
}

핵심 포인트:

  • for_each = local.environments: 맵의 각 항목에 대해 모듈 인스턴스 생성
  • each.key: 환경 이름 (as, hn)
  • each.value: 환경별 설정 (node_group, replicas)

3단계: 공통 설정 집중화 (locals.tf)

locals {
  # 공통 환경 변수
  default_env_vars = [
    {
      name  = "SPRING_PROFILES_ACTIVE"
      value = "prod"
    },
    {
      name  = "JAVA_TOOL_OPTIONS"
      value = "-XX:MaxRAMPercentage=85 -Dsun.net.inetaddr.ttl=0 ..."
    }
  ]

  # 공통 리소스 제한
  default_resources = {
    limits = {
      cpu    = "2"
      memory = "2Gi"
    }
    requests = {
      cpu    = "500m"
      memory = "512Mi"
    }
  }

  # APM 설정
  apm_config = {
    image           = "idock.daumkakao.io/kakaoapm/apm-pod-init:1.5.0-rc"
    volume_dir      = "/share-vol/apm"
    application_key = "e557951153b4471abd5c24e9a5418bd3"
  }

  # 포트 설정
  default_ports = [
    { containerPort = 8080 },
    { containerPort = 8090 }
  ]
}

인사이트:

  • 모든 환경에 공통으로 적용되는 설정을 locals.tf에 집중화
  • 환경 변수, 리소스 제한, APM 설정 등을 한 곳에서 관리
  • 설정 변경 시 한 번만 수정하면 모든 환경에 자동 반영

4단계: 템플릿 작성 (deployment.yaml.tftpl)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${app_name}
  labels:
    name: ${app_name}
spec:
  replicas: ${replicas}
  template:
    metadata:
      labels:
        app: ${app_name}
    spec:
      nodeSelector:
%{for key, value in node_selector~}
        "${key}": "${value}"
%{endfor~}
      containers:
        - name: ${app_name}
          image: ${app_image}
          ports:
%{for port in ports~}
            - containerPort: ${port.containerPort}
%{endfor~}
          resources:
            limits:
              cpu: ${resources.limits.cpu}
              memory: ${resources.limits.memory}
          env:
%{for env in env_vars~}
            - name: ${env.name}
              value: ${env.value}
%{endfor~}

템플릿 문법 팁:

  • ${variable}: 단순 변수 치환
  • %{for item in list~}...%{endfor~}: 리스트 반복
  • ~: 불필요한 공백 제거

5단계: 리소스 생성 로직 (main.tf)

resource "local_file" "deployment_yaml" {
  content = templatefile("${path.module}/deployment.yaml.tftpl", {
    app_name      = var.app_name
    app_image     = var.app_image
    replicas      = var.replicas
    node_selector = var.node_selector
    env_vars      = local.default_env_vars
    ports         = local.default_ports
    resources     = local.default_resources
    apm_config    = local.apm_config
  })
  filename = var.output_deployment_yaml_path
}

resource "local_file" "service_yaml" {
  content = templatefile("${path.module}/service.yaml.tftpl", {
    app_name     = var.app_name
    service_name = var.backend_service_name
  })
  filename = var.output_service_yaml_path
}

resource "local_file" "ingress_yaml" {
  content = templatefile("${path.module}/ingress.yaml.tftpl", {
    app_name              = var.app_name
    hostname              = var.hostname
    tls_secret_name       = var.tls_secret_name
    frontend_service_name = var.frontend_service_name
    backend_service_name  = var.backend_service_name
    ingress_class_name    = var.ingress_class_name
  })
  filename = var.output_ingress_yaml_path
}

resource "local_file" "vip_service_yaml" {
  content = templatefile("${path.module}/vip-service.yaml.tftpl", {
    environment = var.environment
  })
  filename = var.output_vip_yaml_path
}

설계 원칙:

  • 각 Kubernetes 리소스(Deployment, Service, Ingress, VIP)를 별도의 local_file 리소스로 생성
  • 템플릿에 필요한 변수만 전달하여 결합도 낮춤

실전 문제 해결 사례

문제 1: 무한 재귀 참조

에러:

Error: Failed to remove local module cache
...pos_admin_back.pos_admin_back.pos_admin_back...

원인: 모듈 source가 자기 자신을 참조

source = "../base"  # ❌ 현재 디렉토리가 base

해결: 올바른 모듈 경로 지정

source = "./modules/app/pos-admin"  # ✅

교훈: Terraform 모듈 경로는 항상 신중하게 확인. 상대 경로 사용 시 pwd 확인 필수.

문제 2: 변수 참조 오류

에러:

Error: Invalid reference
backend_service_name = "${app_name}-service"

원인: 모듈 호출 시 변수를 직접 참조할 수 없음

해결: 동일한 패턴으로 값 구성

# ❌ 잘못된 방법
backend_service_name = "${app_name}-service"

# ✅ 올바른 방법
backend_service_name = "pos-admin-${each.key}-service"

교훈: Terraform 변수 스코프 이해 중요. 모듈 내부 변수는 모듈 외부에서 참조 불가.

문제 3: 템플릿 문법 오타

에러:

Error: Missing newline after argument
type = map(string)ㅍㅁ

원인: 한글 오타가 코드에 포함됨

해결:

# ❌ 오타
type = map(string)ㅍㅁ

# ✅ 수정
type = map(string)

교훈:

  • 코드 리뷰 시 특수문자/한글 오타 주의
  • IDE의 문법 검사 기능 활용
  • Terraform 파일은 UTF-8 인코딩 사용

문제 4: 중복 변수 정의

에러:

Error: Missing required argument
The argument "output_yaml_path" is required

원인: output_yaml_pathoutput_deployment_yaml_path 중복 정의

해결: 변수명 일관성 유지

# 명확한 이름 사용
variable "output_deployment_yaml_path" { }
variable "output_service_yaml_path" { }
variable "output_ingress_yaml_path" { }
variable "output_vip_yaml_path" { }

교훈: 변수 네이밍 컨벤션을 초기에 확립하고 일관되게 적용

결과 및 효과

Before: 수동 YAML 관리

# JP 환경 추가 시
$ cp as-deployment.yaml jp-deployment.yaml
$ vim jp-deployment.yaml  # node_group 수정
$ cp as-service.yaml jp-service.yaml
$ vim jp-service.yaml     # 이름 수정
$ cp as-ingress.yaml jp-ingress.yaml
$ vim jp-ingress.yaml     # 이름, 클래스 수정
$ cp as-vip.yaml jp-vip.yaml
$ vim jp-vip.yaml         # 이름, 클래스 수정

After: Terraform으로 자동화

# JP 환경 추가 시
$ vim main.tf  # 환경 추가 (5줄)
locals {
  environments = {
    as = { node_group = "as-worker", replicas = 5 }
    hn = { node_group = "hn-worker", replicas = 5 }
    jp = { node_group = "jp-worker", replicas = 3 }  # 추가!
  }
}
$ terraform apply  # 4개 파일 자동 생성

정량적 효과

항목BeforeAfter개선율
새 환경 추가 시간~30분~2분93% 감소
설정 변경 시간~20분~5분75% 감소
휴먼 에러율20%<5%75% 감소
코드 중복도높음거의 없음-
유지보수성낮음높음-

정성적 효과

  • 일관성 보장: 모든 환경이 동일한 템플릿에서 생성되어 일관성 유지
  • 변경 추적: Git으로 인프라 변경 이력 추적 가능
  • 리뷰 가능: 코드 리뷰를 통한 배포 전 검증
  • 확장 용이: 새로운 환경 추가가 매우 간단
  • 문서화: 코드 자체가 인프라의 문서

베스트 프랙티스

1. 모듈 설계 원칙

단일 책임 원칙

# ✅ Good: 애플리케이션별로 모듈 분리
modules/
├── pos-admin/
├── delivery-api/
└── store-api/

# ❌ Bad: 하나의 모듈에 모든 앱 포함
modules/
└── all-apps/

재사용성 고려

# ✅ Good: 일반적인 변수명 사용
variable "app_name" { }
variable "replicas" { }

# ❌ Bad: 특정 앱에 종속된 변수명
variable "pos_admin_name" { }
variable "pos_admin_replicas" { }

2. 디렉토리 구조

terraform/
├── main.tf              # 환경 정의 (간결하게)
├── modules/             # 재사용 가능한 모듈
│   └── app/
│       ├── locals.tf    # 공통 설정 집중
│       ├── variables.tf # 입력 변수 정의
│       ├── main.tf      # 로직 구현
│       └── *.tftpl      # 템플릿 파일
└── result/              # 생성된 파일 (.gitignore)

3. 변수 네이밍

# 출력 파일 경로
output_deployment_yaml_path  # ✅ 명확
output_yaml_path             # ❌ 모호

# 환경 변수
environment                  # ✅ 간결
environment_name             # ⚠️ 과도한 명시
env                          # ❌ 너무 짧음

4. locals vs variables

# locals: 모든 환경에 공통으로 적용되는 설정
locals {
  apm_config = { ... }
  default_resources = { ... }
}

# variables: 환경별로 다를 수 있는 설정
variable "replicas" { }
variable "node_selector" { }

5. 템플릿 작성 팁

# ✅ Good: 조건부 렌더링
%{ if node_selector != null ~}
nodeSelector:
%{ for key, value in node_selector ~}
  ${key}: ${value}
%{ endfor ~}
%{ endif ~}

# ❌ Bad: 항상 렌더링 (값이 없어도)
nodeSelector:
%{ for key, value in node_selector ~}
  ${key}: ${value}
%{ endfor ~}

추가 개선 아이디어

1. Terraform Cloud/Enterprise 활용

terraform {
  backend "remote" {
    organization = "my-org"
    workspaces {
      name = "k8s-prod"
    }
  }
}

장점:

  • State 파일 원격 저장 및 잠금
  • 팀 협업 기능
  • CI/CD 통합

2. 환경별 tfvars 파일

environments/
├── prod.tfvars
├── staging.tfvars
└── dev.tfvars
# prod.tfvars
environments = {
  as = { replicas = 10 }
  hn = { replicas = 10 }
}

# dev.tfvars
environments = {
  as = { replicas = 1 }
  hn = { replicas = 1 }
}

3. GitHub Actions 통합

name: Generate K8s YAML
on:
  push:
    paths:
      - 'k8s/prod/terraform/**'

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: hashicorp/setup-terraform@v1
      - run: terraform init
      - run: terraform plan
      - run: terraform apply -auto-approve
      - uses: actions/upload-artifact@v2
        with:
          name: k8s-yaml
          path: result/

4. Validation 추가

variable "replicas" {
  type        = number
  description = "Number of replicas"
  
  validation {
    condition     = var.replicas > 0 && var.replicas < 100
    error_message = "Replicas must be between 1 and 99."
  }
}

variable "environment" {
  type        = string
  description = "Environment name"
  
  validation {
    condition     = contains(["as", "hn", "jp"], var.environment)
    error_message = "Environment must be one of: as, hn, jp."
  }
}

주의사항 및 한계

1. Terraform ≠ kubectl

이 접근법은 YAML 생성 자동화이지, 직접 배포는 아닙니다.

# 여전히 필요한 단계
terraform apply           # YAML 생성
kubectl apply -f result/  # 실제 배포

대안:

  • ArgoCD + Terraform (GitOps)
  • Terraform Kubernetes Provider 사용

2. State 파일 관리

# .gitignore에 추가
.terraform/
.terraform.lock.hcl
terraform.tfstate*
result/

중요:

  • State 파일은 민감 정보 포함 가능
  • 원격 백엔드(S3, GCS) 사용 권장
  • 팀 협업 시 State 잠금 필수

3. 학습 곡선

Terraform과 HCL 문법에 대한 학습이 필요합니다.

추천 학습 순서:
1. Terraform 기초 (variables, resources, outputs)
2. 모듈 시스템
3. 템플릿 함수 (templatefile, for_each)
4. State 관리

4. 복잡도 증가

단순한 경우에는 오버엔지니어링일 수 있습니다.

적용 기준:

  • ✅ 3개 이상의 유사한 환경
  • ✅ 자주 변경되는 공통 설정
  • ✅ 팀 규모 3명 이상
  • ❌ 단일 환경만 존재
  • ❌ 거의 변경되지 않는 설정

마무리

Terraform을 사용한 Kubernetes YAML 관리는 단순히 "자동화"를 넘어서 Infrastructure as Code의 진정한 가치를 실현하는 여정이었습니다.

핵심 교훈

  1. DRY 원칙: Don't Repeat Yourself - 중복을 제거하면 유지보수성이 크게 향상
  2. 선언적 접근: 원하는 상태를 선언하고, 도구가 나머지를 처리
  3. 모듈화: 재사용 가능한 컴포넌트로 분리하여 확장성 확보
  4. 점진적 개선: 완벽을 추구하기보다 작은 개선부터 시작

다음 단계

  • Terraform Cloud로 마이그레이션
  • ArgoCD 통합으로 GitOps 구현
  • 더 많은 애플리케이션에 모듈 적용
  • 환경별 tfvars 파일로 분리
  • CI/CD 파이프라인 통합

참고 자료


질문이나 피드백은 언제든 환영합니다! 🚀


이 글은 실제 프로덕션 환경에서 Terraform을 도입한 경험을 바탕으로 작성되었습니다.

0개의 댓글