iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔖

A Beginner's Guide to Building Servers with Terraform and AWS

に公開

Introduction

Nowadays, touching the cloud, starting with AWS, has become essential for engineers.
However, when I looked at myself, I was a complete amateur who knew about AWS but had almost never touched it.
Thinking that I couldn't let this continue, I started studying AWS, and since I've now reached a point where I have enough content to write a blog post, I've decided to share it.
I hope this will be helpful for those who, like me, are unfamiliar with AWS.
Note that this blog uses Terraform for IaC to make the environment reproducible at any time.
Please keep in mind that I won't be explaining much about Terraform itself.
Also, the Terraform code is written mostly in a single file without much consideration for maintainability.
Ideally, it should be divided by service, but I combined it into one file because it was easier to write about for the blog.
I apologize for the convenience on my part, but please be aware of that point as well.
It's long, but I hope you enjoy reading it.

Preparing Terraform

Here, I will explain the procedure for obtaining the ID and secret key to run AWS with Terraform.
Please note that these preparations need to be done in the console.
First, access the IAM service and click on "Users" in the side menu.
Then, a button to create a user will appear on the screen, so click it.
When you click it, there's a checkbox: "Provide user access to the AWS Management Console - Optional".
Untitled
Checking it will show the screen above, but since we are creating a user just to obtain keys this time, you don't need to check it.
However, if you are creating a user to use the console, it is recommended to manage accounts with Identity Center, so make sure to configure this checkbox accordingly.
Once you've filled in the other input fields, proceed to the next step.
Then, the items for setting permissions like the one below will be displayed.
2023-10-08_22h53_44.png
Considering management, it's better to create a group with policies attached and assign the user to that group, but since we're just making a user for keys this time, I'll attach the policies directly.
When you select "Attach policies directly," the policies provided by AWS by default will be displayed as follows.
Untitled
Since I want administrator privileges to run Terraform this time, I'll select "AdministratorAccess" and create the user.
Accessing the details screen of the created IAM user, you'll find items like the one in the image below.
Untitled
By clicking "Create access key," the ID and secret key required to run Terraform will be displayed.
By setting these values in Terraform, you'll be able to execute it, so make a note of the values.
Note that once created, you cannot display the secret key again unless you write it down somewhere.
Now that the AWS preparation is complete, we'll configure the Terraform side.
First, create a terraform.tfvars file under any directory in your Linux environment and write the following code.

aws_access_key_id     = "IAM user access key"
aws_secret_access_key = "IAM user secret key"

Next, create variables.tf and write the following code.

variable "aws_access_key_id" {
  type = string
}
variable "aws_secret_access_key" {
  type = string
}

Then, create main.tf and write the code as follows.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.16"
    }
  }
  required_version = ">= 1.2.0"
}
provider "aws" {
  region     = "ap-northeast-1"
  access_key = var.aws_access_key_id
  secret_key = var.aws_secret_access_key
}

As of 2023/10/06, the AWS version is 5.2.0, so please set the required version appropriately.
This completes the Terraform preparation.
Now, let's actually build the network environment in AWS.

Building a Server with AWS

Final Architecture

In this blog, we will create the following AWS configuration.
AWS.drawio.png
Once this configuration is complete, you will be able to display an HTML file created on an EC2 instance belonging to a private subnet, which is our goal for this project.
Let's get started right away.

Creating a Virtual Private Cloud (VPC)

Here, we will create a network to enable building various servers on AWS.
Once this section is complete, AWS will be in the following state:
AWS_only_vpc.drawio.png

A little bit about the concept before creating

What is VPC?
VPC stands for Virtual Private Cloud, and it is a service used when building a network.
This service is realized by dedicated equipment in AWS data centers that runs software simulating the functions of servers and networks.
Therefore, adding or deleting networks can be done just like starting or stopping software.
Also, since each VPC exists independently, creating multiple networks will not affect each other.

About CIDR notation
When building a network in AWS, you need to consider what IP address range to allocate to the VPC area.
The notation used for this is CIDR (Classless Inter-Domain Routing).
Before diving into CIDR, let's briefly explain IP addresses.
An IP address is an address used to identify devices communicating via TCP/IP.
The length of an IP address is 32 bits, and it contains a network part and a host part.
Quoted from IP Address Basic Knowledge
Quoted from IP Address Basic Knowledge

The network part is the value indicating which network it is, and the host part is the value indicating which computer it is.
CIDR is a notation method that indicates how far the network part goes.
CIDR is written in the form of "10.0.0.0/16".
In this case, the 16 bits from the left, i.e., the "10.0" part, is the network part, and everything after that is the host part indicating the computer.
Since you need to describe the address range in CIDR notation when creating a VPC, it is important to understand it.
However, due to subnets which we will see later, it seems sufficient to specify the network part in 16-bit blocks, such as "10.0.0.0/16".

Contents of the VPC to be created

The VPC to be created this time is as follows:

  • Name tag → sample-vpc

This is a tag for us to identify which VPC it is.

  • CIDR block

Specify "10.0.0.0/16".

Terraform code

The Terraform code for creating the VPC is as follows:

