soobook
INFRA

Terraform 101

선언한 인프라와 실제 인프라의 차이를 계산하는 도구, 그리고 그 차이를 다루는 법

Terraform은 “인프라가 이런 모습이어야 한다”는 선언과 “실제로는 이런 모습이다”라는 현실 사이의 차이를 계산해서 그 차이만큼만 API를 호출한다.

Intro

클라우드 콘솔에서 손으로 리소스를 만들다 보면 반드시 겪는 문제가 있다.

지난주에 내가 뭘 만들었는지 기억이 안 나고, 옆 사람이 뭘 바꿨는지도 모르고, 개발 환경과 운영 환경이 언제부터 달라졌는지 아무도 모른다.

인프라의 현재 모습이 어디에도 적혀 있지 않기 때문이다.

Infrastructure as Code(IaC)는 이 상태를 코드로 적자는 발상이다. 서버, 네트워크, DNS 레코드, IAM 정책 같은 것을 텍스트 파일로 선언하고, 그 파일을 Git에 넣고, 변경은 코드 리뷰를 거쳐 반영한다. 인프라의 현재 모습이 저장소 하나에 다 적혀 있으면 위 문제들이 대부분 사라진다.

Terraform은 그 IaC 도구 중 사실상의 표준이다. 이 글은 Terraform을 처음 잡는 사람을 위한 글이다.

What Terraform Is

Terraform은 선언형(declarative) 도구다. “이 순서로 이걸 만들어라”라고 절차를 쓰는 게 아니라, “최종적으로 이런 리소스들이 존재해야 한다”고 결과를 쓴다. 순서를 맞추고, 이미 있는 건 건드리지 않고, 없는 것만 만드는 일은 Terraform이 알아서 한다.

선언은 HCL(HashiCorp Configuration Language)로 쓴다. 가장 작은 단위는 resource 블록이다.

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

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

여기서 aws_instance리소스 타입(resource type), web은 이 코드 안에서 부르는 로컬 이름(local name)이다. aws_instance.web으로 다른 곳에서 참조한다.

aws_instance 같은 타입을 실제 클라우드 API 호출로 바꿔 주는 게 provider다.

Terraform 코어는 클라우드를 하나도 모른다. AWS, GCP, Cloudflare, Datadog, 심지어 GitHub까지 전부 provider 플러그인이 담당한다.

Terraform 코어는 “무엇을 만들지 계산”만 하고, “어떻게 만들지”는 provider에 위임한다.

이 분리 덕분에 하나의 언어로 수백 개 플랫폼을 같은 방식으로 다룰 수 있다.

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

provider "aws" {
  region = "ap-northeast-2"
}

리소스 외에 자주 쓰는 블록은 다음과 같다.

블록역할
resourceTerraform이 생성, 관리하는 리소스
dataTerraform이 만들지 않고 조회만 하는 기존 리소스
variable외부에서 주입하는 입력값
output실행 후 밖으로 내보내는 값 (다른 코드가 참조)
locals코드 내부에서 재사용하는 계산된 값
module여러 리소스를 묶은 재사용 단위

The Core Loop

Terraform을 쓰는 일상은 세 명령의 반복이다.

terraform init    # provider 다운로드, backend 초기화
terraform plan    # 무엇이 바뀔지 계산해서 보여줌
terraform apply   # 실제로 반영

여기서 plan이 Terraform의 핵심이다. plan은 세 가지를 비교한다.

plan은 원하는 상태(Configuration)와 마지막으로 기록한 상태(State)의 차이를 계산하고, refresh 단계에서 State를 실제 인프라와 대조해 갱신한다. 결과는 create, update, destroy 세 액션으로 떨어진다

plan은 먼저 State를 실제 인프라와 대조해 갱신(refresh)하고, 그다음 원하는 상태(Configuration)와 현재 상태(State)의 차이를 계산한다. 결과는 항상 세 종류의 액션으로 떨어진다. 새로 만들 것(+), 바꿀 것(~), 지울 것(-). apply는 이 계획을 그대로 실행할 뿐이다.

