
지난번 'terraform으로 간단한 인프라 구축하기'에 대한 내용을 작성했습니다. Terraform 기본 인프라 구축하기
비슷한 인프라 구축 요구 사항이 들어왔을 때는 해당 코드를 활용하기 좋았지만, 조금이라도 새로운 리소스 구성이 필요하면 코드의 재사용이 어렵다는 것을 느꼈습니다.
또한 비슷한 환경을 생성할 때 마다 terraform의 .tf 파일을 복사해 계속 새로운 파일을 만들어 내는 점이 비효율적이라 생각되었습니다.
💡 따라서 공통으로 사용하는 리소스를 만들어 코드를 재사용하기 위해
terraform module을 사용하고, 팀원들과 동일 코드을 다른 환경에서 사용하기 위해terraform workspace를 도입하기로 했습니다.
⚠️ 본 내용은 Terraform스터디를 진행하며 현업에 적용 해 본 내용을 기재하였기 때문에, AWS 및 Terraform의 기본 개념에 대해서는 생략하였습니다.
지난번 사원 👶🏻 OK는 terraform으로 기본 인프라 구성을 완료했습니다.
하지만 코드의 재사용에 있어 몇 가지 불편한 점이 있었고, 규모가 커질수록 단순한 방식으로 처리가 힘들어졌습니다.
또한 추가로 팀원들과 공동 작업을 하기 위해, 다음과 같이 코드를 개선하기로 했습니다.
terraform의 count 매개변수의 제약 사항
➡️ for_each 표현식으로 변경
구성 환경의 중복된 코드
 ➡️ terraform module과 terraform workspace를 활용하는 방식으로 코드 변경 
규모가 커짐에 따라 필요한 리소스들(EC2, S3, DynamoDB 등)이 증가해, root 경로에서 파일 이름만으로 분리된 소스를 관리하는 것이 불편해짐
  ➡️ 폴더별로 리소스를 구분하고, root 경로에서 리소스별 모듈을 사용하는 코드로 변경
Bastion Host를 통한 Tunneling 접속 방식의 불편함
➡️ Private Server에 직접 접속할 수 있는 AWS Systems Manager(AWS SSM) 구성
👶🏻 OK는 개선이 필요한 사항을 확인하고, Terraform 구성 전에 미리 구축 내용을 작성했습니다.
VPC(Virtual Private Cloud)
다양한 네트워크 환경 구성
10.60.0.0/16Subnet 구성
Public 환경과 Private 환경 구분
Region : ap-northeast-2 [Asia Pacific (Seoul)]
| Env | AZ | Subnet | IPv4 CIDR | 
|---|---|---|---|
| Public | ap-northeast-2a | T101-pub-sub-2a | 10.60.0.0/24 | 
| Public | ap-northeast-2c | T101-pub-sub-2c | 10.60.1.0/24 | 
| Private | ap-northeast-2a | T101-pri-sub-2a | 10.60.2.0/24 | 
| Private | ap-northeast-2c | T101-pri-sub-2c | 10.60.3.0/24 | 
SG(Security Group)
보안그룹은 언제든 확장할 수 있게 구성
EC2 접속을 위한 On-Premise의 IP Inbound Source 정보 (IP는 보안을 위해 임의로 생성했습니다.)
| CIDR | Port | Desc | 
|---|---|---|
| 151.149.23.124/32 | 22 | From Imok SSH(random test ip) | 
| 151.149.23.124/32 | 25 | From Imok SMTP(random test ip) | 
| 151.149.23.124/32 | 80 | From Imok HTTP(random test ip) | 
EC2
다양한 환경에서 EC2 접속할 수 있게 구성
Public EC2 : pem key를 이용한 ssh 접속
Private EC2 : AWS SSM을 이용해 접속
OS : Amazon Linux 2
| 용도 | Name | CPU | Memory | Disk | Type | 
|---|---|---|---|---|---|
| public-server | T101-public-server | 2 | 1GiB | 30GiB | t3.micro | 
| private-server | T101-private-server | 2 | 1GiB | 3oGiB | r5.micro | 
IAM(Identity and Access Management)
SSM 사용 및 S3 접근을 위해 Private EC2에 IAM role 적용
AmazonSSMManagedInstanceCoreAmazonS3FullAccess👶🏻 OK는 다음과 같이 구축하게 될 예상 아키텍처를 그려봤습니다.

