Terraform Study #5(테라폼 워크플로)

이번 주제는 테라폼 워크플로와 파이프라인에 대해 다뤄볼 예정이다.

1. 워크플로

Terraform은 인프라스트럭처를 코드로 작성하고 관리할 수 있게 해주는 도구로, 일련의 명확한 워크플로를 제공한다. 이 워크플로를 따르면 사용자는 안정적이고 반복 가능한 방식으로 클라우드 리소스를 배포하고 변경할 수 있다. Terraform 워크플로는 크게는 Write, Plan, Apply로 구성되지만 상세한 단계는 다음 단계들로 구성된다.

초기화 (Initialization): Terraform 작업 디렉토리를 초기화한다. 명령어는 terraform init을 사용한다. 이 단계에서는 Terraform 설정 파일과 필요한 프로바이더 플러그인을 로드하고 프로바이더는 AWS, Azure, GCP 등과 같은 여러 클라우드 서비스에 대한 API 호출을 수행하게 된다.
코드 작성(Write): 사용자는 *.tf 파일에 IaC을 작성한다. 이 코드는 사용하려는 리소스, 설정 및 프로바이더 정보를 정의한다.
실행 계획 (Plan): 명령어로는 terraform plan을 사용한다. 현재 상태와 Terraform 코드를 비교하여 변경 사항을 표시한다. 이는 “예비 실행”과 같으며 실제 리소스에는 변경사항이 적용되지 않는다. 사용자는 이 단계에서 어떤 변경이 발생할지 미리 확인할 수 있다.
적용 (Apply): 명령어로는 terraform apply을 사용한다. 실행 계획에서 제시된 변경 사항을 실제 리소스에 적용한다. 사용자는 변경 사항을 승인한 후에만 이를 적용할 수 있다.
상태 관리 (State Management): Terraform은 .tfstate 파일에 리소스의 현재 상태를 저장한다. 이 상태 파일을 통해 Terraform은 실제 리소스와 Terraform 코드 사이의 매핑을 관리한다.
변수와 출력: variables.tf와 outputs.tf 파일을 사용하여 입력 변수를 정의하고 결과 출력을 관리할 수 있다.
모듈: 복잡한 인프라 구성을 모듈로 분리하여 코드 재사용성을 향상시킬 수 있다.
제거 (Destroy): terraform destroy: Terraform 코드에 정의된 리소스를 제거한다.

위 워크플로에 따라 Terraform은 선언적인 방식으로 인프라를 구성하고 관리할 수 있게 해준다. 사용자는 원하는 최종 상태만을 정의하면 되며, Terraform은 이를 실제 인프라에 반영하는데 필요한 모든 단계를 처리한다.

2. 워크플로 구분

워크플로는 단순하지만 개인과 다수 작업자에 따라 조금 내용이 달라질 수 있다.

개인 워크플로 (Individual Workflow):

  • 이 워크플로는 개발자나 관리자가 혼자 작업할 때 적합하다.
  • Terraform 코드를 로컬 시스템에서 직접 실행하게 된다.
  • Terraform 상태 파일도 로컬에 저장될 수 있다.
  • 이런 방식은 간단한 프로젝트나 테스트, 프로토타이핑 등에 적합하다.

단계:

terraform init: 초기화
terraform plan: 변경 사항 미리보기
terraform apply: 변경 사항 적용
필요한 경우, terraform destroy로 리소스 제거

다중 작업자 워크플로 (Team Workflow):

  • 팀이나 조직에서 여러 사람이 함께 작업할 때 사용되는 워크플로이다.
  • 상태 파일은 원격 스토리지(예: Amazon S3, Terraform Cloud)에 저장되어 여러 사람이 공유할 수 있게 된다.
  • 원격 스토리지를 사용하면 상태 파일의 동시 변경을 방지하는 락 기능을 활용할 수 있다.
  • Terraform Cloud나 Terraform Enterprise는 협업 기능과 함께 실행 환경, 상태 관리, 모듈 저장소 등의 추가 기능을 제공한다.

단계:

원격 스토리지 및 락 설정: Terraform 설정에서 backend를 사용하여 원격 스토리지를 지정합니다.
terraform init: 초기화 시 원격 스토리지에 연결
terraform plan: 변경 사항 미리보기
terraform apply: 변경 사항 적용
필요한 경우, terraform destroy로 리소스 제거

주의사항:
여러 작업자가 동시에 Terraform을 실행하지 않도록 주의해야 한다. 또한, 락 기능을 활용하여 동시 변경을 방지할 수 있다.
모든 팀원이 동일한 Terraform 버전을 사용하는 것이 좋다.
코드 리뷰, 버전 관리 (예: Git) 및 CI/CD 파이프라인과 같은 현대적인 개발 워크플로를 함께 사용하는 것이 좋다.

3. 격리

Terraform에서의 “격리”는 주로 Terraform 상태, 리소스, 구성 요소를 서로 독립적으로 관리하고 운영하기 위한 구조와 워크플로를 의미한다. 협업을 할 때 격리 구조를 설계하지 않으면 여러 문제점이 발생할 수 있다. 격리는 여러 목적으로 사용될 수 있으며, 주로 다음과 같은 이유로 필요하다.

  1. 환경별 분리: 개발, 스테이징, 프로덕션과 같은 다양한 환경을 독립적으로 관리하고 운영하려면 해당 환경별로 리소스와 상태를 분리해야 한다.
  2. 작업 영역 분리: 다양한 팀이나 프로젝트, 애플리케이션 등에 대한 작업을 독립적으로 수행하려면 해당 영역별로 리소스와 상태를 분리해야 한다.
  3. 변경의 최소화: 특정 구성 요소나 서비스에 변경이 발생했을 때, 그 영향을 해당 부분에 국한시켜 다른 부분에 영향을 주지 않도록 하기 위해 필요하다.\

Terraform에서 격리를 달성하는 주요 방법은 다음과 같다.

  1. 스테이트 격리: Terraform 상태 파일(terraform.tfstate)을 통해 리소스의 현재 상태를 추적한다. 다양한 환경이나 작업 영역에서 독립적인 상태 관리를 위해 각각의 상태 파일을 분리할 수 있다. 원격 스토리지(예: Amazon S3)를 사용하면 각 환경별로 별도의 버킷 또는 경로에 상태 파일을 저장하여 격리할 수 있다.
  2. 워크스페이스 사용: Terraform은 워크스페이스라는 기능을 제공하여 다양한 환경을 동일한 구성 내에서 격리할 수 있다. 각 워크스페이스는 고유한 상태를 가지며, 환경 변수를 통해 다른 환경의 설정 값을 제공할 수 있다.
  3. 모듈화: Terraform 모듈을 사용하면 코드를 재사용 가능한, 독립적인 단위로 분리할 수 있다. 이를 통해 리소스와 구성 요소를 독립적으로 관리하고 테스트할 수 있다.
  4. 환경 구성 분리: 다양한 환경(예: 개발, 스테이징, 프로덕션)에 대한 구성을 별도의 파일이나 디렉토리로 분리하여, 환경별로 다른 변수와 설정 값을 제공할 수 있다.

4. 프로비저닝 파이프라인 설계

스터디 실습에서는 Github Action과 Terraform Cloud 등을 사용하는 예제를 썼지만 나는 업무에 사용해봤던 AWS Code Commit/Pipeline/Build을 사용하려고 한다.

4.1 CodeCommit 생성

AWS Console에서 Code Commit 메뉴를 검색해 들어간 뒤 레포지토리 생성을 클릭한다.

리포지토리 이름을 입력하고 생성 버튼을 클릭한다.

생성은 거의 곧바로 완료되고 아래와 같은 화면을 확인할 수 있다.

vscode에서 CodeCommit에 push 등을 하기 위해 자격증명을 진행한다. 이때 IAM User을 사용했고 해당 User에는 AWSCodeCommitFullAccess 정책을 연결해주었다.
이후 해당 사용자에 대해 자격 증명 생성을 위해 IAM Console에서 해당 사용자에 대해 AWS CodeCommint에 대한 HTTPS Git 자격 증명 생명을 진행해주었다.

HTTPS(GRC)을 통해 연결하기 위해서 macOS에 git-remote-codecommit을 설치했다.

다른 계정과 충돌되지 않기 위해 aws configure –profile을 통해 별도 프로파일로 진행하였다

HTTPS(GRC) 복제를 한다.

git clone 명령어를 통해 vscodedㅔ서 코드를 작성 중인 폴더와 레포지토리 클론을 진행한다.

4.2 CodeBuild 생성

코드 빌드 및 테스트를 위해 Code Build을 생성한다. 빌드를 진행하기 전에 위에서 만든 CodeCommit에 브랜치 정보 확인이 가능해야 하기 때문에 사전에 readme.md 같은 파일 하나만이라도 push을 진행해주면 좋다.

# readme.md 작성
vi readme.md

# git push
git add -A
git commit -m 'Initial checkin'
git push
오브젝트 나열하는 중: 3, 완료.
오브젝트 개수 세는 중: 100% (3/3), 완료.
오브젝트 쓰는 중: 100% (3/3), 229 bytes | 229.00 KiB/s, 완료.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Validating objects: 100%
To codecommit::ap-northeast-2://terraformiac
 * [new branch]      master -> master

AWS Console에서 CodeBuild 선택 후 프로젝트 만들기를 클릭한다.

빌드 프로젝트 이름 설정과 소스 설정을 진행해준다. 소스는 위에서 만든 CodeCommit과 브랜치를 선택해준다.

환경에 대한 내용도 작성해준다. 특별하게 필요한 내용이 없기 때문에 간단하게 설정해준다.

추가로 입력해줄 내용이 없으면 프로젝트 빌드 생성을 눌러준다.

빌드가 돌 때 Terraform 설치 및 init/plan을 하기 위해 buildspec 파일을 작성해준다.

#buildspec.yml
version: 0.2

phases:
  install:
    commands:
      - echo "Installing Terraform"
      - sudo yum install -y yum-utils
      - sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
      - sudo yum -y install terraform
      - terraform init
  pre_build:
    commands:
      - echo "Running terraform fmt"
      - terraform fmt
  build:
    commands:
      - echo "Running terraform plan"
      - terraform plan

4.3 Code 작성

테스트는 EC2 배포하는 것으로 진행할 예정이니 간단하게 EC2 작성하는 코드를 작성하고 Push까지 진행해본다.

# main.tf
provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_instance" "example" {
  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  tags = {
    Name = "ybs-iac-ec2"
  }

}

#git push 진행
git add -A
git commit -m 'Initial checkin'
git push

오브젝트 나열하는 중: 5, 완료.
오브젝트 개수 세는 중: 100% (5/5), 완료.
Delta compression using up to 10 threads
오브젝트 압축하는 중: 100% (4/4), 완료.
오브젝트 쓰는 중: 100% (4/4), 691 bytes | 691.00 KiB/s, 완료.
Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Validating objects: 100%
To codecommit::ap-northeast-2://terraformiac
   f8bf77b..0c7981d  master -> master

4.4 CodeDeploy/Pipeline 생성

Commit/Build를 만들었다면 deploy와 pipeline을 생성해준다.

CodeDeploy을 먼저 만들어준다. 간단하게 이름과 EC2를 선택해주고 생성을 누른다.

deploy 단계에서 작동할 appspec.yml파일을 작성해준다. 이때 build와는 다르게 apply도 같이 진행해준다.

#appspec.yml
version: 0.0
os: linux
files:
  - source: /
    destination: /home/ec2-user/terraform/
hooks:
  ApplicationStart:
    - location: scripts/run_build.sh
      timeout: 300
      runas: root

