Terraform으로 기본 인프라 구축하기

ImOk
Cloud Villains
Published in
27 min readNov 17, 2022

--

시작하기 앞서…

클라우드 서비스 공급자인 AWS는 고객이 인프라 구성을 간편하게 할 수 있도록 웹 콘솔을 지원하고 있습니다.

이러한 GUI 환경을 좋아하시는 분들도 많이 계시지만, 제 경우, 반복적인 작업 시 휴먼 에러를 방지할 수 있고, 생성하고자 하는 리소스를 한눈에 파악할 수 있기 때문에 콘솔 작업보다 코드 작업을 더 선호하는 편입니다.

처음 AWS를 접하고 VPC, EC2, SG(Security Group), ELB와 같은 기본 서비스를 생성하다보면, 처음엔 어렵지만 손에 익으면 곧 지루한 반복 작업으로 느껴지게 됩니다.

따라서 저는 간단하지만 자주 사용하는 기본 인프라 구성을 코드형 인프라(Infrastructure as Code, IaC)중 하나인 Terraform으로 생성해 업무에 활용하고자 하였습니다.

⚠️ 본 내용은 Terraform스터디를 진행 하며 현업에 적용 해 본 내용을 기재하였기 때문에, AWS 및 Terraform의 기본 개념에 대해서는 생략 하였습니다.

인프라 구축 전 체크 사항

A 기업에서 새로운 프로젝트 도입에 앞서 AWS에 테스트 환경을 구축하고자, 주니어 사원 👶🏻 OK에게 다음과 같은 인프라를 생성해줄 것을 요청했다고 가정 해 봤습니다.

인프라 구축 요구 사항 체크 ✅

  1. 서비스 개발을 위해 사용할 서버는 Private Subnet 환경으로 구성

2. 서버는 개발(Dev) 환경과 운영(Prod) 환경으로 나누어 구성

  • 나중에 서버 환경은 추가될 수 있음
  • 운영 서버 환경은 고가용성을 위해 2개의 가용영역(AZ)으로 이중화 구성

3. SG(Security Group)에 다양한 Inbound Source 정보 등록 필요

  • 환경에 따라 Port 및 Source 정보는 달라질 수 있음

4. ALB에 HTTPS로 리스너를 설정해 SSL Offload 수행

  • ALB는 개발 환경과 서비스별로 구분해서 생성
  • Target Group port는 언제든 변경, 추가될 수 있음

👶🏻 OK는 요구 사항을 확인하고, Terraform 구성 전에 미리 구축 내용을 작성했습니다.

구축 내용 ✅

  1. VPC(Virtual Private Cloud)
  • IP 대역 : 10.60.0.0/16
  • On-Premise 환경에서 AWS의 Private Subnet 환경에 접속하기 위해, 인터넷 게이트웨이NAT 게이트웨이로 구성

2. Subnet 구성

  • Region : ap-northeast-2 [Asia Pacific (Seoul)]
subnet 구성 예시

3. SG(Security Group)

  • EC2 접속을 위한 On-Premise의 IP Inbound Source 정보 (IP는 보안을 위해 임의로 생성했습니다.)
bastion default SG 예시

4. EC2

  • Private EC2에 ssh 접속은 Bastion Host를 통한 Tunneling 방식으로 접속
  • OS : Amazon Linux 2
EC2 Server Spec 예시

5. ALB(Application Load Balancer)

ALB 예시

6. ACM(AWS Certificate Manager)

  • AWS ACM 발급을 위해 외부 도메인 호스팅 업체에 DNS 검증 과정을 진행
  • 검증 완료 후 상태 : ✅ Issued 상태

👶🏻 OK는 다음과 같이 예상 아키텍처를 그려봤습니다.

구축 예상 아키텍처 ✅

Architecture (By. ImOK)

인프라 구축

