AWS Development Environment (Three Approaches)

Diagram

Credits

The first thing I want to mention is that this post is a direct implementation of the course that was published by FreeCodeCamp and created by Derek Morgan.

I am adding to it to attempt to do the exercise in two different ways that are not the subject of the course. Derek’s original course was ‘Learn Terraform (and AWS) by Building a Dev Environment.’ My objectives are to duplicate his work in the console first, then in CloudFormation, and finally, using Terraform as his course teaches.

Objectives

My main objectives with this exercise are to:

  • Practice my AWS Console skills to help me prepare for my Sysops Associate exam labs
  • Develop and improve my CloudFormation skills
  • Learn the basics of Terraform
  • Potentially have a new development environment I can deploy to aid in future AWS projects from the outcome of this exercise

Basic Setup

The first step that the course takes is to create an API-enabled user for VSCode. Like the course, I will call mine VSCode and provide it with the same permissions (administrator access). I also installed the AWS extension (and configured it) for VSCode and Terraform.

Create VPC and Subnets

VPC

The first step is to create the VPC. In the console, this is done under the VPC section by adding a new VPC. In this case, we are creating a VPC with IPv4 addresses in the 10.123.0.0/16 range. The tutorial calls the vpc dev; in my implementation, I have used the name dev-vpc.

VPC

In addition, we need to ensure that VPC Hostnames are enabled for the resources in this VPC. This option is off by default. So we select Edit DNS Hostnames from the actions menu:

VPC

Then we tick the checkbox to enable DNS Hostnames and save that selection:

VPC

Internet Gateway

We need to attach an internet gateway to our VPC to allow internet access:

IGW

Internet Gateways do not automatically attach to a VPC, so we need to attach our VPC by choosing Attach from the Action menu.

Actions Menu

From here, we simply need to select the VPC to which to attach the Internet Gateway:

Attach

Subnets

Now we will create a public subnet, within our dev-vpc VPC, for our resources. This subnet will be named dev-public and reside in the us-east-1a availability zone. We will give it the CIDR range of 10.123.1.0/24:

Public Subnet

By default, subnets do not assign public IP addresses to resources, but we need to enable them, so first we edit the subnet settings:

Subnet Settings

Then we make sure we tick the “Enable auto-assign public IPv4 address” checkbox. In my case, I also checked the box to enable DNS A record on launch.

Set Subnet for Public IP

Route Table

We need to create a route table to allow our subnet to reach the internet gateway:

Route Table

To add routes to the new table, we need to select Edit Routes:

Edit Routes

From there, we need to add a default route to the route table; this will establish a route for anything outside of or VPC CIDR block to be directed toward the internet gateway (and from there to the internet):

Add Route

For our subnet to use the new route table, it must be associated with it - to start this process, we need to look under the Subnet Associations tab:

Subnet Associations Tab

From here, we can associate the subnet with the route table:

Associate Subnet

Security Group

To create the security group, we want to provide all inbound and outbound access to our resources. But we only want to do that from our IP address. So we do that as follows:

Security Group

Key Pair

Now we will need a Key Pair to authenticate with our instances. To create the Key Pair, you can create it directly in the console or upload a public key to AWS. I chose to do this. To create the key locally, use the following command:

➜  ~ ssh-keygen -t ed25519
Generating public/private ed25519 key pair.
Enter file in which to save the key (/Users/jeff/.ssh/id_ed25519): /Users/jeff/.ssh/vscode_dev
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/jeff/.ssh/vscode_dev
Your public key has been saved in /Users/jeff/.ssh/vscode_dev.pub
The key fingerprint is:
SHA256:hVBFnHfKV3hEoiz0Wxn8QHX9HehrCYy1JOB6qdKLMSg 
The keys randomart image is:
+--[ED25519 256]--+
|      .oo++. o==*|
|      .. +o=.o===|
|       .. Oo*oo=+|
|      . .o =o+. +|
|     . oS   o.o  |
|  . . o      +   |
|E. + o      .    |
|.   = .          |
|   . .           |
+----[SHA256]-----+

