Terraform module을 활용한 기본 인프라 구축

ImOk
Cloud Villains
Published in
27 min readDec 21, 2022

시작하기 앞서..

지난 게시글에서는 ‘Terraform으로 간단한 인프라 구축하기’에 대해 작성 해 보았습니다.Terraform으로 기본 인프라 구축하기

비슷한 인프라 구축 요구 사항이 들어왔을 때는 해당 코드를 활용하기 좋았지만, 조금이라도 새로운 리소스 구성이 필요하면 코드의 재사용이 어렵다는 것을 느꼈습니다.

또한 비슷한 환경을 생성할 때 마다 terraform의 .tf 파일을 복사해 계속 새로운 파일을 만들어 내는 점이 비효율적이라 생각되었습니다.

💡 따라서 공통으로 사용하는 리소스를 만들어 코드를 재사용하기 위해 terraform module을 사용하고, 팀원들과 동일 코드 다른 환경에서 사용하기 위해 terraform workspace를 도입하기로 했습니다.

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

인프라 구축 전 체크 사항

지난번 사원 👶🏻 OK는 terraform으로 기본 인프라 구성을 완료했습니다.
하지만 코드 재 사용에 몇 가지 불편한 점이 있었고, 규모가 커질수록 단순한 방식으로는 처리가 어려워졌습니다.
추가적으로 팀원들과 공동 작업을 하기 위해, 다음과 같이 코드를 개선하기로 했습니다.

인프라 개선 필요 사항 체크 ✅

  1. terraform의 count 매개변수의 제약 사항
  • 전체 리소스를 반복할 수는 있지만 리소스 내에서 인라인 블록을 반복할 수는 없음
  • 리소스가 배열의 형식으로 생성되기 때문에, 어떤 리소스가 생성되는지 명확히 알 수 없어, 수정 시 에러 발생 가능성이 높음

➡️ for_each 표현식으로 변경

2. 구성 환경의 중복된 코드

  • 개발(Dev)과 운영(Prod) 환경은 일부 속성만 다르기 때문에 코드에 중복된 내용이 상당히 많았음

➡️ terraform moduleterraform workspace를 활용하는 방식으로 코드 변경

3. 규모가 커짐에 따라 필요한 리소스(EC2, S3, DynamoDB 등)이 증가해, root 경로에서 파일 이름만으로 분리된 소스를 관리하는 것이 불편해짐

➡️ 폴더별로 리소스를 구분하고, root 경로에서 리소스별 모듈을 사용하는 코드로 변경

4. Bastion Host를 통한 Tunneling 접속 방식의 불편함

➡️ Private Server에 직접 접속할 수 있는 AWS Systems Manager(AWS SSM) 구성

구축 내용 ✅

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

  1. VPC(Virtual Private Cloud)
    다양한 네트워크 환경 구성
  • IP 대역 : 10.60.0.0/16
  • Internet Gateway
  • NAT gateways
  • Route tables

2. Subnet 구성
Public 환경과 Private 환경 구분

  • Region : ap-northeast-2 [Asia Pacific (Seoul)]
subnet

3. SG(Security Group)
보안그룹은 언제든 확장할 수 있게 구성

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

4. EC2
다양한 환경에서 EC2에 접속할 수 있게 구성

  • Public EC2 : pem key를 이용한 ssh 접속
  • Private EC2 : AWS SSM을 이용해 접속
  • OS : Amazon Linux 2
EC2

5. IAM(Identity and Access Management)
AWS SSM(AWS Systems Manager) 사용 및 S3 접근을 위해 Private EC2에 IAM role 적용

  • AWS SSM Policy : AmazonSSMManagedInstanceCore
  • Amazon S3 Policy : AmazonS3FullAccess

구축 예상 아키텍처 ✅

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

Architecture (By. ImOK)

인프라 구축

👶🏻 OK는 팀원들에게 공통 terraform module을 작성해 배포해야 했기 때문에 다음과 같은 목표를 세웠습니다.

