When I wrote my data lake demo series (part 1, part 2 and part 3) recently, I used an Aurora PostgreSQL, MSK and EMR cluster. All of them were deployed to private subnets and dedicated infrastructure was created using CloudFormation. Using the infrastructure as code (IaC) tool helped a lot, but it resulted in creating 7 CloudFormation stacks, which was a bit harder to manage in the end. Then I looked into how to simplify building infrastructure and managing resources on AWS and decided to use Terraform instead. I find it has useful constructs (e.g. meta-arguments) to make it simpler to create and manage resources. It also has a wide range of useful modules that facilitate development significantly. In this post, we’ll build an infrastructure for development on AWS with Terraform. A VPN server will also be included in order to improve developer experience by accessing resources in private subnets from developer machines.
[UPDATE 2023-10-13]
- In later projects, the VPN admin password and VPN pre shared key are auto-generated and saved as a secret in AWS Secrets Manager. The changes are added to VPN section.
Architecture
The infrastructure that we’ll discuss in this post is shown below. The database is deployed in a private subnet, and it is not possible to access it from the developer machine. We can construct a PC-to-PC VPN with SoftEther VPN. The VPN server runs in a public subnet, and it is managed by an autoscaling group where only a single instance will be maintained. An elastic IP address is associated by a bootstrap script so that its public IP doesn’t change even if the EC2 instance is recreated. We can add users with the server manager program, and they can access the server with the client program. Access from the VPN server to the database is allowed by adding an inbound rule where the source security group ID is set to the VPN server’s security group ID. Note that another option is AWS Client VPN, but it is way more expensive. We’ll create 2 private subnets, and it’ll cost $0.30/hour for endpoint association in the Sydney region. It also charges $0.05/hour for each connection and the minimum charge will be $0.35/hour. On the other hand, the SorftEther VPN server runs in the t3.nano
instance and its cost is only $0.0066/hour.
Even developing a single database can result in a stack of resources and Terraform can be of great help to create and manage those resources. Also, VPN can improve developer experience significantly as it helps access them from developer machines. In this post, it’ll be illustrated how to access a database but access to other resources such as MSK, EMR, ECS and EKS can also be made.
Infrastructure
Terraform can be installed in multiple ways and the CLI has intuitive commands to manage AWS infrastructure. Key commands are
- init - It is used to initialize a working directory containing Terraform configuration files.
- plan - It creates an execution plan, which lets you preview the changes that Terraform plans to make to your infrastructure.
- apply - It executes the actions proposed in a Terraform plan.
- destroy - It is a convenient way to destroy all remote objects managed by a particular Terraform configuration.
The GitHub repository for this post has the following directory structure. Terraform resources are grouped into 4 files, and they’ll be discussed further below. The remaining files are supporting elements and their details can be found in the language reference.
1$ tree
2.
3├── README.md
4├── _data.tf
5├── _outputs.tf
6├── _providers.tf
7├── _variables.tf
8├── aurora.tf
9├── keypair.tf
10├── scripts
11│ └── bootstrap.sh
12├── vpc.tf
13└── vpn.tf
14
151 directory, 10 files
VPC
We can use the AWS VPC module to construct a VPC. A Terraform module is a container for multiple resources, and it makes it easier to manage related resources. A VPC with 2 availability zones is defined and private/public subnets are configured to each of them. Optionally a NAT gateway is added only to a single availability zone.
1# vpc.tf
2module "vpc" {
3 source = "terraform-aws-modules/vpc/aws"
4
5 name = "${local.resource_prefix}-vpc"
6 cidr = "10.${var.class_b}.0.0/16"
7
8 azs = ["${var.aws_region}a", "${var.aws_region}b"]
9 private_subnets = ["10.${var.class_b}.0.0/19", "10.${var.class_b}.32.0/19"]
10 public_subnets = ["10.${var.class_b}.64.0/19", "10.${var.class_b}.96.0/19"]
11
12 enable_nat_gateway = true
13 single_nat_gateway = true
14 one_nat_gateway_per_az = false
15}
Key Pair
An optional key pair is created. It can be used to access an EC2 instance via SSH. The PEM file will be saved to the key-pair folder once created.
1# keypair.tf
2resource "tls_private_key" "pk" {
3 count = var.key_pair_create ? 1 : 0
4 algorithm = "RSA"
5 rsa_bits = 4096
6}
7
8resource "aws_key_pair" "key_pair" {
9 count = var.key_pair_create ? 1 : 0
10 key_name = "${local.resource_prefix}-key"
11 public_key = tls_private_key.pk[0].public_key_openssh
12}
13
14resource "local_file" "pem_file" {
15 count = var.key_pair_create ? 1 : 0
16 filename = pathexpand("${path.module}/key-pair/${local.resource_prefix}-key.pem")
17 file_permission = "0400"
18 sensitive_content = tls_private_key.pk[0].private_key_pem
19}
VPN
The AWS Auto Scaling Group (ASG) module is used to manage the SoftEther VPN server. The ASG maintains a single EC2 instance in one of the public subnets. The user data script (bootstrap.sh) is configured to run at launch and it’ll be discussed below. Note that there are other resources that are necessary to make the VPN server to work correctly and those can be found in the vpn.tf. Also note that the VPN resource requires a number of configuration values. While most of them have default values or are automatically determined, the IPsec Pre-Shared key (vpn_psk) and administrator password (admin_password) do not have default values. They need to be specified while running the plan, apply and _destroy _commands. Finally, if the variable vpn_limit_ingress is set to true, the inbound rules of the VPN security group is limited to the running machine’s IP address.
1# _variables.tf
2variable "vpn_create" {
3 description = "Whether to create a VPN instance"
4 default = true
5}
6
7variable "vpn_limit_ingress" {
8 description = "Whether to limit the CIDR block of VPN security group inbound rules."
9 default = true
10}
11
12variable "vpn_use_spot" {
13 description = "Whether to use spot or on-demand EC2 instance"
14 default = false
15}
16
17variable "vpn_psk" {
18 description = "The IPsec Pre-Shared Key"
19 type = string
20 sensitive = true
21}
22
23variable "admin_password" {
24 description = "SoftEther VPN admin / database master password"
25 type = string
26 sensitive = true
27}
28
29locals {
30 ...
31 local_ip_address = "${chomp(data.http.local_ip_address.body)}/32"
32 vpn_ingress_cidr = var.vpn_limit_ingress ? local.local_ip_address : "0.0.0.0/0"
33 vpn_spot_override = [
34 { instance_type: "t3.nano" },
35 { instance_type: "t3a.nano" },
36 ]
37}
38
39# vpn.tf
40module "vpn" {
41 source = "terraform-aws-modules/autoscaling/aws"
42 count = var.vpn_create ? 1 : 0
43
44 name = "${local.resource_prefix}-vpn-asg"
45
46 key_name = var.key_pair_create ? aws_key_pair.key_pair[0].key_name : null
47 vpc_zone_identifier = module.vpc.public_subnets
48 min_size = 1
49 max_size = 1
50 desired_capacity = 1
51
52 image_id = data.aws_ami.amazon_linux_2.id
53 instance_type = element([for s in local.vpn_spot_override: s.instance_type], 0)
54 security_groups = [aws_security_group.vpn[0].id]
55 iam_instance_profile_arn = aws_iam_instance_profile.vpn[0].arn
56
57 # Launch template
58 create_lt = true
59 update_default_version = true
60
61 user_data_base64 = base64encode(join("\n", [
62 "#cloud-config",
63 yamlencode({
64 # https://cloudinit.readthedocs.io/en/latest/topics/modules.html
65 write_files : [
66 {
67 path : "/opt/vpn/bootstrap.sh",
68 content : templatefile("${path.module}/scripts/bootstrap.sh", {
69 aws_region = var.aws_region,
70 allocation_id = aws_eip.vpn[0].allocation_id,
71 vpn_psk = var.vpn_psk,
72 admin_password = var.admin_password
73 }),
74 permissions : "0755",
75 }
76 ],
77 runcmd : [
78 ["/opt/vpn/bootstrap.sh"],
79 ],
80 })
81 ]))
82
83 # Mixed instances
84 use_mixed_instances_policy = true
85 mixed_instances_policy = {
86 instances_distribution = {
87 on_demand_base_capacity = var.vpn_use_spot ? 0 : 1
88 on_demand_percentage_above_base_capacity = var.vpn_use_spot ? 0 : 100
89 spot_allocation_strategy = "capacity-optimized"
90 }
91 override = local.vpn_spot_override
92 }
93
94 tags_as_map = {
95 "Name" = "${local.resource_prefix}-vpn-asg"
96 }
97}
98
99resource "aws_eip" "vpn" {
100 count = var.vpn_create ? 1 : 0
101 tags = {
102 "Name" = "${local.resource_prefix}-vpn-eip"
103 }
104}
105
106...
The bootstrap script associates the elastic IP address followed by starting the SoftEther VPN server by a Docker container. It accepts the pre-shared key (vpn_psk) and administrator password (admin_password) as environment variables. Also, the Virtual Hub name is set to DEFAULT.
1# scripts/bootstrap.sh
2#!/bin/bash -ex
3
4## Allocate elastic IP and disable source/destination checks
5TOKEN=$(curl --silent --max-time 60 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 30")
6INSTANCEID=$(curl --silent --max-time 60 -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
7aws --region ${aws_region} ec2 associate-address --instance-id $INSTANCEID --allocation-id ${allocation_id}
8aws --region ${aws_region} ec2 modify-instance-attribute --instance-id $INSTANCEID --source-dest-check "{\"Value\": false}"
9
10## Start SoftEther VPN server
11yum update -y && yum install docker -y
12systemctl enable docker.service && systemctl start docker.service
13
14docker pull siomiz/softethervpn:debian
15docker run -d \
16 --cap-add NET_ADMIN \
17 --name softethervpn \
18 --restart unless-stopped \
19 -p 500:500/udp -p 4500:4500/udp -p 1701:1701/tcp -p 1194:1194/udp -p 5555:5555/tcp -p 443:443/tcp \
20 -e PSK=${vpn_psk} \
21 -e SPW=${admin_password} \
22 -e HPW=DEFAULT \
23 siomiz/softethervpn:debian
[UPDATE 2023-10-13] In later projects, the VPN admin password and VPN pre shared key are auto-generated and saved as a secret in AWS Secrets Manager. The secret is named as "${local.name}-vpn-secrets"
and can be obtained on AWS Console. Or the VPN secret ID are added to a Terraform output value, it can be obtained as shown below.
1aws secretsmanager get-secret-value \
2 --secret-id $(terraform output -raw vpn_secret_id) | jq -c '.SecretString | fromjson'
3# {"vpn_pre_shared_key":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","vpn_admin_password":"xxxxxxxxxxxxxxxx"}
Below shows relevant Terraform changes.
1# vpn.tf
2module "vpn" {
3 source = "terraform-aws-modules/autoscaling/aws"
4 version = "~> 6.5"
5 count = local.vpn.to_create ? 1 : 0
6
7 name = "${local.name}-vpn-asg"
8
9 ...
10
11 # Launch template
12 create_launch_template = true
13 update_default_version = true
14
15 user_data = base64encode(join("\n", [
16 "#cloud-config",
17 yamlencode({
18 # https://cloudinit.readthedocs.io/en/latest/topics/modules.html
19 write_files : [
20 {
21 path : "/opt/vpn/bootstrap.sh",
22 content : templatefile("${path.module}/scripts/bootstrap.sh", {
23 aws_region = local.region,
24 allocation_id = aws_eip.vpn[0].allocation_id,
25 vpn_psk = "${random_password.vpn_pre_shared_key[0].result}", # <- internally generated value
26 admin_password = "${random_password.vpn_admin_pw[0].result}" # <- internally generated value
27 }),
28 permissions : "0755",
29 }
30 ],
31 runcmd : [
32 ["/opt/vpn/bootstrap.sh"],
33 ],
34 })
35 ]))
36
37 ...
38
39 tags = local.tags
40}
41
42...
43
44## create VPN secrets - IPsec Pre-Shared Key and admin password for VPN
45## see https://cloud.google.com/network-connectivity/docs/vpn/how-to/generating-pre-shared-key
46resource "random_password" "vpn_pre_shared_key" {
47 count = local.vpn.to_create ? 1 : 0
48 length = 32
49 override_special = "/+"
50}
51
52resource "random_password" "vpn_admin_pw" {
53 count = local.vpn.to_create ? 1 : 0
54 length = 16
55 special = false
56}
57
58resource "aws_secretsmanager_secret" "vpn_secrets" {
59 count = local.vpn.to_create ? 1 : 0
60 name = "${local.name}-vpn-secrets"
61 description = "Service Account Password for the API"
62 recovery_window_in_days = 0
63
64 tags = local.tags
65}
66
67resource "aws_secretsmanager_secret_version" "vpn_secrets" {
68 count = local.vpn.to_create ? 1 : 0
69 secret_id = aws_secretsmanager_secret.vpn_secrets[0].id
70 secret_string = <<EOF
71 {
72 "vpn_pre_shared_key": "${random_password.vpn_pre_shared_key[0].result}",
73 "vpn_admin_password": "${random_password.vpn_admin_pw[0].result}"
74 }
75EOF
76}
77
78resource "tls_private_key" "pk" {
79 count = local.vpn.to_create ? 1 : 0
80 algorithm = "RSA"
81 rsa_bits = 4096
82}
83
84resource "aws_key_pair" "key_pair" {
85 count = local.vpn.to_create ? 1 : 0
86 key_name = "${local.name}-vpn-key"
87 public_key = tls_private_key.pk[0].public_key_openssh
88}
89
90...
91
92# outputs.tf
93...
94
95output "vpn_secret_id" {
96 description = "VPN secret ID"
97 value = local.vpn.to_create ? aws_secretsmanager_secret.vpn_secrets[0].id : null
98}
99
100output "vpn_secret_version" {
101 description = "VPN secret version ID"
102 value = local.vpn.to_create ? aws_secretsmanager_secret_version.vpn_secrets[0].version_id : null
103}
104...
Database
An Aurora PostgreSQL cluster is created using the AWS RDS Aurora module. It is set to have only a single instance and is deployed to a private subnet. Note that a security group (vpn_access) is created that allows access from the VPN server, and it is added to vpc_security_group_ids.
1# aurora.tf
2module "aurora" {
3 source = "terraform-aws-modules/rds-aurora/aws"
4
5 name = "${local.resource_prefix}-db-cluster"
6 engine = "aurora-postgresql"
7 engine_version = "13"
8 auto_minor_version_upgrade = false
9
10 instances = {
11 1 = {
12 instance_class = "db.t3.medium"
13 }
14 }
15
16 vpc_id = module.vpc.vpc_id
17 db_subnet_group_name = aws_db_subnet_group.aurora.id
18 create_db_subnet_group = false
19 create_security_group = true
20 vpc_security_group_ids = [aws_security_group.vpn_access.id]
21
22 iam_database_authentication_enabled = false
23 create_random_password = false
24 master_password = var.admin_password
25 database_name = local.database_name
26
27 apply_immediately = true
28 skip_final_snapshot = true
29
30 db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.aurora.id
31 enabled_cloudwatch_logs_exports = ["postgresql"]
32
33 tags = {
34 Name = "${local.resource_prefix}-db-cluster"
35 }
36}
37
38resource "aws_db_subnet_group" "aurora" {
39 name = "${local.resource_prefix}-db-subnet-group"
40 subnet_ids = module.vpc.private_subnets
41
42 tags = {
43 Name = "${local.resource_prefix}-db-subnet-group"
44 }
45}
46
47...
48
49resource "aws_security_group" "vpn_access" {
50 name = "${local.resource_prefix}-db-security-group"
51 vpc_id = module.vpc.vpc_id
52
53 lifecycle {
54 create_before_destroy = true
55 }
56}
57
58resource "aws_security_group_rule" "aurora_vpn_inbound" {
59 count = var.vpn_create ? 1 : 0
60 type = "ingress"
61 description = "VPN access"
62 security_group_id = aws_security_group.vpn_access.id
63 protocol = "tcp"
64 from_port = "5432"
65 to_port = "5432"
66 source_security_group_id = aws_security_group.vpn[0].id
67}
VPN Configuration
Both the VPN Server Manager and Client can be obtained from the download centre. The server and client configuration are illustrated below.
VPN Server
We can begin with adding a new setting.
We need to fill in the input fields in the red boxes below. It’s possible to use the elastic IP address as the host name and the administrator password should match to what is used for Terraform.
Then we can make a connection to the server by clicking the connect button.
If it’s the first attempt, we’ll see the following pop-up message and we can click yes to set up the IPsec.
In the dialog, we just need to enter the IPsec Pre-Shared key and click ok.
Once a connection is made successfully, we can manage the Virtual Hub by clicking the manage virtual hub button. Note that we created a Virtual Hub named DEFAULT and the session will be established on that Virtual Hub.
We can create a new user by clicking the manage users button.
And clicking the new button.
For simplicity, we can use Password Authentication as the auth type and enter the username and password.
A new user is created, and we can use the credentials on the client program to make a connection to the server.
VPN Client
We can add a VPN connection by clicking the menu shown below.
We’ll need to create a Virtual Network Adapter and should click the yes button.
In the new dialog, we can add the adapter name and hit ok. Note we should have the administrator privilege to create a new adapter.
Then a new dialog box will be shown. We can add a connection by entering the input fields in the red boxes below. The VPN server details should match to what are created by Terraform and the user credentials that are created in the previous section can be used.
Once a connection is added, we can make a connection to the VPN server by right-clicking the item and clicking the connect menu.
We can see that the status is changed into connected.
Once the VPN server is connected, we can access the database that is deployed in the private subnet. A connection is tested by a database client, and it is shown that the connection is successful.
Summary
In this post, we discussed how to set up a development infrastructure on AWS with Terraform. Terraform is used as an effective way of managing resources on AWS. An Aurora PostgreSQL cluster is created in a private subnet and SoftEther VPN is configured to access the database from the developer machine.
Comments