Note the fingerprint in the above output: hVBFnHfKV3hEoiz0Wxn8QHX9HehrCYy1JOB6qdKLMSg. This fingerprint should match what AWS displays when we upload the public key.

Then we go to the AWS EC2 console and select Key Pairs; then we do an import:

Import Key

From there, we need to provide our public key information - you can either paste it in or upload the file. I uploaded the file:

Import Key

(Note: I blurred out part of this because it contained personally identifiable information - not for key security. A public key can be freely shared without concern)

Now verify the key against the fingerprint from earlier - if you didn’t keep it, you could re-generate the fingerprint. Note that the fingerprints match:

Verify Key AWS

Verify Key Local

EC2 Instance

Name, AMI and Instance Type

We will name the instance dev-node. We are using Ubuntu 22.04 LTS Server and will be applying this on a t2.micro:

EC2 Part #1

We need to set the instance to use the Key Pair we just created and our custom VPC and subnet. Additionally, we are upgrading the disk to 10GB from the default 8GB:

EC2 Part #2

Finally, under the advanced details section, we will add some Userdata to configure the instance with software updates and docker automatically:

EC2 Part #3

Now we launch the instance!

We can SSH into the instance now and note that Docker is installed:

Confirm Instance

Note the private IP is in the subnet of our VPC and Subnet and that docker is installed and working from the UserData configuration.

After manually adding the IP address to the ~/.ssh/config file, we can use the SSH extension in VSCode to connect to the instance and work with VSCode using the instance and VSCode server rather than the local VSCode environment.

VSCode SSH

This completes the manual, console-based version; next up is a CloudFormation template version.

CloudFormation

So For Cloudformation I simply repeated the configuration above in template format. There were a few learnings along the way that surprised me:

  1. Systems Manager Parameter Store only provides AMIs for the most recent Amazon Linux Variant. So if you want to use another version of Linux, you have to devise your own solution. I found such a solution on StackOverflow where user kichik provided Cloudformation Templates that:
    • Created a Lambda Role
    • Created a Lambda Function that uses the describe-images endpoint of the EC2 API to find the latest image for a given criteria
    • Created a custom resource to provide this image id back to the rest of the template
  2. There seems to be an issue/bug with the new version of the Ubuntu Server if IPV6 is not enabled. So I had to add Acquire::ForceIPv4=true in several places to allow the UserData script to work correctly.
  3. Sometimes my Internet Gateway Route would be slow to deploy, so I had to add a DependsOn to the EC2 instance creation to make it wait long enough to have an internet connection reliably.

Here is the stack that I came up with:

