[Terraform@CloudNet] Terraform 기본 사용 (3)

2024. 6. 30. 08:44카테고리 없음

- CloudNet에서 주관하는 Terraform 스터디 내용입니다

- 내용은 위 책 테라폼으로 시작하는 IaC 를 기준으로 정리하였습니다.

- 실습은 M1 macbook air 에서 진행했습니다.

- 매번 좋은 스터디를 진행해 주시는 CloudNet 팀 감사드립니다

- 잘못된 점, 업데이트된 지식, 다른 코멘트 언제나 환영입니다!

 

 

목차

반복문(1): for_each

반복문(2): for_expression

반복문(3): dynamic expression

조건문

프로비저너

null_resource & terraform_data

 

 

 

반복문(1): for_each

반복시 접근하는 각각 값에 대해 object로 접근한다. 각 object는 key, value의 속성으로 구성된다.

key-value 에서 떠오르듯 map이나 set type 만으로 접근할 수 있다.

 

만약 다른 타입인데 for_each 문법을 사용하고 싶다면? tomap() 이나 toset() 함수를 이용해서 변환 가능하다.

 

 

잠깐 map, set, list 를 짚고 넘어가자.

list: 인덱스 기반 집합

set: 값 기반 집합

map: 값=속성 기반 집합

# list
variable "list_sample" {
	type = list(string)
    default = ['Anxiety', 'Joy', 'Sadness', 'Anger']
}

# set
variable "set_sample" {
	type = set(string)
    default = ['Anger', 'Anxiety', 'Joy', 'Sadness']
}

# map
variable "map_sample" {
	type = map(string)
    default = {
    	'Anger': 'Red',
        'Anxiety': 'Orange',
        'Joy': 'Yellow',
        'Sadness': 'Blue'
	}
}

 

-  list & set:  array 형태임이 유사하지만 list는 index 를 참조, Map은 Key로 참조

- map & set: 선언한 값이 정렬된다

 

Map과 set은 Key 로 순회하므로 중간에 리소스 추가/삭제로 인덱스에 변화가 생겨도 안전하게 실행 가능함이 count와 비교했을 때 장점이다. 가능한 for_each 를 쓰는 게 좋을 것 같다.

 

 

 

아래 예시는 username 이 들어 있는 list 를 Set으로 변환한 뒤 for_each 로 순회하는 코드이다. 그러나 list 를 set으로 변환하였기 때문에 key값만 있고 value값은 없다는 점에 주의해야 한다.

resource "aws_iam_user" "the-accounts" {
  for_each = toset(["Todd", "James", "Alice", "Dottie"])
  name     = each.key
}

 

 

 

for_each를 사용해서 리소스를 생성하는 예제를 실행해보자.

 

아래 main.tf 를 저장하고

resource "local_file" "abc" {
  for_each = {
    a = "content a"
    b = "content b"
  }
  content  = each.value
  filename = "${path.module}/${each.key}.txt"
}

 

terraform을 실행하면

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

이렇게 map의 Key를 제목으로, value를 내용으로 하는 .txt 파일이 모두 생성되었다.

 

 

 

아래 다양한 예시들이 있으니 필요할 때 참고하면 좋을 것 같다

variable "string_a" {
  default     = "myString"
}

variable "string_b" {
  type        = string
  default     = "myString"
}

# variable "string_c" {
#   type        = string
#   default     = myString
# }


variable "number_a" {
  default = 123
}

variable "number_b" {
  type    = number
  default = 123
}

variable "number_c" {
  default = "123"
}


variable "boolean" {
  default = true
}


# (Array) list , set , tuple - value , [ ] 사용
variable "list_set_tuple_a" {
  default = ["aaa", "bbb", "ccc"]
}

variable "list_set_tuple_b" {
  type    = list(string)
  default = ["bbb", "ccc", "aaa"]
}

variable "list_set_tuple_c" {
  type    = set(string)
  default = ["bbb", "ccc", "aaa"]
}

