Simple Ec2 Instance

Creating infrastructure with code is an extremely powerful tool that can abstract away the need for on-premises hardware. Instead of attempting to hand provision things in private cloud consoles such as AWS, GCP, or Azure we can instead rely on tools such as Terraform and Pulumi to help automate this and in both cases help to keep track of the state of the infrastructure. Sometimes, if you just need something quickly, one could also use Bash and the aws-cli to provision the same. Be aware, however, that you would need to write an accompanying uninstall script and/or destroy the resources manually.

After trying out both Terraform and Pulumi it’s safe to say that Pulumi is extremely easy to use, fast, and developer friendly. For the purposes of a demo let’s install the cli tool with brew install pulumi for those on macOS. Next, we can run pulumi new <project-name> and the CLI will walk you through provisioning a new project. For the purposes of comparison let’s try and serve hello world at port 80 on an ec2 instance in AWS. Using the pulumi python library that will look something like the following:

#!/usr/bin/env python3

from pulumi import export
import pulumi_aws as aws

vpc = aws.ec2.Vpc(
    "ec2-vpc",
    cidr_block="10.0.0.0/16"
)

public_subnet = aws.ec2.Subnet(
    "ec2-public-subnet",
    cidr_block="10.0.101.0/24",
    tags={
        "Name": "ec2-public"
    },
    vpc_id=vpc.id
)

igw = aws.ec2.InternetGateway(
    "ec2-igw",
    vpc_id=vpc.id,
)

route_table = aws.ec2.RouteTable(
    "ec2-route-table",
    vpc_id=vpc.id,
    routes=[
        {
            "cidr_block": "0.0.0.0/0",
            "gateway_id": igw.id
        }
    ]
)

rt_assoc = aws.ec2.RouteTableAssociation(
    "ec2-rta",
    route_table_id=route_table.id,
    subnet_id=public_subnet.id
)

sg = aws.ec2.SecurityGroup(
    "ec2-http-sg",
    description="Allow HTTP traffic to EC2 instance",
    ingress=[{
        "protocol": "tcp",
        "from_port": 80,
                "to_port": 80,
                "cidr_blocks": ["0.0.0.0/0"],
    }],
    vpc_id=vpc.id,
)

ami = aws.ec2.get_ami(
    most_recent="true",
    owners=["amazon"],
    filters=[{"name": "name", "values": ["amzn-ami-hvm-*"]}]
)


user_data = """
#!/bin/bash
echo "Hello, world!" > index.html
nohup python -m SimpleHTTPServer 80 &
"""

ec2_instance = aws.ec2.Instance(
    "ec2-tutorial",
    instance_type="t2.micro",
    vpc_security_group_ids=[sg.id],
    ami=ami.id,
    user_data=user_data,
    subnet_id=public_subnet.id,
    associate_public_ip_address=True,
)

export("ec2-public-ip", ec2_instance.public_ip)

To run the configuration you can simply issue pulumi up from the root directory of the pulumi project and the script will provision the specified resources and save the state of the resources! Run time was about 20 seconds to provision the resources. We can verify the installation went ok by using curl to contact our simple HTTP server with curl <public_ip> and to tear down the resources use pulumi destroy and the specified resources will be torn down, this took about 1 minute.

Similarly, to do the same in Terraform is a little bit more leg work on behalf of the module creator. Whereas in Pulumi we didn’t need to be quite so explicit, Terraform will expect extremely explicit and well planned inputs. If you are on macOS install Hashicorp’s Terraform with:

  • brew install hashicorp/tap/terraform
  • brew install terraform A helpful alias is alias tf=terraform, so you can skip some typing later on. We’re going to lay out the Terraform project something like:
tree .
.
├── ec2
│   ├── main.tf
│   └── outputs.tf
├── main.tf
└── outputs.tf

We’ll create an ec2 module, best practice might dictate factoring some of the components like the vpc to it’s own module, but in the interest of brevity let’s call it a complete ec2 example including everything the ec2 instance might need:

main.tf

terraform {
  required_version = ">= 0.13.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 4.7"
    }
  }
}

module "ec2-terraform" {
  source = "./ec2"
}

outputs.tf

  • We need to pass the public ip of the ec2 up from the module
output "ec2_module_says" {
  value = module.ec2-terraform.public_ip
}

ec2/main.tf

provider "aws" {
  region = local.region
}

locals {
  name            = "example-ec2"
  region          = "us-west-2"
  main_vpc_cidr   = "10.0.0.0/24"
  public_subnets  = "10.0.0.128/26"
  private_subnets = "10.0.0.192/26"

  user_data = <<-EOL
    #!/bin/bash -xe
    echo "Hello Terraform!" > index.html
    nohup python -m SimpleHTTPServer 80 &
  EOL

  tags = {
    Owner       = "user"
    Environment = "dev"
  }
}

