IaC(Infrastructure as Code)

Terraform Provisioner/EC2 Userdata

cwchoiit 2024. 3. 11. 22:08
728x90
반응형
SMALL
728x90
SMALL

테라폼에서 Provisioner가 어떤것이고 EC2 Userdata는 무엇이며 둘 간의 차이는 무엇인지 알아보자.

 

 

Provisioner

테라폼에서 공식적으로 지원하는 문법이다. 세 가지 종류가 있는데 다음과 같다.

  • file: 로컬 -> 리모트 파일복사
  • local-exec: 로컬PC에서 명령어 수행
  • remote-exec: 리모트 머신에서 명령어 수행 (SSH, WinRM 과 같은 프로토콜을 지원)

그래서 위 세가지 경우를 처리하기 위해 테라폼에서 provisioner라는 녀석을 제공해준다고 생각하면 된다.

이 녀석은 기본적으로는 첫 리소스 생성 시점에 수행되지만 여러 옵션들을 통해 삭제 시점이라던가 매번 수행할 수 있도록 커스터마이징 할 수도 있다.

EC2 Userdata

우선 Userdata는 무엇이냐? AWS EC2 인스턴스를 만들 때 하단에 Advanced Settings 설정을 열어보면 Userdata가 보일것이다.

이 작업은 부팅 시점에 부트스트래핑을 해주는 녀석이다. 예를 들면, 사용자를 생성한다거나, 파일을 구성한다거나, 소프트웨어 설치를 한다거나 하는 작업들을 첫 부팅 시점에만 실행을 해준다.

 

 

실습

그럼 이제 provisioner를 이용해서 EC2를 만들어보는것과 Userdata를 이용해서 EC2를 만들어보고 어떤 차이점이 있고 어떻게 돌아가는지 직접 확인해보자.

 

다음이 이 내용을 실습하기 위한 파일 구조이다.

하나씩 살펴보자.

 

versions.tf

이 파일은 기본적으로 요구되는 버전을 명시한 파일이다. 특이사항은 없다.

terraform {
  required_version = "~> 1.0"

  required_providers {
    aws = {
        source = "hashicorp/aws"
        version = "~> 3.0"
    }
  }
}

 

outputs.tf

provisioner와 userdata를 각각 이용해서 만든 EC2 인스턴스에 대한 Public/Private IP와 DNS관련 데이터이다. 특이사항은 없다.

output "provisioner_instance" {
  value = {
    public_ip = aws_instance.provisioner.public_ip
    public_dns = aws_instance.provisioner.public_dns
    private_ip = aws_instance.provisioner.private_ip
    private_dns = aws_instance.provisioner.private_dns
  }
}

output "userdata_instance" {
  value = {
    public_ip = aws_instance.userdata.public_ip
    public_dns = aws_instance.userdata.public_dns
    private_ip = aws_instance.userdata.private_ip
    private_dns = aws_instance.userdata.private_dns
  }
}

files/index.html

이 파일은 nginx로 띄워줄 index.html 파일이다. 특이사항은 없다.

<h1>Hello Chyonee!</h1>

files/install-nginx.sh

이 파일은 nginx 설치 관련 ShellScript 파일이다. 역시 특이사항은 없다.

#!/bin/bash

sudo apt-get update
sudo apt-get install -y nginx

 

main.tf

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

data "aws_ami" "ubuntu" {
  most_recent = true

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

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

  owners = ["099720109477"]
}

locals {
  vpc_name = "cwchoiit-vpc"
  common_tags = {
    "Project" = "provisioner-userdata"
  }
}

module "security_group" {
  source = "tedilabs/network/aws//modules/security-group"

  version = "0.24.0"

  name        = "${local.vpc_name}-provisioner-userdata"
  description = "Security Group for SSH."
  vpc_id      = "vpc-04a888e12b489eb12"

  ingress_rules = [
    {
      id          = "ssh"
      protocol    = "tcp"
      from_port   = 22
      to_port     = 22
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow SSH from anywhere."
    },
    {
      id          = "http"
      protocol    = "tcp"
      from_port   = 80
      to_port     = 80
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTP from anywhere."
    }
  ]

