본문 바로가기
terraform

terraform 을 통한 클라우드 인프라 관리

by misankim 2023. 4. 4.

AWS의 클라우드포메이션처럼 AWS를 포함한 여러 클라우드 환경 및 컨테이너 환경의 인프라를 제어할 수 있는 infrastructure as code 도구인 terraform을 이용하여 AWS 인프라를 제어하는 방법에 대해 공유합니다.

설치(linux 기준)

curl -O https://releases.hashicorp.com/terraform/0.13.5/terraform_0.13.5_linux_amd64.zip

unzip terraform_0.13.5_linux_amd64.zip

mv terraform /usr/bin/
chmod 755 /usr/bin/terraform

terraform version

저는 AWS 외부에 있는 centos 상에서 테라폼을 사용했지만, MacOS에서도 쉽게 설치가 가능합니다. (MacOS를 포함한 다른 환경에서의 설치 방법은 https://www.terraform.io/downloads.html URL 참고 부탁드립니다)

인증을 위한 환경변수 설정

AWS 서비스에 api 액세스를 위한 인증은 아래와 같이 환경변수 설정으로 가능합니다.

export AWS_ACCESS_KEY_ID="액세스키ID"
export AWS_SECRET_ACCESS_KEY="프라이빗액세스키"
export AWS_DEFAULT_REGION="ap-northeast-2"

환경변수로 설정하지 않고 공급자 정보 파일에 아래와 같이 하드코딩도 가능하나, github 등의 퍼블릭 저장소로 업로드 시 유출의 위험이 있기 때문에 주의가 필요합니다.

(참고) 환경변수를 사용하지 않고 인증 정보 하드코딩

mkdir ~/terraform -> 앞으로 테라폼 관련 코드를 작성할 워킹 디렉토리입니다. 임의의 이름으로 설정합니다.

cd ~/terraform

vim provider.tf

provider "aws" {
  access_key = "<AWS_ACCESS_KEY>"
  secret_key = "<AWS_SECRET_KEY>"
  region = "ap-northeast-2"
}

작업 디렉토리 생성 및 공급자 정보 파일 작성

git 처럼 작업할 디렉토리를 새로 만든 후 해당 디렉토리에서 작업을 진행합니다. 테라폼은 .tf 확장자로 끝나는 파일을 읽어들이고, 의존 관계에 따라 리소스가 생성될 순서를 결정합니다. 먼저 여러 테라폼 공급자 중 aws를 사용한다는 내용이 담긴 공급자 파일을 생성합니다.

mkdir ~/terraform
cd ~/terraform

vim provider.tf

provider "aws" {}

# 저장 후 공급자 초기화
terraform init

초기화가 완료되면 아래와 같이 출력이 표시됩니다.

리소스 파일 작성

먼저 vpc 생성을 위한 리소스 파일을 작성해봅니다.

vim vpc.tf -> 파일의 이름은 사용자가 구분하기 위한 것으로 뭘로 하든 상관 없습니다. 확장자만 .tf로 일치시켜줍니다.

resource "aws_vpc" "vpc" {
  cidr_block       = var.vpc-cidr
  instance_tenancy = "default"
  enable_dns_hostnames = "true"

  tags = {
    Name = "${var.resource-prefix}-vpc"
  }
}

var. 으로 시작되는 문자열은 임의로 지정한 변수의 의미로, 위의 예에서 사용된 변수는 var.vpc-cidr 와 var.resource-prefix 총 2개입니다. 두 변수 값에 대한 정의를 별도의 파일을 통해 진행합니다. 한 파일에 쭉 이어서 작성해도 전혀 관계가 없지만 내용이 길어졌을 경우 변수만 따로 수정하기 위해 별도로 작성합니다. (작성 방식은 취향따라 해주시면 될것 같습니다)

vim variable.tf

variable "resource-prefix" {
  default = "premisan-test"
}

variable "vpc-cidr" {
  default = "10.0.0.0/16"
}

resource-prefix 라고 명명한 변수는 앞으로 생성될 리소스 앞에 공통적으로 부여할 접두사입니다. vpc-cidr 라고 명명한 변수는 vpc에서 사용할 ip cidr 범위입니다. 저는 아래와 같이 변수를 설정했습니다.

생성될 인프라 내역(plan) 확인

이제 작성한 tf 파일들을 토대로 어떤 인프라가 생성될지 확인합니다. 확인을 위해서는 terraform plan 명령어가 사용됩니다. 아래는 terraform plan 명령어의 출력물입니다. plan 은 실제 인프라가 생성되는 것이 아닌 생성되거나 삭제될 인프라 내역을 미리 확인해보는 명령어입니다. 때문에 실제 인프라 적용 전 plan 을 미리 확인해보는 것이 바람직하겠습니다.

작성한 리소스를 실제 인프라에 적용

terraform plan 명령어를 통해 확인한 내역을 적용하기 위해서는 terraform apply 명령어를 사용합니다. terraform apply 명령어는 실제 AWS 인프라에 변경사항을 발생시키기 때문에 신중히 확인 후 수행합니다. 명령어 실행 시 생성될 인프라 내역을 출력하며, 최종적으로 위의 내용을 적용할 것인지 묻습니다. yes를 입력하여 변경사항을 적용하도록 합니다.

위에서 수행한 리소스 내역이 정상적으로 AWS상에서 생성되었는지 확인해봅니다. 설정한 var.vpc-cidr 와 var.resource-prefix 변수에 따라 vpc가 생성된 것을 확인할 수 있습니다.

리소스 수정

만약 생성한 인프라의 수정이 필요한 경우 기존 .tf 파일을 편집한 후 최초 리소스 생성과 동일한 방법으로 terraform apply 명령어를 통해 변경사항을 적용합니다. 예를 들어 생성한 vpc의 태그(name)를 수정하고 싶다면 아래와 같이 파일을 수정합니다.

resource "aws_vpc" "vpc" {
  cidr_block       = var.vpc-cidr
  instance_tenancy = "default"
  enable_dns_hostnames = "true"

  tags = {
    Name = "${var.resource-prefix}-vpc" -> "${var.resource-prefix}-sample-vpc" 로 수정 후 저장합니다.
  }
}

수정 완료 후 terraform plan 명령어를 통해 적용할 변경사항을 미리 확인합니다. vpc 의 tags 값에 대한 수정이 진행될 것을 확인할 수 있습니다. terraform apply 명령어를 통해 실제로 적용해봅니다.

변경사항이 정상적으로 반영되었는지 확인합니다.

위의 예에서는 vpc tag를 수정하였기 때문에 기존 vpc에서 수정만 진행되었지만, 해당 속성이 리소스를 최초 생성할 때에만 설정이 가능한 옵션이라면 기존 리소스가 삭제 후 새로운 리소스가 생성됩니다. 예를 들어 vpc cidr 블록 값을 변경하는 경우 기존 vpc 상에서 수정이 불가능하기 때문에 기존 vpc가 삭제되고 새로운 vpc가 생성됩니다.

variable "resource-prefix" {
  default = "premisan-test"
}

variable "vpc-cidr" {
  default = "10.0.0.0/16" -> "172.16.0.0/16" 으로 수정 후 저장합니다.
}

기존 vpc 삭제와 새로운 vpc 생성이 정상적으로 이뤄졌는지 확인합니다.

리소스 삭제

테라폼을 통해 구성한 리소스를 삭제하기 위해서는 리소스 생성과 유사하게 terraform plan --destroy 명령어를 통해 제거될 리소스 내역을 확인하고, terraform destroy 명령어를 통해 리소스를 삭제합니다.

 

테라폼을 통해 AWS 인프라를 관리하는 것의 이점

1. 동일한 아키텍쳐를 반복하여 빠르게 생성

사실 복잡한 인프라 구성을 코드로 새로 작성하는 것은 콘솔을 통해 수작업으로 리소스를 생성하는 것보다 많은 시간이 소요됩니다. 하지만 한번 코드를 작성하면 반복되는 동일한 아키텍쳐에 대해 빠르고 정확한 생성이 가능한 장점이 있습니다.

2. 리소스간의 복잡한 의존 관계를 알아서 반영해줍니다.

테라폼은 리소스간의 의존 관계가 있는 경우 변경사항을 참조하여 변경된 값으로 수정되기 때문에 변경사항이 잦은 복잡한 아키텍쳐에서도 효과적으로 관리가 가능합니다. A -> B 로 리소스 간의 의존 관계가 있는 경우 A의 정보가 수정되면 의존 관계를 가진 B도 A의 변경사항을 반영하여 업데이트됩니다.

예를 들어, EC2 인스턴스가 포함된 로드밸런서의 대상 그룹이 있는 경우 EC2 인스턴스가 재생성되어 인스턴스 정보가 변경되는 경우 테라폼은 로드밸런서의 대상 그룹에 사용된 인스턴스 정보도 함께 수정합니다. 즉 변경된 리소스에 대한 의존 관계가 있는 다른 리소스 정보까지 함께 수정이 됩니다.

3. 멀티 클라우드 환경에 적합합니다.

테라폼은 오픈소스이며, AWS 뿐만 아니라 다양한 퍼블릭 클라우드, kubernetes 등의 컨테이너 환경 등을 지원합니다. 때문에 테라폼의 기본 사용법만 알면 다른 클라우드 환경이나, 다른 플랫폼 환경에서도 응용이 가능합니다.

샘플 코드와 코드 작성을 위한 참고

첨부 파일로 vpc, ec2, elb, acm(인증서 생성), keypair 등을 생성하는 샘플 코드를 작성하였습니다. 테스트해보실 분은 첨부파일을 모두 다운로드하여 별도의 디렉토리에 넣고 terraform plan 명령어로 생성될 리소스를 확인해보시기 바랍니다. 첨부된 파일의 코드는 모두 테스트용 샘플이기 때문에 다양하게 응용해서 사용하셔도 좋을 것 같습니다.

테라폼으로 AWS 리소스를 제어하기 위한 AWS 공급자 사용 가이드(https://registry.terraform.io/providers/hashicorp/aws/latest/docs)에 서비스별 코드 작성 방법과 예시, 옵션 등이 상세히 기재되어 있으니 코드 작성 시 참고 부탁드립니다.

샘플 코드 해석

아래부터는 첨부된 샘플 코드에 대한 해석입니다.

1) variable.tf -> 전체 인프라에 사용될 사용자 지정 변수값을 정의하는 파일입니다. 각자 환경에 맞게 수정하여 진행합니다.

variable "resource-prefix" {
  default = "premisan-test" -> 리소스 생성 시 공통적으로 붙을 접두사, AWS의 다른 리소스와 구분짓기 위함
}

variable "vpc-cidr" {
  default = "10.0.0.0/16" -> vpc cidr 범위, 서브넷의 범위도 이것을 토대로 지정됩니다.
}

variable "my-ip" {
  default = "2.2.2.2/32" -> 기본 보안그룹에 포함시킬 현재 사용자의 공인 아이피, ssh 접속을 위함
}

variable "instance-type" {
  default = "t3a.small" -> ec2 인스턴스 생성 시 인스턴스 타입
}

variable "domain" {
  default = "premisan01.shop" -> elb에 적용할 acm 인증서의 도메인, 해당 도메인과 *.도메인이 자동으로 포함
}

2) vpc.tf -> vpc 를 포함한 AWS 네트워크 리소스를 생성하는 코드입니다.