resource "aws_vpc" "sample-vpc" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "sample-vpc"
  }
}

By specifying the IP address range with CIDR and adding "sample-vpc" to the tag for easy identification, you can create it.
Since we are just setting up the space, it's very easy as long as you don't do detailed settings.

Creating Availability Zones and Subnets

After completing this section, AWS will be in the following state:
AWS4-3.drawio.png

A little bit about the concept before creating

Availability Zones
AWS has the concept of Regions as the unit for setting up the environment and location for building cloud services.
Each Region is built with multiple data centers called "Availability Zones."*
*Note: This phrasing, which might imply a Region consists only of Availability Zones, is not entirely accurate as there are other elements. However, it is not completely incorrect, so please excuse this explanation.
These Availability Zones are completely independent of each other; even if one fails and becomes inoperable, other Availability Zones can continue to operate normally.
Therefore, when building an environment in AWS, we often create identical environments in multiple Availability Zones as a measure against failures.
Note that it is also possible to build services across multiple Regions.
However, to be honest, unless your application stopping would cause trouble on a national or global scale, there isn't much benefit to configuring across multiple Regions.

Subnets
A subnet is a unit that further divides the VPC's address range.
In the diagram shown at the beginning of this chapter, I created areas within the VPC.
These areas are subnets, and the way they are created is by dividing IP addresses.
By dividing IP addresses and creating subnets, you can configure individual settings for each subnet.
This allows you to make some resources public, while others are kept private and accessible only through specific methods.
Note that the notation for assigning subnets is CIDR notation, same as for VPCs.
One thing to note is that when writing a subnet in CIDR notation, you need to write it considering the network part set in the VPC.
As shown in the image below, if the VPC is set to "10.0.0.0/16" (network part is 16 bits) and the subnet part is set to 8 bits, the subnet's CIDR must be expressed as "10.0.0.0/24", including the network part.
Quoted from What are the methods for solving Class A to C problems? Understanding the relationship between classes and subnetting, with image modifications.
Quoted from What are the methods for solving Class A to C problems? Understanding the relationship between classes and subnetting, with image modifications.

Since subnets can only be assigned after the network part is set, CIDR notation shouldn't feel too strange if you look at it calmly.
However, at first glance, I mentioned it this time because it might be surprising to see 24 bits suddenly assigned to a subnet, making one wonder if there are enough bits left!

Creation details

The subnets we will create this time are as follows:

  • Create subnets within the VPC created earlier.
  • Set ap-northeast-1a and ap-northeast-1c as Availability Zones.
  • Create a public subnet for external exposure and a private subnet for internal-only access within each of the above Availability Zones.
  • For ap-northeast-1a, the public subnet CIDR is "10.0.0.0/20" and the private subnet CIDR is "10.0.64.0/20".
  • For ap-northeast-1c, the public subnet CIDR is "10.0.16.0/20" and the private subnet CIDR is "10.0.80.0/20".

Note that each CIDR is slightly different because if they were the same, we wouldn't be able to distinguish between the subnets.

Terraform code

The Terraform code reflecting these creation details is as follows:

resource "aws_subnet" "sample-subnet-public01" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1a"
  cidr_block        = "10.0.0.0/20"
  tags = {
    Name = "sample-subnet-public01"
  }
}
resource "aws_subnet" "sample-subnet-private01" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1a"
  cidr_block        = "10.0.64.0/20"
  tags = {
    Name = "sample-subnet-private01"
  }
}
resource "aws_subnet" "sample-subnet-public02" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1c"
  cidr_block        = "10.0.16.0/20"
  tags = {
    Name = "sample-subnet-public02"
  }
}
resource "aws_subnet" "sample-subnet-private02" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1c"
  cidr_block        = "10.0.80.0/20"
  tags = {
    Name = "sample-subnet-private02"
  }
}

Roughly speaking, we are doing the same thing for every subnet:

  1. Set the VPC to be associated with.
  2. Define the Availability Zone where the subnet will be placed.
  3. Define the CIDR.
  4. Set a name for easy identification.

By doing this, you can complete the creation of the subnets.

Internet Gateway Configuration

Configure an Internet Gateway so that the VPC can connect to the internet.
Once this section is complete, AWS will be in the following state:
AWS4-4.drawio.png

A little bit about the concept before creating

Internet Gateway
So far, the place to prepare resources in the VPC is complete.
However, currently, the VPC remains closed and has no hole for communicating with the internet.
When publishing an app, etc., no matter how much you set up the environment internally, you cannot deliver the app to users if you cannot communicate with the internet.
Therefore, it is often required to open a hole for the internet in the VPC.
This hole for the internet is called an Internet Gateway.
By preparing an Internet Gateway, you can get ready to communicate with the internet.

Creation details

What we are creating this time is an Internet Gateway for the VPC we've created so far.
And we will give it the name "sample-igw" to make it easy to identify.

Terraform code

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.sample-vpc.id
  tags = {
    Name = "sample-igw"
  }
}

Since an Internet Gateway only needs to be associated with a VPC, we specify the ID of the VPC to be linked.
And we set a name so that it can be identified.

