WebPiki
tutorial

Terraform으로 인프라 코드화(IaC) 시작하기

Infrastructure as Code의 개념, Terraform 기본 사용법, 실전 예제를 정리했다.

AWS 콘솔에 접속해서 EC2 인스턴스를 만들고, 보안 그룹을 설정하고, RDS를 생성하고, VPC를 연결하고... 이걸 콘솔 UI에서 클릭클릭으로 하면 처음에는 괜찮다. 그런데 같은 환경을 스테이징용으로 하나 더 만들어야 하면? 다른 리전에도 배포해야 하면? 3개월 뒤에 이 인프라가 어떻게 구성되어 있는지 기억이 나나?

Infrastructure as Code(IaC)는 인프라를 코드로 정의하는 방식이다. 코드 파일에 "EC2 인스턴스 t3.medium 하나, 보안 그룹은 이렇게, VPC는 저렇게"를 적어놓으면, 도구가 이 코드를 읽어서 실제 인프라를 생성한다.

IaC가 주는 것

재현성 — 같은 코드를 실행하면 같은 인프라가 만들어진다. "이번에는 콘솔에서 어떤 옵션을 눌렀더라?" 같은 상황이 없다. 개발, 스테이징, 프로덕션 환경을 동일하게 유지할 수 있다.

버전 관리 — 코드니까 Git에 넣을 수 있다. 인프라 변경 이력이 커밋 로그에 남는다. 누가 언제 왜 바꿨는지 추적 가능하다. 문제가 생기면 이전 버전으로 되돌릴 수도 있다.

자동화 — CI/CD 파이프라인에 포함시켜서 코드 리뷰 후 자동 배포할 수 있다. 사람이 콘솔에서 수동으로 하는 것보다 실수가 줄어든다.

문서화 — 인프라 구성 자체가 코드에 문서화되어 있다. 별도 문서를 만들 필요가 줄어들고, 코드가 곧 현재 상태의 진실(source of truth)이다.

Terraform 기초

Terraform은 HashiCorp가 만든 IaC 도구다. AWS, Azure, GCP 같은 주요 클라우드뿐 아니라, Cloudflare, Datadog, GitHub 같은 서비스까지 수백 개의 프로바이더를 지원한다. 특정 클라우드에 종속되지 않는다는 게 장점.

HCL(HashiCorp Configuration Language)이라는 자체 설정 언어를 쓴다. JSON과 YAML의 중간 즈음 되는 느낌이다.

Provider

Terraform에게 "나는 AWS를 쓸 거야"라고 알려주는 설정이다.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-2"  # 서울 리전
}

~> 5.0은 5.x 버전을 쓰되 6.0 미만으로 제한한다는 뜻. 마이너 업데이트는 허용하지만 메이저 업데이트는 명시적으로 올려야 한다.

Resource

인프라의 구성 요소. EC2 인스턴스, S3 버킷, VPC, 보안 그룹 등.

resource "aws_instance" "web" {
  ami           = "ami-0c9c942bd7bf113a2"
  instance_type = "t3.micro"

  tags = {
    Name = "web-server"
  }
}

"aws_instance"는 리소스 타입, "web"은 이 리소스의 로컬 이름이다. Terraform 코드 내에서 이 인스턴스를 참조할 때 aws_instance.web으로 쓴다.

Variable

설정값을 외부에서 주입할 수 있게 한다. 환경별로 다른 값을 쓸 때 유용하다.

variable "instance_type" {
  description = "EC2 인스턴스 타입"
  type        = string
  default     = "t3.micro"
}

variable "environment" {
  description = "배포 환경"
  type        = string
  # default가 없으면 실행 시 입력을 요구한다
}

resource "aws_instance" "web" {
  ami           = "ami-0c9c942bd7bf113a2"
  instance_type = var.instance_type

  tags = {
    Name        = "web-server"
    Environment = var.environment
  }
}

변수 값은 terraform.tfvars 파일, 환경 변수(TF_VAR_xxx), 또는 CLI 옵션(-var)으로 전달할 수 있다.

Output

리소스 생성 후 필요한 정보를 출력한다. 예를 들어 EC2 인스턴스의 퍼블릭 IP.

output "web_public_ip" {
  description = "웹 서버의 퍼블릭 IP"
  value       = aws_instance.web.public_ip
}

Terraform 워크플로우

네 가지 명령어가 핵심이다.

terraform init

프로젝트 초기화. Provider 플러그인을 다운로드한다. npm install 같은 단계라고 생각하면 된다. 새 프로젝트를 시작하거나 Provider를 추가했을 때 실행한다.

terraform plan

현재 코드와 실제 인프라를 비교해서 어떤 변경이 일어날지 미리 보여준다. 실제로 변경하지는 않는다.

$ terraform plan

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0c9c942bd7bf113a2"
      + instance_type                = "t3.micro"
      + ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

+는 생성, ~는 변경, -는 삭제. 이걸 먼저 확인하고 의도한 변경인지 검토한 뒤에 apply를 한다. plan을 건너뛰고 바로 apply하는 건 위험하다.

terraform apply

plan에서 보여준 변경사항을 실제로 적용한다. 확인 프롬프트가 나오고 yes를 입력하면 실행된다.

terraform destroy

Terraform이 관리하는 리소스를 전부 삭제한다. 테스트 환경을 정리할 때 쓴다. 프로덕션에서는 극도로 주의.

State — Terraform의 기억

Terraform은 terraform.tfstate라는 파일에 현재 인프라의 상태를 저장한다. 이 파일이 "현재 인프라가 어떻게 생겼는지"에 대한 Terraform의 기억이다.

코드에 aws_instance.web이 있고, state에도 aws_instance.web이 있으면 Terraform은 "아, 이건 이미 만들어져 있네. 속성이 바뀌었나 보자"라고 판단한다. state에 없으면 "새로 만들어야겠다"고 판단한다.