#ipts/run_build.sh
#!/bin/bash
terraform -chdir=/home/ec2-user/terraform/ init
terraform -chdir=/home/ec2-user/terraform/ apply -auto-approve

그 다음 pipeline을 만들어준다. 파이프라인의 이름을 간단하게 작성하고 다음을 누른다.

소스는 위에서 만든 codecommit과 브랜치를 선택해주고 다음을 누른다.

빌드 또한 위에서 만든 build 값을 입력해준다.

배포 스테이지는 위에서 만든 Deploy Application 정보를 입력해준다. (배포 그룹은 간단하게 만들 수 있으니 만들어서 넣어준다.)

그 후 별다른 수정사항이 없으면 파이프라인 생성을 눌러 생성을 진행한다.

파이프라인이 잘 생성되면 알아서 최초 1회 실행되는 것을 확인할 수 있다.

Build 단계에서 실패가 떴다.

원인을 찾아보니 EC2 생성하는 권한이 없어서 오류가 발생한 것으로 보인다. 권한을 추가해주고 다시 실행을 시켰다.

작은 몇 가지 트러블 슈팅을 진행하면서 정상적으로 돌아가기를 기다렸다.

EC2가 제대로 배포된 것을 확인할 수 있다.

5. 정리

이번 시간에는 워크플로와 나름의 파이프라인을 작성해서 테스트를 해보았다. 파이프라인은 프로젝트를 통해 조금은 익숙해졌다고 생각했는데 Role마다 넣어줘야 하는 권한들이 있어 자잘한 트러블슈팅이 꽤 많은 시간을 잡아먹은 거 같다. 그래도 AWS 네이비트 요소들로 테스트할 수 있어서 나름의 도움이 된 거 같다.

Terraform Study #4(State, Module, 협업)

오늘은 Terraform state, Module 그리고 협업에 대해 간략하게 알아볼 예정이다. IaC는 협업을 하기 위함임을 생각하면 오늘 배우는 내용이 중요할 거 같다.

1. State

1.1 State의 목적과 의미

State의 주요 목적과 의미는 다음과 같다.
현재 인프라 상태의 추적: State 파일은 Terraform 구성이 실제 클라우드 인프라에 어떻게 매핑되는지에 대한 정보를 포함한다. 이를 통해 Terraform은 다음 실행 시 어떤 리소스를 생성, 수정, 삭제할지 결정할 수 있다.
변경의 빠른 감지: Terraform은 State 파일을 기반으로 현재 인프라 상태와 구성의 차이점을 판단한다. 이를 통해 Terraform은 최소한의 변경만을 적용하여 인프라를 원하는 상태로 만든다.
출력 변수 저장: Terraform 출력 변수는 State에 저장된다. 이를 통해 다른 Terraform 구성에서 이 값을 참조할 수 있다.
팀과의 협업: 원격 상태 저장소를 사용하면 여러 팀원이 동일한 인프라에 대해 Terraform을 실행할 때 State의 일관성과 동기화를 유지할 수 있다.
리소스 의존성 관리: State를 통해 Terraform은 리소스 간의 의존성을 파악하고, 리소스를 올바른 순서로 생성, 수정, 삭제한다.
드리프트 감지: State 파일을 사용하여 드리프트를 감지하고, 필요한 경우 Terraform을 통해 원하는 상태로 되돌릴 수 있다.
요약하면, State는 Terraform에서 중심적인 역할을 하는 요소로, 실제 클라우드 인프라와 Terraform 구성 간의 차이점과 연관성을 추적하고 관리하는 데 필요하다.

이런 중요한 State에는 몇 가지 조건이 필요하다.
정확성: State 파일은 항상 현재 인프라의 정확한 표현이어야 한다. State와 실제 리소스 간의 불일치는 문제를 일으킬 수 있으므로, State는 최신 상태를 유지해야 한다.
접근 제한: State 파일은 민감한 정보(예: 비밀번호, 엑세스 키 등)를 포함할 수 있기 때문에 적절한 접근 제어가 필요하며, 필요한 팀원만 접근할 수 있도록 해야 한다.
동기화: 여러 개발자나 시스템 관리자가 동일한 인프라에 작업할 경우 원격 상태 저장소를 사용하여 State를 중앙에서 관리하고, 동시에 발생하는 변경을 피하도록 구성해야 한다.
백업: State 파일은 중요한 데이터를 포함하므로, 정기적으로 백업해야 하고, 이를 통해 잘못된 변경이나 손상된 경우 이전 상태로 복구할 수 있다.
버전 관리: 원격 상태 저장소에서는 State 파일의 버전 관리 기능을 활용할 수 있다. AWS S3의 경우 Object Versioning을 활성화하여 State의 변경 이력을 추적할 수 있다.
잠금 Mechanism: Terraform은 리소스 생성 및 수정 작업 중에 state 파일을 잠글 수 있는 기능을 제공한다. 이렇게 하면 동시에 여러 사용자가 같은 state 파일을 변경하는 것을 방지할 수 있다.
암호화: 위의 접근 제한과 마찬가지로 중요 정보를 담고 있을 수 있기 때문에 저장소에 저장되는 상태 파일은 암호화되어야 한다. AWS S3를 원격 상태 저장소로 사용하는 경우, S3의 서버 측 암호화를 활용하여 데이터를 암호화할 수 있다.

1.2 State 동기화

테라폼 구성 파일은 기존 State와 구성을 비교해 실행 계획에서 생성, 수정, 삭제 여부를 결정한다.

https://kschoi728.tistory.com/135

Terraform 액션에 따라 State에 어떤 동작이 발생하는지 유형 별로 확인해보도록 하겠다.

테라폼 구성과 State 흐름 : Plan 과 Apply 중 각 리소스에 발생할 수 있는 네 가지 사항, 아래 실행 계획 출력 기호와 의미

기호의미
+Create
Destroy
-/+Replace
~Updated in-place

Replace 동작은 기본값을 삭제 후 생성하지만 lifecycle의 create_before_destroy 옵션을 통해 생성 후 삭제 설정 가능

1.3 워크스페이스

Terraform의 워크스페이스는 Terraform 구성의 여러 버전 또는 세트를 관리하도록 도와주는 기능이다. 워크스페이스를 사용하면 동일한 구성으로 여러 다른 환경(예: 개발, 스테이징, 프로덕션 등)을 관리할 수 있다.
워크스페이스의 주요 특징 및 사용 사례는 몇 가지가 있다.
환경 분리: 워크스페이스는 다양한 환경을 분리된 상태로 유지하는 데 도움이 된다. 예를 들어 dev, staging, prod와 같은 워크스페이스를 만들어 환경별로 다른 변수나 리소스를 적용할 수 있다.
독립적인 State 관리: 각 워크스페이스는 자체적인 Terraform state 파일을 갖게 된다. 따라서, 한 환경에서의 변경이 다른 환경의 state에 영향을 주지 않는다.
변수 재사용: 워크스페이스별로 변수를 재정의하면서 동일한 Terraform 코드를 재사용할 수 있다. 예를 들어, 각 환경에 대한 다른 서브넷이나 크기의 VM을 지정할 수 있다.
워크스페이스 명령어는 몇 가지가 있다.
terraform workspace new [workspace-name]: 새로운 워크스페이스를 생성한다.
terraform workspace select [workspace-name]: 특정 워크스페이스로 전환한다.
terraform workspace list: 사용 가능한 모든 워크스페이스를 나열한다.
terraform workspace show: 현재 선택된 워크스페이스를 표시한다.

간단한 실습으로 State와 워크스페이스를 확인해본다.

# workspace 확인
terraform workspace list
* default

# 간단 EC2 배포 main.tf
resource "aws_instance" "mysrv1" {
  ami           = "ami-0ea4d4b8dc1e46212"
  instance_type = "t2.micro"
  tags = {
    Name = "t101-week4"
  }
}

# Terraform 배포 후 state 확인
terraform init && terraform apply -auto-approve
terraform state list
aws_instance.mysrv1

# Public/Private IP 확인
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
3.39.231.x
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.private_ip'
172.31.39.105
cat terraform.tfstate | jq -r '.resources[0].instances[0].private' | base64 -d | jq
{
  "e2bfb730-ecaa-11e6-8f88-34363bc7c4c0": {
    "create": 600000000000,
    "delete": 1200000000000,
    "update": 600000000000
  },
  "schema_version": "1"
}

새로운 워크스페이스를 만들어서 apply을 진행해봤다. workspace가 새로 생기면 state도 새로 생기고 개별로 구성되는 것을 확인할 수 있다.

# 새 워크스페이스 생성
terraform workspace new mywork1
terraform workspace list
  default
* mywork1
terraform workspace show
mywork1

tree terraform.tfstate.d
terraform.tfstate.d
└── mywork1
2 directories, 0 files

# plan 할 경우 기존 워크스페이스라면 create가 표시되지 않아야 하나 새로운 워크스페이스(mywork1)이 선택되어 있어서 create로 표시
terraform plan

# apply을 하면 plan 내용처럼 배포가 진행 된다.
terraform apply -auto-approve

# 워크스페이스 확인
terraform workspace list
  default
* mywork1

# State 확인하면 기존 배포한 것과 새로 배포한 것의 Public IP가 차이가 난다.
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
3.39.231.x
cat terraform.tfstate.d/mywork1/terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
15.164.x.x

# 실습 리소스 삭제
terraform workspace select default
terraform destroy -auto-approve
terraform workspace select mywork1
terraform destroy -auto-approve

워크스페이스의 장단점은 아래와 같다.

장점
하나의 루트 모듈에서 다른 환경을 위한 리소스를 동일한 테라폼 구성으로 프로비저닝하고 관리
기존 프로비저닝된 환경에 영향을 주지 않고 변경 사항 실험 가능
깃의 브랜치 전략처럼 동일한 구성에서 서로 다른 리소스 결과 관리

단점
State가 동일한 저장소에 저장되어 State 접근 권한 관리가 불가능(어려움)
모든 환경이 동일한 리소스를 요구하지 않을 수 있으므로 테라폼 구성에 분기 처리가 다수 발생 가능
프로비저닝 대상에 대한 인증 요소를 완벽히 분리하기 어려움-> 가잔 큰 단점은 완벽한 격리가 불가능
-> 해결방안 1. 해결하기 위해 루트 모듈을 별도로 구성하는 디렉터리 기반의 레이아웃 사용
-> 새결방안 2. Terraform Cloud 환경의 워크스페이스를 활용

2. 모듈

Terraform 모듈은 Terraform 코드의 집합이다. 이를 사용하면 코드를 재사용하고, 구조화하며, 공유할 수 있다. 기본적으로 모듈은 Terraform의 스크립트나 구성을 논리적 단위로 캡슐화하는 방법이다. 모듈을 사용하여 공통 구성 요소를 쉽게 재사용하고 관리할 수 있다.
Terraform 모듈의 주요 특징 및 용도는 아래와 같다.
코드 재사용: 모듈은 반복되는 코드 패턴을 줄이기 위해 공통 리소스나 구성을 캡슐화한다. 예를 들어, 여러 프로젝트나 환경에서 동일한 VPC, 보안 그룹, EC2 인스턴스 설정을 사용해야 할 때 모듈로 정의하면 이를 쉽게 재사용할 수 있다.
논리적 구조: 모듈을 사용하여 복잡한 Terraform 코드를 관리하기 쉬운 논리적 단위로 분리할 수 있다. 이렇게 하면 코드의 가독성과 유지 관리성이 향상된다.
변수 및 출력: 모듈은 입력 변수를 통해 매개 변수화되고, 출력 변수를 통해 다른 Terraform 구성 또는 모듈에 데이터를 반환할 수 있다.
버전 관리: Terraform 모듈은 Git과 같은 소스 코드 관리 시스템에 저장되어 버전 관리될 수 있다. 또한, Terraform Registry나 다른 버전화된 저장소를 사용하여 공유 및 배포될 수 있다.
안정성과 표준화: 모듈을 사용하면 안정적이고 표준화된 인프라 구성 요소를 제공할 수 있다. 특히 큰 팀이나 여러 프로젝트에서 일관된 인프라 구성을 유지하기 위해 유용하다.