Building a NAT Gateway

We do not want resources set in a private subnet to connect with external networks directly. Even if they need to go out to the internet, we do not want them to be accessed from the internet.
To achieve this requirement, we need to prepare NAT (Network Address Translation), and in AWS, this can be achieved with a NAT Gateway.
Once this section is complete, AWS will be in the following state:
AWS4-5.drawio.png

A little bit about the concept before creating

NAT Gateway
The Internet Gateway created earlier was a hole for connecting the VPC and the internet.
While the Internet Gateway prepared the communication, a public IP is required for resources within the VPC to communicate directly with the internet.
However, we do not want resources placed in a private subnet to have a public IP.
Having a public IP means being exposed to the internet and being directly accessible, which defeats the purpose of making it private in the first place.
Nonetheless, information generated by resources in a private subnet is often passed to the internet.
This is where a NAT Gateway comes in.
By giving a NAT Gateway a public IP and going through it, information can flow to the internet without directly interacting with the private subnet.
Note that since a NAT Gateway is a window for flowing content from a private subnet, the NAT Gateway itself must be placed in a public subnet.
I previously misunderstood that it should be placed in the private subnet because it concerns it, so please be careful if you feel the same way.

Elastic IP
A NAT Gateway is a mechanism that allows outgoing access to the internet by adding a public IP to the private IP of a resource in a private subnet.
Therefore, the NAT Gateway needs to have a public IP, and there is a function called Elastic IP to create this public IP.
Elastic IP can create a static public IP that is not associated with other functions, so it can be assigned to any resource after creation.
This time, we will assign a public IP created with Elastic IP to the NAT Gateway.

Creation details

This time, we will create a NAT Gateway for each of the two public subnets.
The first NAT Gateway is as follows:

  • Name → sample-ngw-01
  • Subnet to create in → sample-subnet-public01
  • Connectivity type → Public
  • Public IP → IP automatically generated by Elastic IP

The second NAT Gateway is as follows:

  • Name → sample-ngw-0
  • Subnet to create in → sample-subnet-public012
  • Connectivity type → Public
  • Public IP → IP automatically generated by Elastic IP

Terraform code

resource "aws_eip" "elastic-ip-01" {
  domain = "vpc"
}
resource "aws_nat_gateway" "sample-ngw-01" {
  allocation_id = aws_eip.elastic-ip-01.id
  subnet_id     = aws_subnet.sample-subnet-public01.id
  tags = {
    Name = "sample-ngw-01"
  }
}
resource "aws_eip" "elastic-ip-02" {
  domain = "vpc"
}
resource "aws_nat_gateway" "sample-ngw-02" {
  allocation_id = aws_eip.elastic-ip-02.id
  subnet_id     = aws_subnet.sample-subnet-public02.id
  tags = {
    Name = "sample-ngw-02"
  }
}

Since the NAT Gateway is placed in the public subnet, we specify the ID of the public subnet created earlier.
Also, since the NAT Gateway must have a public ID, we use resource "aws_eip" to create an IP with Elastic IP and assign that IP to each NAT Gateway.
Finally, as before, we add name tags to make them easy to identify.

References

Page that gave a vague overview of the relationship between NAT Gateway and ALB
Page where I felt the necessity of Elastic IP the most

Creating Route Tables

Up to this point, by creating subnets and preparing various gateways, we have set up the entrances and exits for communication between the internet and resources, and between resources themselves.
However, currently, no paths have been created for actual communication.
Therefore, we will build route tables here to create paths for communication.
Once this section is complete, AWS will be in the following state:
AWS4-6.drawio.png

A little bit about the concept before creating

Route Table
In AWS, the function to set communication paths between subnets is called a route table.
You can set connection rules in a table format, such as "When connecting to this server, go through here."
The configuration method involves setting the connection destination IP as the Destination, as shown in the image.
Note that this destination can be a specific IP or a range specified in CIDR notation.
2023-10-09_19h35_09.png

The Target specifies what to go through.
The main ones are as follows:

  • local
    ⇒ When accessing resources within the same VPC
  • Internet Gateway
    ⇒ When resources in a public subnet communicate with servers on the internet
  • NAT Gateway
    ⇒ When resources in a private subnet communicate with servers on the internet
  • VPN Gateway
    ⇒ When communicating with servers on a separate network connected via VPN
  • VPC Peering
    ⇒ When communicating with resources in another VPC that has been permitted for connection.

Creation details

This time, we will create three route tables.
If the same route table is applicable, it can be reused.
Therefore, while the targets for route tables are a total of four (public and private subnets in each Availability Zone), we will create only three route tables.
Specifically, we will create the following route tables:

① For Public Subnets
Target subnets: sample-subnet-public01, sample-subnet-public02
Route 1 Destination: 10.0.0.0/16
Route 1 Target: local
Route 2 Destination: 0.0.0.0/0
Route 2 Target: sample-igw (Internet Gateway)

② For Private Subnet Part 1
Target subnet: sample-subnet-private01
Route 1 Destination: 10.0.0.0/16
Route 1 Target: local
Route 2 Destination: 0.0.0.0/0
Route 2 Target: sample-ngw-01 (NAT Gateway)

