├── .gitignore ├── LICENSE ├── README.md ├── deploy ├── ecs-deploy.py └── requirements.txt ├── main.tf ├── policies ├── ecs-instance-role-policy.json ├── ecs-role.json └── ecs-service-role-policy.json ├── services.tf ├── task-definitions └── test-http.json └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | terraform.tfstate 2 | terraform.tfstate.backup 3 | 4 | *.py[co] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Alex Gaynor 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ECS + Terraform 2 | 3 | This repo contains a set of [Terraform](https://terraform.io/) modules for 4 | provisioning an [AWS ECS](https://aws.amazon.com/ecs/) cluster and registering 5 | services with it. 6 | 7 | If you want to use this, basically replace `services.tf` with services which 8 | describe containers you actually want to run. 9 | 10 | There's still a handful of `TODO` comments, and it may not be 100% idiomatic 11 | Terraform or AWS. 12 | 13 | Right now this provisions _everything_, including its own VPC and related 14 | networking accoutrements. It does not handle setting up a Docker Registry. It 15 | does not do anything about attaching other AWS services (e.g. RDS) to a 16 | container. 17 | 18 | ## Deploying 19 | 20 | In addition to the Terraform modules, there is a script for doing deployments to 21 | ECS. 22 | 23 | To execute a deployment: 24 | 25 | ```console 26 | $ # Push a container to your docker registry 27 | $ python deploy/ecs-deploy.py deploy --cluster= --service= --image= 28 | ``` 29 | 30 | It will then update the image being used by that service's task. ECS will handle 31 | updating the running containers. (Be aware that you must have as many EC2 32 | instances in the cluster as 2x the number of running tasks for your service.) 33 | -------------------------------------------------------------------------------- /deploy/ecs-deploy.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import boto3 4 | 5 | import click 6 | 7 | 8 | @contextmanager 9 | def log_call(msg): 10 | click.echo("start {}".format(msg)) 11 | yield 12 | click.echo("finish {}".format(msg)) 13 | 14 | 15 | def get_current_task_definition(client, cluster, service): 16 | with log_call("describe services"): 17 | response = client.describe_services(cluster=cluster, services=[service]) 18 | current_task_arn = response["services"][0]["taskDefinition"] 19 | 20 | with log_call("describe task definition"): 21 | response = client.describe_task_definition( 22 | taskDefinition=current_task_arn 23 | ) 24 | 25 | return response 26 | 27 | 28 | @click.group() 29 | def cli(): 30 | pass 31 | 32 | 33 | @cli.command() 34 | @click.option("--cluster") 35 | @click.option("--service") 36 | @click.option("--image") 37 | def deploy(cluster, service, image): 38 | client = boto3.client("ecs") 39 | 40 | response = get_current_task_definition(client, cluster, service) 41 | # We don't handle tasks with multiple containers for now. 42 | assert len(response["taskDefinition"]["containerDefinitions"]) == 1 43 | container_definition = response["taskDefinition"]["containerDefinitions"][0].copy() 44 | container_definition["image"] = image 45 | 46 | with log_call("register task definition"): 47 | response = client.register_task_definition( 48 | family=response["taskDefinition"]["family"], 49 | volumes=response["taskDefinition"]["volumes"], 50 | containerDefinitions=[container_definition], 51 | ) 52 | new_task_arn = response["taskDefinition"]["taskDefinitionArn"] 53 | 54 | with log_call("update task definition"): 55 | response = client.update_service( 56 | cluster=cluster, 57 | service=service, 58 | taskDefinition=new_task_arn, 59 | ) 60 | 61 | 62 | @cli.command() 63 | @click.option("--cluster") 64 | @click.option("--service") 65 | def rollback(cluster, service): 66 | client = boto3.client("ecs") 67 | 68 | response = get_current_task_definition(client, cluster, service) 69 | 70 | family = response["taskDefinition"]["family"] 71 | with log_call("list task definitions"): 72 | response = client.list_task_definitions( 73 | familyPrefix=family, 74 | ) 75 | # Deploy the second to last one. Probably could use some better logic? 76 | new_task_arn = response["taskDefinitionArns"][-2] 77 | 78 | with log_call("update task definition"): 79 | response = client.update_service( 80 | cluster=cluster, 81 | service=service, 82 | taskDefinition=new_task_arn, 83 | ) 84 | 85 | 86 | if __name__ == "__main__": 87 | cli() 88 | -------------------------------------------------------------------------------- /deploy/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | click 3 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | access_key = "${var.aws_access_key}" 3 | secret_key = "${var.aws_secret_key}" 4 | region = "${var.region}" 5 | } 6 | 7 | resource "aws_key_pair" "alex" { 8 | key_name = "alex-key" 9 | public_key = "${file(var.ssh_pubkey_file)}" 10 | } 11 | 12 | resource "aws_vpc" "main" { 13 | cidr_block = "10.0.0.0/16" 14 | enable_dns_hostnames = true 15 | } 16 | 17 | resource "aws_route_table" "external" { 18 | vpc_id = "${aws_vpc.main.id}" 19 | route { 20 | cidr_block = "0.0.0.0/0" 21 | gateway_id = "${aws_internet_gateway.main.id}" 22 | } 23 | } 24 | 25 | resource "aws_route_table_association" "external-main" { 26 | subnet_id = "${aws_subnet.main.id}" 27 | route_table_id = "${aws_route_table.external.id}" 28 | } 29 | 30 | # TODO: figure out how to support creating multiple subnets, one for each 31 | # availability zone. 32 | resource "aws_subnet" "main" { 33 | vpc_id = "${aws_vpc.main.id}" 34 | cidr_block = "10.0.1.0/24" 35 | availability_zone = "${var.availability_zone}" 36 | } 37 | 38 | resource "aws_internet_gateway" "main" { 39 | vpc_id = "${aws_vpc.main.id}" 40 | } 41 | 42 | resource "aws_security_group" "load_balancers" { 43 | name = "load_balancers" 44 | description = "Allows all traffic" 45 | vpc_id = "${aws_vpc.main.id}" 46 | 47 | # TODO: do we need to allow ingress besides TCP 80 and 443? 48 | ingress { 49 | from_port = 0 50 | to_port = 0 51 | protocol = "-1" 52 | cidr_blocks = ["0.0.0.0/0"] 53 | } 54 | 55 | # TODO: this probably only needs egress to the ECS security group. 56 | egress { 57 | from_port = 0 58 | to_port = 0 59 | protocol = "-1" 60 | cidr_blocks = ["0.0.0.0/0"] 61 | } 62 | } 63 | 64 | resource "aws_security_group" "ecs" { 65 | name = "ecs" 66 | description = "Allows all traffic" 67 | vpc_id = "${aws_vpc.main.id}" 68 | 69 | # TODO: remove this and replace with a bastion host for SSHing into 70 | # individual machines. 71 | ingress { 72 | from_port = 0 73 | to_port = 0 74 | protocol = "-1" 75 | cidr_blocks = ["0.0.0.0/0"] 76 | } 77 | 78 | ingress { 79 | from_port = 0 80 | to_port = 0 81 | protocol = "-1" 82 | security_groups = ["${aws_security_group.load_balancers.id}"] 83 | } 84 | 85 | egress { 86 | from_port = 0 87 | to_port = 0 88 | protocol = "-1" 89 | cidr_blocks = ["0.0.0.0/0"] 90 | } 91 | } 92 | 93 | 94 | resource "aws_ecs_cluster" "main" { 95 | name = "${var.ecs_cluster_name}" 96 | } 97 | 98 | resource "aws_autoscaling_group" "ecs-cluster" { 99 | availability_zones = ["${var.availability_zone}"] 100 | name = "ECS ${var.ecs_cluster_name}" 101 | min_size = "${var.autoscale_min}" 102 | max_size = "${var.autoscale_max}" 103 | desired_capacity = "${var.autoscale_desired}" 104 | health_check_type = "EC2" 105 | launch_configuration = "${aws_launch_configuration.ecs.name}" 106 | vpc_zone_identifier = ["${aws_subnet.main.id}"] 107 | } 108 | 109 | resource "aws_launch_configuration" "ecs" { 110 | name = "ECS ${var.ecs_cluster_name}" 111 | image_id = "${lookup(var.amis, var.region)}" 112 | instance_type = "${var.instance_type}" 113 | security_groups = ["${aws_security_group.ecs.id}"] 114 | iam_instance_profile = "${aws_iam_instance_profile.ecs.name}" 115 | # TODO: is there a good way to make the key configurable sanely? 116 | key_name = "${aws_key_pair.alex.key_name}" 117 | associate_public_ip_address = true 118 | user_data = "#!/bin/bash\necho ECS_CLUSTER='${var.ecs_cluster_name}' > /etc/ecs/ecs.config" 119 | } 120 | 121 | 122 | resource "aws_iam_role" "ecs_host_role" { 123 | name = "ecs_host_role" 124 | assume_role_policy = "${file("policies/ecs-role.json")}" 125 | } 126 | 127 | resource "aws_iam_role_policy" "ecs_instance_role_policy" { 128 | name = "ecs_instance_role_policy" 129 | policy = "${file("policies/ecs-instance-role-policy.json")}" 130 | role = "${aws_iam_role.ecs_host_role.id}" 131 | } 132 | 133 | resource "aws_iam_role" "ecs_service_role" { 134 | name = "ecs_service_role" 135 | assume_role_policy = "${file("policies/ecs-role.json")}" 136 | } 137 | 138 | resource "aws_iam_role_policy" "ecs_service_role_policy" { 139 | name = "ecs_service_role_policy" 140 | policy = "${file("policies/ecs-service-role-policy.json")}" 141 | role = "${aws_iam_role.ecs_service_role.id}" 142 | } 143 | 144 | resource "aws_iam_instance_profile" "ecs" { 145 | name = "ecs-instance-profile" 146 | path = "/" 147 | roles = ["${aws_iam_role.ecs_host_role.name}"] 148 | } 149 | -------------------------------------------------------------------------------- /policies/ecs-instance-role-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "ecs:CreateCluster", 8 | "ecs:DeregisterContainerInstance", 9 | "ecs:DiscoverPollEndpoint", 10 | "ecs:Poll", 11 | "ecs:RegisterContainerInstance", 12 | "ecs:StartTelemetrySession", 13 | "ecs:Submit*", 14 | "ecs:StartTask" 15 | ], 16 | "Resource": "*" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /policies/ecs-role.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2008-10-17", 3 | "Statement": [ 4 | { 5 | "Action": "sts:AssumeRole", 6 | "Principal": { 7 | "Service": [ 8 | "ecs.amazonaws.com", 9 | "ec2.amazonaws.com" 10 | ] 11 | }, 12 | "Effect": "Allow" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /policies/ecs-service-role-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "elasticloadbalancing:Describe*", 8 | "elasticloadbalancing:DeregisterInstancesFromLoadBalancer", 9 | "elasticloadbalancing:RegisterInstancesWithLoadBalancer", 10 | "ec2:Describe*", 11 | "ec2:AuthorizeSecurityGroupIngress" 12 | ], 13 | "Resource": [ 14 | "*" 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /services.tf: -------------------------------------------------------------------------------- 1 | resource "aws_elb" "test-http" { 2 | name = "test-http-elb" 3 | security_groups = ["${aws_security_group.load_balancers.id}"] 4 | subnets = ["${aws_subnet.main.id}"] 5 | 6 | listener { 7 | lb_protocol = "http" 8 | lb_port = 80 9 | 10 | instance_protocol = "http" 11 | instance_port = 8080 12 | } 13 | 14 | health_check { 15 | healthy_threshold = 3 16 | unhealthy_threshold = 2 17 | timeout = 3 18 | target = "HTTP:8080/hello-world" 19 | interval = 5 20 | } 21 | 22 | cross_zone_load_balancing = true 23 | } 24 | 25 | resource "aws_ecs_task_definition" "test-http" { 26 | family = "test-http" 27 | container_definitions = "${file("task-definitions/test-http.json")}" 28 | } 29 | 30 | resource "aws_ecs_service" "test-http" { 31 | name = "test-http" 32 | cluster = "${aws_ecs_cluster.main.id}" 33 | task_definition = "${aws_ecs_task_definition.test-http.arn}" 34 | iam_role = "${aws_iam_role.ecs_service_role.arn}" 35 | desired_count = 2 36 | depends_on = ["aws_iam_role_policy.ecs_service_role_policy"] 37 | 38 | load_balancer { 39 | elb_name = "${aws_elb.test-http.id}" 40 | container_name = "test-http" 41 | container_port = 8080 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /task-definitions/test-http.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "test-http", 4 | "image": "alexgaynor/test-http", 5 | "cpu": 10, 6 | "memory": 512, 7 | "links": [], 8 | "portMappings": [ 9 | { 10 | "containerPort": 8080, 11 | "hostPort": 8080, 12 | "protocol": "tcp" 13 | } 14 | ], 15 | "essential": true, 16 | "entryPoint": [], 17 | "command": [], 18 | "environment": [], 19 | "mountPoints": [], 20 | "volumesFrom": [] 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "aws_access_key" { 2 | description = "The AWS access key." 3 | } 4 | 5 | variable "aws_secret_key" { 6 | description = "The AWS secret key." 7 | } 8 | 9 | variable "region" { 10 | description = "The AWS region to create resources in." 11 | default = "us-east-1" 12 | } 13 | 14 | # TODO: support multiple availability zones, and default to it. 15 | variable "availability_zone" { 16 | description = "The availability zone" 17 | default = "us-east-1a" 18 | } 19 | 20 | variable "ecs_cluster_name" { 21 | description = "The name of the Amazon ECS cluster." 22 | default = "main" 23 | } 24 | 25 | variable "amis" { 26 | description = "Which AMI to spawn. Defaults to the AWS ECS optimized images." 27 | # TODO: support other regions. 28 | default = { 29 | us-east-1 = "ami-ddc7b6b7" 30 | } 31 | } 32 | 33 | 34 | variable "autoscale_min" { 35 | default = "1" 36 | description = "Minimum autoscale (number of EC2)" 37 | } 38 | 39 | variable "autoscale_max" { 40 | default = "10" 41 | description = "Maximum autoscale (number of EC2)" 42 | } 43 | 44 | variable "autoscale_desired" { 45 | default = "4" 46 | description = "Desired autoscale (number of EC2)" 47 | } 48 | 49 | 50 | variable "instance_type" { 51 | default = "t2.micro" 52 | } 53 | 54 | variable "ssh_pubkey_file" { 55 | description = "Path to an SSH public key" 56 | default = "~/.ssh/id_rsa.pub" 57 | } 58 | --------------------------------------------------------------------------------