AWS Bastion with Terraform Modules

이 글은 AWS VPC with Terraform Modules에서 이어지는 글이다.

지난 글에서 AWS VPC를 Terraform Module로 정의해 보았는데 Public, Private Subnet으로 Subnet을 분리해서 VPC를 생성하면 필수적으로 Bastion instance를 생성하게 된다.

Bastion Instance

Bastion(Instance)은 외부로부터 격리된 Private Subnet에 SSH로 접근하기 위한 역할만을 수행하는 EC2 instance를 말한다. Bastion instance는 Public Subnet에 위치하며, Private Subnet에 생성된 EC2 인스턴스는 Bastion에게만 SSH 접속을 허용하도록 한다.

이번에도 Bastion module의 사용 예를 먼저 확인해 보자.

# terraform/common/vpc/staging_vpc.tf 
module "vpc" {
  # 이전 블로그 글 참고 ...
  source = "../../modules/vpc"

  name = "staging"
  cidr = "172.17.0.0/16"

  azs              = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnets   = ["172.17.1.0/24", "172.17.2.0/24"]
  private_subnets  = ["172.17.101.0/24", "172.17.102.0/24"]
  database_subnets = ["172.17.201.0/24", "172.17.202.0/24"]

  tags = {
    "TerraformManaged" = "true"
  }
}

module "bastion" {
  source = "../../modules/bastion"

  name   = "bastion-staging"
  vpc_id = "${module.vpc.vpc_id}"

  ami                 = "${data.aws_ami.amazon_linux.id}"
  availability_zone   = "ap-northeast-1a"
  subnet_id           = "${module.vpc.public_subnets_ids[0]}"
  ingress_cidr_blocks = "${var.office_cidr_blocks}"
  keypair_name        = "${var.keypair_name}"

  tags = {
    "TerraformManaged" = "true"
  }
}

VPC Module을 정의한 것과 대부분 동일하고, 생성된 module.vpc의 output 변수에 접근하기 위해서
${module.vpc.vpc_id} 과 같이 module.vpc를 사용한 부분 정도가 다르다고 할 수 있다.

Bastion module 작성

VPC Module과 마찬가지로 terraform/modules/bastion 디렉터리를 생성하고 다음과 같이
main.tf, outputs.tf, variables.tf 파일을 생성한다.

$ cd 2dal-infrastructure
$ cd terraform/modules/bastion
$ tree
.
├── README.md    # module의 설명/사용방법 등을 작성한다.
├── main.tf      # module의 리소스를 정의한다.
├── outputs.tf   # 외부에서 module에 접근해서 사용할 output 변수를 정의한다. 
└── variables.tf # module을 사용할 때 입력받는 variable 변수를 정의한다. 

variables.tf

이 값들은 module을 선언해서 사용할 때 입력받게 된다.
위에서 작성했던 bastion.tf 파일에서 각 variable의 입력 예제를 확인할 수 있다.

variable "name" {
  description = "모듈에서 정의하는 모든 리소스 이름의 prefix"
  type        = "string"
}

variable "vpc_id" {
  description = "VPC ID"
  type        = "string"
}

variable "ami" {
  description = "bastion 생성에 사용할 AMI"
  type        = "string"
}

variable "instance_type" {
  description = "bastion EC2 instance type"
  default     = "t2.nano" 
}

variable "availability_zone" {
  description = "bastion EC2 instance availability zone"
  type        = "string"
}

variable "subnet_id" {
  description = "bastion EC2 instance Subnet ID"
  type        = "string"
}

variable "keypair_name" {
  description = "bastion이 사용할 keypair name"
  type        = "string"
}

variable "ingress_cidr_blocks" {
  description = "bastion SSH 접속을 허용할 CIDR block 리스트"
  type        = "list"
}

variable "tags" {
  description = "모든 리소스에 추가되는 tag 맵"
  type        = "map"
}

  • Bastion은 SSH 접속의 용도로만 사용하기 때문에 t2.nano를 default instance type으로 선언했다.

main.tf

main.tf 파일에는 Module이 사용하는 리소스(그룹)를 정의한다.

# SG for SSH Connect to Bastion
resource "aws_security_group" "bastion" {
  name        = "bastion"
  description = "Allow SSH connect to bastion instance"
  vpc_id      = "${var.vpc_id}"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = "${var.ingress_cidr_blocks}"
  }

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

  tags = "${merge(var.tags, map("Name", format("%s-bastion", var.name)))}"
}

