AWS VPC Network with Terraform

지난 블로그 AWS VPC basic 에서 AWS VPC Network에 대한 기초지식을 살펴 봤었는데 AWS VPC에 대해 공부하게 된 계기 중에 하나가 Terraform이었다.

지금 다니고 있는 회사에서는 Terraform을 이용해서 AWS 리소스를 관리하고 있는데 새롭게 접한 Terraform이 쉽사리 익숙해지지 않아 개인 AWS 계정을 통해 Terraform을 연습해 보고자 시도를 했었다.

그런데 막상 Terraform을 이용해 VPC를 생성하려고 보니 AWS VPC의 각각의 리소스에 대해 명확한 이해가 되어있지 않아 Terraform파일 작성이 쉽지 않았다.

지난 글을 통해서 AWS VPC를 정리하면서 이제 AWS VPC에 대해 어느정도 감을 잡았다고 생각하고 해당 지식을 기반으로 Terraform을 이용해서 VPC를 생성해 보려고 한다.

Terraform으로 VPC를 생성해보자.

먼저 구글에서 Terraform AWS VPC 의 키워드로 검색해보면, 다음과 같은 글들을 쉽게 찾을 수 있다.

Terraform으로 AWS VPC 생성하기 :: Outsider’s Dev Story
Terraform으로 VPC 설정하기
Create AWS VPC with Terraform – InsidePacket
Building VPC with Terraform in Amazon AWS – DevOps
Terraform: AWS VPC with Private and Public Subnets — Nick Charlton

이렇게 참고할 수 있는 자료가 많이 있는데도 불구하고 굳이 블로그에 새로이 정리하는 이유는 몇일 전 AWS VPC basic이라는 글을 쓰고보니 다른 사람이 작성한 글을 읽어 보는 것실제로 내가 실행해보고 정리해서 설명하는 것 은 차원이 다르다. 라는 걸 깨닫게 되었기 때문이다…라는 건 왠지 멋지게 보이려고 써 본 이유고, 실은 블로그 글 10개를 쓰면 스스로에게 산토리 가쿠빈을 선물로 주자! 하고 결심했기 때문에…(이 글을 다쓰면 8번만 더 쓰면 된다! 파이팅!)

그런데 Terraform은 과연 무엇인가?

Terraform
TerraformHashiCorp에서 만든 인프라스트럭처 관리도구다.
홈페이지에는 Write, Plan, and Create Infrastructure as Code 라는 슬로건이 큼지막하게 화면 중앙을 차지하고 있다. 말 그대로 인프라스트럭처를 GUI가 아닌코드를 통해 생성, 수정, 삭제할 수 있다. 이 코드는 일반적으로 개발자들이 작성하는 소스코드처럼 (대부분) VCS를 통해 관리되고, 코드가 변하지 않는 한 언제든 동일한 설정 값(상태)으로 새로운 인프라를 생성할 수 있다.

Terraform은 특정 벤더(프로바이더)에 종속되어 있지 않기 때문에 Providers 페이지를 보면 Alicloud, AWS, DigitalOcean, Google Cloud, OpenStack등의 리소스를 Terraform Configuration코드를 통해 관리할 수 있음을 알 수 있다. 클라우드 리소스뿐만 아니라 DNS, Consul, Github등의 다양한 infrastructure를 코드로 관리할 수 있다.

Terraform이 사용하는 Configuration 파일은 Hashicorp가 만든 HCL – HashiCorp configuration language 포맷의 파일을 .tf 확장자로 생성하거나, JSON 포맷의 파일을 .tf.json 확장자로 생성해서 사용할 수 있다.

Terraform에 대한 좀 더 자세한 설명은 @outsider 님의 Terraform에 대해서… :: Outsider’s Dev StoryTerraform으로 AWS 관리하기 :: Outsider’s Dev Story를 읽어보는 것을 추천한다.

Terraform 사용 준비하기

brew를 이용한 설치

