여러분의 팀은 몇 개의 환경에 동일한 애플리케이션을 배포하고 있나요?
AS, HN, JP처럼 여러 리전이나 환경에 같은 애플리케이션을 배포하다 보면, 각 환경마다 거의 비슷하지만 조금씩 다른 Kubernetes YAML 파일을 관리하게 됩니다. 처음에는 단순히 파일을 복사하고 일부 값만 수정하면 되기 때문에 큰 문제가 없어 보입니다.
하지만 환경이 늘어나고 설정이 복잡해질수록 수동 YAML 관리는 점점 부담이 됩니다.
저희 GOA 팀도 비슷한 문제를 겪었습니다. POS Admin 백엔드 서비스를 AS, HN 등 여러 리전에 배포하면서 수십 개의 YAML 파일을 직접 관리해야 했습니다. 하나의 공통 설정을 바꾸려면 모든 환경의 YAML을 일일이 수정해야 했고, 이 과정에서 오타나 누락으로 인한 배포 오류도 발생했습니다.
이 글에서는 Terraform을 사용해 Kubernetes 리소스를 코드로 관리하고, 멀티 환경 YAML 생성을 자동화한 경험을 공유합니다.
기존에는 환경별로 Deployment, Service, Ingress, VIP Service YAML을 각각 관리했습니다.
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 설정, 리소스 제한, Java 옵션, 포트 설정처럼 모든 환경에 동일하게 적용되어야 하는 설정을 변경하려면 모든 YAML 파일을 수정해야 했습니다.
예를 들어 JAVA_TOOL_OPTIONS 값을 변경해야 한다면 AS, HN, JP 등 모든 환경의 Deployment YAML을 열어 수정해야 했습니다.
이런 방식은 시간이 오래 걸릴 뿐 아니라, 특정 환경만 수정이 누락될 가능성도 높았습니다.
새로운 환경을 추가하려면 기존 환경의 YAML 파일을 복사한 뒤, 필요한 값을 직접 바꿔야 했습니다.
예를 들어 JP 환경을 추가한다면 최소한 다음 작업이 필요했습니다.
cp as-deployment.yaml jp-deployment.yaml
cp as-service.yaml jp-service.yaml
cp as-ingress.yaml jp-ingress.yaml
cp as-vip.yaml jp-vip.yaml
이후 각 파일을 열어 이름, nodeSelector, ingress class, service name 등을 직접 수정해야 했습니다.
수동으로 많은 파일을 수정하다 보면 다음과 같은 실수가 발생하기 쉽습니다.
결국 문제의 핵심은 비슷한 YAML을 수동으로 반복 관리하고 있다는 점이었습니다.
저희는 Terraform의 templatefile 함수와 for_each 메타 인자를 활용해 Kubernetes YAML 생성을 자동화했습니다.
이 방식의 목표는 Kubernetes 리소스를 Terraform으로 직접 배포하는 것이 아니라, 환경별 Kubernetes YAML 파일을 일관된 방식으로 생성하는 것이었습니다.
| 구분 | 설명 |
|---|---|
| 템플릿 분리 | Kubernetes YAML을 .tftpl 템플릿 파일로 분리 |
| 변수 추상화 | 환경마다 달라지는 값만 변수로 관리 |
| 공통 설정 집중화 | 모든 환경에 공통으로 적용되는 설정은 locals.tf에 정의 |
| 선언적 환경 정의 | locals 블록에서 환경 목록을 Map 형태로 관리 |
| 자동 파일 생성 | Terraform local_file 리소스로 YAML 파일 생성 |
처음에는 모든 Terraform 코드를 한 파일에 넣으려고 했습니다. 하지만 그렇게 하면 코드가 금방 복잡해지고, 다른 애플리케이션에 재사용하기도 어려워집니다.
그래서 애플리케이션 단위로 모듈을 분리했습니다.
k8s/prod/terraform/
├── main.tf
├── modules/
│ └── app/
│ └── pos-admin/
│ ├── variables.tf
│ ├── locals.tf
│ ├── main.tf
│ ├── deployment.yaml.tftpl
│ ├── service.yaml.tftpl
│ ├── ingress.yaml.tftpl
│ └── vip-service.yaml.tftpl
└── result/
└── pos-admin/
├── as-deployment.yaml
├── as-service.yaml
├── as-ingress.yaml
├── as-vip.yaml
└── ...
| 파일 | 역할 |
|---|---|
main.tf | 환경 목록 정의 및 모듈 호출 |
variables.tf | 모듈 입력 변수 정의 |
locals.tf | 공통 환경 변수, 리소스 제한, APM 설정 등 정의 |
main.tf | 템플릿 렌더링 및 YAML 파일 생성 |
*.yaml.tftpl | Kubernetes YAML 템플릿 |
result/ | Terraform으로 생성된 YAML 파일 저장 위치 |
모듈을 분리하니 POS Admin 외에 다른 애플리케이션에도 같은 구조를 확장하기 쉬워졌습니다.
루트 main.tf에서는 배포 환경을 Map 형태로 정의했습니다.
locals {
environments = {
as = {
node_group = "as-worker"
replicas = 5
}
hn = {
node_group = "hn-worker"
replicas = 5
}
}
}
그리고 for_each를 사용해 환경별로 모듈 인스턴스를 생성했습니다.
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
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 | 환경 Map의 각 항목마다 모듈 생성 |
each.key | 환경 이름. 예: as, hn |
each.value | 환경별 설정 값 |
each.value.node_group | 각 환경의 node group |
each.value.replicas | 각 환경의 replica 수 |
이 구조를 사용하면 새로운 환경을 추가할 때 YAML 파일을 복사할 필요가 없습니다.
환경 Map에 항목만 추가하면 됩니다.
locals {
environments = {
as = {
node_group = "as-worker"
replicas = 5
}
hn = {
node_group = "hn-worker"
replicas = 5
}
jp = {
node_group = "jp-worker"
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_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에는 모든 환경에 동일하게 적용되는 설정만 둡니다.
예를 들면 다음과 같습니다.
반대로 환경마다 달라질 수 있는 값은 variable로 받습니다.
replicasnode_selectorenvironmentingress_class_namebackend_service_name이렇게 역할을 나누면 공통 설정을 한 번만 수정해도 모든 환경에 동일하게 반영할 수 있습니다.
기존 Kubernetes YAML을 .tftpl 템플릿 파일로 변환했습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${app_name}
labels:
name: ${app_name}
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ${app_name}
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}
requests:
cpu: ${resources.requests.cpu}
memory: ${resources.requests.memory}
env:
%{ for env in env_vars ~}
- name: ${env.name}
value: "${env.value}"
%{ endfor ~}
| 문법 | 설명 |
|---|---|
${app_name} | 변수 치환 |
%{ for item in list } | 리스트 반복 |
%{ endfor } | 반복문 종료 |
%{ if condition } | 조건부 렌더링 |
~ | 불필요한 공백 제거 |
모듈 내부 main.tf에서는 templatefile과 local_file 리소스를 사용해 YAML 파일을 생성했습니다.
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
}
Service YAML도 같은 방식으로 생성합니다.
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
}
Ingress YAML은 ingress 관련 변수를 전달합니다.
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
}
VIP Service YAML도 별도 파일로 생성합니다.
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을 분리합니다.main.tf에서 관리합니다.locals.tf에서 관리합니다.이렇게 하면 템플릿과 변수의 결합도를 낮출 수 있습니다.
Error: Failed to remove local module cache
...pos_admin_back.pos_admin_back.pos_admin_back...
모듈의 source 경로가 자기 자신을 참조하고 있었습니다.
source = "../base" # 잘못된 예시
올바른 모듈 경로를 지정했습니다.
source = "./modules/app/pos-admin"
Terraform 모듈 경로는 반드시 현재 작업 디렉토리 기준으로 확인해야 합니다.
특히 상대 경로를 사용할 때는 pwd를 확인하고, 모듈이 자기 자신을 참조하지 않는지 주의해야 합니다.
Error: Invalid reference
backend_service_name = "${app_name}-service"
모듈 호출부에서 모듈 내부 변수를 직접 참조하려고 했습니다.
backend_service_name = "${app_name}-service"
Terraform에서는 모듈 외부에서 모듈 내부 변수를 직접 참조할 수 없습니다.
루트 모듈에서 같은 규칙으로 값을 직접 구성했습니다.
backend_service_name = "pos-admin-${each.key}-service"
Terraform의 변수 스코프를 명확히 이해해야 합니다.
모듈 내부 변수는 모듈 외부에서 사용할 수 없고, 필요한 값은 모듈 호출 시 명시적으로 전달해야 합니다.
Error: Missing newline after argument
type = map(string)ㅍㅁ
Terraform 파일에 한글 오타가 들어갔습니다.
type = map(string)ㅍㅁ
불필요한 문자를 제거했습니다.
type = map(string)
Terraform 파일은 작은 문법 오류에도 실행이 중단됩니다.
다음과 같은 방법으로 예방할 수 있습니다.
terraform fmt 실행terraform validate 실행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
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
이 과정에서 이름, node group, ingress class, service name 등을 모두 직접 수정해야 했습니다.
이제는 환경 Map에 JP 항목만 추가하면 됩니다.
locals {
environments = {
as = {
node_group = "as-worker"
replicas = 5
}
hn = {
node_group = "hn-worker"
replicas = 5
}
jp = {
node_group = "jp-worker"
replicas = 3
}
}
}
이후 Terraform을 실행하면 환경별 YAML이 자동으로 생성됩니다.
terraform init
terraform plan
terraform apply
| 항목 | Before | After | 개선 효과 |
|---|---|---|---|
| 새 환경 추가 시간 | 약 30분 | 약 2분 | 약 93% 감소 |
| 공통 설정 변경 시간 | 약 20분 | 약 5분 | 약 75% 감소 |
| 휴먼 에러 가능성 | 높음 | 낮음 | 크게 감소 |
| 코드 중복도 | 높음 | 낮음 | 중복 제거 |
| 유지보수성 | 낮음 | 높음 | 개선 |
좋은 예시는 다음과 같습니다.
modules/
├── pos-admin/
├── delivery-api/
└── store-api/
반대로 하나의 모듈에 모든 애플리케이션을 넣으면 재사용성과 가독성이 떨어집니다.
modules/
└── all-apps/
모듈은 가능한 한 단일 책임을 가지는 것이 좋습니다.
특정 애플리케이션에 종속된 변수명보다는 일반적인 변수명을 사용하는 것이 좋습니다.
variable "app_name" {}
variable "replicas" {}
variable "node_selector" {}
반대로 다음과 같은 이름은 재사용성을 떨어뜨립니다.
variable "pos_admin_name" {}
variable "pos_admin_replicas" {}
출력 파일이 여러 개라면 변수명을 구체적으로 작성하는 것이 좋습니다.
output_deployment_yaml_path
output_service_yaml_path
output_ingress_yaml_path
output_vip_yaml_path
다음처럼 모호한 이름은 나중에 혼란을 만들 수 있습니다.
output_yaml_path
locals에는 모든 환경에 공통으로 적용되는 설정을 둡니다.
locals {
apm_config = { ... }
default_resources = { ... }
default_env_vars = [ ... ]
}
variables에는 환경별로 달라질 수 있는 값을 둡니다.
variable "replicas" {}
variable "node_selector" {}
variable "environment" {}
이 기준을 정해두면 코드 구조가 훨씬 명확해집니다.
항상 값이 존재한다고 가정하고 YAML을 렌더링하면 불필요한 필드가 생성될 수 있습니다.
%{ if node_selector != null ~}
nodeSelector:
%{ for key, value in node_selector ~}
${key}: ${value}
%{ endfor ~}
%{ endif ~}
이런 방식으로 조건부 렌더링을 사용하면 값이 없을 때 불필요한 YAML 블록이 생성되는 것을 방지할 수 있습니다.
환경 설정이 더 복잡해진다면 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
}
}
실행 시에는 다음처럼 파일을 지정할 수 있습니다.
terraform apply -var-file=environments/prod.tfvars
잘못된 값이 들어오는 것을 막기 위해 변수에 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."
}
}
Terraform 실행을 GitHub Actions나 사내 CI에 연결하면 YAML 생성 과정을 자동화할 수 있습니다.
name: Generate K8s YAML
on:
push:
paths:
- "k8s/prod/terraform/**"
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan
- run: terraform apply -auto-approve
- uses: actions/upload-artifact@v4
with:
name: k8s-yaml
path: result/
현재 방식은 YAML을 생성하는 단계까지 자동화한 것입니다.
실제 배포까지 자동화하려면 다음 방식도 고려할 수 있습니다.
다만 운영 환경에서는 단순히 자동 배포하기보다, 리뷰와 승인 단계를 어떻게 둘 것인지도 함께 고민해야 합니다.
이 방식은 Kubernetes YAML을 생성하는 자동화입니다.
즉, Terraform을 실행한다고 해서 리소스가 Kubernetes 클러스터에 바로 배포되는 것은 아닙니다.
terraform apply
kubectl apply -f result/
직접 배포까지 Terraform으로 관리하고 싶다면 Terraform Kubernetes Provider를 사용할 수 있습니다.
하지만 기존 운영 방식이 YAML 기반이라면, 우선 YAML 생성 자동화부터 도입하는 것도 충분히 현실적인 접근입니다.
Terraform을 사용하면 state 파일이 생성됩니다.
.terraform/
.terraform.lock.hcl
terraform.tfstate
terraform.tfstate.backup
일반적으로 다음 파일들은 Git에 올리지 않는 것이 좋습니다.
.terraform/
terraform.tfstate*
result/
팀 단위로 협업한다면 S3, GCS, Terraform Cloud 같은 원격 Backend를 사용하는 것이 좋습니다.
원격 Backend를 사용하면 다음 장점이 있습니다.
Terraform과 HCL 문법에 익숙하지 않다면 처음에는 다소 어렵게 느껴질 수 있습니다.
추천 학습 순서는 다음과 같습니다.
variable, local, resource, outputfor_eachtemplatefile단일 환경만 존재하거나 YAML 변경이 거의 없다면 오히려 오버엔지니어링일 수 있습니다.
이 방식은 다음 상황에서 특히 효과적입니다.
반대로 다음 상황에서는 도입 효과가 크지 않을 수 있습니다.
Terraform을 사용한 Kubernetes YAML 관리는 단순한 자동화 작업을 넘어, 인프라 설정을 코드로 다루는 경험이었습니다.
가장 큰 변화는 반복 작업이 줄어든 것이었습니다. 이전에는 환경을 추가하거나 공통 설정을 바꿀 때 여러 YAML 파일을 직접 수정해야 했지만, 이제는 환경 정의와 공통 설정을 한 곳에서 관리할 수 있게 되었습니다.
비슷한 YAML을 여러 개 관리하는 방식은 시간이 지날수록 부담이 커집니다.
중복을 제거하고 템플릿화하면 변경 지점이 줄어들고, 운영 안정성도 높아집니다.
환경별 차이를 Map으로 선언하고, Terraform이 반복 생성을 담당하게 하면 작업 방식이 단순해집니다.
중요한 것은 “무엇을 만들 것인가”를 코드에 선언하고, 반복 작업은 도구에 맡기는 것입니다.
처음부터 완벽한 구조를 만들기는 어렵습니다.
하지만 애플리케이션 단위로 모듈을 나누면, 이후 다른 서비스에도 같은 패턴을 적용하기 쉬워집니다.
처음부터 Terraform Cloud, GitOps, CI/CD까지 모두 도입하려고 하면 부담이 큽니다.
먼저 중복 YAML을 템플릿화하고, 환경별 YAML을 자동 생성하는 것부터 시작해도 충분히 큰 효과를 볼 수 있습니다.
앞으로는 다음 개선을 진행해볼 수 있습니다.
tfvars 파일 분리templatefile 함수for_each 메타 인자local_file Provider