In this post, we're going to talk about how to setup a beach head in the AWS cloud with a simple EC2 instance, spun up painlessly and repeatably, with Terraform. Terraform is the poster child of Infrastructure as Code, a DevOps process that's being quickly adopted as it provides not only easy, but more importantly repeatable infrastructure roll outs via a fairly straight forward (if not quite a programming language) definition that can be easily thrown into a Git repo and thus tracked as if all the infrastructure being managed was simply code objects in a giant application. There are "providers" for all sorts of things, from AWS to F5, to Dominos Pizza. Today, we're going to focus on the AWS provider, and more importantly, I'm going to show you how you can easily spin up an EC2 instance that's routable and could be easily configured to start serving web traffic today.
First off, I really recommend that you're going to want a git repo for this. It just really helps out to manage, publish, and track your infrastructure if you treat it as real code. (You could skip this step, but being that we will spin up AND tear down this infrastructure from this code I recommend it as running infrastructure in AWS costs money and not having the code around to run the tear down later would just make more work for you).
Okay, I've got my GitHub "congenial-octo-goggles" Repo, now can we start? First off, let's talk a little about the way Terraform declares blocks of code.
resource "resource_type" "your_name_for_the_resource" {
count = 1
a_setting = "A string"
a_list = ["foo","bar"]
a_map = { version = 38, name = "Fizzbuzz" }
a_number = 42
a_bool: true
option_block {
like_options: true
are_sometimes_in_blocks: 12345
}
}
You get the idea... if you've ever done much programming lately, I'm sure it looks pretty familiar, but remember it's not QUITE a programming language. These are resource defintion blocks. There's a few different kinds of blocks we use (we'll get into that later), but don't think you can go crazy with if/then/else and async/await blocks or something. You can get creative with bits to do some on the fly deduction, and later you'll see you can even do string manipulation and lookups, so it's still pretty powerful!
I'm sure you're itching to get something in AWS spun up, but we have ONE more thing. Terraform is a stateful language, meaning it tracks what it's done, what's in code, and what's running and then rectifies that on "apply". This means it needs somewhere to store it's state file. Personally, I highly recommend you store that in an aws s3 bucket. There are a few other backends supported, but s3 is quick, straight-foward, and you'll be able to get to it again when your computer dies. (seriously, commit this code to a git repo, and your state to s3 and it won't matter where you are or what computer you're running from, you can update your infrastructure.)
To put your state in s3, first setup an aws s3 bucket that you can access, and then throw it in the first file we'll create in this repo, backend.tf
:
terraform {
backend "s3" {
bucket = "<this_is_your_state_file_bucket>"
key = "us-east-2"
region = "us-east-2"
}
}
Once you've got that, we'll create our next file, providers.tf
.
# Configure the AWS Provider
provider "aws" {
profile = var.aws_profile
region = "us-east-2"
}
Yeap, we can put comments in there with either the #
or //
or /* */
syntax. It comes in really handy when things get complicated. Anyway, this file tells terraform we want to use the aws provider. We can even add a version or other attributes when needed, but this one is good. You'll notice something odd in that though, I didn't just use a string for the 'profile'... I specified var.aws_profile
? What's that mean? Well.. it's a variable, and we're going to declare it here, in the brand new variables.tf
file:
variable "aws_profile" {
type = string
description = "AWS Profile"
default = "default"
}
Yeap, that's a variable block. We can declare what ever we want as a variable, and when I mean variable, I mean commandline variable. If we didn't set a default, you'd have to add -var aws_profile=default
to your terraform run. However, throwing this in there not only teaches you about variables (Ha! Got you!), but it also allows you to run this against multiple locally stored aws profiles you've configured with the aws cli.
We now have enough coded to run a meaningful terraform command. Let's try it!
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v3.44.0...
- Installed hashicorp/aws v3.44.0 (self-signed, key ID 34365D9472D7468F)
Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
Whoohoo, we're ready to go. Let's create some resources!
So, we want to make an EC2 instance. But any good ec2 instance needs a network connection. And a network connection needs a NIC, and a NIC needs an IP and an IP needs a subnet, and a subnet needs a CIDR block... and we get all that in a VPC. VPC's are AWS's "Virtual Private Cloud"s, meaning that they allow you to define where your instances and other resources will run.
resource "aws_vpc" "my_vpc" {
cidr_block = "192.168.16.0/24"
tags = {
Name = "Primary VPC"
}
}
# This is 64 addresses for external
resource "aws_subnet" "dmz" {
vpc_id = aws_vpc.my_vpc.id
cidr_block = "192.168.16.0/26"
tags = {
Name = "DMZ Subnet"
}
}
Now, we've declared a VPC and we told terraform we want it to have a 64 IP address subnet at 192.168.16.0/26. Obviously, this can be whatever you want, but you'll notice another interesting bit here... the vpc_id is a variable, but not one that starts with "var". That's because it's a resource lookup. The subnet block uses the id
that it got from the vpc, because aws gives it a random one, and it wants to point to that one.
The syntax here is pretty straight forward: terraform destroy
can do all that for you. Plus, if you later decide you don't want that subnet, but want others, it can figure all that out for you!
resource "aws_security_group" "webserver" {
name = "Webserver-SG"
description = "Allow HTTP(s) inbound traffic"
vpc_id = aws_vpc.my_vpc.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_route_table_association" "dmz_assoc" {
subnet_id = aws_subnet.dmz.id
route_table_id = aws_route_table.dmz_route_table.id
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.thejml_east_vpc.id
tags = {
Name = "Eastern VPC Internet Gateway"
}
}
resource "aws_route_table" "dmz_route_table" {
vpc_id = aws_vpc.thejml_east_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
# route {
# cidr_block = aws.thejml_east_vpc.cidr
# route {
# ipv6_cidr_block = "::/0"
# egress_only_gateway_id = aws_egress_only_internet_gateway.foo.id
# }
tags = {
Name = "Primary Route Table"
}
}
resource "aws_instance" "omniserver" {
ami = var.ami
instance_type = "t3.micro"
associate_public_ip_address = true
subnet_id = aws_subnet.dmz.id
vpc_security_group_ids = [aws_security_group.webserver.id, aws_security_group.ssh.id]
iam_instance_profile = aws_iam_instance_profile.webserver.name
key_name = aws_key_pair.webserver.key_name
disable_api_termination = true
ebs_optimized = false
hibernation = false
monitoring = false
credit_specification {
cpu_credits = "unlimited"
}
enclave_options {
enabled = false
}
metadata_options {
http_endpoint = "enabled"
http_put_response_hop_limit = 1
http_tokens = "optional"
}
root_block_device {
volume_type = "gp2"
volume_size = 30
}
tags = {
Name = "Test Webserver"
key = "purpose"
value = "webserver"
propagate_at_launch = true
}
}
resource "aws_key_pair" "webserver" {
key_name = "Webserver host key"
public_key = "ssh-rsa <REDACTED HASH>"
}
So, it turns out that things in Terraform state are kinda useful for us humans as well. For example, the IP we just got for that webserver. It'd be really handy to just know that from the command line and do something with it instead of having to open up the AWS web console to snag it every time. It ALSO turns out that it's easy for terraform to tell you!
output "elastic_ip" {
value = aws_eip.webserver_eip.*.public_ip
description = "Elastic IP for Omniserver"
}
Adding that will allow you to run:
$ terraform output elastic_ip
[
"18.212.152.231",
]
It's a json doc, so you can either import that into another script, or flatten/parse it with jq
. If you leave elastic_ip
off, it'll just print out all of your defined outputs.
At this point, if you're ready to clean things up, all you have to do is run:
$ terraform destroy
As usual, it'll say what it's going to do, and then upon your confirmation, nuke everything we just built. Of course, since you have the code, you can do it again whenever you want!
We'll be covering some of these topics in the coming weeks so stay tuned!