AWSTemplateFormatVersion: '2010-09-09'
Description: |
  Creates a development environment like the one that Derek Morgan creates in his tutorial, shared by FreeCodeCamp
  in this youtube video (https://www.youtube.com/watch?v=iRaai1IBlB0&t=2s).  His tutorial is about Terraform but 
  I wanted to try to do the same thing in the Console, CloudFormation, and Terraform.  
Outputs:
  EC2InstanceIP:
    Description: Public IP Address Associated with the EC2 Instance
    Value: !GetAtt DevNode.PublicIp
Parameters:
  LocalCIDR:
    Type: String
    Description: Enter your local IP CIDR range (i.e. 55.55.55.55/32)
    Default: '0.0.0.0/0'
Resources:
  # The lines until the next comment are credited to use kichik (https://stackoverflow.com/users/492773/kichik)
  # from this Stack Overflow answer: https://stackoverflow.com/questions/55479945/cloudformation-always-use-latest-ami
  # These lines allow you to lookup a non-amazon linux AMI by creating a function that uses the boto3 sdk to 
  # query the describe-images endpoint for the required image.  This matches the functionality of the Terraform 
  # project this is based on with its ability to do this -- all be it much more easily.   The addition of this 
  # part of the code requires the addition of --capabilities CAPABILITY_IAM to your command line deploy of this template
  # -- Start Stack Overflow Sourced Find AMI code
  DescribeImagesRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: DescribeImages
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Action: ec2:DescribeImages
                Effect: Allow
                Resource: "*"
  GetLatestAMI:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.9
      Handler: index.handler
      Role: !Sub ${DescribeImagesRole.Arn}
      Timeout: 60
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import json
          import traceback
          def handler(event, context):
            try:
              response = boto3.client('ec2').describe_images(
                Owners=[event['ResourceProperties']['Owner']],
                Filters=[
                  {'Name': 'name', 'Values': [event['ResourceProperties']['Name']]},
                  {'Name': 'architecture', 'Values': [event['ResourceProperties']['Architecture']]},
                  {'Name': 'root-device-type', 'Values': ['ebs']},
                ],
              )

              amis = sorted(response['Images'], key=lambda x: x['CreationDate'], reverse=True)
              id = amis[0]['ImageId']

              cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, id)
            except:
              traceback.print_last()
              cfnresponse.send(event, context, cfnresponse.FAIL, {}, "ok")          
  UbuntuAmi:
    Type: Custom::FindAMI
    Properties:
      ServiceToken: !Sub ${GetLatestAMI.Arn}
      Owner: "099720109477"
      Name: "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
      Architecture: "x86_64"
  # -- End Stack Overflow Sourced Find AMI code
  DevVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.123.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: dev-vpc
  DevIGW:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: dev-igw
  IGWAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref DevIGW
      VpcId: !Ref DevVPC
  DevPublicSN:
    Type: AWS::EC2::Subnet
    Properties: 
      CidrBlock: 10.123.1.0/24
      VpcId: !Ref DevVPC
      MapPublicIpOnLaunch: True
      Tags:
        - Key: Name
          Value: dev-public
  DevPublicRT:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref DevVPC
      Tags:
        - Key: Name
          Value: dev_public_rt
  IGWRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref DevPublicRT
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref DevIGW
  PublicSNRTAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref DevPublicRT
      SubnetId: !Ref DevPublicSN
  DevSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: dev_sg
      GroupDescription: Dev Security Group
      VpcId: !Ref DevVPC
      SecurityGroupIngress:
        - IpProtocol: "-1"
          FromPort: 0
          ToPort: 0
          CidrIp: !Ref LocalCIDR
  DevKeyPair:
    Type: AWS::EC2::KeyPair
    Properties:
      KeyName: vs_code
      PublicKeyMaterial: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExY5J7HPyd4F1IKAOVjBdwcCcF+PflF+bqycSxBjRZG
  DevNode:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t2.micro
      ImageId: !Ref UbuntuAmi
      KeyName: !Ref DevKeyPair
      SubnetId: !Ref DevPublicSN
      SecurityGroupIds:
        - !Ref DevSG
      UserData: 
        Fn::Base64: |
          #!/bin/bash
          sudo apt-get update -o Acquire::ForceIPv4=true -y &&
          sudo apt-get install -o Acquire::ForceIPv4=true -y \
          apt-transport-https \
          ca-certificates \
          curl \
          gnupg-agent \
          software-properties-common &&
          curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - &&
          sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" &&
          sudo apt-get update -o Acquire::ForceIPv4=true -y &&
          sudo sudo apt-get install docker-ce docker-ce-cli containerd.io -o Acquire::ForceIPv4=true -y &&
          sudo usermod -aG docker ubuntu          
      Tags:
        - Key: Name
          Value: dev-node
    DependsOn: IGWRoute

Deploying this from the command line allowed me to include my IP address in the command to do the deployment fairly easily. I was also able to call the describe-stacks endpoint to get the final IP addresss for the instance.