variable "list_set_tuple_d" {
  default = ["aaa", 1, false]
}

variable "list_set_tuple_e" {
  type    = tuple([string, number, bool])
  default = ["aaa", 1, false]
}


# (Object) map , object - key : value , { } 사용
variable "map_object_a" {
  default = {"a" : "aaa", "b" : "bbb" , "c" : "ccc"}
}

variable "map_object_b" {
  type    = map(string)
  default = {"b" : "bbb" , "c" : "ccc", "a" : "aaa"}
}

variable "map_object_c" {
  default = {"name" : "gasida", "age" : 27 }
}

variable "map_object_d" {
  type = object({ name = string, age = number })
  default = {"name" : "gasida", "age" : 27 }
}

 

 

 

반복문(2): for_expressions

for_expression 는 복합 형식을 변환하는 데 사용한다 (for_each: 반복 조회). 기존에 코드에서 쓰던 for문과 유사하다.

파이썬 유저의 관점에서는 list comprehension과 비슷하게 느껴졌다.

 

위의 for_each 에서와 유사하게 a,b,c를 이용해서 for_expression 을 실습해 보자.

우선 jsonencode 라는 함수를 사용해서 반복 결과를 json 으로 변환해 저장할 것이다.

variable "names" {
  default = ["a", "b", "c"]
}

resource "local_file" "abc" {
  content  = jsonencode(var.names) # 결과 : ["a", "b", "c"]
  filename = "${path.module}/abc.txt"
}

output "file_content" {
  value = local_file.abc.content
}

 

terraform apply 로 반영하면 ["a", "b", "c"] 가 담긴 리스트가 json으로 변환되어 아래와 같이 저장된다.

 

그러면 이번에는 같은 list 의 값을 for expression으로 일괄적으로 대문자로 바꿔보자.

upper() 함수를 사용한다.

variable "names" {
  default = ["a", "b", "c"]
}

resource "local_file" "abc" {
  content  = jsonencode([for s in var.names : upper(s)]) # 결과 : ["A", "B", "C"]
  filename = "${path.module}/abc.txt"
}

output "file_content" {
  value = local_file.abc.content
}

 

아래와 같이 모두 대문자로 바뀐 것을 볼 수 있다.

 

 

 

이번에는 key-value 값이 같이 묶이는 map type 을 for expression 으로 변환해보자. 예시로 아래 variable 을 사용할 것이다.

variable "members" {
  type = map(object({
    role = string
  }))
  default = {
    ab = { role = "member", group = "dev" }
    cd = { role = "admin", group = "dev" }
    ef = { role = "member", group = "ops" }
  }
}

 

 

우선 가장 기본적으로 map 을 순회한 결과를 tuple(list) 형태로 반환하는 예시를 보자.

value 를 [ ] 로 감쌌기 때문에 tuple 형태로 반환된다. 

output "A_to_tupple" {
  value = [for k, v in var.members : "${k} is ${v.role}"]
}

 

for loop으로 항목을 순회하며 key와 role 을 동시에 사용한 tuple 을 구성했다.

 

이번에는 map -> map 형태로 변환해보자.

map 형태로 묶을 때는 => 을 사용한다.

 

아래 코드는 user의 role이 admin 일 때 name과 role을 묶어 새로운 map 을 반환한다.

if 를 맨 위가 아닌 맨 아래에 쓰는 점이 특이하다.

output "B_get_only_role" {
  value = {
    for name, user in var.members : name => user.role
    if user.role == "admin"
  }
}

 

위에서 'cd'  만 admin 이므로 cd 의 role 이 출력됨을 확인한다.

 

set, map, list 의 비교를 악분 님의 실습으로 다시 한번 연습해보자.

 

사용할 variable 들이다. main.tf 에 저장한다.

variable "fruits_set" {
  type        = set(string)
  default     = ["apple", "banana"]
  description = "fruit example"
}