맥을 이용하는 경우, 다음과 같이 brew를 이용해서 간단히 설치할 수 있다.

$ brew install terraform

바이너리 파일을 이용한 설치

Download Terraform 에서 Terraform을 다운받는다.
다운받은 zip파일의 압축을 풀면, terraform 바이너리 파일이 짠! 하고 나온다.
HashiCorp의 대부분의 툴들과 마찬가지로 Terraform도 golang으로 만들어졌기 때문에 별도의 설치 과정없이 실행환경에 맞는 바이너리 파일을 받아 path에 위치시키고 사용하면 된다.

나는 terraform 바이너리 파일의 심볼릭링크를 /usr/local/bin 아래에 생성했다.

$ which terraform
/usr/local/bin/terraform

$ terraform -v
Terraform v0.10.5

Terraform에서 사용할 IAM 생성

AWS Console IAM – Users 메뉴에서 Add user를 선택하고 User를 생성한다.

User 명, access type 선택

add_user1
terraform이라는 이름으로 User를 생성한다. AWS Console에는 사용하지 않고, API 호출만 사용하는 User이므로 Access type은 programmatic access를 선택한다.

Policy 선택

add_user2
Terraform이 사용할 policy를 추가한다.
이번에는 VPC 생성을 할 예정이므로 AmazonVPCFullAccess를 선택했다.
처음부터 AdministratorAccess policy를 줄 수도 있지만 좀 더 안전하게 필요한 policy를 그때 그때 추가하는 방법이 좋다고 생각한다.

Review

add_user3
Review에서 생성할 내용을 확인하고 Create user를 클릭해서 User생성을 완료한다.

Key 확인

add_user4
Terraform에 세팅할 terraform 계정의 Access key IDSecret access key(Show 버튼을 눌러 확인)를 기록해둔다.

Terraform이 사용할 Access key ID, Secret access key 세팅

Terraform에 AWS IAM을 적용할 때는 환경변수에 세팅, provider configuration에 직접 세팅, *.tfvar를 사용한 세팅 등의 방법이 있다.
나는 direnv를 사용하기 때문에 project root 경로에 .envrc 파일을 다음과 같이 작성하고 환경변수를 이용해 IAM을 적용한다.

# terraform personal AWS
export AWS_ACCESS_KEY_ID={유저 생성시 할당받은 Access key ID}
export AWS_SECRET_ACCESS_KEY={유저 생성 시 할당받은 Secret access key}
export AWS_DEFAULT_REGION=ap-northeast-1

direnv는 디렉터리 기반으로 환경변수를 설정할 수 있어 프로젝트 마다 별도의 env를 지정해서 사용해야할 경우 유용하다. 이전에는 direnv없이 어떻게 개발했었나 싶다.

프로젝트 디렉터리 생성

Terraform configuration file(이하 Terraform 파일)을 작성하기 위해서 2dal-infrastrucure라는 디렉터리를 생성하고, 다음과 같이 구조를 잡았다.

$ tree
.
├── README.md
└── terraform
    ├── common # 공용 리소스 정의
    │   ├── iam # iam 정의
    │   └── dev_vpc # dev VPC 정의
    └── project # 프로젝트 별 리소스 정의

Terraform으로 VPC 생성하기

AWS VPC basic 글에서 설계했던 VPC Network를 Terraform을 이용해서 생성해 보자.
AWS VPC3

개발에 사용할 목적의 VPC이므로 VPC 이름을 dev VPC로 생성한다.
두개의 AZ위에 public / private으로 구분한 subnet을 올리고 public subnet에는 외부로 나가는 트래픽을 위해 NAT – IGW를 연결했다. 그림에는 보이지 않지만 각각의 연결단계에서 라우팅테이블로 아웃바운드 룰을 정의한다.

AWS 리소스에 대해서는 Provider: AWS 페이지에 정리되어 있으므로, 이 페이지를 참고해서 각각의 리소스에 해당하는 Terraform 파일을 작성해보자.

