AWS 실전 #4 IaC: Terraform 입문
#1 ~ #3에서 만든 인프라는 콘솔 / CLI로 직접 만지고 있습니다. 한 번 더 같은 구성을 띄워보라면, 기억으로? 메모로? 흔들립니다. 그 작업을 Terraform으로 옮기는 게 이번 글입니다.
다룰 내용:
- 왜 IaC. 반복성 / 코드 리뷰 / drift 추적
- Terraform의 구조. provider, resource, data, variable, output, state
- state가 진짜 핵심. S3 + DynamoDB lock backend
- 모듈. 재사용 단위, 환경별 분기
- #1의 ECS 인프라를 한 줄씩 코드화
왜 IaC #
콘솔만 쓰던 방식에서 만나는 통증 4가지:
- 재현 불가. staging을 prod와 똑같이 띄워라? 사람의 기억으로는 미세한 차이가 늘 남습니다
- 변경 추적 불가. “지난주에 누가 SG를 바꿨지?” → CloudTrail 뒤지기. 코드라면 git log
- 리뷰 불가. 운영 클러스터의 SG 인바운드 한 줄 수정에 동료의 눈이 안 들어옴
- 삭제 / 재생성 부담. 하나만 잘못 만들어도 고치기 두려움
IaC (Infrastructure as Code)는 인프라를 선언적 코드로 표현해 위 4가지를 한 번에 잡겠습니다.
| 도구 | 역할 |
|---|---|
| Terraform | 멀티 클라우드, 가장 표준. 이 글의 주인공 |
| Pulumi | TypeScript / Python / Go로 작성. 동적 로직에 강함 |
| AWS CDK | TypeScript / Python → CloudFormation 트랜스파일 |
| CloudFormation | AWS 네이티브 YAML/JSON. 동적 표현 약함 |
| OpenTofu | Terraform의 OSS 포크 (라이선스 분쟁 후) |
이 시리즈는 Terraform으로 통일합니다. 회사 정책상 OpenTofu를 쓰더라도 문법은 동일합니다.
1) Terraform의 다섯 구성 요소 #
# 1) Provider : AWS와 어떻게 통신
provider "aws" {
region = "ap-northeast-2"
}
# 2) Resource : 실제 만들 인프라
resource "aws_ecr_repository" "blog_api" {
name = "blog-api"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# 3) Data : 이미 있는 리소스 조회
data "aws_caller_identity" "current" {}
# 4) Variable : 외부 입력
variable "environment" {
type = string
default = "dev"
}
# 5) Output : 만든 결과를 노출
output "ecr_url" {
value = aws_ecr_repository.blog_api.repository_url
}다섯 구성 요소가 한 파일에 모이면 한 인프라 단위가 됩니다.
워크플로우 4 단 #
terraform init # provider 다운로드, backend 초기화
terraform plan # 무엇이 만들어지고/바뀌고/삭제되는지 미리 보기
terraform apply # 적용
terraform destroy # 삭제plan의 출력이 Terraform의 가장 큰 가치. 사고를 코드 머지 전에 막습니다.
Terraform will perform the following actions:
# aws_security_group.fargate will be created
+ resource "aws_security_group" "fargate" {
+ arn = (known after apply)
+ name = "sg-fargate"
+ ingress = [
+ {
+ from_port = 8000
+ to_port = 8000
+ protocol = "tcp"
+ ...
},
]
}
Plan: 1 to add, 0 to change, 0 to destroy.+ 추가 / ~ 변경 / - 삭제 / -/+ 재생성 (ID가 바뀌면 끔찍, 항상 의식).
2) State: 진짜 핵심 #
Terraform은 state (.tfstate 파일)에 “지금까지 만든 인프라의 상태” 를 저장합니다. 이 파일이 있어야 다음 plan이 차이를 계산 할 수 있습니다.
실제 AWS의 인프라 ←────── Terraform code
│
▼
state (마지막 apply의 결과)Terraform은 code ↔ state ↔ AWS의 3 자 정합성을 본 다음 변경 계획을 짭니다.
state가 깨지면 어떻게 되는가 #
| 상황 | 결과 |
|---|---|
| state 분실 | Terraform은 “아무것도 만든 적 없음” 으로 인식 → 이미 있는 리소스를 또 만들려 함 |
| 두 사람이 동시에 apply | state가 깨지거나 한쪽이 다른 쪽 변경을 덮어씀 |
| state 파일이 git에 평문 | 비밀번호 / 키 노출 (state 안에 secret이 있는 자원 다수) |
→ 로컬 .tfstate는 학습용만. 운영은 원격 backend가 필수.
S3 + DynamoDB Backend #
가장 흔하게 쓰이는 운영 패턴입니다.
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "myorg-terraform-state"
key = "blog-api/prod/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}구성 정리:
| 역할 | |
|---|---|
| S3 bucket | state 파일 저장 (버전 관리 + 암호화 활성) |
| DynamoDB table | 동시 apply 차단. 락 테이블 |
| bucket key의 prefix | <프로젝트>/<환경>/terraform.tfstate 패턴으로 환경 분리 |
| encrypt = true | KMS로 자동 암호화 |
Backend를 셋업하는 한 번의 부트스트랩 #
S3와 DynamoDB 자체는 누군가 먼저 만들어야 합니다. 닭과 달걀 문제. 두 가지 흐름:
- 콘솔 / CLI로 한 번 수동 생성 (이 글의 가정)
- 별도 “bootstrap” 폴더에서 local backend로 만들고, 그 다음 backend를 S3로 마이그레이션
aws s3api create-bucket \
--bucket myorg-terraform-state \
--region ap-northeast-2 \
--create-bucket-configuration LocationConstraint=ap-northeast-2
aws s3api put-bucket-versioning \
--bucket myorg-terraform-state \
--versioning-configuration Status=Enabled
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-2이 두 리소스는 절대 Terraform으로 destroy 하지 마세요. state가 그 안에 살고 있습니다.
3) 디렉터리 구조: 환경별 분리 #
infra/
├─ modules/
│ ├─ network/ ← VPC, Subnets, SGs
│ ├─ ecs-service/ ← ALB + Service + Auto Scaling
│ └─ rds/ ← DB
├─ envs/
│ ├─ dev/
│ │ ├─ main.tf
│ │ ├─ backend.tf
│ │ ├─ variables.tf
│ │ └─ terraform.tfvars
│ └─ prod/
│ ├─ main.tf
│ ├─ backend.tf
│ ├─ variables.tf
│ └─ terraform.tfvars
└─ bootstrap/ ← S3 / DynamoDB (한 번만)환경별 backend key를 다르게 두어 state를 분리:
terraform { backend "s3" {
bucket = "myorg-terraform-state"
key = "blog-api/dev/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "terraform-state-lock"
}}이러면 dev와 prod가 완전 분리됩니다. dev의 apply가 prod state를 건드릴 일이 없습니다.
4) 모듈: 재사용 단위 #
같은 인프라 패턴을 dev / prod에서 반복하지 않도록.
variable "name" { type = string }
variable "cluster_arn" { type = string }
variable "image" { type = string }
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
variable "alb_sg_id" { type = string }
variable "desired_count" { type = number, default = 2 }
variable "cpu" { type = string, default = "512" }
variable "memory" { type = string, default = "1024" }
variable "container_port" { type = number, default = 8000 }resource "aws_security_group" "fargate" {
name = "sg-${var.name}-fargate"
description = "Fargate task SG"
vpc_id = var.vpc_id
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [var.alb_sg_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_lb_target_group" "this" {
name = "tg-${var.name}"
port = var.container_port
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc_id
health_check {
path = "/health"
healthy_threshold = 2
interval = 15
}
}
resource "aws_ecs_task_definition" "this" {
family = var.name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([{
name = "api"
image = var.image
portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "api"
}
}
}])
}
resource "aws_ecs_service" "this" {
name = var.name
cluster = var.cluster_arn
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.subnet_ids
security_groups = [aws_security_group.fargate.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = "api"
container_port = var.container_port
}
deployment_circuit_breaker {
enable = true
rollback = true
}
}
output "target_group_arn" { value = aws_lb_target_group.this.arn }
output "service_name" { value = aws_ecs_service.this.name }#1의 콘솔 작업이 여기 한 파일로 모였습니다.
모듈 사용 #
module "network" {
source = "../../modules/network"
name = "blog-prod"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
}
module "rds" {
source = "../../modules/rds"
name = "blog-prod"
vpc_id = module.network.vpc_id
db_subnet_ids = module.network.db_subnet_ids
fargate_sg_id = module.api.fargate_sg_id
multi_az = true
instance_class = "db.t4g.small"
deletion_protection = true
}
module "api" {
source = "../../modules/ecs-service"
name = "blog-prod"
cluster_arn = aws_ecs_cluster.blog.arn
image = var.image # CI가 주입
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
alb_sg_id = module.network.alb_sg_id
desired_count = 4
cpu = "1024"
memory = "2048"
}dev 환경은 desired_count = 1, multi_az = false, instance_class = "db.t4g.micro"로 가볍게 구성합니다. 같은 모듈 + 다른 변수가 핵심입니다.
5) Terraform ↔ CI/CD 통합 #
#3의 GitHub Actions와 어떻게 묶을 것인가.
두 가지 흐름 #
| 설명 | |
|---|---|
| A. 인프라 / 앱 분리 | 인프라 변경은 별도 PR + apply, 앱 배포는 image만 갱신 |
| B. 한 워크플로우로 묶음 | image 빌드 → terraform apply가 새 image를 service에 |
처음에는 A 방식을 권장합니다. 인프라 변경은 무겁고 앱 배포는 잦아 두 흐름의 위험도가 다릅니다.
Plan을 PR 코멘트로 #
name: Terraform Plan
on:
pull_request:
paths: ['infra/**']
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run: { working-directory: infra/envs/prod }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
aws-region: ap-northeast-2
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.9.0 }
- run: terraform init
- run: terraform plan -no-color -out=tfplan
- name: Comment Plan
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const out = require('child_process')
.execSync('terraform show -no-color tfplan', { cwd: 'infra/envs/prod' });
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```\n' + out + '\n```'
});PR 검토 단계에서 무엇이 바뀌는지 한눈에 확인할 수 있어, 운영 사고를 코드 머지 전에 막는 가장 효과적인 방법입니다.
terraform-plan 역할은 read-only 권한으로 충분합니다. apply 권한은 별도 역할.
6) Drift 추적 #
콘솔에서 손으로 바꾼 리소스는 state와 어긋납니다 (drift). terraform plan이 차이를 보여주면서 “원복할까?” 를 묻습니다.
terraform plan -detailed-exitcode
# exit 0 = 차이 없음
# exit 2 = 차이 있음 (실패가 아님)CI에서 매일 한 번 돌리고, exit 2 면 슬랙 알림.
함정: Terraform 운영의 함정 #
1) state 잠김 해제 안 됨 #
apply가 ctrl-c로 중단되면 DynamoDB 락이 그대로 남습니다. 다음 apply가 “Resource locked"로 실패합니다.
terraform force-unlock <LOCK_ID>LOCK_ID는 에러 메시지에 표시. 다른 사람이 진짜 작업 중이 아닌지 반드시 확인 후.
2) state 수동 편집 #
.tfstate를 vim으로 열어 편집. 거의 항상 후회. 대신:
terraform state list # 자원 목록
terraform state show aws_ecr_repository.x # 한 자원 상세
terraform state rm aws_ecr_repository.x # state에서 제거 (실제 자원은 안 지움)
terraform state mv module.a.x module.b.x # 자원 이동
terraform import aws_ecr_repository.x my-repo # 기존 자원을 state에 등록3) 비밀번호가 state에 평문 #
aws_db_instance의 password, aws_secretsmanager_secret_version의 secret_string은 state에 평문으로 들어갑니다. state bucket 암호화 + 접근 제한이 필수.
data "aws_iam_policy_document" "state_bucket" {
statement {
effect = "Deny"
actions = ["s3:*"]
resources = ["arn:aws:s3:::myorg-terraform-state/*"]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}4) -/+ destroy/create
#
plan에서 -/+가 보이면 자원 ID가 바뀝니다. RDS 라면 데이터 손실. 자세히 봐야 할 부분:
# aws_db_instance.blog must be replaced
-/+ resource "aws_db_instance" "blog" {
~ engine_version = "16.3" -> "17.0" # forces replacement
}→ 이런 변경은 별도 마이그레이션 절차로 처리해야 합니다. RDS는 in-place 업그레이드 옵션이 따로 있습니다.
5) Provider 버전 고정 안 함 #
required_providers에 version을 지정하지 않으면 다음 init에서 깨질 수 있습니다. 항상 ~> 5.0 같은 패턴으로 고정하세요.
6) terraform destroy 사고
#
prod 환경에서 실수로 destroy. 보호 장치:
resource "aws_db_instance" "blog" {
# ...
lifecycle {
prevent_destroy = true
}
}prevent_destroy = true가 있는 자원은 destroy / replace에서 plan 단계에서 막힘.
정리 #
이번 글에서 잡은 것:
- 왜 IaC. 재현 / 추적 / 리뷰 / 안전한 destroy
- 다섯 블록. provider, resource, data, variable, output
- 워크플로우. init → plan → apply → destroy. plan이 가장 큰 가치
- state. Terraform의 핵심. 로컬 state는 학습용
- S3 + DynamoDB Backend. 운영 표준, encrypt, versioning
- Bootstrap. backend 자체는 콘솔 / 별도 모양
- 디렉터리 구조. modules/ + envs/{dev,prod}/, 환경별 backend key 분리
- 모듈. 같은 패턴을 변수로 차별화. dev = 가볍게, prod = 풀 옵션
- CI/CD 통합. Plan을 PR 코멘트로, plan/apply 권한 분리
- Drift 추적.
plan -detailed-exitcode로 정기 - 함정. 락 해제, state 편집, 평문 비밀번호,
-/+, provider 버전, destroy 보호
다음: 모니터링 #
인프라가 코드가 됐고 배포가 자동입니다. 이제 돌고 있는지 / 잘 돌고 있는지를 본격적으로 봐야 할 차례.
#5 모니터링: CloudWatch 알람과 X-Ray에서는 ECS / RDS / ALB의 핵심 메트릭, Logs Insights 운영 쿼리, 알람을 슬랙으로 보내는 흐름, 그리고 X-Ray 분산 추적으로 “왜 이 요청만 5초 걸렸지?” 를 한 줄로 잡는 흐름까지.