resource "aws_vpc" "Main" {
  cidr_block       = local.main_vpc_cidr
  instance_tenancy = "default"
}


resource "aws_internet_gateway" "IGW" {
  vpc_id = aws_vpc.Main.id
}


resource "aws_subnet" "publicsubnets" {
  vpc_id     = aws_vpc.Main.id
  cidr_block = local.public_subnets
}


resource "aws_subnet" "privatesubnets" {
  vpc_id     = aws_vpc.Main.id
  cidr_block = local.private_subnets
}


resource "aws_route_table" "PublicRT" {
  vpc_id = aws_vpc.Main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.IGW.id
  }
}


resource "aws_route_table" "PrivateRT" {
  vpc_id = aws_vpc.Main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.NATgw.id
  }
}


resource "aws_route_table_association" "PublicRTassociation" {
  subnet_id      = aws_subnet.publicsubnets.id
  route_table_id = aws_route_table.PublicRT.id
}


resource "aws_route_table_association" "PrivateRTassociation" {
  subnet_id      = aws_subnet.privatesubnets.id
  route_table_id = aws_route_table.PrivateRT.id
}


resource "aws_eip" "nateIP" {
  vpc = true
}


resource "aws_nat_gateway" "NATgw" {
  allocation_id = aws_eip.nateIP.id
  subnet_id     = aws_subnet.publicsubnets.id
}


resource "aws_instance" "example_ec2_host" {
  count                       = 1
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.publicsubnets.id
  ami                         = data.aws_ami.amazon_linux.id
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.example_sg_ec2.id]

  user_data = local.user_data

  tags = {
    Name      = "ec2-example"
    Terraform = "true"
  }
}

# Create the security groups
resource "aws_security_group" "example_sg_ec2" {
  vpc_id = aws_vpc.Main.id
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow ssh from any"
  }
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow http from any"
  }
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow https from any"
  }
  ingress {
    cidr_blocks = ["0.0.0.0/0"]
    from_port   = 8
    to_port     = 0
    protocol    = "icmp"
    description = "Allow ping from any"
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow out to any"
  }
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn-ami-hvm-*-x86_64-gp2"]
  }
}

ec2/outputs.tf

output "public_ip" {
  description = "The public IP address assigned to the instance"
  value       = try(aws_instance.example_ec2_host[0].public_ip, "")
}

Once these files are defined and from the root directory of the project run tf init to initialize the Terraform project. Next, let’s plan the project and see what will be provisioned with tf plan -out=out.tfplan, and finally, if the changes are satisfactory, apply the plan with tf apply "out.tfplan". Resource creation took about a minute and twenty seconds, but I’ve seen it hang when provisioning resources in AWS so be patient. Once we’ve verified the HTTP server with curl <public_ip> we can tear everything down with a tf destroy.

For completeness checkout the following gist of an aws-cli script provisioning the same resources. As mentioned at the outset managing this installation and its configuration could be challenging to do manually which is something both Pulumi and Terraform do out of the box!