./terraform/common/dev_vpc 경로에서 Terraform파일을 작성한다.

$ mkdir -p terraform/common/dev/dev_vpc
$ cd terraform/common/dev_vpc

Provider

  • provider.tf
provider "aws" {
  region = "ap-northeast-1"
}

AWS provider를 정의한다.
나는 도쿄리전을 사용하기 때문에 region 항목에 ap-northeast-1 을 설정했다.

terraform파일을 작성 후에 execution plan을 생성하고, 리뷰(변경사항을 체크)할 수 있는 terraform plan 명령을 수행해보면

$ terraform plan
Plugin reinitialization required. Please run "terraform init".
Reason: Could not satisfy plugin requirements.
...

위와 같이 오류가 발생한다. terraform plan, apply등의 명령을 실행하기 앞서서 provider plugin설치 등의 초기셋업을 위해 terraform init을 실행해야 한다.

terraform init을 실행한다.

$ terraform init
...
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.
...

이제 terraform plan을 실행해보면 변경(적용)할 내용이 없다는 메시지가 정상적으로 출력된다.

No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.

VPC

  • vpc.tf
resource "aws_vpc" "dev" {
  cidr_block           = "172.16.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  instance_tenancy     = "default"

  tags {
    Name = "dev"
  }
}

dev VPC를 정의한다.
enable_dns_xxxxx 옵션은 EC2 인스턴스에 DNS 호스트 이름을 사용하기위해 true로 세팅한다.
instance_tenancy 옵션은 dedicated로 설정한 경우 Amazon EC2 Dedicated Instances 의 설명처럼 유저 전용의 하드웨어에서 EC2 instance를 생성한다. dev VPC에서는 default로 값을 설정한다

Subnet

AWS VPC3
subnet.tf 파일 작성 전에 생성할 VPC Subnet을 한번 더 확인 해보자.

다음과 같이 2개의 AZ에 public, private subnet을 각각 1개씩 생성한다.

AZ: ap-northeast-1a
    public  subnet: 172.16.1.0/24
    private subnet: 172.16.101.0/24
AZ: ap-northeast-1c
    public  subnet: 172.16.2.0/24
    private subnet: 172.16.102.0/24
  • subnet.tf
resource "aws_subnet" "public_1a" {
  vpc_id            = "${aws_vpc.dev.id}"
  availability_zone = "ap-northeast-1a"
  cidr_block        = "172.16.1.0/24"

  tags {
    Name = "public-1a"
  }
}

resource "aws_subnet" "private_1a" {
  vpc_id            = "${aws_vpc.dev.id}"
  availability_zone = "ap-northeast-1a"
  cidr_block        = "172.16.101.0/24"

  tags {
    Name = "private-1a"
  }
}

resource "aws_subnet" "public_1c" {
  vpc_id            = "${aws_vpc.dev.id}"
  availability_zone = "ap-northeast-1c"
  cidr_block        = "172.16.2.0/24"

  tags {
    Name = "public-1c"
  }
}

resource "aws_subnet" "private_1c" {
  vpc_id            = "${aws_vpc.dev.id}"
  availability_zone = "ap-northeast-1c"
  cidr_block        = "172.16.102.0/24"

  tags {
    Name = "private-1c"
  }
}

${aws_vpc.dev.id} 는 aws_vpc의 dev리소스로부터 id값을 가져와서 세팅한다.
resource name은 {aws_subnet.public_1a.id} 와 같이 작성하기 쉽도록 underscore를 사용했다.

Internet Gateway (IGW)

  • internet_gateway.tf
resource "aws_internet_gateway" "dev" {
  vpc_id = "${aws_vpc.dev.id}"

  tags {
    Name = "dev"
  }
}

dev VPC에서 사용할 IGW를 정의한다. IGW는 AZ에 무관하게 한개의 IGW를 공유해서 사용할 수 있다.

