[Terraform 5장] Terraform Tips andTricks: Loops, If-Statements,Deployment
본 글은 Terraform Up& Running 책에 대하여 5장에 대한 내용 정리 글이다.
개요
선언형 언어의 특징을 가지는 테라폼은 절차적 언어보다 현재 인프라 상태를 더 명확하게 파악하는데 있어 장점을 제공하나, 반복문(for), 조건문(if)과 같이 절차적 로직이 없거나 제한적이라서 복잡한 작업을 수행하기 어렵다는 단점을 가진다.
그렇다면 아래와 같은 문제상황에 봉착하게 되는데,
- 여러 개의 유사 리소스를 생성할 때(예: 동일한 구성의 서버 3대를 만드는 상황)에 전통적인 for 루프가 없으니, 어떻게 반복 처리를 해야 할까?
- 선언형 언어에서 if 문이 제한적이므로, 사용자에 따라 특정 리소스만 선택적으로 생성하려면 어떻게 해야 할까?
- 무중단 배포처럼 절차적 개념을 선언형 언어로 표현하려면 어떻게 접근해야 할까?
Terraform은 count, for_each, for 표현식 등의 메타 파라미터와 삼항 연산자(ternary operator), create_before_destroy라는 라이프사이클 블록, 그리고 다양한 내장 함수를 통해 반복문과 조건문, 무중단 배포 등의 과정을 어느 정도 구현할 수 있도록 지원한다.
Loop
5-1. ‘count’ 구문
count는 “몇 개의 리소스를 만들 것인가?”라는 수를 지정할 때 사용한다. 예를 들어 count = 3이면 한 개의 리소스 블록이 세 번 복제되어 총 세 개가 만들어진다. 단, count는 단순히 리소스(또는 모듈) 전체를 반복 생성해 줄 뿐, 리소스 내부의 일부 블록을 반복 처리하는 데는 사용할 수 없다. 아래 IAM 여러 사용자 만드는 예시를 살펴보자.
한 명만 만드는 코드
resource "aws_iam_user" "example" {
name = "neo"
}
세 명 만들기
만약 일반적인 프로그래밍 언어였다면 for (i=0; i<3; i++) { ... }로 만들 수 있겠지만, Terraform은 절차적 언어가 아니므로 이런 문법이 없다. 대신 count = 3를 이용해 다음과 같이 작성한다.
resource "aws_iam_user" "example" {
count = 3
name = "neo"
}
그러나, 이렇게 생성하면 동일한 이름이 세번 생성되기에 충돌이 발생한다. (각 사용자 이름은 고유해야 한다.) 이를 해결하기 위해 사용자별 고유한 이름을 가지게 해야하는데, count.index를 활용하여 현재 반복되는 인덱스 번호를 가져올 수 있다.
resource "aws_iam_user" "example" {
count = 3
name = "neo.${count.index}"
}
이렇게 하면 “neo.0”, “neo.1”, “neo.2”처럼 고유한 이름들이 만들어진다.
가독성 높은 이름 사용하기
“neo.0” 대신 더 의미 있는 이름을 쓰고 싶다면, 변수(배열 형태)로 사용자 이름을 정의하고 length 함수를 통해 배열 길이를 계산하면 된다.
variable "user_names" {
type = list(string)
default = ["neo", "trinity", "morpheus"]
}
resource "aws_iam_user" "example" {
count = length(var.user_names)
name = var.user_names[count.index]
}
이렇게 구성하면 ["neo", "trinity", "morpheus"] 각각을 순회하면서 세 명을 생성할 수 있다. count 를 쓰면 해당 리소스는 단일 객체가 아니라 “배열 형태”가 된다. (예: aws_iam_user.example[0], aws_iam_user.example[1])
첫 번째 사용자의 ARN만 출력하려면
output "first_arn" {
value = aws_iam_user.example[0].arn
description = "첫 번째 사용자 ARN"
}
모든 사용자의 ARN 목록을 출력하고 싶다면
output "all_arns" {
value = aws_iam_user.example[*].arn
description = "모든 사용자 ARN 목록"
}
단순 리소스뿐 아니라 모듈에도 count를 적용 가능하다. 아래 예시는 count 구문을 활용하여 여러 IAM 사용자 생성하는 부분이다.
module "users" {
source = "../../../modules/landing-zone/iam-user"
count = length(var.user_names)
user_name = var.user_names[count.index]
}
output "user_arns" {
value = module.users[*].user_arn
description = "생성된 모든 IAM 사용자의 ARN"
}
Count 구문의 한계점
첫째. 인라인 블록 반복 불가
- aws_autoscaling_group에서 tag { ... } 블록 같은 “리소스 내부의 블록”은 count로 반복 생성할 수 없다.
둘째.인덱스 기반 리소스 식별 문제
- count는 리소스 위치(인덱스 번호)로 리소스를 식별한다.
- 예를 들어, IAM 사용자 목록 ["neo", "trinity", "morpheus"] 중간에서 “trinity”를 빼면, 인덱스가 달라져서 terraform plan 시 기존 리소스들이 예상치 못하게 재생성·삭제될 수 있다.
이 때문에 실제 운영 환경에서 리스트 중간의 항목을 삭제하거나 순서를 바꾸는 등의 변경은 위험할 수 있다. 이러한 문제를 해결하기 위해 for_each (Terraform 0.12+)를 사용하는 것을 권장한다. for_each는 “인덱스 번호” 대신 “키”를 기준으로 리소스를 추적하기 때문에 중간 항목 삭제 시 리소스를 재생성하는 문제를 예방할 수 있다.
5-2. ‘for_each’ 구문
Terraform에서 count는 단순하고 빠르게 여러 리소스를 생성할 수 있는 방법이지만, 리스트 중간 요소를 삭제할 때 예상치 못한 리소스 재생성·삭제가 발생하는 단점이 존재한다. 이러한 문제를 해결하고 더 다양한 상황(예: 리소스 내부 블록 반복)에 대처하기 위해 나온 것이 바로 for_each이다.
5-2-1. for_each 사용법
리소스나 모듈에 for_each = <COLLECTION> 구문을 추가하여, 컬렉션(주로 map이나 set) 안의 요소를 순회하며 여러 개를 생성한다.
- each.key와 each.value로 현재 반복되는 컬렉션 아이템의 키와 값을 사용할 수 있다.
- count처럼 인덱스 번호(count.index)를 쓰지 않으므로, 리소스가 “인덱스”가 아니라 “키”로 관리된다.
resource "<PROVIDER>_<TYPE>" "<NAME>" {
for_each = <COLLECTION>
# 설정
# each.key, each.value 사용 가능
}
예시: IAM 여러 사용자 만들기
variable "user_names" {
type = list(string)
default = ["neo", "trinity", "morpheus"]
}
resource "aws_iam_user" "example" {
for_each = toset(var.user_names)
name = each.value
}
이렇게 하면 ["neo", "trinity", "morpheus"] 각각을 순회하면서 IAM 사용자를 세 개를 생성한다.
리소스 접근
- aws_iam_user.example는 “map” 형태가 된다.
- aws_iam_user.example["neo"], aws_iam_user.example["trinity"] 식으로 각 사용자에 접근할 수 있다.
- output "all_users" { value = aws_iam_user.example } 로 출력하면, 키로 “neo”, “trinity”, “morpheus”를 갖는 맵 전체가 출력된다.
모듈에서도 동일하게 for_each를 적용할 수 있다. 아래와 같이 적용함으로써 IAM 유저를 만드는 모듈이 반복 실행되어 각 사용자가 생성된다.
module "users" {
source = "../../../modules/landing-zone/iam-user"
for_each = toset(var.user_names)
user_name = each.value
}
output "user_arns" {
value = values(module.users)[*].user_arn
description = "생성된 모든 IAM 사용자의 ARN"
}
5-2-2. 리소스 내부 블록 반복
for_each의 또 다른 유용한 기능은 리소스 내부의 인라인 블록을 반복적으로 생성하는 것이다.
아래 예시는 aws_autoscaling_group에서 여러 tag {} 블록을 동적으로 생성하고자 할 때이다.
dynamic "<VAR_NAME>" {
for_each = <COLLECTION>
content {
# 반복해서 생성될 블록 내용
key = <VAR_NAME>.key
value = <VAR_NAME>.value
...
}
}
내용을 정리하면,
- count가 단순한 “인덱스 기반” 반복이라면, for_each는 키(key) 기반 반복이라는 점이 가장 큰 차이다.
- 중간에 항목을 제거할 때도 다른 리소스에 영향을 주지 않고 안전하게 삭제가 가능하므로, 일반적으로 여러 리소스를 만들 때는 for_each가 더 권장되어진다.
- Dynamic blocks(dynamic "...")를 이용하면, 리소스 내부에 반복적으로 필요한 블록(예: tag)도 탄력적으로 생성할 수 있다.
5-3. ‘for’ 구문
for 구문은 변수나 아웃풋 레벨에서 목록(List)이나 맵(Map)을 “변환”하는 데 초점을 맞춘다. 파이썬의 리스트/딕셔너리 컴프리헨션과 유사한 기능이다.
5-3-1. 리스트 전체 변환
<예시: upper(name)로 대문자 변환>
- for name in var.names를 통해 "neo", "trinity", "morpheus" 각각을 순회
variable "names" {
type = list(string)
default = ["neo", "trinity", "morpheus"]
}
output "upper_names" {
value = [for name in var.names : upper(name)]
}
# >> ["NEO", "TRINITY", "MORPHEUS"] 형태의 리스트 생성
5-3-2. 필터링 적용
특정 조건을 만족하는 아이템만 결과 리스트에 포함하고자 할 때 이용된다.
<예시: length(name) < 5 조건을 만족하는 문자열만 대문자로 변환해 결과에 담음>
# 문법: [for<ITEM>in<LIST>:<OUTPUT>if<CONDITION>]
output "short_upper_names" {
value = [for name in var.names : upper(name) if length(name) < 5]
}
# ["neo"]만 필터링되어 ["NEO"] 결과 출력
5-3-3 맵 변환
<예시: 맵 → 리스트 변환>
- hero_thousand_faces 맵의 key는 neo, trinity, morpheus 등이고, value는 "hero", "love interest", "mentor" 등
- ["neo is the hero", "trinity is the love interest", "morpheus is the mentor"] 형태의 리스트가 생성
#[for<KEY>,<VALUE>in<MAP>:<OUTPUT>]
variable "hero_thousand_faces" {
type = map(string)
default = {
neo = "hero"
trinity = "love interest"
morpheus = "mentor"
}
}
output "bios" {
value = [for name, role in var.hero_thousand_faces : "${name} is the ${role}"]
}
<예시: 맵 → 맵 변환>
#for<KEY>,<VALUE>in<MAP>:<OUTPUTKEY>=><OUTPUTVALUE>
output "upper_roles" {
value = {
for name, role in var.hero_thousand_faces :
upper(name) => upper(role)
}
}
결과
output "upper_roles" {
value = {
for name, role in var.hero_thousand_faces :
upper(name) => upper(role)
}
}
5-3-4. for expressions vs. for_each의 차이점
- for_each: 리소스/모듈 단위 반복. 실제로 “여러 리소스”를 만들어낼 때 사용.
- for expressions: 하나의 변수(list/map)를 다른 형태(list/map)로 가공할 때 사용. (리소스 자체를 생성하는 것이 아니라, “데이터 변환”이 주 목적)
5-4. 문자열 내부에서의 반복
Terraform에서는 "Hello, ${var.name}" 같은 코드를 작성할 수 있다. 그런데 여기에 단순 변수 참조뿐만 아니라 반복 구조도 사용할 수 있도록 가능하게 해주는 것이 “String Directives”이다.
String Directive는 “문자열에 반복문이나 조건을 녹여 넣어” 복잡한 문자열을 쉽게 구성하도록 도와주는 도구이다.
문자열 안에 %{ … } 구문을 사용하여, for 루프나 if 문 같은 간단한 제어문을 추가할 수 있다.
<예시>
variable "names" {
type = list(string)
default = ["neo", "trinity", "morpheus"]
}
output "for_directive" {
value = "%{ for name in var.names }${name}, %{ endfor }"
}
- var.names 리스트에서 ["neo", "trinity", "morpheus"]를 순회
- 각 name에 대해 "<name>, "을 출력
- 결과: "neo, trinity, morpheus, " (맨 뒤에 “, ”가 붙음)
<예시: 인덱스가 필요한 경우>
output "for_directive_index" {
value = "%{ for i, name in var.names }(${i}) ${name}, %{ endfor }"
}
- 결과: "(0) neo, (1) trinity, (2) morpheus, "
- 인덱스와 이름을 함께 출력
Zero-Downtime Deployment
운영 중인 웹 서버 클러스터에 새 코드를 배포하면서도, 사용자들에게 다운타임을 주지 않는 방법에 대해 알아보자. 일반적으로 “서버를 교체”해야 하면 기존 서버를 내리고 새 서버를 띄우는 과정에서 잠시라도 서비스가 중단되는 경우가 있다. Terraform에서는 ASG(오토 스케일링 그룹)를 교체할 때 기존 ASG를 완전히 지우기 전에 새 ASG를 먼저 띄우는 방식을 통해, 다운타임 없이 배포(Zero-Downtime)를 구현할 수 있다.
아래와 같은 상황을 가정하자.
- 코드 버전 v1 ASG가 이미 서비스 중.
- 새 이미지/코드로 만든 v2 ASG를 배포하려고 terraform apply 실행.
핵심 아이디어는 Terraform의 lifecycle 블록 내에 있는 create_before_destroy = true 옵션을 통해, 새로운 ASG를 먼저 띄운 뒤, 기존 ASG를 제거하는 부분이다.
Step 1. AMI와 User Data를 변수로 노출
- 배포 시점마다 “이번엔 어떤 AMI를 쓸까?” “서버에 어떤 텍스트(또는 환경 변수)를 전달할까?” 등을 쉽게 조정 가능
variable "ami" {
description = "The AMI to run in the cluster"
type = string
default = "ami-0fb653ca2d3203ac1"
}
variable "server_text" {
description = "The text the web server should return"
type = string
default = "Hello, World"
}
Step 2. Launch Configuration 설정
- create_before_destroy = true: Launch Configuration(LC)을 교체할 때 새로운 LC를 먼저 만든 뒤 기존 것을 제거
resource "aws_launch_configuration" "example" {
image_id = var.ami
instance_type = var.instance_type
security_groups = [aws_security_group.instance.id]
user_data = templatefile("${path.module}/user-data.sh", {
server_port = var.server_port
db_address = data.terraform_remote_state.db.outputs.address
db_port = data.terraform_remote_state.db.outputs.port
server_text = var.server_text
})
lifecycle {
create_before_destroy = true
}
}
Step 3. Auto Scaling Group 설정
(1) name 항목에 Lauch configuration 포함
- LC의 이름을 name 항목에 넣어줌으로써 Terraform이 ASG를 교체해야 함을 “감지”하게 한다.
- LC는 변경될 때마다 이름이 새로 생성되어지기에 ASG.name도 따라 달라지므로 ASG 자체를 “새로운 리소스”로 인식하여 “교체(replace)” 과정을 실행하게 된다.
resource "aws_autoscaling_group" "example" {
name = "${var.cluster_name}-${aws_launch_configuration.example.name}"
launch_configuration = aws_launch_configuration.example.name
...
}
(2) create_before_destroy = true 설정
- ASG 교체가 필요하면(=이름이 바뀌어야 함) Terraform은 새로운 ASG를 먼저 만들고, 새 인스턴스가 정상 동작(헬스 체크 통과)하는지 확인한다.
- 만약 새 ASG가 건강 상태를 확보하면, 그제서야 기존 ASG를 제거한다.
- 새 인스턴스가 ALB에 연결되어 트래픽을 처리하기 시작할 때까지, 기존 인스턴스는 계속 살아 있기 다운타임이 없게된다.
resource "aws_autoscaling_group" "example" {
...
lifecycle {
create_before_destroy = true
}
}
(3) min_elb_capacity = var.min_size 설정
- 새 ASG가 충분한 수의 인스턴스를 띄워서 ALB(로드 밸런서)에 등록 & 헬스 체크를 통과하기 전까지 기존 ASG가 제거되지 않도록 지연시키는 역할을 수행한다.
- 예를 들어, min_size = 2라면, 새 ASG에서 최소 2대 인스턴스가 정상 작동 상태(Health OK)가 되어야 기존 ASG를 줄이기 시작한다.
resource "aws_autoscaling_group" "example" {
...
min_elb_capacity = var.min_size
...
}
추가적으로,
- wait_for_capacity_timeout 설정을 통해 새 인스턴스가 헬스 체크를 통과하지 못하면, Terraform은 배포 실패로 보고 새 ASG를 삭제(롤백)한다. 기존 ASG는 살려두어 서비스가 계속 운영되어질 수 있다.
- 이런 식의 “헬스 체크 + 롤백” 메커니즘으로, 배포 중 문제가 생기면 서비스가 영향을 덜 받도록 설계할 수 있다.
댓글