#!/usr/bin/env bash
set -euo pipefail
# Create a VPC
AWS_VPC=$(aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--query 'Vpc.{VpcId:VpcId}' \
--output text)
# Add a name tag to the VPC
aws ec2 create-tags \
--resources $AWS_VPC \
--tags Key=Name,Value=DevOpsVPC
# Enable DNS hostnames
aws ec2 modify-vpc-attribute \
--vpc-id $AWS_VPC \
--enable-dns-hostnames "{\"Value\":true}"
# Enable DNS support
aws ec2 modify-vpc-attribute \
--vpc-id $AWS_VPC \
--enable-dns-support "{\"Value\":true}"
# Create a public subnet
AWS_PUBLIC_SUBNET=$(aws ec2 create-subnet \
--vpc-id $AWS_VPC \
--cidr-block 10.0.1.0/24 \
--availability-zone us-east-1a \
--query 'Subnet.{SubnetId:SubnetId}' \
--output text)
# Add a name tag to the public subnet
aws ec2 create-tags \
--resources $AWS_PUBLIC_SUBNET \
--tags Key=Name,Value=DevOpsPublicSubnet
# create a private subnet
AWS_PRIVATE_SUBNET=$(aws ec2 create-subnet \
--vpc-id $AWS_VPC \
--cidr-block 10.0.2.0/24 \
--availability-zone us-east-1a \
--query 'Subnet.{SubnetId:SubnetId}' \
--output text)
# Add a name tag to the private subnet
aws ec2 create-tags \
--resources $AWS_PRIVATE_SUBNET \
--tags Key=Name,Value=DevOpsPrivateSubnet
# Enable auto-assign public IP on the public subnet
aws ec2 modify-subnet-attribute \
--subnet-id $AWS_PUBLIC_SUBNET \
--map-public-ip-on-launch
AWS_INTERNET_GATEWAY=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.{InternetGatewayId:InternetGatewayId}' \
--output text)
# Add a name tag to the Internet Gateway
aws ec2 create-tags \
--resources $AWS_INTERNET_GATEWAY \
--tags Key=Name,Value=DevOpsInternetGateway
# Get Elastic IP
AWS_ELASTIC_IP=$(aws ec2 allocate-address \
--domain vpc \
--query 'AllocationId' \
--output text)
# Create a NAT gateway
AWS_NAT_GATEWAY=$(aws ec2 create-nat-gateway \
--subnet-id $AWS_PUBLIC_SUBNET \
--allocation-id $AWS_EIP_ALLOCATION \
--query 'NatGateway.{NatGatewayId:NatGatewayId}' \
--output text)
# Add a name tag to the NAT gateway
aws ec2 create-tags \
--resources $AWS_NAT_GATEWAY \
--tags Key=Name,Value=DevOpsNATGateway
# Attach the Internet gateway to your VPC
aws ec2 attach-internet-gateway \
--vpc-id $AWS_VPC \
--internet-gateway-id $AWS_INTERNET_GATEWAY \
--query 'InternetGateway.{InternetGatewayId:InternetGatewayId}' \
--output text
# Create a custom route table
AWS_ROUTE_TABLE=$(aws ec2 create-route-table \
--vpc-id $AWS_VPC \
--query 'RouteTable.{RouteTableId:RouteTableId}' \
--output text)
# Add a name tag to the route table
aws ec2 create-tags \
--resources $AWS_ROUTE_TABLE \
--tags Key=Name,Value=DevOpsRouteTable
# Create a custom route table association
aws ec2 associate-route-table \
--route-table-id $AWS_ROUTE_TABLE \
--subnet-id $AWS_PUBLIC_SUBNET \
--output text
# Associate the subnet with route table, making it a public subnet
aws ec2 create-route \
--route-table-id $AWS_ROUTE_TABLE \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $AWS_INTERNET_GATEWAY \
--output text
# Associate the NAT gateway with the route table, making it a private subnet
aws ec2 create-route \
--route-table-id $AWS_ROUTE_TABLE \
--destination-cidr-block 10.2.0.0/24 \
--nat-gateway-id $AWS_NAT_GATEWAY \
--output text
# Create a security group
AWS_SECURITY_GROUP=$(aws ec2 create-security-group \
--group-name DevOpsSG \
--description "DevOps Security Group" \
--vpc-id $AWS_VPC \
--query 'GroupId' \
--output text)
# Add a name tag to the security group
aws ec2 create-tags \
--resources $AWS_SECURITY_GROUP \
--tags Key=Name,Value=DevOpsSG
# Add a rule to the security group
# Add SSH rule
aws ec2 authorize-security-group-ingress \
--group-id $AWS_SECURITY_GROUP \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0 \
--output text
# Add HTTP rule
aws ec2 authorize-security-group-ingress \
--group-id $AWS_SECURITY_GROUP \
--protocol tcp \
--port 80 \
--cidr 0.0.0.0/0 \
--output text
# Get the latest AMI ID
AWS_AMI=$(aws ec2 describe-images \
--owners 'amazon' \
--filters 'Name=name,Values=amzn2-ami-hvm-2.0.20221004.0-x86_64-gp2' \
'Name=state,Values=available' \
--query 'sort_by(Images, &CreationDate)[-1].[ImageId]' \
--output 'text')
# Create a bash run script
cat <<EOF > run.sh
#!/bin/bash
echo "Hello, aws-cli!" > index.html
nohup python -m SimpleHTTPServer 80 &
EOF
# Create an EC2 instance
AWS_EC2_INSTANCE=$(aws ec2 run-instances \
--image-id $AWS_AMI \
--instance-type t2.micro \
--key-name DevOpsKeyPair \
--monitoring "Enabled=false" \
--security-group-ids $AWS_SECURITY_GROUP \
--subnet-id $AWS_PUBLIC_SUBNET \
--user-data file://run.sh \
--private-ip-address 10.0.1.10 \
--query 'Instances[0].InstanceId' \
--output text)
# Add a name tag to the EC2 instance
aws ec2 create-tags \
--resources $AWS_EC2_INSTANCE \
--tags "Key=Name,Value=DevOpsInstance"
# Get the public ip address of your instance
AWS_PUBLIC_IP=$(aws ec2 describe-instances \
--instance-ids $AWS_EC2_INSTANCE \
--query 'Reservations[*].Instances[*].[PublicIpAddress]' \
--output text)
echo $AWS_EC2_INSTANCE_PUBLIC_IP
view raw aws-ec2.sh hosted with ❤ by GitHub

References


See also