중요한 습관 하나. plan의 출력은 실행 전에 반드시 눈으로 읽는다. Terraform이 리소스를 지우고 다시 만든다(-/+, replace)고 할 때 그게 DB라면 데이터가 날아간다. plan은 이런 사고를 apply 전에 잡아 주는 마지막 관문이다.

State

State는 Terraform이 관리하는 리소스의 목록과 그 속성을 담은 파일(terraform.tfstate, JSON)이다. Terraform이 State를 두는 이유는 하나다. 코드에 적힌 논리적 리소스와 클라우드에 존재하는 물리적 리소스를 이어 주는 매핑이 필요하기 때문이다.

코드에는 aws_instance.web이라는 논리 이름만 있고, State가 그것을 실제 인스턴스 ID인 i-0abc123def에 매핑한다. 그 ID가 클라우드의 실제 EC2 인스턴스를 가리킨다

코드에는 aws_instance.web이라고만 적혀 있다. 이게 클라우드의 어떤 인스턴스인지(i-0abc123def)는 State가 기억한다. State가 없으면 Terraform은 plan을 돌릴 때마다 “이 코드가 가리키는 실제 리소스가 뭐지?”를 알 수 없어서, 매번 전부 새로 만들려 든다.

State에서 실무적으로 반드시 알아야 할 것 세 가지가 있다.

Remote backend. State 파일을 로컬에 두면 팀 협업이 불가능하다. 내 노트북에만 State가 있으면 동료는 그 매핑을 모른다. 그래서 실무에서는 State를 원격 저장소(S3, GCS, HCP Terraform 등)에 둔다. 이걸 backend라 부른다.

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

State locking. 두 사람이 동시에 apply하면 State가 깨진다. 그래서 backend는 apply 동안 State를 잠근다(lock). S3 backend는 락 상태를 함께 관리하고, HCP Terraform은 run을 큐에 직렬화한다. 락 덕분에 “동시 apply로 State가 반쯤 갱신되는” 사고가 막힌다.

민감정보 노출. State에는 리소스의 모든 속성이 평문으로 저장된다. DB 비밀번호, 생성된 인증서 키, IAM 시크릿까지. 이건 Terraform의 구조적 특성이라 피할 수 없다.

State는 평문 비밀 저장소다. outputsensitive = true를 붙이면 CLI 출력에서는 가려지지만 State 파일 안에는 그대로 평문으로 남는다.

그래서 State backend는 저장 시 암호화(S3 SSE 등)와 접근 통제를 반드시 걸고, State 파일은 절대 Git에 커밋하지 않으며(.gitignore*.tfstate), 진짜 비밀은 Terraform이 만들게 하기보다 Vault, Secrets Manager 같은 전용 시스템에서 참조하는 쪽을 택한다.

Drift

State의 전제는 “Terraform 밖에서는 아무도 인프라를 안 건드린다”이다. 현실은 다르다. 누군가 콘솔에서 급하게 보안 그룹 포트를 열고, 장애 대응 중에 인스턴스 타입을 바꾼다. 그 순간 State가 기억하는 모습과 실제 인프라가 어긋난다. 이걸 drift라 한다.

Configuration과 State는 instance_type이 t3.micro로 일치하는데, 실제 인프라만 콘솔에서 t3.large로 수동 변경돼 State와 어긋난 상태가 drift다

drift를 잡는 건 결국 plan이다. plan이 refresh 단계에서 실제 인프라(t3.large)를 읽어 State(t3.micro)와 다름을 발견하면, 코드(t3.micro)에 맞추기 위해 “인스턴스를 t3.micro로 되돌리겠다”는 계획을 보여준다. 즉 Terraform의 기본 태도는 코드를 진실로 보고 현실을 코드에 맞추는 것이다.

drift를 다루는 실무 패턴은 상황에 따라 갈린다.

되돌린다. 수동 변경이 실수였다면 그냥 apply해서 코드 상태로 복원한다. 가장 흔하고 무난한 방법이다.

