(base) nayongjun@nayongjun-ui-noteubug cherrypic-iac % cd terraform/env/dev
이번에 새로운 프로젝트를 하면서 Terraform을 적용해 보았는데요, 제가 순차적으로 공부했던 순서와 Terraform CI/CD를 구축하기까지 모든 내용을 요약해 보겠습니다.
미리 저의 IAC github repository를 첨부하겠습니다. 간단히 설명하고 넘어가는 부분도 많아서 중간 중간에 참고하시면 보시면 좋을 것 같아요. 근거가 있는 경우 레퍼런스 링크를 달아주었고 저의 주관적인 판단도 많이 있다는 점 참고하시면 좋을 것 같습니다.
📌 왜 테라폼을 사용하면 좋을까요?
Terraform이란 클라우드 인프라를 코드로 관리(IaC)를 할 수 있는 유용한 도구입니다. 지금의 제 상황에 대입해서 장점들을 생각해 보자면 다음과 같습니다.
- AWS 환경 문서화를 통해서 매번 팀원들에게 설명할 필요없이 테라폼 코드로 관리 가능.
- 설정이 문서화되고 버전 관리가 되니까 변경 추적이 용이하다
- 지원이 없는 팀프로젝트의 경우 서버비를 아껴야 하는데 서버를 2개 나눠서 운영하거나 1년마다 서버 이동도 편하게 가능.
- 지금 운영하는 프로젝트 같은 경우는 나중에 개발팀이 리크루팅 될 수도 있는데 제가 없어도 클라우드 관리는 아무 문제 없어질 예정
장점들이 굉장히 많죠?
다들 테라폼의 장점과 필요성에 대해서는 이미 충분히 알고 있다고 생각하기 때문에 간단히 설명하고 넘어가겠습니다.
📌 선행 지식 공부
우선 HCL언어 자체와 테라폼의 기본적인 문법에 대해서는 따로 설명드리지는 않겠습니다. 저 같은 경우는 다음과 같은 공식문서들을 참고했습니다.
1. HashCorp에서 제공하는 AWS에 terraform을 적용하는 guide
- AWS 가이드는 5개의 섹션으로 나눠져있는데 1시간 정도면 모두 이해할 수 있을 정도로 간단합니다. 공식 문서는 과하게 구체적인 부분까지도 정리를 했을 거라고 가끔 생각을 하는데, 해당 워크 쓰루는 딱 필요한 내용만 알려주기 때문에 저기서 언급하는 내용은 모두 이해하셔야 합니다.
2. HashCorp에서 제공하는 Module에 대한 guide
- 테라폼에서 중요한 모듈이라는 개념에 대한 워크 쓰루입니다. 10개로 구성된 모듈 워크 쓰루 같은 경우는 처음 보면 조금 난해한 부분이 있는데 가볍게 이해하시고 오시면 좋습니다.
위 자료들에 대한 숙지가 되어 있다고 생각하고 진행해 보도록 하겠습니다.
📌 테라폼 프로젝트 구조
테라폼도 DDD와 멀티 모듈처럼 사람들이 약속하고 사용하는 구조가 있습니다. 가장 먼저 어떤 프로젝트 구조를 사용할지 생각해 보겠습니다.
(이 부분에서 많은 도움을 받은 마켓 컬리 DevOps팀의 2021년 기술 블로그)
첫 번째 링크에서는 어떤 module structure를 사용할지에 대한 문서이고 다음은 code 구조를 어떻게 하면 좋을지에 대한 문서입니다.
마켓 컬리 같은 경우는 아래와 같은 구조를 사용했는데요..
.
├── README.md
├── env // 실제 Provision될 Environment Code 위치
│ ├── dev
│ ├── qa
│ ├── stg
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ ├── variables.tf
│ │ └── version.tf
└── modules // AWS modules 코드
├── acm
├── compute
│ ├── alb
│ │ ├── main.tf
│ │ ├── output.tf
│ │ ├── variables.tf
│ │ └── versions.tf
│ ├── asg
│ ├── efs
│ ├── lb_listener
│ ├── lt
│ ├── nlb
│ ├── sg
├── database
│ ├── ec
│ ├── rds
│ └── rds-aurora
├── developer_tool
│ ├── codebuild
│ └── codedeploy
├── iam
├── mq
└── network
└── route53
간단히 설명을 드리면, AWS 리소스를 용이하게 만들 수 있도록 만드는 모듈(모듈에 대해서는 아래에서 구체적으로 설명)을 하나의 디렉토리로 빼놓고 , dev(개발 환경) 또는 prod(운영 환경)에 따라서 분리된 AWS 환경 구성 코드를 env에 모아두었습니다.
사람들마다 코드 구조가 조금씩 다르지만 , 위처럼 module 디렉토리를 만드는 것은 거의 확정적이라고 생각하시면 됩니다. 그런데 AWS 환경 구성 코드를 env로 분리하지 않는 경우도 있습니다.
위의 블로그에서는 다음과 같이 구조를 작성했는데요,
.
├── README.md
├── images
│ └── archutecture.png
├── modules
│ ├── ec2
│ │ ├── README.md
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── variables.tf
│ │ └── version.tf
│ ├── lb
│ │ ├── main.tf
│ │ ├── output.tf
│ │ ├── provider.tf
│ │ └── variables.tf
│ ├── rds
│ │ ├── mysql
│ │ │ ├── README.md
│ │ │ ├── data.tf
│ │ │ ├── main.tf
│ │ │ ├── output.tf
│ │ │ ├── provider.tf
│ │ │ ├── test_vars
│ │ │ │ └── test.tfvars
│ │ │ └── variables.tf
│ │ └── postgresql
│ └── vpc
│ ├── README.md
│ ├── main.tf
│ ├── output.tf
│ ├── provider.tf
│ └── variables.tf
└── project
├── 3Tier
│ ├── asg.tf
│ ├── asg_policy.tf
│ ├── data.tf
│ ├── ec2.tf
│ ├── ec2_security_group.tf
│ ├── env
│ │ └── test.tfvars
│ ├── outputs.tf
│ ├── provider.tf
│ ├── rds.tf
│ ├── scripts
│ │ ├── common
│ │ │ ├── amz2_init.sh
│ │ │ └── jenkins_install.sh
│ │ ├── was
│ │ │ ├── setenv.sh
│ │ │ ├── shutdown.sh
│ │ │ ├── startup.sh
│ │ │ ├── tomcat.service
│ │ │ ├── tomcat_install.sh
│ │ │ └── was_userdata.sh
│ │ └── web
│ │ └── nginx_tomcat.tftpl
│ ├── variables.tf
│ ├── vpc.tf
│ ├── was-nlb.tf
│ └── web-alb.tf
├── common
│ ├── acm
│ │ ├── data.tf
│ │ ├── main.tf
│ │ ├── output.tf
│ │ ├── provider.tf
│ │ └── variables.tf
│ ├── etc
│ │ ├── backend.tf
│ │ ├── provider.tf
│ │ ├── s3.tf
│ │ └── variables.tf
│ ├── iam
│ │ ├── data.tf
│ │ ├── main.tf
│ │ ├── provider.tf
│ │ └── variables.tf
│ └── route53
│ ├── main.tf
│ ├── provider.tf
│ └── variables.tf
├── packer
│ └── was
│ ├── packer.pkrvars.hcl
│ ├── tomcat_ami.pkr.hcl
│ └── tomcat_install.sh
└── test
├── data.tf
├── provider.tf
└── variables.tf
이는 환경별로 구성 코드를 분리하지 않고 하나의 구조 코드를 가지고 있는 상태에서 Project>env> .tfvars를 통해서 환경별로 다르게 변수를 주입해주고 있습니다.
하지만, 제 개인적인 생각으로는 마켓 컬리 처럼 env에 따라서 AWS 구성 코드를 작성하는 것이 더 좋다고 생각했습니다.
왜냐하면, tfvars를 통해서 변수 주입을 다르게 해서 환경을 분리한다면.. 모든 환경이 비슷한 구조를 가지게 되기 떄문입니다.
예를 들어서,
"VPC를 1개 만들고 subnet을 2개 연결해! "라는 코드를 작성했다고 하고 입력값을
VPC의 이름과 Subnet 각각의 이름을 입력값으로 남겨놨다고 생각해 봅시다.
이것을 tfvars를 통해서 prod와 dev로 나눈다 해도, 이름 정도만 자유롭게 지을 수 있을 뿐 VPC가 1개이며 subnet 2개가 연결되어 있다는 구조에서 벗어나기는 힘들게 될 것입니다.
따라서, 저는 마켓 컬리처럼 module 디렉토리를 두고 env별로 구분해서 AWS 환경 구성 코드를 작성하기로 했습니다.
이어서 모듈 디렉토리의 nested 모듈들을 살펴보도록 하겠습니다.
nested 모듈 같은 경우에는 어느 정도 프로젝트마다 약속을 해놓고 사용하는 것이 좋습니다. 저희는 마켓컬리와 조금은 다른 방식을 사용했는데요.
modules/
├ network/
│ ├ vpc/
│ ├ subnet/
│ ├ nat/
│ └ route53/
├ compute/
│ ├ alb/
│ ├ asg_web/
│ └ asg_app/
├ database/
│ └ rds/
├ storage/
│ └ s3/
└ iam/ <- 이건 사용하지 않을 것 같지만 우선 포함
- network: 연결을 담당하는 모든 것들
- compute: 컴퓨팅 인스턴스
- database: 데이터 베이스 (rds, elastiCache, 그리고 dynamoDB 등등..)
- storage: 스토리지 (s3)
이 부분에 대해서는 정해진 것은 없으나, 팀원들과 상의 후에 결정하시면 좋을 것 같습니다.
⭐ 중요! : 테라폼을 적용하면서 수동으로 관리될 부분과 자동(terraform)으로 관리할 부분들을 나눠야 하는데요 마켓 컬리 팀에서도 IAM은 수동으로 관리를 했고, 작은 규모인 저희 프로젝트에서는 2명의 팀원이 어드민을 나눠가졌기 때문에 terraform 문서에서는 IAM 모듈을 따로 관리하지 않았습니다.
이외에도 수동 관리에 대한 많은 인사이트가 있는데 뒤로 가면서 천천히 설명드리도록 하겠습니다.
📌 모듈 내부 구조
이제 하나의 모듈 내부와 어떻게 작성을 해야 하는지 알아보도록 하겠습니다.
module/
├── LICENSE # 라이선스 정보
├── README.md # 사용법 및 설명
├── versions.tf # Terraform, Provider 버전 제약
├── main.tf # 리소스 정의
├── variables.tf # 입력 변수
├── outputs.tf # 출력 변수
├── examples/ # 활용 예시
└── docs/ # 상세 문서
모듈 내부는 많으면 위와 같은 사항들을 모두 넣을 수 있습니다.
핵심적인 부분은
- main.tf : 구성 코드 그 자체
- variables : 요구하는 변수들
- outputs : 출력하는 변수들
이 3가지입니다. 저는 추가적으로 모듈마다 version.tf와 README.md 정도를 추가해서 사용했습니다.
README는 모듈에 대한 간단한 설명과 사용법을 넣었고 version을 root 모듈이 아닌 하위 모듈마다 하나씩 둔 이유는 추후에 모듈들이 수정될 때 각각 따로 수정되며 버전이 달라질 수도 있다고 생각했기 때문입니다. 이 구조 역시 정답이 있는 것은 아니며, 합리적인 근거를 가지고 적용하시면 됩니다.
✅ 참고로 terraform-docs를 이용해서 README를 자동으로 variables와 output을 인식하여 작성하도록 할 수도 있습니다. 저는 경로 문제와 여러 오류가 발생해서 적용하지는 않았습니다.
📌 모듈은 어떤 기준으로 작성하면 될까?
모듈은 만들어 놓고 필요할 때마다 가져다 사용할 수 있는, Java에 비유하자면 클래스와 비슷하다고 보면 됩니다.
가령, 다음과 같이 구성된 VPC 모듈이 있습니다.
//main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = merge(
var.tags,
{
Name = "cherrypic-vpc"
}
)
}
//variables.tf
variable "cidr_block" {
description = "VPC CIDR 블록"
type = string
}
variable "tags" {
description = "공통 태그"
type = map(string)
default = {}
}
// outputs.tf
output "id" {
description = "VPC ID"
value = aws_vpc.main.id
}
저의 VPC 모듈을 하나의 vpc 리소스만을 생성하며, 입력값으로는 공통 태그와 cidr 주소만 받고 있습니다. 이것 이외에 VPC에 설정할 부분이 많을 수 있으나 저는 일반적으로 사용 가능 + 확장성 + 내 프로젝트 적합성을 고려해서 만들었고, 간단히 필요한 것만 받고 필요한 부분만 출력하는 것입니다.
⭐ 또한, 모듈은 꼭 하나의 resource만 생성할 필요는 없습니다.
예를 들어서, 저는 위에서 VPC 모듈에서는 VPC 자체만을 만들도록 했으나, 누군가는 내부에 서브넷까지 만들어지는 것을 포함해서 vpc 모듈을 만들 수도 있습니다. 이렇게 생각하면 "모듈 하나가 너무 무겁지 않나..?"라는 생각을 할 수도 있는데, 그런 기준을 가지고 만들어 주시면 됩니다.
좀 더 합리적인 예시를 들어보겠습니다.
resource "aws_security_group" "main" {
name = "${var.purpose}${var.env != "" ? "-${var.env}" : ""}-sg"
description = var.description
vpc_id = var.vpc_id
tags = merge(
var.tags,
{
Name = "${var.purpose}${var.env != "" ? "-${var.env}" : ""}-sg"
}
)
}
resource "aws_security_group_rule" "ingress_cidr" {
for_each = {
for idx, rule in var.ingress_rules :
idx => rule if try(rule.use_cidr, false)
}
type = "ingress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
security_group_id = aws_security_group.main.id
}
resource "aws_security_group_rule" "ingress_sg" {
for_each = {
for idx, rule in var.ingress_rules :
idx => rule if try(rule.use_sg, false)
}
type = "ingress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
source_security_group_id = each.value.source_security_group_id
security_group_id = aws_security_group.main.id
}
resource "aws_security_group_rule" "egress" {
for_each = {
for idx, rule in var.egress_rules :
idx => rule
}
type = "egress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
security_group_id = aws_security_group.main.id
}
저의 보안그룹 모듈의 main.tf입니다.
코드를 확인하면, 인바운드 규칙과 아웃바운드 규칙이 보안그룹 자체와는 다른 리소스인 것을 확인할 수 있는데 , 그렇다면 보안그룹 모듈과 규칙 모듈을 각각 만들어야 할까요? 규칙 모듈은 보안그룹이 없으면 의미가 없기 때문에 저는 포함시켜서 모듈을 구성했습니다. 위와 같이 일반적인 사용이 가능한 기준과 프로젝트에 필요한 부분을 생각하면서 만들면 되겠습니다.
⭐ variables와 ouputs
입력 변수와 출력 변수 또한 필요에 따라서 다르게 설계하면 되는데요,
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = merge(
var.tags,
{
Name = "cherrypic-vpc"
}
)
}
다시 VPC로 돌아와서 저는 cherrypic이라는 이름의 앱을 개발하고 있어서 이름을 하드 코딩했지만 만약 어디에 쓰일지 모른다면?
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = merge(
var.tags,
{
Name = "${vars.app_name}-vpc"
}
)
}
위처럼 변수로 처리해 주고 모듈을 사용하면 됩니다.
이렇게 모든 모듈들을 만들어 주시면 되는데요,
- HashCorps Naming 컨벤션 : 이름 컨벤션
- Terraform Registry : 리소스 코딩 안내 사항이 모두 적혀있음!
이걸 참고해서 작성하면 아주 좋습니다!
📌 리소스 Naming Conventions & Tags
바로 위의 vpc예시를 보시면 tag를 받도록 했었는데요,
variable "tags" {
description = "공통 태그"
type = map(string)
default = {}
}
variables를 가보면 map(string)으로 확장성 있게 받아주었는데요, 이는 나중에 env환경마다 코딩을 할 때 local.tf를 이용해서 한 번에 적용해 주기 위함입니다. 실무에서는 거의 항상 여러 가지 태그를 붙여서 인스턴스를 분기하고 관리하는데, 이번 프로젝트에서는 다음과 같은 tag 구조를 사용해 보았습니다.
locals {
env = "dev"
common_tags = {
App = "cherrypic"
Environment = "dev"
Team = "backend"
ManagedBy = "terraform"
}
}
그리고 Naming 컨벤션은 다음과 같이 사용해 보았습니다.
✅ Network
VPC
- {myApp}-vpc
Subnet
- {myApp}-[public or private]-subnet-[index]
Route Table
- {myApp}-[public or private]-rt
IGW
- {myApp}-igw
Elastic Ip
- 이름 없음
Security Group
- {용도}-{Env}-sg
Application Load Balancer
- {myApp}-alb-{env}
Target Group (로드 밸런서)
- {myApp}-tg-{env}
✅ Compute
EC2
- {myApp}-{용도}-{env}
Bastion Host 예외
- {myApp}-bastion-host
✅ database
RDS
- {myApp}-{용도}-{env} ex) cherrypic-mysql-dev
서브넷 그룹
파라미터 그룹
ElastiCache
- {myApp}-{용도}-{env} ex) cherrypic-cache-dev
✅ Storage
S3
- {myApp}-bucket-{env}
- 환경별로 나눠질 필요가 있는가?
- 용도나 접근 제한이 있는가?
- 다중으로 생성되어 index가 필요한가? ex) subnet
- 이름 자체가 필요가 없는가?
여러가지 기준을 가지고 어느 정도 팀원들끼리 Naming Convention을 가져가 보았는데요.. 이 부분 역시 자유입니다!
정한 컨벤션을 바탕으로 모듈에서 이름 명명 규칙을 잘 적용하면 좋을 것 같아요.
이렇게 모듈을 완성해 주시면 됩니다.
📌 환경별 코드를 작성 시작 - bootstrap
이제 모듈이 완성되었으니 환경별로 코드 작성을 시작해 봅시다. 저 같은 경우는 dev를 작성하는 예시로 시작해 보도록 하겠습니다.
우선 테라폼 코드를 적용하기에 앞서서 각각 인스턴스와 모듈의 상태인 tfstate을 보관하는 S3가 필요합니다. 마켓 컬리 블로그가 쓰인 2021년도에는 S3만으로 tfstate 동시성 제어가 안돼서 dynamoDB를 함께 사용했는데, 2025년 5월 정도부터 terraform 1.11 버전이 도입되면서 공식적으로 S3만으로 tfstate 관리가 가능해졌습니다.
그런데 dev 환경의 main.tf를 짜기 전에 이 S3 버킷이 필요합니다.
"어..? AWS 인스턴스를 만들기 위해서 Terraform 문서화를 하는데 Terraform 문서화를 위해서 S3가 필요하다고..?"
모순이죠..? 바로 이런 부분이 수동적으로 관리를 해야 한다고 말씀드린 부분입니다.
보시면 backend.tf라는 파일 내부에는 어디 S3 버킷에 tfstate가 보관되는지 작성을 해주어야 하는데요. 반드시 환경별 HCL코드를 작성하기 전에 S3 버킷이 필요합니다.
❓근데 여기까지 생각하면 tfstate가 도대체 무엇이고 왜 이렇게 조심스럽게 관리를 해야 하는가? 생각하게 될 수도 있습니다.
⭐⭐⭐⭐⭐
tfstate는 말 그대로. tf 파일들의 상태를 기록하는 파일입니다. 그런데 이게 왜 중요할까요..?
비유를 하자면, tfstate를 보관하는 S3는 테라폼의 github라고 생각하셔도 됩니다. AWS의 형상관리 도구인 것이죠.
예를 들어서, remote AWS의 vpc를 tfstate로 추적하고 있었는데 로컬 파일이 바뀌게 되면 어떤 부분이 바뀌었는지 tfstate를 보고 판단하고 terraform이 알아서 반영해 주는 구조입니다.
만약, remote에 분명 vpc가 있는데 tfstate으로 관리가 되고 있지 않다..? -> 그럼 로컬 테라폼에서는 "원격에 VPC가 없네? 새로 만들어야지! "라는 어처구니없는 plan을 제시할 수도 있습니다.
당연하게도, 현재 저는 terraform으로 만들어지지 않은 remote 인프라를 테라폼으로 바꾸는 과정에 있기 때문에 remote의 인스턴스들이 tfstate로 관리되고 있지 않겠죠? 나중에 어떻게 해결하는지 이 부분을 유심히 봐주세요!
다시 돌아와서 S3를 수동으로 만들어 주어도 되지만, 최초 세팅 파일 느낌의 bootstrap을 만들기도 합니다.
전 다음과 같이 terraform 경로 아래에 bootstrap 파일을 만들어 주었습니다.
terraform {
required_version = ">= 1.12.0"
}
provider "aws" {
region = "ap-northeast-2"
access_key = var.access_key_id
secret_key = var.secret_access_key
}
module "tf_state_bucket" {
source = "../modules/storage/s3"
purpose = "tfstate"
environment = var.environment
enable_versioning = false
enable_sse = true
enable_block_public_access = true
tags = var.tags
}
main에서 다음과 같이 S3 버킷을 만들도록 세팅을 해두었고 README에 꼭 한번 최초 실행 해야 한다고 작성해 두었습니다.
이 코드를 실행하는 시점에는 S3 버킷이 없기 때문에 형상 관리가 불가능하고 당연히 backend.tf도 없게 되는 셈이죠.
이제 구체적으로 어떻게 모듈을 실행해야 하는지 알아보겠습니다.
위의 provider block을 보면 aws와 관련된 Access Key를 필요로 합니다. 이는 AWS의 IAM에서 발급받으시면 되는데요, 이는 민감정보입니다. 따라서, 깃허브에 올라가면 안 되겠죠? 저런 부분은 main.tf에서 하드 코딩 하는 것이 아닌 variables로 받도록 열어줍니다.
그리고 env나 tag같이 유동적으로 바뀔 수 있는 부분은 하드 코딩을 하지 않았습니다.
- 민감한 정보
- 변동성이 생길 수 있는 값
위와 같은 사항은 variables로 빼주는 것이 좋습니다. 그리고 env와 tags는 변동성이 생길 수 있는 값이지만, 민감하지는 않기 때문에
// prod.tfvars
environment = "prod"
tags = {
App = "cherrypic"
Environment = "prod"
Team = "backend"
ManagedBy = "terraform"
}
// dev.tfvars
environment = "dev"
tags = {
App = "cherrypic"
Environment = "dev"
Team = "backend"
ManagedBy = "terraform"
}
환경 별로 tfvars로 직접 주입을 해주는 방식으로 했고.
aws 토큰 같은 경우는 깃허브에 올리지 않고 로컬에서 환경 변수로 export해준 이후에 실행했습니다(CI/CD에서 주입될 경우 github secret 등을 활용할 수 있겠죠?). 이때 환경 변수화 하는 방법은,
- . env를 만들고 direnv를 조합해서 활용한다
- 매번 export로 그냥 넣어준다
두 가지 방법이 있는데 저는 첫 번째 방법이 좋다고 생각합니다.
이를 init, plan, apply 명령어를 통해서 실행해 주면 되는데 그 방법이 궁금하시다면 아래 README를 참고해 주세요.
# 📦 Terraform Bootstrap Module
bootstrap 모듈은 `dev` 또는 `prod` 환경을 실행하기 전에 반드시 선행되어야 하는 기본 세팅입니다.
- Terraform 상태 파일(tfstate) 저장을 위한 S3 버킷을 생성하는 부트스트랩 모듈입니다.
- CI/CD 및 협업 환경에서 Terraform 충돌을 방지하고 안전한 상태 관리가 가능하도록 합니다.
---
## 📁 구성 모듈
| 모듈 이름 | 설명 |
|------------------|------|
| `tf_state_bucket` | Terraform 상태 파일을 저장할 S3 버킷 생성 |
---
## ⚙️ 사용법
> ❗ **주의:** 이 과정은 ci/cd에 포함되지 않으며 반드시 최초 1회 수동 실행되어야 합니다.
- 환경.secret.tfvars를 만들고 내부에 AWS 접속 관련 Key를 넣어주세요 (gitignore 됩니다).
> 예시) dev.secret.tfvars
>
> access_key_id (access key)
>
> secret_access_key= (secret key)
- 그 후 터미널에서 다음 명령어를 실행해 주세요.
```bash
# example : dev 환경 초기화
# 초기화: provider 설치 및 backend 연결
terraform init
# 계획 확인: dev 환경 기준으로 리소스 변경 내역 확인
terraform plan \
-var-file="dev.tfvars" \
-var-file="dev.secret.tfvars"
# 적용: 문제가 없다면 실제 인프라 적용
terraform apply -var-file=dev.tfvars
```
- 이후에 생기는 `tfstate`과 같은 파일은 모두 gitignore되어 있으며 커밋하지 않으셔도 됩니다.
참고로 bootstrap 환경에서 apply를 하면 lock 파일이 생기고(이는 자연스러운 과정), tfstate가 로컬에 생기게 됩니다. 원래는 tfstate는 S3 버킷에 저장되는데 bootstrap에서는 없기 때문에 로컬에 생성이 됩니다. gitIgnore 해두시면 되겠습니다.
이제 dev 환경의 terraform을 작성할 준비가 모두 끝났어요..!
📌 dev환경을 terraform화 해보자
잠깐 다시 돌아와서 마켓 컬리의 프로젝트 구조입니다. dev 환경의 코드를 작성하려고 한 순간 한 가지 의문이 들게 되었습니다. 현재 dev 환경에 존재하는 인스턴스와 구조를 우선 코드로 옮겨주어야 하는데 main.tf 하나에 모두 넣기에는 코드가 너무 길어질 것 같다는 생각이 들었습니다 (실제로 작성해 보시면 압니다.. 마켓 컬리도 절대로 main.tf에 모두 넣지 않았을 거라고 확신합니다..^^).
아무튼 그래서 역할 별로 분리를 좀 해보았는데요,
(. terraform과 lock 파일은 Init을 했기 때문에 보이는 파일이라 무시하셔도 됩니다)
- backend.tf : 형상 관리 S3와 관련
- locals : 현재 환경에서 사용할 로컬 변수
- data.tf : 최신 외부 이미지를 가져오는 것과 관련된 코드
- providers.tf , terraform.tf : 각각 블록과 관련된 tf
이런 식으로 나누면서 사용하는 것이 좋다는 베스트 프렉티스가 있었습니다!
나머지는 제가 모듈을 분리했던 단위에 따라서 묶어주었습니다!
이런식으로 쭉 작성해 주시면 되는데, 내부 문법적인 부분들 보다는 제가 생각했던 인사이트 위주로 설명해 보겠습니다(코드가 궁금하다면 repository 링크를 참고해 주세요).
✅ 태그는 local 변수로 관리하자
// locals.tf
locals {
env = "dev"
common_tags = {
App = "cherrypic"
Environment = "dev"
Team = "backend"
ManagedBy = "terraform"
}
}
매번 인스턴스마다 태그를 변수로 입력받게 되면 너무 많은 값들을 입력해야 하는데요, 위처럼 통일된 로컬 태그 변수를 만들어 놓는다면,
// compute.tf 중 일부
module "bastion_dev" {
source = "../../modules/compute/ec2"
ami_id = "ami-03e38f46f79020a70" // Bastion backup AMI
instance_type = "t2.micro"
subnet_id = module.public_subnet_1.id
vpc_security_group_ids = [module.bastion_dev_sg.id]
associate_public_ip = true
key_name = "cherrypic-bastion-host-key"
root_volume_size = 30
root_volume_type = "gp3"
purpose = "bastion"
environment = ""
enable_eip = false
tags = local.common_tags
}
위처럼 local.common_tags로 중복과 코드량을 줄일 수 있습니다!
✅ 웬만한 값들은 하드 코딩하는 것이 좋지 않을까?
위의 compute.tf 예시를 보시면 재사용 모듈과는 다르게 대부분 값들을 하드 코딩한 모습을 볼 수 있습니다.
모듈을 만드는 과정에서는, 재사용되어야 하기 때문에 vars로 빼주었지만 이미 환경별로 분리된 dev 디렉토리 내부의 tf 파일들에서는 variables로 분리하고 .tfvars로 주입하는 것은 전혀 의미가 없고 오히려 가독성과 유지 보수성을 떨어뜨린다고 생각했습니다. 따라서, 저는 중복을 줄이고 여러 번 쓰이는 값들은 locals.tf에 넣어주고.
variables.tf로는 민감한 값들만 빼주고 CI/CD에서 github secrets로 주입하기로 했습니다.
// variables.tf
variable "access_key_id" {
description = "AWS access key id"
type = string
sensitive = true
}
variable "secret_access_key" {
description = "AWS secret access key"
type = string
sensitive = true
}
variable "username" {
description = "RDS 마스터 사용자 이름"
type = string
}
variable "password" {
description = "RDS 마스터 사용자 비밀번호"
type = string
sensitive = true
}
이렇게 dev 환경의 코드를 모두 작성해 주시면 되는데요, 저는 참고로 의존성 방향을 생각하면서 코딩을 했습니다. 특정 모듈의 output이 다른 모듈의 variable로 필요한 경우가 있어서.. 다음과 같은 순서로 했던 것 같아요. (vpc → igw → route table → security group → subnet → elastic ip)
dev환경의 코드를 모두 작성하셨다면(정확하게 작성했다고 생각한다면), 이제 어려운 부분을 시작해야 합니다. 바로 remote환경을 동기화하는 과정입니다.
📌 remote환경을 terraforming 하는 과정과 트러블 슈팅
이제 어려운 부분입니다... 먼저 dev환경을 작업 중이었기 때문에 dev 경로로 와서 terraform init을 실행해 줍니다.
(base) nayongjun@nayongjun-ui-noteubug cherrypic-iac % cd terraform/env/dev
(base) nayongjun@nayongjun-ui-noteubug dev % terraform init
이렇게 되면. terraform 파일과 lock 파일이 생기게 되는데 역시 깃허브에는 업로드할 필요는 없습니다.
이 상태에서
terraform fmt // 포메팅 기능 코드가 바뀌면 항상 점검하는 것을 습관화 합시다.
terraform validate
두 코드를 실행해 줍니다. validate에서 현재 문법적 오류가 있는 경우 에러가 나게 됩니다. 우선적으로 해당 오류들을 고쳐주시면 됩니다.
존재하지 않는 리소스 참조 | aws_vpc.main.id를 쓰는데 aws_vpc.main이 없음 |
입력값 누락 | 필수 변수에 값이 없거나 잘못된 값 |
리소스 간 의존성 문제 | subnet이 필요한데 vpc가 아직 없음 |
잘못된 리소스 속성 값 | instance_type = "abc"처럼 존재하지 않는 타입 지정 |
이때 validate에서 잡히는 오류는 정적인 오류이기 때문에 이걸 모두 고친다고 plan과 apply가 문제없이 항상 실행되는 것은 아니지만, 가장 기본적으로 점검해야 하는 부분이긴 합니다.
⭐ 트러블 슈팅 : tfstate 수동 등록하기
이제 동기화하는 부분입니다. 저는 어느 정도 infra가 개발이 되어 있는 상태에서 terraform을 중간 적용을 했기 때문에 이것을 동기화해주는 부분이 필요했습니다.
아무런 동기화 과정 없이 terraform plan을 입력하게 되면, tfstate로 관리하고 있는 리소스가 없기 때문에 terraform은 모두 새로 만든다고 인식하게 됩니다. 이게 plan을 통과할지라도 terraform apply를 하게 되면 이미 존재하는 리소스가 있어서 에러가 나게 됩니다.
따라서, remote에 존재하는 리소스를 하나하나 로컬 콘솔에서 id값을 보며 import 해주어야 합니다.
ex)
terraform import aws_instance.web i-0abcd1234efgh5678
terraform import aws_s3_bucket.log_bucket my-existing-log-bucket
terraform import aws_security_group.web_sg sg-0123456789abcdef0
그런데 리소스는 너무 많고, 저런 것들을 모두 import 해주는 것은 비효율적이라고 생각했습니다. vpc 같은 경우는 리소스도 하나지만,
resource "aws_security_group" "main" {
name = "${var.purpose}${var.env != "" ? "-${var.env}" : ""}-sg"
description = var.description
vpc_id = var.vpc_id
tags = merge(
var.tags,
{
Name = "${var.purpose}${var.env != "" ? "-${var.env}" : ""}-sg"
}
)
}
resource "aws_security_group_rule" "ingress_cidr" {
for_each = {
for idx, rule in var.ingress_rules :
idx => rule if try(rule.use_cidr, false)
}
type = "ingress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
security_group_id = aws_security_group.main.id
}
resource "aws_security_group_rule" "ingress_sg" {
for_each = {
for idx, rule in var.ingress_rules :
idx => rule if try(rule.use_sg, false)
}
type = "ingress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
source_security_group_id = each.value.source_security_group_id
security_group_id = aws_security_group.main.id
}
resource "aws_security_group_rule" "egress" {
for_each = {
for idx, rule in var.egress_rules :
idx => rule
}
type = "egress"
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
security_group_id = aws_security_group.main.id
}
보안 그룹 같은 경우는 규칙 하나하나 리소스이기 때문에 저 모든 것들도 수동 등록을 해주어야 했고.. 이는 너무 비효율 적이라고 생각하게 되었습니다.
❗ 해결 방안 : 필요한 것만 import 하자
어떤 부분들을 수동적으로 관리하고 어떤 부분들을 자동으로 관리할 것인가? 동기화를 어떻게 해야 하는가? 이런 부분들은 조금 비슷한 고민인 것 같습니다. 제가 생각했던 방법은 중요한 것들은 Import 하고 재생성되어도 무방한 것들을 모두 삭제하고 다시 생성하는 것이었습니다.
어떤 예시가 있을까요?
중요한 것들
- ec2 : 내부 설정은 모두 날아가게 됨
- rds : 내부 데이터가 보존되어야 함.
중요하지 않은 것들
- routing table
- igw
- security group
ec2나 rds는 재생성되는 것으로 완벽히 원래 리소스를 복구하기는 힘듭니다. ( ec2 같은 경우는 packer를 통해서 내부 설정까지 하는 경우 어느 정도 가능하기도 함.)
따라서, 위와 같은 리소스는 재생성되지 않도록 꼭 import를 해야 합니다. 나머지는 어느 정도 terraform 코드와 충돌이 나는 경우 remote에서 삭제해 버리고 terraform으로 다시 만들어도 무방했습니다.
위에서 말한 방법대로 필요한 리소스만 tfstate를 등록하고 나머지를 모두 삭제한 채로 terraform이 끝났다면 행복했겠지만,
위의 문제를 고려하면서 의존성(참조)이라는 새로운 문제가 발생하면서 조금 더 복잡한 문제들과 해결 방안이 필요했습니다.
다음 케이스들을 함께 보시죠.
✅ 중요한 리소스를 유지하는 방법
저의 원격 서버 같은 경우에는 Jekins, Was, 그리고 Bastion Host 이렇게 3개가 이미 생성되어 있는 상태였습니다.
그중 Bastion Host를 Terraform으로 적용한 방법에 대해서 알려드리겠습니다.
다음과 같이 dev 경로의 터미널에서 import 문을 통해서 원격의 bastion을 등록해 주었습니다.
terraform import module.bastion_dev.aws_instance.bastion (Bastion Host의 id!)
그리고, AWS GUI에서 AMI 이미지를 구워주었습니다.
보통은 EC2를 만드는 과정에서 Ubuntu의 최신 이미지를 data.tf에서 가져와서 다음과 같이 ec2를 생성해주게 됩니다.
ami_id = data.aws_ami.ubuntu_latest
그런데, 해당 ami를 적용하게 되면 Ubunutu만 설치되어 있는.. (내부는 비어있는) 새로운 EC2가 생성되게 됩니다.
따라서, 다음과 같이 Bastion에서 구워놓은 AMI이미지를 id에 입력해 주게 된다면 terraform에서 destroy 하고 다시 create 하지 않습니다.
module "bastion_dev" {
source = "../../modules/compute/ec2"
ami_id = "ami-03e38f46f79020a70" // Bastion backup AMI
instance_type = "t2.micro"
subnet_id = module.public_subnet_1.id
vpc_security_group_ids = [module.bastion_dev_sg.id]
associate_public_ip = true
key_name = "cherrypic-bastion-host-key"
root_volume_size = 30
root_volume_type = "gp3"
purpose = "bastion"
environment = ""
enable_eip = false
tags = local.common_tags
}
이외에 혹시나 tags처럼 수정 가능한 사항들이 변경되는 경우, terraform은 리소스를 재생성하지 않고 update를 하도록 계획을 세우게 됩니다. 따라서, 중요한 리소스를 유지한 채로 테라폼으로 관리가 가능합니다.
Terraform will perform the following actions:
# module.bastion_dev.aws_instance.bastion will be updated in-place
~ resource "aws_instance" "bastion" {
id = xxx
ami = xxx
instance_type = xxx
subnet_id = xxx
vpc_security_group_ids = [
xxx
]
~ tags = {
- Name = "cherrypic-bastion"
+ Name = "cherrypic-bastion"
+ Environment = "dev" # ➜ 새 태그 추가
+ Owner = "nayongjun" # ➜ 새 태그 추가
}
}
Plan: 0 to add, 1 to change, 0 to destroy.
이런 식으로 Updated in-place가 뜬다면 성공입니다.
✅ 중요한 리소스가 바뀔 수밖에 없는 경우
그런데, 중요한 리소스가 바뀔 수 밖에 없는 경우가 있습니다. 예를 들어서, RDS 같은 경우는 이름이 identifier 그 자체이기 때문에 바뀌게 되면 리소스 자체가 재생성되어야 합니다.
이런 경우, 주요 리소스임에도 불구하고 재생성되어야 한다는 리스크가 있을 수 있습니다.
- RDS의 경우 백업을 만들어놓고 새로운 rds에 옮겨준다
- EC2의 경우 AMI 이미지를 통해서 옮겨준다.
그런데, 이름과 같은 경우는 그냥 타협하여 사용할 수도 있는데요, 이런 경우에는 moved on 코드를 통해서 옮겨졌음을 명시할 수 있습니다.
moved {
from = aws_db_instance.old
to = aws_db_instance.new
}
✅ 중요하지 않은 리소스를 다시 만들어야 하는데 , 중요한 리소스가 이를 참조하는 경우.
보안 그룹 같은 경우에는 재성성 되어도 아무런 문제가 없는 리소스인데요. 막상 EC2 computing 리소스들과 rds 등을 등록해 주고 나머지를 삭제 후 생성하려고 하면 많은 에러에 부딪히게 됩니다. 그 이유는 지우려고 하는 리소스가 지울 수 없는 다른 리소스를 참조하고 있을 경우입니다.
예를 들어서, 위에서 설명했던 Bastion 같은 경우에도 지우고자 하는 보안 그룹을 참조하고 있었습니다. EC2는 보안그룹을 적어도 하나는 가지고 있어야 하기 때문에 이런 경우 삭제되지 않습니다. 해결 방법은 수동으로 AWS GUI에서 삭제하고자 하는 보안 그룹을 제거해 주고 default 보안 그룹을 임시로 부착해 주는 것입니다(default의 존재의 의미가 이거였을까요..?).
대부분의 경우에는 테라폼이 적용되지 않던 프로젝트에서 테라폼을 적용하려는 사례가 더 많을 거라고 생각합니다. 따라서, 이미 존재하는 인프라를 테라폼으로 옮기는 작업에 대해서 경험해 보면 좋을 것 같아요. 또한, 처음부터 테라폼이 적용되어 있다고 할지라도 수동적으로 관리되는 부분은 분명 존재하기 때문에 이런 인사이트는 실무에서 더욱 중요할 것 같습니다.
모듈들과 리소스 간에 의존 관계 + 수동적으로 관리되는 부분에 대한 인사이트
이런 점들에 집중해 나가면서 테라폼을 다뤄보면 좋을 것 같아요..!
결과적으로, 저 같은 경우는 다음과 같은 순서로 테라폼을 적용했습니다.
- 우선 모든 리소스를 테라폼 문서로 옮기고 plan을 통해서 실수하지 않았는지 체크하기
- RDS와 EC2같이 중요한 것들만 tfstate로 import 하고 나머지는 참조되는 부분을 전부 해제 후 재생성.
이와 같은 순서로 편하게 프로젝트를 테라폼으로 관리할 수 있었습니다 (물론 좀 더 복잡한 규모의 인프라였다면 세세한 디테일을 더 신경 써야 할 것입니다.)!
📌 소소하게 발생했던 다른 문제들
✅ 테라폼에서 valkey를 지원하지 않았다.
module "cache_dev" {
source = "../../modules/database/elasticcache"
environment = "dev"
purpose = "cache"
subnet_ids = [module.private_subnet_1.id, module.private_subnet_2.id]
security_group_ids = [module.cache_dev_sg.id]
node_type = "cache.t2.micro"
num_cache_nodes = 1
engine = "redis"
engine_version = "7.1"
parameter_group_name = "default.redis7"
port = 6379
subnet_group_description = "Cherrypic cache subnet group"
replicas_per_node_group = 0
tags = local.common_tags
}
비교적 최근의 기능인 valkey를 적용하고자 했는데 테라폼에서는 아직 valkey를 지원하지 않는 문제가 있었습니다. 이처럼 테라폼은 AWS에서 업데이트되는 것보다는 살짝 느리기 때문에 이런 부분들은 조금 신경 쓰면 좋을 것 같습니다.
따라서, 저희 팀은 Redis OSS를 사용하는 방향으로 변경했습니다.
⭐⭐⭐ Plan 단계에서 확정되지 않는 값들에 의존하지 말 것. flag 변수를 이용해야 한다
예를 들어서 ec2 모듈에서 eip를 연결하는 부분의 resocurce 코드를 가져와 보겠습니다.
resource "aws_eip_association" "main" {
count = var.eip_allocation_id != null ? 1 : 0 // 이 부분이 문제
instance_id = aws_instance.main.id
allocation_id = var.eip_allocation_id
}
문제가 생긴 시점의 코드입니다. count를 통해서 eip 연결 리소스를 만들지 말지 결정을 하도록 구현이 되어 있습니다.
"만약, eip_allocation_id를 주입을 하게 된다면 만든다!"라는 코드로 직관적으로 이해를 할 수 있습니다.
eip를 따로 만들고 EC2 주입에서 output 값을 넣어주는 방식으로 설계를 했습니다.
// eip
module "eip_jenkins" {
source = "../../modules/network/elasticip"
domain = "vpc"
tags = local.common_tags
}
// ec2에서 Output 주입
module "jenkins_dev" {
~~ 나머지 생략
eip_allocation_id = module.eip_jenkins.id
}
코드만 본다면 큰 문제가 없어 보이지만, 저대로 실행을 하면 에러를 마주하게 됩니다.
왜냐면, plan 단계에서는 eip_allocation_id가 unknown이기 때문입니다.
물론 코드상으로는 저희는 분명 eip가 생성이 될 것이고, 그 값을 할당한다라고 생각할 수 있지만. terraform plan 코드에서는 아직 코드가 돌아간 것이 아니기 때문에 id값을 알 수 없기 때문이죠!
따라서, 저런 식으로 외부의 생성값을 기준으로 생성 조건문 (count)를 해주는 것보다는 boolean flag 값을 사용하는 것이 좋습니다.
resource "aws_eip_association" "main" {
count = var.enable_eip ? 1 : 0 // boolean으로 수정
instance_id = aws_instance.main.id
allocation_id = var.eip_allocation_id
}
다음과 같이 조건문을 사용하는 경우 외부 생성값에 의존하지 않도록 주의를 합시다!
📌 Terraform CI/CD
마지막으로, CI/CD 파이프라인입니다.
name: Terraform Dev CI
on:
pull_request:
branches: [ develop ]
jobs:
terraform:
name: Terraform Format, Validate, Plan
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v3
with:
aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.12.2
- name: Terraform Init (dev)
run: terraform -chdir=terraform/env/dev init
- name: Terraform Format Check (전체)
run: terraform fmt -check -recursive
- name: Terraform Plan (dev)
id: plan
run: |
terraform -chdir=terraform/env/dev plan \
-input=false \
-no-color \
-var="access_key_id=${{ secrets.DEV_AWS_ACCESS_KEY_ID }}" \
-var="secret_access_key=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}" \
-var="username=${{ secrets.DEV_RDS_USERNAME }}" \
-var="password=${{ secrets.DEV_RDS_PASSWORD }}" \
> terraform/env/dev/plan.txt
- name: Delete previous Terraform plan comments
uses: actions/github-script@v7
with:
script: |
const planTag = "## 📝 Terraform Plan Result (dev)";
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
for (const comment of comments.data) {
if (comment.body && comment.body.startsWith(planTag)) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id
});
}
}
- name: Comment PR with plan output
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('terraform/env/dev/plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## 📝 Terraform Plan Result (dev)\n\n\`\`\`terraform\n${plan}\n\`\`\``
});
미리 말씀드린 것처럼 github에 올라가면 안 되는 민감한 정보들은 variables로 빼준 뒤 CI/CD 파이프라인에서 secrets로 주입을 해주었습니다. 그리고 CI에서는 코드가 정확한지 확인해야 하기 때문에...
- 포매팅이 잘 되어 있는가? (terraform fmt를 적용하고 업로드한 게 맞는지)
- validation을 잘 통과하는지 (terraform validate를 확인하고 업로드 한게 맞는지)
를 점검합니다.
마지막으로, terraform plan을 적용해서 PR에 어떤 부분들이 바뀔지 확인할 수 있도록 댓글이 달리게 설계를 했습니다.
이 부분을 확인하고 괜찮다고 판단을 하게 되면 CD에서는 PR merge와 함께 remote의 aws환경에 적용이 됩니다.
name: Terraform Dev CD
on:
push:
branches:
- develop
jobs:
terraform-apply:
name: Terraform Apply to Dev
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v3
with:
aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.12.2
- name: Terraform Init (dev)
run: terraform -chdir=terraform/env/dev init
- name: Terraform Apply (dev)
run: |
terraform -chdir=terraform/env/dev apply \
-auto-approve \
-input=false \
-var="aws_access_key=${{ secrets.DEV_AWS_ACCESS_KEY_ID }}" \
-var="aws_secret_key=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}" \
-var="username=${{ secrets.DEV_RDS_USERNAME }}" \
-var="password=${{ secrets.DEV_RDS_PASSWORD }}"