variable "fruits_list" {
  type        = list(string)
  default     = ["apple", "banana"]
  description = "fruit example"
}

variable "fruits_map" {
  type        = map(string)
  default     = {"first": "apple", "second": "banana"}
  description = "fruit example"
}

 

Terraform console 을 실행하고 main.tf 에 저장한 variable 들을 찍어보면 사용 가능하다.

var.fruits_set
var.fruits_list
var.fruits_map

type(var.fruits_set)
type(var.fruits_list)
type(var.fruits_map)

 

set 을 아무것도 감싸지 않고 for loop 으로 순회하면 에러가 발생한다.

[ ] 로 감싸서 for 로 순회하면 tuple(list) 형태로 잘 반환된다.

 

for item in var.fruits_set: item
[for item in var.fruits_set: item]
type([for item in var.fruits_set: item])

set 을 map 형태로 바꿔보자. 이때 key 없이는 for loop 이 돌지 않으니 주의한다.

{for item in var.fruits_set: item}
{for key,value in var.fruits_set: key => value}
type({for key,value in var.fruits_set: key => value})

 

 

 

이번에는 list 를 순회해보자

마찬가지로 [ ] 로 감싸지지 않은 tuple & key 없이 도는 map 에서는 오류가 발생한다.

[for item in var.fruits_list: item]
type([for item in var.fruits_list: item])

{for key,value in var.fruits_list: key => value}
{for i, v in var.fruits_list: i => v}
type({for i, v in var.fruits_list: i => v})

마지막으로 map 도 순회해본다.

주의점은 위 set, list 와 같다

[for item in var.fruits_map: item]
type([for item in var.fruits_map: item])

{for key,value in var.fruits_map: key => value}
{for k, v in var.fruits_map: k => v}
type({for k, v in var.fruits_map: k => v})

 

 

 

반복문(3): dynamic

dynamic 은 리소스 내부 속성 블록을 동적으로 생성한다. 종속성 있는 다중 for문이랑 비슷한 개념 같다.

aws 에서의 대표적인 사용사례는 security group 리소스에서 Ingress/Egress 를 선언해놓고 security group 별로 ingress/egress 를 계속 가져다 사용할 때이다.

 

일반적으로 반복한다면 아래와 같이 써야겠지만

data "archive_file" "dotfiles" {
  type        = "zip"
  output_path = "${path.module}/dotfiles.zip"

  source {
    content  = "hello a"
    filename = "${path.module}/a.txt"
  }

  source {
    content  = "hello b"
    filename = "${path.module}/b.txt"
  }

  source {
    content  = "hello c"
    filename = "${path.module}/c.txt"
  }
}

 

 

dynamic block 을 이용하면 훨씬 가독성 좋게 사용 가능하다.

main.tf 파일 수정 : 리소스 내에 반복 선언 구성을 dynamic 블록으로 재구성
variable "names" {
  default = {
    a = "hello a"
    b = "hello b"
    c = "hello c"
  }
}

data "archive_file" "dotfiles" {
  type        = "zip"
  output_path = "${path.module}/dotfiles.zip"

  dynamic "source" {
    for_each = var.names
    content {
      content  = source.value
      filename = "${path.module}/${source.key}.txt"
    }
  }
}

 

 

 

 

조건문

조건문은 {조건 정의} ? {옳은 경우} : {틀린 경우} 의 삼항 연산자 형태로 사용된다.

삼항연산자를 처음 보는 분을 위해 예시를 들면 아래와 같다.

{알람울림}?{일어나야됨}:{더자도됨}

 

개인적으로 옳은경우-틀린경우 순서가 헷갈렸는데, 위 예시로 외우고는 안 헷갈린다...

 

 

 

테라폼에서 조건식의 응용 예시를 들어보았다.

- 특정 속성 정의

- 로컬 변수 재정의

- 출력 값 조건 정의

- 리소스 생성 여부

 

 

 

테라폼에서 조건식 사용시 조건끼리는 형태를 맞춰줄 것을 권장하고 있다.

