Day 001 of 100DaysAWSIaCDevopsChallenge
In this article I am going to design a simple AWS infrastructure and build it using three IaC (Infrastructure As Code) tools: AWS CloudFormation, AWS CDK and Terraform. The most popular of these is Terraform which provides the easiest way to create cloud insfrastructure. it gives the hability to build in any cloud providers such AWS, Google Cloud Platform, Microsoft Azure Kubernetes. While you can only build an AWS Infrastructure using CloudFormation or CDK, because these tools are designed by Amazon to fit their solution.
The infrastructure to build consist of creating an EC2 instance, which will be publicly accessible on port 80 (HTTP connection) and port 22 (SSH connection). It will reside inside a Virtual Private Cloud (VPC) that contains one public subnet. To make EC2 connects and communicates with internet, I will create an internet gateway for the VPC that will route traffic from the internet into the subnet and vice versa.
Table of contents
- Diagram of the infrastructure
- Network section
- Security section
- Compute section
- Deploy Infrastructure
Prerequises
- Basic acknownleage of AWS Networking (VPC, Subnet and Routing)
- Basic acknownleage of AWS Security (Security group and Key Pair)
- Elastic cloud computer (EC2)
- Terraform
- CloudFormation & CDK
- Typescript
Diagram of the infrastructure
Network section
Virtual Private Cloud (VPC)
In this section I am going to create the network resources that will route users coming from the internet to our cloud. To do this I will start by creating the virtual private cloud (VPC)
_
Terraform version
# The main VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "MainVpc"
}
}
In this project, I use IPv4 protocol, it's also possible to use IPv6.
cidr_block: This is an optional field, if you don't specify it, you must fill in
ipv4_netmax_length
which aws will used to derived the CIDR block from IPAM. The allowed block size ranges from /16 to /28. Change this field by ipv6_cidr_block if you want to use IPv6 protocol instead (respectivelyipv6_netmax_length
if you want amazon to derived it from IPAM).enable_dns_support: To enable/disable DNS support in the VPC.
enable_dns_hostnames: To enable/desable DNS hostnames support in the VPC.
tags: the map of tags to assign to the VPC resource.
CloudFormation version
Resources:
MainVpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !FindInMap [ CidrMap, Vpc, Value ] # will return "10.0.0.0/16"
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: "MainVpc"
You can see that the configuration are the same like terraform
+----------------------+--------------------+
| Terraform | CoudFormation |
|----------------------+--------------------|
| cidr_block | CidrBlock |
| enable_dns_support | EnableDnsSupport |
| enable_dns_hostnames | EnableDnsHostnames |
| tags | Tags |
+----------------------+--------------------+
AWS CDK - Typescript
const vpc = new ec2.CfnVPC(this, "MainVpc", {
enableDnsHostnames: true,
enableDnsSupport: true,
instanceTenancy: "default",
cidrBlock: props.cidrVpc,// will return "10.0.0.0/16"
tags: [{key: 'Name', value: 'MainVpc'}]
,
});
I use to create my VPC using the Level 1 to avoid the additional resources which could be created if I use level 2 (ec2.Vpc(this, "MainVpc, {...})). For more details about the configuration refere to terraform section.
Public Subnet
Now I am going to create subnet. It is simple to understand. To create a subnet there are three mandatories parameters.
- The VPC resource ID: we can get this parameter in the previous VPC created
- The Availlability Zone: the availlability zone in which the subnet will be created
- The IP address range (CIDR block): The CIDR block will be within our VPC's CIDR block. To have a suitable way, I will use the utility function provided by all IaC tools based on the VPC CIDR.
As the subnet will be public, I am going to add an additional parameter that tell aws to associate a public IP address to our EC2 Instance on launch time.
Let's jump to the code:
</> Terraform
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, 4)
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
enable_resource_name_dns_a_record_on_launch = true
depends_on = [aws_vpc.main]
tags = {
Name = "PublicSubnet"
}
}
Terraform provide for us cidrsubnet(cidr_based, count, bits)
function to create cidr for the subnet based the parent cidr (in this case the vpc's cidr).
I create the dependency with the VPC by using depends_on
, it instructs terraform to create the vpc first and then create the subnet. So that we can extract the resource Id of vpc to attach it to the subnet.
Note that I add map_public_ip_on_launch
and enable_resource_name_dns_a_record_on_launch
to associate Public IP Adress and generate the dns address for us.
</> CloudFormation
Mappings:
CidrMap:
Vpc:
Value: "10.0.0.0/16"
...
Resource:
PublicSubnet:
Type: AWS::EC2::Subnet
DependsOn: MainVpc
Properties:
VpcId: !Ref MainVpc
CidrBlock: !Cidr [!FindInMap [ CidrMap, Vpc, Value ], 8, 4 ]
AvailabilityZone: !Join [ "", [ !Ref Region, "a" ] ]
MapPublicIpOnLaunch: true
PrivateDnsNameOptionsOnLaunch:
EnableResourceNameDnsARecord: true
HostnameType: ip-name
Tags:
- Key: Name
Value: "Public-Subnet"
!Ref MainVpc
return the VPC Resource ID previously created
and !Join [ "", [ !Ref Region, "a" ] ]
return us-east-1a, considering that the current region is us-east-1
</> AWS CDK - Typescript
const publicSubnet = new ec2.CfnSubnet(this, "PublicSubnet", {,
cidrBlock: Fn.cidr(props.cidrVpc, 8, 4),
vpcId: vpc.attrVpcId,
availabilityZone: "us-east-1",
mapPublicIpOnLaunch: true,
tags: [{key: 'Name', value: 'PublicSubnet'}]
});
vpc.attrVpcId
return the VPC Resource ID previously created
Ineternet Gateway
Remember I call my subnet a Public Subnet because all the underlined resources need to exchange with internet. To make this possible I need to configure an Internet Gateway. An Internet gateway helps us to route traffic throught the internet. Internet gateway is very simple to create whether with terraform, Cloudformation or even CDK. However, there's a slight difference between Terraform and Cloudformation/CDK. When using terraform you need to provide the VPC Resource ID directly, while with Cloudformation and CDK, you to attach it separatly.
</> Terraform
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id //<-- mandatory
depends_on = [aws_vpc.main]
tags = {
Name = "InternetGateway"
}
}
The vpc_id
is required in terraform.
</> CloudFormation
MyInternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: "InternetGateway"
And then attach to the VPC in another resource called AWS::EC2::VPCGatewayAttachment
Let's now attach our VPC to the internet gateway
IGWVpcAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref MainVpc
InternetGatewayId: !Ref MyInternetGateway
</> CDK
const igw = new ec2.CfnInternetGateway(this, "MyInternetGateway", {
tags: [{key: 'Name', value: 'InternetGateway'}],
});
// Attach VPC to internet gateway
new ec2.CfnVPCGatewayAttachment(this, `VpcGatewayAttachment`, {
vpcId: vpc.attrVpcId,
internetGatewayId: igw.attrInternetGatewayId,
});
igw.attrInternetGatewayId
return the Internet Gateway Resource ID
Routing
Now that our VPC, Subnet and Internet gateway are created, the next step is to create the routing inside our cloud infrastructure. To do this AWS has made availlable for us the Route Table. The concept is simple: the route table determines where network traffic is directed based on the destination IP address. Public subnet in your VPC is associated with a route table that controls the traffic flow between the subnet.
To establish routing withn your cloud infrastructure, I need to first create a Route for our VPC and Internet Gateway, then create a route table to associate with the public subnet. The method differs slightly between terraform and cloudformation/cdk. I will explain the difference in the code section.
</>Terraform
resource "aws_route_table" "rt" {
vpc_id = aws_vpc.main.id
route { // <-- the route
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
depends_on = [aws_vpc.main]
tags = {
Name = "RouteTable"
}
}
As you can see, the route is directly included in the terraform aws_route_table resource route
. Note that, it's possible to configure more than one route into the aws_route_table resource.
On the other hand, if you don't want to include route(s)
in the aws_route_table resource, you can create its separactly like this:
# the Route Table
resource "aws_route_table" "rt" {
vpc_id = aws_vpc.main.id // required
depends_on = [aws_vpc.main]
tags = {
Name = "RouteTable"
}
}
# The route
resource "aws_route" "myroute" {
route_table_id = aws_route_table.rt.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
And then associate the subnet to the route table created.
resource "aws_route_table_association" "rt_assoc" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.rt.id
}
Op! la! The routing is well configured using the terraform tool 😇.
</> Cloudformation
For cloudformation I need first to create a route table
for our VPC, then create the route
that associates the Internet Gateway + route table previously created + destination CIDR Block. After the route table and its routes is created, I added a new resource to associate our subnet with the route table. Let's jump into the code right now:
Mappings:
CidrMap:
...
Internet:
Value: "0.0.0.0/0"
...
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref MainVpc
Tags:
- Key: Name
Value: "RouteTable"
MainRoute:
Type: AWS::EC2::Route
Properties:
GatewayId: !Ref InternetGateway
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: !FindInMap [ CidrMap, Internet, Value ]
PublicSubnetRoutingAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
Now the routing is well configured using the cloudformation tool.
CDK
The principle is exactly the same as for cloudformation
const routeTable = new ec2.CfnRouteTable(this, "RouteTable", {
vpcId: vpc.attrVpcId,
tags: [{key: 'Name', value: 'RouteTable'}],
});
const routeGateway = new ec2.CfnRoute(this, "Route", {
routeTableId: routeTable.attrRouteTableId,
destinationCidrBlock: props.cidrVpcInternet,
gatewayId: igw.attrInternetGatewayId
});
new ec2.CfnSubnetRouteTableAssociation(this, `SubnetRouteTable-attach`, {
subnetId: publicSubnet.attrSubnetId,
routeTableId: routeTable.attrRouteTableId
});
Security Section
This part consist of creating the barrier around the Ec2 Instance. I need to expose my instance to the internet on port 80 by using security group
. Additionaly, I need to configure ssh access for my instance. Furthermore, I will create Aws KeyPair that allow me to access my Instance.
</> Terraform
Firstly, In Terraform I want to generate the public and private keys that we will need to create the AWS KeyPair. This is the snippets code to generate these keys:
resource "tls_private_key" "key" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "local_file" "privatekey" {
filename = "day1kp.pem"
content = tls_private_key.key.private_key_pem
depends_on = [tls_private_key.key]
}
As you can see I used tls
and local
providers to generate keys and save the private key locally (It will help to use ssh
bash command). for more about tls and local providers, refer to tls docs🔗,local doc 🔗
now, I create the keypair with the private key previously created
resource "aws_key_pair" "keypair" {
key_name = "day1kp"
public_key = tls_private_key.key.public_key_openssh
depends_on = [tls_private_key.key]
tags = {
Name = "Instance-KeyPair"
}
}
⚠️ Note
: here the public key need to store in the EC2 Instance
Let's now create the securities groups
which will use by the EC2 Instance.
variable "host_machine_ip_addr" {
type = string
}
resource "aws_security_group" "ssh" {
name = "Allow-SSH"
description = "Allow SSH inbound traffic"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "all"
cidr_blocks = ["0.0.0.0/0"]
description = "allow internet access outbound"
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["${var.host_machine_ip_addr}/32"]
}
depends_on = [data.local_file.ipfile]
tags = {
Name = "Allow-SSH"
}
}
resource "aws_security_group" "http" {
name = "http-sg"
vpc_id = aws_vpc.main.id
description = "Allow HTTP traffic from/to ec2"
egress {
from_port = 0
to_port = 0
protocol = "all"
cidr_blocks = ["0.0.0.0/0"]
description = "allow internet access outbound"
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "allow internet access inbound"
}
tags = {
Name = "Allow-HTTP"
}
}
var.host_machine_ip_addr
contains the IP address which will use for SSH connection.
</> CloudFormation
Mappings:
CidrMap:
...
Internet:
Value: "0.0.0.0/0"
...
Parameters:
HostMachineIpAddr:
Type: String
Description: "The host machine Ip Address"
Resource:
InstanceKeyPair:
Type: AWS::EC2::KeyPair
Properties:
KeyName: "day1kp"
KeyFormat: pem
KeyType: rsa
Tags:
- Key: Name
Value: "InstanceKeyPair"
InstanceSecurityGroup:
Type: AWS::EC2::SecurityGroup
DependsOn: MainVpc
Properties:
GroupDescription: "Allowing traffics in/out ec2 instance"
GroupName: "Allow-HTTP-SSH"
VpcId: !Ref MainVpc
SecurityGroupEgress:
- CidrIp: !FindInMap [ CidrMap, Internet, Value ]
Description: "Allow traffic to the internet"
FromPort: 0
ToPort: 0
IpProtocol: "-1"
SecurityGroupIngress:
- CidrIp: !FindInMap [ CidrMap, Internet, Value ]
Description: "Allow HTTP traffic to the instance"
FromPort: 80
ToPort: 80
IpProtocol: "tcp"
- CidrIp: !Join [ "/", !Ref HostMachineIpAddr, "32" ]
Description: "Allow SSH traffic to the instance"
FromPort: 22
ToPort: 22
IpProtocol: "tcp"
Tags:
- Key: Name
Value: "InstanceSecurityGroup"
After the Stack is created, if you want to connect to the instance using ssh, you can import the private-key which is stored in the Secret System Manager Parameter Store
during the stack creation. If you want more about the retrieving private key refer to the AWS CloudFormation docs
</> CDK
const cidrVpcInternet = "0.0.0.0/0";
const securityGroup = new ec2.CfnSecurityGroup(this, "SecurityGroup", {
groupDescription: "Allowing traffic from/to instance",
groupName: "allow-http-and-ssh",
vpcId: vpc.attrVpcId,
securityGroupEgress: [{
fromPort: 0,
toPort: 0,
ipProtocol: '-1',
description: "Allow the outbound traffic to anywhere",
cidrIp: cidrVpcInternet
}],
securityGroupIngress: [{
fromPort: 22,
toPort: 22,
ipProtocol: "tcp",
description: "Allow SSH traffic",
cidrIp: cidrVpcInternet
},
{
fromPort: 80,
toPort: 80,
ipProtocol: "tcp",
description: "Allow HTTP traffic",
cidrIp: cidrVpcInternet
}],
tags: [{Key: 'Name', Value: "WebserverSG"}]
});
// key pair for ssh connection
const keypair = new ec2.CfnKeyPair(this, "InstanceKeyPair", {
keyName: "day1kp",
keyType: "rsa",
keyFormat: "pem",
tags: [{Key: 'Name', Value: "Webserver-KeyPair"}]
});
Computer Section
Now that everything is configured, let's create the EC2 Instance inside the subnet.
</> tarraform
resource "aws_instance" "webapp" {
instance_type = "t2.micro"
ami = var.ami_ubuntu-2204-tls
key_name = aws_key_pair.keypair.key_name
vpc_security_group_ids = [aws_security_group.http.id, aws_security_group.ssh.id]
subnet_id = aws_subnet.public.id
user_data = templatefile("bootstrap.sh.tpl", {})
depends_on = [
aws_subnet.public,
aws_key_pair.keypair,
aws_security_group.http,
aws_security_group.ssh
]
tags = {
Name = "WebAppInstance"
}
}
Below is the content of bootstrap.sh.tpl
#!/bin/bash
sudo su
apt update -y
apt install nginx -y
systemctl start nginx.service
</> Cloudformation
Mappings:
Ec2Settings:
Type:
Value: t2.micro
AMI:
Value: ami-04b70fa74e45c3917
Resources:
WebInstance:
Type: AWS::EC2::Instance
DependsOn:
- PublicSubnet
- InstanceSecurityGroup
Properties:
ImageId: !FindInMap [ Ec2Settings, AMI, Value ]
InstanceType: !FindInMap [ Ec2Settings, Type, Value ]
Tenancy: default
SubnetId: !Ref PublicSubnet
SecurityGroupIds:
- !Ref InstanceSecurityGroup
KeyName: !Ref InstanceKeyPair
UserData:
Fn::Base64: !Sub |
#!/bin/bash
sudo apt update -y
sudo apt install nginx -y
sudo systemctl start nginx.service
Tags:
- Key: Name
Value: "WebAppInstance"
</> CDK
const userData = UserData.forLinux();
userData.addCommands(readFileSync("./assets/ec2_bootstrap_script.sh", "utf-8"))
const webserver = new ec2.CfnInstance(this, "WebInstance", {
keyName: keypair.keyName,
subnetId: subnet.attrSubnetId,
instanceType: "t2.micro",
imageId: "ami-04b70fa74e45c3917",
securityGroupIds: [sg.attrGroupId],
userData: Fn.base64(userData.render()),
tags: [{Key: "Name", Value: "Webserver-Instance"}]
});
webserver.addDependency(sg);
webserver.addDependency(subnet);
And the content of ./assets/ec2_bootstrap_script.sh
sudo apt update
sudo apt install nginx -y
sudo systemctl start nginx
⚠️⚠️ Pay attention about the script above. You can notice it doesn't start with a shebang #!/bin/bash
, it's because the userData.render()
method automatically adds the linux shebang #!/bin/bash
if it isn't provided. To avoid the default value we can specify another shebang during the initialization of userData like this:
const userData = ec1.UserData.forLinux({
shebang: '#!/env/bin/sh sh' // 🤓
});
Run differents codes
Now that we have setup up and explain each layer steb by step, it's time to run our entire infrastructure.
To begin, clone first the code by typing:
git clone \
--branch day1/create-an-instance-inside-vpc-and-igw \
https://github.com/nivekalara237/100DaysTerraformAWSDevops.git
</> Terraform
cd 100DaysTerraformAWSDevops/day1/terraform
terraform init -upgrade
terraform plan # and expect that everything is good
# -auto-approve is to avoid the manual approval
terraform apply -auto-approve
</> CloudFormation
cd 100DaysTerraformAWSDevops/day1/cfn
aws cloudformation deploy \
--stack-name MyStack \
--template-file stack.yaml \
--capabilities "CAPABILILITY_NAMED_IAM" \
--profile cfn-user
Or you can deploy the stack file directly in aws cloudformation console
</> CDK
cd 100DaysTerraformAWSDevops/day1/cdk
# https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html
npm install -g aws-cdk
npm install
# prepare aws environment for stack deployment
cdk bootstrap --profile cdk-user
# display the resources to deploy (optional)
## same as `terraform plan`
cdk diff --profile cdk-user
# and then deploy
cdk deploy --profile cdk-user
We've reached the end of this tutorial which marks my debut on dev.to 🤩
Find the full code at Github repository
Hope It can help.
Top comments (2)
Nice article!
Thank you @ivanbass 🙂