resource "aws_vpc" "vpc" { -> vpc를 생성하는 블록입니다.
  cidr_block       = var.vpc-cidr -> 변수 파일에 정의한 cidr 블록 값을 불러옵니다.
  instance_tenancy = "default"
  enable_dns_hostnames = "true"

  tags = {
    Name = "${var.resource-prefix}-vpc" -> "변수_파일에_정의한_접두사-vpc"의 형태로 vpc 이름이 생성됩니다.
  }
}

resource "aws_subnet" "public1" { -> 서브넷을 생성하는 블록입니다.
  vpc_id     = aws_vpc.vpc.id -> 위에서 작성한 vpc의 id 값을 불러와 서브넷이 생성될 vpc를 지정합니다.
  availability_zone = "ap-northeast-2a"
  cidr_block = replace(var.vpc-cidr, "0.0/16", "10.0/24") -> replace 함수를 이용하여 vpc의 cidr 블록에서 24비트 서브넷 대역을 만듭니다.
  map_public_ip_on_launch = "true"

  tags = {
    Name = "${var.resource-prefix}-public1"
  }
}

resource "aws_subnet" "public2" {
  vpc_id     = aws_vpc.vpc.id
  availability_zone = "ap-northeast-2c"
  cidr_block = replace(var.vpc-cidr, "0.0/16", "20.0/24")
  map_public_ip_on_launch = "true"

  tags = {
    Name = "${var.resource-prefix}-public2"
  }
}

