In this post I’ll share a simple Node.js application with AWS S3 connectivity and the Terraform configuration files I used to provision the architecture in AWS ECS . I included S3 integration in this guide to show how IAM policies can be used with ECS tasks via Terraform.
I started by creating a simple Node.JS/Express app. To demonstrate S3 connectivity, on application start, it copies a text file containing the date into an S3 bucket, and subsequent HTTP requests fetch the file and return its contents. new file: express/app.js
const express = require ( ' express ' )
const app = express ()
const app_port = 80
# initialize AWS S3 connection and push the current date to a text file
const AWS = require ( ' aws-sdk ' )
const s3 = new AWS . S3 ()
const s3_bucket = process . env . S3_DATA_BUCKET
const s3_put_params = {
Body : ( new Date ()). toISOString (),
Bucket : s3_bucket ,
Key : ' app_start.txt '
}
s3 . putObject ( s3_put_params , function ( err , data ){
if ( err ) console . log ( err );
else console . log ( data );
})
const s3_get_params = {
Bucket : s3_bucket ,
Key : ' app_start.txt '
}
app . get ( ' / ' , function ( req , res ) {
s3 . getObject ( s3_get_params , function ( err , data ){
if ( err ) res . status ( err . statusCode ). send ( err . message )
else res . send ( data . Body . toString ())
})
})
app . listen ( app_port , function () {
console . log ( ' App starting on port: ' + app_port )
})
I created a simple Dockerfile that inherits from the Node.js image, copies the app.js and package.json files, and installs NPM packages. new file: express/Dockerfile
FROM node:6.11.1
ENV APP_HOME /express
WORKDIR $APP_HOME
COPY app.js $APP_HOME
COPY package.json $APP_HOME
RUN npm install
Next I added a shell script to build the Node.js image, tag it, and push the image to Amazon ECR . New file: express/build.sh
#!/bin/bash
set -e
cd " $( dirname " $0 " ) "
source ../terraform/.env
AWS_ACCOUNT_ID = $( aws sts get-caller-identity --output text --query 'Account' --profile $AWS_PROFILE )
# ensure ECR repository exists
existing_repo = $( aws --profile $AWS_PROFILE ecr describe-repositories --query "repositories[?repositoryName==' ${ ECR_REPO } '].repositoryName" --output text)
if [ -z $existing_repo ] ; then
aws --profile $AWS_PROFILE ecr create-repository --repository-name $ECR_REPO
fi
# build, tag, push to AWS ECR
docker_login = $( aws ecr get-login --no-include-email --region $AWS_REGION )
eval $docker_login
docker build -t $ECR_REPO .
docker tag $ECR_REPO :latest $AWS_ACCOUNT_ID .dkr.ecr.$AWS_REGION .amazonaws.com/$ECR_REPO :latest
docker push $AWS_ACCOUNT_ID .dkr.ecr.$AWS_REGION .amazonaws.com/$ECR_REPO :latest
The above script (and following terraform script) loads environment variables from a “.env” file. Example file: terraform/.env
AWS_KEY_NAME =
AWS_PROFILE = default
AWS_REGION = us-east-1
AWS_SECURITY_GROUP_IDS = '["sg-########"]'
AWS_SUBNET_ID = subnet-########
EC2_AMI_ID = ami-04351e12
EC2_INSTANCE_TYPE = m4.large
ECR_REPO = eric-test/express
S3_DATA_BUCKET = eric-express-data
TF_STATE_BUCKET = eric-terraform-state
TF_STATE_KEY = terraform/ecs.tfstate
Part 2: Terraform
The first file I added is used to configure the Terraform backend to store state in S3. I left the values empty since they are loaded from the “.env” file and passed to Terraform via terraform init
. New file: terraform/backend.tf
terraform {
# Stores the Terraform state in S3
# https://www.terraform.io/docs/backends/types/s3.html
backend "s3" {
bucket = ""
key = ""
profile = ""
region = ""
}
}
I added a variables file which will also be loaded from “.env”. new file: terraform/vars.tf
provider "aws" {
region = "${var.aws_region}"
profile = "${var.aws_profile}"
max_retries = "10"
}
variable "aws_key_name" {
type = "string"
}
variable "aws_profile" {
type = "string"
}
variable "aws_region" {
type = "string"
default = "us-east-1"
}
variable "aws_security_group_ids" {
type = "list"
}
variable "aws_subnet_id" {
type = "string"
}
variable "ec2_ami_id" {
type = "string"
}
variable "ec2_instance_type" {
type = "string"
}
variable "express_ecr_image" {
type = "string"
}
variable "s3_data_bucket" {
type = "string"
}
Next I added a file to contain S3 specific configurations. It provides an S3 bucket and IAM policy to define the S3 permissions used by the ECS application role. New file: terraform/s3.tf
# AWS S3 bucket
# TF: https://www.terraform.io/docs/providers/aws/r/s3_bucket.html
# AWS: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/s3api/create-bucket.html
resource "aws_s3_bucket" "s3_data_bucket" {
bucket = "${var.s3_data_bucket}"
acl = "private"
tags {
Name = "Eric Data Bucket"
}
}
# [Data] IAM policy to define S3 permissions
# TF: https://www.terraform.io/docs/providers/aws/d/iam_policy_document.html
# AWS: http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/iam/create-policy.html
data "aws_iam_policy_document" "s3_data_bucket_policy" {
statement {
sid = ""
effect = "Allow"
actions = [
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.s3_data_bucket.bucket}"
]
}
statement {
sid = ""
effect = "Allow"
actions = [
"s3:DeleteObject" ,
"s3:GetObject" ,
"s3:PutObject" ,
"s3:PutObjectAcl"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.s3_data_bucket.bucket}/*"
]
}
}
# AWS IAM policy
# TF: https://www.terraform.io/docs/providers/aws/r/iam_policy.html
# AWS: http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/iam/create-policy.html
resource "aws_iam_policy" "s3_policy" {
name = "eric-s3-policy"
policy = "${data.aws_iam_policy_document.s3_data_bucket_policy.json}"
}
# Attaches a managed IAM policy to an IAM role
# TF: https://www.terraform.io/docs/providers/aws/r/iam_role_policy_attachment.html
# AWS: http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/iam/attach-role-policy.html
resource "aws_iam_role_policy_attachment" "ecs_role_s3_data_bucket_policy_attach" {
role = "${aws_iam_role.ecs_role.name}"
policy_arn = "${aws_iam_policy.s3_policy.arn}"
}
The following Terraform file defines the remainder of the ECS infrastructure. This includes an ECS cluster, IAM policy document (to allow ECS tasks to assume a role), an IAM role, an ECS task definition (which uses the Node.js ECR image), an ECS service which manages the ECS task, and an EC2 instance. new file: terraform/ecs.tf
# AWS ECS cluster
# TF: https://www.terraform.io/docs/providers/aws/r/ecs_cluster.html
# AWS: http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_clusters.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/ecs/create-cluster.html
resource "aws_ecs_cluster" "eric_express" {
name = "eric-express"
}
# [Data] IAM policy document (to allow ECS tasks to assume a role)
# TF: https://www.terraform.io/docs/providers/aws/d/iam_policy_document.html
# AWS: http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/iam/create-policy.html
data "aws_iam_policy_document" "ecs_assume_role_policy" {
statement {
sid = ""
effect = "Allow"
actions = [
"sts:AssumeRole" ,
]
principals {
type = "Service"
identifiers = [ "ecs-tasks.amazonaws.com" ]
}
}
}
# AWS IAM role (to allow ECS tasks to assume a role)
# TF: https://www.terraform.io/docs/providers/aws/r/iam_role.html
# AWS: http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/iam/create-role.html
resource "aws_iam_role" "ecs_role" {
name = "eric-ecs-role"
assume_role_policy = "${data.aws_iam_policy_document.ecs_assume_role_policy.json}"
}
# [Data] AWS ECS task definition
# Simply specify the family to find the latest ACTIVE revision in that family.
# TF: https://www.terraform.io/docs/providers/aws/d/ecs_task_definition.html
# AWS: http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definitions.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/ecs/register-task-definition.html
data "aws_ecs_task_definition" "express_ecs_task_definition" {
task_definition = "${aws_ecs_task_definition.express_ecs_task_definition.family}"
}
# AWS ECS task definition
# TF: https://www.terraform.io/docs/providers/aws/d/ecs_task_definition.html
# AWS: http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definitions.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/ecs/register-task-definition.html
resource "aws_ecs_task_definition" "express_ecs_task_definition" {
family = "eric-express"
task_role_arn = "${aws_iam_role.ecs_role.arn}"
container_definitions = << DEFINITION
[
{
"name": "express",
"command": ["node", "app.js"],
"essential": true,
"image": "${var.express_ecr_image}",
"memoryReservation": 128,
"privileged": false,
"portMappings": [
{
"hostPort": 80,
"containerPort": 80,
"protocol": "tcp"
}
],
"environment": [
{
"name": "S3_DATA_BUCKET",
"value": "${aws_s3_bucket.s3_data_bucket.bucket}"
}
]
}
]
DEFINITION
}
# AWS ECS service
# TF: https://www.terraform.io/docs/providers/aws/r/ecs_service.html
# AWS: http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/ecs/create-service.html
resource "aws_ecs_service" "express_ecs_service" {
name = "eric-express-ecs-service"
cluster = "${aws_ecs_cluster.eric_express.id}"
desired_count = 1
task_definition = "${aws_ecs_task_definition.express_ecs_task_definition.family}:${max(" $ { aws_ecs_task_definition . express_ecs_task_definition . revision } ", " $ { data . aws_ecs_task_definition . express_ecs_task_definition . revision } ")}"
}
# AWS EC2 instance
# TF: https://www.terraform.io/docs/providers/aws/r/instance.html
# AWS: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Instances.html
# AWS CLI: http://docs.aws.amazon.com/cli/latest/reference/ec2/run-instances.html
resource "aws_instance" "express_ec2" {
ami = "${var.ec2_ami_id}"
instance_type = "${var.ec2_instance_type}"
key_name = "${var.aws_key_name}"
subnet_id = "${var.aws_subnet_id}"
vpc_security_group_ids = "${var.aws_security_group_ids}"
iam_instance_profile = "ecsInstanceRole"
tags {
name = "Eric Express ECS"
}
user_data = << SCRIPT
#!/bin/bash
echo ECS_CLUSTER=${aws_ecs_cluster.eric_express.name} >> /etc/ecs/ecs.config
SCRIPT
}
output "PrivateIP" {
value = "http://${aws_instance.express_ec2.private_ip}"
}
output "PublicIP" {
value = "http://${aws_instance.express_ec2.public_ip}"
}
Last I added a Terraform provisioning shell script. It handles the loading and passing of environment variables, ensuring the s3 state bucket exists, initializes the backend, and executes the Terraform command. New file: terraform/provision.sh
#!/bin/bash
set -e
cd " $( dirname " $0 " ) "
source .env
TF_COMMAND = $1
if [ -z $TF_COMMAND ] ; then
echo "Usage: ./provision.sh [plan|apply|destroy]"
exit 1
fi
AWS_ACCOUNT_ID = $( aws sts get-caller-identity --output text --query 'Account' --profile $AWS_PROFILE )
EXPRESS_ECR_IMAGE = " ${ AWS_ACCOUNT_ID } .dkr.ecr. ${ AWS_REGION } .amazonaws.com/ ${ ECR_REPO } "
# ensure terraform state bucket exists
existing_tf_bucket = $( aws --profile $AWS_PROFILE s3api list-buckets --query "Buckets[?Name==' ${ TF_STATE_BUCKET } '].Name" --output text)
if [ -z $existing_tf_bucket ] ; then
aws --profile $AWS_PROFILE s3api create-bucket --bucket $TF_STATE_BUCKET
fi
# init terraform & s3 backend
rm -f * .tfstate
rm -rf ./.terraform
terraform init \
-force-copy \
-backend = true \
-backend-config "bucket= ${ TF_STATE_BUCKET } " \
-backend-config "key= ${ TF_STATE_KEY } " \
-backend-config "profile= ${ AWS_PROFILE } " \
-backend-config "region= ${ AWS_REGION } "
# execute terraform
terraform $TF_COMMAND \
-var "aws_key_name= ${ AWS_KEY_NAME } " \
-var "aws_profile= ${ AWS_PROFILE } " \
-var "aws_region= ${ AWS_REGION } " \
-var "aws_security_group_ids= ${ AWS_SECURITY_GROUP_IDS } " \
-var "aws_subnet_id= ${ AWS_SUBNET_ID } " \
-var "ec2_ami_id= ${ EC2_AMI_ID } " \
-var "ec2_instance_type= ${ EC2_INSTANCE_TYPE } " \
-var "express_ecr_image= ${ EXPRESS_ECR_IMAGE } " \
-var "s3_data_bucket= ${ S3_DATA_BUCKET } "
To provision the architecture, I populated the terraform/.env
and executed the following:
# build the ECR image
express/build.sh
# view the Terraform plan
terraform/provision.sh plan
# execute the Terraform plan
terraform/provision.sh apply
I then tested the connectivity between S3 and the ECS Node.js task by curl’ing the IP:
$ curl http://ip-of-the-ec2-instance
2017-08-05T14:12:39.398Z%
Source code on Github