Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Terraform으로 AWS VPC 생성하기

회사에서 Terraform을 계속 사용하고 있어서 연습 겸 개인 AWS 인프라도 모두 Terraform으로 최근에 갈아탔다. 어차피 새로 구성하는 것이므로 큰 부담 없이 테스트로 사용했다.

VPC

VPC는 Virtual Private Cloud를 의미한다. AWS에서 EC2 서버나 다른 리소스를 사용하려면 먼저 VPC를 생성해야 한다. 이 VPC로 AWS 내에서 사용할 내부 CIDR 대역을 지정하고 내부 네트워크를 구성할 수 있다. 이렇게 만든 VPC 내에서 외부에 점근 등을 제어할 수 있다. 이 글은 VPC의 개념까지 설명하기에는 어려우므로 AWS 문서AWS VPC를 디자인해보자를 읽어보자. 나 같은 경우에는 후자의 글이 꽤 많은 도움이 되었다.

Terraform으로 구성할 VPC

여기서는 Terraform으로 VPC를 구성하는 방법을 설명한다. Terraform으로 AWS 등의 리소스를 구성하는 것은 문서를 찾아가면서 손수 정의해야 하므로 꽤 고통스러운 작업이지만 Terraform으로 구성을 해보면 AWS 리소스에 대한 이해도가 아주 높아지는 것을 느낄 수 있다. API 등을 이용할 때도 비슷하겠지만 AWS 웹 콘솔에서 VPC를 만들면 그 개념을 잘 몰라도 GUI가 안내를 해주거나 빠진 부분을 설명해 주무로 대충 만지다 보면 구성할 수 있다.

Terraform으로 구성하려면 웹 콘솔보다 훨씬 세부적인 리소스로 나누어지고 각 리소스 간의 관계를 직접 지정해 주어야 하므로 서로 어떤 관계를 갖는지 이해도가 없으면 만들기가 어렵다. 그래서 Terraform으로 직접 리소스를 정의하다 보면 각 리소스에 대한 용도와 관계를 어쩔 수 없이 공부하게 되고 자연히 이해도가 높아진다. 이전 Terraform으로 AWS 관리하기에서 간단한 VPC를 만들어서 예시로 사용했지만, 실제 서비스하는 VPC를 만들려면 상당히 많은 리소스가 필요하다. VPC를 어떻게 구성하는가는 다양한 접근 방법이 가능하지만 가장 많이 사용하는 시나리오가 AWS 문서에 잘 나와 있다.

VPC는 직접 구성하는 것이므로 필요에 따라 원하는 대로 만들 수 있다. 여기서 만들 VPC는 AWS 문서에 나와 있는 퍼블릭 서브넷과 프라이빗 서브넷이 있는 VPC(NAT)의 구조와 가장 가까운 형태이고 특별한 요구사항이 없다면 가장 일반적인 구조라고 생각한다.

VPC 구성도