💡 서비스별로 폴더를 분리해, root module에서만 코드 수정하도록 환경 구성
💡
각자의 aws 환경에서 진행할 수 있도록 terraform workspace 환경 구성

Terraform Workspace 구성

작업 공간을 통한 Workspaces 격리
HashiCorp 공식 문서 : Terraform Workspaces

  • 테라폼은 기본 default 작업 공간을 사용합니다. 새 작업 공간을 만들거나 전환하려면 terraform workspace 명령을 사용합니다.
  • 작업 공간은 code refactoring을 시도하는 것 같이, 이미 배포된 인프라에 영향을 주지 않고 테라폼 모듈을 테스트할 때 유용합니다.

workspace 구조

terraform.tfstate.d라는 폴더 아래에 workspace 별로 폴더가 생성되고, 상태 파일인 .tfstate 파일이 각각 관리되는 형태입니다.

# workspace 구조 예시

├── terraform.tfstate.d
│ ├── imok
│ │ ├── terraform.tfstate
│ │ └── terraform.tfstate.backup
│ └── project
│ ├── terraform.tfstate
│ └── terraform.tfstate.backup

workspace 사용

  1. 새 workspace 생성

terraform workspace new [workspace name]

2. workspace 전환

terraform workspace select [workspace name]

3. workspace 리스트 확인

terraform workspace list

4. 현재 workspace 확인

terraform workspace show

aws credentials 사용

terraform 프로젝트를 두 개 이상의 어카운트에서 사용해야 할 경우, aws credentialprofile 이름을 workspace 이름과 일치시키는 방법을 사용합니다.

  • workspace name == credentials profile == imok
# ~/.aws/credentials 파일 예

[imok]
aws_access_key_id = []
aws_secret_access_key = []

[workspace name]
aws_access_key_id = []
aws_secret_access_key = []

...

Terraform module 구성

Terraform Architecture (By. ImOk)
  1. root module : 실제로 수행하게 되는 작업 디렉터리의 terraform 코드 모음
  • root.tf

2. child module : root module에서 리소스를 생성하기 위해 참조하고 있는 module block

  • EC2, VPC, IAM, SG

child module에서 모듈이 생성하는 resource는 보통의 리소스를 생성하는 코드와 동일하지만, root module(모듈을 사용하는 코드)에서 건네주는 변수들을 사용해서 리소스를 생성해야 합니다.
저는 root moduleroot.tf파일로 정의하고, 해당 파일에서 모듈을 사용하는 코드를 작성했습니다.

var.az_names와 같이 root module에서 설정한 변수를 받아와 child module에서 리소스를 생성할 수 있습니다.
이러한 변수를 받아오기 위해서는 child module에서 variable을 설정해주어야 합니다.

child module에서 리소스를 생성한 후, 생성한 리소스에 대한 정보(arn, id 등)를 받기 위해 child module에서 output을 통해 리소스 정보를 출력해주어야 합니다.

root module에서는 module.<MODULE_NAME>.id와 같이 output을 받아올 수 있습니다.

⚠️ 해당 내용이 모듈을 도입하면서 제일 많이 헷갈리고, 제일 많은 오류를 겪었던 부분입니다. 😵
반복해서 환경을 구성해보면서 이해해 보는 것을 추천 합니다.

저는 다음과 같이 root module과 서비스 별 child module영역으로 구분해 코드를 작성했습니다.

root module / child module 도식화 (By. ImOK)
.
├── ec2
│ ├── ami.tf
│ ├── key-pair.tf
│ ├── main.tf
│ ├── outputs.tf
│ ├── user_data
│ │ ├── user_data_private.sh
│ │ └── user_data_public.sh
│ └── variables.tf
├── iam
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── sg
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├──vpc
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── terraform.tfstate.d # workspace
│ └── imok
│ ├── terraform.tfstate
│ └── terraform.tfstate.backup
├── terraform.tfvars
├── outputs.tf
├── provider.tf
├── root.tf # root module
└── variables.tf

VPC Resource

