Reviewing Terraform’s Basics – Part 1

T101 가시다님 Terraform 스터디 정리 1주차

Sigrid Jin
64 min readJun 15, 2024

간단한 EC2 1대를 배포해보자

Terraform을 필요할 때마다 사용해왔지만 마음 잡고 개념을 정리하거나 공부를 한 적이 없어서 새로운 스터디에 참여했다. The Tao of HashiCorp라고 하는 이들의 철학은— 워크플로에 집중하고 코드형 인프라를 설계하며 실용주의를 추구한다 — 엔지니어링 전반에 있어서 도움을 주는 설계와 철학이 될 것이라고 생각한다.

테라폼은 이뮤터블 immutable 한가요? 뮤터블 mutable 한가요? →이뮤터블 : 인프라스트럭처의 상태를 변경할 때, 기존의 인프라스트럭처를 수정하거나 업데이트하는 것이 아니라 새로운 인프라스트럭처를 생성하여 이전 상태의 인프라스트럭처를 교체하는 방식을 의미한다.

간단하지만 아주 기본적인 EC2 1대를 배포해보도록 한다. 아마존 리눅스 2의 최신 AMI ID를 찾아서 배포하여야 한다. 기본적인 방법은 aws ssm 명령어를 이용해서 최신 AMI를 찾고 이를 환경변수로 지정해서 테라폼 코드에 선언된 변수를 집어넣는 것이다.

# 각자 편한 디렉터리를 생성해주시면 됩니다
mkdir t101-1week-ec2
cd t101-1week-ec2

# Amazon Linux 2 최신 ami id 찾기 : ami-XYZ → 자주 업데이트가 됨
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2$ aws ec2 describe-images --owners amazon --filters "Name=name,Values=amzn2-ami-hvm-2.0.*-x86_64-gp2" "Name=state,Values=available" --query 'Images|sort_by(@, &CreationDate)[-1].[ImageId]' --output text
ami-0ebb3f23647161078

AL2ID=`aws ec2 describe-images --owners amazon --filters "Name=name,Values=amzn2-ami-hvm-2.0.*-x86_64-gp2" "Name=state,Values=available" --query 'Images|sort_by(@, &CreationDate)[-1].[ImageId]' --output text`

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2$ echo $AL2ID
ami-0ebb3f23647161078