정확한 형태를 그리면 위와 같은 형태가 된다. 이 그림에도 많은 개념이 들어가 있는데 이글에서 모두 설명하기는 쉽지 않다. Terraform으로 구성하기 전에 VPC에 대해 어느 정도 이해를 하고 있어야 하므로 간단히 특징 부분만 설명한다.

  • Region 안에는 Availability Zone(AZ)이 여러 개 있다. 여기서는 2개를 사용하는데 보통 이중화를 할 때 다른 AZ에 같은 서버 및 구성을 두어 한쪽 AZ에 장애가 나더라도 문제없게 한다. 그래서 이 그림에서도 양쪽에 두 개의 AZ가 있다.
  • VPC를 생성하고 모든 자원은 이 VPC 안에 만든다.
  • VPC안에 퍼블릭 서브넷과 프라이빗 서브넷을 2개씩 만든다. 2개인 이유는 AZ마다 하나씩 만들기 때문이다.

    • 퍼블릭 서브넷은 외부에서 접근할 수 있고 내부에서도 VPC 밖의 인터넷으로 접근할 수 있는 서브넷이다. 퍼블릭 IP로 접근해야 하는 서버 등은 여기에 띄워야 한다.
    • 프라이빗 서브넷은 외부에서는 접근할 수 없고 VPC 내에서만 접근할 수 있다. 대부분의 서비스 서버는 여기에 둔다.
  • 퍼블릭 서브넷

    • 서브넷앞에 Network ACL을 둔다. Network ACL로 오가는 트래픽을 모두 제어할 수 있다.
    • Network ACL 앞에 Route Table을 둔다. 이는 Subnet 내의 트래픽을 어디로 갈지 정하는데 VPC의 CIDR은 모두 내부를 보도록 하고 외부로 나가는 트래픽은 Internet Gateway로 보내도록 한다.
    • Internet Gateway를 통해서 퍼블릭 서브넷의 아웃바운드 트래픽이 외부 인터넷으로 연결된다.
  • 프라이빗 서브넷

    • 이 서브넷에 RDS나 EC2 인스턴스를 둔다. 서버 자체는 외부에서 아예 접근이 안 되고 서비스는 ELB를 통해서 공개하므로 여기에 두는 것이 좋다.
    • 똑같이 서브넷 앞에 Network ACL과 Route Table을 둔다.
    • 프라이빗 서브넷은 외부에서 접근할 수 있지만 서브넷 내에서 외부에 접근은 가능해야 한다. 패키지를 설치하거나 소스를 가져오거나... 기본적으로 프라이빗 서브넷은 막혀 있으므로 Route Table로 아웃바운드 트래픽을 NAT Gateway로 연결한다.
    • NAT Gateway도 AWS에서 서비스로 제공하는데 이를 퍼블릭 서브넷 안에 만들어 두고 프라이빗 서브넷의 아웃바운드 트래픽은 이 NAT Gateway를 통해서 외부 인터넷으로 나가게 된다.
  • Bastion host

    • 바스티온 호스트는 네트워크에 접근하기 위한 서버를 의미한다.
    • VPC 자체를 네트워크단에서 접근을 제어하고 있으므로 퍼블릭 서브넷에 바스티온 호스트를 만들어두고 외부에서 SSH 등으로 접근할 수 있는 서버는 이 서버가 유일하다.
    • 프라이빗 서브넷이나 VPC 내의 자원에 접근하려면 바스티온 호스트에 접속한 뒤에 다시 접속하는 방식으로 사용한다.
    • Bastion Host도 이중화해서 AZ마다 한대씩 만들어 둘 수 있다.

Terraform으로 VPC 구성하기

Terrafrom으로 VPC를 구성해 보자.

resource "aws_vpc" "side_effect" {
  cidr_block  = "10.10.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support = true
  instance_tenancy = "default"

  tags {
    "Name" = "side effect"
  }
}

aws_vpc 리소스로 VPC를 정의한다. 여기서 side_effect라는 이름은 임의로 준 이름이다.(내 개인 프로젝트용 도메인이이라서...) 리소스 이름은 맘대로 사용할 수 있는데 나중에 aws_vpc.side_effect.id, aws_subnet,side_effect.id 같은 식으로 참조해서 사용하므로 같은 리소스 내에서만 이름이 충돌하지 않으면 된다. 그래서 여러 이름이 필요하지 않으면 연관된 리소스는 같은 이름을 쓰거나 접두사 형식으로 사용한다. 그리고 Terraform의 리소스 명이 모두 스네이크케이스(_로 단어를 이어붙이는..)를 사용하기 때문에 이름도 같은 방식을 사용하고 있다.

aws_vpc는 VPC를 하나 만든 것이다. 원하는 설정을 넣고 CIDR 대역을 10.10.0.0/16로 지정했다.

resource "aws_default_route_table" "side_effect" {
  default_route_table_id = "${aws_vpc.side_effect.default_route_table_id}"

  tags {
    Name = "default"
  }
}

aws_default_route_table는 좀 특수한 리소스이다. AWS에서 VPC를 생성하면 자동으로 route table이 하나 생긴다. 이는 Terraform으로 직접 생성하는 것이 아니므로 aws_default_route_table는 route table을 만들지 않고 VPC가 만든 기본 route table을 가져와서 Terraform이 관리할 수 있게 한다. 이 Route Table에 이름을 지정하고 관리하게 두기 위해서 Terraform으로 가져왔고 이 테이블과의 연결은 VPC에서 사용하는 다른 속성을 사용할 예정이다. 이는 뒤에서 좀 더 설명한다.

// public subnets
resource "aws_subnet" "side_effect_public_subnet1" {
  vpc_id = "${aws_vpc.side_effect.id}"
  cidr_block = "10.10.1.0/24"
  map_public_ip_on_launch = false
  availability_zone = "${data.aws_availability_zones.available.names[0]}"
  tags = {
    Name = "public-az-1"
  }
}

resource "aws_subnet" "side_effect_public_subnet2" {
  vpc_id = "${aws_vpc.side_effect.id}"
  cidr_block = "10.10.2.0/24"
  map_public_ip_on_launch = true
  availability_zone = "${data.aws_availability_zones.available.names[1]}"
  tags = {
    Name = "public-az-2"
  }
}