resource "aws_subnet" "private1" {
  vpc_id     = aws_vpc.vpc.id
  availability_zone = "ap-northeast-2a"
  cidr_block = replace(var.vpc-cidr, "0.0/16", "11.0/24")
  map_public_ip_on_launch = "false"

  tags = {
    Name = "${var.resource-prefix}-private1"
  }
}

resource "aws_subnet" "private2" {
  vpc_id     = aws_vpc.vpc.id
  availability_zone = "ap-northeast-2c"
  cidr_block = replace(var.vpc-cidr, "0.0/16", "21.0/24")
  map_public_ip_on_launch = "false"

  tags = {
    Name = "${var.resource-prefix}-private2"
  }
}

resource "aws_internet_gateway" "igw" { -> 인터넷 게이트웨이를 생성합니다.
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.resource-prefix}-igw"
  }
}

resource "aws_route_table" "public-rtb" { -> 라우팅 테이블을 생성합니다.
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "${var.resource-prefix}-public-rtb"
  }
}

resource "aws_route_table" "private-rtb" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.resource-prefix}-private-rtb"
  }
}

resource "aws_route_table_association" "public1" { -> 위에서 생성한 라우팅테이블에 서브넷을 연결합니다.
  subnet_id      = aws_subnet.public1.id
  route_table_id = aws_route_table.public-rtb.id
}