aws ssm get-parameters-by-path --path /aws/service/ami-amazon-linux-latest
{
"Parameters": [
{
"Name": "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64",
"Type": "String",
"Value": "ami-0450ec15bbf42649e",
"Version": 77,
"LastModifiedDate": "2024-06-11T08:07:20.684000+09:00",
"ARN": "arn:aws:ssm:ap-northeast-2::parameter/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64",
"DataType": "text"
},
{
"Name": "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64",
"Type": "String",
"Value": "ami-0edc5427d49d09d2a",
"Version": 77,
"LastModifiedDate": "2024-06-11T08:07:21.230000+09:00",
"ARN": "arn:aws:ssm:ap-northeast-2::parameter/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64",
"DataType": "text"
},
{
"Name": "/aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-6.1-arm64",
"Type": "String",
"Value": "ami-0209db445c41d59db",
"Version": 77,
"LastModifiedDate": "2024-06-11T08:07:21.754000+09:00",
"ARN": "arn:aws:ssm:ap-northeast-2::parameter/aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-6.1-arm64",
"DataType": "text"
},
{


sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2$ aws ssm get-parameters-by-path --path /aws/service/ami-amazon-linux-latest --query 'Parameters[*].[Value, Name]' --output text
ami-0450ec15bbf42649e /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64
ami-0edc5427d49d09d2a /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64
ami-0209db445c41d59db /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-6.1-arm64
ami-0d412c1f9bec3c853 /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-6.1-x86_64
ami-0209db445c41d59db /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-default-arm64
ami-0c41e6fba063eb8a7 /aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2
ami-0ac114b359b90e4ff /aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-s3
ami-0bc18c0a337801100 /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-ebs
ami-0ebb3f23647161078 /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2
ami-06f220419a1559609 /aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-5.10-hvm-x86_64-ebs
ami-0450ec15bbf42649e /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
ami-0edc5427d49d09d2a /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
ami-0d412c1f9bec3c853 /aws/service/ami-amazon-linux-latest/al2023-ami-minimal-kernel-default-x86_64
ami-0b179c90538a5c304 /aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-ebs
ami-027ac312b4ff78bb4 /aws/service/ami-amazon-linux-latest/amzn-ami-minimal-hvm-x86_64-ebs
ami-01bff9a1419df6af0 /aws/service/ami-amazon-linux-latest/amzn-ami-minimal-hvm-x86_64-s3
ami-05fc299818f2c779a /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2
ami-0d7acf3d584720d31 /aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-5.10-hvm-arm64-gp2
ami-034a31ed1d34ef024 /aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-5.10-hvm-x86_64-gp2
ami-082c2ab556e6d3d80 /aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-arm64-ebs
ami-062ede8c68df11721 /aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-x86_64-ebs

하지만 데이터 소스 자체도 Terraform에서 지정하는 것이 코드 상에서 관리된다는 점에서 좋다. 또한 Cloud Provider에 agnostic하게 구현할 수 있으려면 별도의 모듈을 구현하고 이에 따라 구성하는 것이 좋다. 따라서 아래와 같이 간단한 예제를 만든다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ tree
.
├── main.tf
├── modules
│ └── aws
│ ├── main.tf
│ └── variables.tf
├── terraform.tfstate
├── terraform.tfstate.backup
├── terraform.tfvars
├── tfplan
└── variables.tf

2 directories, 8 files

최신 AMI 이미지를 취득하기 위한 데이터소스의 사용을 정의하는 곳은 modules/aws/main.tf에 있다. 아래와 같은 3가지 방법에 대해 고민해볼 수 있겠다. 첫 번째 방법은 AWS SSM Parameter Store를 사용해서 최신 AMI ID를 가져오는 것이고, 또 하나는 filter 연산을 통해서 AMI를 직접 검색해서 가져오는 방법이고, 마지막으로는 2번과 비슷하게 Ubuntu AMI 자체에 대해서 가져오겠다고 하고 이에 따른 filter를 거는 방법이다. 사용 시에는 most_recent attribute와 lifecycle attribute에 집중해서 보아야 한다. 전자는 항상 최신의 이미지를 가져오겠다고 하는 변수인 반면 후자는 이미지가 변경되었다고 해서 Replace를 진행하지 않도록 차단하는 것이다.

# 방법1
data "aws_ssm_parameter" "amzn2_latest" {
name = "/aws/service/ami-amazon-linux-latest/amzn2-ami-kernel-5.10-hvm-x86_64-gp2"
}

# 방법2
data "aws_ami" "linux" {
owners = ["amazon"]
most_recent = true

filter {
name = "name"
values = ["amzn2-ami-hvm*"]
}
}
resource "aws_instance" "aaaaaaaa" {
ami = data.aws_ami.amazonlinux2.id

//...

lifecycle {
ignore_changes = [ami]
}
}

# 방법3
data "aws_ami" "ubuntu" {
most_recent = true

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

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

owners = ["099720109477"] # Canonical
}

나는 모듈을 구현할 때 2번째 방법을 택하여 진행했다. 위 tree에서도 볼 수 있듯이 만약 azure, gcp와 같은 다른 클라우드 벤더사를 선택하고자 하는 경우 modules directory에 aws처럼 추가해 넣어주면 만사 OK이다. 참고로 새로운 모듈을 추가해주었으면 terraform init 을 통해 terraform backend가 팔로업할 수 있도록 지정해야만 한다.

// variables.tf

variable "cloud_provider" {
description = "Cloud provider to use"
type = string
}

variable "instance_type" {
description = "Type of instance to create"
type = string
}

variable "image_name" {
description = "Generic name of the image, translated by each module"
type = string
}
// modules/aws/main.tf
# Data source to find the latest Amazon Linux 2 AMI
data "aws_ami" "selected" {
most_recent = true
owners = ["amazon"]

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

# Resource block to create an EC2 instance using the found AMI
resource "aws_instance" "instance" {
ami = data.aws_ami.selected.id
instance_type = var.instance_type

lifecycle {
ignore_changes = [ami] # This prevents Terraform from replacing the instance when a new AMI is released
}
}
// main.tf

module "aws_instance" {
source = "./modules/aws"
count = var.cloud_provider == "aws" ? 1 : 0

instance_type = var.instance_type
image_name = var.image_name
}

몇 가지 유용한 팁들

  1. 파이프라인 설계를 할 일이 존재할 것이다. 이 때 detailed-exitcode를 추가하면 exitcode를 환경 변수로 구성하여 정상 종료 여부를 확인할 수 있다.
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform plan --detailed-exitcode
module.aws_instance[0].data.aws_ami.selected: Reading...
module.aws_instance[0].data.aws_ami.selected: Read complete after 0s [id=ami-0ebb3f23647161078]
module.aws_instance[0].aws_instance.instance: Refreshing state... [id=i-011f6ee45e6b9922b]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes
are needed.
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ echo $?
0

# 코드 확인 : 0(변경 사항이 없는 성공), 1(오류가 있음), 2(변경 사항이 있는 성공)

2. terraform validate라는 커맨드가 있는데 이는 API 작업을 발생시키는 terraform plan과 달리 HCL 코드 자체의 유효성을 검사하는 역할을 진행한다. 예를 들어 구성 문법과 종속성, 이름과 연결된 값의 정확성을 확인하는 역할 따위이다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ tree
.
├── main.tf
├── modules
│ └── aws
│ ├── main.tf
│ └── variables.tf
├── terraform.tfstate
├── terraform.tfstate.backup
├── terraform.tfvars
├── tfplan
└── variables.tf

2 directories, 8 files
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform validate
Success! The configuration is valid.

3. -upgrade : 0.14 버전 이후부터 프로바이더 종속성을 고정시키는 .terraform.lock.hcl이 추가되었다. 작업자가 의도적으로 버전을 변경하거나 코드에 명시한 다른 버전으로 변경하려면 terraform init -upgrade 를 수행한다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform init -upgrade

Initializing the backend...
Upgrading modules...
- aws_instance in modules/aws

Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Using previously-installed hashicorp/aws v5.54.1

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

4. fmt : format 또는 reformat 줄임 표시로 terraform tmt 명령어로 수행, 테라폼 구성 파일을 표준 형식과 표준 스타일로 적용. 코드 가독성 높일 수 있다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform fmt~/sigrid/gashida/t101-1week-ec2/terraform

5. 테라폼 구성에서 관리하는 일부 리소스만 제거하려면 다음과 같이 하면 된다. terraform state list를 적극 이용해본다.

terraform state list // 지금 백엔드가 관리하고 있는 리소스 확인
terraform destroy -target=aws_instance.example // 그 특정 리소스를 제거한다
// terraform state rm aws_security_group.example // 테라폼이 관리하고 있는 상태에서 제거한다.

6. replace 명령어를 사용하면 기존에 존재하던 리소스를 삭제하고 새로 구성하게 된다. 특정 상황에서 terraform apply를 할 때 리소스를 새로 구성하는 것이 맞다고 판단되는 경우 terraform state list를 통해 백엔드에서 관리되고 있는 리소스를 알아낸 뒤 이를 replace 구문에 넣어줄 수 있다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform state list
module.aws_instance[0].data.aws_ami.selected
module.aws_instance[0].aws_instance.instance

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-ec2/terraform$ terraform apply -replace="module.aws_instance[0].aws_instance.instance" -auto-approve
module.aws_instance[0].data.aws_ami.selected: Reading...
module.aws_instance[0].data.aws_ami.selected: Read complete after 0s [id=ami-0ebb3f23647161078]
module.aws_instance[0].aws_instance.instance: Refreshing state... [id=i-011f6ee45e6b9922b]

Terraform used the selected providers to generate the following execution plan. Resource
actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

# module.aws_instance[0].aws_instance.instance will be replaced, as requested
-/+ resource "aws_instance" "instance" {
~ arn = "arn:aws:ec2:ap-northeast-2:712218945685:instance/i-011f6ee45e6b9922b" -> (known after apply)
~ associate_public_ip_address = true -> (known after apply)
~ availability_zone = "ap-northeast-2a" -> (known after apply)
~ cpu_core_count = 1 -> (known after apply)
~ cpu_threads_per_core = 1 -> (known after apply)
~ disable_api_stop = false -> (known after apply)
~ disable_api_termination = false -> (known after apply)
~ ebs_optimized = false -> (known after apply)
- hibernation = false -> null
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ iam_instance_profile = (known after apply)
~ id = "i-011f6ee45e6b9922b" -> (known after apply)
~ instance_initiated_shutdown_behavior = "stop" -> (known after apply)
+ instance_lifecycle = (known after apply)
~ instance_state = "running" -> (known after apply)
~ ipv6_address_count = 0 -> (known after apply)
~ ipv6_addresses = [] -> (known after apply)
+ key_name = (known after apply)
~ monitoring = false -> (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
~ placement_partition_number = 0 -> (known after apply)
~ primary_network_interface_id = "eni-00fe6f7fee17063d5" -> (known after apply)
~ private_dns = "ip-172-31-12-12.ap-northeast-2.compute.internal" -> (known after apply)
~ private_ip = "172.31.12.12" -> (known after apply)
~ public_dns = "ec2-43-201-64-137.ap-northeast-2.compute.amazonaws.com" -> (known after apply)
~ public_ip = "43.201.64.137" -> (known after apply)
~ secondary_private_ips = [] -> (known after apply)
~ security_groups = [
- "default",
] -> (known after apply)
+ spot_instance_request_id = (known after apply)
~ subnet_id = "subnet-0a0e608fdc601614f" -> (known after apply)
~ tags = {
+ "Environment" = "Development"
+ "Name" = "MyInstance"
+ "Owner" = "IT Department"
}
~ tags_all = {
+ "Environment" = "Development"
+ "Name" = "MyInstance"
+ "Owner" = "IT Department"
}
~ tenancy = "default" -> (known after apply)
+ user_data = (known after apply)
+ user_data_base64 = (known after apply)
~ vpc_security_group_ids = [
- "sg-0ba57b0ffbea1512a",
] -> (known after apply)
# (5 unchanged attributes hidden)

- capacity_reservation_specification {
- capacity_reservation_preference = "open" -> null
}

- cpu_options {
- core_count = 1 -> null
- threads_per_core = 1 -> null
# (1 unchanged attribute hidden)
}

- credit_specification {
- cpu_credits = "standard" -> null
}

- enclave_options {
- enabled = false -> null
}

- maintenance_options {
- auto_recovery = "default" -> null
}

- metadata_options {
- http_endpoint = "enabled" -> null
- http_protocol_ipv6 = "disabled" -> null
- http_put_response_hop_limit = 1 -> null
- http_tokens = "optional" -> null
- instance_metadata_tags = "disabled" -> null
}

- private_dns_name_options {
- enable_resource_name_dns_a_record = false -> null
- enable_resource_name_dns_aaaa_record = false -> null
- hostname_type = "ip-name" -> null
}

- root_block_device {
- delete_on_termination = true -> null
- device_name = "/dev/xvda" -> null
- encrypted = false -> null
- iops = 100 -> null
- tags = {} -> null
- tags_all = {} -> null
- throughput = 0 -> null
- volume_id = "vol-06021db14cd536add" -> null
- volume_size = 8 -> null
- volume_type = "gp2" -> null
# (1 unchanged attribute hidden)
}
}

Plan: 1 to add, 0 to change, 1 to destroy.
module.aws_instance[0].aws_instance.instance: Destroying... [id=i-011f6ee45e6b9922b]
module.aws_instance[0].aws_instance.instance: Still destroying... [id=i-011f6ee45e6b9922b, 10s elapsed]

간단한 EC2 1대의 배포와 웹 서버의 설정

Terraform을 이용해서 EC2 1대를 배포하면서 userdata라고 하는 구문에 인스턴스 실행 시 사용자의 원하는 구문을 동작시킬 수 있도록 할 수 있다. 이를 통해 웹 서버를 설정하고 간단하게 애플리케이션의 설정을 자동화할 수 있지만, 문제는 어디까지를 Terraform이 관리하는 상태로 두어야 하냐는 문제가 있다. 이는 AWS ECS를 배포할 때 Terraform이 그 Task Definition의 상태까지 관리하게 만들어야 하냐는 질문과 유사하기도 하다.

아래와 같은 main.tf 파일이 있다고 가정하고, 8080 포트로 웹 서버를 설정했다고 가정해보자. 보안 그룹을 생성하고 연동해야 한다는 것을 잊지 말자.

보안 그룹은 id라는 주석을 내보내므로 이를 참조하는 표현식은 아래와 같음

aws_security_group.instance.id

하나의 리소스에서 다른 리소스로 참조를 추가하면 내재된 종속성이 작성됨

테라폼은 종속성 구문을 분석하여 종속성 그래프를 작성하고, 이를 사용하여 리소스를 생성하는 순서를 자동으로 결정함

cat <<EOT > main.tf
provider "aws" {
region = "ap-northeast-2"
}

resource "aws_instance" "example" {
ami = "$UBUNTUID"
instance_type = "t2.micro"
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 8080 &
EOF

tags = {
Name = "Single-WebSrv"
}
}

resource "aws_security_group" "instance" {
name = var.security_group_name

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

variable "security_group_name" {
description = "The name of the security group"
type = string
default = "terraform-example-instance"
}

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

# plan/apply가 정석
terraform plan
terraform apply -auto-approve

# 모니터링 : EC2 정보와 curl 접속 확인
PIP=3.39.22.130
혹은
PIP=$(terraform output -raw public_ip)

while true; do curl --connect-timeout 1 http://$PIP:8080/ ; echo "------------------------------"; date; sleep 1; done

# (옵션) 리소스 생성 그래프 확인
terraform graph

# graph 확인 > 파일 선택 후 오른쪽 상단 DOT 클릭
terraform graph > graph.dot

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ cat graph.dot
digraph G {
rankdir = "RL";
node [shape = rect, fontname = "sans-serif"];
"aws_instance.example" [label="aws_instance.example"];
"aws_security_group.instance" [label="aws_security_group.instance"];
"aws_instance.example" -> "aws_security_group.instance";
}

PIP=$(terraform output -raw public_ip)

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ curl $PIP:8080
Hello, T101 Study

서버의 포트를 변경하고 싶으면 userdata의 값을 바꾸고 이에 맞게 보안 그룹의 from_port, to_port 값을 바꾸어서 배포하면 된다. 여기서 user_data_replace_on_change를 true로 하면 리소스를 아예 삭제하고 다시 배포하며, false인 경우 리소스를 재부팅해서 다시 배포한다. 빈번하게 서버의 포트가 변경되면 인프라 레벨에까지 영향을 미치는 행위다.

user_data — (Optional) User data to provide when launching the instance. Do not pass gzip-compressed data via this argument; see user_data_base64 instead. Updates to this field will trigger a stop/start of the EC2 instance by default. If the user_data_replace_on_change is set then updates to this field will trigger a destroy and recreate.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform plan
aws_security_group.instance: Refreshing state... [id=sg-081c08df235adc4f8]
aws_instance.example: Refreshing state... [id=i-095bda924010d0623]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
~ update in-place

Terraform will perform the following actions:

# aws_instance.example will be updated in-place
~ resource "aws_instance" "example" {
id = "i-095bda924010d0623"
tags = {
"Name" = "Single-WebSrv"
}
~ user_data = "d91ca31904077f0b641b5dd5a783401396ffbf3f" -> "97c03d511c58d87f453b860da34eb781da004061"
# (39 unchanged attributes hidden)

# (8 unchanged blocks hidden)
}

# aws_security_group.instance will be updated in-place
~ resource "aws_security_group" "instance" {
id = "sg-081c08df235adc4f8"
~ ingress = [
- {
- cidr_blocks = [
- "0.0.0.0/0",
]
- from_port = 8080
- ipv6_cidr_blocks = []
- prefix_list_ids = []
- protocol = "tcp"
- security_groups = []
- self = false
- to_port = 8080
# (1 unchanged attribute hidden)
},
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 9090
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 9090
# (1 unchanged attribute hidden)
},
]
name = "terraform-example-instance"
tags = {}
# (8 unchanged attributes hidden)
}

Plan: 0 to add, 2 to change, 0 to destroy.

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform
apply" now.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform apply --auto-approve
aws_security_group.instance: Refreshing state... [id=sg-081c08df235adc4f8]
aws_instance.example: Refreshing state... [id=i-095bda924010d0623]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
~ update in-place

이를 위해서 Provisioner라는 개념이 존재하기도 하고, Provisioner의 도구로서 Ansible이나 Chef 같은 도구를 사용하기도 한다. Provisioner는 Terraform으로 리소스를 생성하거나 제거할 때 로컬이나 원격에서 스크립트를 실행할 수 있는 기능이다.

위와 같이 테라폼 코드 userdata를 불편함이 있지만 사용하기도 하고 cloud-init 사용, Packer 활용, Provisioner Connections 활용, 별도의 설정 관리 툴 사용(Chef, Habitat, Puppet 등) 하기도 한다. 과거에는 위 링크에도 나와있듯 local-exec provisioners를 통해서 ansible과 연동하여 인프라 배포 후 구성 관리를 하는 것이 일반적이었지만, 최근에는 terraform-provider-ansible가 각광을 받고 있다고 한다.

Terraform Backend

terraform apply 명령어를 실행하면 기본 local 백엔드에서 lock info가 저장된다. 이를 확인하기 위해서는 .terraform.tfstate.lock.info 를 확인하면 된다.

{"ID":"51d72eaa-f6b8-0833-3899-a93fa6f6ac74","Operation":"OperationTypeApply","Info":"","Who":"sigridjineth@sigridjineth-Z590-VISION-G","Version":"1.8.5","Created":"2024-06-15T18:42:27.679663838Z","Path":"terraform.tfstate"}

lock info를 지워버리면 다음과 같이 된다. 안전 장치를 걸어두는 것이다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform apply

│ Error: Error acquiring the state lock

│ Error message: 2 errors occurred:
│ * resource temporarily unavailable
│ * open .terraform.tfstate.lock.info: no such file or
│ directory



│ Terraform acquires a state lock to protect the state
│ from being written
│ by multiple users at the same time. Please resolve the
│ issue above and try
│ again. For most commands, you can disable locking with
│ the "-lock=false"
│ flag, but this is not recommended.

백엔드 상태 자체는 아래와 같이 확인해볼 수 있다. serial은 아마 apply를 한 incremental한 횟수를 의미하는 것 같다.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ ls terraform.tfstate*
terraform.tfstate terraform.tfstate.backup
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ vim terraform.tfstate
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ cat terraform.tfstate
{
"version": 4,
"terraform_version": "1.8.5",
"serial": 17,
"lineage": "da57d877-50df-c4b5-6725-75ebd6d0d2c5",
"outputs": {
"public_ip": {
"value": "52.78.144.143",
"type": "string"
}
},
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "example",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"ami": "ami-0bcdae8006538619a",
"arn": "arn:aws:ec2:ap-northeast-2:712218945685:instance/i-0090b304aa455bc27",
"associate_public_ip_address": true,
"availability_zone": "ap-northeast-2a",
"capacity_reservation_specification": [
{
"capacity_reservation_preference": "open",
"capacity_reservation_target": []
}
],
"cpu_core_count": 1,
"cpu_options": [
{
"amd_sev_snp": "",
"core_count": 1,
"threads_per_core": 1
}
],
"cpu_threads_per_core": 1,
"credit_specification": [
{
"cpu_credits": "standard"
}
],
"disable_api_stop": false,
"disable_api_termination": false,
"ebs_block_device": [],
"ebs_optimized": false,
"enclave_options": [
{
"enabled": false
}
],
"ephemeral_block_device": [],
"get_password_data": false,
"hibernation": false,
"host_id": "",
"host_resource_group_arn": null,
"iam_instance_profile": "",
"id": "i-0090b304aa455bc27",
"instance_initiated_shutdown_behavior": "stop",
"instance_lifecycle": "",
"instance_market_options": [],
"instance_state": "running",
"instance_type": "t2.micro",
"ipv6_address_count": 0,
"ipv6_addresses": [],
"key_name": "",
"launch_template": [],
"maintenance_options": [
{
"auto_recovery": "default"
}
],
"metadata_options": [
{
"http_endpoint": "enabled",
"http_protocol_ipv6": "disabled",
"http_put_response_hop_limit": 1,
"http_tokens": "optional",
"instance_metadata_tags": "disabled"
}
],
"monitoring": false,
"network_interface": [],
"outpost_arn": "",
"password_data": "",
"placement_group": "",
"placement_partition_number": 0,
"primary_network_interface_id": "eni-08188ab258279799b",
"private_dns": "ip-172-31-2-99.ap-northeast-2.compute.internal",
"private_dns_name_options": [
{
"enable_resource_name_dns_a_record": false,
"enable_resource_name_dns_aaaa_record": false,
"hostname_type": "ip-name"
}
],
"private_ip": "172.31.2.99",
"public_dns": "ec2-52-78-144-143.ap-northeast-2.compute.amazonaws.com",
"public_ip": "52.78.144.143",
"root_block_device": [
{
"delete_on_termination": true,
"device_name": "/dev/sda1",
"encrypted": false,
"iops": 100,
"kms_key_id": "",
"tags": {},
"tags_all": {},
"throughput": 0,
"volume_id": "vol-0057f6598ce614b5c",
"volume_size": 8,
"volume_type": "gp2"
}
],
"secondary_private_ips": [],
"security_groups": [
"terraform-example-instance"
],
"source_dest_check": true,
"spot_instance_request_id": "",
"subnet_id": "subnet-0a0e608fdc601614f",
"tags": {
"Name": "Single-WebSrv"
},
"tags_all": {
"Name": "Single-WebSrv"
},
"tenancy": "default",
"timeouts": null,
"user_data": "b8949f5116d15de0283399af467ca17be5185c52",
"user_data_base64": null,
"user_data_replace_on_change": false,
"volume_tags": null,
"vpc_security_group_ids": [
"sg-081c08df235adc4f8"
]
},
"sensitive_attributes": [],
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwicmVhZCI6OTAwMDAwMDAwMDAwLCJ1cGRhdGUiOjYwMDAwMDAwMDAwMH0sInNjaGVtYV92ZXJzaW9uIjoiMSJ9",
"dependencies": [
"aws_security_group.instance"
]
}
]
},
{
"mode": "managed",
"type": "aws_security_group",
"name": "instance",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"arn": "arn:aws:ec2:ap-northeast-2:712218945685:security-group/sg-081c08df235adc4f8",
"description": "Managed by Terraform",
"egress": [],
"id": "sg-081c08df235adc4f8",
"ingress": [
{
"cidr_blocks": [
"0.0.0.0/0"
],
"description": "",
"from_port": 8081,
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"protocol": "tcp",
"security_groups": [],
"self": false,
"to_port": 8081
}
],
"name": "terraform-example-instance",
"name_prefix": "",
"owner_id": "712218945685",
"revoke_rules_on_delete": false,
"tags": {},
"tags_all": {},
"timeouts": null,
"vpc_id": "vpc-0741031214e074bde"
},
"sensitive_attributes": [],
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6OTAwMDAwMDAwMDAwfSwic2NoZW1hX3ZlcnNpb24iOiIxIn0="
}
]
}
],
"check_results": null
}