👶🏻 OK는 팀원들에게 공통
terraform module을 작성해 배포해야 했기 때문에 다음과 같은 목표를 세웠습니다.💡 서비스별로 폴더를 분리해,
root module에서만 코드 수정하도록 환경 구성
💡 각자의 aws 환경에서 진행할 수 있도록terraform workspace환경 구성
작업 공간을 통한 Workspaces 격리
HashiCorp 공식 문서 : Terraform Workspaces
default 작업 공간을 사용합니다. 새 작업 공간을 만들거나 전환하려면 terraform workspace 명령을 사용합니다.terraform.tfstate.d라는 폴더 아래에 workspace 별로 폴더가 생성되고, 각각 상태 파일인 .tfstate 파일이 관리되는 형태입니다.
# workspace 구조 예시
├── terraform.tfstate.d
│   ├── imok
│   │   ├── terraform.tfstate
│   │   └── terraform.tfstate.backup
│   └── project
│       ├── terraform.tfstate
│       └── terraform.tfstate.backup
새 workspace 생성
terraform workspace new [workspace name]
workspace 전환
terraform workspace select [workspace name]
workspace 리스트 확인
terraform workspace list
현재 workspace 확인
terraform workspace show
terraform 프로젝트를 두 개 이상의 어카운트에서 사용해야 할 경우, aws credential의 profile 이름을 workspace 이름과 일치시키는 방법을 사용합니다.
~/.aws/credentials 파일 예[imok]
aws_access_key_id = []
aws_secret_access_key = []
[workspace name]
aws_access_key_id = []
aws_secret_access_key = []
...

root.tfEC2, VPC, IAM, SGchild module에서 모듈이 생성하는 resource는 보통의 리소스를 생성하는 코드와 동일하지만, root module(모듈을 사용하는 코드)에서 건네주는 변수들을 사용해서 리소스를 생성해야 합니다. 
저는 root module을 root.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영역으로 구분해 코드를 작성했습니다.

.
├── 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
지난 번 구성과 다른 점은 count를 for_each 문으로 변경했다는 점입니다.
for each 표현식에 대한 설명은 제 블로그 내용 참고 부탁드립니다. Terraform 반복문 Loops 사용하기
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,
  )
}
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" {}
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
}
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,
  )
}
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" {}
output.tf 파일 구성
root module의 output에서 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
}
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 구성은 지난번 구성과 거의 동일하고, locals 변수를 추가해 tag에 활용했습니다.
main.tf 파일 구성
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
  )
}
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" {}
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
}
Private EC2에 AWS SSM을 통해 접속하기 위한 IAM Role 구성입니다.
SSM의 AmazonSSMManagedInstanceCore 정책과 S3의 AmazonS3FullAccess 정책을 붙였습니다.
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
}
variables.tf 파일 구성
root module에서 정의한 변수를 받아오기 위해 다음과 같이 variable을 설정했습니다.
# Project name
variable "name" {}
# Tags
variable "tags" {}
output.tf 파일 구성
ec2 리소스에서 iam의 iam_instance_profile 변수를 사용하기 위해 output에 정의했습니다.
output "iam_instance_profile" {
  value = aws_iam_instance_profile.private.name
}
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
}
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
}
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"
  }
}
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" {}
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 output

VPC - Subnet

IAM Role

EC2

ssh -i "T101-key.pem" ec2-user@15.164.88.41
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을 고도화할 생각입니다.
테라폼 모듈의 고수가 될 그날까지... 🏃🏻♀️
와우! 개선 사항 정리 부터, 실제 개선 내용을 코드로 반영해서 배포 내용까지 잘 정리해주셨네요.
8주 기간 동안 매주 주말 마다 스터디 참여해서 과제 제출까지 고생 많으셨습니다.