들어가며

테라폼 기초 입문 실습 스터디 3주차이고, 기본 사용법 마지막 주차입니다. 이번 주에도 학습해야 하는 내용이 많아서 부지런히 달려보겠습니다. “테라폼으로 시작하는 IaC” 책을 기준으로 정리하였습니다.

테라폼으로 시작하는 IaC

테라폼으로 시작하는 IaC

HCL (계속)

이번주에도 HCL(HashiCorp Configuration Language) 의 문법에 대해 알아보겠습니다.

반복문 (2/2)

for_each

  • 반복문이며 선언된 key 값 개수만큼 리소스를 생성합니다.
  • for_eachcount 와 비슷하지만, count 는 인덱스를 사용하고 for_each 는 키를 사용합니다.
  • each object 를 사용하여 keyvalue 를 사용할 수 있습니다.
    • each.key : key
    • each.value : value 값 (set 타입을 사용하면 each.key 와 같습니다.)
  • for_each 는 mapset 타입만을 허용합니다. 따라서 mapset 타입이 아닌 표현식은 tomap(), toset() 등을 통해 변환해서 사용할 수 있습니다.
  • 실습 1. for_each 사용하기
    # main.tf
    resource "local_file" "abc" {
      # map 타입으로 선언
      for_each = tomap({
        a = "content a"
        b = "content b"
      })
      
      # 파일 제목은 key 값으로 설정 (a, b)
      filename = "${path.module}/${each.key}.txt"
    
      # 파일 내용은 value 값으로 설정 (content a, content b)
      content  = each.value
    }
    
    resource "local_file" "def" {
      # toset() 함수를 사용하여 set 타입으로 변환
      for_each = toset(["ccc", "ddd", "eee"])
      
      # 파일 제목은 key 값으로 설정
      filename = "${path.module}/${each.key}.txt"
      
      # 파일 내용은 value 값으로 설정 (set의 경우 value와 key가 같음)
      content  = each.value
    }
    
    • 실행 결과
      $ terraform apply -auto-approve
          
      $ terraform state list
      # => local_file.abc["a"]
      #    local_file.abc["b"]
      #    local_file.def["ccc"]
      #    local_file.def["ddd"]
      #    local_file.def["eee"]
          
      $ echo 'local_file.abc["a"]' | terraform console
      $ echo 'local_file.abc["a"].content' | terraform console
      # => "content a"
          
      $ cat a.txt
      # => content a
      
      $ echo 'local_file.def["ccc"].content' | terraform console
      $ cat ccc.txt
      # => "ccc"   # key와 value가 같음을 확인할 수 있음  
      
  • 실습 2. variable 블록을 사용해 선언된 변수를 사용하여 local_file.abc 리소스를 생성하고, local_file.abc 리소스를 사용하여 local_file.def 리소스를 생성합니다.
    # main.tf
    variable "names" {
      default = {
        a = "content a"
        b = "content b"
        c = "content c"
      }
    }
      
    resource "local_file" "abc" {
      for_each = var.names
      content  = each.value
      filename = "${path.module}/abc-${each.key}.txt"
    }
      
    resource "local_file" "def" {
      # 앞에서 만든 local_file.abc 리소스를 사용
      for_each = local_file.abc  
      content  = each.value.content
      filename = "${path.module}/def-${each.key}.txt"
    }
    
    • 결과
      $ terraform apply -auto-approve
          
      $ terraform state list
      # => local_file.abc["a"]
      #    local_file.abc["b"]
      #    local_file.abc["c"]
      #    local_file.def["a"]
      #    local_file.def["b"]
      #    local_file.def["c"]
          
      $ ls -1 *.txt
      # => abc-a.txt
      #    abc-b.txt    
      #    abc-c.txt    
      #    def-a.txt    
      #    def-b.txt    
      #    def-c.txt
          
      $ echo 'local_file.abc["a"].content' | terraform console
      # => "content a"    
      
  • 실습 3. “실습 2”에서 names 변수의 b 값을 삭제하고 terraform apply 명령어를 실행합니다. count와는 달리 중간의 항목이 삭제되어도 제대로 동작하는것을 확인할 수 있습니다.

    # main.tf
    variable "names" {
      default = {
        a = "content a"
        c = "content c"
      }
    }
      
    resource "local_file" "abc" {
      for_each = var.names
      content  = each.value
      filename = "${path.module}/abc-${each.key}.txt"
    }
      
    resource "local_file" "def" {
      # 앞에서 만든 local_file.abc 리소스를 사용
      for_each = local_file.abc  
      content  = each.value.content
      filename = "${path.module}/def-${each.key}.txt"
    }
    
    • 결과
      $ terraform apply -auto-approve
      # => ...
      #      # local_file.abc["b"] will be destroyed
      #    ...
      #      # local_file.def["b"] will be destroyed
      #    ...
      #    Apply complete! Resources: 0 added, 0 changed, 2 destroyed.
          
      $ terraform state list
      # => local_file.abc["a"]
      #    local_file.abc["c"]
      #    local_file.def["a"]
      #    local_file.def["c"]
          
      $ ls -1 *.txt
      # => abc-a.txt
      #    abc-c.txt
      #    def-a.txt
      #    def-c.txt    
      
      • count와는 다르게 중간의 항목이 삭제되어도 제대로 동작하여 키가 b인 것에 관련된 항목이 삭제되었음을 확인할 수 있습니다.

