If you have not already done so, read Terraform and Amazon ECS – Part 1 and Terraform and Amazon ECS – Part 2 before proceeding.
Now that we have a VPC and a Load Balancer to route traffic to our APIs, its time to create our ECS Cluster and all of the services and associated tasks that will run our application.
But first, there is one small order of business to take care of: Service Discovery. In order for our containers to be able to talk to one another, they need to be able to locate each other. This is what DNS is made for, and luckily for us Amazon Cloud Map provides a solution that seamlessly integrates with ECS. All we need to do is set up a DNS namespace in Route53 and the rest of the work happens behind the scenes.
Step 4: Create a DNS Namespace
Your DNS namespace can be whatever makes sense for you. All we need to do is decide on the structure for our namespace and associate it with our VPC. So, let’s begin by creating a new module:
mkdir discovery touch discovery/main.tf touch discovery/vars.tf touch discovery/outputs.tf
We need to create the two variables…
variable "vpc_id" {} variable "dns_namespace" {}
… the resource
resource "aws_service_discovery_private_dns_namespace" "main" { name = var.dns_namespace description = "Private service discovery namespace" vpc = var.vpc_id }
… and output the ID for the namespace so that we can use it when we register services with our cluster.
output "service_discovery_ns_id" { value = aws_service_discovery_private_dns_namespace.main.id }
Lastly, lets call the discovery module from root.tf and pass in the ID of the VPC we created earlier. We also need to add a variable for the DNS namespace to our root vars.tf file and a corresponding value in terraform.tfvars.
// vars.tf variable dns_namespace {} // terraform.tfvars dns_namespace = svc.jsoncampos.local // root.tf module "discovery" { source = "./discovery" vpc_id = module.vpc.vpc_id dns_namespace = var.dns_namespace }
Thats it! Setting up discovery in this way is incredibly simple. Now, on to the heavy lifting.
Step 5: The ECS Cluster & Tasks
It appears we now have everything we need to create the ECS cluster itself and to start defining our tasks and services. So, lets create the module space:
mkdir ecs mkdir ecs/main.tf mkdir ecs/vars.tf mkdir ecs/outputs.tf
Our ECS cluster is going to need a name, so lets define the variable for that in our vars file
variable name {}
I will be using the Fargate task type which will alleviate me of the responsibility of managing the EC2 instances onto which our containers would be deployed. Fargate is a “serverless” solution for ECS.
resource "aws_ecs_cluster" "main" { name = var.name capacity_providers = ["FARGATE", "FARGATE_SPOT"] }
Now it’s time to start defining our tasks. Each task definition is given a set of containers to run as a part of the task. We will be running each service as a separate task since there is really nothing that tightly couples these services to one another. To define the containers that will be running, we will create a folder called tasks and place some JSON files in that folder which contains an array of container definitions to run as a part of the task.
mkdir ecs/tasks touch ecs/tasks/aws.json touch ecs/tasks/todo.json touch ecs/tasks/aws.json touch ecs/tasks/mysql.json touch ecs/tasks/consul.json
The container definitions should be as follows. Note that I am using a very small subset of what is available to the container and task definitions. I don’t include things like logging to CloudWatch for example. Feel free to customize this to whatever suits your needs.
// tasks/aws.json [ { "essential": true, "image": "752478895906.dkr.ecr.us-west-2.amazonaws.com/jsoncampos/aws", "name": "aws", "portMappings": [ { "containerPort": 8080, "protocol": "tcp" } ] } ] // tasks/consul.json [ { "essential": true, "image": "consul:1.6.2", "name": "consul", "portMappings": [ { "containerPort": 8500, "protocol": "tcp" } ] } ] // tasks/mysql.json [ { "essential": true, "image": "752478895906.dkr.ecr.us-west-2.amazonaws.com/jsoncampos/mysql", "name": "mysql", "portMappings": [ { "containerPort": 3306, "hostPort": 3306, "protocol": "tcp" } ], "environment": [ { "name": "MYSQL_ROOT_PASSWORD", "value": "password" }, { "name": "MYSQL_DATABASE", "value": "hello_web_api" } ] } ] // tasks/todo.json [ { "essential": true, "image": "752478895906.dkr.ecr.us-west-2.amazonaws.com/jsoncampos/todo", "name": "todo", "portMappings": [ { "containerPort": 80, "protocol": "tcp" } ], "environment": [ { "name": "CONSUL_HOST", "value": "http://consul.svc.jsoncampos.local:8500" }, { "name": "CONSUL_KEY", "value": "hello-web-api/production" } ] } ] // tasks/ui.json [ { "essential": true, "image": "752478895906.dkr.ecr.us-west-2.amazonaws.com/jsoncampos/ui", "name": "ui", "portMappings": [ { "containerPort": 80, "protocol": "tcp" } ] } ]
Note that the MySQL database image I am launching already has the database defined as a part of the image. The keys for our consul key/value store however are not a part of the image. In order to import the keys, you will need to manually connect to the consul container (once its actually running of course) and insert the keys that are used by the To-Do application to configure its connection the the database. Alternatively, you could just hard-code the connection information and remove Consul from the equation altogether.
ECS tasks need to be assigned an IAM Role which provides the necessary permissions to be able to manage resources and pull from ECR repositories. Luckily, AWS provides a managed policy for us so we don’t need to go through the trouble of creating and maintaining our own. We will use the Terraform Datasource for IAM Roles to locate the appropriate role and we can reference that role when creating our task definitions. Add the following to ecs/main.tf
data "aws_iam_role" "task_execution_role" { name = "ecsTaskExecutionRole" }
Now that we have the containers for each task defined and the IAM Role for executing the tasks located, we can define tasks for all of our services.
resource "aws_ecs_task_definition" "ui" { family = "jsoncampos-ui" container_definitions = file("./tasks/ui.json") network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] memory = 512 cpu = 256 execution_role_arn = data.aws_iam_role.task_execution_role.arn } resource "aws_ecs_task_definition" "aws" { family = "jsoncampos-aws" container_definitions = file("./tasks/aws.json") network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] memory = 512 cpu = 256 execution_role_arn = data.aws_iam_role.task_execution_role.arn } resource "aws_ecs_task_definition" "todo" { family = "jsoncampos-todo" container_definitions = file("./tasks/todo.json") network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] memory = 512 cpu = 256 execution_role_arn = data.aws_iam_role.task_execution_role.arn } resource "aws_ecs_task_definition" "mysql" { family = "jsoncampos-mysql" container_definitions = file("./tasks/mysql.json") network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] memory = 512 cpu = 256 execution_role_arn = data.aws_iam_role.task_execution_role.arn } resource "aws_ecs_task_definition" "consul" { family = "jsoncampos-consul" container_definitions = file("./tasks/consul.json") network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] memory = 512 cpu = 256 execution_role_arn = data.aws_iam_role.task_execution_role.arn }
There is a ton of boilerplate here. We give each task a unique family, provide the container definition for each task, set the networking mode to “awsvpc” (learn more about task networking), and set the Fargate task requirements (see valid combinations here). Finally, we provide the ARN of the task execution role which we retrieved using the datasource in the previous step.