👶🏻 OK는 향후 비슷한 업무를 맡게되었을 때, 미리 작성해 놓은 Terraform 코드를 재가공해 빠르게 인프라를 구축하고자 하는 목적이 있습니다. 따라서 다음과 같은 목표를 세웠습니다.

💡 재사용 가능한 코드 작성을 위해 서비스별로 코드를 분리하기

💡 서비스 구축을 위한 코드는 가급적 수정하지 않고, 요구 사항의 변화에 맞춰 variables 파일만 수정하여 사용하기

Terraform 사용 준비

Terraform은 다음과 같은 workflow로 동작합니다.

https://learn.hashicorp.com/tutorials/terraform/infrastructure-as-code?in=terraform/aws-get-started

AWS credentials

저는 업무 특성상 여러 AWS 계정을 다루고 있어, profileaws configure를 관리하고 있었습니다.

따라서 아래와 같이 profile을 미리 정의하고 시작 해 보겠습니다.

export AWS_PROFILE="imok"

Variables

보통 테라폼 코드를 작성할 때 variables.tf 파일을 만들어 해당 파일에 변수를 저장합니다.

company명, service명, service port 등 인프라 생성 시에 바뀔 수 있는 부분을 변수로 지정해 사용했습니다.

terraform.tfvars

정의한 변수에 값을 주입하기 위한한 가장 일반적인 방법은 terraform.tfvars 파일을 생성하는 것으로, variable = value 형태로 정의합니다.

저는 보안그룹에 추가 해야 할 Inbound Source 정보를 모두 terraform.tfvars 파일에 정의해 놓고 사용했습니다.

# terraform.tfvars파일 작성 예시
bastion_ingress_rules = [
{
from_port = "22",
to_port = "22",
cidr = "18.164.239.93/32"
desc = "ImOK Tower wireless ip"
},
{
from_port = "22",
to_port = "22",
cidr = "111.245.120.118/32"
desc = "ImOK Tower lan"
}
]

AWS ACM의 경우 보통 기업의 상황에 따라 도메인 호스팅 업체에 등록 후 사용하는 경우가 많기 때문에, Terraform 코드로 생성하는 것이 아닌, AWS Console에서 생성 후 ARN을 tfvars에 등록하는 방법을 택했습니다.

terraform init

terraform init 명령어를 실행하면, local의 현재 디렉터리 아래에 아래 캡쳐 화면과 같이 미리 선언된 프로바이더(AWS) 플러그인을 설치해 줍니다.

init 작업을 완료하면, 테라폼의 꽃🌺인 .tfstate 파일과 .tfstate에 정의된 내용을 담은 .terraform 파일이 생성됩니다.

.tfstate 파일은 상태 저장을 위한 파일로 기존에 다른 개발자가 이미 .tfstate에 인프라를 정의해 놓은 것이 있다면, 다른 개발자는 init작업을 통해서 local에 sync를 맞출 수 있습니다.

.terraform
├── environment
├── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── aws
│ └── 4.38.0
│ └── darwin_arm64
│ └── terraform-provider-aws_v4.38.0_x5
└── terraform.tfstate

이제 인프라 생성을 위한 초기 준비가 완료됐습니다.

Terraform 구성

저는 다음과 같이 10개의 영역으로 구분해 코드를 작성했습니다.

terraform_study
├── alb.tf
├── ami.tf
├── ec2.tf
├── key-pair.tf
├── output.tf
├── provider.tf
├── sg.tf
├── terraform.tfvars
├── variables.tf
└── vpc.tf

Provider

  1. variables.tf 파일 구성

profile에 쓰일 accountregion을 미리 variables에 정의했습니다.

variable "account" {
default = "imok"
description = "aws account"
}

variable "region" {
type = string
default = "ap-northeast-2"
}

2. provider.tf 파일 구성

provider "aws" {
region = var.region
profile = var.account
}

key-pair Resource

  1. variables.tf 파일 구성

key에 붙는 이름은 기업명, 프로젝트명에 따라 달라지기 때문에 tags를 미리 variables에 정의했습니다.