for Expression

  • for Expression은 for 문을 사용하여 set이나 list, map의 항목들에 대해 반복적으로 작업을 수행할 수 있습니다.
  • python의 list comprehension과 비슷한 기능을 제공합니다.
  • 기본적인 사용법은 아래와 같습니다.
    • 리턴 타입이 tuple 인 경우
      [for <항목의 변수명> in <set, list, map 등의 콜렉션> : <항목의 변수명을 가공하는 표현식>]
      
    • 리턴 타입이 object 인 경우
      {for <항목의 변수명> in <set, list, map 등의 콜렉션> : <결과 키> => <결과 값>}
      

      리턴되는 키와 값은 => 로 구분합니다.

    • 콜렉션이 list 유형인 경우 <항목의 변수명>하나인 경우 값을, 두개인 경우 “,” (쉼표)로 구분하여, 앞의 인수가 인덱스, 뒤의 인수가 값을 반환합니다.
      • 관용적으로 인덱스는 i, 값은 v로 표현합니다.
        $ terraform console
              
        > [for v in tolist(["aa", "bb", "cc", "dd"]) : "${title(v)}"]
        # => [ "Aa", "Bb", "Cc", "Dd" ]    
              
        > [for i, v in tolist(["aa", "bb", "cc", "dd"]) : "${i} => ${title(v)}"]
        # => [ "0 => Aa", "1 => Bb", "2 => Cc", "3 => Dd" ]    
        
    • 콜렉션이 map 유형인 경우 <항목의 변수명>하나인 경우 값을, 두개인 경우 “,” (쉼표)로 구분하여, 앞의 인수가 키, 뒤의 인수가 값을 반환합니다.
      • 관용적으로 키는 k, 값은 v로 표현합니다.
        $ terraform console
        
        > [for v in tomap({ name = "gildong", age = 234 }) : "${upper(v)}"]
        # => [
        #      "234",
        #      "GILDONG"
        #    ]
                    
        > [for k, v in tomap({ name = "gildong", age = 234 }) : "${upper(k)} => ${title(v)}"]
        # => [
        #      "AGE => 234",
        #      "NAME => Gildong",
        #    ]
        
    • if 를 통해 특정 조건을 만족하는 값만 가공하여 리턴 할 수 있습니다.
      $ terraform console
          
      # 짝수인 경우만 제곱한 값을 리턴
      > [for v in tolist([1,2,3,4]) : "${pow(v, 2)}" if v % 2 == 0]
      # => [ 4, 16 ]
          
      # 키가 "age"인 경우 제외하고 가공
      > {for k, v in tomap({ name = "gildong", age = 234 }) : upper(k) => title(v) if k != "age"}
      # => { "NAME" = "Gildong" }
      
  • 실습
    • 간단한 for 표현식 실습
      $ terraform console
          
      # 1. tuple 타입을 사용하여 for 표현식 사용
      > [for value in [0, 1, 2, 3] : pow(2, value * value) ]
      # => [ 1, 2, 16, 512 ]
          
      # 2. set 타입을 사용하여 for 표현식 사용
      > [for name in toset(["gildong", "sejong"]) : "Hi, ${title(name)}!" ]
      # => [ "Hi, Gildong!", "Hi, Sejong!", ]
          
      # 3. map 타입을 사용하여 for 표현식 사용
      > [for key, value in tomap({ name = "gildong", age = 234 }) : "${upper(key)} => ${title(value)}"]
      # => [
      #      "AGE => 234",
      #      "NAME => Gildong",
      #    ]
          
      # 4. 타입 확인
      > type([for key, value in tomap({ name = "gildong", age = 234 }) : "${upper(key)} => ${title(value)}"])
      # => tuple([
      #      string,
      #      string,
      #    ])
      
    • 악분님 for expression 실습

       # 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
            
       var.fruits_set                                        # => toset(["apple", "banana"]) 
       var.fruits_list                                       # => tolist(["apple", "banana"]) 
       var.fruits_map                                        # => tomap({"first" = "apple", "second" = "banana"})
                                                                  
       type(var.fruits_set)                                  # => set(string)
       type(var.fruits_list)                                 # => list(string)
       type(var.fruits_map)                                  # => map(string)
            
       # for item in var.fruits_set: item                    # 오류 발생! []로 감싸야함 
       [for item in var.fruits_set: item]                    # => ["apple", "banana"]
       type([for item in var.fruits_set: item])              # => tuple([string, string])
            
       # {for item in var.fruits_set: item}                  # 오류 발생! {}로 감싸졌으나 결과 값이 key, value 형태가 아님
       {for key,value in var.fruits_set: key => value}       # => { "apple" = "apple", "banana" = "banana" }
       type({for key,value in var.fruits_set: key => value}) # => object({apple: string, banana: string})
            
       # for item in var.fruits_list: item                   # 오류 발생! []로 감싸야함
       [for item in var.fruits_list: item]                   # => ["apple", "banana"]
       type([for item in var.fruits_list: item])             # => tuple([string, string])
            
       # {for item in var.fruits_list: item}                 # 오류 발생! {}로 감싸졌으나 결과 값이 key, value 형태가 아님
       {for key,value in var.fruits_list: key => value}      # => { "0" = "apple", "1" = "banana" }
       {for i, v in var.fruits_list: i => v}                 # => { "0" = "apple", "1" = "banana" }
       type({for i, v in var.fruits_list: i => v})           # => object({0: string, 1: string})
                
       # for item in var.fruits_map: item                    # 오류 발생! []로 감싸야함
       [for item in var.fruits_map: item]                    # => ["apple", "banana"]
       type([for item in var.fruits_map: item])              # => tuple([string, string])
                
       # {for item in var.fruits_map: item}                  # 오류 발생! {}로 감싸졌으나 결과 값이 key, value 형태가 아님
       {for key,value in var.fruits_map: key => value}       # => { "first" = "apple", "second" = "banana" }
       {for k, v in var.fruits_map: k => v}                  # => { "first" = "apple", "second" = "banana" }
       type({for k, v in var.fruits_map: k => v})            # => object({first: string, second: string}) 
      