module "vpc" {
  source  = "aws/vpc/aws"
  version = "2.0.0"
  ...
}

간단한 예제로 위의 예제에서는 AWS VPC 모듈을 사용하여 VPC를 생성하며, 해당 모듈은 공식 Terraform Registry에서 가져온다
모듈을 사용하면 Terraform 코드의 재사용성과 유지 관리성을 크게 향상시킬 수 있다. 여러 프로젝트나 환경 간에 일관된 인프라 코드를 유지하기 위한 기본 도구로 간주된다.

간단한 모듈화 테스트를 해보려고 한다. VPC을 만드는 모듈을 사용해볼 예정이다.
먼저 my_vpc_module라는 디렉터리를 만들고 모듈 코드를 작성한다.

#my_vpc_module/main.tf
resource "aws_vpc" "ybs-vpc" {
  cidr_block = var.cidr_block
  tags = {
    Name = var.vpc_name
  }
}

output "vpc_id" {
  value = aws_vpc.ybs-vpc.id
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "vpc_name" {
  description = "Name tag for the VPC"
  type        = string
}

루트 디렉토리에 main.tf을 만들어서 vpc 모듈을 호출한다.

module "my_vpc" {
  source    = "./my_vpc_module"
  cidr_block = "10.0.0.0/16"
  vpc_name   = "ybs-test-vpc"
}

output "created_vpc_id" {
  value = module.my_vpc.vpc_id
}

해당 모듈을 호출하는 테스트를 진행해본다. 잘 생성되는 것과 더불어 vpc 이름까지 잘 적용된 것을 확인할 수 있다.

#terraform init 및 Plan/Apply 실행
terraform init && terraform apply -auto-approve

#terraform state 확인
terraform state list                          
module.my_vpc.aws_vpc.ybs-vpc

조금 더 심화 내용으로 모듈을 2개 사용하는 실습을 진행할 예정이다. VPC 모듈과 EC2 모듈을 각각 작성하고 두 모듈을 호출해서 리소스를 생성할 예정이다.

먼저 vpc 폴더를 만들고 main.tf로 vpc 모듈을 작성한다.

#vpc/main.tf
resource "aws_vpc" "ybs-vpc" {
  cidr_block = var.cidr_block
  tags = {
    Name = var.vpc_name
  }
}

resource "aws_subnet" "ybs-subnet" {
  vpc_id     = aws_vpc.ybs-vpc.id
  cidr_block = var.subnet_cidr_block
  availability_zone = var.availability_zone
  tags = {
    Name = "${var.vpc_name}-subnet"
  }
}

output "vpc_id" {
  value = aws_vpc.ybs-vpc.id
}

output "subnet_id" {
  value = aws_subnet.ybs-subnet.id
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
 
}

variable "vpc_name" {
  description = "VPC Name"
  type = string
 
}
variable "subnet_cidr_block" {
  description = "CIDR block for the Subnet"
 
}

variable "availability_zone" {
  description = "The availability zone where the subnet will be created"
  type = string
}

EC2도 마찬가지로 폴더를 만들고 모듈을 작성한다.

#ec2/main.tf
resource "aws_instance" "ybs-ec2" {
  ami           = var.ami_id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  tags = {
    Name = var.instance_name
  }
}

output "instance_id" {
  value = aws_instance.ybs-ec2.id
}

variable "ami_id" {
  description = "AMI ID for the EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "Instance type for the EC2 instance"
  type        = string
}

variable "subnet_id" {
  description = "Subnet ID where the EC2 instance will be launched"
  type        = string
}

variable "instance_name" {
  description = "Name tag for the EC2 instance"
  type        = string
}

두 모듈을 호출하는 main.tf를 루트 디렉토리에 만든다.

#main.tf
module "my_vpc" {
  source    = "./vpc"
  cidr_block = "10.0.0.0/16"
  vpc_name   = "MyVPC"
}

module "my_ec2" {
  source         = "./ec2"
  ami_id         = "ami-0123456789abcdef0" # 예시 AMI ID
  instance_type  = "t2.micro"
  subnet_id      = module.my_vpc.vpc_id # VPC 모듈에서 생성된 VPC의 ID를 사용
  instance_name  = "MyInstance"
}

output "created_vpc_id" {
  value = module.my_vpc.vpc_id
}

output "created_instance_id" {
  value = module.my_ec2.instance_id
}

모듈을 실행시키면 VPC와 EC2가 제대로 만들어지는 걸 확인할 수 있다.

#terraform init 및 Plan/Apply 실행
terraform init && terraform apply -auto-approve

#terraform state 확인
terraform state list 
module.my_ec2.aws_instance.ybs-ec2
module.my_vpc.aws_subnet.ybs-subnet
module.my_vpc.aws_vpc.ybs-vpc      

3.협업

인프라 규모가 커지고 관리 팀원이 늘어날 수록 구성 코드 관리가 필요하다. → 서로 작성 코드 점검 및 협업 환경 구성

구성 요소 : 코드를 다수의 작업자가 유지 보수 할 수 있도록 돕는 VCS Version Control System + 테라폼 State를 중앙화하는 중앙 저장소

https://kschoi728.tistory.com/139

유형 1 : VCS, 중앙 저장소 없음

  • 동일한 대상을 관리하는 여러 작업자는 동일한 프로비저닝을  위해 각자 자신이 작성한 코드를 수동으로 공유가 필요
  • 작업자의 수가 늘어날수록 코드 동기화는 어려워지고, 각 작업자가 작성한 코드를 병합하기도 어렵다 → VCS 도구 도입 고민 시점

유형 2 : VCS(SVN, Git), 중앙 저장소 도입

  • 형성관리 도구를 통해 여러 작업자가 동일한 테라폼 코드를 공유해 구성 작업
    • 변경 이력 관리 및 이전 버전으로 롤백 가능
  • 공유파일은 테라폼 구성파일과 State ← 테라폼 프로비저닝의 결과물로 데이터 저장소와 같음
  • 작업자가 서로 다른 프로비저닝한 State 결과를 공유를 위해서 백엔드(공유 저장소) 설정을 제공

유형 3 : VCS(Github), 중앙 저장소 도입

  • 테라폼 코드 형상관리를 위한 중앙 저장소 + State 백엔드 → 작업자는 개별적으로 프로비저닝을 테스트하고 완성된 코드를 공유
    • State 는 중앙 관리되어, 작업자가 프로비저닝을 수행하면 원격 State의 상태를 확인하고 프로비저닝 수행

4. 정리

협업은 아직 실습까진 진행하지 않았고 기초적인 이론만 살펴보았다. IaC는 궁극적으로 협업을 하기 위한 도구에 지나지 않는다고 생각한다. 그 협업을 하기 위해 State 관리가 필요하고 모듈을 세분화해서 관리하는 게 유리하기 때문에 State와 모듈에 대해 자세히 알아볼 필요가 있는 것 같다.
다음 시간에는 협업에 대한 실습을 상세히 진행해보면 좋을 거 같다.

Terraform Study #3(조건식, 함수, 프로바이더, 프로비저너)

오늘은 Terraform 조건식을 이용해서 리소스를 배포하는 것과 프로비저너, 함수 그리고 moved 블록 등을 사용해서 aws 리소스를 배포해보는 실습을 진행할 예정이다.

1. 조건식 (도전과제 #1)

Terraform에서의 조건식은 주로 boolean 값, 비교 연산자, 논리 연산자를 사용하여 표현된다. 조건식을 사용하여 변수나 리소스의 속성 값에 따라 다른 결과를 도출할 수 있다.
일반적으로 비교, 논리 연산자를 사용해 조건을 확인하고 조건식은 ? 기호를 기준으로 왼쪽은 조건, 오른쪽은 : 기준으로 왼쪽이 조건에 대해 true가 반환되는 경우이고 오른쪽이 false가 반환되는 경우에 대한 값이다.
예를 들어 아래와 같은 연산조건식이 있다면, var.is_produntion 변수가 참일 경우 t2.large를 사용하고 false일 경우 t2.micro을 사용하게 된다.

locals {
  instance_size = var.is_production ? "t2.large" : "t2.micro"
}

조건식에 대해서 간단한 예제 겸 도전과제를 진행해보도록 한다.
아래 코드는 create_instance라는 변수를 입력 받아서 true일 경우 EC2를 생성하고 false 경우 생성하지 않는 조건식을 담고 있다.

variable "create_instance" {
  type        = bool
}

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_instance" "ybs-ec2-bool" {
  count = var.create_instance ? 1 : 0

  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  tags = {
    Name = "ybs-ec2-bool"
  }
}

terraform apply 후 false을 입력하면 아래와 같이 아무런 변화가 발생하지 않는 것을 볼 수 있다.

반대로 true을 입력하면 EC2가 정상적으로 실행되는 것을 볼 수 있다.

이렇게 조건식을 간단하게 사용할 수 있다. 추후 Prd, Stg, Dev 등 환경을 나눠서 배포해야 하는 경우거나 Application 종류에 따라 다른 태그값 등을 입력하고 싶을 때 조건식을 사용하는 게 큰 도움이 될 거 같다.

2. 함수(도전과제 #2)

Terraform은 프로그래밍 언어적인 특성을 갖고 있어서 값의 유형을 변경하거나 조합할 수 있는 내장 함수를 제공하여 코드를 작성하는데 도움을 준다.
이 함수들은 문자열 처리, 숫자 연산과 데이터 구조의 변환 그리고 조건 판단 등 다양한 작업을 수행하게 된다.
함수에는 몇 가지가 있는데 아래와 같은 종류가 있다.

문자열 함수:
join(separator, list): 리스트의 요소를 주어진 구분자로 연결
split(separator, string): 문자열을 구분자로 분리하여 리스트를 생성
lower(string): 문자열을 소문자로 변환

숫자 함수:
max(a, b, …): 주어진 숫자 중 가장 큰 값을 반환
min(a, b, …): 주어진 숫자 중 가장 작은 값을 반환
abs(number): 주어진 숫자의 절대값을 반환

리스트와 맵 함수:
length(list): 리스트나 맵의 길이를 반환
element(list, index): 리스트에서 특정 인덱스의 요소를 반환
merge(map1, map2, …): 두 개 이상의 맵을 병합

조건 함수:
coalesce(value1, value2, …): 주어진 값 중 첫 번째 null이 아닌 값을 반환
count(list): 주어진 리스트의 길이를 반환
contains(list, value): 리스트가 특정 값을 포함하는지 여부를 반환

타입 변환 함수:
tostring(value): 주어진 값을 문자열로 변환
tonumber(value): 주어진 값을 숫자로 변환
tolist(value): 주어진 값을 리스트로 변환

기타:
file(path): 파일의 내용을 문자열로 읽음
templatefile(path, vars): 템플릿 파일을 읽어와 변수를 주입
timestamp(): 현재 시간을 ISO 8601 형식의 문자열로 반환

위 내용을 기반으로 간단한 예제 겸 도전과제 2번을 진행해봤다.
샘플은 도전과제 #1에서 사용한 내용을 기반으로 하였고 Enviroment 값을 추가로 입력 받아 Name Tag와 merge하여 EC2의 Tag을 정의하는 내용으로 진행했다.

variable "create_instance" {
  type        = bool
}

variable "app_env" {
  type = string

  validation {
    condition     = contains(["prd", "dev", "stg"], var.app_env)
    error_message = "prd, dev, stg 중 하나만 입력하세요."

  }
}


locals {
  common_tags = {
    "Name" = "ybs-ec2-fn"
  }
  env_tags = {
    "Environment" = var.app_env
  }


  final_tags = merge(local.common_tags, local.env_tags)
}

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_instance" "ybs-ec2-fn" {
  count = var.create_instance ? 1 : 0

  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  tags = local.final_tags
}

terraform apply을 수행하면 app_env을 물어보고 이후에 생성 가/부를 물어본다. 각각 prd, true을 입력하면 아래와 같이 태그가 입력되면서 EC2가 생성되는 것을 확인할 수 있다.

3. 프로비저너(도전과제 #3)

Terraform의 프로비저너(provisioners)는 리소스를 생성, 수정, 또는 제거한 후에 로컬 머신이나 원격 머신에서 특정 작업을 실행할 수 있도록 해준다. 일반적으로 프로비저너는 구성 관리 도구, 초기 스크립트 실행, 또는 다른 로직을 적용하기 위해 사용된다.
예를 들어서 EC2를 배포 후 특정 패키지를 설치하거나 파일을 생성해야 하는 경우에 프로비저너를 사용하여 진행하게 된다.

프로비저너를 사용하면 저런 편리함을 가질 수 있지만 아래와 같은 주의해야 하는 부분도 있다.
프로비저너는 오류 복구가 어려울 수 있으며, 선언적이지 않은 로직을 도입할 수 있다.
가능하면 초기 구성은 구성 관리 도구나 클라우드 초기화 스크립트 (예: cloud-init) 등을 통해 처리하는 것이 더 좋다.

그럼 프로비저너를 사용하는 간단한 예제 겸 도전과제 3번을 진행하도록 한다.
EC2 인스턴스를 생성하고 file 프로비저너를 사용해서 로컬에 있는 test.sh 파일을 업로드 특정 위치에 업로드 하고 remote-exec 프로비저너를 통해 해당 업로드한 test.sh을 실행하는 로직으로 구성되어 있다.
apply을 실행하면 생성하고 접속 후 file의 내용을 remote-exec을 통해 잘 불러오는 것을 확인할 수 있다.

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_key_pair" "pem_key" {
  key_name   = "testpem"
  public_key = file("~/.ssh/id_rsa.pub")
}

resource "aws_security_group" "allow_ssh" {
  name        = "inssh"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "example" {
  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  key_name          = aws_key_pair.pem_key.key_name
  security_groups   = [aws_security_group.allow_ssh.name]
  associate_public_ip_address = true

#로컬 파일에 있는 sh 파일을 원격 업로드
  provisioner "file" {
    source      = "./test.sh"
    destination = "/tmp/test.sh"
  }

#업로드한 sh 파일을 실행(chmod로 실행 권한 부여 진행)
    provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/test.sh",
      "/tmp/test.sh"
    ]
  }

  connection {
    type        = "ssh"
    host        = self.public_ip
    user        = "ec2-user"
    private_key = file("~/.ssh/id_rsa")
  }

  tags = {
    Name = "ybs-remote-test"
  }
}