서브넷은 aws_subnet으로 만드는데 앞에서 말한 대로 퍼블릭 서브넷을 2개 만들고 각각 CIDR 대역을 지정했다. 그리고 퍼블릭 서브넷이므로 서버 등을 띄울 때 자동으로 퍼블릭 IP가 할당되도록 map_public_ip_on_launch를 지정했다.

availability_zone으로 두 서브넷이 다른 AZ에 생성하도록 했다.

> data.aws_availability_zones.available.names
[
  ap-northeast-1a,
  ap-northeast-1c
]

data "aws_availability_zones" "available" {}와 같은 데이터를 지정하면 아래와 같이 해당 리전의 AZ 이름을 가져올 수 있다. 여기서는 이 배열의 첫 번째와 두 번째를 각각 지정해서 이름에서 오타가 발생하거나 이름을 기억할 필요가 없게 한 것이다.

// private subnets
resource "aws_subnet" "side_effect_private_subnet1" {
  vpc_id = "${aws_vpc.side_effect.id}"
  cidr_block = "10.10.10.0/24"
  availability_zone = "${data.aws_availability_zones.available.names[0]}"
  tags = {
    Name = "private-az-1"
  }
}

resource "aws_subnet" "side_effect_private_subnet2" {
  vpc_id = "${aws_vpc.side_effect.id}"
  cidr_block = "10.10.11.0/24"
  availability_zone = "${data.aws_availability_zones.available.names[1]}"
  tags = {
    Name = "private-az-2"
  }
}

퍼블릭 서브넷과 똑같이 프라이빗 서브넷을 2개 만들었다.

resource "aws_internet_gateway" "side_effect_igw" {
  vpc_id = "${aws_vpc.side_effect.id}"
  tags {
    Name = "internet-gateway"
  }
}

aws_internet_gateway로 VPC에서 외부 인터넷에 접근하기 위한 인터넷 게이트웨이를 만들었다. 인터넷 게이트웨이를 AWS에서 제공하므로 이를 VPC 안에 만들면 된다.

// route to internet
resource "aws_route" "side_effect_internet_access" {
  route_table_id = "${aws_vpc.side_effect.main_route_table_id}"
  destination_cidr_block = "0.0.0.0/0"
  gateway_id = "${aws_internet_gateway.side_effect_igw.id}"
}

aws_route는 Route Table에 라우팅 규칙을 추가하는 리소스이다. 이 테이블을 aws_vpc.side_effect.main_route_table_id로 Route Table에 추가했다. main_route_table_id는 VPC의 기본 Route Table을 의미하고 이는 앞에서 살펴본 aws_default_route_table와 같은 테이블이다. aws_default_route_table.side_effect.id를 사용해도 같은 값이지만 의미가 더 명확해 보이는 main_route_table_id를 사용했다.

// eip for NAT
resource "aws_eip" "side_effect_nat_eip" {
  vpc = true
  depends_on = ["aws_internet_gateway.side_effect_igw"]
}

// NAT gateway
resource "aws_nat_gateway" "side_effect_nat" {
  allocation_id = "${aws_eip.side_effect_nat_eip.id}"
  subnet_id = "${aws_subnet.side_effect_public_subnet1.id}"
  depends_on = ["aws_internet_gateway.side_effect_igw"]
}

프라이빗 서브넷에서 외부 인터넷으로 요청을 내보낼 수 있도록 하는 NAT 게이트웨이다. NAT 게이트웨이에서 사용할 elastic IP를 하나 만들고 aws_nat_gateway에서 이 IP를 연결하고 퍼블릭 서브넷에 만들어지도록 했다. 둘다 인터넷 게이트웨이가 만들어진 뒤에 구성하려고 aws_nat_gateway에 의존성을 지정했다.

// private route table
resource "aws_route_table" "side_effect_private_route_table" {
  vpc_id = "${aws_vpc.side_effect.id}"
  tags {
    Name = "private"
  }
}

resource "aws_route" "private_route" {
  route_table_id = "${aws_route_table.side_effect_private_route_table.id}"
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id = "${aws_nat_gateway.side_effect_nat.id}"
}

프라이빗 서브넷에서 사용할 Route Table을 만들고 여기서 0.0.0.0/0으로 나가는 요청이 모두 NAT 게이트웨이로 가도록 설정했다.

