여러분의 팀은 몇 개의 환경(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
Terraform의 templatefile 함수와 for_each 메타-인자를 활용하여 Infrastructure as Code를 구현했습니다.
.tftpl 템플릿 파일로 변환locals.tf에 모든 환경에 적용되는 설정 정의locals 블록에서 환경 목록을 맵으로 관리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
└── ...
인사이트: 처음에는 모든 것을 한 파일에 넣으려 했지만, 모듈을 분리하니 재사용성과 가독성이 크게 향상되었습니다.
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)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에 집중화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~}: 리스트 반복~: 불필요한 공백 제거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
}
설계 원칙:
local_file 리소스로 생성에러:
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 확인 필수.
에러:
Error: Invalid reference
backend_service_name = "${app_name}-service"
원인: 모듈 호출 시 변수를 직접 참조할 수 없음
해결: 동일한 패턴으로 값 구성
# ❌ 잘못된 방법
backend_service_name = "${app_name}-service"
# ✅ 올바른 방법
backend_service_name = "pos-admin-${each.key}-service"
교훈: Terraform 변수 스코프 이해 중요. 모듈 내부 변수는 모듈 외부에서 참조 불가.
에러:
Error: Missing newline after argument
type = map(string)ㅍㅁ
원인: 한글 오타가 코드에 포함됨
해결:
# ❌ 오타
type = map(string)ㅍㅁ
# ✅ 수정
type = map(string)
교훈:
에러:
Error: Missing required argument
The argument "output_yaml_path" is required
원인: output_yaml_path와 output_deployment_yaml_path 중복 정의
해결: 변수명 일관성 유지
# 명확한 이름 사용
variable "output_deployment_yaml_path" { }
variable "output_service_yaml_path" { }
variable "output_ingress_yaml_path" { }
variable "output_vip_yaml_path" { }
교훈: 변수 네이밍 컨벤션을 초기에 확립하고 일관되게 적용
# 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 # 이름, 클래스 수정
# 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개 파일 자동 생성
| 항목 | Before | After | 개선율 |
|---|---|---|---|
| 새 환경 추가 시간 | ~30분 | ~2분 | 93% 감소 |
| 설정 변경 시간 | ~20분 | ~5분 | 75% 감소 |
| 휴먼 에러율 | 20% | <5% | 75% 감소 |
| 코드 중복도 | 높음 | 거의 없음 | - |
| 유지보수성 | 낮음 | 높음 | - |
단일 책임 원칙
# ✅ 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" { }
terraform/
├── main.tf # 환경 정의 (간결하게)
├── modules/ # 재사용 가능한 모듈
│ └── app/
│ ├── locals.tf # 공통 설정 집중
│ ├── variables.tf # 입력 변수 정의
│ ├── main.tf # 로직 구현
│ └── *.tftpl # 템플릿 파일
└── result/ # 생성된 파일 (.gitignore)
# 출력 파일 경로
output_deployment_yaml_path # ✅ 명확
output_yaml_path # ❌ 모호
# 환경 변수
environment # ✅ 간결
environment_name # ⚠️ 과도한 명시
env # ❌ 너무 짧음
# locals: 모든 환경에 공통으로 적용되는 설정
locals {
apm_config = { ... }
default_resources = { ... }
}
# variables: 환경별로 다를 수 있는 설정
variable "replicas" { }
variable "node_selector" { }
# ✅ 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 ~}
terraform {
backend "remote" {
organization = "my-org"
workspaces {
name = "k8s-prod"
}
}
}
장점:
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 }
}
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/
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."
}
}
이 접근법은 YAML 생성 자동화이지, 직접 배포는 아닙니다.
# 여전히 필요한 단계
terraform apply # YAML 생성
kubectl apply -f result/ # 실제 배포
대안:
# .gitignore에 추가
.terraform/
.terraform.lock.hcl
terraform.tfstate*
result/
중요:
Terraform과 HCL 문법에 대한 학습이 필요합니다.
추천 학습 순서:
1. Terraform 기초 (variables, resources, outputs)
2. 모듈 시스템
3. 템플릿 함수 (templatefile, for_each)
4. State 관리
단순한 경우에는 오버엔지니어링일 수 있습니다.
적용 기준:
Terraform을 사용한 Kubernetes YAML 관리는 단순히 "자동화"를 넘어서 Infrastructure as Code의 진정한 가치를 실현하는 여정이었습니다.
질문이나 피드백은 언제든 환영합니다! 🚀
이 글은 실제 프로덕션 환경에서 Terraform을 도입한 경험을 바탕으로 작성되었습니다.