생성과 실행까지 잘 되었지만 테스트 과정에서 프로비저너를 몇 번 수정해야 하는 경우가 있었다. sh 파일 경로를 잘못 입력하거나 chmod로 권한을 부여하지 않아 권한 오류가 뜨는 등 몇 번의 수정이 필요했다.
그때마다 수정한 내용이 반영되지 않아 destroy을 진행 후 다시 apply을 해야했는데 이는 프로비저너의 경우 테라폼의 상태 파일과 동기화되지 않기 때문이다. 따라서 수정을 하더라도 상태 파일에서는 수정했다고 인지하지 않아 변경점이 적용되지 않는다. 이는 실제 운영단계에서는 참고해서 진행해야 하는 부분이라고 생각한다.

4. null_resource와 terraform_data

테라폼 1.4 버전이 릴리즈되면서 기존 null_resource 리소스를 대체하는 terraform_data 리소스가 추가되었다.
null_resource는 Terraform에서 특별한 동작을 하지 않는 리소스이다. 이는 다른 리소스가 생성, 수정, 또는 삭제될 때마다 동작을 트리거하는 등, 복잡한 조건이나 의존성을 관리하는 데 주로 사용된다. null_resource 자체는 아무런 리소스를 생성하지 않지만, provisioner나 triggers와 같은 추가 설정을 통해 다른 리소스에 영향을 줄 수 있다.
예를 들어, null_resource를 사용하여 앞서 진행한 프로비저너를 통해 AWS EC2 인스턴스가 생성될 때마다 로컬 스크립트를 실행하도록 할 수 있다.

간단한 예제는 아래와 같다.
아래 예제를 실행하면 ec2 인스턴스가 생성 될 때마다 local-exec 프로비저너를 실행하게 된다.

resource "aws_instance" "example" {
  ami           = "ami-xxxxxxxxxx"
  instance_type = "t2.micro"
}

resource "null_resource" "example" {
  triggers = {
    instance_id = aws_instance.example.id
  }

  provisioner "local-exec" {
    command = "echo ${aws_instance.example.id} >> instances.txt"
  }
}

terraform_data 리소스는 null_resource와 마찬가지로 아무것도 수행하지 않지만 null_resource와의 차이점은 별도의 프로바이더 구성 없이 Terraform 자체에 포함 된 기본 수명주기 관리자가 제공된다는 것이 특징이다.

5. moved 블록(도전과제 #5)

Terraform의 state에 기록되는 리소스 주소의 이름이 변경되면 기존 리소스는 삭제되고 새로운 리소스가 생성된다. 하지만 Terraform 리소스를 선언하다 보면 이름을 변경해야 하는 상황이 발생하게 된다. 이때 이름만 변경하고 리소스는 삭제 후 재생성되지 않게 유지하기 위한 방법으로 moved 블록을 Terraform 1.1 버전부터 제공한다.
moved 블록 제공 이전에는 state을 직접 편집하는 terraform state mv 명령을 사용하여 state를 편집해야 하는 부담이 있었지만 moved 블록을 사용하게 되면 리소스 영향 없이 쉽게 리소스 이름을 변경할 수 있다.

간단한 실습 겸 도전과제를 진행해보도록 하겠다.
앞서 도전과제 #1에서 사용한 코드로 우선 EC2를 생성하였다.

variable "create_instance" {
  type        = bool
}

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_instance" "ybs-ec2-bool" {
  count = var.create_instance ? 1 : 0

  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  tags = {
    Name = "ybs-ec2-bool"
  }
}

이후 moved 블록을 사용하여 리소스 이름만 변경해보았다.

variable "create_instance" {
  type        = bool
}

provider "aws" {
  region = "ap-northeast-2"
}

#moved 블록으로 이름 전환
  moved {
    from = aws_instance.ybs-ec2-bool
    to   = aws_instance.ybs-ec2-bool-new
  }

#리소스 이름도 같이 변경
resource "aws_instance" "ybs-ec2-bool-new" {
  count = var.create_instance ? 1 : 0

  ami           = "ami-084e92d3e117f7692"
  instance_type = "t2.micro"

  tags = {
    Name = "ybs-ec2-bool"
  }
}

apply을 실행하면 added, changed, destroyed 모두 0으로 표시되고 Resource 이름이 -new로 잘 바뀐 것을 확인할 수 있다.

6. 프로바이더

프로바이더는 Terraform이 인프라스트럭처 서비스, 플랫폼 서비스, 기타 API에 대한 CRUD(Create, Read, Update, Delete) 작업을 수행할 수 있도록 API와의 상호작용을 담당한다. 예를 들어, AWS, Azure, GCP와 같은 클라우드 제공 업체에 대한 프로바이더가 있으며, GitHub, GitLab, Datadog 등 다른 서비스에 대한 프로바이더도 있다.

프로바이더는 특정 서비스와의 상호작용을 추상화하므로, 사용자는 서비스의 구체적인 API를 직접 다루지 않아도 된다. 대신, Terraform의 HCL(HashiCorp Configuration Language)을 사용하여 서비스 리소스를 정의하고 관리한다.

https://www.hashicorp.com/blog/making-terraform-provider-development-more-accessible

프로바이더를 사용하면 서로 다른 리전에 동일한 종류의 리소스를 배포하는 것도 가능하다.
다만, 주의사항이 있다.
Multi-region is hard – active-active 멀티 리전 서비스를 위해서 ‘지역간 지연 시간, 고유 ID, 최종 일관성’ 등 여러가지 고려사항이 많아서 프로덕션 수준의 멀티 리전은 어렵다.
Use aliases sparingly alias를 빈번하게 사용하지 말자 – 별칭을 사용하여 두 리전에 배포하는 단일 테라폼 모듈은 한 리전이 다운 시, plan과 apply 시도가 실패하게 된다.

주의사항을 알아봤으니 간단하게 실습 겸 도전과제로 서울과 버지니아 북부 리전에 S3를 배포하는 실습을 진행하보도록 한다.

2개의 프로바이더를 선언하고 리소스를 생성할 때 provider를 통해 각각의 리전을 호출해서 생성하는 코드를 작성했다.

provider "aws" {
  region = "ap-northeast-2"
  alias  = "seoul"
}

provider "aws" {
  region = "us-east-1"
  alias  = "virginia"
}

# 서울 리전에 S3 버킷 생성
resource "aws_s3_bucket" "bucket_seoul" {
  provider = aws.seoul
  bucket   = "ybs-t102-prv-seoul"

  tags = {
    Name        = "ybs-t102-prv-seoul"
  }
}

# 버지니아북부 리전에 S3 버킷 생성
resource "aws_s3_bucket" "bucket_virginia" {
  provider = aws.virginia
  bucket   = "ybs-t102-prv-virginia"

  tags = {
    Name        = "ybs-t102-prv-virginia"
  }
}

프로바이더를 사용해서 별 무리 없이 각각의 리전에 S3 버킷을 배포할 수 있다.

7. 정리

조건식과 함수를 통해 리소스를 조금 더 환경에 맞춰 배포하는 방법을 익힐 수 있었다.
프로바이더를 활용하여 리전에 맞춰 배포하는 것도 흥미로웠다. 오늘 배운 내용들을 추후에 대규모 인프라를 배포할 때 참고해서 진행하면 좋을 거 같다.

Terraform Study #2

오늘은 데이터소스와 변수 그리고 반복문에 대해 진행할 예정이다.

1. Data Source(도전과제 #1 포함)

Terraform에서 Data Source는 구성된 제공자(provider)에서 읽을 수 있는 데이터를 나타낸다. Data Source는 이미 존재하는 리소스에 대한 정보를 조회하거나 외부와 통신하여 정보를 얻을 수도 있다. 이 정보는 다른 리소스를 새엉하거나 구성할 때 사용할 수 있다.
예를 들어, AWS에서 실행 중인 EC2 인스턴스의 정보를 얻으려면 aws_instance Data Source을 사용해서 얻을 수 있다.

Data Source는 data 블록을 사용한다. 미리 배포한 테스트 목적의 EC2에 대한 Public IP을 출력하는 내용을 진행해봤다.

data "aws_instance" "example" {
  instance_id = "i-12345678"
}

output "instance_public_ip" {
  value = data.aws_instance.example.public_ip
}

#초기화 및 plan 진행
terraform init
terraform plan

EC2 ID에 맞는 Public IP을 호출하는 것을 확인할 수 있다.

간단하게 Data Source을 사용해봤다면 도전과제를 진행해보도록 한다.
해당 내용을 진행하기 위한 과정을 간단하게 정리하면 다음과 같다.
1. AWS 서울 리전에서 사용 가능한 가용 영역을 Data Source을 통해 가져온다.
2. 가져온 가용 영역을 사용하여 VPC 내 Subnet을 생성

위 내용을 아래 코드로 작성하였다.
사용 가능한 가용 영역 목록을 Data Source을 통해 불러오고 VPC IP CIdr을 10.0.0./16으로 선택한 후 서브넷을 각각 /24로 생성하는 과정이다.

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "ybs-c1-vpc" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = "ybs-c1-vpc"
  }
}