dynamic

  • countfor_each를 사용하여 리소스 전체를 여러개 생성하는것 외에도 리소스 내부의 속성 블록을 동적으로 생성할 수 있습니다.
  • 예를 들어 archive 프로바이더의 archive_file 리소스의 source 속성에 여러 파일을 동적으로 지정할 수 있습니다.
    • dynamic을 사용하지 않았을 때
      # main.tf
      data "archive_file" "files" {
        type        = "zip"
        output_path = "${path.module}/files.zip"
          
        source {
          content  = "hello a"
          filename = "a.txt"
        }
          
        source {
          content  = "hello b"
          filename = "b.txt"
        }
          
        source {
          content  = "hello c"
          filename = "c.txt"
        }
      } 
      
      • 실행 후 확인
        $ terraform apply -auto-approve
        $ terraform state list
        $ terraform state show data.archive_file.files
        
        $ echo "tolist(data.archive_file.files.source)[0].filename" | terraform console
        # => "a.txt"
        $ echo "tolist(data.archive_file.files.source)[0].content" | terraform console
        # => "hello a"      
        $ unzip files.zip
        $ ls *.txt
        # => a.txt b.txt c.txt
        $ cat a.txt
        # => hello a
        
    • dynamic을 사용했을 때
      # main.tf
      variable "files" {
        type = map(object({ content  = string }))
        default = {
          "a.txt" = { content = "hello a" }
          "b.txt" = { content = "hello b" }
          "c.txt" = { content = "hello c" }
        }
      }
          
      data "archive_file" "files" {
        type        = "zip"
        output_path = "${path.module}/files.zip"
          
        dynamic "source" {
          for_each = var.files
          
          content {
            filename = source.key
            content  = source.value.content
          }
        }
      }
      
      • 실행 후 확인
        $ terraform apply -auto-approve
        $ terraform state list
        $ terraform state show data.archive_file.files
        
        $ echo "tolist(data.archive_file.files.source)[0].filename" | terraform console
        # => "a.txt"
        $ echo "tolist(data.archive_file.files.source)[0].content" | terraform console
        # => "hello a"      
        $ unzip files.zip
        $ ls *.txt
        # => a.txt b.txt c.txt
        $ cat a.txt
        # => hello a
        
  • 이와 같이 dynamic을 사용하면 반복되는 수가 많아져도 데이터만 바꾸면 되니 코드 중복을 줄일 수 있고, 유지보수가 용이해집니다.
    • 또한 dynamic은 다음과 같이 dynamic안에 또 dynamic을 사용하여 중첩할 수 있어서 상당히 편리합니다.
        ...
        dynamic "origin_group" {
          for_each = var.load_balancer_origin_groups
          content {
            name = origin_group.key
        
            dynamic "origin" {
              for_each = origin_group.value.origins
              content {
                hostname = origin.value.hostname
              }
            }
          }
        } 
        ...
      

조건문 (Conditional Expression)

  • 조건문은 테라폼에서는 다음과 같이 3항 연산자 형태로 사용할 수 있습니다.
    <condition> ? <condition 참일때 값> : <condition 거짓일때 값>
    
    • ?를 기준으로 좌측에는 조건이며, 우측에는 : 기호룰 기준으로 좌측은 조건에 대해 참일 때의 값, 우측은 거짓일 때의 값을 나타냅니다.
    • ? 좌측의 조건이 참일때는 : 앞의 참 값을, 거짓 일때는 : 뒤의 거짓 값을 반환합니다.
    • 다음의 예에서 var.a 가 빈 문자열이 아니면 var.a를, 빈 문자열이면 “default”를 반환합니다.
      ...
      var.a != "" ? var.a : "default"
      ...
      
  • 실습
    $ terraform console
      
    > 1 == 1 ? "1과 1은 같다" : "1과 1은 다르다"
    # => "1과 1은 같다" 
    
    > 1 == 2 ? "1과 2는 같다" : "1과 2는 다르다"
    # => "1과 2는 다르다"
    
  • 조건이 참일때의 값과 거짓일때의 값의 타입이 다를 경우, 테라폼은 자동으로 문자열 타입으로 변환하여 반환합니다.
    $ terraform console
    
    > 3 == 4 ? 3 : 4
    # => 4
      
    > 1 == 1 ? 1 : "two"
    # => "1"
      
    > 1 == 2 ? 1 : "two"
    # => "two"
    

    위의 예제와 같이 1 == 1 일때 값이 숫자 1이지만 문자열 “1”을 반환함을 확인할 수 있습니다.

  • 가능하면 명시적으로 타입을 지정하여 사용하는 것이 좋습니다.
    $ terraform console
      
    > var.a ? 1 : "two"            # 비권장
    > var.a ? "1" : "two"          # 권장
    > var.a ? tostring(1) : "two"  # 권장
    
  • 조건문을 사용하여 리소스 생성 여부를 결정할 수 있습니다.
    # main.tf
    variable "enable" {
      default = true
    }
      
    resource "local_file" "abc" {
      count = var.enable ? 1 : 0
      filename = "${path.module}/abc.txt"
      content  = "content abc"
    }
    
    • 실행 결과
      $ terraform apply -auto-approve
      $ terraform state list
      # => local_file.abc[0]
      $ cat abc.txt
      # => content abc
          
      # 변수의 default 보다 우선순위가 높은 환경변수를 통해 enable 변수를 false로 변경
          
      $ export TF_VAR_enable=false
      $ terraform apply -auto-approve
      $ terraform state list
      # => 없음
      $ cat abc.txt
      # => cat: abc.txt: No such file or directory 
      

      위와 같이 var.enable 변수 값에 따라 true 일때는 파일이 생성되고, false 일때는 파일이 생성되지 않음을 확인할 수 있습니다.