Bastion에 접속할 CIDR Block에 22번 SSH포트의 접속을 허용하는 Security Group을 정의한다.

  • var.ingress_cidr_blocks 에는 접속을 허용할 CIDR Block이 들어온다.
  • Name Tag는 staging-bastion의 형태로 생성된다.
# SG for Connect to Private Subnet from Bastion
resource "aws_security_group" "ssh_from_bastion" {
  name        = "ssh_from_bastion"
  description = "Allow SSH connect from bastion instance"
  vpc_id      = "${var.vpc_id}"

  ingress {
    from_port       = 22
    to_port         = 22
    protocol        = "tcp"
    security_groups = ["${aws_security_group.bastion.id}"]
  }

  tags = "${merge(var.tags, map("Name", format("%s-ssh-from-bastion", var.name)))}"
}

Bastion으로 부터의 SSH 접속을 허용하는 Security Group을 정의한다.

  • Private Subnet의 instance에는 이 SG(Bastion으로부터의 SSH 접속)를 허용하도록 한다.
  • Name Tag는 staging-ssh-from-bastion의 형태로 생성된다.
# bastion EC2
resource "aws_instance" "bastion" {
  ami                    = "${var.ami}"
  instance_type          = "${var.instance_type}"
  availability_zone      = "${var.availability_zone}"
  subnet_id              = "${var.subnet_id}"
  key_name               = "${var.keypair_name}"
  vpc_security_group_ids = ["${aws_security_group.bastion.id}"]

  associate_public_ip_address = true

  tags = "${merge(var.tags, map("Name", format("%s-bastion", var.name)))}"
}

Bastion EC2 Instance를 정의한다.

  • aws_security_group.bastion 으로 부터의 접속을 허용한다.
# bastion EIP
resource "aws_eip" "bastion" {
  vpc      = true
  instance = "${aws_instance.bastion.id}"
}

Bastion EC2인스턴스에 할당할 EIP를 정의한다. 규칙을 정해서 Route53 resource를 이용해서 Bastion에 특정 도메인 호스트를 부여할 수도 있겠다.

outputs.tf

output.tf 파일에는 module 외부에서 ${module.bastion.instance_id} 와 같이 모듈내부에서 생성한 리소스를 외부에서 접근하고자 할 수 있도록 output 변수를 정의한다.

output "instance_id" {
  description = "Bastion EC2 instance ID"
  value       = "${aws_instance.bastion.id}"
}

output "bastion_sg_id" {
  description = "Bastion에 접속하는 SG ID"
  value       = ["${aws_security_group.bastion.id}"]
}

output "ssh_from_bastion_sg_id" {
  description = "Bastion을 통한 SSH 연결을 허용하는 SG ID"
  value       = ["${aws_security_group.ssh_from_bastion.id}"]
}

output "eip_id" {
  description = "Bastion에 할당된 EIP ID"
  value       = ["${aws_eip.bastion.id}"]
}

README 작성

VPC Module에서와 같이 terraform-docs를 사용해서 README를 작성한다.

$ cd terraform/modules/bastion
$ terraform-docs md .

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|:----:|:-----:|:-----:|
| ami | bastion 생성에 사용할 AMI | string | - | yes |
| availability_zone | bastion EC2 instance availability zone | string | - | yes |
| ingress_cidr_blocks | bastion SSH 접속을 허용할 CIDR block 리스트 | list | - | yes |
| instance_type | bastion EC2 instance type | string | `t2.nano` | no |
| keypair_name | bastion이 사용할 keypair name | string | - | yes |
| name | 모듈에서 정의하는 모든 리소스 이름의 prefix | string | - | yes |
| subnet_id | bastion EC2 instance Subnet ID | string | - | yes |
| tags | 모든 리소스에 추가되는 tag 맵 | map | - | yes |
| vpc_id | VPC ID | string | - | yes |

## Outputs

| Name | Description |
|------|-------------|
| bastion_sg_id | Bastion에 접속하는 SG ID |
| eip_id | Bastion에 할당된 EIP ID |
| instance_id | Bastion EC2 instance ID |
| ssh_from_bastion_sg_id | Bastion을 통한 SSH 연결을 허용하는 SG ID |

작성된 README.md 파일은 2dal-infrastructure/terraform/modules/bastion 에서 확인할 수 있다.

Bastion module을 사용해서 Bastion instance 생성

staging_vpc.tf

각 VPC 마다 bastion이 포함될 예정이므로 bastion을 위한 별도 tf 파일을 생성하지 않고 staging_vpc.tf 파일 하단에 bastion module을 정의한다.

