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. 정리

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