  egress_rules = [
    {
      id          = "all/all"
      description = "Allow to communicate to the Internet."
      protocol    = "-1"
      from_port   = 0
      to_port     = 0
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]

  tags = local.common_tags
}

resource "aws_instance" "userdata" {
  ami                         = data.aws_ami.ubuntu.image_id
  instance_type               = "t2.micro"
  key_name                    = "cwchoiit-ec2-keypair-home"
  subnet_id                   = "subnet-0b23fd05b5919269e"
  associate_public_ip_address = true

  user_data = <<EOT
#!/bin/bash
sudo apt-get update
sudo apt-get install -y nginx
EOT

  vpc_security_group_ids = [
    module.security_group.id,
  ]

  tags = {
    Name = "cwchoiit-userdata"
  }
}

resource "aws_instance" "provisioner" {
  ami                         = data.aws_ami.ubuntu.image_id
  instance_type               = "t2.micro"
  key_name                    = "cwchoiit-ec2-keypair-home"
  subnet_id                   = "subnet-0b23fd05b5919269e"
  associate_public_ip_address = true

  vpc_security_group_ids = [
    module.security_group.id,
  ]

  tags = {
    Name = "cwchoiit-provisioner"
  }
}

resource "null_resource" "provisioner" {
  triggers = {
    instance_id = aws_instance.provisioner.id
    script      = filemd5("${path.module}/files/install-nginx.sh")
    index_file  = filemd5("${path.module}/files/index.html")       
  }

  provisioner "local-exec" {
    command = "echo Hello World"
  }

  provisioner "file" {
    source      = "${path.module}/files/index.html"
    destination = "/tmp/index.html"

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }

  provisioner "remote-exec" {
    script = "${path.module}/files/install-nginx.sh"

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }

  provisioner "remote-exec" {
    inline = [
      "sudo cp /tmp/index.html /var/www/html/index.html"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }
}

 

하나씩 뜯어보자. 위 부분(AWS Provider, AMI 가져오는 Data Source, Local Variables은 생략하겠다. 너무 많이 봐왔던 것들이기 때문)

 

EC2에 할당할 Security Group은 누군가가 만들어놓은 Module을 가져다가 사용했다. Inbound/Outbound 규칙은 ingress, egress에 매핑된 상태고, name, description, vpc_id를 할당해주었다.

module "security_group" {
  source = "tedilabs/network/aws//modules/security-group"

  version = "0.24.0"

  name        = "${local.vpc_name}-provisioner-userdata"
  description = "Security Group for SSH."
  vpc_id      = "vpc-04a888e12b489eb12"

  ingress_rules = [
    {
      id          = "ssh"
      protocol    = "tcp"
      from_port   = 22
      to_port     = 22
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow SSH from anywhere."
    },
    {
      id          = "http"
      protocol    = "tcp"
      from_port   = 80
      to_port     = 80
      cidr_blocks = ["0.0.0.0/0"]
      description = "Allow HTTP from anywhere."
    }
  ]

  egress_rules = [
    {
      id          = "all/all"
      description = "Allow to communicate to the Internet."
      protocol    = "-1"
      from_port   = 0
      to_port     = 0
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]

  tags = local.common_tags
}

 

이제 Userdata를 이용해서 AWS EC2 인스턴스를 만드는 부분이다. 기본적으로 ami, instance_type, subnet_id, associate_public_ip_address는 할당해주었다. 여기서 새롭게 보이는 key_name은 EC2에 SSH로 접속할 때 사용했던 Key pair의 Name이다. 그리고 이제 핵심 부분인 user_data가 있다. 보면 EOT로 시작해서 EOT로 끝나는데 그냥 Multiple 문자열을 받기 위해 저렇게 사용한다. 그리고 첫 부팅시에만 적용될 sudo apt-get updatenginx를 설치하는 작업을 한다. 그 다음 위에서 설명한 security_group을 지정했다. 

resource "aws_instance" "userdata" {
  ami                         = data.aws_ami.ubuntu.image_id
  instance_type               = "t2.micro"
  key_name                    = "cwchoiit-ec2-keypair-home"
  subnet_id                   = "subnet-0b23fd05b5919269e"
  associate_public_ip_address = true

  user_data = <<EOT
#!/bin/bash
sudo apt-get update
sudo apt-get install -y nginx
EOT

  vpc_security_group_ids = [
    module.security_group.id,
  ]

  tags = {
    Name = "cwchoiit-userdata"
  }
}

 

그 다음 EC2 하나를 더 만든다. 이 부분은 provisioner를 사용하는 부분은 없다. 그 바로 밑에 있는데 우선 리소스 이름 자체는 provisioner로 설정했다. 위 내용과 설명은 동일하다.

resource "aws_instance" "provisioner" {
  ami                         = data.aws_ami.ubuntu.image_id
  instance_type               = "t2.micro"
  key_name                    = "cwchoiit-ec2-keypair-home"
  subnet_id                   = "subnet-0b23fd05b5919269e"
  associate_public_ip_address = true

  vpc_security_group_ids = [
    module.security_group.id,
  ]

  tags = {
    Name = "cwchoiit-provisioner"
  }
}

 

 

이제 이 부분이 핵심이다. null_resource는 이 역시 Terraform에서 지원하는 Provider 중 하나인데 이 녀석은 딱 한가지 Attribute를 가지고 있다. triggers 라는 Attribute인데 이 triggers는 그 안에 지정한 것들 중 하나라도 변경 사항이 생기면 이 null_resource라는 전체 리소스를 재실행한다. 즉 리소스를 다시 만들어낸다. 그리고 이것을 사용해서 실제 provisioner를 사용했다.

 

resource "null_resource" "provisioner" {
  triggers = {
    instance_id = aws_instance.provisioner.id
    script      = filemd5("${path.module}/files/install-nginx.sh") 
    index_file  = filemd5("${path.module}/files/index.html")       
  }

  provisioner "local-exec" {
    command = "echo Hello World"
  }

  provisioner "file" {
    source      = "${path.module}/files/index.html"
    destination = "/tmp/index.html"

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }

  provisioner "remote-exec" {
    script = "${path.module}/files/install-nginx.sh"

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }

  provisioner "remote-exec" {
    inline = [
      "sudo cp /tmp/index.html /var/www/html/index.html"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      host        = aws_instance.provisioner.public_ip
      private_key = file("${path.module}/../cwchoiit-ec2-keypair-home.pem")
    }
  }
}

 

triggers에 3개가 있다. instance_id, script, index_file. 우선 instance_id는 위에서 만든 provisioner라는 이름의 리소스를 가진 리소스의 ID가 변경되거나, script, index_file은 files 폴더에 있는 파일의 해시값이 변경된다(파일 내용이 변경됨)면 이 리소스가 재실행된다는 의미이다.

 

여기에 local-exec는 로컬 PC에서 명령어를 수행하는 부분이다. 의미없지만 잘 수행되는지 Hello World를 echo로 실행한다.

file은 로컬에서 리모트로 파일을 전송한다. 그래서 sourcedestination 속성이 있다. 저 부분은 뭐가 뭔지 한 눈에 알 수 있을것이고 connection 부분이 중요하다. 이 부분이 어떻게 전송할지에 대한 내용이기 때문이다. 우선 SSH로 보내고 유저 이름은 "ubuntu"이다. Host는 위에서 provisioner라는 리소스 이름의 인스턴스 Public IP가 된다. 그리고 private_key가 이제 EC2에 SSH로 접속할 때 사용하는 .pem 파일이다. 이 .pem 파일은 AWS Console에서 EC2 인스턴스 만들 때 Key pair로 사용하는 그 .pem 파일이다.

 

그리고 remote-exec는 리모트 머신에서 명령어를 수행한다. 그 수행하는 명령어가 바로 script = "실행할 스크립트 파일의 경로" 또는 inline = ["명령어", "명령어"]이다. 이 명령어를 작성하는 방법은 세가지가 있다. script, scripts, inline. script와 inline은 보았고, scripts는 스크립트 파일 한개가 아니라 여러개를 실행할 때 사용한다.

 

그래서 이 main.tf에 대해 Apply를 하면 기존에 있는 VPC를 사용해서(저 위에 vpc-id가 기존에 있는 내 VPC ID를 붙인 것) 해당 VPC의 Subnet역시 기존에 있는 Subnet에 EC2 두개가 만들어진다. (Userdata로, Provisioner로) 그리고 각 EC2는 Nginx를 설치하는데 Userdata는 설치하는 것까지가 끝이다. 그리고 그것은 딱 한번 최초 부팅시에만 실행될 것이다. 그게 Userdata의 특징이니까. 그러나 provisioner로 만든 EC2는 null_resource를 사용해서 감시하는 3개의 트리거가 발동될 때마다 새롭게 null_resource에 작성한 작업들을 수행하게 된다. 왜냐하면 null_resource에서 작업하기로 하는 HOST가 전부 provisioner로 만든 EC2의 Public IP를 가르키기 때문.

 

그래서 이 것을 Apply해보자. 다음과 같이 정상적으로 Apply가 됐고 EC2 인스턴스 두개가 생겼다.

 

AWS Console에 들어가보면 만든 EC2 두개가 보인다.

 

그리고 각 EC2의 Security도 잘 적용된 상태다.

 

이 상태에서 저 Outputs에서 알려준 Public IP를 접근해보자. Nginx를 두 EC2 모두 설치했기 때문에 80으로 접속 시 Nginx 화면이 보여야한다.

 

Userdata로 만든 EC2

 

Provisioner로 만든 EC2

 

Provisioner로 만든 EC2는 왜 다를까? null_resource에서 index.html 파일을 Nginx가 기본으로 뿌려주는 경로에 위치시켰기 때문에 내가 만든 이 index.html 파일이 보이는 것이다.

 

그럼 여기서 한 발 더 나아가서 이 파일(index.html)이 수정되면? => 트리거가 발동 => provisioner로 만든 EC2가 Replace 될 것

files/index.html

다음과 같이 수정해보자.

<h1>Hello Chyoni!</h1>

 

그 후 Apply 진행. 변경된다는 Plan이 보인다.

 

작업이 진행되고 다시 provisioner로 띄운 EC2로 들어가보면 다음과 같이 변경 사항이 적용됐음을 확인 가능하다.

 

728x90
반응형
LIST

'IaC(Infrastructure as Code)' 카테고리의 다른 글

Packer Part. 2 (Builder)  (0) 2024.03.14
Packer Part. 1  (0) 2024.03.14
terraform_remote_state  (0) 2024.03.10
Terraform 나만의 Module 만들어보기  (0) 2024.03.10
Terraform Cloud  (0) 2024.03.10