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

ImOk·2022년 12월 11일
1

Terraform

목록 보기
4/4
post-thumbnail

시작하기 앞서..

지난번 '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)]

      EnvAZSubnetIPv4 CIDR
      Publicap-northeast-2aT101-pub-sub-2a10.60.0.0/24
      Publicap-northeast-2cT101-pub-sub-2c10.60.1.0/24
      Privateap-northeast-2aT101-pri-sub-2a10.60.2.0/24
      Privateap-northeast-2cT101-pri-sub-2c10.60.3.0/24
  3. SG(Security Group)
    보안그룹은 언제든 확장할 수 있게 구성

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

      CIDRPortDesc
      151.149.23.124/3222From Imok SSH(random test ip)
      151.149.23.124/3225From Imok SMTP(random test ip)
      151.149.23.124/3280From Imok HTTP(random test ip)
  4. EC2
    다양한 환경에서 EC2 접속할 수 있게 구성

    • Public EC2 : pem key를 이용한 ssh 접속

    • Private EC2 : AWS SSM을 이용해 접속

    • OS : Amazon Linux 2

      용도NameCPUMemoryDiskType
      public-serverT101-public-server21GiB30GiBt3.micro
      private-serverT101-private-server21GiB3oGiBr5.micro
  5. IAM(Identity and Access Management)
    SSM 사용 및 S3 접근을 위해 Private EC2에 IAM role 적용

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

구축 예상 아키텍처 ✅

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


인프라 구축

👶🏻 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 이름과 일치시키는 방법을 사용합니다.

  • ~/.aws/credentials 파일 예
[imok]
aws_access_key_id = []
aws_secret_access_key = []
[workspace name]
aws_access_key_id = []
aws_secret_access_key = []
...

Terraform module 구성

  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영역으로 구분해 코드를 작성했습니다.

.
├── 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 Resouece

지난 번 구성과 다른 점은 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 Resouece

  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,
      )
    }
  1. 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" {}
  2. 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
    }
  3. 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 Resouece

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

  1. 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
      )
    }
  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 Resouece

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을 고도화할 생각입니다.

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

profile
ImOk👌

2개의 댓글

comment-user-thumbnail
2022년 12월 12일

와우! 개선 사항 정리 부터, 실제 개선 내용을 코드로 반영해서 배포 내용까지 잘 정리해주셨네요.

8주 기간 동안 매주 주말 마다 스터디 참여해서 과제 제출까지 고생 많으셨습니다.

1개의 답글