➜  Scratch git:(main) ✗ IP=$(curl -4 icanhazip.com) && aws cloudformation deploy --template-file ./cloudformation_templates/dev-env.yaml --stack-name dev-stack --capabilities CAPABILITY_IAM --parameter-overrides LocalCIDR=$IP/32 --profile profile-name && aws cloudformation describe-stacks --stack-name dev-stack --profile profile-name | grep OutputValue
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    15  100    15    0     0    108      0 --:--:-- --:--:-- --:--:--   118

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - dev-stack
                    "OutputValue": "54.91.67.59",

With the final result being a connection with VSCode as before:

VSCode Cloudformation

And to shut down the instance and destroy the infrastructure, you can do that locally from the command line with:

aws cloudformation delete-stack --stack-name dev-stack --region us-west-2 --profile profile-name

I also successfully deployed this in a second region, making the template portable between regions.

Terraform

The Terraform part of this project is arguably the easiest of the three. Because I just followed the step-by-step video mentioned in the first paragraph.

In doing this exercise, what struck me was how much more intuitive the Terraform syntax and tooling seem to be when working with it. In addition, I appreciate the interactive and iterative way it allows you to work with the resources versus CloudFormations all or nothing approach.

Here are my final scripts:

datasources.tf

data "aws_ami" "server_ami" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

outputs.tf

output "dev_ip" {
    value = aws_instance.dev_node.public_ip
}

providers.tf

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region                   = "us-east-1"
  shared_credentials_files = ["~/.aws/credentials"]
  profile                  = "vscode"
}

variables.tf

variable "host_os" {
    type = string
    default = "linux"
}

terraform.tfvars

host_os = "linux"

And finaly, the main script: main.tf

resource "aws_vpc" "mtc_vpc" {
  cidr_block           = "10.123.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "dev"
  }
}

resource "aws_subnet" "mtc_public_subnet" {
  vpc_id                  = aws_vpc.mtc_vpc.id
  cidr_block              = "10.123.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "us-east-1a"

  tags = {
    Name = "dev-public"
  }
}

resource "aws_internet_gateway" "mtc_internet_gateway" {
  vpc_id = aws_vpc.mtc_vpc.id

  tags = {
    Name = "dev-igw"
  }
}

resource "aws_route_table" "mtc_public_rt" {
  vpc_id = aws_vpc.mtc_vpc.id

  tags = {
    Name = "dev_public_rt"
  }
}

resource "aws_route" "default_route" {
  route_table_id         = aws_route_table.mtc_public_rt.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.mtc_internet_gateway.id
}

resource "aws_route_table_association" "mtc_public_assoc" {
  subnet_id      = aws_subnet.mtc_public_subnet.id
  route_table_id = aws_route_table.mtc_public_rt.id
}

resource "aws_security_group" "mtc_sg" {
  name        = "dev_sg"
  description = "dev security group"
  vpc_id      = aws_vpc.mtc_vpc.id

  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["136.32.115.208/32"]
  }

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

resource "aws_key_pair" "mtc_auth" {
  key_name   = "mtckey"
  public_key = file("~/.ssh/vscode_dev.pub")
}

resource "aws_instance" "dev_node" {
  instance_type          = "t2.micro"
  ami                    = data.aws_ami.server_ami.id
  key_name               = aws_key_pair.mtc_auth.id
  vpc_security_group_ids = [aws_security_group.mtc_sg.id]
  subnet_id              = aws_subnet.mtc_public_subnet.id
  user_data              = file("userdata.tpl")

  root_block_device {
    volume_size = 10
  }
  tags = {
    name = "dev-node"
  }

  provisioner "local-exec" {
    command = templatefile("${var.host_os}-ssh-config.tpl", {
        hostname = self.public_ip
        user = "ubuntu"
        identityfile = "~/.ssh/vscode_dev"
    })
    interpreter = var.host_os == "windows" ? ["Powershell", "-Command"] : ["bash", "-c"]
  }
}

Ultimately, if given a choice between these tools, I would almost definitely choose to work with Terraform over Cloudformation. That said, I’m excited to use them more over the next few months to learn more about both of them.

Finally, thank you to Derek Morgan and FreeCodeAcademy for sharing this project. It was a fun exercise.