Elastic IP (EIP)

  • eip.tf
resource "aws_eip" "nat_dev_1a" {
  vpc = true
}

resource "aws_eip" "nat_dev_1c" {
  vpc = true
}

각각의 AZ의 NAT에서 사용할 EIP를 정의한다.
vpc = true 항목은 aws_eip 페이지에 별도의 설명은 없지만 EIP 생성 시 EIP의 scope를 VPC로 할지 classic으로 할지 물어봤던 옵션을 의미하는 것으로 추측된다.

NAT Gateway

  • nat_gateway.tf
resource "aws_nat_gateway" "dev_1a" {
  allocation_id = "${aws_eip.nat_dev_1a.id}"
  subnet_id     = "${aws_subnet.public_1a.id}"
}

resource "aws_nat_gateway" "dev_1c" {
  allocation_id = "${aws_eip.nat_dev_1c.id}"
  subnet_id     = "${aws_subnet.public_1c.id}"
}

설정파일을 작성하다보니 NAT도 IGW처럼 한개를 공유해서 사용하는지, 아니면 AZ별로 각각 NAT를 생성해야 하나 의문이 생겼었는데 NAT 게이트웨이 – Amazon Virtual Private Cloud 가이드 문서에 따르면 다음과 같이 가용영역(AZ) 별로 NAT 게이트웨이를 사용해야 복수의 AZ를 사용하는 장점을 같이 가져갈 수 있음을 알 수 있다.

여러 가용 영역에 리소스가 있고 하나의 NAT 게이트웨이를 공유하는 경우 NAT 게이트웨이의 가용 영역이 다운되면 다른 가용 영역의 리소스도 인터넷에 액세스할 수 없습니다. 가용 영역과 독립적인 아키텍처를 만들려면 각 가용 영역에 NAT 게이트웨이를 만들고 리소스가 동일한 가용 영역의 NAT 게이트웨이를 사용하도록 라우팅을 구성합니다.

NAT Gateway는 왠지 tags(Name)를 넣을 수 없었다. (Console에서는 잘 넣어짐)

Route Table

  • route_table.tf
# dev_public
resource "aws_route_table" "dev_public" {
  vpc_id = "${aws_vpc.dev.id}"

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

  tags {
    Name = "dev-public"
  }
}

resource "aws_route_table_association" "dev_public_1a" {
  subnet_id      = "${aws_subnet.public_1a.id}"
  route_table_id = "${aws_route_table.dev_public.id}"
}

resource "aws_route_table_association" "dev_public_1c" {
  subnet_id      = "${aws_subnet.public_1c.id}"
  route_table_id = "${aws_route_table.dev_public.id}"
}

# dev_private_1a
resource "aws_route_table" "dev_private_1a" {
  vpc_id = "${aws_vpc.dev.id}"

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = "${aws_nat_gateway.dev_1a.id}"
  }

  tags {
    Name = "dev-private-1a"
  }
}

resource "aws_route_table_association" "dev_private_1a" {
  subnet_id      = "${aws_subnet.private_1a.id}"
  route_table_id = "${aws_route_table.dev_private_1a.id}"
}

# dev_private_1c
resource "aws_route_table" "dev_private_1c" {
  vpc_id = "${aws_vpc.dev.id}"

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = "${aws_nat_gateway.dev_1c.id}"
  }

  tags {
    Name = "dev-private-1c"
  }
}

resource "aws_route_table_association" "dev_private_1c" {
  subnet_id      = "${aws_subnet.private_1c.id}"
  route_table_id = "${aws_route_table.dev_private_1c.id}"
}

public subnet -> IGW
private subnet -> NAT Gateway 로 0.0.0.0/0 outbound를 라우팅하는 route_table을 정의하고 해당 route_table을 적용할 subnet에 route table association을 만들어준다.

만약 여기까지 작성하고 terraform apply를 수행하면