함수 (Function)

  • 테라폼에서는 프로그래밍 언어 처럼 값의 유형을 변경하거나 가공할 수 있는 다양한 내장 함수를 제공합니다.
  • 전체 함수 목록은 공식문서에서 확인할 수 있습니다.
  • 사용자 정의 함수는 지원하지 않으며, 숫자, 문자열, 컬렉션, 인코딩, 날짜, 파일, 네트워크 등 다양한 함수를 제공합니다.
  • 실습
    $ terraform console
      
    > upper("hello")                          # => "HELLO"
    > title("hello world")                    # => "Hello World"
    > max(1, 20, 3, 40, 5)                    # => 40
    > cidrnetmask("172.16.0.0/12")            # => "255.240.0.0"
    > cidrsubnet("1.1.1.0/24", 1, 0)          # => "1.1.1.0/25"
    > cidrsubnet("1.1.1.0/24", 1, 1)          # => "1.1.1.128/25"
    > cidrsubnets("10.1.0.0/16", 4, 4, 8, 4)  # => tolist([
                                              #      "10.1.0.0/20",
                                              #      "10.1.16.0/20",
                                              #      "10.1.32.0/24",
                                              #      "10.1.48.0/20",
                                              #    ])  
    > timestamp()                             # => "2024-06-27T14:00:00Z"
    > "${title("hello world again")}!!"       # => "Hello World Again!!"
      
    > [for a in [0,1,2,3] : cidrsubnet("1.1.1.0/24", 2, a)]  # => [
                                                             #      "1.1.1.0/26",
                                                             #      "1.1.1.64/26",
                                                             #      "1.1.1.128/26",
                                                             #      "1.1.1.192/26",
                                                             #    ]
    

프로비저너 (provisioner)

  • 프로비저너는 리소스가 생성된 후에 리소스에 대한 추가적인 설정이나 초기화 작업을 수행하는 기능입니다.
  • 프로바이더로 실행되지 않는 명령어 실행과 파일 복사 같은 역할을 수행할 수 있습니다.
  • 단, 프로비저너를 통한 작업은 상태(state)가 관리 되지 않으며, 프로비저닝에 대한 결과가 항상 같다고 보장할 수 없기에 다른 방안이 없을때 최후의 수단으로만 사용해야 합니다.
    • 프로비저너의 대안
      • AWS EC2의 경우 테라폼 코드 user_data 사용
        # main.tf
        ...
        resource "aws_instance" "example" {
          ami           = "..."
          instance_type = "t2.micro"
              
          user_data = <<-EOF
          #!/bin/bash
          echo "Hello, World" > index.html
          nohup busybox httpd -f -p 8080 &
          EOF
        }   
        
      • cloud-init, packer, provisioner connections 등을 사용
      • 별도의 설정 관리툴 사용 (Ansible, Chef, Puppet 등)
    • 이전에는 local-exec 프로비저너를 통해서 ansible과 연동하여 인프라 프로비저닝 후 구성을 관리하였으나, 최근에는 이를 개선하여 terraform-provider-ansible을 사용합니다.

local-exec 프로비저너

  • 테라폼이 실행되는 로컬에서 실행되는 명령어를 실행할 수 있습니다.
  • 리눅스나 윈도우 등 실행 환경에 맞게 커맨드를 정의해야 하며, 다음의 인수 값을 사용합니다.
    • command (필수) : 실행할 명령어를 정의합니다. «EOF를 사용하여 여러줄로 정의할 수 있습니다.
    • working_dir (선택) : 명령어를 실행할 디렉토리를 지정합니다.
    • interpreter (선택) : 배열로 명령어를 실행할 인터프리터를 지정합니다. 첫번째 인수는 인터프리터 명령어 이름이고, 두번째 부터는 인수 값입니다.
    • environment (선택) : 실행시의 환경 변수에 추가적인 환경 변수를 설정합니다. key = value 형태로 정의합니다.
    • on_failure (선택) : 명령어 실행이 실패했을 때 실행할 명령어를 정의합니다.
  • 예시
    • Unix/Linux/macOS
      # main.tf
      resource "null_resource" "example" {
        provisioner "local-exec" {
          command = <<EOF
            echo "Hello, World $FOO"
            EOF
          interpreter = ["bash", "-c"]
          working_dir = "/tmp"
          environment = {
            FOO = "bar"
          }
        }
      }
      
      • 실행결과
        $ terraform apply -auto-approve
        # => ...
        #    null_resource.example: Provisioning with 'local-exec'...
        #    null_resource.example (local-exec): Executing: ["bash" "-c" "          echo \"Hello, World $FOO\"\n"]
        #    null_resource.example (local-exec): Hello, World bar
              
        $ terraform state list
        $ terraform state show null_resource.example
        $ cat terraform.tfstate 
        

        명령이 실행은 되나 상태 파일에는 프로비저닝에 대한 정보가 저장되지 않음을 확안 할 수 있습니다.

    • Windows
      # main.tf
      resource "null_resource" "example" {
        provisioner "local-exec" {
          command = <<EOF
            echo "Hello, World %FOO%"
            EOF
          interpreter = ["PowerShell", "-Command"]
          working_dir = "C:\\windows\\temp"
          environment = {
            FOO = "bar"
          }
        }
      } 
      