variable "tags" {
type = string
default = "imok-corp"
description = "Additional company tags"
}

2. key-pair.tf 파일 구성

# Generates a secure private key and encodes it as PEM
resource "tls_private_key" "key_pair" {
algorithm = "RSA"
rsa_bits = 4096
}
# Create the Key Pair
resource "aws_key_pair" "key_pair" {
key_name = "${var.tags}-key"
public_key = tls_private_key.key_pair.public_key_openssh
}
# Save Pem Key
resource "local_file" "ssh_key" {
filename = "${aws_key_pair.key_pair.key_name}.pem"
content = tls_private_key.key_pair.private_key_pem
}

VPC Resource

  1. variables.tf 파일 구성

vpc에 사용할 ip 대역대와 subnet 대역대, 가용영역(az) 정보를 미리 variables에 정의 했습니다.

variable "aws_az" {
type = list(any)
default = ["ap-northeast-2a", "ap-northeast-2c"]
}

variable "aws_az_des" {
type = list(any)
default = ["2a", "2c"]
}

variable "vpc_cidr" {
type = string
default = "10.60.0.0/16"
}

variable "dev_private_subnet" {
type = list(any)
default = ["10.60.0.0/24", "10.60.1.0/24"]
}

variable "prd_private_subnet" {
type = list(any)
default = ["10.60.2.0/24", "10.60.3.0/24"]
}

variable "public_subnet" {
type = list(any)
default = ["10.60.4.0/24", "10.60.5.0/24"]
}

2. vpc.tf 파일 구성

subnet은 보통 az 2개의 쌍으로 구성하기 때문에, 코드의 반복을 줄이고자 count라는 메타 변수를 사용했습니다.

variables에서 정의한 aws_az의 개수가 2이기 때문에, length 함수를 이용해 길이를 계산해 count 변수에서 정의했습니다.

따라서 count = length(var.aws_az) 로 count = 2가 되어 총 2개의 리소스가 생성됩니다.

count가 설정된 스탠자(stanza)에서 index라는 객체를 사용할 수 있기 때문에, variables에서 정의한 public_subnet 변수에 count.index를 사용했습니다.

💡 저는 terraform 초보이고 생성할 리소스가 2개뿐이어서 count를 사용했지만, count는 인라인 블록을 반복할 수 없는 제약 사항이 있기 때문에 생성할 리소스가 많을 때는 for each 문을 사용하는 것이 더 효율적인 방법이라고 합니다. 따라서 좀 더 학습 후 for each 문으로 변경 해 볼 예정입니다.

2022.12.11 for_each 문으로 변경 완료 😘 for_each 활용해 vpc 생성하기

## vpc
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true

tags = {
Name = "${var.tags}-vpc"
}
}
## Subnet
# public subnet
resource "aws_subnet" "pub_sub" {
count = length(var.aws_az)
vpc_id = aws_vpc.vpc.id
cidr_block = var.public_subnet[count.index]
availability_zone = var.aws_az[count.index]
map_public_ip_on_launch = false

tags = {
Name = "${var.tags}-pub-sub-${var.aws_az_des[count.index]}"
}
}

EC2 AMI data 정의

OS 요구에 따라 다양한 EC2 AMI를 활용하기 위해 최신 AMI 정보를 미리 구성했습니다.

  1. Amazon Linux2