// associate subnets to route tables
resource "aws_route_table_association" "side_effect_public_subnet1_association" {
  subnet_id = "${aws_subnet.side_effect_public_subnet1.id}"
  route_table_id = "${aws_vpc.side_effect.main_route_table_id}"
}

resource "aws_route_table_association" "side_effect_public_subnet2_association" {
  subnet_id = "${aws_subnet.side_effect_public_subnet2.id}"
  route_table_id = "${aws_vpc.side_effect.main_route_table_id}"
}

resource "aws_route_table_association" "side_effect_private_subnet1_association" {
  subnet_id = "${aws_subnet.side_effect_private_subnet1.id}"
  route_table_id = "${aws_route_table.side_effect_private_route_table.id}"
}

resource "aws_route_table_association" "side_effect_private_subnet2_association" {
  subnet_id = "${aws_subnet.side_effect_private_subnet2.id}"
  route_table_id = "${aws_route_table.side_effect_private_route_table.id}"
}

위에서 Route Table을 2개 만들었는데 이를 각각 퍼블릭/프라이빗 서브넷에 연결하는 과정이다. 퍼블릭 서브넷에는 메인 Route Table을 연결하고 Private 용으로 만들 Route Table은 프라이빗 서브넷에 연결했다.

// default security group
resource "aws_default_security_group" "side_effect_default" {
  vpc_id = "${aws_vpc.side_effect.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 = "default"
  }
}

aws_default_security_group은 VPC를 만들면 자동으로 만들어지는 기본 시큐리티 그룹을 Terraform에서 관리할 수 있도록 지정한 것이다. 이 리소스는 Terraform이 생성하지 않고 정보만 가져와서 연결한다.

resource "aws_default_network_acl" "side_effect_default" {
  default_network_acl_id = "${aws_vpc.side_effect.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
  }

  tags {
    Name = "default"
  }
}

aws_default_network_acl도 VPC를 만들 때 기본으로 만들어지는 네트워크 ACL이다. 이름을 지정하고 관리하기 위해서 가져왔지만, 이 ACL은 어떻게 설정해서 다뤄야 할지 애매해서 가져온 채로만 두고 그대로 놔두었다. 각 서브넷에서 사용할 네트워크 ACL을 추가로 만들어서 사용했다.

// network acl for public subnets
resource "aws_network_acl" "side_effect_public" {
  vpc_id = "${aws_vpc.side_effect.id}"
  subnet_ids = [
    "${aws_subnet.side_effect_public_subnet1.id}",
    "${aws_subnet.side_effect_public_subnet2.id}",
  ]

  tags {
    Name = "public"
  }
}

퍼블릭 서브넷에서 사용할 네트워크 ACL을 생성해서 퍼블릭 서브넷에 연결했다.

resource "aws_network_acl_rule" "side_effect_public_ingress80" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 100
  rule_action = "allow"
  egress = false
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 80
  to_port = 80
}

resource "aws_network_acl_rule" "side_effect_public_egress80" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 100
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 80
  to_port = 80
}

resource "aws_network_acl_rule" "side_effect_public_ingress443" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 110
  rule_action = "allow"
  egress = false
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 443
  to_port = 443
}

resource "aws_network_acl_rule" "side_effect_public_egress443" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 110
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 443
  to_port = 443
}

resource "aws_network_acl_rule" "side_effect_public_ingress22" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 120
  rule_action = "allow"
  egress = false
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 22
  to_port = 22
}

resource "aws_network_acl_rule" "side_effect_public_egress22" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 120
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "${aws_vpc.side_effect.cidr_block}"
  from_port = 22
  to_port = 22
}

resource "aws_network_acl_rule" "side_effect_public_ingress_ephemeral" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 140
  rule_action = "allow"
  egress = false
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 1024
  to_port = 65535
}

resource "aws_network_acl_rule" "side_effect_public_egress_ephemeral" {
  network_acl_id = "${aws_network_acl.side_effect_public.id}"
  rule_number = 140
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 1024
  to_port = 65535
}

퍼블릭 서브넷용 네트워크 ACL에 규칙을 추가한 부분이다. 네트워크 ACL을 기본적으로는 모든 포트가 막혀있으므로 필요한 부분을 열어야 하므로 80, 443, 22, ephemeral 포트를 인바운드/아웃바운드로 열어서 규칙을 추가했다.

// network acl for private subnets
resource "aws_network_acl" "side_effect_private" {
  vpc_id = "${aws_vpc.side_effect.id}"
  subnet_ids = [
    "${aws_subnet.side_effect_private_subnet1.id}",
    "${aws_subnet.side_effect_private_subnet2.id}"
  ]

  tags {
    Name = "private"
  }
}