원격지 연결

  • remote-execfile 프로비저너를 사용하여 원격지 서버에 명령어를 실행하거나 파일을 복사하려면 원격지 연결할 SSH 또는 WinRM (windows 서버의 경우) 연결 정의가 필요합니다.
  • connection 블록은 리소스 내에서 선언시 해당 블록 내의 프로비저너에서 공통으로 사용가능하며, 프로비저너 내에서 선언 되는 경우 해당 프로비저너에서만 사용 가능합니다.
    # main.tf
    # connection 블록으로 원격지 연결 정의
    resource "null_resource" "example1" {
        
      connection {
        type     = "ssh"
        user     = "root"
        password = var.root_password
        host     = var.host
      }
        
      provisioner "file" {
        source      = "conf/myapp.conf"
        destination = "/etc/myapp.conf"
      }
        
      provisioner "file" {
        source      = "conf/myapp.conf"
        destination = "C:/App/myapp.conf"
          
        connection {
            type     = "winrm"
            user     = "Administrator"
            password = var.admin_password
            host     = var.host
        }
      }
    }   
    
  • connection 적용 인수와 설명

    인수 연결 타입 설명 기본값
    type SSH/WinRM 연결 유형으로 ssh 또는 winrm ssh
    user SSH/WinRM 연결에 사용되는 사용자 ssh: root, winrm: Administrator
    password SSH/WinRM 연결에 사용되는 비밀번호  
    host SSH/WinRM (필수) 연결 대상 주소  
    port SSH/WinRM 연결 대상의 타입별 사용 포트 ssh: 22, winrm: 5985
    timeout SSH/WinRM 연결 시도에 대한 대기 값 5m
    script_path SSH/WinRM 스크립트 복제 시 생성되는 경로  
    private_key SSH 연결 시 사용할 SSH key를 지정하며, password 인수보다 우선함  
    certificate SSH 서명된 CA 인증서로 사용 시 private_key와 함께 사용  
    agent SSH ssh-agent를 사용해 인증하지 않는 경 우 false로 설정하며 Windows의 경우 Pageant만 사용 가능  
    agent_identity SSH 인증을 위한 ssh-agent의 기본 사용자  
    host_key SSH 원격 호스트 또는 서명된 CA의 연결을 확인 하는 데 사용되는 공개키  
    target_platform SSH 연결 대상 플랫폼으로 windows 또는 unix unix
    https WinRM true인 경우 HTTPS로 연결 false
    insecure WinRM true인 경우 HTTPS 유효성 무시 false
    use_ntlm WinRM true인 경우 NTLM 인증을 사용 false
    cacert WinRM 유효성 검증을 위한 CA 인증서  
  • 원격연결이 요구되는 프로비저너의 경우 스크립트 파일을 원격 시스템에 업로드하여 해당 시스템의 기본 쉘에서 실행하도록 하므로 script_path에 적절한 위치를 지정해야 합니다. 경로는 난수인 %RAND% 경로가 포함되어 생성됩니다.
    Unix/Linux/macOS : /tmp/terraform_%RAND%.sh
    Windows(cmd) : C:/windows/temp/terraform_%RAND%.cmd
    Windows(PowerShell) : C:/windows/temp/terraform_%RAND%.ps1
    
  • 베스천 호스트를 통한 연결의 경우 관련 인수를 제공합니다. 참고자료 링크

    인수 설명 기본값
    bastion_host 설정하게 되면 배스천 호스트 연결이 활성화되며, 연결 대상 호스트를 지정  
    bastion_host_key 호스트 연결을 위한 공개키  
    bastion_port 배스천 호스트에 연결할 포트 port 인수 값
    bastion_user 배스천 호스트에 연결할 사용자 user 인수 값
    bastion_password 배스천 호스트 연결에 사용할 비밀번호 password 인수 값
    bastion_private key 배스천 호스트 연결에 사용할 SSH 키파일 private key 인수 값
    bastion_certificate 서명된 CA 인증서 내용으로 bastion_private_key와 함께 사용  

file 프로비저너

  • file 프로비저너는 로컬 파일이나 디렉터리를 원격지 서버로 복사하는 기능을 제공합니다.
  • 사용되는 인수
    • source (선택) : 로컬 파일 또는 디렉터리의 경로를 지정하며 상대 경로 또는 절대 경로로 지정할 수 있습니다.
    • content (선택) : 파일의 내용을 직접 지정할 수 있으며, sourcecontent 중 하나만 사용할 수 있습니다. 대상이 디렉터리인 경우 tf-file-content 파일로 생성되고, 대상이 파일인 경우 해당 파일에 내용이 기록됩니다.
    • destination (필수) : 원격지 서버에 복사될 파일 또는 디렉터리의 경로를 지정합니다. 항상 절대 경로를 지정해야 합니다.
  • destination 지정시 주의할 점은 ssh 연결의 경우 디렉터리가 존재해야 하며, WinRM 연결의 경우 디렉터리가 존재하지 않으면 자동으로 생성합니다.
  • 또한 디렉터리를 대상으로 할 경우 source 경로에 따라 파일 또는 디렉터리로 복사됩니다.
    • destination이 /tmp 일 때 source가 디렉터리로 /foo 처럼 마지막에 / 가 없는 경우 대상 디렉터리에 지정된 디렉터리가 업로드 되어 /tmp/foo 형태로 업로드 됩니다.
    • destination이 /tmp 일 때 source가 디렉터리로 /foo/ 처럼 마지막에 / 가 있는 경우 대상 디렉터리에 지정된 디렉터리가 업로드 되어 /tmp/foo 형태로 업로드 됩니다.
  • 예제
    # main.tf
    resource "null_resource" "foo" {    
      # connection 블록으로 원격지 정의
      connection {
        type     = "ssh"
        user     = "root"
        password = var.root_password
        host     = var.host
      }
      
      # myapp.conf 파일이 /etc/myapp.conf 로 업로드
      provisioner "file" {
        source      = "conf/myapp.conf"
        destination = "/etc/myapp.conf"
      }
        
      # content의 내용이 /tmp/file.log 파일로 생성
      provisioner "file" {
        content     = "ami used: ami"
        destination = "/tmp/file.log"
      }
        
      # configs.d 디렉터리가 /etc/configs.d 로 업로드
      provisioner "file" {
        source      = "conf/configs.d"
        destination = "/etc"
      }
        
      # apps/app1 디렉터리 내의 파일들만 D:/IIS/webapp1 디렉터리 내에 업로드
      provisioner "file" {
        source      = "apps/app1/"
        destination = "D:/IIS/webapp1"
      }
    }  
    