# amazon linux2 ami latest
data "aws_ami" "amazon-linux-2" {
most_recent = true
owners = ["amazon"]

filter {
name = "name"
# values = ["amzn2-ami-hvm*"]
values = ["amzn2-ami-hvm-*-gp2"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}

2. Amazon Linux2 kernel-5

# amazon linux2 ami latest kernel-5
data "aws_ami" "amazon-linux-2-kernel-5" {
most_recent = true
owners = ["amazon"]

filter {
name = "name"
values = ["amzn2-ami-kernel-5*"]
}
}

3. Ubuntu

# ubuntu ami latest
data "aws_ami" "ubuntu_latest" {
most_recent = true
owners = ["099720109477"] # Canonical(owner account id)

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-xenial-16.04-amd64-server-*"]
# values = ["ubuntu/images/hvm-ssd/ubuntu-xenial-18.04-amd64-server-*"]
# values = ["ubuntu/images/hvm-ssd/ubuntu-xenial-22.04-arm64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}

EC2 Resource

EC2의 인스턴스 타입을 결정할 때 보통 cpu와 memory 스펙을 정하고 워크로드에 적합한 타입을 선정합니다.

저는 아래 명령어를 통해 인스턴스 타입을 결정하고 있습니다.

aws ec2 describe-instance-types --filters \
"Name=current-generation,Values=true" \
"Name=memory-info.size-in-mib,Values=4096" \
"Name=vcpu-info.default-vcpus,Values=2" \
--query "InstanceTypes[*].[InstanceType]" --output text | sort
  1. ec2.tf 파일 구성

요구사항에 맞춰 미리 위에서 정의한 ami 중 최신 amazon linux2의 이미지를 선택했고, 인스턴스 타입, 볼륨 등을 설정했습니다.

## EC2
# bastion
resource "aws_instance" "bastion" {
ami = data.aws_ami.amazon_linux2_kernel_5.id
instance_type = "t3.small"
key_name = aws_key_pair.key_pair.key_name
subnet_id = element(aws_subnet.pub_sub.*.id, 0) # public-subnet-2a
vpc_security_group_ids = [aws_security_group.bastion_sg.id]

root_block_device {
volume_type = "gp3"
volume_size = "10"
}

tags = {
Name = "${var.tags}-bastion"
}
}

# dev-management
resource "aws_instance" "dev_management_2a" {
ami = data.aws_ami.amazon_linux2_kernel_5.id
instance_type = "r5.large"
key_name = aws_key_pair.key_pair.key_name
subnet_id = element(aws_subnet.dev_pri_sub.*.id, 0) # dev-private-subnet-2a
vpc_security_group_ids = [aws_security_group.dev_management_sg.id]

root_block_device {
volume_type = "gp3"
volume_size = "100"
}

tags = {
Name = "${var.tags}-dev-management-2a"
}
}

SG(Security Group) Resource

  1. variables.tf 파일 구성

SG(Security Group)에서 사용하기 위한 ingress rules을 미리 variables에 정의했습니다.

default를 비워둔 이유는 위에서 언급한 terraform.tfvars 파일에 정의한 변수를 사용하기 위함입니다.

variable "bastion_ingress_rules" {
type = list(map(string))
default = []
description = "bastion sg rule"
}

variable "service_ingress_rules" {
type = list(map(string))
default = []
description = "service sg rule"
}

variable "management_ingress_rules" {
type = list(map(string))
default = []
description = "management sg rule"
}

2. sg.tf 파일 구성
aws_security_group 리소스의 내부에 ingress block을 생성하는 데 dynamic block을 활용했습니다.

for_each 표현식에서 block을 생성할 정보를 담은 collection을 전달받고, 이 collection의 item 수 만큼 block이 생성됩니다.

# for_each 표현식
resource "<PROVIDER>_<TYPE>" "<NAME>" {
for_each = <COLLECTION>

[CONFIG ...]
}
  • for 표현식 : [for <ITEM> in <LIST> : <OUTPUT>]

content는 실제로 정의하려는 block 안에 전달되는 값들을 명시하는 곳입니다. 따라서 원래 ingress block을 생성할 때 필요한 값을 넣어줍니다.

## Security group
# bastion-sg
resource "aws_security_group" "bastion_sg" {
# count = length(var.bastion_ip)
name = "${var.tags}-bastion-sg"
description = "${var.tags}-bastion-sg"
vpc_id = aws_vpc.vpc.id

# inbound rule
dynamic "ingress" {
for_each = [for s in var.bastion_ingress_rules : {
from_port = s.from_port
to_port = s.to_port
desc = s.desc
cidrs = [s.cidr]
}]
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
cidr_blocks = ingress.value.cidrs
protocol = "tcp"
description = ingress.value.desc
}
}

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

tags = {
Name = "${var.tags}-bastion-sg"
}
}

ALB Resource

ALB 구성이 개인적으로 제일 힘들었습니다. 처음엔 ALB의 Listener rule의 조건을 코드로 모두 작성하려고 했습니다. 하지만 아래 캡쳐와 같이 생각해야 할 분기 조건들이 너무 많았고, 처음부터 코드를 간결히 작성하는 데에만 집중해 count와 list로 해결하려다 보니 의존성에 문제가 생겨 오류가 계속 발생했습니다. 결국 룰 분기 조건은 콘솔창에서 작성하는 게 더 빠를 것 같다는 판단을 내렸습니다.

제 개인적인 생각으로는 ALB는 수고스럽지만, 코드가 반복되더라도 하나하나 작성하는 게 더 좋지 않았을까라는 생각을 합니다.

(아직 제가 좋은 방법이 있지만 찾지 못해서 그럴 수도..더 좋은 방법을 찾으면 개선하겠습니다. 🙇🏻‍♀️)

  1. variables.tf 파일 구성

ALB에서 Target Group에 사용하기 위한 서비스 포트 및 certificate arn을 미리 variables에 정의했습니다.

variable "env" {
type = list(any)
default = ["dev", "prd"]
description = "Additional env tags"
}

variable "service_server_port" {
type = list(any)
default = [8085, 8086]
}

variable "management_server_port" {
type = list(any)
default = [8080]
}

variable "acm_certi" {
type = string
default = ""
}

2. alb.tf 파일 구성

alb는 개발 환경에 따라 구성되어야 하기 때문에 variables에 정의한 환경 변수의 개수만큼 생성되도록 했습니다.

# ALB 생성
resource "aws_lb" "service_alb" {
count = length(var.env)
name = "${var.tags}-${var.env[count.index]}-alb-service"
load_balancer_type = "application"
security_groups = [aws_security_group.service_alb_sg.id]
subnets = [element(aws_subnet.pub_sub.*.id, 0), element(aws_subnet.pub_sub.*.id, 1)]

tags = {
Name = "${var.tags}-${var.env[count.index]}-alb-service"
}
}

alb의 target group을 생성하고, target인 인스턴스를 붙입니다.

## ALB target group & attachment
# dev service_tg
resource "aws_lb_target_group" "dev_service_tg" {
count = length(var.service_server_port)
name = "${var.tags}-${var.env[0]}-service-tg-${var.service_server_port[count.index]}"
port = var.service_server_port[count.index]
protocol = "HTTP"
vpc_id = aws_vpc.vpc.id
}

resource "aws_alb_target_group_attachment" "dev_service_tg_attach_2a" {
count = length(aws_lb_target_group.dev_service_tg)
target_group_arn = element(aws_lb_target_group.dev_service_tg.*.arn, count.index)
target_id = aws_instance.dev_service_2a.id
port = var.service_server_port[count.index]
}

ALB listener를 http와 https로 나눠서 생성 후, https의 리스너 설정에서 terraform.tfvars에 정의한 AWS ACM ARN을 사용했습니다.

## ALB listener Redirect Action
# service alb listener_http
resource "aws_lb_listener" "service_alb_listener_http" {
count = length(var.env)
load_balancer_arn = element(aws_lb.service_alb.*.arn, count.index)
port = 80
protocol = "HTTP"

default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}

# service alb listener_https
resource "aws_lb_listener" "service_alb_listener_https" {
count = length(var.env)
load_balancer_arn = element(aws_lb.service_alb.*.arn, count.index)
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = var.acm_certi

default_action {
type = "fixed-response"

fixed_response {
content_type = "text/plain"
message_body = "Fixed response content"
status_code = "503"
}
}
}

Terraform 실행

terraform plan

앞서 작성한 리소스들이 실제 AWS에서 생성 될 지 terraform plan 명령어를 통해 확인 해 보겠습니다.

plan 명령어를 사용하면 현재 정의되어있는 리소스들을 실제 프로바이더에 적용했을 때, 테라폼이 어떤 작업을 수행할지 계획을 보여줍니다.

개인적으로 apply를 실행하기 전에 내가 작성한 코드에 문제가 없는지 알 수 있어서 꼭 필요한 명령어라고 생각했습니다. (AWS 리소스를 함부로 생성했다간 돌이킬 수 없는 일이 생길 수도 있기 때문이죠 🥲)

아래 명령어 수행 결과를 보면, 총 71개의 리소스가 추가 될 예정임을 알 수 있습니다.

terraform apply

plan을 통해 확인한 내용을 실제 AWS 프로바이더에 terraform apply 명령어를 통해 적용합니다.

“정말 적용해도 괜찮으시겠어요?”라는 질문은 마치 “이 명령어에 책임을 질 수 있겠냐”는 의미로 느껴졌습니다. yes를 기입하니 최종적으로 리소스들을 생성할 수 있습니다.

(물론, 생략하고 바로 생성할 수 있는 ‘terraform apply -auto-approve’ 라는 명령어가 있습니다.)

아래 캡쳐 화면과 같이 Creating.. 이라는 문구가 계속 올라가면서 리소스들이 생성되는 과정을 확인할 수 있습니다.

마지막으로 Apply complete!라는 문구가 나오면, 이제 AWS에 콘솔에서 리소스들이 정상적으로 생성되었음을 확인할 수 있습니다.

  1. VPC

2. EC2

3. SG(Security Group)

  • 가장 반복 작업이라고 생각하는 inboud rule 한 번에 생성!🥳

4. ALB

👶🏻 OK는 3분 만에 구축 요구 사항에 맞는 인프라 구성을 완료했고, 룰루랄라 놀 수…는 없었고, 다시 새로운 일을 시작할 수 있었습니다. 😂

완성 코드는 제 깃허브에 올려놨습니다 참고 부탁드립니다 :)

https://github.com/euneun316/terraform-study/tree/main/Default_Infra

마치며

처음 테라폼으로 인프라 구축을 시작했을 때 받았던 느낌은 ‘와 이걸 왜 지금 알았지..?’ 였습니다. 명령어 몇 개로 쉽게 인프라가 뚝딱 만들어졌기 때문이죠.

계속 공부하면서 느낀 점은 쓰면 쓸수록 어렵고, 앞으로는 정말 ‘잘’ 쓸 수 있도록 노력해야겠다고 생각했습니다. 하지만 아무것도 없는 상태에서 기본 인프라 구축을 하는 데에는 테라폼은 사용하기에 정말 좋은 도구라고 생각 했습니다.

그렇지만 저는 위의 기본 인프라를 생성하는데 테라폼을 사용하면서도 많은 에러를 겪었습니다. subnet에서 리스트로 생성하다 꼬여서 6개가 만들어져야 하는데, 12개씩 생성되기도 하고, 만들어 놓은 ALB가 삭제가 안 되기도 하고, 잘 만들어진 EC2였는데, 글자 몇 개 수정했더니 삭제되고 다시 생성되기도 하고.. 그런데 이 리소스가 운영 중이던 서비스였다면..? 생각만 해도 아찔합니다.

따라서 앞으로는 .tfstate (상태 저장 파일)을 활용하는 부분, 테라폼을 사용해 협업하는 부분 에 중점을 두고 공부 해 볼 예정입니다.

테라폼 고수가 되어 다시 이 글을 업데이트 할 그날까지… 🏃🏻‍♀️

--

--