resource "aws_route_table_association" "public2" {
  subnet_id      = aws_subnet.public2.id
  route_table_id = aws_route_table.public-rtb.id
}

resource "aws_route_table_association" "private1" {
  subnet_id      = aws_subnet.private1.id
  route_table_id = aws_route_table.private-rtb.id
}

resource "aws_route_table_association" "private2" {
  subnet_id      = aws_subnet.private2.id
  route_table_id = aws_route_table.private-rtb.id
}

resource "aws_security_group" "default-sg" { -> vpc에 보안그룹을 생성합니다.
  name        = "${var.resource-prefix}-sg"
  description = "${var.resource-prefix}-default-sg"
  vpc_id      = aws_vpc.vpc.id

  ingress {
    description = "All from VPC" -> vpc 내부 리소스 간 모든 프로토콜 통신을 허용합니다.
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = [aws_vpc.vpc.cidr_block]
  }

  ingress {
    description = "ssh from my-ip" -> 변수 파일에 지정한 현재 사용자의 아이피에 대해 ssh 접속을 허용합니다.
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my-ip]
  }

  ingress {
    description = "ssh from office" -> 사무실에서 ssh 접속을 허용합니다.
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["1.1.1.1/32"]
  }

  egress { -> 아웃바운드 트래픽으로 기본값으로 모두 허용입니다.
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.resource-prefix}-sg"
  }
}

3) keypair.tf -> 사용자 PC의 퍼블릭 키를 이용하여 키페어를 생성합니다. 퍼블릭 키의 경로는 "~/.ssh/id_rsa.pub"로 명시해놨지만, 수정하여 다른 경로의 파일을 업로드할 수도 있습니다. 아직 생성된 퍼블릭 키가 없는 경우 ssh-keygen -b 2048 -t rsa -f ~/.ssh/id_rsa -q -N "" 명령어를 이용하여 퍼블릭 키를 새로 생성하는 작업이 필요합니다.

resource "aws_key_pair" "keypair" {
  key_name = var.resource-prefix
  public_key = file("~/.ssh/id_rsa.pub")
}

4) ec2.tf -> 최신 centos 이미지를 이용하여 ec2 인스턴스를 생성합니다.

data "aws_ami" "centos" { -> data 구문은 AWS의 리소스를 쿼리하는데 사용됩니다. 
owners      = ["679593333241"]
most_recent = true

  filter { -> 필터를 이용하여 원하는 이미지를 검색하고 그 결과를 가져옵니다.
      name   = "name"
      values = ["CentOS Linux 7 x86_64 HVM EBS *"]
  }

  filter {
      name   = "architecture"
      values = ["x86_64"]
  }

  filter {
      name   = "root-device-type"
      values = ["ebs"]
  }
}

resource "aws_instance" "web" { -> 생성할 EC2 인스턴스에 대한 정보입니다.
  ami           = data.aws_ami.centos.id -> 위에서 불러온 최신의 centos 이미지에 대한 id 값입니다.
  key_name = aws_key_pair.keypair.key_name -> 위에서 업로드한 키페어의 이름을 불러옵니다.
  vpc_security_group_ids = [aws_security_group.default-sg.id] -> 위에서 생성한 보안그룹을 불러옵니다.
  instance_type = var.instance-type -> 변수파일에 지정한 인스턴스 타입을 불러옵니다.
  subnet_id = aws_subnet.public1.id -> vpc 파일로 생성한 퍼블릭 서브넷1의 id를 불러옵니다.
  user_data = file("user-data.sh") -> 사용자 PC의 파일을 유저 데이터로 업로드 가능합니다.

  root_block_device {
      delete_on_termination = "true" -> 인스턴스 종료 시 EBS 볼륨에 대한 삭제 설정입니다.
  }

  tags = {
    Name = "${var.resource-prefix}-web"
  }
}