아래와 같이 default Network ACL, default Security Group이 자동 생성된다.

  • dev VPC의 default Network ACL
    • 4개의 subnet과 associated된 (0.0.0.0/0 모든포트로 in/out bound 모두 허용)
  • dev VPC의 default Security Group
    • 동일 security group내에서 모든포트로 inbound 허용
    • 0.0.0.0/0 모든포트로 outbound 허용

보안 – Amazon Virtual Private Cloud 페이지의 설명을 보면
AWS resource의 관점에서 SG를 1차 보안 계층(인스턴스 수준)으로, NACL을 2차 보안 계층(서브넷 수준)으로 나누고 있다.
또한 NACL의 경우 stateless하기 때문에 inbound로 들어온 트래픽의 응답도 별도로 ACL을 열어줘야 전송이 가능하다. SG의 경우 stateful하기 때문에 inbound만 열어주면 해당 요청의 응답이 별도 설정없이 가능해진다.

이대로 자동 생성된 NACL, SG를 사용할 수도 있지만, Terraform으로 리소스를 관리하기로 한 이상 default 리소스에 대해서도 Terraform으로 정의해서 사용하는 것이 좋겠다는 생각이 든다. 가급적이면 Terraform에 정의되지 않은 리소스는 AWS에 존재하지 않도록 하려고 생각하고 있다.
default NACL, SG의 terraform 정의는 @outsider 님의 Terraform으로 AWS VPC 생성하기 :: Outsider’s Dev Story 포스팅을 참고해서 작성했다.

Network ACL (NACL)

  • nacl.tf
resource "aws_default_network_acl" "dev_default" {
  default_network_acl_id = "${aws_vpc.dev.default_network_acl_id}"

  ingress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  subnet_ids = [
    "${aws_subnet.public_1a.id}",
    "${aws_subnet.public_1c.id}",
    "${aws_subnet.private_1a.id}",
    "${aws_subnet.private_1c.id}",
  ]

  tags {
    Name = "dev-default"
  }
}

Security Group (SG)

  • security_group.tf
resource "aws_default_security_group" "dev_default" {
  vpc_id = "${aws_vpc.dev.id}"

  ingress {
    protocol  = -1
    self      = true
    from_port = 0
    to_port   = 0
  }

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

  tags {
    Name = "dev-default"
  }
}

Bastion

외부로부터 격리된 private subnet에 ssh로 접근하기 위해서 Bastion instance를 생성한다.
EC2 instance 생성을 위해서 AWS IAM – Users 메뉴에서 Add permissions 버튼을 클릭하고 다음과 같이 EC2 Full Access policy를 추가한다.
ec2 full access

필요에 따라 Bastion instance도 AZ별로 구축할 수 있지만, 이번에는 public-1a subnet에만 구축하도록 하겠다.
Bastion은 SG + EIP + EC2 instance를 하나의 세트로 보고 bastion.tf 파일에 관련 리소스를 모두 정의했다.

  • bastion.tf
resource "aws_security_group" "bastion" {
  name        = "bastion"
  description = "open ssh port for bastion"

  vpc_id = "${aws_vpc.dev.id}"

  ingress {
    from_port   = 22
    to_port     = 22
    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 = "bastion"
  }
}

resource "aws_eip" "bastion_1a" {
  instance = "${aws_instance.bastion_1a.id}"
  vpc      = true
}

resource "aws_instance" "bastion_1a" {
  ami               = "${var.amazon_linux}"
  availability_zone = "ap-northeast-1a"
  instance_type     = "t2.nano"
  key_name          = "${var.dev_keyname}"

  vpc_security_group_ids = [
    "${aws_security_group.bastion.id}",
    "${aws_default_security_group.dev_default.id}",
  ]

  subnet_id                   = "${aws_subnet.public_1a.id}"
  associate_public_ip_address = true

  tags {
    Name = "bastion-1a"
  }
}