# terraform/common/vpc/staging_vpc.tf

module "vpc" {
  source = "../../modules/vpc"

  name = "staging"
  cidr = "172.17.0.0/16"

  azs              = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnets   = ["172.17.1.0/24", "172.17.2.0/24"]
  private_subnets  = ["172.17.101.0/24", "172.17.102.0/24"]
  database_subnets = ["172.17.201.0/24", "172.17.202.0/24"]

  tags = {
    "TerraformManaged" = "true"
  }
}

# module 키워드를 사용해서 vpc bastion을 정의한다.
module "bastion" {
  # source는 variables.tf, main.tf, outputs.tf 파일이 위치한 디렉터리 경로를 넣어준다.
  source = "../../modules/bastion"

  # VPC이름을 넣어준다. 이 값은 bastion module이 생성하는 모든 리소스 이름의 prefix가 된다.
  name   = "staging"
  # module.vpc에서 생성된 vpc_id가 입력된다.
  vpc_id = "${module.vpc.vpc_id}"

  # 최신 버전의 amazon_linux AMI id가 입력된다.
  ami                 = "${data.aws_ami.amazon_linux.id}"
  # bastion을 생성할 AZ을 정의한다.
  availability_zone   = "ap-northeast-1a"
  # bastion을 생성할 subnet id를 정의한다.
  subnet_id           = "${module.vpc.public_subnets_ids[0]}"
  # bastion에 SSH접속을 허용할 CIDR block을 var.office_cidr_blocks의 값으로 정의한다.
  ingress_cidr_blocks = "${var.office_cidr_blocks}"
  # bastion SSH 접속에 사용할 keypair_name을 var.keypair_name의 값으로 정의한다.
  keypair_name        = "${var.keypair_name}"

  # bastion module이 생성하는 모든 리소스에 기본으로 입력될 Tag를 정의한다.
  tags = {
    "TerraformManaged" = "true"
  }
}

variables.tf

  • bastion module은 VPC별로 따로 정의하게 되는데, 이 때 다음의 값들은 VPC에 무관하게 공통으로 사용할 수 있어 별도 variables.tf파일을 작성해서 공유할 수 있도록 했다.
# terraform/common/vpc/variables.tf

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

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

  filter {
    name   = "name"
    values = ["amzn-ami-hvm-*"]
  }

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

  filter {
    name   = "block-device-mapping.volume-type"
    values = ["gp2"]
  }
}

variable "office_cidr_blocks" {
  type = "list"

  default = [
    "0.0.0.0/0" # 이 값은 실제 접속을 허용할 IP를 넣어야 함
  ]
}

variable "keypair_name" {
  default = "2dal-dev"
}
  • data.aws_ami로부터 amazon_linux의 최신 버전(most_recent = true) 을 가져온다.
  • office_cidr_blocks에는 실제 bastion에 접속을 허용할 ip를 입력한다.
  • keypair_name은 각 VPC별로 각기 다른 keypair name을 사용할 수도 있다.

Terraform get

새로운 Module이 추가되었으므로 terraform get 명령으로 Module을 가져온다.

$ cd terraform/common/vpc
$ terraform get
Get: file:///Users/asbubam/dev/2dal-infrastructure/terraform/modules/bastion

Terraform plan

$ terraform plan
  + module.bastion.aws_eip.bastion
  + module.bastion.aws_instance.bastion
  + module.bastion.aws_security_group.bastion
  + module.bastion.aws_security_group.ssh_from_bastion
Plan: 4 to add, 0 to change, 0 to destroy.

terraform plan의 결과 4개의 리소스가 생성될 예정임을 알 수 있다.

Terraform apply

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

이로서 Bastion Module을 사용해서 Bastion생성에 성공했다. Yay!:)

정리

지난 AWS VPC with Terraform Modules 글에 이어, Bastion instance를 Terraform Module로 정의하고 생성해봤다. 예제를 위해 최대한 간단하게 정의해 보았는데 실제 서비스에 반영하고, Module을 수정해가면서 계속 모습은 바뀌게 될 것 같다. 새로운 내용이나 변경사항이 있다면 다음 블로그 글에서 계속 이야기 해나갈 수 있도록 하겠다.

설명에 사용된 파일은 GitHub – asbubam/2dal-infrastructure에서 확인할 수 있다.

참고자료

Terraform Module Registry
GitHub – segmentio/stack: A set of Terraform modules for configuring production infrastructure with AWS