코드에 반영한다. 수동 변경이 옳았다면(예: 정말 t3.large가 필요했다면) 코드를 t3.large로 고쳐 커밋한다. 그러면 다음 plan에서 diff가 사라진다.

현실만 State에 흡수한다. 코드는 그대로 두고 State만 실제에 맞추고 싶을 때는 refresh-only 모드를 쓴다.

terraform plan -refresh-only    # State를 실제에 맞추는 변경만 미리보기
terraform apply -refresh-only   # 코드는 안 건드리고 State만 갱신

애초에 막는다. 가장 좋은 drift 대응은 drift가 안 생기게 하는 것이다. 프로덕션 리소스에 대한 콘솔 쓰기 권한을 회수하고(사람은 읽기만, 변경은 Terraform 파이프라인만), CI에서 정기적으로 plan을 돌려 drift가 감지되면 알림을 보낸다. HCP Terraform은 이 drift detection을 기능으로 제공한다.

Refactoring State

인프라 코드도 리팩터링한다. 리소스 이름을 바꾸고, 모듈로 묶고, 이미 손으로 만든 리소스를 Terraform 관리로 편입시킨다. 문제는 이 작업들이 전부 State의 매핑을 건드린다는 점이다. 순진하게 하면 Terraform은 “옛 리소스 destroy + 새 리소스 create”로 계획해서 멀쩡한 인프라를 날린다.

과거에는 terraform state mv 같은 명령을 손으로 쳤지만, 지금은 코드 안의 선언형 블록으로 처리한다. 이게 훨씬 안전하다. 리뷰와 버전 관리가 되기 때문이다.

moved. 이름/위치를 바꿀 때. 리소스 주소가 바뀌어도 같은 실물이라는 걸 Terraform에 알린다.

moved {
  from = aws_instance.web
  to   = aws_instance.app_server
}

이렇게 적으면 plan은 destroy/create 대신 “주소만 옮김”으로 계획한다.

import. 기존 리소스를 관리에 편입할 때. 콘솔에서 손으로 만든 리소스를 Terraform이 접수하게 한다. import 블록으로 적으면 plan/apply 흐름 안에서 처리된다.

import {
  to = aws_instance.legacy
  id = "i-0abc123def"
}

resource "aws_instance" "legacy" {
  # import 후 실제 속성에 맞춰 채운다
  ami           = "ami-0abc1234"
  instance_type = "t3.micro"
}

removed. 관리에서 뺄 때(실물은 유지). 코드에서 리소스를 지우면 Terraform은 실물도 destroy한다. 실물은 남기고 관리만 놓고 싶으면 removed 블록을 쓴다.

removed {
  from = aws_instance.legacy

  lifecycle {
    destroy = false   # 실제 리소스는 남기고 State에서만 제거
  }
}

세 블록은 모두 State 조작을 코드로 선언하고, plan으로 미리 확인하고, PR로 리뷰한다. 손으로 State를 만지던 시절보다 사고 확률이 크게 낮아진다.

Patterns

규모가 커지면 반복되는 구성 방식이 있다.

Module. 관련 리소스를 묶어 재사용 단위로 만든다. 함수와 같다. 입력(variable)을 받고 출력(output)을 내며, 내부 구현은 감춘다. “VPC + 서브넷 + 라우팅”을 network 모듈로 묶어 두면 환경마다 호출만 하면 된다.

module "network" {
  source = "./modules/network"

  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}

환경 분리. dev, stage, prod를 나누는 방법은 크게 둘이다. 하나는 환경마다 디렉터리(environments/prod, environments/dev)를 두고 각자 별도 State를 갖게 하는 것. 다른 하나는 CLI workspace(terraform workspace new prod)로 하나의 코드에 여러 State를 붙이는 것이다. 실무에서는 blast radius를 줄이고 설정 차이를 명시적으로 두기 위해 디렉터리 분리를 더 선호한다. CLI workspace는 환경 간 차이가 거의 없을 때만 가볍게 쓴다.