resource "aws_subnet" "ybs-c1-vpc-subnet" {
  count = length(data.aws_availability_zones.available.names)

  cidr_block = "10.0.${count.index + 1}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]
  vpc_id = aws_vpc.ybs-c1-vpc.id

  tags = {
    Name = "ybs-c1-vpc-subnet-${count.index}"
  }
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

VPC와 서브넷 4개가 잘 생성된 것을 볼 수 있다.

콘솔에서도 확인했을 때 가용 영역이 a~d까지 잘 생성된 것으로 보인다.

graph 생성을 통해 본 로직은 아래와 같다.

2. 입력 변수 Variable

Terraform에서 입력 변수(Variable)는 Terraform 구성에 값을 주입하는 방법을 제공한다. 이는 동적인 구성을 가능하게 하고 동일한 코드를 다양한 환경 또는 조건에 재사용할 수 있게 해준다. 변수는 var 식별자를 통해 참조할 수 있고 구성 내에서 다양한 리소스의 출력에 사용 된다.

변수를 선언하기 위해 variable 블록을 사용한다. 이 블록에는 변수의 이름, 타입과 기본값 그리고 설명 등을 포함한다.

간단하게 작성을 하면 아래와 같다. region이라는 변수를 선언하고 provider 블록에서 var.region을 통해 region 변수 값을 호출할 수 있다.

variable "region" {
  description = "The AWS region to deploy into"
  default     = "ap-northeast-2"
  type        = string
}

provider "aws" {
  region = var.region
}

아무 변수 이름이나 사용할 수 있는 것은 아니고 Terraform에서 예약 변수로 사용하고 있는 변수들은 사용할 수 없다.(source, version, providers, count, for_each, lifecycle, depends_on, locals 등)

변수 타입은 아래와 같다.

String : 텍스트 값
Number : 숫자
Bool : true/false
List([type]) : 같은 유형의 여러 값
Set([type]) : 유일한 값들의 집합
Map([type]) : 키-값 쌍의 집합
Object({ key: type, … }) : 여러 필드를 가진 객체
Tuple([type, …]) : 여러 유형을 가진 리스트

3. VPC/SG/EC2 배포(Data Source+변수 사용, 도전과제 #2, #3, #4)

Data Source와 변수를 사용해서 VPC/SG/EC2를 배포하는 실습을 진행해봤다.

우선 vpc.tf을 통해 vpc와 Subnet, Route Table 그리고 IGW를 생성했다.
가용 영역의 경우 az1, az3라는 변수를 통해 서브넷 생성 시 변수 호출할 수 있게 작성했다.
IGW로 라우팅을 할 수 있도록 Route Table을 만들고 2개의 Subnet에 연결해준 뒤 0.0.0.0/0에 대해 IGW로 라우팅할 수 있는 Route을 등록해줬다.

variable "region" {
  description = "The AWS region to deploy into"
  default     = "ap-northeast-2"
  type        = string
}

variable "az1" {
  description = "The availability zone a"
  default     = "ap-northeast-2a"
  type        = string
}

variable "az3" {
  description = "The availability zone c"
  default     = "ap-northeast-2c"
  type        = string
}

provider "aws" {
  region  = var.region
}

resource "aws_vpc" "ybs-vpc" {
  cidr_block       = "10.10.0.0/16"

  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "ybs-vpc"
  }
}

resource "aws_subnet" "ybs-subnet1" {
  vpc_id     = aws_vpc.ybs-vpc.id
  cidr_block = "10.10.1.0/24"

  availability_zone = var.az1

  tags = {
    Name = "ybs-subnet1"
  }
}

resource "aws_subnet" "ybs-subnet2" {
  vpc_id     = aws_vpc.ybs-vpc.id
  cidr_block = "10.10.2.0/24"

  availability_zone = var.az3

  tags = {
    Name = "ybs-subnet2"
  }
}

resource "aws_internet_gateway" "ybs-igw" {
  vpc_id = aws_vpc.ybs-vpc.id

  tags = {
    Name = "ybs-igw"
  }
}

resource "aws_route_table" "ybs-rt1" {
  vpc_id = aws_vpc.ybs-vpc.id

  tags = {
    Name = "ybs-rt1"
  }
}

resource "aws_route_table_association" "ybs-rt1-asso1" {
  subnet_id      = aws_subnet.ybs-subnet1.id
  route_table_id = aws_route_table.ybs-rt1.id
}

resource "aws_route_table_association" "ybs-rt1-asso2" {
  subnet_id      = aws_subnet.ybs-subnet2.id
  route_table_id = aws_route_table.ybs-rt1.id
}

resource "aws_route" "ybs-rt1-drt" {
  route_table_id         = aws_route_table.ybs-rt1.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.ybs-igw.id
}

output "aws_vpc_id" {
  value = aws_vpc.ybs-vpc.id
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
aws_internet_gateway.ybs-igw
aws_route.ybs-rt1-drt
aws_route_table.ybs-rt1
aws_route_table_association.ybs-rt1-asso1
aws_route_table_association.ybs-rt1-asso2
aws_subnet.ybs-subnet1
aws_subnet.ybs-subnet2
aws_vpc.ybs-vpc

보안그룹은 특별한 내용 없이 지난 주 실습 내용을 토대로 Egress Any와 Ingress 80 Port에 대한 허용 설정을 진행해줬다. 여기서도 Ingress 80 Port와 Any Cidr Block는 변수로 설정해서 추후 SG가 추가될 때 참조할 수 있도록 했다.

variable "http" {
  description = "The HTTP port"
  default     = 80
  type        = number
}

variable "anycidr" {
  description = "The any cidr block"
  default     = ["0.0.0.0/0"]
  type        = list
}

resource "aws_security_group" "ybs-sg1" {
  vpc_id      = aws_vpc.ybs-vpc.id
  name        = "YBS SG 1"
  description = "YBS Study SG 1"
}

resource "aws_security_group_rule" "ybs-in-http-rule" {
  type              = "ingress"
  from_port         = var.http
  to_port           = var.http
  protocol          = "tcp"
  cidr_blocks       = var.anycidr
  security_group_id = aws_security_group.ybs-sg1.id
}

resource "aws_security_group_rule" "ybs-out-any-rule" {
  type              = "egress"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = var.anycidr
  security_group_id = aws_security_group.ybs-sg1.id
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
aws_internet_gateway.ybs-igw
aws_route.ybs-rt1-drt
aws_route_table.ybs-rt1
aws_route_table_association.ybs-rt1-asso1
aws_route_table_association.ybs-rt1-asso2
aws_security_group.ybs-sg1
aws_security_group_rule.ybs-in-http-rule
aws_security_group_rule.ybs-out-any-rule
aws_subnet.ybs-subnet1
aws_subnet.ybs-subnet2
aws_vpc.ybs-vpc

앞서 생성한 VPC와 SG을 참조해서 EC2를 생성하도록 한다. 해당 EC2에는 Data Source을 통해 ami id을 참조하도록 한다. 그리고 아래 다룰 locals을 사용해서 ec2 이름을 입력하도록 했다.
배포를 하고 Public IP을 통해 접속 테스트까지 진행했다.

data "aws_ami" "ybs-aml2id" {
  most_recent = true
  filter {
    name   = "owner-alias"
    values = ["amazon"]
  }

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-ebs"]
  }

  owners = ["amazon"]
}

locals {
  ec2name = {
    Name = "ybs-ec2"
  }
}


resource "aws_instance" "ybs-ec2" {

  depends_on = [
    aws_internet_gateway.ybs-igw
  ]

  ami                         = data.aws_ami.ybs-aml2id.id
  associate_public_ip_address = true
  instance_type               = "t2.micro"
  vpc_security_group_ids      = ["${aws_security_group.ybs-sg1.id}"]
  subnet_id                   = aws_subnet.ybs-subnet1.id

  user_data = <<-EOF
              #!/bin/bash
              wget https://busybox.net/downloads/binaries/1.31.0-defconfig-multiarch-musl/busybox-x86_64
              mv busybox-x86_64 busybox
              chmod +x busybox
              RZAZ=$(curl http://169.254.169.254/latest/meta-data/placement/availability-zone-id)
              IID=$(curl 169.254.169.254/latest/meta-data/instance-id)
              LIP=$(curl 169.254.169.254/latest/meta-data/local-ipv4)
              echo "<h1>RegionAz($RZAZ) : Instance ID($IID) : Private IP($LIP) : Web Server</h1>" > index.html
              nohup ./busybox httpd -f -p 80 &
              EOF

  user_data_replace_on_change = true

  tags = local.ec2name
}

output "ybs-ec2-publicip" {
  value       = aws_instance.ybs-ec2.public_ip
  description = "The public IP of the Instance"
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
data.aws_ami.ybs-aml2id
aws_instance.ybs-ec2
aws_internet_gateway.ybs-igw
aws_route.ybs-rt1-drt
aws_route_table.ybs-rt1
aws_route_table_association.ybs-rt1-asso1
aws_route_table_association.ybs-rt1-asso2
aws_security_group.ybs-sg1
aws_security_group_rule.ybs-in-http-rule
aws_security_group_rule.ybs-out-any-rule
aws_subnet.ybs-subnet1
aws_subnet.ybs-subnet2
aws_vpc.ybs-vpc

# 데이터소스 값 확인
terraform console
> data.aws_ami.ybs-aml2id
{
  "architecture" = "x86_64"
  "arn" = "arn:aws:ec2:ap-northeast-2::image/ami-05749a4578e04e5ac"
  "block_device_mappings" = toset([
...
}

# 출력된 EC2 퍼블릭IP로 cul 접속 확인
terraform output -raw ybs-ec2-publicip
MYIP=$(terraform output -raw ybs-ec2-publicip)
while true; do curl --connect-timeout 1  http://$MYIP/ ; echo "------------------------------"; date; sleep 1; done
2023년 9월  8일 금요일 17시 48분 27초 KST
<h1>RegionAz(apne2-az1) : Instance ID(i-06633af887ed4a47a) : Private IP(10.10.1.84) : Web Server</h1>
------------------------------
2023년 9월  8일 금요일 17시 48분 28초 KST
<h1>RegionAz(apne2-az1) : Instance ID(i-06633af887ed4a47a) : Private IP(10.10.1.84) : Web Server</h1>
------------------------------

접속이 잘 되는 것과 내가 설정한 AZ를 포함해서 Private IP까지 호출되는 화면을 확인할 수 있다.

4. local 지역 값

Terraform에서 local 지역 값은 구성 내에서 변수처럼 사용 되지만, 입력이나 출력으로 사용되지 않는 중간 계산 결과를 저장할 수 있다. Terraform 코드 내에서 재사용될 수 있는 값들을 저장하는데 사용 된다고 보면 된다. locals 블록 내에서 하나 이상의 로컬 값을 정의하여 사용할 수 있다.

local값 정의와 사용은 아래와 같이 쓸 수 있다.
아래와 같은 값을 사용하면 간단하게 resource name을 설정할 수 있다.

locals {
  calculated_value = var.some_input * 10
  name_prefix      = "dev-"
  full_name        = "${local.name_prefix}${var.resource_name}"
}

resource "aws_instance" "example" {
  tags = {
    Name = local.full_name
  }
}

간단한 실습 예제를 통해 테스트를 진행해봤다. iamuser의 이름을 locals 블록에 넣고 실행한 내용이다.

provider "aws" {
  region = "ap-northeast-2"
}

locals {
  name = "mytest"
  team = {
    group = "dev"
  }
}

resource "aws_iam_user" "myiamuser1" {
  name = "${local.name}1"
  tags = local.team
}

resource "aws_iam_user" "myiamuser2" {
  name = "${local.name}2"
  tags = local.team
}

#
terraform init && terraform apply -auto-approve
terraform state list
aws_iam_user.myiamuser1
aws_iam_user.myiamuser2
terraform state show aws_iam_user.myiamuser1
resource "aws_iam_user" "myiamuser1" {
    arn           = "arn:aws:iam::xxxxxxxxxx:user/mytest1"
    force_destroy = false
    id            = "mytest1"
    name          = "mytest1"
....
}

5. 반복문

Terraform에서 반복문은 크게 4가지 정도가 있다. count, for, for_each, dynamic이다.

Count
count는 리소스 또는 모듈 블록에서 사용되며, 동일한 형태의 리소스를 여러 개 생성할 수 있다. count.index를 통해 현재 반복의 인덱스에 접근할 수 있다.
아래 예제를 실행시키면 3개의 AWS EC2를 생성하며 각 EC2의 이름에 index 번호를 붙일 수 있다.

resource "aws_instance" "example" {
  count = 3

  ami           = "ami-xxxxxxxxxxxxxx"
  instance_type = "t2.micro"

  tags = {
    Name = "example-instance-${count.index}"
  }
}

위의 예와 같이 count를 넣은 숫자 만큼 반복하며 그 count 숫자의 index 값을 tags 등에 활용할 수 있는 것을 볼 수 있다.

For_each
for_each는 count와 비슷하지만, 목록 또는 맵에 대해 반복을 실행한다. for_each를 사용하면 생성된 리소스에 더 의미 있는 이름을 부여할 수 있고, 특정 리소스에만 변경을 적용할 수 있다.
아래 코드를 실행시키면 로컬의 user_map을 기반으로 AWS IAM 사용자를 생성한다. each.key는 alice, bob 등의 사용자 이름을, each.value는 admin, developer 등의 역할을 나타낸다. 이렇게 for_each을 사용해서 tag을 입력하는 용도로 사용할 수 있다.

locals {
  user_map = {
    alice = "admin"
    bob   = "developer"
  }
}

resource "aws_iam_user" "example" {
  for_each = local.user_map

  name = each.key
  tags = {
    Role = each.value
  }
}

For 표현식
for 표현식은 목록, 맵, 집합 등에 대한 반복을 수행할 수 있다. 이는 리소스 블록 외부에서 리스트나 맵을 생성할 때 주로 사용된다.
아래 코드는 숫자 목록 [1, 2, 3, 4]에 대해 제곱을 구하고, 이를 맵 형태로 저장한다. 출력 결과는 {“1” = 1, “2” = 4, “3” = 9, “4” = 16}과 같이 출력 된다.

locals {
  numbers    = [1, 2, 3, 4]
  square_map = { for num in local.numbers : tostring(num) => num * num }
}

output "square_map" {
  value = local.square_map
}

dynamic
dynamic 블록은 복잡한 객체의 반복을 처리하기 위해 사용된다. 대부분의 경우, 리소스의 중첩된 블록(예: AWS 보안 그룹 규칙, VPC 서브넷 등)을 동적으로 생성할 때 유용하게 쓰인다. dynamic 블록을 사용하면 코드 중복을 줄이고, 조건부 또는 반복적인 구성을 쉽게 할 수 있습다.
dynamic 블록 내에서 for_each나 count 같은 반복문을 사용하여 여러 개의 중첩된 블록을 생성할 수 있으며, content 블록을 사용하여 실제로 생성될 내용을 정의한다.

아래 코드에서 dynamic “ingress” 블록은 for_each를 통해 여러 인그레스 규칙을 동적으로 생성한다. content 블록 내에서는 실제 규칙에 대한 세부 정보를 지정한다.

resource "aws_security_group" "example" {
  name        = "example"
  description = "Example security group"

  dynamic "ingress" {
    for_each = [
      {
        from_port   = 22
        to_port     = 22
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      },
      {
        from_port   = 80
        to_port     = 80
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      }
    ]
    content {
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

간단하게 반복문에 대해 알아봤고 반복문을 사용한 실습을 진행해보도록 한다.

count부터 실습을 진행해봤다. iam user을 count 입력 받아 반복해서 생성하는 실습이다.
iam user을 생성하고 console 명령어를 통해 user 정보까지 확인해보았다.

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_iam_user" "myiam" {
  count = 3
  name  = "myuser.${count.index}"
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
aws_iam_user.myiam[0]
aws_iam_user.myiam[1]
aws_iam_user.myiam[2]

terraform console
> aws_iam_user.myiam[0]
{
  "arn" = "arn:aws:iam::xxxxxxxxxxxxx:user/myuser.0"
  "force_destroy" = false
  "id" = "myuser.0"
  "name" = "myuser.0"
  "path" = "/"
  "permissions_boundary" = tostring(null)
  "tags" = tomap(null) /* of string */
  "tags_all" = tomap({})
  "unique_id" = "xxxxxxxxxxxxxxxxxxxx"
}
> aws_iam_user.myiam[0].name
"myuser.0"

#삭제
terraform destroy -auto-approve

이번에는 위와 같이 count을 사용하지만 입력변수를 받아 iam user 이름을 정의하도록 한다.
iam user을 count 길이(여기서는 3) 만큼 만들고 각 사용자의 arn을 출력하도록 한다.

variable "user_names" {
  description = "Create IAM users with these names"
  type        = list(string)
  default     = ["gasida", "akbun", "ssoon"]
}

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_iam_user" "myiam" {
  count = length(var.user_names)
  name  = var.user_names[count.index]
}

output "first_arn" {
  value       = aws_iam_user.myiam[0].arn
  description = "The ARN for the first user"
}

output "all_arns" {
  value       = aws_iam_user.myiam[*].arn
  description = "The ARNs for all users"
}

# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
aws_iam_user.myiam[0]
aws_iam_user.myiam[1]
aws_iam_user.myiam[2]

#output 출력
terraform output
terraform output all_arns
all_arns = [
  "arn:aws:iam::xxxxxxxxxxxxx:user/gasida",
  "arn:aws:iam::xxxxxxxxxxxxx:user/akbun",
  "arn:aws:iam::xxxxxxxxxxxxx:user/ssoon",
]
first_arn = "arn:aws:iam::xxxxxxxxxxxxx:user/gasida"
[
  "arn:aws:iam::xxxxxxxxxxxxx:user/gasida",
  "arn:aws:iam::xxxxxxxxxxxxx:user/akbun",
  "arn:aws:iam::xxxxxxxxxxxxx:user/ssoon",
]

이번에는 for_each을 사용해서 iam user 3명을 만들어보도록 하겠다.
count에서 이름을 받아 길이를 통해 만드는 것과 유사하다. 입력 변수에 사용자명 3개를 만들고 for_each에 변수를 호출한다. output에서 사용자 이름을 출력하는 것도 같이 입력해준다.
count와 다르게 state list을 했을 때 [0]~[2]가 아니라 [사용자명]~[사용자명]으로 출력되는 것을 볼 수 있다.

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_iam_user" "myiam" {
  for_each = toset(var.user_names)
  name     = each.value
}

variable "user_names" {
  description = "Create IAM users with these names"
  type        = list(string)
  default     = ["gasida", "akbun", "ssoon"]
}

output "all_users" {
  value = aws_iam_user.myiam
}


# 테라폼 초기화/plan/실행
terraform init && terraform plan && terraform apply -auto-approve

# state list 확인
terraform state list
aws_iam_user.myiam["akbun"]
aws_iam_user.myiam["gasida"]
aws_iam_user.myiam["ssoon"]

#output 출력
terraform output
all_users = {
  "akbun" = {
    "arn" = "arn:aws:iam::xxxxxxxxxxxxx:user/akbun"
    "force_destroy" = false
    "id" = "akbun"
    "name" = "akbun"
    "path" = "/"
....
}

6. 정리

오늘은 프로그래밍 언어를 배우다보면 기초적으로 배우게 되는 변수 활용과 반복문에 대해서 진행해보았다. 테라폼은 비교적 단순한 구조를 갖고 있지만 이런 부분들을 활용하지 못하면 복잡한 구조의 인프라를 배포하기 어려울 거 같다는 생각을 하게 됐다.

Terraform Study #Final Exam

길다고 생각했던 Terraform Study가 드디어 끝이 났다.

혼자 공부를 할 때 어려웠던 부분을 잘 쉽게 설명해주신 gasida님 덕분에 즐거운 스터디 기간이 됐던 것 같다.

오늘은 그 동안 배웠던 내용을 토대로 하나의 프로젝트를 생성해보려고 한다.

요새 AWS 환경에 테스트 중인 AWS Backup을 배포하여 사용해보려고 한다.

실습 단계는 아래와 같이 진행할 예정이다.

1. VPC, Subnet 생성

2. 2대의 EC2 생성 – AMI 백업 목적의 EC2와 EFS을 마운트 할 목적의 EC2 생성(EFS도 같이 생성)

3. RDS 생성

4. Backup vault, Plan 생성 – KMS Key도 같이 생성

5. EFS 마운트 확인

위와 같은 순서로 리소스들을 생성할 예정이다.

대부분의 코드는 기존 챕터 과제와 중간 과제 때 사용했던 코드를 가져와서 이용했다.

전체적인 구조는 리소스를 Modules의 정보를 가져와서 생성하게끔 되어있다.

PRD/DEV는 따로 분류하지 않았다.

1. VPC, Subnet 생성

코드는 아래 열기 버튼을 클릭하면 된다.

VPC와 서브넷을 포함한 네트워크 리소스들은 대부분 이전 프로젝트에서 따와서 작성했다.

서울 Region에 VPC을 생성하고 Zone A, C로 나눈 서브넷을 각각 생성했다.

SG의 경우 SSH을 위한 22, HTTP을 위한 80과 외부 통신을 하기 위해 Egress는 Any로 열어줬다. 

EFS 마운트를 위해 2049도 추가로 열어주었다.

Mysql RDS와 EC2간의 통신을 위한 포트도 추가해야 하는데 이는 아직 DB 데이터가 없어 추후 변경 시에 넣을 예정이다.

locals {
  http_port    = 80
  ssh_port     = 22
  any_port     = 0
  efs_port     = 2049
  any_protocol = "-1"
  tcp_protocol = "tcp"
  my_ip        = ["222.111.4.135/32"]
  all_ips      = ["0.0.0.0/0"]
  all_block    = "0.0.0.0/0"
}

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

  tags = {
    Name = "${var.prj_name}-vpc"
  }
}

resource "aws_subnet" "prj_subnet1" {
  vpc_id     = aws_vpc.prj_vpc.id
  cidr_block = var.subnet1_cidr

  availability_zone = var.az_a

  tags = {
    Name = "${var.prj_name}-subnet1"
  }
}

resource "aws_subnet" "prj_subnet2" {
  vpc_id     = aws_vpc.prj_vpc.id
  cidr_block = var.subnet2_cidr

  availability_zone = var.az_c

  tags = {
    Name = "${var.prj_name}-subnet2"
  }
}

resource "aws_internet_gateway" "prj_igw" {
  vpc_id = aws_vpc.prj_vpc.id

  tags = {
    Name = "${var.prj_name}-igw"
  }
}


resource "aws_route_table" "prj_rt" {
  vpc_id = aws_vpc.prj_vpc.id

  tags = {
    Name = "${var.prj_name}-rt"
  }
}

resource "aws_route_table_association" "prj_asso1" {
  subnet_id      = aws_subnet.prj_subnet1.id
  route_table_id = aws_route_table.prj_rt.id
}

resource "aws_route_table_association" "prj_asso2" {
  subnet_id      = aws_subnet.prj_subnet2.id
  route_table_id = aws_route_table.prj_rt.id
}

resource "aws_route" "prj_dftrt" {
  route_table_id         = aws_route_table.prj_rt.id
  destination_cidr_block = local.all_block
  gateway_id             = aws_internet_gateway.prj_igw.id
}


resource "aws_security_group" "prj_sg" {
  name = "${var.prj_name}-sg"
  vpc_id      = aws_vpc.prj_vpc.id

  ingress {
    from_port   = local.http_port
    to_port     = local.http_port
    protocol    = local.tcp_protocol
    cidr_blocks = local.all_ips
  }

  ingress {
    from_port   = local.ssh_port
    to_port     = local.ssh_port
    protocol    = local.tcp_protocol
    cidr_blocks = local.my_ip
  }
  ingress {
description = "EFS mount target"
from_port   = local.efs_port
to_port     = local.efs_port
protocol    = local.tcp_protocol
cidr_blocks = local.all_ips
  }

  egress {
    from_port   = local.any_port
    to_port     = local.any_port
    protocol    = local.any_protocol
    cidr_blocks = local.all_ips
  }
  tags = {
    Name = "${var.prj_name}-sg"
  }
}

2. 2대의 EC2 생성

AMI 백업만을 진행할 목적의 EC2 1대와 EFS을 백업할 목적의 EC2를 1대 각각 배포한다.

Keypair는 스터디 진행 중인 맥북의 ssh key로 생성할 수 있게 하였다.

앞단에는 ALB을 두어 http 통신이 가능하게끔 했다.

이때 EFS도 같이 생성을 하고 자동 백업은 실행되지 않게끔 해둔 상태로 생성을 진행했다.

EFS 백업 목적의 EC2에는 user data에 EFS을 마운트하는 명령어도 추가하였다.

EFS 마운트는 해당 EC2의 서브넷에서만 가능하게 하였다.

코드는 아래에서 확인 가능하다.

<hide/>
locals {
  http_protocol = "HTTP"
  ec2_file_system_local_mount_path = "/mnt/efs"
}

data "aws_ami" "prj_amazonlinux2" {
  most_recent = true
  filter {
    name   = "owner-alias"
    values = ["amazon"]
  }

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-ebs"]
  }

  owners = ["amazon"]
}

resource "aws_lb" "prj_alb" {
  name               = "${var.prj_name}-alb"
  load_balancer_type = "application"
  subnets            = [aws_subnet.prj_subnet1.id, aws_subnet.prj_subnet2.id]
  security_groups = [aws_security_group.prj_sg.id]

  tags = {
    Name = "${var.prj_name}-alb"
  }
}

resource "aws_lb_listener" "prj_http" {
  load_balancer_arn = aws_lb.prj_alb.arn
  port              = local.http_port
  protocol          = local.http_protocol

  <em># By default, return a simple 404 page</em>
  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found - T101 Study"
      status_code  = 404
    }
  }
}

resource "aws_lb_target_group" "prj_albtg" {
  name = "${var.prj_name}-albtg"
  port     = local.http_port
  protocol = local.http_protocol
  vpc_id   = aws_vpc.prj_vpc.id

  health_check {
    path                = "/"
    protocol            = local.http_protocol
    matcher             = "200-299"
    interval            = 5
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

resource "aws_lb_target_group_attachment" "prj_albrg_atch1" {
  target_group_arn = "${aws_lb_target_group.prj_albtg.arn}"
  target_id        = "${aws_instance.ami_backup_ec2.id}"
  port             = 80
}
resource "aws_lb_target_group_attachment" "prj_albrg_atch2" {
  target_group_arn = "${aws_lb_target_group.prj_albtg.arn}"
  target_id        = "${aws_instance.efs_backup_ec2.id}"
  port             = 80
}

resource "aws_key_pair" "prj_keypair" { 
  key_name = "ec2-kp"
  public_key = file("~/.ssh/id_rsa.pub") 
}


resource "aws_instance" "ami_backup_ec2" {

  depends_on = [
    aws_internet_gateway.prj_igw
    
  ]

  ami                         = data.aws_ami.prj_amazonlinux2.id
  associate_public_ip_address = true
  instance_type               = var.instance_type
  vpc_security_group_ids      = ["${aws_security_group.prj_sg.id}"]
  subnet_id                   = aws_subnet.prj_subnet1.id
key_name = aws_key_pair.prj_keypair.key_name
  user_data = <<-EOF
              <em>#!/bin/bash</em>
              wget https://busybox.net/downloads/binaries/1.31.0-defconfig-multiarch-musl/busybox-x86_64
              mv busybox-x86_64 busybox
              chmod +x busybox
              RZAZ=$(curl http://169.254.169.254/latest/meta-data/placement/availability-zone-id)
              IID=$(curl 169.254.169.254/latest/meta-data/instance-id)
              LIP=$(curl 169.254.169.254/latest/meta-data/local-ipv4)
              echo "<h1>RegionAz($RZAZ) : Instance ID($IID) : Private IP($LIP) : Web Server</h1>" > index.html
              nohup ./busybox httpd -f -p 80 &
              EOF

  user_data_replace_on_change = true

  tags = {
    Name = "${var.prj_name}-amibackup-ec2"
    backup = "enable"
  }
}

resource "aws_instance" "efs_backup_ec2" {

  depends_on = [
    aws_internet_gateway.prj_igw
    
  ]

  ami                         = data.aws_ami.prj_amazonlinux2.id
  associate_public_ip_address = true
  instance_type               = var.instance_type
  vpc_security_group_ids      = ["${aws_security_group.prj_sg.id}"]
  subnet_id                   = aws_subnet.prj_subnet2.id
key_name = aws_key_pair.prj_keypair.key_name

  user_data = <<-EOF
              <em>#!/bin/bash</em>
              wget https://busybox.net/downloads/binaries/1.31.0-defconfig-multiarch-musl/busybox-x86_64
              mv busybox-x86_64 busybox
              chmod +x busybox
              RZAZ=$(curl http://169.254.169.254/latest/meta-data/placement/availability-zone-id)
              IID=$(curl 169.254.169.254/latest/meta-data/instance-id)
              LIP=$(curl 169.254.169.254/latest/meta-data/local-ipv4)
              echo "<h1>RegionAz($RZAZ) : Instance ID($IID) : Private IP($LIP) : Web Server</h1>" > index.html
              nohup ./busybox httpd -f -p 80 &
              mkdir -p ${local.ec2_file_system_local_mount_path}
              yum install -y amazon-efs-utils
              mount -t efs -o iam,tls ${aws_efs_file_system.prj_efs.id} ${local.ec2_file_system_local_mount_path}
              echo "${aws_efs_file_system.prj_efs.id} ${local.ec2_file_system_local_mount_path} efs _netdev,tls,iam 0 0" >> /etc/fstab
              <em># Creating demo content for other services</em>
              mkdir -p ${local.ec2_file_system_local_mount_path}/fargate
              mkdir -p ${local.ec2_file_system_local_mount_path}/lambda
              df -h > ${local.ec2_file_system_local_mount_path}/fargate/demo.txt
              df -h > ${local.ec2_file_system_local_mount_path}/lambda/demo.txt
              chown ec2-user:ec2-user -R ${local.ec2_file_system_local_mount_path}
              EOF

  user_data_replace_on_change = true

  tags = {
    Name = "${var.prj_name}-amibackup-ec2"
    <em>#backup = "enable"</em>
  }
}

resource "aws_lb_listener_rule" "prj_albrule" {
  listener_arn = aws_lb_listener.prj_http.arn
  priority     = 100

  condition {
    path_pattern {
      values = ["*"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.prj_albtg.arn
  }
}

resource "aws_efs_file_system" "prj_efs" {
  encrypted = false
  tags = {
    Name = "${var.prj_name}-efs"
    backup = "enable"
  }
}

resource "aws_efs_backup_policy" "prj_efs_backup_policy" {
  file_system_id = aws_efs_file_system.prj_efs.id

  backup_policy {
    status = "DISABLED"
  }
}

resource "aws_efs_mount_target" "prj_efs_backup_mt" {
  file_system_id = aws_efs_file_system.prj_efs.id
  subnet_id      = aws_subnet.prj_subnet2.id
  security_groups = ["${aws_security_group.prj_sg.id}"]
}

3. RDS 생성

RDS는 별다른 설정 없이 우선 기본값으로 생성하였다.

추후 html 코드 등을 수정하여 DB값을 불러올 때 수정할 예정이다.

<hide/>

resource "aws_db_instance" "prj_rds" {
  <em>#name = "${var.prj_name}-prjdb"</em>
  db_name           = "tfstudyfprjdb"
  allocated_storage = 8
  engine = "mysql"
  engine_version = "5.7"
  instance_class = "db.t2.micro"
  username = ""
  password = ""
  db_subnet_group_name = aws_db_subnet_group.prj_dbsubnetgr.name
}

resource "aws_db_subnet_group" "prj_dbsubnetgr" {
    Name = "${var.prj_name}-dbsubnetgr"
  subnet_ids = [aws_subnet.prj_subnet1.id, aws_subnet.prj_subnet2.id]
}

4. Backup 리소스 생성

Backup Vault을 생성하기 전에 KMS Key을 생성하였다.

이후 Vault을 생성하고 Plan과 리소스 종류를 결정 지어줬다.

백업 선택은 Tag에 backup:enable이 입력 된 리소스들에 한해 백업을 진행할 수 있도록 하였다.

백업 스케쥴은 2시간 단위로 백업이 진행 될 수 있도록 Cron 표현식을 사용하였다.


resource "aws_kms_key" "prj_kmskey" {
  description             = "KMS key for backup vault"
}

resource "aws_backup_vault" "prj_kmskey" {
  name = "${var.prj_name}-backupvault"
  kms_key_arn = aws_kms_key.prj_kmskey.arn
}

resource "aws_backup_selection" "prj_backup_sel" {
  iam_role_arn = "arn:aws:iam::536033748497:role/aws-service-role/backup.amazonaws.com/AWSServiceRoleForBackup"
  name = "${var.prj_name}-backupsel"
  plan_id      = aws_backup_plan.prj_backup_plan.id

  selection_tag {
    type  = "STRINGEQUALS"
    key   = "backup"
    value = "enalbe"
  }

}

resource "aws_backup_plan" "prj_backup_plan" {
  name = "${var.prj_name}-backupplan"

  rule {
    rule_name         = "${var.prj_name}-backuprule-ami"
    target_vault_name = aws_backup_vault.prj_kmskey.name
    schedule          = "cron(0 0/2 1/1 * ? *)"
    
    lifecycle {
      delete_after = 2
    }
  }

    rule {
    rule_name         = "${var.prj_name}-backuprule-efs"
    target_vault_name = aws_backup_vault.prj_kmskey.name
    schedule          = "cron(0 0/2 1/1 * ? *)"
    
    lifecycle {
      delete_after = 2
    }
  }
  rule {
    rule_name         = "${var.prj_name}-backuprule-rds"
    target_vault_name = aws_backup_vault.prj_kmskey.name
    schedule          = "cron(0 0/2 1/1 * ? *)"

    lifecycle {
      delete_after = 2
    }
  }

  advanced_backup_setting {
    backup_options = {
      WindowsVSS = "enabled"
    }
    resource_type = "EC2"
  }
}

5. EFS 마운트 확인

배포가 전부 완료 되면 EC2에 접근하여 EFS Mount가 잘 됐는지 확인한다.

마운트는 물론 폴더 생성하는 스크립트까지 잘 작동한 것을 확인할 수 있다.

6. 회고

RDS을 활용하지 못해 아쉬웠다.

백업의 경우에도 시간이 부족해 백업이 잘 작동하고 이걸 바탕으로 복원하는 테스트까진 진행해보지 못했다.

콘솔 상에선 작동해봤지만 코드로 진행해보지 못해서 아쉽다.

테라폼을 쓰다보면 리소스 관리가 수월하다는 것을 알 수 있다. 하지만 단점도 분명 존재하는 것 같다.

예를 들면, 콘솔에서 UI을 바탕으로 생성할 때는 손 쉽게 구조가 보이지만 테라폼으로 하려면 코드 몇 줄만으로는 생성이 어렵다는 것을 알 수 있게 된다.

물론 테라폼 Docs에 어느정도 기술이 되어있지만 이것만 보고는 진행하기가 어려울 것으로 생각 된다.

plan과 apply을 계속 실행해보며 오류를 찾아서 해결해나가는 과정이 필요하지 않을까 싶다.

그런 과정 속에서 RDS나 ASG같이 삭제되고 재생성되는 리소스들의 시간이 오래 걸리는 부분도 단점이 아닐까 싶다.

그럼에도 불구하고 편리하게 리소스를 배포하고 관리할 수 있다는 점에서는 분명 좋은 툴이라고 생각이 든다.

앞으로 더 다양한 리소스를 테라폼을 통해 배포하는 과정을 진행해봐야겠다.

Terraform Study #1

4주 동안 Terraform Study에 참여하였고 이번 주에 중간 과제를 제출해야 한다.

어떤 주제로 진행을 할까 고민하였는데 지금까지 배웠던 내용들을 간략하게 정리하면서 가장 최근에 배운 기능인 모듈을 활용해서 각기 다른 환경에 리소들을 배포한 뒤 테스트하는 내용으로 결정했다.

실습 환경

랩탑 M1 Macbook Pro 10core 32GB RAM, macOS 12.6.1

VSCode, iTerm2, AWS

실습 내용

모듈을 사용하여 환경을 나누어(PRD, DEV) 리소스 배포 후 curl 테스트

Mac OS에서 Terraform을 설치할 때는 단순한 설치보다는 tfenv을 설치해서 버저닝 기능을 사용하면 좋다.

최신 버전인 1.3.4를 설치하여 사용했다.

– IAM User을 생성하여 권한 부여
IAM User로 생성한 계정의 Access 정보를 변수로 입력해놓으면 터미널을 사용할 때 편리하다.
export AWS_ACCESS_KEY_ID=””
export AWS_SECRET_ACCESS_KEY=””

export AWS_DEFAULT_REGION=ap-northeast-2

– 테라폼을 간단하게 구성할 경우 variables.tf, output.tf, main.tf 등을 기본으로 해서 간단하게 배포가 가능하다.

–  기본적인 명령어는 3가지만 우선 알면 된다.

terraform init && terraform plan && terraform apply -auto-approve

-auto-approve는 별다른 확인 절차 없이 바로 배포를 진행하겠다는 의미로 운영계에서는 가급적 삼가는 게 좋을 것 같다.

– variables.tf 을 활용할 때는 아래와 같이 사용한다.

– ubuntu 최신 버전을 사용하려면 아래와 같이 data 소스를 생성

data "aws_ami" "ubuntu" {
    most_recent = true

    filter {
        name   = "name"
        values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
    }

    filter {
        name   = "virtualization-type"
        values = ["hvm"]
    }

    owners = ["099720109477"] # Canonical
}

– output 사용해서 생성 된 EC2의 Public IP 등을 확인할 수 있다.

output "public_ip" {
  value       = aws_instance.example.public_ip
  description = "The public IP of the Instance"
}

– userdata을 통해 배포되는 인스턴스에 웹서비스 설치 및 간단한 html을 배포하여 사용할 수 있다.

스터디에서 사용한 간단한 예제는 다음과 같다.

  user_data = <<-EOF
              #!/bin/bash
              wget https://busybox.net/downloads/binaries/1.31.0-defconfig-multiarch-musl/busybox-x86_64
              mv busybox-x86_64 busybox
              chmod +x busybox
              RZAZ=$(curl http://169.254.169.254/latest/meta-data/placement/availability-zone-id)
              IID=$(curl 169.254.169.254/latest/meta-data/instance-id)
              LIP=$(curl 169.254.169.254/latest/meta-data/local-ipv4)
              echo "<h1>RegionAz($RZAZ) : Instance ID($IID) : Private IP($LIP) : Web Server</h1>" > index.html
              nohup ./busybox httpd -f -p 80 &
              EOF

– 아직 상태 관리에 대한 부분은 이해를 다 하지 못해서 이 부분은 천천히 따로 복습해봐야 할 것 같다.

resource "aws_s3_bucket" "prj_s3bucket" {
  bucket = "${var.prj_name}-tfstate"
}

# Enable versioning so you can see the full revision history of your state files
resource "aws_s3_bucket_versioning" "prj_s3bucket_versioning" {
  bucket = aws_s3_bucket.prj_s3bucket.id
  versioning_configuration {
    status = "Enabled"
  }
}

output "s3_bucket_arn" {
  value       = aws_s3_bucket.prj_s3bucket.arn
  description = "The ARN of the S3 bucket"
}

resource "aws_dynamodb_table" "prj_dynamodbtable" {
  name         = "tfme--locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

output "dynamodb_table_name" {
  value       = aws_dynamodb_table.prj_dynamodbtable.name
  description = "The name of the DynamoDB table"
}

– 이번 과제의 구조는 아래와 같이 생성했다.

조금 아쉬운 점은 Modules의 경우 compute와 network로 폴더 구조화해서 하위에 ec2.tf, sg,tf 등 tf을 리소스 종류 별로 구분하여 생성하고 싶었는데 그렇게 진행하지 못한 점이다. 그렇게 진행하지 못한 이유는 Compute 쪽에서 Network 쪽의 리소스 정보를 받아와야 하는데 그러지 못함에 있었다. 아마 별도로 호출하는 방안이 있을 것 같은데 다음에 시도해볼 예정이다.

– 구조는 최대한 단순하게 작성했다.

ASG에 웹서버만 설치한 인스턴스를 배포하였다. DB을 연결하여 특정 데이터를 호출하는 페이지를 생성하고 싶었지만 Module을 적용하는 과정에서 시간이 많이 소요되어 해당 기능들을 적용하지는 못했다. 추후 해당 기능은 추가할 예정이다.

– ALB을 사용하였기 때문에 CURL 테스트 때 적용할 DNS 값을 호출하기 위해 output.tf에 alb dns을 호출할 수 있도록 했다.

– 리소스 이름을 관리하기 위해 prd, dev 환경에 따라 prj_name을 입력 받고 Module에서 “prj_name-리소스명” 방식으로 이름을 입력하게끔 하였다. 이렇게 하면 프로젝트 이름과 Env 값을 받아 tfstudy-prd가 기본값이 되고 그 뒤에 리소스 명(ex:subnet1)이 결합하여 tfstudy-prd-subnet1이라는 서브넷이 생성되게 된다.

  resource~~{
  tags = {
    Name = "${var.prj_name}-subnet1"
  }
  }
  
  module "midexam" {
  source = "../Modules"

  prj_name           = "tfstudy-prd"
  }

– 환경에 따라 max, min 인스턴스 숫자와 인스턴스 크기의 차이점을 두고 vpc, subnet의 cidr 값을 설정했다.

개발계와 운영계는 IP을 다르게 설정하였다. 추후 확장했을 때 VPN을 연결하여 IDC와 연동한다고 한다면 중복되는 IP로 인해 충돌이 날 수 있기 때문에 이 부분을 고려하여 환경 간 IP  차이를 두었다.

#Dev 환경
module "midexam" {
  source = "../Modules"

  prj_name           = "tfstudy-dev"
  bucket_name = "tfstudy-dev-tfstate"
  instance_type = "t2.micro"
  min_size      = 2
  max_size      = 3
  vpc_cidr  = "192.168.0.0/16"
  subnet1_cidr  = "192.168.0.0/24"
  subnet2_cidr  = "192.168.1.0/24"
}

#Prd 환경
module "midexam" {
  source = "../Modules"

  prj_name           = "tfstudy-prd"
  bucket_name = "tfstudy-prd-tfstate"
  instance_type = "m4.large"
  min_size      = 2
  max_size      = 10
  vpc_cidr  = "172.16.0.0/16"
  subnet1_cidr  = "172.16.0.0/24"
  subnet2_cidr  = "172.16.1.0/24"
}

– 처음 dev 환경에서 배포를 시작했을 때 여러 오류가 발생했었다. 대표적으로는 valiables.tf에서 값을 불러오는 것에서 충돌이 많았는데 중복되는 설정값 등이 있어서 이 부분을 정리했다. 대부분의 변수값은 Modules에서 받아올 수 있도록 했다.

– Modules 안에서만 불러오고 밖에서 참조할 필요가 없는 값의 경우 locals 을 통해 Modules 내부에서 처리하였다.

대표적으로, Security Group 생성 시에 사용하는 port, cidr, protocol에 활용하였다. 추후 locals에 넣을 수 있는 변수들이 어떤 게 있을지 조금 더 고민해봐야 할 것 같다.

locals {
  http_port    = 80
  any_port     = 0
  any_protocol = "-1"
  tcp_protocol = "tcp"
  all_ips      = ["0.0.0.0/0"]
  all_block    = "0.0.0.0/0"
}

– 배포 후 리소스 목록은 아래와 같다.

– 배포를 완료하고 curl 테스트를 완료한 화면은 아래와 같다.

위 화면이 운영계에서의 테스트고 아래가 개발계에서의 테스트이다.

둘 다 정상적으로 ALB을 통해 각 인스턴스로 트래픽이 분배되는 것을 확인할 수 있었다.

모듈을 통한 배포를 진행했기 때문에 각 환경에서는 환경에 맞는 변수와 아이템을 추가해주기만 하면 되어서 여러 번을 거친 작업을 하지 않아도 되는 부분은 충분히 장점으로 인식 됐다.

아쉬운 점은, 폴더를 도식화했을 때 그 각 폴더를 참조하기 위해 source 경로를 입력해주는 부분이 조금 불편했다. 이 부분을 쉽게 읽어올 수 있는 방법이 있으면 좋을 것 같다. (이미 있을 수도 있는데 내가 못 찾은 건지도..)

위에 진행을 하는 과정에서도 적었던 아쉬운 점이지만, 아직 테라폼에 대한 이해도가 부족해 Module을 제대로 사용하지 못한 점과 여러 리소스를 활용하지 못한 점이 아쉽다.

이 과제는 지우지 않고 계속 보완해가면서 활용할 수 있으면 좋을 것 같다.

여기까지 직접 테라폼으로 만들어보니 앞으로 업무에 적용하거나 개인 프로젝트를 할 때도 활용할 수 있을 것 같다는 생각이 든 게 가장 큰 수확이라고 생각한다.

남은 스터디 기간 동안 더 열심히 해서 조금 더 나은 결과물을 만들어야겠다.