Terraform
개요
하시코프에서 개발한 프로비저닝을 위한 오픈소스.
IaC를 편하게 할 수 있다는 장점을 가지고 있다.
간단하게 용례를 들어보자면, 4.RESOURCE/KNOWLEDGE/AWS/AWS 전체 아키텍처를 테라폼을 통해 만들 수 있다.
GCP, Azure 같이 클라우드 아키텍쳐 뿐 아니라 쿠버네티스 내부 워크로드 아키텍쳐도 코드로 관리할 수 있다!
워낙에 많이 쓰이고, 크게 어려운 점도 없다.
인프라라는 표현이 간혹 온프레미스와 동치되어 사용되는 경우가 있다.
인프라 엔지니어와 클라우드 엔지니어를 대비시켜 보는 방식이 바로 인프라를 온프렘으로 해석하는 케이스이다.
최소한 이 문서에서 인프라라고 하면 테라폼을 통해 프로비저닝되는, 실제 서비스를 운영하기 위한 환경과 관련된 자원의 묶음을 의미한다.
IaC에서의 인프라도 말 그대로 기반이 되는 시설, 구조, 자원들을 의미한다.
이렇게 인프라를 정의하는 이유는 테라폼은 실제 물리 환경에 대해서도, 가상 환경에 대해서도, 클라우드 환경에서도 어디에도 적용이 가능한 툴이기 때문이다.
그래서 인프라스트럭처라는 본연의 의미대로 해석해주면 되겠다.
특징
동작 흐름
테라폼을 쓸 때는 먼저 관련한 프로바이더를 애드온처럼 설치해줘야 한다.
이 프로바이더는 대상마다 만들어져 있으며, 이를 기반으로 테라폼 코드를 짜면 이것이 대상의 api를 활용해 원하는 인프라를 프로비저닝하는 방식이다.
이에 따라 다음의 단계로 관리를 진행하면 된다.
- Write
- 말 그대로 테라폼 코드를 짜면 된다.
- 코드는 해당 환경의 리소스 상태가 어떤 식이길 바라는지를 적는 과정이라고 보면 된다.
- Plan
- 해당 코드를 실제 환경에 적용하면 어떻게 될지 미리 검토하는 단계이다.
- 테라폼 코드 상의 오류를 잡아주기도 하고, 실제로 각 코드들이 적용될 때 프로비저닝될 순서가 짜여지는 단계이다.
- 하나의 단계라고 이야기하지만, 실질적으로 이건 선택적인 단계이다.
- Apply
- 쓰여진 코드를 실제로 적용하는 단계이다.
유저의 입장에서 정리했을 때는 이런 순서를 따르면 되는데, plan이나 apply를 하게 되면 테라폼에는 tfstate라고 하는 상태파일이 생긴다.
이 파일은 실제 인프라 환경
디렉토리 구조
테라폼 코드를 작성할 때 디렉토리는 이런 식으로 구성한다.
main.tf, outputs.tf 이런 식으로 .tf
를 확장자로 작성해주면 된다.
이렇게 하라고 형태가 고정되어 있는 건 아니고 관리 편의성을 위해 이런 식으로 컨벤션을 지정해서 사용한다는 말이다.
기본적으로 하나의 디렉토리는 하나의 모듈이 된다.
이 모듈 내에서 모든 파일 간에는 순서나 우위가 없다.
유의할 점은, 서브 디렉토리는 같은 모듈이 아니라는 것이다!
모듈은 디렉토리 별로 구분된다.
한 모듈 안에 다른 디렉토리로 두는 모듈을 서브 모듈이라고 부른다.
위 그림에서는 vpc가 하나의 서브모듈인 것이다.
HCL 문법
테라폼은 자체적인 언어를 사용하는데, 이것을 HCL(Hashicorp Configuration Language)라고 부른다.[1]
여기에서 말하는 언어는 일반적인 프로그래밍 언어라기보다는 yaml, json과 같은 형식을 정의해둔 언어이다.
그래서 설정을 정의해두는, 선언적 언어라고 부른다.
원래 HCL의 기반은 json 형식인데, 사용성을 극대화할 수 있도록 조금 변형됐다.
달리 말해 HCL을 json 형태로 바꾸는 것도 가능은 하다.
variable "example" {
default = "hello"
}
---
{
"variable": {
"example": {
"default": "hello"
}
}
}
위의 코드가 HCL 문법을 따르는 코드인데, 이 코드는 json으로는 아래의 코드와 같다.
HCL이란 것은 결국 조금 더 편리하게 사용할 수 있는 json이라고 보면 되겠다.
<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" {
# Block body
<IDENTIFIER> = <EXPRESSION> # Argument
}
resource "aws_vpc" "main" {
cidr_block = var.base_cidr_block
}
가장 기본적으로 형태는 이러한 모습을 띈다.
일단 블록을 지정하는데, 이 블록은 여러 설정들을 넣는 묶음체이다.
블록은 바디 이전에 여러 가지 블록 라벨을 가질 수 있다.
첫 라벨은 어떤 리소스인지를 나타내고, 두번째 라벨은 테라폼 코드에서의 해당 블록의 이름을 나타낸다.
블록의 바디에는 여러 가지 인자(arguement)가 들어갈 수 있다.
이 인자들 간의 순서는 아무런 의미가 없다.
그리고 블록은 또 다른 블록을 품을 수도 있다.
그럼 이제부터 다양한 블록의 유형과, 각 블록이 가질 수 있는 각종 특징들을 정리하겠다.
원래는 이 문단의 하위 문단으로 정리하는 게 맞지만, 내용이 많아 각각을 하나의 문단으로 분리한다.
추후에 내용이 더 많아진다면 아예 문서를 분리할 수도 있다.
변수, 값
진짜 핵심이 되는 것은 아래에서 볼 [[#resource]] 블록인데, 테라폼도 나름 언어인 만큼 여러 변수를 이용해서 편하게 관리를 하는 것이 가능하다.
그래서 이 변수나 값 유형의 블록이 뭐가 있는지 알아보고 친숙해진 이후에 본편으로 들어가자.
실질적으로 테라폼 코드는 하나의 거대한 함수와도 같다.
입력값이 있으면 동작이 실행된 후 어떤 출력값을 내뱉는, 프로그래밍 언어에서의 그 함수를 말하는 것이다.
여기에 중간 중간 잠깐 쓰이는 값으로 로컬 값까지 합하여 테라폼에서는 총 3가지의 변수를 지원한다.
input variables
variable "image_id" {
type = string
sensitive = true
}
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."
validation {
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}
variable "availability_zone_names" {
type = list(string)
default = ["us-west-1a"]
}
variable "docker_ports" {
type = list(object({
internal = number
external = number
protocol = string
}))
default = [
{
internal = 8300
external = 8300
protocol = "tcp"
}
]
}
입력 변수를 말한다.
terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"
terraform apply -var-file="testing.tfvars"
실제로 terraform apply를 할 때 인자로 직접 설정해서 넣는 식으로 운용되니, 정말 말 그대로 입력값이라 하겠다.
테라폼 상에서는 그냥 variable이라고 표현하기에, 보통 변수 블록이라 하면 이걸 말한다.
이들은 하나의 블록 라벨을 가지며, 이것이 곧 테라폼 코드 상에서의 변수 이름이 된다.
위를 예시로 들면 각 입력 변수는 테라폼 코드에서는 var.image_id
, var.docker_ports
이런 식이 된다.
변수 블록이 가질 수 있는 인자들을 살펴보자.
- default
- 말 그대로 기본값이다.
- 이게 없으면 apply할 때 대화형으로 넣어주던가 해야 한다.
- type
- 해당 변수의 자료형을 말한다.
- 어떤 자료형이 있는지는 아래 [[#자료형]] 참고.
- description
- 해당 변수에 대한 설명을 넣는 인자이다.
- apply될 때 입력되는 변수들이 뜨는데 이때 이 설명이 제공된다.
- validation
- 위에서 보이듯, 입력 값 검증을 하는 인자이다.
- ephemeral
- 프로비저닝 시에 쓰이기는 하나 상태 정보가 저장되지 않아야 할 때 쓴다.
- sensitive
- 민감한 정보에 대해 넣는 인자다.
- 이걸 넣으면 프로비저닝이 완료된 이후 출력물을 볼 때 값이 표시되지 않는다.
- 참고로 출력물로서만 안 보일 뿐, 암호화되거나 그런 거 없이 프로비저닝 때 사용된다.
- nullable
- 해당 변수가 null이어도 되는지를 지정한다.
- 보통은 false일 것이다.
설명만 봐도 어렵지 않아서 간단하게만 정리했다.
적용 방법
terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"
####
terraform apply -var-file="testing.tfvars"
####
export TF_VAR_image_id=ami-abc123
terraform plan
위의 방법들처럼 입력 변수를 넣을 수 있다.
tfvars 확장자 파일을 통해 넣어줄 수도 있다.
만약.. 굳이 json 형태로 넣어주고 싶다면 .tfvars.json
접미사로 만들어서 넣어주면된다.
안 넣고 일단 apply 때린 다음에 대화형으로 입력하는 것도 가능하다.
local value
locals {
service_name = "forum"
owner = "Community Team"
}
local, 말 그대로 지역 변수다.
문서에서는 값이라고 표현하나, 실질적으로 이것도 변수라고 보는 게 맞지 않나 싶다.
아무튼 테라폼 전체 코드 상에서 입력 변수로 쓰기에는 부적합하고 단지 내부에서만 사용하고 싶은 변수는 이렇게 locals라는 블록에 넣으면 된다.
locals 블록은 블록 라벨이 따로 없고, 내부의 인자들 각각이 변수가 된다.
위의 예시대로라면 이 값들은 local.service_name
, local.owner
이런 식으로 접근된다.
언뜻 보면 var.data~
, local.data~
이렇게 사용한다고 하니 이 둘의 용례가 어떻게 구분이 되나 싶을 수도 있다.
실제로도 이 둘은 그냥 비슷하게 사용할 수 있다.
따지자면 입력 변수는 명확하게 입력 상태도 지정돼야 하고 테라폼 코드로 운영되는 환경 상에서 핵심이 되는 변수들을 넣어준다.
이들은 실제로 파일에 상태도 저장된다.
반면 로컬 변수는 외부로 출력되거나 저장되지 않는 변수들이 쓰이기에 적합하다.
그리고 별 의미는 없지만 여러 코드에서 반복되는 값이 있을 때 쓰기에도 좋다.
그냥 함수를 쓸 때 입력 파라미터로 받는 값과 함수 내 지역 변수로 쓰는 값의 차이라고 봐도 무방하겠다.
output value
output "instance_ip_addr" {
value = aws_instance.server.private_ip
}
말 그대로 출력 값이다.
프로비저닝이 적용된 이후 테라폼을 실행한 환경에 원하는 출력물을 걸 수 있다.
가령 aws 환경을 프로비저닝했다면 이후에 ssh 커맨드가 나오게 하고 싶을 수 있는데, 이럴 때 쓴다.
(참고로 output은 plan 단계에서는 출력되지 않는다.)
이 친구도 하나의 블록 라벨을 가지고 이것이 값의 이름을 나타낸다.
이 친구도 쓸 수 있는 인자가 위의 입력 변수와 거의 비슷하다.
하나, depends_on이라는 인자가 있는데 이건 이후에 나올 [[#depends_on 인자]]을 참고하자.
resource
resource "aws_instance" "web" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
}
리소스 블록은 프로비저닝될 환경의 자원 단위를 나타내는, 실질적으로 가장 중요한 블록이다.
첫번째 라벨은 이 자원의 타입이 무엇인지 정의한다.
리소스 블록은 인프라스트럭처에서 단위가 될 수 있는 모든 것들을 타입으로 가질 수 있는데, 이건 각 환경마다 타입을 가지게 될 것이다.
가령 쿠버네티스라면 오브젝트들이 각 타입이 될 것이고, AWS라면 EC2, VPC, ALB 등이 타입일 것이다.
두번째 라벨은 테라폼에서 사용될 해당 블록의 이름을 정의한다.
리소스는 프로바이더마다 타입마다 사용되는 인자가 전부 다르다.
그래서 각 프로바이더마다 레지스트리에 제공하는 문서를 읽고 사용하면 되겠다.[2]
리소스 동작
위에서 동작 흐름을 대충 봤는데, 리소스들은 apply를 할 때 실제 인프라 환경에 만들어진다.
이들은 한번 만들어지면 state 파일로 상태가 저장된다.
그리고 테라폼 코드를 변경하거나 추가, 삭제하여 다시금 apply를 하면 기존에 있던 state 파일과 비교가 되어 어떤 것들이 변경돼야하는지 추적된다.
가령 새로운 리소스 블록을 만들고 apply를 하면 해당 자원이 state 파일에 없음을 테라폼은 인지하고 새로운 자원을 인프라에 프로비저닝하여 이를 반영하여 새로 state 파일이 만들어질 것이다.
반대로 어떤 리소스가 없어진 채 apply된다면 해당 자원은 디프로비저닝될 것이다.
의존성
리소스들은 서로 의존성을 가질 수 있다.
서로 의존성이 없다면 각 리소스의 생성과 변경은 병렬적으로 일어날 수 있는데, 간혹 다른 리소스가 만들어져야만 만들어질 수 있는 리소스도 있는데, 이런 것들은 리소스 바디를 보고 결정된다.
가령 vpc 블록과 서브넷 블록이 있다고 생각해보자.
이때 서브넷 블록 안에는 vpc의 id가 들어가게 될 텐데, 이럴 때 테라폼에서는 암묵적으로 vpc 뒤에 서브넷이 생성돼야 한다는 것을 인지하고 이를 의존성으로 반영한다.
코드 상에서 의존성이 추적되지 않지만 실질적으로는 의존성을 가지게 되는 리소스들이 있다면, 아래 [[#depends_on]] 인자를 이용해 명시적으로 의존성을 설정할 수도 있다.
meta-arguements
리소스 바디를 채우는 인자들은 각 리소스 타입마다 다를 것이다.
그러나 블록들이 무조건 공통적으로 가질 수 있는 인자도 존재하는데, 이것은 테라폼 내에서 리소스가 동작하는 방식이나 제어 방식에 관한 것이다.
대표적으로 다음의 것들이 있다.
depends_on 인자
resource "aws_iam_role_policy" "example" {
name = "example"
role = aws_iam_role.example.name
policy = jsonencode({
"Statement" = [{
"Action" = "s3:*",
"Effect" = "Allow",
}],
})
}
resource "aws_instance" "example" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
depends_on = [
aws_iam_role_policy.example
]
}
의존성을 테라폼 코드 상에서 관리할 수 없는 리소스 간의 관계를 명시적으로 의존성을 지정하고 싶을 때 사용하는 인자이다.
위의 코드에서, ec2의 인스턴스 프로필을 만들기 위해서는 꼭 iam 정책이 먼저 만들어져야 하는데 이 관계가 코드 상으로는 드러나지 않는다.
그래서 이럴 때 depends_on을 이용해서 관계를 명확하게 지정하는 것이 가능하다.
count 인자
resource "aws_instance" "server" {
count = 4
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
tags = {
Name = "Server ${count.index}"
}
}
같은 종류의 리소스를 여러 개 만들어야 할 때, 프로그래밍 언어의 반복문이 너무나도 절실할 것이다.
테라폼에서도 반복문을 지원하는데, 이를 위한 것이 바로 메타 인자 count와 아래의 for_each이다.
위 예시의 count 인자는 이 리소스 블록이 몇 번 반복돼야 하는지를 나타낸다.
지금 count가 4이므로 ec2는 총 4개가 만들어지게 될 것이다.
이때 각 리소스에 특별한 값을 부여하고 싶을 때는 현재 카운트의 인덱스를 사용하면 된다.
count가 있는 블록은 특별하게 count 변수를 사용할 수 있는데, 하위 속성으로 index를 가진다.
위에서는 ec2의 이름을 count.index를 이용하여 지정하는 것이 보인다.
이렇게 만들어진 리소스들은 상태를 체크할 때는 정말 리스트로 표시된다.
위의 예시로 계속 보자면 각 인스턴스는 aws_instance[0] 이런 식으로 표현된다.
resource "aws_instance" "server" {
count = var.create ? 1 : 0
...
}
count를 쓰는 흔한 방법 중 하나는 리소스를 만들지 말지를 결정하는 것이다.
var.create가 참일 때만 특정 리소스를 만들고 싶다면 위와 같은 방식을 활용할 수 있다.
for_each 인자
resource "azurerm_resource_group" "rg" {
for_each = tomap({
a_group = "eastus"
another_group = "westus2"
})
name = each.key
location = each.value
}
for_each도 count와 비슷하게 반복문이지만, 이 친구는 키와 값을 가진다는 점이 다르다.
count가 일반 리스트라면 for_each는 map이나 set이라고 보면 된다.
for_each에서 특별하게 지정되는 변수는 each이며, 여기에는 key와 value가 담긴다.
locals {
list = ["Todd", "James", "Alice", "Dottie"]
}
---
resource "aws_iam_user" "the-accounts" {
for_each = toset(local.list)
name = each.key
}
---
resource "aws_iam_user" "the-accounts" {
count = length(local.list)
name = local.list[count.index]
}
아무래도 인덱스를 사용하는 것이 아니다보니 조금 더 명확하게 관계나 제약을 걸기에 용이해보이다보니 리스트로 제시되는 것들도 for_each로 만들고 싶을 수도 있을 것이다.
이럴 때는 toset으로 리스트를 감싸서 사용해주면 된다.
위의 예시의 두 블록은 궁극적으로는 같은 동작을 한다.
for_each에 대해서 유의할 점 중 하나는 apply된 이후에 알게 되는 값을 대상으로 삼을 수 없다는 것이다.
그리고 여기에는 민감한 값을 넣는 것도 불가능하다.
그래서 실제로 적용 이전에 명확하게 값을 알 수 있고, 키값쌍을 활용해야 하는 경우에 for_each를 쓰는 것이 좋다.
이렇게 만들어진 리소스들은 상태를 체크할 때는 맵으로 표시된다.
위의 예시로 계속 보자면 각 인스턴스는 aws_instance['키'] 이런 식으로 표현된다.
provider 인자
provider "google" {
region = "us-central1"
}
provider "google" {
alias = "europe"
region = "europe-west1"
}
resource "google_compute_instance" "example" {
provider = google.europe
# ...
}
이건 [[#provider]] 블록에 대해 먼저 보고 오는 게 나은데, 어떤 프로바이더로부터 생기는 리소스인지 명시할 때 쓴다.
위의 예시처럼 같은 프로바이더지만 다른 값을 가지는 것들을 많이 만들 수 있다.
이럴 때 해당 리소스가 어떤 프로바이더에 속하는지 명시할 때 쓴다.
lifecycle 인자
resource "aws_instance" "example" {
instance_type = "t2.micro"
ami = "ami-abc123"
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [
이 리소스의 인자,
이 리소스의 또다른 인자..
]
replace_triggered_by = [
다른 리소스,
또다른 리소스의 인자..
]
precondition {
condition = data.aws_ami.example.architecture == "x86_64"
error_message = "The selected AMI must be for the x86_64 architecture."
}
postcondition {
condition = data.aws_ami.example.architecture == "x86_64"
error_message = "The selected AMI must be for the x86_64 architecture."
}
}
}
lifecycle은 리소스의 라이프사이클을 명시하고 싶을 때 사용하는 인자 블록이다.
하위로 여러 개가 들어가는데, 이름만 봐도 뭔지 대충 알 것 같이 생긴 인자들이라 아주 칭찬..
- create_before_destroy
- 코드의 변경으로 인해 업데이트를 하는 리소스들이 있는 한편, 무조건 없어졌다 생기는 리소스도 있다.
- 그런데 없어지는 그 순간에 의해 에러가 발생하는 환경이라면, 반드시 새로운 게 생성된 이후에 기존 것을 삭제해줘야 한다.
- 이걸 넣어도 되는 리소스는 대체로 접미사로 이름이 충돌나지 않아서 동시에 존재해도 고유할 수 있는 리소스여야 할 것이다.
- prevent_destroy
- 리소스 삭제를 하려할 때 에러를 발생시키도록 하는 인자이다.
- 데이터베이스 같이 삭제됐다 재생성되는 것을 엄격하게 막아야하는 리소스에 사용할 만하다.
- ignore_changes
- 코드 상에서, 혹은 리소스가 만들어진 이후 생기는 인자들 중에서는 변경이 잦은 값이 있을 수 있다.
- 이런 것들이 실제로 인프라 환경의 상태 업데이트를 일으키지 않길 바란다면 여기에 리스트로 명시해준다.
- 여기에 all이라는 값을 때려넣으면 그냥 모든 인자들의 변경을 무시해버린다.
- replace_triggered_by
- 다른 리소스, 혹은 리소스의 특정 인자가 변경될 때 이 리소스도 영향을 받아야만 한다면 이것을 쓴다.
- 보통 잘 설정해뒀다면 알아서 의존성이 추적되겠지만 안 되는 경우에 써주면 된다.
- precondition
- 자원이 프로비저닝되기 이전 명확하게 체크를 해야 할 게 있다면 쓴다.
- postcondition
- 자원이 프로비저닝되기 이후 명확하게 체크를 해야 할 게 있다면 쓴다.
provisioner 인자
resource "aws_instance" "web" {
# ...
provisioner "local-exec" {
command = "echo The server's IP address is ${self.private_ip}"
}
}
리소스 안에 다른 리소스를 명시적으로 넣는 인자이다.
이 내용은 나중에 추가적으로 다루겠다.
dynamic 블록
문서는 이걸 표현식 부분에서 다루던데, 나는 메타 인자쪽에서 다루는 게 맞다고 생각해 여기에 넣는다.[3]
resource "aws_elastic_beanstalk_environment" "tfenvtest" {
name = "tf-test-name"
application = aws_elastic_beanstalk_application.tftest.name
dynamic "setting" {
for_each = var.settings
content {
value = setting.value["value"]
}
}
}
간혹 어떤 리소스는 여러 개의 같은 유형의 내부 블록을 가질 수 있다.
이럴 때 이 블록을 여러 개 넣으려면 dynamic 블록을 사용하면 표현을 깔끔하게 해낼 수 있다.
dynamic은 기본적으로 이름 블록 라벨을 가진다.
그리고 dynamic은 for each 인자를 내부적으로 가져, 이것으로 순회한다.
안에 content 블록을 두어 실제 리소스의 스펙 관련한 인자를 넣어주면 되겠다.
이걸 많이 쓰면 코드의 가독성을 크게 해치므로 진짜 필요할 때만 사용하는 게 좋다.
이외
resource "aws_db_instance" "example" {
timeouts {
create = "60m"
delete = "2h"
}
}
간혹 리소스 블록들은 timeouts라는 특수 인자를 또 블록으로 가진다.
이건 해당 자원이 만들어지거나 지워질 때 최대한 기다릴 수 있는 시간을 지정한다.
특별 리소스
보통 리소스는 인프라 자원을 의미한다고 했지만, 간혹 로컬 환경의 동작을 정의하거나 파일, 정보 등을 나타내는 데에도 사용될 수 있다.
aws 환경을 만들고 여기에 SSH로 접근하기 위한 키를 로컬에 만들고 싶다고 생각해보자.
이럴 때는 내 로컬 환경에 키 페어가 만들어져야 하는데 이런 것들을 위해서도 리소스가 사용될 수 있다는 것이다.
아니면 시간의 지연을 의도적으로 내기 위해 시간 리소스 같은 것도 만들 수 있다!
로컬 리소스
로컬 환경에서의 리소스이다.
terraform_data
variable "revision" {
default = 1
}
resource "terraform_data" "replacement" {
input = var.revision
}
resource "example_database" "test" {
lifecycle {
replace_triggered_by = [terraform_data.replacement]
}
}
이건 아무런 자원도 의미하지 않는, 아주 원초적인 형태의 리소스이다.
근데 이걸 사용해서 효과를 볼 수 있는 케이스가 있다.
replace 특수 인자를 쓸 때, variable 블록의 변경은 plan 단게에서 추적되지 않는다.
이럴 때 위처럼 순수 변경을 추적하도록 만든 terraform_data를 사용해서 plan 단계에서 추적이 되도록 할 수 있다.
triggers_replace = [
aws_instance.web.id,
aws_instance.database.id
]
triggers_replace를 이용해서 오히려 다른 리소스의 replace trigger by를 대체하는 것도 가능하다!
ephemeral
ephemeral "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret_version.db_password.secret_id
}
resource "aws_db_instance" "example" {
instance_class = "db.t3.micro"
password_wo = ephemeral.aws_secretsmanager_secret_version.db_password.secret_string
password_wo_version = aws_secretsmanager_secret_version.db_password.secret_string_wo_version
}
이건 앞 단위가 다르니 리소스 블록이 맞냐! 할 수도 있긴 한데, 일단 이것도 리소스로 치기는 한다.
아무튼 이 리소스는 정말 잠깐만 생겼다가 상태가 저장되지 않고 사라져야 할 리소스에 대해서 써준다.
이걸 쓰는 케이스는 위처럼 프로비저닝되는 동안에만 유지되고 실제 상태로는 저장되어서는 안 되는 값이 있을 때이다.
이런 식으로 쓰이는 인자는 write-only arguement라고 따로 부른다.
data
data "aws_ami" "web" {
filter {
name = "state"
values = ["available"]
}
filter {
name = "tag:Component"
values = ["web"]
}
most_recent = true
}
resource "aws_instance" "web" {
ami = data.aws_ami.web.id
instance_type = "t1.micro"
}
이것도 어떻게 보면 리소스의 일종이라고 볼 수 있다.
이건 현재 인프라에 이미 존재하고 있는 자원을 코드로 불러올 때 사용하는 블록이다.
대표적인 두 가지 사용 케이스가 있다.
- 자원이기는 한데, 사용자가 커스텀하지 않고 프로바이더에서 관리하기에 제공만 하는 자원(managed resource)
- aws의 ami는 직접 만드는 것도 가능하지만 이미 존재하는 ami를 쓸 수도 있다.
- 이런 것을 현 코드 상에 명확하게 가져오고 싶을 때 쓴다.
- 이미 운영 중인 환경의 자원
- aws에 서비스를 구축하고 있으나 이후에 테라폼을 사용하기 시작한 케이스를 생각해보자.
- 테라폼을 사용한답시고 새로 리소스를 만들 수는 없으니, 사용 중인 자원들을 불러와야 한다.
- 이때도 data 블록을 이용해 이미 사용되고 있는 자원들을 불러온다.
data 블록의 인자는 대체로 쿼리를 위한 인자들이다.
위의 예시도 ami인데 태그가 저러면서 가장 최근의 ami 데이터를 불러오는 예시이다.
이것도 리소스마다 사용하는 방식이 달라서 결국 다 알아서 찾아봐야 한다.
그래도 거의 리소스와 비슷하기 때문에, count라던가 하는 메타인자들도 활용할 수 있다.
provider
provider "google" {
project = "acme-app"
region = "us-central1"
}
프로바이더는 사용될 리소스 타입의 묶음을 제공하는 제공자이다.
쿠버네티스, aws 같은 것들이 바로 이 프로바이더에 속한다.
그래서 테라폼에서 프로바이더라는 것은 실제로 세팅을 하게 될 인프라 제공자, 혹은 그 자체를 의미한다.
이 프로바이더 내부에는 각종 리소스들을 언어적으로 정의하고, 실제 인프라 환경에 적용될 때 사용되는 api 동작들을 정의하는 코드가 내장되어 있다.
각종 다양한 서비스 공급자나 오픈소스가 프로바이더로 제작되어 공유되고 있다.
이것도 레지스트리에서 알아서 열심히 찾아서 설정법을 찾아보면 된다.[2:1]
alias
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
기본 버전과 주소를 명시하여 쓰더라도, 다른 세팅으로 이뤄진 같은 프로바이더를 여러 개 세팅해야 할 수 있다.
이럴 때 각 프로바이더가 테라폼 코드에서 사용될 이름을 별칭으로 명시적으로 지정해야 충돌이 나지 않는다.
의존성 락 파일
리소스에서의 의존성은 리소스 간 적용 순서를 말하는 것이었다면, 프로바이더에 대해서 의존성이란 호환 버전을 말한다.
리소스 설정 방법이나, 각 인프라 환경의 api 변경으로 인해 다양한 설정의 충돌이 발생할 수 있기에 이것들을 명확하게 버전을 관리할 필요가 있고, 이를 파일로 또 명확하게 저장할 필요가 있다.
리소스에서는 state라는 파일이 쓰였다면, 프로바이더에 대해서는 .terraform.lock.hcl
이라는 파일이 쓰인다.
이건 terraform init
을 할 때 생기는 파일이다.
파일은 무결성을 위하여 저렇게 해시값이 담겨있다.
이것에 대한 심화 내용이 더 있기는 한데, 당장 추가적으로 더 다루지는 않겠다.[4]
version
프로바이더에 있어서 그리 중요하다는 게 버전이라는데, 그럼 그 버전은 어디에 명시해야 하나?
그건 바로 아래 있는, terraform 블록을 참고하자.
terraform
terraform {
required_providers {
mycloud = {
source = "hashicorp/aws"
version = "~> 1.0"
}
}
}
provider "mycloud" {
# ...
}
이제 와서 코드 형상적으로는 최상단에 해당하는 테라폼 블록 등장!
테라폼 블록은 현재 테라폼 코드가 실행되는 디렉토리의 여러 설정과 제한 사항을 지정하기 위한 근본 블록이다.
근데 실질적으로 위와 같이 필요한 프로바이더, 그리고 그 프로바이더의 버전을 지정하는데 사용되기 때문에 설명 상으로는 뒤로 뺐다..
프로바이더의 버전은 이 블록에서 명시한다.
이를 통해 전체 리소스 블록의 버전이 명확하게 지정된다.
이 버전을 명확하게 지정하는 이유는, 버전마다 리소스들의 명세가 달라지는 경우가 많기 때문이다.
이거 지정 안했다가 진짜 곤란해지는 케이스가 나올 수 있으니, 버전 지정은 그냥 필수라 생각하는 게 좋다.
저도 알고 싶지 않았다구요
위와 같이 테라폼 블록에서 required_providers를 사용할 때 명확한 소스(레지스트리 보면 나옴)와 버전을 명시해준다.
mycloud라고 키를 지정했는데, 이렇게 하면 provider 블록을 쓸 때 이것을 사용할 수 있게 된다.
backend
terraform {
backend "remote" {
organization = "example_corp"
workspaces {
name = "my-app-prod"
}
}
}
테라폼을 사용하면 상태 파일이나 각종 의존성 관리 파일들이 생긴다.
협업을 하는 운영 환경에서는, 이 파일들을 모두가 동기화시키는 것이 매우 중요하다.
a와 b가 각각의 로컬에서 작업하고 이를 인프라 환경에 프로비저닝해서 충돌이 나는 케이스를 막기 위해서는, 이 파일들을 명확하게 동기화시켜 관리할 필요가 있다.
이때 저장소로서 사용될 백엔드를 지정하는 것이 바로 이 인자 블록이다.
요로코롬 다양한 백엔드를 이용할 수 있다.
쿠버네티스는 뭔가 싶을 텐데, 저건 시크릿에 상태파일을 저장하는 방식이다.
s3는 최근에 추가됐다고 한다.
각각의 설정 방법은 직접 문서를 참고하자.
참고로 cloud 인자를 사용하면 테라폼 클라우드 돈내고 사용할 수도 있읍니당
이외의 특수 블록
이것들은 기본 개념을 익히는데 필수적인 것까지는 아니라고 생각하여 뺀 개념들에 대한 소개를 담는다.
removed
removed {
from = aws_instance.example
lifecycle {
destroy = true
}
provisioner "local-exec" {
when = destroy
command = "echo 'Instance ${self.id} has been destroyed.'"
}
}
removed는 리소스를 지울 때 쓰는 블록이다.
사실 그냥 지울 리소스는 코드에서 없애기만 해도 되긴 하는데.. 아무튼 그럼에도 흔적을 남기거나 이후에 다시 넣어야 하는 리소스라면!
잠시 지울 때는 명시적으로 이런 블록을 써주면 도움이 될 수도..?
여기에 lifecycle.destroy를 참으로 주면 실제 자원을 삭제하진 않고 테라폼 상태 파일에서만 지운다.
moved
moved {
from = aws_instance.tmp
to = aws_instance.조금_더_괜찮은_네이밍컨벤션의_이름
}
리팩토링을 할 때, 이름을 변경할 때 사용하는 블록이다.
실제 인프라 환경에 변화는 없으면서 이름만 바꾸고 싶다면 이런 식으로 해주면 되겠다.
check
check "health_check" {
data "http" "terraform_io" {
url = "https://www.terraform.io"
}
assert {
condition = data.http.terraform_io.status_code == 200
error_message = "${data.http.terraform_io.url} returned an unhealthy status code"
}
}
문법 표현식
HCL이라는 언어는 언어인 만큼 각종 함수나 자료형이 존재한다.
변수 사용법
코드 상에서 블록들을 참조하거나 변수를 사용하는 방법을 잠시 총정리하겠다.
- resource
aws_instance.abc
와 같은 식으로 리소스 타입, 테라폼 상 이름을 넣어주면 된다.- count가 쓰였다면 리스트처럼, for_each가 쓰였다면 map처럼 사용해야 한다.
- data
data.ami_type.bbb
와 같이 앞에 data를 붙인 후 데이터 타입, 테라폼 상 이름을 넣어준다.
- 입력 변수
var.tmp
와 같이 var를 앞에 붙인다.
- 지역 변수
local.ab
와 같이 local을 앞에 붙인다.
- 모듈 출력값
module.모듈이름.output
와 같이 서브모듈의 데이터는 module을 붙인다.
한 블록 내에서 지역적으로 사용되는 몇 가지 변수는 위에서 봤을 것이다.
- cound.index
- each.key, each.value
- self - 이건 provisioner를 쓸 때 자신 블록을 참조하기 위한 변수이다.
resource "aws_instance" "example" {
ami = "ami-abc123"
instance_type = "t2.micro"
ebs_block_device {
device_name = "sda2"
volume_size = 16
}
ebs_block_device {
device_name = "sda3"
volume_size = 20
}
}
위의 기본 방법들은 어렵지 않은데, 간혹 헷갈리는 게 이렇게 블록이 중첩되는 경우이다.
현재 ebs_block_device가 여러 개 중첩돼서 나오는데, 이럴 때 이 데이터에 접근하려면 이런 식으로 써주면 된다.
aws_instace.example.ebs_block_device[*].device_name
위의 경우에는 각 내부 블록이 블록 라벨을 가지지 않았기에 리스트처럼 접근하는 것이다.
(참고로 [*]
는 splat이라 하여 각 리스트 원소의 값들을 가져와 리스트로 변환하는 표현식이다.)
device "foo" {
size = 2
}
만약 이렇게 블록 라벨이 있었다면 map 형식으로 접근해주면 된다.
이밖에 몇 가지 특수 변수가 존재한다.
- path.module
- 이 표현이 사용된 모듈의 파일시스템 경로를 말한다.
- 많이 쓰일 일은 없다.
- path.root
- 루트 모듈의 파일시스템 경로
- path.cwd
- 테라폼 코드를 실행하는 프로세스의 파일시스템 경로를 나타낸다.
- terraform.workspace
- 테라폼 워크 스페이스를 나타낸다.
- 워크 스페이스는 깃 브랜치 같은 건데, 이후에 다루겠다.
자료형
자료형이 매우 간단해서 그냥 뒤로 뺐다.
테라폼의 기본이 되는 자료형은 string, number, bool 딱 3개다!
여기에 이들의 묶음을 제공하는 자료형이 몇 가지 더 있다.
- list - 리스트.
- 값에 접근할 때는 대괄호를 쓰고 인덱스를 넣어준다.
- set - 흔히 아는 순서 보장 안 되고 고유한 값만 남기는 그 집합이 맞다.
- tolist() 함수로 감싸면 리스트가 된다.
- map - 키와 값을 가지는 그 map이 맞다.
- 키는 string으로 들어가지만 값은 어떤 것이 들어가도 된다.
- 각 키의 값에 접근할 때는 대괄호 안에 키를 넣어주면 된다.
- any
- 이건 그냥 모든 자료형을 뜻한다.
- 별 이유 없이 이걸 자료형으로 쓰는 곳은 진짜 몇 대 맞아야한다.
아주 기본적이다!
참고로 자료형을 명시해주는 부분, 가령 [[#input variables]]에서 type을 쓸 때 묶음 자료형에 대해 내부 자료형을 명시하는 것도 가능하다.
map(string)
이런 식으로 하면 이 map의 값들은 문자열이라고 지정하는 것이다.
추가적으로 특수한 자료형들이 있다.
- null - 말 그대로 아무것도 없다는 걸 나타내는 자료형
- object
- 구조를 정의하는 자료형이다.
{ 키 = 자료형, 키 = 자료형}
과 같은 식으로 쓰여서 어떤 키의 값이 어떤 자료형인지를 세부적으로 지정한다.- 이걸로 어떤 리소스가 어떤 식으로 생겨먹었는지를 형식을 지정할 수 있다.
- tuple
- 이것도 구조를 정의하는데, 리스트 형식으로 자료형을 지정한다.
[자료형, 자료형]
이런 식이다.
쉽게 이해하고자 예를 들어보자.
variable "person" {
type = object({ name=string, age=number })
}
variable "info" {
type = tuple([string, number, bool])
}
가령 위의 형식의 변수들은 어떤 식의 값을 가질 수 있는가?
{
name = "John"
age = 52
}
["a", 15, true]
각각 이런 식으로 값을 가질 수 있는 것이다!
variable "person" {
type = object({
name=string
age=optional(number, 17)
})
}
이렇게 구조를 나타내는 자료형에는 optional이라는 특수 인자를 넣을 수 있다.
말 그대로 있어도 없어도 된다는 것을 나타낸다.
이걸 잘 정의해주는 모듈을 사용하면 극락을 느낄 수 있다(대부분 그렇게 안 해준다).
연산자
연산자(operator)에 대한 개념은 그냥 뺐다.
프로그래밍 언어 상의 기본과 그냥 같다고 보면 된다..
비트 연산자는 없다!
조건문, 반복문
condition ? true_val : false_val
기본적으로 코드 상에서 지원하는 조건문은 삼항 연산자밖에 없다.
(참고로 문자열에서는 diretive를 통해 if문 사용이 가능하다.)
{for s in var.list : s => upper(s)}
반복문은 for in 문만 가능하다.
위의 화살표 함수는 map으로 자료를 반환하는 방식이다.
뒤에 if를 넣어서 파이썬 마냥 필터링도 가능하다.
locals {
users_by_role = {
for name, user in var.users : user.role => name...
}
}
for문에는 조금 신기한 문법이 있는데, 화살표 함수를 써서 map을 반환하는 게 가능하다고 했다.
이때 map의 키는 고유해야 하는데 이 키별로 결과를 리스트로 묶고 싶을 수가 있다.
이럴 때 ...을 이용해서 grouping을 할 수 있다!
(이거 좀 혁신인데..?)
위의 예시에서 var.users는 원래 유저이름이 고유하고 여러 role이 있는 상태였다.
그러나 role을 키로 하고 그루핑을 하자 이렇게 각 role 별로 리스트를 만든 것이 보인다!
문자열
hcl은 실질적으로 json인 만큼, 사실 모든 자료형은 결국 문자열로 변환이 된다고 봐도 과언이 아니다.
근데 또 언어로서 사용하는 만큼, 단순 문자열 변수와 다른 변수들을 함께 사용하는데 있어 몇 가지 기법이 있다.
"hello"
일단 기본적으로 문자열은 큰따옴표로 감싸준다.
여기에 \n
과 같은 escape문자도 사용할 수 있다.
<<EOT
hello
world
EOT
줄바꿈이 여러 번 들어간다면 일일히 escape 문자로 쓰기 귀찮기에, 히어독(heredoc) 문자열도 지원한다.
<< 뒤에 마이너스를 붙이면 각 줄의 첫 공백문자를 없앤다(이거 yaml 같은 들여쓰기 중요한 문자열 작성할 때 유용할 수 있다).
example = jsonencode({
a = 1
b = "hello"
})
###
example = yamlencode({
"a" : "1"
})
어떤 문자열에 직접적으로 json 형식이나 yaml을 넣어야 하는 경우가 있을 수 있다.
이럴 때는 테라폼 코드를 json으로 변환하는 jsonencode, json을 yaml로 변환하는 yamlencode를 쓰자.
(반대로 yamldecode라 해서 코드로 변환하는 함수도 있다.)
"Hello, ${var.name}!"
문자열 안에 테라폼 코드 상의 변수를 넣을 때는 이런 식으로 ${}
를 사용해야 한다.
<<EOT
%{ for ip in aws_instance.example[*].private_ip }
server ${ip}
%{ endfor ~}
EOT
%{}
를 이용해서 선언자를 넣는 것도 가능하다.
반복문, 조건문을 넣을 때 사용하면 된다.
끝에 물결표를 넣으면 뒤 공백 문자를 지우겠다는 뜻이다.
함수
함수는.. 그냥 흔히 사용하는 것 몇 개만 정리하겠다.
- 숫자 관련
- min, max, abs, ceil...
- 문자열 관련
- chomp("hello\r\n") - 뒤 줄바꿈 문자 제거
- endswith, startwith
- format("Hello, %s!", "Ander") - printf처럼 사용
- indent(2, "helo") - 숫자만큼 들여쓰기
- join, lower, replace, split, substr
- 묶음 관련
- alltrue, anytrue, concat, contains, coalesce, length, tolist, tomap, merge, range
- lookup(map, key, default) - map에서 특정 key가 있으면 그 값을, 아니면 default 값을 반환
- 네트워크 관련
- 이거 알아두면 다른 코드 볼 때 유용하다.
- cidrsubnet("172.16.0.0/12", 4, 2) - 기본 cidr를 받아서, 새로 비트를 추가하고 네트워크 부에는 값을 추가한다.
- 이 함수의 반환은 172.18.0.0/16이 된다.
모듈
위에서도 말했듯, 모듈은 하나의 테라폼 코드가 담긴 디렉토리를 말한다.
모듈은 하위 모듈을 둘 수 있는데, 이때 기본이 되는 모듈을 루트 모듈, 하위 모듈은 자식 모듈(서브 모듈)이라고 부른다.
같은 디렉토리에 있어도 하위 디렉토리로 분리된 파일들은 별도의 모듈로 치부되는데, 이렇게 사용하는 게 보통 서브 모듈의 용례이다.
잠시 언급하기로 테라폼 코드는 하나의 거대한 함수와도 같다고 했다.
그렇게 봤을 때, 모듈은 곧 함수라고 할 수 있겠다.
그리고 보통 프로그래밍 언어에서도 유용한 함수를 라이브러리로 만들어 공유하듯이, 모듈 역시 퍼블리싱을 하는 게 가능하다!
이것도 역시 레지스트리에서 확인이 가능하다.[2:2]
세팅을 위해서 귀찮게 여러 개 리소스를 만들어 세팅해야 하는 것들.. 그런 것들은 보통 보면 누군가가, 혹은 프로바이더들이 알아서 모듈화시켜서 제공해주는 게 많아서 이것들을 이용하면 개꿀을 빨 수 있다!
만약 자신이 해당 프로바이더 설정에 대한 지식이 부족하다면, 솔직히 퍼블리싱된 모듈을 사용하는 것을 그다지 추천하지 않는다.
다양한 커스텀 설정들을 할 수 있게 제공을 해주는데 이것들은 결국 각 리소스에 대한 지식을 필요로 하는 경우가 매우 많으며, 이것들을 설정하다가 결국 해당 모듈 코드를 뜯어보는 자신을 발견할 수도 있다.
또한 이런 모듈들은 개인적인 차원에서 관리되는 경우가 많아서, 버전이 올라갈 때마다 천지창조급 변화가 일어나는 경우도 왕왕 있다.
모듈은 리소스 블록이 사용하는 메타 인자들을 거의 다 사용할 수 있다.
서브 모듈
module "servers" {
source = "./app-cluster"
servers = 5
}
루트 모듈에서 하위 모듈을 쓸 때는 이런 식으로 모듈이라는 블록을 사용한다.
계속 함수처럼 생각하라고 말하고 있는데, 이렇게 하위 모듈을 쓸 때 명확하게 이해가 되기 때문이다.
위 예시에서 severs라는 인자를 모듈 블록에 넣고 있는데, 이것은 해당 모듈에 지정된 variables 블록에 값을 넣어주는 방식이다.
그냥 함수에 인자 넣듯이 넣은 것이다!
resource "aws_elb" "example" {
# ...
instances = module.servers.instance_ids
}
반대로, 모듈의 output 값은 이렇게 모듈의 속성으로서 접근된다.
모듈이란 함수가 반환해준 값을 사용하는 방식이라고 이해할 때 나는 확 와닿았다.
서브 모듈을 사용할 때는 두 가지 인자를 추가적으로 넣어줘야 한다.
- source
- 프로바이더 쓸 때처럼, 어디에서 굴러온 뻑다구인지 써준다.
- 같은 파일시스템 내에 있기만 하다면, 위처럼 상대 경로로 작성하는 것도 가능하다.
- version
- 모듈은 여러 버전을 가질 수 있는데, 이를 명시하는 필드이다.
- 이거 정말 잘 지정해야 한다..
모듈의 소스가 될 수 있는 경로는 이렇게나 다양하다.
각각의 사용방식은 그냥 문서를 참고하자.[5]
state
설치
테라폼 설치는 문서에 잘 나와있으니 이걸 참고하도록 한다.[6]
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
리눅스에서는 간단하게 레지스트리를 등록하고 설치하면 된다.
윈도우의 경우 자료가 많으니 검색해서 설치하길 권장한다.[7]
관련 문서
이름 | noteType | created |
---|---|---|
클라우드 엔지니어 인턴 과제 | - | 2024-08-05 |
Terraform | knowledge | 2025-02-05 |
1주차 - 테라폼으로 프로비저닝, 다양한 노드 활용해보기 | project | 2025-02-05 |
2주차 - 테라폼 세팅 | project | 2025-02-09 |
2주차 - 네트워크 | project | 2025-02-25 |
2W - 테라폼으로 환경 구성 및 VPC 연결 | published | 2025-02-11 |
4W - 번외 AL2023 노드 초기화 커스텀 | published | 2025-02-25 |
8W - 가상머신 통합하기 | published | 2025-06-01 |
E-이스티오 가상머신 통합 | topic/explain | 2025-06-01 |
S-vpc 설정이 eks 액세스 엔드포인트에 미치는 영향 | topic/shooting | 2025-02-07 |
S-테라폼으로 헬름 설치할 때 네임스페이스 이슈 | topic/shooting | 2025-04-07 |
참고
require'lspconfig'.terraformls.setup{}
vim.api.nvim_create_autocmd({"BufWritePre"}, {
pattern = {"*.tf", "*.tfvars"},
callback = vim.lsp.buf.formatting_sync(),
})
네오빔에서 lsp 쓰려니 별도의 세팅을 해줘야 하는 모양이다.