var.example ? 12 : "hello"            # 비권장
var.example ? tostring(12) : "hello"  # 권장

 

 

실습을 위해 조건식을 포함한 테라폼 파일을 작성한다. enable_file 이 False이면 "", True 이면 "foo" 를 출력한다.

variable "enable_file" {
  default = true
}

resource "local_file" "foo" {
  count    = var.enable_file ? 1 : 0
  content  = "foo!"
  filename = "${path.module}/foo.bar"
}

output "content" {
  value = var.enable_file ? local_file.foo[0].content : ""
}

 

terraform console에서 변수를 내보내고 (코드보다 우선순위가 더 높다) 실행하면 false 이므로 아무것도 메시지가 나오지 않는다.

export TF_VAR_enable_file=false
terraform apply -auto-approve

 

 

내보낸 변수를 삭제하면 default 대로 True 를 반환하므로 "foo!" 가 출력된다. 

unset TF_VAR_enable_file
terraform apply -auto-approve

 

 

 

 

 

프로비저너

프로바이더는 command와 파일 복사 등을 실행할 수 없기 때문에, 이런 나머지 역할들을 프로비저너가 수행한다.

The Last Resort: 꼭 필요한 상황에서만 사용하세요

사용 자제를 요청하는 이유는, 프로비저너의 실행결과는 .tfstate 에 저장되지 않기 때문에 선언적 보장이 불가하기 때문이다.

 

프로비저너를 사용하는 상황의 예시로는 EC2 생성 이후 패키지 설치, 파일생성 등이 있다.

 

 

프로비저너 사용을 실습하기 위해 local에서 command를 실행하고 파일을 저장/삭제 하는 실습을 해보자.

variable "sensitive_content" {
  default   = "secret"
  #sensitive = true
}

resource "local_file" "foo" {
  content  = upper(var.sensitive_content)
  filename = "${path.module}/foo.bar"

  #### 프로비저너 사용 ####
  provisioner "local-exec" {
    command = "echo The content is ${self.content}"
  }

  provisioner "local-exec" {
    command    = "abc"
    on_failure = continue
  }

  provisioner "local-exec" {
    when    = destroy
    command = "echo The deleting filename is ${self.filename}"
  }
  #### 프로비저너 사용 ####
}

 

 

위 코드를 apply 하면 foo.bar 라는 파일이 생성된다. 그리고 "abc" 라는 command가 없기 때문에 실행이 안 되고 넘어간다

만약 command="abc" 가 있는 프로비저너에 on_failure = continue 설정이 없다면 거기서 종료되고 뒤로 넘어가지 않는다

sensitive=True 의 주석을 풀어서 활성화시키면 해당 내용은 콘솔에 출력되지 않는다.

 

local-exec 프로비저너를 간단히 설명하면, 테라폼이 실행되는 환경에서 수행할 커맨드를 정의하는 것이다.

command 의 사용은 필수이고 working_dir (경로설정), interpreter, environment(환경변수) 는 필요에 따라 선택적으로 사용한다.

 

간단하게 명령어로 /tmp 경로의 file.txt 에 Hello!! World!! 를 쓰는 예제를 실행해보았다. 

resource "null_resource" "example1" {
  
  provisioner "local-exec" {
    command = <<EOF
      echo Hello!! > file.txt
      echo $ENV >> file.txt
      EOF
    
    interpreter = [ "bash" , "-c" ]

    working_dir = "/tmp"

    environment = {
      ENV = "world!!"
    }

  }
}

 

 

provisioner 을 이용해서 원격지에 연결도 가능하다. 리소스 내 connection block 선언시 해당 리소스 안에 모두 적용되지만 별도로 프로비저너 안에 connection 이 선언되어 있으면 그게 더 우선순위가 높게 적용된다.