CLI의 terraform workspace와 HCP Terraform의 Workspace는 이름만 같고 다른 개념이다. 전자는 하나의 backend 안에 State를 여러 개 두는 장치, 후자는 실행 환경과 State와 변수를 통째로 담는 관리 단위다.

Remote state 참조. 한 프로젝트의 output을 다른 프로젝트가 읽는다. network 코드가 만든 VPC ID를 app 코드가 참조하는 식이다. terraform_remote_state data source로 다른 State의 output을 읽어 온다. 다만 이건 프로젝트 간 결합을 만들므로, 안정적인 값만(예: VPC ID) 노출하는 게 좋다.

Best Practices

101 수준에서 몸에 붙여 두면 좋은 습관들이다.

  • Plan을 리뷰한다. apply 전에 plan 출력을 사람이 읽거나 CI가 PR에 붙인다. 특히 replace(-/+)와 destroy(-)는 눈으로 확인한다.
  • State는 원격에 두고 커밋하지 않는다. backend를 쓰고, *.tfstate.gitignore에 넣는다.
  • 버전을 고정한다. Terraform 버전(required_version)과 provider 버전(~> 5.0)을 명시해, 팀원마다 다른 버전으로 State가 뒤틀리는 일을 막는다.
  • Blast radius를 작게. 하나의 거대한 State에 전부 몰아넣지 않는다. 네트워크, 데이터, 앱을 별도 State로 쪼개면 한 곳의 실수가 전체로 번지지 않는다.
  • 수동 변경을 금지한다. 프로덕션은 콘솔 쓰기 권한을 회수하고 변경은 Terraform으로만. drift의 근원을 없애는 가장 확실한 방법이다.
  • 최소 권한. Terraform이 쓰는 자격증명은 필요한 리소스에만 권한을 준다. State에 담기는 파급을 생각하면 이건 보안 문제이기도 하다.

HCP Terraform

여기까지는 Terraform을 각자 노트북이나 CI에서 돌리는 그림이었다. 팀 규모가 되면 실행 환경 자체를 중앙화하고 싶어진다. HCP Terraform(구 Terraform Cloud)이 그 자리를 채운다. self-hosted가 필요하면 같은 제품의 배포판인 Terraform Enterprise를 쓴다.

HCP Terraform이 제공하는 것을 나열하면 이렇다.

  • Remote run. planapply를 로컬이 아니라 HCP Terraform의 실행 환경에서 돌린다. 자격증명이 개인 노트북에 흩어지지 않는다.
  • State 관리. State 저장, 암호화, 락, 버전 히스토리를 기본 제공한다. S3 backend를 손으로 꾸리지 않아도 된다.
  • VCS 연동. GitHub 등 저장소에 PR이 열리면 자동으로 plan을 돌려 결과를 PR에 붙이고, main에 머지되면 apply한다.
  • Policy as Code. apply 전에 정책을 강제한다. Sentinel(HashiCorp)이나 OPA로 “퍼블릭 S3 버킷 금지”, “승인된 인스턴스 타입만” 같은 가드레일을 코드로 건다.
  • Private registry, drift detection 등.

GitHub에 PR이 열리면 HCP Terraform이 plan을 돌리고, Sentinel이나 OPA 정책 검사를 통과하면 apply해서 클라우드에 반영한다. plan부터 apply까지는 HCP Terraform Workspace 안에서 실행되고 State도 그곳에 저장된다

HCP Terraform의 관리 단위는 Workspace다. 하나의 Workspace가 하나의 State, 하나의 실행 환경, 그리고 변수 집합을 담는다. 대략 “하나의 Terraform 프로젝트 = 하나의 Workspace”로 생각하면 된다.

Workspaces as Code

여기서 흥미로운 지점이 나온다. Workspace, 변수, 팀 권한 같은 HCP Terraform의 설정도 결국 리소스다. 그리고 리소스라면 Terraform으로 관리할 수 있다. 이걸 담당하는 게 공식 tfe provider다.

provider "tfe" {
  # token은 환경변수 TFE_TOKEN 등으로 주입
}