5) acm.tf -> 현재 Route53에 도메인은 등록되어 있으나, SSL 인증서가 없는 경우 인증서를 생성합니다.

resource "aws_acm_certificate" "cert" { -> 생성할 인증서 정보입니다.
  domain_name       = var.domain -> 변수 파일에 지정한 도메인을 불러옵니다.
  subject_alternative_names = ["*.${var.domain}"] -> 추가 도메인으로 *.도메인을 지정합니다.
  validation_method = "DNS"
}

data "aws_route53_zone" "zone" { -> data 구문으로 변수 파일에 지정한 도메인의 호스팅 영역을 불러옵니다.
  name         = var.domain
  private_zone = false
}

resource "aws_route53_record" "dv-record" { -> 인증서 발급을 위한 인증에 필요한 레코드 값을 route53에 생성합니다.
  for_each = {
    for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.zone.zone_id
}

resource "aws_acm_certificate_validation" "dv" { -> 인증서 발급을 위한 인증에 대한 리소스입니다.
  certificate_arn         = aws_acm_certificate.cert.arn
  validation_record_fqdns = [for record in aws_route53_record.dv-record : record.fqdn]
}

6) elb.tf -> 위에서 생성한 ec2 인스턴스를 대상 그룹으로 설정하여 ALB를 생성합니다.

resource "aws_lb" "web-alb" { -> 생성할 alb에 대한 정보입니다.
  name               = "${var.resource-prefix}-web-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.web-alb-sg.id]
  subnets            = [aws_subnet.public1.id,aws_subnet.public2.id]
}

resource "aws_security_group" "web-alb-sg" { -> alb 전용 보안그룹을 생성합니다.
  name        = "${var.resource-prefix}-web-alb-sg"
  description = "${var.resource-prefix}-web-alb-sg"
  vpc_id      = aws_vpc.vpc.id

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

  ingress {
    description = "HTTPS from All"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.resource-prefix}-web-alb-sg"
  }
}

resource "aws_lb_target_group" "web-alb-tg" { -> alb의 대상 그룹을 생성합니다.
  name     = "${var.resource-prefix}-web-alb-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.vpc.id
}

resource "aws_lb_target_group_attachment" "web-alb-tga" { -> 위에서 생성한 ec2 인스턴스를 alb 대상그룹에 포함합니다.
  target_group_arn = aws_lb_target_group.web-alb-tg.arn
  target_id        = aws_instance.web.id
  port             = 80
}

data "aws_acm_certificate" "cert" { -> data 구문을 이용하여 AWS ACM에서 현재 사용 가능한 인증서 리스트 중 변수 파일에 지정한 도메인과 일치하는 인증서 정보를 가져옵니다.
  domain   = var.domain
  statuses = ["ISSUED"]
}

resource "aws_lb_listener" "web-alb-http" { -> alb의 http 리스너를 설정합니다.
  load_balancer_arn = aws_lb.web-alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "redirect" -> 80으로 수신한 트래픽을 443으로 리다이렉팅합니다.

    redirect {
      port            = "443"
      protocol      = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "web-alb-https" { -> alb의 https 리스너를 설정합니다. 
  load_balancer_arn = aws_lb.web-alb.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = data.aws_acm_certificate.cert.arn -> 위에서 불러온 인증서의 arn 값입니다.

  default_action {
    type             = "forward" -> 443으로 수신한 트래픽을 위에서 생성한 대상 그룹으로 전달합니다.
    target_group_arn = aws_lb_target_group.web-alb-tg.arn
  }
}

결론

위와 같이 AWS 리소스에 대한 코드를 구성하고 테라폼을 이용하여 AWS상에 리소스를 적용하는 작업을 해보았습니다. 테라폼을 이용하는 경우 변경되는 사항에 대해 직관적으로 확인이 가능하고, 클라우드포메이션에 비해 템플릿을 수정하고 배포하는 과정이 더 빠르고 쉽게 느껴졌습니다. 위에 업로드한 예시 및 공식 가이드를 응용하여 다양한 인프라 구성이 가능할 것으로 생각됩니다.