③ For Private Subnet Part 2
Target subnet: sample-subnet-private02
Route 1 Destination: 10.0.0.0/16
Route 1 Target: local
Route 2 Destination: 0.0.0.0/0
Route 2 Target: sample-ngw-02 (NAT Gateway)

Note that the CIDR "0.0.0.0/0" set for the gateway means all destinations.
In this case, it means that all destinations other than "10.0.0.0/16" will communicate through the gateway.

Terraform code

The code for setting up the route tables is as follows:

resource "aws_route_table" "sample-rtb-public" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }
  tags = {
    Name = "sample-rtb-public"
  }
}
resource "aws_route_table_association" "relate-public-route-table1" {
  subnet_id      = aws_subnet.sample-subnet-public01.id
  route_table_id = aws_route_table.sample-rtb-public.id
}
resource "aws_route_table_association" "relate-public-route-table2" {
  subnet_id      = aws_subnet.sample-subnet-public02.id
  route_table_id = aws_route_table.sample-rtb-public.id
}
resource "aws_route_table" "sample-rtb-private01" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.sample-ngw-01.id
  }
  tags = {
    Name = "sample-rtb-private01"
  }
}
resource "aws_route_table_association" "relate-private-route-table1" {
  subnet_id      = aws_subnet.sample-subnet-private01.id
  route_table_id = aws_route_table.sample-rtb-private01.id
}
resource "aws_route_table" "sample-rtb-private02" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.sample-ngw-02.id
  }
  tags = {
    Name = "sample-rtb-private02"
  }
}
resource "aws_route_table_association" "relate-private-route-table2" {
  subnet_id      = aws_subnet.sample-subnet-private02.id
  route_table_id = aws_route_table.sample-rtb-private02.id
}

Security Group Settings

We have configured the paths for resources and communication is now possible. However, as it stands, it is still in a state where access via the internet is possible. Therefore, we will set up security groups to restrict external access.
Once this section is complete, AWS will be in the following state:
AWS4-7.drawio.png

A little bit about the concept before creating

Security Groups
Security groups define rules for allowed sources, protocols, and ports. By setting port numbers and IP addresses, you can determine where access is permitted from. By associating the created rules with specific resources, they can function like a firewall.
Note that you can set inbound and outbound rules for security groups. As the names suggest, you can configure reception rules (inbound) and transmission rules (outbound) for the resources assigned to the security group.

Creation details

We will create the following security groups:

Security group for the bastion server (described later)

  • Name: sample-bg-bastion
  • Description: for bastion server
  • Associated VPC: sample-vpc
  • Inbound Rule (Type): SSH
  • Inbound Rule (Source): 0.0.0.0/0
    ⇒ This means accepting requests from anywhere as long as the connection method is SSH.

Default security group

  • Associated VPC: sample-vpc
  • Inbound Rule: Accept all requests.
  • Outbound Rule: Allow sending requests to all destinations.

Terraform code

The Terraform code for creating the security groups is as follows:

resource "aws_security_group" "sample-sg-bastison-terraform" {
  name        = "sample-sg-bastion-terraform"
  description = "for bastion server"
  vpc_id      = aws_vpc.sample-vpc.id
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
resource "aws_default_security_group" "default" {
  vpc_id = aws_vpc.sample-vpc.id
  ingress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

Note that a default security group usually exists already, but since I wasn't sure how to link to that specific group in the subsequent EC2 steps, I am defining the default security group here.

Building a Server with EC2

Now that the network construction is largely complete, we will build the servers here.
We will create an EC2 instance for a bastion server in the public subnet, and Web servers in the private subnet.
Once this section is complete, AWS will be in the following state:
AWS0.drawio.png

A little bit about the concept before creating

EC2
EC2 stands for Elastic Compute Cloud, and it is a service that provides virtual server environments. It is used as an execution platform for various software, including application deployment.

AMI
This refers to the guest OS that runs as a virtual server on EC2. Starting with Amazon Linux provided by AWS, Linux distributions such as CentOS and Ubuntu can also be used.

Key Pair
This refers to the public key required to connect to an EC2 instance. By setting this on the AWS side, you can connect via SSH using a private key. Note that if you use Systems Manager or Session Manager, you don't need to set up this key pair, but I won't go into further detail here.

Bastion Server
A bastion server (or jump server) is a server used as a relay to log into a target server.
Quoted from Risks of Bastion Server Operation and the Necessity of Obtaining Audit Trails
Quoted from Risks of Bastion Server Operation and the Necessity of Obtaining Audit Trails

By limiting access to EC2 instances in the private subnet only through the bastion server, you get the following benefits:

  • By restricting direct access, you reduce the risk of unauthorized access.
  • By going through a bastion server, you only need to apply control over access sources and times to the bastion server itself, making management of restrictions easier.
  • It becomes easier to manage operation logs, such as who accessed which resource.
    • This is because once a bastion server is set up, you only need to look at the bastion server to find the logs.
  • Unauthorized access from sources other than the bastion server is easier to detect.

There are other advantages as well, so it is important to go through a bastion server rather than allowing direct access to each EC2 instance. However, using a bastion server is not the only way to prevent direct access. AWS has features like EC2 Instance Connect to prevent direct access, so it's best to choose the one that fits your project.

Multi-hop Connection
As mentioned, we will connect to the EC2 in the private subnet via the bastion server. Therefore, to access the private EC2, you would normally need to SSH into the bastion server first, then SSH into the target EC2 again. In this case, the first SSH connection is fine because the user's terminal has the private key, but to connect to the subsequent EC2, you would need to pass the private key to the bastion server and use it there. This is cumbersome and, more importantly, we want to avoid placing sensitive information like private keys on a bastion server as much as possible.

This is where multi-hop (SSH proxy) connection comes in. By using a multi-hop setup, you can perform the action of "connecting to server A and then connecting to server B from there" with a single command. It also allows SSH connection through the bastion server without passing the private key to it.
Since I've explained the significance of multi-hop connection, I'll also briefly describe how to write it.

Host 192.168.0.85
        HostName        192.168.0.85
        Port            22
        IdentityFile    ~/.ssh/id_rsa.aws
        User            centos
        ProxyCommand    ssh -W %h:%p 192.168.0.84
Host 192.168.0.84
        HostName        192.168.0.84
        Port            22
        IdentityFile    ~/.ssh/id_rsa.aws
        User            centos
        ProxyCommand    ssh -W %h:%p 52.40.178.xxx

Each item is as follows:

  • Host: The name used when connecting via SSH. It doesn't have to be an IP address; any name is fine.
  • HostName: The destination address or the fully qualified domain name (FQDN) without abbreviation.
  • Port: The destination port number.
  • IdentityFile: The path to the private key.
  • User: The username for the SSH connection.
  • ProxyCommand: Specifies the command to be executed automatically at the destination host.

Of particular note is ProxyCommand. By specifying this, you can execute a command upon connection. If you specify an EC2 instance in a private subnet, it will first connect to the bastion server and then automatically connect to the EC2 beyond it. This allows a single command to execute a multi-hop connection that passes through the bastion server to reach the EC2 in the private subnet.

Creation details

Bastion Server

  • Name: sample-ec2-bastion
  • Amazon Machine Image (AMI): Amazon Linux 2
  • Instance Type: t2.micro
  • Key Pair: Created within Terraform
  • Subnet: sample-subnet-public01 (Public subnet)
  • Security Groups: Default, and the security group for the bastion server
  • Assign a public IP.

Web Server 1 in Private Subnet

  • Name: sample-ec2-web01
  • Amazon Machine Image (AMI): Amazon Linux 2
  • Instance Type: t2.micro
  • Key Pair: Created within Terraform
  • Subnet: sample-subnet-private01 (Private subnet)
  • Security Groups: Default security group

Web Server 2 in Private Subnet

  • Name: sample-ec2-web02
  • Amazon Machine Image (AMI): Amazon Linux 2
  • Instance Type: t2.micro
  • Key Pair: Created within Terraform
  • Subnet: sample-subnet-private02 (Private subnet)
  • Security Groups: Default security group

Note that public IPs will not be assigned to the Web servers.

Terraform code

The Terraform code to create the EC2 instances, including the bastion server, is as follows:

variable "key_name" {
  type        = string
  description = "keypair name"
  # Specify the key pair name here
  default = "hoge-key"
}
locals {
  public_key_file  = "./.key_pair/${var.key_name}.id_rsa.pub"
  private_key_file = "./.key_pair/${var.key_name}.id_rsa"
}
# Configuration for the private key algorithm
resource "tls_private_key" "keygen" {
  algorithm = "RSA"
  rsa_bits  = 2048
}
# Generation of the private key
resource "local_file" "private_key_pem" {
  filename = local.private_key_file
  content  = tls_private_key.keygen.private_key_pem
  provisioner "local-exec" {
    command = "chmod 600 ${local.private_key_file}"
  }
}
# Generation of the public key
resource "local_file" "public_key_openssh" {
  filename = local.public_key_file
  content  = tls_private_key.keygen.public_key_openssh
  provisioner "local-exec" {
    command = "chmod 600 ${local.public_key_file}"
  }
}
resource "aws_key_pair" "key_pair" {
  key_name   = var.key_name
  public_key = tls_private_key.keygen.public_key_openssh
  provisioner "local-exec" {
    command = <<-EOT
     echo "${tls_private_key.keygen.private_key_pem}" > /mnt/c/Users/Username/.ssh/${var.key_name}.pem
    EOT
  }
}
data "aws_ami" "amzlinux2" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}
resource "aws_instance" "sample-ec2-bastion" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-public01.id
  security_groups = [aws_security_group.sample-sg-bastion-terraform.id, aws_default_security_group.default.id]
  associate_public_ip_address = true
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-bastion"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
resource "aws_instance" "sample-ec2-web01" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-private01.id
  security_groups = [aws_security_group.sample-sg-bastion-terraform.id, aws_default_security_group.default.id]
  associate_public_ip_address = false
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-web01"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
resource "aws_instance" "sample-ec2-web02" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-private02.id
  security_groups = [aws_default_security_group.default.id]
  associate_public_ip_address = false
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-web02"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
# Specifying local_file resources allows for file creation and command execution within the directory where terraform is run.
resource "local_file" "config_file" {
  filename = "/mnt/c/Users/Username/.ssh/config"
  content  = <<-EOT
Host bastion
    Hostname ${aws_instance.sample-ec2-bastion.public_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
Host web01
    Hostname ${aws_instance.sample-ec2-web01.private_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
    ProxyCommand ssh.exe -W %h:%p bastion
Host web02
    Hostname ${aws_instance.sample-ec2-web02.private_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
    ProxyCommand ssh.exe -W %h:%p bastion
    EOT
}

The keys are generated using OpenSSH, the public key is set in the key pair, and the private key is configured locally. Then, the Amazon Linux instances to run on EC2 are created. After that, EC2 instances are created under the conditions shown in the creation details. Finally, a config file for multi-hop connection is output to the specified local path.

Verification

Let's try accessing the EC2 instance created in the private subnet.
First, open PowerShell and execute ssh web01 at any location.
You will be asked whether to add the fingerprint to hosts, so answer yes. If there are no issues, you will be able to access the inside of the EC2 instance.
Note that if you get a permission error for the config file or the private key when running ssh web01, please re-apply permissions based on the reference materials.

References

Load Balancer Settings

By now, we have also prepared the Web servers. However, as they are, the Web servers are not yet exposed to the internet. Therefore, we will prepare a load balancer so that we can view the applications configured on the Web servers through a browser.
Once this section is complete, AWS will be in the following state:
AWS.drawio.png

A little bit about the concept before creating

Load Balancer
When the number of requests increases, a single server can no longer handle them. In such cases, a method is taken to increase performance by preparing multiple servers. However, simply preparing multiple servers is not enough to utilize them. This is where a load balancer is used.
By setting up a load balancer, multiple requests can be evenly distributed among each server. This allows the multiple servers to operate appropriately and handle the increase in requests. Additionally, by performing SSL processing at the load balancer, there is no need to perform encryption on the Web server, meaning the Web server's resources won't be consumed by encryption or decryption processes. Furthermore, since all requests are aggregated at the load balancer, countermeasures against malicious requests don't need to be implemented on every server and can be concentrated on the load balancer instead.
Quoted from 【Understand with Diagrams】What is a Load Balancer? Explaining the Mechanism of Load Balancing
Quoted from 【Understand with Diagrams】What is a Load Balancer? Explaining the Mechanism of Load Balancing

Target Group
A target group is a collection of instances or containers grouped across Availability Zones. By setting up a target group, requests can be distributed to resources in different Availability Zones based on a single condition. This prevents the application from stopping completely by routing requests to resources in another Availability Zone even if a failure occurs in one of them.

Creation details

Load Balancer
Associated VPC: sample-vpc
Availability Zones: sample-subnet-public01, sample-subnet-public02 *
*Although I say Availability Zones, the values to specify are the public subnets in each Availability Zone.
Security Groups: Default, and a security group that accepts requests on ports 80 and 443.

Target Group
Name: sample-tg
Protocol: HTTP
Port: 3000
Available instances (associated instances): sample-ec2-web01, sample-ec2-web02 (both Web servers)

Terraform Code

// Security group for the load balancer
resource "aws_security_group" "sample-sg-elb-terraform" {
  name        = "sample-sg-elb-terraform"
  description = "for load balancer"
  vpc_id      = aws_vpc.sample-vpc.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
// ALB configuration
resource "aws_lb" "sample-elb-terraform" {
  name               = "sample-elb-terraform"
  internal           = false
  load_balancer_type = "application"
  ip_address_type    = "ipv4"
  // Associating security groups and subnets
  security_groups = [module.vpc-module.default-security-groups-id, module.vpc-module.sample-security-group-elb-id]
  subnets = [module.vpc-module.sample-subnet-public01-id,
  module.vpc-module.sample-subnet-public02-id]
}
// Target group
resource "aws_lb_target_group" "sample-tg-terraform" {
  name     = "sample-tg-terraform"
  port     = 3000
  protocol = "HTTP"
  vpc_id   = module.vpc-module.sample-vpc-id
}
// Registering EC2 instances to the target group
// Register first EC2 instance
resource "aws_lb_target_group_attachment" "sample-target_ec01" {
  target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  target_id        = aws_instance.sample-ec2-web01.id
}
// Register second EC2 instance
resource "aws_lb_target_group_attachment" "sample-target_ec02" {
  target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  target_id        = aws_instance.sample-ec2-web02.id
}
// Listener configuration
resource "aws_lb_listener" "sample-tg" {
  load_balancer_arn = aws_lb.sample-elb-terraform.arn
  port              = "80"
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  }
}

Verification

Now, let's create a file on the EC2 instances and confirm that it is displayed in the browser.
First, access Web Server 1 via SSH and create an HTML file containing the following code using Vim or a similar editor.

<body>
  Hello AWS Terraform
</body>

Once created, run python -m SimpleHTTPServer 3000 to start the HTTP server.
Similarly, create the following HTML file on Web Server 2 and start the HTTP server with python -m SimpleHTTPServer 3000.

<body>
  Hello AWS Terraform2
</body>

Then, access the EC2 console and select "Load Balancers" from the side menu.
A list of created load balancers will be displayed; copy the "DNS name" of the load balancer you created.
2023-10-10_22h49_03.png
When you access the copied DNS name in a browser, the HTML file created earlier on Web Server 1 will be displayed.
2023-10-11_00h01_39.png
When you refresh the browser, the HTML file created on Web Server 2 will be displayed this time.
2023-10-11_00h01_59.png
Once these are confirmed, the load balancer configuration and the connection to the Web servers are complete.

References

Referenced the code for the ALB configuration method

Full View of Terraform

Finally, here is the full view of the Terraform code created so far.

# Initial setup
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.16"
    }
  }
  required_version = ">= 1.2.0"
}
provider "aws" {
  region     = "ap-northeast-1"
  access_key = var.aws_access_key_id
  secret_key = var.aws_secret_access_key
}
# Creating a VPC
resource "aws_vpc" "sample-vpc" {
  cidr_block = "10.0.0.0/16"
  tags = {
    Name = "sample-vpc"
  }
}
# Performed the following tasks.
# 1. Setting up Availability Zones
# 2. Allocating public and private subnets to each Availability Zone
resource "aws_subnet" "sample-subnet-public01" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1a"
  cidr_block        = "10.0.0.0/20"
  tags = {
    Name = "sample-subnet-public01"
  }
}
resource "aws_subnet" "sample-subnet-private01" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1a"
  cidr_block        = "10.0.64.0/20"
  tags = {
    Name = "sample-subnet-private01"
  }
}
resource "aws_subnet" "sample-subnet-public02" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1c"
  cidr_block        = "10.0.16.0/20"
  tags = {
    Name = "sample-subnet-public02"
  }
}
resource "aws_subnet" "sample-subnet-private02" {
  vpc_id            = aws_vpc.sample-vpc.id
  availability_zone = "ap-northeast-1c"
  cidr_block        = "10.0.80.0/20"
  tags = {
    Name = "sample-subnet-private02"
  }
}
# Creating an Internet Gateway
resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.sample-vpc.id
  tags = {
    Name = "sample-igw"
  }
}
# Creating a NAT Gateway and configuring the Elastic IP used for it
resource "aws_eip" "elastic-ip-01" {
  domain = "vpc"
}
resource "aws_nat_gateway" "sample-ngw-01" {
  allocation_id = aws_eip.elastic-ip-01.id
  subnet_id     = aws_subnet.sample-subnet-public01.id
  tags = {
    Name = "sample-ngw-01"
  }
}
resource "aws_eip" "elastic-ip-02" {
  domain = "vpc"
}
resource "aws_nat_gateway" "sample-ngw-02" {
  allocation_id = aws_eip.elastic-ip-02.id
  subnet_id     = aws_subnet.sample-subnet-public02.id
  tags = {
    Name = "sample-ngw-02"
  }
}
# Creating route tables and associating them with subnets
resource "aws_route_table" "sample-rtb-public" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }
  tags = {
    Name = "sample-rtb-public"
  }
}
resource "aws_route_table_association" "relate-public-route-table1" {
  subnet_id      = aws_subnet.sample-subnet-public01.id
  route_table_id = aws_route_table.sample-rtb-public.id
}
resource "aws_route_table_association" "relate-public-route-table2" {
  subnet_id      = aws_subnet.sample-subnet-public02.id
  route_table_id = aws_route_table.sample-rtb-public.id
}
resource "aws_route_table" "sample-rtb-private01" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.sample-ngw-01.id
  }
  tags = {
    Name = "sample-rtb-private01"
  }
}
resource "aws_route_table_association" "relate-private-route-table1" {
  subnet_id      = aws_subnet.sample-subnet-private01.id
  route_table_id = aws_route_table.sample-rtb-private01.id
}
resource "aws_route_table" "sample-rtb-private02" {
  vpc_id = aws_vpc.sample-vpc.id
  route {
    cidr_block = "10.0.0.0/16"
    gateway_id = "local"
  }
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.sample-ngw-02.id
  }
  tags = {
    Name = "sample-rtb-private02"
  }
}
resource "aws_route_table_association" "relate-private-route-table2" {
  subnet_id      = aws_subnet.sample-subnet-private02.id
  route_table_id = aws_route_table.sample-rtb-private02.id
}
# Creating security groups
resource "aws_security_group" "sample-sg-bastion-terraform" {
  name        = "sample-sg-bastion-terraform"
  description = "for bastion server"
  vpc_id      = aws_vpc.sample-vpc.id
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
resource "aws_security_group" "sample-sg-elb-terraform" {
  name        = "sample-sg-elb-terraform"
  description = "for load balancer"
  vpc_id      = aws_vpc.sample-vpc.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
# Creating the default security group
resource "aws_default_security_group" "default" {
  vpc_id = aws_vpc.sample-vpc.id
  ingress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}
# Creating EC2 instances for the bastion and web servers, and configuring key pairs
# Also, creating a config for connecting via the bastion server
variable "key_name" {
  type        = string
  description = "keypair name"
  # Specify the key pair name here
  default = "hoge-key"
}
locals {
  public_key_file  = "./.key_pair/${var.key_name}.id_rsa.pub"
  private_key_file = "./.key_pair/${var.key_name}.id_rsa"
}
# Configuration for the private key algorithm
resource "tls_private_key" "keygen" {
  algorithm = "RSA"
  rsa_bits  = 2048
}
# Specifying local_file resources allows for file creation and command execution within the directory where terraform is run.
resource "local_file" "private_key_pem" {
  filename = local.private_key_file
  content  = tls_private_key.keygen.private_key_pem
  provisioner "local-exec" {
    command = "chmod 600 ${local.private_key_file}"
  }
}
resource "local_file" "public_key_openssh" {
  filename = local.public_key_file
  content  = tls_private_key.keygen.public_key_openssh
  provisioner "local-exec" {
    command = "chmod 600 ${local.public_key_file}"
  }
}
resource "aws_key_pair" "key_pair" {
  key_name   = var.key_name
  public_key = tls_private_key.keygen.public_key_openssh
  provisioner "local-exec" {
    command = <<-EOT
     echo "${tls_private_key.keygen.private_key_pem}" > /mnt/c/Users/tihou/.ssh/${var.key_name}.pem
    EOT
  }
}
data "aws_ami" "amzlinux2" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}
resource "aws_instance" "sample-ec2-bastion" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-public01.id
  security_groups = [aws_security_group.sample-sg-bastion-terraform.id, aws_default_security_group.default.id]
  associate_public_ip_address = true
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-bastion"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
resource "aws_instance" "sample-ec2-web01" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-private01.id
  security_groups = [aws_security_group.sample-sg-bastion-terraform.id, aws_default_security_group.default.id]
  associate_public_ip_address = false
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-web01"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
resource "aws_instance" "sample-ec2-web02" {
  ami             = data.aws_ami.amzlinux2.id
  instance_type   = "t2.micro"
  subnet_id       = aws_subnet.sample-subnet-private02.id
  security_groups = [aws_default_security_group.default.id]
  associate_public_ip_address = false
  key_name                    = aws_key_pair.key_pair.key_name
  tags = {
    Name = "sample-ec2-web02"
  }
  lifecycle {
    ignore_changes = [ami]
  }
}
resource "local_file" "config_file" {
  # filename = "/home/tihoutaikai2011/.ssh/config"
  filename = "/mnt/c/Users/tihou/.ssh/config"
  content  = <<-EOT
Host bastion
    Hostname ${aws_instance.sample-ec2-bastion.public_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
Host web01
    Hostname ${aws_instance.sample-ec2-web01.private_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
    ProxyCommand ssh.exe -W %h:%p bastion
Host web02
    Hostname ${aws_instance.sample-ec2-web02.private_ip}
    User ec2-user
    IdentityFile ~/.ssh/hoge-key.pem
    ProxyCommand ssh.exe -W %h:%p bastion
    EOT
}
# Creating an Application Load Balancer
# Balancer part
resource "aws_lb" "sample-elb-terraform" {
  name               = "sample-elb-terraform"
  internal           = false
  load_balancer_type = "application"
  // Only valid for application type load balancers
  security_groups = [aws_security_group.sample-sg-elb-terraform.id, aws_default_security_group.default.id]
  subnets         = [aws_subnet.sample-subnet-public01.id, aws_subnet.sample-subnet-public02.id]
  ip_address_type = "ipv4"
}
# Target group
resource "aws_lb_target_group" "sample-tg-terraform" {
  name     = "sample-tg-terraform"
  port     = 3000
  protocol = "HTTP"
  vpc_id   = aws_vpc.sample-vpc.id
}
# Registering EC2 instances to the target group
resource "aws_lb_target_group_attachment" "sample-target_ec01" {
  target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  target_id        = aws_instance.sample-ec2-web01.id
}
resource "aws_lb_target_group_attachment" "sample-target_ec02" {
  target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  target_id        = aws_instance.sample-ec2-web02.id
}
# Listener configuration
resource "aws_lb_listener" "sample-tg" {
  load_balancer_arn = aws_lb.sample-elb-terraform.arn
  port              = "80"
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.sample-tg-terraform.arn
  }
}

Conclusion

In this post, we created a server using AWS and Terraform.
As a beginner, I'm honestly glad I was able to build this much.
However, looking back at the conceptual explanations, I feel my understanding is still shallow.
When I blog about other resources like S3 in the future, I want to research more thoroughly and share my understanding.
Thank you for reading this far.

References

AWS Documentation
Introduction to Infrastructure Construction with AWS 2nd Edition: How to Build a Safe and Robust Production Environment
Textbook for Understanding All AWS Basics, Mechanisms, and Key Terms
Amazon Web Services: Building Networks and Servers from the Ground Up Revised 3rd Edition

Discussion