resource "tfe_workspace" "app_prod" {
  name         = "app-prod"
  organization = "my-org"

  vcs_repo {
    identifier     = "my-org/app-infra"
    branch         = "main"
    oauth_token_id = var.oauth_token_id
  }
}

resource "tfe_variable" "region" {
  workspace_id = tfe_workspace.app_prod.id
  key          = "aws_region"
  value        = "ap-northeast-2"
  category     = "terraform"
}

이렇게 하면 “Workspace 20개를 콘솔에서 클릭으로 만들고 변수를 손으로 채우는” 대신, 코드로 선언하고 리뷰하고 재현할 수 있다. 새 팀이 들어오면 Workspace 정의를 복사해 값만 바꾸면 된다. 인프라를 코드로 관리하는 그 원칙을, 인프라를 관리하는 도구 자체에 다시 적용하는 셈이다.

여기엔 유명한 닭과 달걀 문제가 있다. 부트스트랩이다. Workspace를 Terraform으로 만들려면 그 Terraform을 돌릴 Workspace가 먼저 있어야 한다. 보통은 이렇게 푼다.

손으로 하나만 만든 management Workspace가 tfe provider로 app-prod, app-dev, data-prod를 비롯한 나머지 Workspace 전부를 코드로 생성하고 관리한다

관리용 Workspace 하나만 손으로 만들고(또는 로컬 State로 부트스트랩하고), 그 Workspace가 tfe provider로 나머지 Workspace를 전부 찍어낸다. 이후로는 Workspace 추가나 변경도 전부 PR을 통해 이뤄진다. 딱 하나의 수동 리소스만 남기고 나머지를 코드로 끌어올리는 이 패턴은 HCP Terraform 운영의 사실상 표준이다.

Testing and Policy

인프라 코드도 테스트한다. 두 층위가 있다.

모듈 단위 테스트. Terraform 1.6부터 들어온 native test 프레임워크(.tftest.hcl)로, 모듈에 특정 입력을 줬을 때 원하는 리소스/속성이 나오는지 검증한다.

# tests/network.tftest.hcl
run "creates_vpc" {
  command = plan

  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR가 기대값과 다르다"
  }
}

command = plan이면 실제로 만들지 않고 계획만으로 검증하고, command = apply면 임시로 리소스를 만들었다가 테스트 후 정리한다.

정책 단위 가드레일. 앞서 HCP Terraform에서 언급한 Policy as Code가 이 층이다. 개별 모듈이 맞게 동작하는지가 아니라, 조직 전체 규칙(“암호화 안 된 볼륨 금지” 같은)을 apply 직전에 강제한다. 테스트가 “이 코드가 의도대로 동작하나”를 본다면, 정책은 “이 변경이 조직 규칙을 어기지 않나”를 본다. 둘은 겹치지 않고 보완한다.

Summary

Terraform의 전부는 결국 하나의 문장으로 돌아온다. 원하는 상태(Configuration)와 마지막으로 기록한 상태(State)와 실제 인프라(Real), 이 셋을 맞춰 나가는 도구다.

  • plan은 이 셋의 차이를 계산하는 함수, apply는 그 계획을 실행하는 명령이다.
  • State는 코드의 논리적 리소스와 실물을 잇는 매핑이고, 평문 비밀 저장소이므로 원격 backend와 접근 통제가 필수다.
  • Drift는 State와 실제가 어긋난 상태이며, plan으로 감지하고 되돌리거나 코드에 반영하거나 refresh-only로 흡수한다. 근본 대응은 수동 변경 자체를 막는 데 있다.
  • moved, import, removed 블록으로 State를 코드 리뷰가 되는 방식으로 안전하게 리팩터링한다.
  • HCP Terraform은 실행, State, 정책을 중앙화하며, 그 Workspace조차 tfe provider로 코드가 관리한다. 관리용 Workspace 하나만 손으로 남기는 부트스트랩이 표준이다.

처음에는 명령 세 개(init, plan, apply)와 3-way 모델만 손에 쥐면 된다. 나머지는 여기에 필요할 때 하나씩 더하면 된다.

References