remote_exec 프로비저너

  • remote-exec 프로비저너는 원격지 서버에 명령어를 실행하는 기능을 제공합니다. 예를들어 AWS의 EC2 인스턴스를 생성하고 해당 VM에서 명령을 실행할 때 사용합니다.
  • 사용하는 인수는 다음과 같고, 상호 배타적으로 하나만 사용 가능합니다.
    • inline : 명령어에 대한 목록으로 [ ] 블록 내에 “ “ 로 묶인 다수의 명령을 , 로 구분하여 지정합니다.
    • script : 실행할 스크립트 파일의 경로를 지정합니다. 스크립트 파일은 로컬 파일로 지정하며, 원격지 서버로 복사되어 실행됩니다.
    • scripts : 실행할 스크립트 파일의 목록으로 [ ] 블록 내에 “ “ 로 묶인 다수의 파일을 , 로 구분하여 지정합니다. 마찬가지로 로컬 파일로 지정하며, 원격지 서버로 복사되어 실행됩니다.
  • scriptscripts는 인수를 지정할 수 없으므로, 인수가 필요한 경우 file 프로바이더로 스크립트를 업로드하고 inline 인수로 스크립트에 인수를 추가하여 실행합니다.
  • 예제
    # main.tf
    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

  • null_resource 블록은 자체적으로는 아무것도 수행하지 않는 리소스를 생성할 때 사용합니다.
    • 이런 리소스가 필요한 이유는 사용자가 의도적으로 프로비저닝 동작을 조율해야 하는 상황에서, 프로바이더가 제공하는 리소스 수명주기관리만으로는 해결하기 어려울 때 사용합니다.
    • 주로 사용되는 시나리오는 다음과 같습니다.
      • 프로비저닝 수행 과정에서 명령어 실행
      • 프로비저너와 함께 사용
      • 모듈, 반복문, 데이터 소스, 로컬 변수와 함께 사용
      • 출력을 위한 데이터 가공
    • 예제
      • 다음 상황을 가정
        • AWS EC2 인스턴스를 프로비저닝하면서 웹서비스를 실행시키고 싶다.
        • 웹서비스 설정에는 노출되어야 하는 고정된 외부 IP가 포함된 구성이 필요하다. 따라서 aws_eip 리소스를 생성해야 한다.
      • AWS EC2 인스턴스를 프로비저닝하기 위해 aws_instance 리소스 구성 시 앞서 확인한 프로비저너를 활용하여 웹서비스를 실행하고자 한다
      • 1차 시도
        # main.tf
        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"  # 각자 default VPC에 subnet ID 아무거나
          private_ip             = "172.31.1.100"
          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"
          }
              
          provisioner "remote-exec" {
            inline = [
              "echo ${aws_eip.myeip.public_ip}"
             ]
          }
        }
              
        resource "aws_eip" "myeip" {
          #vpc = true
          instance = aws_instance.example.id
          associate_with_private_ip = "172.31.1.100"
        }
              
        output "public_ip" {
          value       = aws_instance.example.public_ip
          description = "The public IP of the Instance"
        }      
        
        • aws_eip가 생성되는 고정된 IP를 할당하기 위해서는 대상인 aws_instance의 id값이 필요합니다.
        • aws_instance의 프로비저너 동작에서는 aws_eip가 생성하는 속성 값인 public_ip가 필요합니다.
        • apply시 aws_eip와 aws_instance가 서로를 참조하여 순환 참조 오류가 발생합니다.
          $ terraform init
          $ terraform plan 
          # => Error: Cycle: aws_eip.myeip, aws_instance.example
          
      • 2차 시도
        • 둘중의 하나의 실행 시점을 뒤로 미뤄야 합니다.
        • 이럴때 null_resource를 사용하여 둘 중 하나의 리소스를 뒤로 미뤄 순환 참조를 막을 수 있습니다.
          # main.tf
          provider "aws" {
            region = "ap-northeast-2"
          }
                  
          resource "aws_security_group" "instance" {
            vpc_id = aws_vpc.peter_vpc.id
            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              = aws_subnet.peter_subnet.id
            private_ip             = "172.31.0.100"
            key_name               = "aws-ec2" # 각자 자신의 EC2 SSH Keypair 이름 지정
            vpc_security_group_ids = [aws_security_group.instance.id]
                  
            user_data = <<-EOF
                        #!/bin/bash
                        echo "Hello, T101 4th 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"
          }
                  
          resource "null_resource" "echomyeip" {
            provisioner "remote-exec" {
              connection {
                host = aws_eip.myeip.public_ip
                type = "ssh"
                user = "ubuntu"
                private_key =  file("/Users/admin/.ssh/aws-ec2-key.cer") # 각자 자신의 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를 사용하여 aws_eip 리소스를 뒤로 미루고, aws_instance 리소스가 먼저 생성되도록 하여 순환 참조 오류를 해결할 수 있습니다.
        • 실행 결과
          $ terraform apply -auto-approve
          # => ...
          #    Outputs:
          #    
          #    eip = "43.202.208.93"
          #    public_ip = "43.202.208.93"
                  
          $ terraform state list
          
          $ echo "aws_eip.myeip.private_ip" | terraform console
          # => "172.31.0.100"
          
          $ echo "aws_eip.myeip.public_ip" | terraform console
          # => "43.202.208.93"
          
          $ terraform output
          # => eip = "43.202.208.93"
          #    public_ip = "43.202.208.93" 
                  
          $ terraform destroy -auto-approve
          
aws_eip.myeip aws_instance.example aws_security_group.instance aws_subnet.peter_subnet aws_internet_gateway.peter_igw aws_vpc.peter_vpc aws_route.peter_defaultroute aws_route_table.peter_rt aws_route_table_association.peter_rtassociation null_resource.echomyeip
  • null_resource는 정의된 속성이 id가 전부이므로, 선언된 내부의 구성이 변경되더라도 새로운 Plan 과정에서 실행 계획에 포함되지 못합니다.
  • 따라서 사용자가 null_resource에 정의된 내용을 강제로 다시 실행하기 위한 인수로 trigger가 제공됩니다.
  • trigger는 임의의 string 형태의 map 데이터를 정의하는데, 정의된 값이 변경되면 null_resource 내부에 정의된 행위를 다시 실행합니다.
  • trigger 정의와 동작 예제
    resource "null_resource" "foo" {
      triggers = {
        ec2_id = aws_instance.bar.id # instance의 id가 변경되는 경우 재실행
      }
      ...생략...
    }
      
    resource "null_resource" "bar" {
      triggers = {
        ec2_id = time() # 테라폼으로 실행 계획을 생성할 떄마다 재실행
      }
      ...생략...
    }  
    

terraform_data

  • 테라폼 1.4 이후에는 null_resource 대신 terraform_data를 사용하여 아무 작업도 수행하지 않는 리소스 구현이 가능합니다.
  • 이후에는 가능한 null_resource 대신 terraform_data를 사용하는 것이 좋습니다.
  • 이 리소스 또한 자체적으로 아무것도 수행하지 않지만 null_resource별도의 프로바이더 구성이 필요하다는 점과 비교하여 추가 프로바이더 없이 테라폼 자체에 포함된 기본 수명주기 관리자가 제공된다는 것이 장점입니다.
  • 사용 시나리오는 기본 null_resourcr와 동일하며 강제 재실행을 위한 trigger_replace와 상태 저장을 위한 input 인수와 input에 저장된 값을 출력하는 output 속성이 제공됩니다.
  • triggers_replace에 정의되는 값이 기존 map 형태에서 tuple로 변경되어 쓰임이 더 간단해졌습니다.
  • terraform_data 리소스의 trigger_replace 정의와 동작 예제

    resource "aws_instance" "web" {
      # ...
    }
      
    resource "aws_instance" "database" {
      # ...
    }
      
    # A use-case for terraform_data is as a do-nothing container
    # for arbitrary actions taken by a provisioner.
    resource "terraform_data" "bootstrap" {
      triggers_replace = [
        aws_instance.web.id,
        aws_instance.database.id
      ]
      
      provisioner "local-exec" {
        command = "bootstrap-hosts.sh"
      }
    }
      
    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"
    }
    

moved 블록

  • 테라폼은 state에 기록되는 리소스의 이름이 변경되면 기존 리소스는 삭제되고, 새로운 리소스를 생성합니다.
  • 테라폼 리소스를 선언하다보면 이름을 변경해야 하는 상황이 발생되며 다음과 같은 예들이 있습니다.
    • 리소스 이름 변경
    • count로 처리하던 반복문을 for_each로 변경
    • 리소스가 모듈로 이동하여 참조되는 주소가 변경
  • moved 블록은 리소스의 이동을 표현하는 블록으로, 실제 리소스는 변경이 없으면서 테라폼에서 관리되는 이름만 변경되는것을 테라폼에게 알려주고 불필요한 리소스의 재생성을 방지합니다.
  • 예제
    • main.tf 생성
      resource "local_file" "a" {
        content  = "foo!"
        filename = "${path.module}/foo.bar"
      }
          
      output "file_content" {
        value = local_file.a.content
      }
      
      • 실행
        $ terraform init && terraform plan && terraform apply -auto-approve
        $ cat foo.bar
        $ terraform state list
        $ echo "local_file.a" | terraform console
        $ echo "local_file.a.id" | terraform console
        # => "4bf3e335199107182c6f7638efaad377acc7f452"
        
    • main.tf의 local_file의 이름을 a => b 로 변경
      resource "local_file" "b" {
        content  = "foo!"
        filename = "${path.module}/foo.bar"
      }
          
      output "file_content" {
        value = local_file.b.content
      }
      
      • plan 확인시 기존 리소스 a를 제거하고 새로운 리소스 b를 생성하는 것을 확인할 수 있습니다.
        $ terraform plan
        # => ...
        #      # local_file.a will be destroyed
        #    ...
        #      # local_file.b will be created
        #    ...
        #    Plan: 1 to add, 0 to change, 1 to destroy.
        
    • moved 블록을 사용하여 리소스의 이름 변경을 표현
      resource "local_file" "b" {
        content  = "foo!"
        filename = "${path.module}/foo.bar"
      }
      
      moved {
        from = local_file.a
        to = local_file.b
      }
          
      output "file_content" {
        value = local_file.b.content
      }
      
      • plan 과 apply시 제거나 생성이 없고 idlocal_file.a 일때와 동일합니다.
        $ terraform plan
        # => ... 
             Plan: 0 to add, 0 to change, 0 to destroy.
              
        $ terraform apply -auto-approve
        $ terraform state list
        $ echo "local_file.b.id" | terraform console 
        # => "4bf3e335199107182c6f7638efaad377acc7f452"
        
    • local_file.a일때와 local_file.b일때의 terraform.tfstate 파일을 비교해보면 아래와 같습니다.
      $ diff terraform.tfstate.a terraform.tfstate 
      
      4c4
      <   "serial": 1,
      ---
      >   "serial": 2,
      16c16
      <       "name": "a",
      ---
      >       "name": "b",    
      

      namea에서 b로 변경되었음을 확인할 수 있습니다.

    • 마지막으로 moved 블록이 적용되면, moved 블록은 더이상 필요가 없기 때문에 삭제해서 리팩토링을 완료합니다.
      # main.tf  
      resource "local_file" "b" {
        content  = "foo!"
        filename = "${path.module}/foo.bar"
      }
      
      # moved {
      #   from = local_file.a
      #   to = local_file.b
      # }
          
      output "file_content" {
        value = local_file.b.content
      }
      

CLI를 위한 시스템 환경 변수

  • 테라폼은 다양한 환경 변수를 사용하여 동작을 제어할 수 있습니다.
  • 로컬 작업환경 별 환경변수 지정 방법
    Mac/리눅스/유닉스: export <환경 변수 이름>=<값>
    Windows CMD: set <환경 변수 이름>=<값>
    Windows PowerShell: $Env:<환경 변수 이름>='<값>'
    
  • TF_LOG : 테라폼의 stderr 로그에 대한 레벨을 정의 합니다.
    • trace, debug, info, warn, error, off를 설정할 수 있고 관련 환경 변수가 없는 경우 off와 동일합니다.
    • TF_LOG를 info로 설정하고 terraform plan 동작을 하면 테라폼 출력에 관련 로그가 출력됩니다.
      $ export TF_LOG=info
      $ terraform plan
      
  • TF_LOG_PATH : 로그 출력 파일 위치 지정합니다.
  • TF_LOG_CORE : TF_LOG와 별도로 테라폼 자체 코어에 대한 로깅 레벨 지정 또는 해제합니다.
  • TF_LOG_PROVIDER : TF_LOG와 별도로 테라폼에서 사용하는 프로바이더에 대한 로깅 레벨 지정 또는 해제합니다.
  • TF_INPUT : 값을 false 또는 0으로 설정하면 테라폼 실행 시 인수에 -input=false 를 추가한 것과 동일한 수행 결과를 얻을 수 있습니다.
    • 단, TF_INPUT=0으로 하고 terraform plan을 실행했을때 입력변수를 반드시 입력 받아야 하는 경우 오류가 발생합니다.
      $ TF_INPUT=0  terraform plan
      # => Error: No value for required variable
      
  • TF_VAR_name : TF_VAR_<변수이름>을 사용하면 입력시 또는 default로 선언된 변수값을 대체합니다. (2주차 “변수 입력 방식과 우선 순위” 부분 참고)
  • TF_CLI_ARGS / TF_CLI_ARGS_subcommand : 테라폼 실행 시 추가할 인수를 정의합니다.
    # TF_CLI_ARGS="-input=false" terraform apply -auto-approve 는 terraform apply -input=false -auto-approve 와 같습니다.
    $ TF_CLI_ARGS="-input=false" terraform apply -auto-approve
    # => Error: No value for required variable
      
    # TF_CLI_ARGS_apply로 인수를 정의하면 terraform apply 커맨드 수행 시에만 동작합니다.
    $ export TF_CLI_ARGS_apply="-input=false"
    $ terraform apply -auto-approve
    # => <에러>
      
    $ terraform plan
    # => <정상 계획 예측 출력> 
    
  • TF_DATA_DIR : State 저장 백엔드 설정과 같은 작업 디렉터리별 데이터를 보관하는 위치를 지정합니다.
    • 이 데이터는 .terraform 디렉터리 위치에 기록되지만 TF_DATA_DIR에 경로가 정의되면 기본 경로를 대체하여 사용됩니다.
    • 일관된 테라폼 사용을 위해서 해당 변수는 실행 시마다 일관되게 적용될 수 있도록 설정하는 것이 중요합니다.
    • 설정 값이 이전 실행 시에만 적용되는 경우 init 명령으로 수행된 모듈, 아티팩트 등의 파일을 찾지 못합니다.

마치며

지금까지 3주간에 걸처 테라폼 HCL의 기본 문법과 기능을 알아보았습니다. 내용도 많고 실습해볼것도 많고, 블로그도 써야하고 다소 힘들었지만 :cry:, 다음 주차 수업부터 실제 응용을 배울 생각을 하니 두근 거립니다.

이번 주도 과제가 무사히 통과되길 기도:pray:하며 이번 블로그를 마칩니다.