bastion instance는 SSH를 터널링하는 용도로만 사용하기 때문에 t2.nano로 정의했다. key_name에는 AWS EC2Key Pairs에서 생성한 Key 이름을 입력한다.
vpc_security_group_ids에는 bastion SG와 함께 dev VPC subnet의 다른 인스턴스(private subnet의 인스턴스)에 접속하기 위해 dev VPC default SG를 추가했다.

AMI 버전, dev VPC에서 사용할 key name을 한 곳에서 관리할 수 있도록 다음과 같이 variable로 정의해서 사용한다.

  • variable.tf
variable "amazon_linux" {
  # Amazon Linux AMI 2017.03.1 (HVM), SSD Volume Type - ami-4af5022c
  default = "ami-4af5022c"
}

variable "dev_keyname" {
  default = "2dal-dev"
}

Terraform fmt

terraform fmt 명령으로 tf파일의 포맷팅 표준에 맞게 수정할 수 있다.
명령 수행 후 수정된 파일 명이 화면에 출력된다.

$ terraform fmt
bastion.tf

Terraform plan

terraform plan을 실행해서 변경되는 내용에 대해서 리뷰한다.
포맷이 맞지 않거나, 존재하지 않는 resource type, name등을 사용하면 오류 메시지가 출력된다.

$ terraform plan
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + aws_default_network_acl.dev_default
...
Plan: 22 to add, 0 to change, 0 to destroy.

Terraform apply

이제 작성된 Terraform 파일을 terraform apply 명령으로 AWS에 적용한다.

$ terraform apply
...
Apply complete! Resources: 22 added, 0 changed, 0 destroyed.

terraform plan으로 한번 더 확인해 본다.

$ terraform plan
...
No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.

No changes가 얼마나 반가운 메시지인지 곧 알게 될 것이다.

접속 테스트

다음과 같이 Bastion을 통해 ubuntu기반의 private instance에 접속해본다.

$ ssh -i ~/.ssh/2dal-dev.pem -A ec2-user@{Bastion IP} -t ssh ubuntu@{Private instance IP}
...
Are you sure you want to continue connecting (yes/no)? yes
...
Welcome to Ubuntu 16.04.2 LTS (GNU/Linux 4.4.0-1022-aws x86_64)

접속이 잘된다!

magic

정리

와! 이제 끝났다! 글쓰기를 시작해서 작성하고, 실패하고, 정리하고 몇 일이 걸린 것 같다.
기다려라 산토리 가쿠빈!

Terraform을 이용해서 Multi A-Z, public/private subnet으로 분리된 기본적인 VPC Network를 생성해봤다. 설명에 사용된 파일은 GitHub – asbubam/2dal-infrastructure 에서 확인할 수 있다.

VPC Network를 어떻게 설계하는가에 따라 큰 그림은 달라지겠지만 세부적인 AWS 리소스, association의 정의는 크게 다르지 않을 것으로 생각된다.

Terraform으로 리소스를 정의하다보면 GUI 콘솔에서는 보이지 않거나 자동으로 생성되서 크게 신경쓰지 않았던 리소스들에 대해서, 그리고 리소스와 리소스가 연결되는 관계에 대해서 명확하게 알게되는 장점이 있다. (이점이 Terraform의 가장 큰 어려움이면서 또한 장점이라고 생각된다.)

앞으로 다양한 리소스를 Terraform을 통해 관리해보고, 그 내용을 정리해 나갈 수 있으면 좋겠다.

참고자료

Provider: AWS – Terraform by HashiCorp
NAT 게이트웨이 – Amazon Virtual Private Cloud
보안 – Amazon Virtual Private Cloud
AWS VPC를 디자인해보자(2) – ACL과 Security Group을 활용한 보안 강화
Terraform으로 AWS VPC 생성하기 :: Outsider’s Dev Story
Terraform으로 VPC 설정하기
Terraform: AWS VPC with Private and Public Subnets — Nick Charlton