Table of Contents
Introduction
The AWS CDK opens the possibilities for adding wonderful features to your IaC and help bring it to life. Today we explore how we can use it to quickly bootstrap a high security application that can be reconfigured and customized for many use cases.
For this project we use the AWS CDK, a framework by AWS for defining your infrastructure as actual code in your programming language of choice. If you're new to the CDK then the workshop is the best introduction you can get for it.
Outcome
Creating an infrastructure that is redundant across multiple availability zones, end users can only access the app through a domain name, the database should only be accessible from the application servers. a secure way to access the servers for troubleshooting must be provided. Networking resources should be managed separately
Networking Infrastructure
For this project we have two files, A file where the network is defined and another where the app is defined on the network created. Let's look at the network file and we'll explain it bit by bit
from aws_cdk import (
Stack,
aws_ec2 as ec2
)
from constructs import Construct
class vpcStack(Construct):
def __init__(self, scope: Construct, construct_id: str, vpc_cidr='10.0.0.0/16', three_tier=True,**kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
''' AWS CDK Construct that creates a configurable VPC for hosting a three tier architecture'''
First we ask the question of what is this Construct
class that we use to define our VpcStack
Class ?
A construct is a resource, when you define an EC2 Instance, a Lambda function, a DynamoDB table.. etc in a CDK template we're defining what is known as a construct. We can create our custom resources by inheriting from this class and defining the actual resources we want in this new object as attributes of our class! Quite revolutionary right ? We will see how we implement this soon.
For now, Our class is instantiated using two extra custom parameters we provide for flexibility. vpc_cidr
and three_tier
.
The VPC is created by default with a 10.0.0.0/16 CIDR. but parametrizing this will allow us to later pass the CIDR as a command line argument. three_tier is a boolean value for conditional infrastructure creation, in case you have an existing database you may not want to create the needed resources for it.
if three_tier:
self.vpc = ec2.Vpc(
self, 'vpc',
cidr=vpc_cidr,
max_azs=2,
nat_gateways=2,
enable_dns_hostnames=True,
enable_dns_support=True,
subnet_configuration=[
ec2.SubnetConfiguration(
name= 'load_balancer',
subnet_type= ec2.SubnetType.PUBLIC,
cidr_mask= 24
),
ec2.SubnetConfiguration(
name= 'application',
subnet_type= ec2.SubnetType.PRIVATE_WITH_NAT,
cidr_mask= 24
),
ec2.SubnetConfiguration(
name= 'database',
subnet_type= ec2.SubnetType.PRIVATE_ISOLATED,
cidr_mask= 24
)
]
)
else:
self.vpc = ec2.Vpc(
self, 'vpc',
cidr=vpc_cidr,
max_azs=2,
nat_gateways=2,
enable_dns_hostnames=True,
enable_dns_support=True,
subnet_configuration=[
ec2.SubnetConfiguration(
name= 'load_balancer',
subnet_type= ec2.SubnetType.PUBLIC,
cidr_mask= 24
),
ec2.SubnetConfiguration(
name= 'application',
subnet_type= ec2.SubnetType.PRIVATE_WITH_NAT,
cidr_mask= 24
)
]
)
In case we don't need a three tier app. our construct will create 2 subnets per AZ in the VPC. one will host the load balancer and is a public subnet. the other is private subnet with access to a NAT gateway. it's important to note here that if we were using any other IaC tool we would need to define an internet gateway, a NAT gateway, route tables, routes and route table associations. all of this is automated away with a few simple lines of code
Next, let's define our security groups
# Load balancer security group
self.lb_sg = ec2.SecurityGroup(
self, 'lb-SG',
vpc=self.vpc,
allow_all_outbound=True
)
self.lb_sg.add_ingress_rule(
ec2.Peer.any_ipv4(),
ec2.Port.tcp(443)
)
self.lb_sg.add_ingress_rule(
ec2.Peer.any_ipv4(),
ec2.Port.tcp(80)
)
# Bastion security group
self.bastion_sg = ec2.SecurityGroup(
self, 'bastion-SG',
vpc=self.vpc,
allow_all_outbound=True
)
self.bastion_sg.add_ingress_rule(
ec2.Peer.any_ipv4(),
ec2.Port.tcp(22)
)
# Application layer security group
self.asg_sg = ec2.SecurityGroup(
self, 'asg-SG',
vpc=self.vpc,
allow_all_outbound=True
)
self.asg_sg.add_ingress_rule(
self.lb_sg,
ec2.Port.tcp(443)
)
self.asg_sg.add_ingress_rule(
self.lb_sg,
ec2.Port.tcp(80)
)
self.asg_sg.add_ingress_rule(
self.bastion_sg,
ec2.Port.tcp(22)
)
# Database security group if three tier is specified
if three_tier:
self.db_SG = ec2.SecurityGroup(
self, 'db-SG',
vpc=self.vpc,
allow_all_outbound=True
)
self.db_SG.add_ingress_rule(
self.asg_sg,
ec2.Port.tcp(5432)
)
Note how all resources are created as attributes of the class. this is so we can access all resources individually later in the stack. for now we want our load balancer to be internet facing, and the application to receive web traffic only from the load balancer and SSH traffic from a bastion server. Bastion servers are a central point of access for SSH traffic, this limits the attack surface for your network and heavily restricts traffic to your servers.
Source targets con be written as other security groups as we see, we don't need to specify an ID, simply passing the construct of other security groups is good enough to write a valid ingress rule.
You can read more about the numerous security benefits from this setup here. and also more on bastion hosts here.
Web Layer
from aws_cdk import (
Stack,
aws_ec2 as ec2,
aws_autoscaling as autoscaling,
aws_elasticloadbalancingv2 as elbv2,
aws_iam as iam,
aws_rds as rds,
Duration
)
from constructs import Construct
from .vpc_stack import vpcStack
class AppStack(Stack):
def __init__(self, scope: Construct, construct_id: str, three_tier=True, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# Attempt to get custom cidr from context if provided
context_cidr = self.node.try_get_context("cidr")
# Creating the networking resources
network = vpcStack(self, 'vpc', three_tier=three_tier, vpc_cidr=context_cidr if context_cidr else '10.0.0.0/16')
As we see, the app it self is inheriting from the Stack
class, unlike the networking resources. This Stack overflow answer clearly explains the difference between the two.
Here we can see the benefit from having added vpc_cidr
as a property. we can use the context variable to pass it dynamically during deployment like this:
cdk deploy -c cidr=172.0.0.0/16
. if it's not passed the default of 10.0.0.0/16 will be used. learn more about the CDK context in this link.
Now, let's define our servers..
# Define our server execution role
asg_role = iam.Role(self, "MyRole",
assumed_by=iam.ServicePrincipal("ec2.amazonaws.com")
)
asg_role.add_managed_policy(iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonEC2RoleforSSM"))
# Define our servers
asg = autoscaling.AutoScalingGroup(self, "ASG",
vpc=network.vpc,
instance_type=ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
machine_image=ec2.GenericLinuxImage({'us-east-1':'ami-0cff7528ff583bf9a'}),
security_group=network.asg_sg,
cooldown=Duration.minutes(1),
min_capacity=2,
max_capacity=8,
role=asg_role,
vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_NAT),
user_data=ec2.UserData.custom(
'''
#!/bin/bash
sudo yum update -y
sudo yum install -y httpd.x86_64
sudo systemctl start httpd
sudo systemctl enable httpd
sudo chmod 755 -R /var/www/html
sudo echo "hello world from $(hostname -f)" >> /var/www/html/index.html
'''
)
)
We want our servers in private subnets but not fully isolated, which is why we specify subnets with a route to the NAT gateway for outbound only connection to the internet.
We want them to be granted permissions to communicate with AWS SSM for secure management in case we don't want to use the bastion.
Also, we define our base OS using the GenericLinuxImage class despite that it's amazon linux. and amazon linux can be defined using a special class called AmazonlinuxImage. the reason for this is that using the amazon linux image class returned a very primitive Linux image that doesn't have systemctl, grep and other important linux modules so we use the default AMI ID for Amazon Linux in the us-east-1 region.
We also configure our servers to show a simple page for users, you can change the user data script to be anything else you want to deploy your own apps, such as cloning a repo and starting a nodeJS server. you can also create a machine image and deploy it instead of using Amazon Linux with user data
# Creating the bastion server
bastion = ec2.BastionHostLinux(self, "BastionHost",
vpc=network.vpc,
security_group=network.bastion_sg,
subnet_selection=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC)
)
# Configure load balancer
lb = elbv2.ApplicationLoadBalancer(self, "LB",
vpc=network.vpc,
internet_facing=True,
security_group=network.lb_sg
)
listener = lb.add_listener("Listener",
port=80
)
listener.add_targets("ApplicationFleet",
port=80,
targets=[asg]
)
First, we define a bastion server using the handy BastionHostLinux
class. this host has the security group that is the only allowed source for port 22 communication with the servers.
We define a load balancer with the application servers as targets when receiving HTTP traffic. many other properties can be defined depending on your application use case, such as session stickiness, connection draining period.. etc. this Page from the docs is a good summary of what you can do.
Data Layer
Again, only created if three_tier
option is specified..
if three_tier:
# in case a three tier architecture is specified, create a database and a
# secrets manager secret, and grants get secret value role to the
# application fleet
db_secrets = rds.DatabaseSecret(self, 'postgres-secret',
username='postgres',
secret_name='postgres-credentials'
)
db = rds.DatabaseInstance(self, "db",
engine=rds.DatabaseInstanceEngine.postgres(version=rds.PostgresEngineVersion.VER_13_4),
instance_type=ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
credentials=rds.Credentials.from_secret(db_secrets),
vpc=network.vpc,
multi_az=True,
security_groups=[network.db_SG],
vpc_subnets=ec2.SubnetSelection(
subnet_type=ec2.SubnetType.PRIVATE_ISOLATED
)
)
asg.add_to_role_policy(iam.PolicyStatement(
resources=[db_secrets.secret_arn],
actions=[
"secretsmanager:GetSecretValue"]
))
The database lives in a private isolated subnet, the only open connection is on the default engine port which is PostgreSQL in our case, and it's only open to the application layer's security group. we also define the database password as a secrets manager secret. and giving the application layer the ability to only retrieve the value of this secret only. In accordance with the least privilege principle. read more about AWS security best practices here
Conclusion
Application availability, security, quick and ease of deployment are no longer a hassle and are attainable for all organizations. and this is only the start...
this infrastructure can be easily turned to one which can stand regional failure. we can replace postgreSQL with Aurora global and configure DNS for failover routing. we can also enhance observability by exporting bastion SSH logs to Cloudwatch, and many other things.. I'm actually thinking of turning this into a mini series where i take this infrastructure and take it to its limits according to SRE principals. Let me know what you people think in the comments.
All the code for this article is available in this repo
Top comments (0)