resource "null_resource" "example1" {
  
  connection {
    type     = "ssh"
    user     = "root"
    password = var.root_password
    host     = var.host
  }

  provisioner "file" { #기본 connection 사용
    source      = "conf/myapp.conf"
    destination = "/etc/myapp.conf"
  }

  provisioner "file" { #별도 connection 사용
    source      = "conf/myapp.conf"
    destination = "C:/App/myapp.conf"

    connection {
        type     = "winrm"
        user     = "Administrator"
        password = var.admin_password
        host     = var.host
    }
  }
}

 

 

 

 

Remote-exec provisioner 는 원격지에서 커맨드를 실행하는 것이다.

아래 예시는 web의 aws_instance 에 접속해서 script.sh를 실행하는 예시이다.

resource "aws_instance" "web" {
  # ...

  # Establishes connection to be used by all
  # generic remote provisioners (i.e. file/remote-exec)
  connection {
    type     = "ssh"
    user     = "root"
    password = var.root_password
    host     = self.public_ip
  }

  provisioner "file" {
    source      = "script.sh"
    destination = "/tmp/script.sh"
  }

  provisioner "remote-exec" {
    inline = [
      "chmod +x /tmp/script.sh",
      "/tmp/script.sh args",
    ]
  }
}

 

 

 

 

null_resource와 terraform_data

null_resource 란 말 그대로 아무 작업도 수행햐지 않는 리소스라고 한다. 처음에는 무슨말인지 이해가 잘 안되었는데... 테라폼 구성정의에서 상호 참조가 발생하는 상황에서 유용하게 쓰인다고 한다.

 

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

resource "aws_security_group" "instance" {
  name = "t101sg"

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

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

}

resource "aws_instance" "example" {
  ami                    = "ami-0c9c942bd7bf113a2"
  instance_type          = "t2.micro"
  subnet_id              = "subnet-dbc571b0"
  private_ip             = "172.31.0.100"
  key_name               = "my-keypair" # 각자 자신의 EC2 SSH Keypair 이름 지정
  vpc_security_group_ids = [aws_security_group.instance.id]

  user_data = <<-EOF
              #!/bin/bash
              echo "Hello, T101 Study" > index.html
              nohup busybox httpd -f -p 80 &
              EOF

  tags = {
    Name = "Single-WebSrv"
  }

}

resource "aws_eip" "myeip" {
  #vpc = true
  instance = aws_instance.example.id
  associate_with_private_ip = "172.31.0.100"
}

#### NULL RESOURCE ####
resource "null_resource" "echomyeip" {
  provisioner "remote-exec" {
    connection {
      host = aws_eip.myeip.public_ip
      type = "ssh"
      user = "ubuntu"
      private_key =  file("/lets/go_to/my.pem") # 각자 자신의 EC2 SSH Keypair 파일 위치 지정
      #password = "qwe123"
    }
    inline = [
      "echo ${aws_eip.myeip.public_ip}"
      ]
  }
}
########################

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

output "eip" {
  value       = aws_eip.myeip.public_ip
  description = "The EIP of the Instance"
}

 

이 null resource 부분을 주석처리하고 실행하면 아래와 같이 subnet 이 존재하지 않는 시점에 참조되기 때문에 에러가 발생한다. 따라서 null resource 를 사용하여 Temporary data 를 홀드했다 사용할 수 있다.

 

 

 

null resource 를 보다 간단하게 사용하기 위해 수명주기 관리자와 프로바이더 구성을 통합한 terraform_data 를 1.4 버전부터 지원한다.

아래와 같은 예시로 사용할 수 있다.

resource "terraform_data" "foo" {
  triggers_replace = [
    aws_instance.foo.id,
    aws_instance.bar.id
  ]

  input = "world"
}

output "terraform_data_output" {
  value = terraform_data.foo.output  # 출력 결과는 "world"
}

 

 

 

 

잊지 말아요!! 자원 삭제!!

테라폼을 이용하면 휴먼에러를 많이 줄일 수 있을 것 같아서 기대된다.

항상 좋은 스터디 이끌어주셔서 감사합니다!