지난 번 구성과 다른 점은 countfor_each 문으로 변경했다는 점입니다.
for each 표현식에 대한 설명은 아래의 블로그 내용 참고 부탁드립니다. Terraform 반복문 Loops 사용하기

  1. main.tf 파일 구성
resource "aws_subnet" "private" {
for_each = var.private_subnets
vpc_id = aws_vpc.vpc.id
cidr_block = each.value["cidr"]
availability_zone = each.value["zone"]

tags = merge(
{
Name = format(
"%s-pri-sub-%s",
var.name,
element(split("_", each.key), 2)
)
},
var.tags,
)
}

2. variables.tf 파일 구성

root module에서 정의한 변수를 받아오기 위해 다음과 같이 variable을 설정했습니다.

# project name
variable "name" {}

# VPC default CIDR
variable "vpc_cidr" {}

# Availability Zones
variable "az_names" {}

# public subnet list
variable "public_subnets" {}

# private subnet list
variable "private_subnets" {}

# Tags
variable "tags" {}

3. output.tf 파일 구성

ec2 리소스에서 vpc의 subnet id 변수를 사용하기 위해 output에 정의했습니다.

output "public_subnet_ids" {
value = values(aws_subnet.public)[*].id
}

output "private_subnet_ids" {
value = values(aws_subnet.private)[*].id
}

EC2 Resource

  1. main.tf 파일 구성
  • 미리 ami.tf 파일에서 정의한 ami 중 최신 amazon linux2의 이미지를 선택했고, 인스턴스 타입, 볼륨 등을 설정했습니다.
  • user_data는 미리 파일을 구성해서 ${path.module}를 사용해 해당 모듈 경로에 있는 파일을 불러와 인스턴스를 생성했습니다.
resource "aws_instance" "private" {
key_name = var.key_name
ami = data.aws_ami.amazon_linux2_kernel_5.id
instance_type = var.ec2_type_private
vpc_security_group_ids = [var.security_group_id_private]
subnet_id = var.pri_sub_ids[0]

iam_instance_profile = var.iam_instance_profile
disable_api_termination = var.instance_disable_termination

user_data = file("${path.module}/user_data/user_data_private.sh")

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

tags = merge(
{
Name = format(
"%s-private-server",
var.name
)
},
var.tags,
)
}

2. variables.tf 파일 구성
root module에서 정의한 변수를 받아오기 위해 다음과 같이 variable을 설정했습니다.
다른 모듈의 output에서 정의된 변수를 여기에서 받아 옵니다.

#  For EC2
variable "name" {}
variable "tags" {}
variable "az_names" {}
variable "instance_disable_termination" {}
variable "key_name" {}
variable "ec2_type_public" {}
variable "ec2_type_private" {}
variable "volume_size" {}
variable "public_subnets" {}
variable "private_subnets" {}

# From module VPC
variable "pub_sub_ids" {}
variable "pri_sub_ids" {}

# From module IAM
variable "iam_instance_profile" {}

# From module SG
variable "security_group_id_public" {}
variable "security_group_id_private" {}

3. output.tf 파일 구성
root moduleoutput에서 ec2 접속을 위한 정보를 확인하기 위해 다음과 같이 필요한 변수들을 정의했습니다.

output "key_pair" {
value = var.key_name
}

output "public_eip" {
value = aws_eip.public.public_ip
}

output "ec2_private_id" {
value = aws_instance.private.id
}

4. key-pair.tf 파일 구성
${path.module}를 사용해 local의 모듈 경로에 key file을 생성합니다.

# 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.name}-key"
public_key = tls_private_key.key_pair.public_key_openssh
}
# Save Pem Key
resource "local_file" "ssh_key" {
filename = "${path.module}/${aws_key_pair.key_pair.key_name}.pem"
content = tls_private_key.key_pair.private_key_pem
}

SG Resource

SG 구성은 지난번 구성과 거의 동일하고, locals 변수를 추가해 tag에 활용했습니다.

  1. main.tf 파일 구성
# locals에 변수명 구성
locals {
public_sg = format("%s-%s-sg", var.name, "public")
private_sg = format("%s-%s-sg", var.name, "private")
}