프라이빗 서브넷에서 사용할 네트워크 ACL을 만들어서 서브넷에 연결했다.

resource "aws_network_acl_rule" "side_effect_private_ingress_vpc" {
  network_acl_id = "${aws_network_acl.side_effect_private.id}"
  rule_number = 100
  rule_action = "allow"
  egress = false
  protocol = -1
  cidr_block = "${aws_vpc.side_effect.cidr_block}"
  from_port = 0
  to_port = 0
}

resource "aws_network_acl_rule" "side_effect_private_egress_vpc" {
  network_acl_id = "${aws_network_acl.side_effect_private.id}"
  rule_number = 100
  rule_action = "allow"
  egress = true
  protocol = -1
  cidr_block = "${aws_vpc.side_effect.cidr_block}"
  from_port = 0
  to_port = 0
}

resource "aws_network_acl_rule" "side_effect_private_ingress_nat" {
  network_acl_id = "${aws_network_acl.side_effect_private.id}"
  rule_number = 110
  rule_action = "allow"
  egress = false
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 1024
  to_port = 65535
}

resource "aws_network_acl_rule" "side_effect_private_egress80" {
  network_acl_id = "${aws_network_acl.side_effect_private.id}"
  rule_number = 120
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 80
  to_port = 80
}

resource "aws_network_acl_rule" "side_effect_private_egress443" {
  network_acl_id = "${aws_network_acl.side_effect_private.id}"
  rule_number = 130
  rule_action = "allow"
  egress = true
  protocol = "tcp"
  cidr_block = "0.0.0.0/0"
  from_port = 443
  to_port = 443
}

프라이빗 서브넷용 네트워크 ACL을 위한 규칙으로 VPC 내에서는 모든 포트를 열고(더 엄격히 갈 수도 있지만, 개인용이라 좀 편하게 설정했다.) NAT으로 들어오는 요청과 80, 443으로 나가는 요청을 규칙으로 추가해서 열어주었다.

// Basiton Host
resource "aws_security_group" "side_effect_bastion" {
  name = "bastion"
  description = "Security group for bastion instance"
  vpc_id = "${aws_vpc.side_effect.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"
  }
}

바스티온 호스트에서 사용할 시큐리티 그룹을 하나 만들었다. 바스티온 호스트에 접속 가능한 IP 대역을 지정할 수도 있지만 나는 어디서 접근할지 알 수 없으므로 전체로 22 포트를 여는 시큐리티 그룹을 만들었다.

resource "aws_instance" "side_effect_bastion" {
  ami = "${data.aws_ami.ubuntu.id}"
  availability_zone = "${aws_subnet.side_effect_public_subnet1.availability_zone}"
  instance_type = "t2.nano"
  key_name = "YOUR-KEY-PAIR-NAME"
  vpc_security_group_ids = [
    "${aws_default_security_group.side_effect_default.id}",
    "${aws_security_group.side_effect_bastion.id}"
  ]
  subnet_id = "${aws_subnet.side_effect_public_subnet1.id}"
  associate_public_ip_address = true

  tags {
    Name = "bastion"
  }
}

resource "aws_eip" "side_effect_bastion" {
  vpc = true
  instance = "${aws_instance.side_effect_bastion.id}"
  depends_on = ["aws_internet_gateway.side_effect_igw"]
}

바스티온 호스트를 EC2 인스턴스로 띄웠다. 바스티온 호스트는 중계 역할 밖에 안 하므로 간단히 t2.nano로 퍼블릭 서브넷에 EC2 인스턴스를 띄웠다. 이 인스턴스는 data.aws_ami.ubuntu.id로 우분투 AMI의 ID를 가져와서 사용했는데 원하는 AMI ID를 지정해서 사용해도 된다. 바스티온 호스트의 IP가 서버 바꿀 때마다 바뀌면 피곤하므로 Elastic IP를 하나 만들어서 바스티온 호스트에 접속했다.

이 구성을 다 만드는데 꽤 많은 삽질이 필요했지만 한번 만들고 나니까 변경하면서 관리하기는 꽤 쉬운 상태가 되었다. 필요하다면 이를 복사해서 다른 VPC를 만들거나 다른 리전에 VPC를 생성하는 것도 어렵지 않게 됐다. 이 VPC 구성의 전체 파일은 GitHub 저장소에 올려두었다. 이후 관리하면서 수정이 생길 수 있으므로 이 글과 완전히 일치하지 않을 수 있다.

2017/07/14 23:50 2017/07/14 23:50