State를 로컬에 두면 안 되는 이유

팀에서 작업할 때 state 파일이 각자의 로컬에 있으면 문제가 된다. A가 EC2를 만들었는데 B의 state에는 그 정보가 없다. B가 apply를 하면 또 만들려고 한다.

그래서 state를 원격 저장소에 둔다. AWS S3 + DynamoDB 조합이 일반적이다. S3에 state를 저장하고, DynamoDB로 락(lock)을 걸어서 동시 수정을 방지한다.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

State 파일에는 민감 정보가 포함될 수 있다. DB 비밀번호 같은 게 평문으로 들어간다. S3에 저장할 때 반드시 암호화를 켜고, 접근 권한을 엄격하게 관리해야 한다.

모듈 — 코드 재사용

같은 패턴의 인프라를 여러 번 만들어야 할 때 모듈로 묶는다. 함수와 비슷한 개념이다.

# modules/vpc/main.tf
variable "cidr_block" {
  type = string
}

variable "name" {
  type = string
}

resource "aws_vpc" "this" {
  cidr_block = var.cidr_block
  tags = {
    Name = var.name
  }
}

output "vpc_id" {
  value = aws_vpc.this.id
}
# 사용하는 쪽
module "prod_vpc" {
  source     = "./modules/vpc"
  cidr_block = "10.0.0.0/16"
  name       = "prod-vpc"
}

module "staging_vpc" {
  source     = "./modules/vpc"
  cidr_block = "10.1.0.0/16"
  name       = "staging-vpc"
}

Terraform Registry에 공개된 커뮤니티 모듈도 많다. VPC, EKS 클러스터 같은 복잡한 인프라를 잘 만들어진 모듈로 간단하게 생성할 수 있다. 다만 외부 모듈을 쓸 때는 코드를 확인하고, 버전을 고정하는 게 좋다.

실전 예제: VPC + EC2

간단한 예제를 보자. VPC를 만들고 퍼블릭 서브넷에 EC2 인스턴스를 띄운다.

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = { Name = "main-vpc" }
}

# 퍼블릭 서브넷
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "ap-northeast-2a"

  tags = { Name = "public-subnet" }
}

# 인터넷 게이트웨이
resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "main-igw" }
}

# 라우트 테이블
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }

  tags = { Name = "public-rt" }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# 보안 그룹
resource "aws_security_group" "web" {
  name_prefix = "web-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["203.0.113.0/32"]  # 내 IP만 허용
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# EC2 인스턴스
resource "aws_instance" "web" {
  ami                    = "ami-0c9c942bd7bf113a2"
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = { Name = "web-server" }
}

리소스 간의 참조(aws_vpc.main.id, aws_subnet.public.id)를 보면 Terraform이 의존성을 자동으로 파악해서 올바른 순서로 생성한다는 걸 알 수 있다. VPC를 먼저 만들고, 그 안에 서브넷을 만들고, 그 서브넷에 인스턴스를 만드는 순서.

다른 IaC 도구와의 비교

AWS CloudFormation

AWS 전용. JSON이나 YAML로 작성한다. AWS만 쓴다면 나쁘지 않다. AWS 서비스와의 통합이 깊고, 스택 롤백 기능이 내장되어 있다. 단점은 멀티 클라우드 지원이 안 되고, 템플릿이 장황해지는 경향이 있다.

Pulumi

Terraform과 비슷하지만 HCL 대신 범용 프로그래밍 언어(TypeScript, Python, Go, C#)를 쓴다. 조건문, 반복문, 함수를 자유롭게 쓸 수 있으니까 복잡한 로직을 표현하기 편하다. 다만 HCL의 선언적 특성이 주는 단순함을 잃는 측면도 있다.

AWS CDK

CloudFormation을 TypeScript, Python 등으로 작성할 수 있게 해주는 도구. 결국 CloudFormation 템플릿으로 변환된다. AWS에 올인한 환경이라면 괜찮은 선택.

Terraform이 가장 범용적이고 생태계가 넓다. 처음 IaC를 배운다면 Terraform부터 시작하는 게 적용 범위가 넓어서 실용적이다.

실전 팁

.terraform.lock.hcl을 커밋하라. Provider 버전을 고정하는 락 파일이다. 팀원들이 같은 버전의 Provider를 사용하도록 보장한다. package-lock.json과 같은 역할.

terraform plan을 CI에 넣어라. PR을 올릴 때 plan 결과가 자동으로 코멘트에 달리면 리뷰어가 어떤 인프라 변경이 일어날지 확인할 수 있다. Atlantis라는 도구가 이걸 해준다.

환경별로 디렉토리를 분리하라. environments/prod/, environments/staging/ 처럼. 같은 모듈을 쓰되 변수만 다르게 넣으면 환경 간 일관성을 유지하면서도 독립적으로 관리할 수 있다.

state를 직접 수정하지 마라. state가 꼬이면 terraform state 명령어로 수정할 수 있긴 한데, 잘못하면 인프라가 날아갈 수 있다. 최후의 수단이다.

destroy에 주의하라. terraform destroy는 강력하다. 프로덕션에서 실행하면 모든 리소스가 삭제된다. CI에서 destroy가 자동으로 실행되지 않도록 주의하고, 중요한 리소스에는 lifecycle { prevent_destroy = true }를 설정해두자.

IaC는 처음 세팅하는 데 시간이 좀 걸리지만, 인프라가 커질수록 빛을 발한다. 콘솔에서 30분 걸리던 작업이 코드 한 줄 수정으로 끝나는 순간이 오면, 왜 진작 안 했을까 싶어진다.

#Terraform#IaC#DevOps#클라우드#인프라

관련 글