resource "aws_security_group" "private" {
name = local.private_sg
description = "private security group for ${var.name}"
vpc_id = var.vpc_id

# inbound rule
dynamic "ingress" {
for_each = [for s in var.private_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 = merge(
{
Name = local.private_sg
},
var.tags
)
}

2. variables.tf 파일 구성
root module에서 정의한 변수를 받아오기 위해 다음과 같이 variable을 설정했습니다.

# Project name
variable "name" {}

# Tags
variable "tags" {}

# public ingress IP list
variable "public_ingress_rules" {}

# private ingress IP list
variable "private_ingress_rules" {}

# From module VPC
variable "vpc_id" {}

3. output.tf 파일 구성
ec2 리소스에서 sg의 security_group_id 변수를 사용하기 위해 output에 정의했습니다.

output "security_group_id_public" {
value = aws_security_group.public.id
}

output "security_group_id_private" {
value = aws_security_group.private.id
}

IAM Resource

Private EC2에 AWS SSM을 통해 접속하기 위한 IAM Role 구성입니다.
SSM의 AmazonSSMManagedInstanceCore 정책과 S3의 AmazonS3FullAccess 정책을 붙였습니다.

  1. main.tf 파일 구성
# IAM role
resource "aws_iam_role" "private" {
name = format("%s-private-role", lower(var.name))

assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
}

# Attaches a Managed IAM Policy to an IAM role
resource "aws_iam_role_policy_attachment" "private_for_s3" {
role = aws_iam_role.private.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

resource "aws_iam_role_policy_attachment" "private_for_ssm" {
role = aws_iam_role.private.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# IAM instance profile
resource "aws_iam_instance_profile" "private" {
name = format("%s-private", lower(var.name))
role = aws_iam_role.private.name
}

2. variables.tf 파일 구성
root module에서 정의한 변수를 받아오기 위해 다음과 같이 variable을 설정했습니다.

# Project name
variable "name" {}

# Tags
variable "tags" {}

3. output.tf 파일 구성
ec2 리소스에서 iam의 iam_instance_profile 변수를 사용하기 위해 output에 정의했습니다.

output "iam_instance_profile" {
value = aws_iam_instance_profile.private.name
}

root module

  1. provider.tf 파일 구성
    terraform.workspace를 사용해 workspace를 변경할 때마다 환경이 적용되도록 구성했습니다.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.45.0"
}
}

required_version = ">= 1.2.0"
}

provider "aws" {
region = var.region
profile = terraform.workspace
}

2. root.tf 파일 구성
root module에서 child module을 사용하기 위한 코드를 작성했습니다.
각각의 모듈의 경로를 source = "./vpc" 다음과 같이 정의하고 시작하고, child module에 건네주기 위한 변수를 정의합니다.

이제 파일 한 곳에서 모든 리소스를 통제할 수 있습니다. 🤗

module "vpc" {
# Required
source = "./vpc"

# environment = var.environment
name = var.name
tags = var.tags
az_names = var.az_names

vpc_cidr = var.vpc_cidr
public_subnets = var.public_subnets
private_subnets = var.private_subnets
}

module "iam" {
# Required
source = "./iam"

name = var.name
tags = var.tags
}

module "ec2" {
# Required
source = "./ec2"

name = var.name
tags = var.tags
az_names = var.az_names

public_subnets = var.public_subnets
private_subnets = var.private_subnets

# module vpc
pub_sub_ids = module.vpc.public_subnet_ids
pri_sub_ids = module.vpc.private_subnet_ids

# module iam
iam_instance_profile = module.iam.iam_instance_profile

# module sg
security_group_id_public = module.sg.security_group_id_public
security_group_id_private = module.sg.security_group_id_private

instance_disable_termination = var.instance_disable_termination
key_name = "${var.name}-key"
volume_size = var.ec2_volume_size
ec2_type_public = var.ec2_type_public
ec2_type_private = var.ec2_type_private
}

module "sg" {
# Required
source = "./sg"

name = var.name
tags = var.tags

public_ingress_rules = var.public_ingress_rules
private_ingress_rules = var.private_ingress_rules

# module vpc
vpc_id = module.vpc.vpc_id
}

3. terraform.tfvars.tf 파일 구성
variables에서 사용하기 위한 변수를 tfvars.tf에서 정의했습니다.

# Terraform setting
environment = "dev"
region = "ap-northeast-2"

tags = {
MadeBy = "imok"
}

# Project name
name = "T101"

# Network setting
vpc_cidr = "10.60.0.0/16"

az_names = [
"ap-northeast-2a",
"ap-northeast-2c"
]

public_subnets = {
pub_sub_2a = {
zone = "ap-northeast-2a"
cidr = "10.60.0.0/24"
},
pub_sub_2c = {
zone = "ap-northeast-2c"
cidr = "10.60.1.0/24"
}
}

4. variables.tf 파일 구성
root module 구성에 필요한 모든 변수를 정의합니다.

# env - e.g: dev|prd|stage
variable "environment" {}

# Region - e.g: ap-northeast-2
variable "region" {}

# project name
variable "name" {}

# EC2 instance type
variable "ec2_type_public" {}
variable "ec2_type_private" {}

# EC2 volume size
variable "ec2_volume_size" {}

# EC2 termination protection
variable "instance_disable_termination" {}

# VPC default CIDR
variable "vpc_cidr" {}

# Availability Zones
variable "az_names" {}

# public subnet list
variable "public_subnets" {}

# private subnet list
variable "private_subnets" {}

# Tag
variable "tags" {}

# public ingress IP list
variable "public_ingress_rules" {}

# private ingress IP list
variable "private_ingress_rules" {}

# DB port
variable "db_port" {}

5. output.tf 파일 구성
최종 출력이 필요한 output을 모두 정의합니다.

output "vpc_id" {
value = module.vpc.vpc_id
}

output "public_subnet_ids" {
value = module.vpc.public_subnet_ids
}

output "private_subnet_ids" {
value = module.vpc.private_subnet_ids
}

output "nat_eip" {
value = module.vpc.nat_eip
}

output "key_pair" {
value = module.ec2.key_pair
}

output "public_eip" {
value = module.ec2.public_eip
}

output "ec2_private_id" {
value = module.ec2.ec2_private_id
}

Terraform 실행

terraform output 확인

terraform output
  • instance id 확인 : i-080061b0ee6c5e7bb
  • key-pair 확인 : T101-key
  • public ip 확인 : 15.164.88.41

생성된 리소스 및 접속 확인

  1. VPC — Subnet

2. IAM Role

3. EC2

  • Public EC2 접속 : pem key 사용
ssh -i "T101-key.pem" ec2-user@15.164.88.41
  • Private EC2 접속 및 S3 접근 : aws ssm, s3 정책 적용한 iam role 사용
aws ssm start-session \
--target i-080061b0ee6c5e7bb

👶🏻 OK는 이제 root module 파일 한 곳에서 모든 리소스를 통제, 관리할 수 있게 되었습니다. 💜

완성 코드는 제 깃허브에 올려놨습니다 참고 부탁드립니다 :)
https://github.com/euneun316/terraform-study/tree/main/Modules/default

마치며..

사실 terraform module은 이미 잘 구성된 코드들이 많습니다. 하지만, 정확하게 module을 파악하지 않으면 그 소스를 가져와 활용하기란 무척 어렵습니다.

이번에 Terraform module을 직접 구성 해 보니, 이제야 전반적으로 Terraform이 동작하는 원리를 더 정확히 알 수 있었습니다.

현재는 EC2, IAM, VPC, SG와 같은 기본 서비스 구성만 완료된 상태지만,

앞으로 kinesis, glue, athena와 같은 타 서비스 생성에 대한 기본 코드도 작성해 놓고, 필요할 때마다 코드를 활용할 수 있도록 module을 고도화할 생각입니다.

테라폼 모듈의 고수가 될 그날까지… 🏃🏻‍♀️

--

--