main.tf에서 backend 블록을 설정할 수 있는 것을 보니, 기본 루트 디렉토리에 백엔드 파일이 저장되도록 하는 것보다는 state라고 하는 폴더를 만들어서 여기에 저장시키는 것이 깔끔할 것 같다.

terraform {
backend "local" {
path = "state/terraform.tfstate"
}
}

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "local" backend. No existing state was found in the newly
configured "local" backend. Do you want to copy this state to the new "local"
backend? Enter "yes" to copy and "no" to start with an empty state.

Enter a value: yes


Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v5.54.1

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ tree state
state
└── terraform.tfstate

0 directories, 1 file

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ cat state/terraform.tfstate | jq -r .serial
17

이런 일이 일어나서는 안되겠지만 만약 terraform.tfstate 파일이 삭제되면 어떻게 될까. 다행히 terraform.tfstate.backup 파일이 존재할 것이고 웬만해서는 local에서 사용하는 것이 아니라 s3 bucket을 사용하는 등의 최소한의 안전 장치는 있겠지만 만약 삭제되었다는 것은 전제하고 생각해보자.

terraform refresh와 terraform import라는 2개의 명령어가 눈에 들어올텐데, terraform refresh는 현재 backend가 관리하고 있는 상태에 대하여 최신 클라우드 벤더사의 리소스 상태와 동기화하는 것이기 때문에 우리가 전제하는 백엔드 파일이 사라진 상태에서는 동작하지 않는다. 그렇다면 terraform import 명령어가 있을텐데, 일일이 하나하나 리소스 ID를 정의하고 이에 따라 import를 해야 한다. 각 리소스를 하나 하나 나열해야 하며
각 리소스에 맞는 리소스 id을 매칭해야 한다. 상당히 번거롭다.

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes

aws_security_group.instance: Creating...

│ Error: creating Security Group (terraform-example-instance): InvalidGroup.Duplicate: The security group 'terraform-example-instance' already exists for VPC 'vpc-0741031214e074bde'
│ status code: 400, request id: f7599b3a-a411-4894-909c-04404a96deac

│ with aws_security_group.instance,
│ on main.tf line 29, in resource "aws_security_group" "instance":
│ 29: resource "aws_security_group" "instance" {


sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform sync
Terraform has no command named "sync".

To see all of Terraform's top-level commands, run:
terraform -help

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraform refresh

│ Warning: Empty or non-existent state

│ There are currently no remote objects tracked in the state, so there is nothing to refresh.

sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$

우회 방법 중 하나로 Google Cloud에서 제공하는 Terraformer라는 것을 이용해볼 수 있다. Terraformer는 기존 클라우드 벤더사에서 배포되어 있던 리소스를 역으로 terraform file로 변환시키는 도구이다. 이를 이용하여 terraform import를 진행할 수 있다.

export PROVIDER=all
curl -LO "https://github.com/GoogleCloudPlatform/terraformer/releases/download/$(curl -s https://api.github.com/repos/GoogleCloudPlatform/terraformer/releases/latest | grep tag_name | cut -d '"' -f 4)/terraformer-${PROVIDER}-linux-amd64"
chmod +x terraformer-${PROVIDER}-linux-amd64
sudo mv terraformer-${PROVIDER}-linux-amd64 /usr/local/bin/terraformer

terraformer import aws --resources=* --regions=ap-northeast-2

# sigridjineth@sigridjineth-Z590-VISION-G:~/sigrid/gashida/t101-1week-web$ terraformer import aws --resources="*" --regions=ap-northeast-2 --excludes=identitystore

cd generated && terraform state replace-provider -auto-approve -- -/aws hashicorp/aws

이후 terraform init 과 terraform plan을 하면 정상적으로 import된 것들을 확인할 수 있다.

이 외에도 새로 알게 된 옵션들을 적어본다.

  • 추가 옵션1 (이전 구성 유지) : -migrate-state는 terraform.tfstate의 이전 구성에서 최신의 state 스냅샷을 읽고 기록된 정보를 새 구성으로 전환한다.
  • 추가 옵션2 (새로 초기화) : -reconfigure는 init을 실행하기 전에 terraform.tfstate 파일을 삭제해 테라폼을 처음 사용할 때처럼 이 작업 공간(디렉터리)을 초기화 하는 동작이다.

Lifecycle

lifecycle은 리소스의 기본 수명주기를 작업자가 의도적으로 변경하는 메타인수다. 메타인수 내에는 아래 선언이 가능하다.

  • create_before_destroy (bool): 리소스 수정 시 신규 리소스를 우선 생성하고 기존 리소스를 삭제
  • prevent_destroy (bool): 해당 리소스를 삭제 Destroy 하려 할 때 명시적으로 거부
  • ignore_changes (list): 리소스 요소에 선언된 인수의 변경 사항을 테라폼 실행 시 무시
  • precondition: 리소스 요소에 선언해 인수의 조건을 검증
  • postcondition: Plan과 Apply 이후의 결과를 속성 값으로 검증

예를 들어 create_before_destroy를 테스트해보자. true일 때와 false일 때 어떻게 다른가? false일 때는 원하는 방식으로 동작하는 것 같다. 그런데 true일 때는 replacement를 만들고 이후에 삭제하기 때문에 원하는 형태의 동작일 것이다. EC2 Autoscaling에서 instance 전체가 생성되고 그 다음에 사라지는 형태인 무중단 배포를 지향해야 할 때는 유용할 것으로 보인다.


resource "local_file" "abc" {
content = "lifecycle - step 1111111111111111"
filename = "${path.module}/abc.txt"

lifecycle {
create_before_destroy = false
}
}

# local_file.abc must be replaced
-/+ resource "local_file" "abc" {
~ content = "lifecycle - step 1" -> "lifecycle - step 1111111111111111" # forces replacement
~ content_base64sha256 = "mWPFMXSOz+Q/uQKVbykSD/aZqtO8THFqRCcH2NaqkXc=" -> (known after apply)
~ content_base64sha512 = "Wa0KB/Qm0YIFpVWUcDMFSiC42nNRLzGsB+MOox8EnJMWGwJ5gkfx4GdODN9BX9lPO+C1lWMAhUNTOLD6j1cBRg==" -> (known after apply)
~ content_md5 = "973913f00967dad5c7f311448854eadf" -> (known after apply)
~ content_sha1 = "7e817d811209db576335597bcd326518fe13398b" -> (known after apply)
~ content_sha256 = "9963c531748ecfe43fb902956f29120ff699aad3bc4c716a442707d8d6aa9177" -> (known after apply)
~ content_sha512 = "59ad0a07f426d18205a555947033054a20b8da73512f31ac07e30ea31f049c93161b02798247f1e0674e0cdf415fd94f3be0b595630085435338b0fa8f570146" -> (known after apply)
~ id = "7e817d811209db576335597bcd326518fe13398b" -> (known after apply)
# (3 unchanged attributes hidden)
}

Plan: 1 to add, 0 to change, 1 to destroy.
local_file.abc: Destroying... [id=7e817d811209db576335597bcd326518fe13398b]
local_file.abc: Destruction complete after 0s
local_file.abc: Creating...
local_file.abc: Creation complete after 0s [id=10250e15edc8a551b7f85e55efd9575c3c8c3113]

resource "local_file" "abc" {
content = "lifecycle - step 2"
filename = "${path.module}/abc.txt"

lifecycle {
create_before_destroy = true
}
}








Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
local_file.abc: Refreshing state... [id=10250e15edc8a551b7f85e55efd9575c3c8c3113]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+/- create replacement and then destroy

Terraform will perform the following actions:

# local_file.abc must be replaced
+/- resource "local_file" "abc" {
~ content = "lifecycle - step 1111111111111111" -> "lifecycle - step 2" # forces replacement
~ content_base64sha256 = "2KkphlGQaXXYqdJppTLnWFjbvI1N3z68/TkHTNGJIzI=" -> (known after apply)
~ content_base64sha512 = "CrhG6iVVtZ/E8e1xF52D0ySBoFVTi4FvqODstSdux0fWc1pNwP+fAZMqE4AYjYLgycye+8US2pW2ipal1xyUCA==" -> (known after apply)
~ content_md5 = "6e91b5c7879549a7510fc88ccd6e9c23" -> (known after apply)
~ content_sha1 = "10250e15edc8a551b7f85e55efd9575c3c8c3113" -> (known after apply)
~ content_sha256 = "d8a9298651906975d8a9d269a532e75858dbbc8d4ddf3ebcfd39074cd1892332" -> (known after apply)
~ content_sha512 = "0ab846ea2555b59fc4f1ed71179d83d32481a055538b816fa8e0ecb5276ec747d6735a4dc0ff9f01932a1380188d82e0c9cc9efbc512da95b68a96a5d71c9408" -> (known after apply)
~ id = "10250e15edc8a551b7f85e55efd9575c3c8c3113" -> (known after apply)
# (3 unchanged attributes hidden)
}

Plan: 1 to add, 0 to change, 1 to destroy.
local_file.abc: Creating...
local_file.abc: Creation complete after 0s [id=43809e4e5139be51422bfdcb41cab0852741ec10]
local_file.abc (deposed object 06ba2484): Destroying... [id=10250e15edc8a551b7f85e55efd9575c3c8c3113]
local_file.abc: Destruction complete after 0s

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

이 외에도 precondition, postcondition과 같이 리소스 요소에 선언해서 인수의 조건을 검증하는 것도 유용해보인다. 아래에서 보이는 예시들에 따르면 모두 동작하지 않을 것인데, condition 블록에서 유효성 검증에 실패할 것이기 때문이다.

https://malwareanalysis.tistory.com/627
variable "file_name" {
default = "step0.txt"
}

resource "local_file" "abc" {
content = "lifecycle - step 6"
filename = "${path.module}/${var.file_name}"

lifecycle {
precondition {
condition = var.file_name == "step6.txt"
error_message = "file name is not \"step6.txt\""
}
}
}
resource "local_file" "abc" {
content = ""
filename = "${path.module}/step7.txt"

lifecycle {
postcondition {
condition = self.content != ""
error_message = "content cannot empty"
}
}
}

output "step7_content" {
value = local_file.abc.id
}

--

--