ECS Part 1: Building a Container Runtime Foundation with Fargate
In the previous chapter, Tasuku's ALB gained an HTTPS entry point. However, the listener on port 443 only returns a fixed string, and there is still nothing real behind it. In this chapter, we build the container runtime foundation that actually processes requests: an ECS cluster and task definition, an ECR repository to hold images, two kinds of IAM roles that grant permissions to the container, and CloudWatch Logs as the log destination. Connecting to the ALB and turning this into an always-running service are left for the next chapter; here we go only as far as launching a one-off task to confirm that the foundation works.
8.1. ECS basics: organizing clusters, task definitions, tasks, and services
ECS (Elastic Container Service) is a container runtime platform. Let's first sort out the relationship between four concepts.
- Task definition: a JSON-formatted blueprint describing a container's configuration. It specifies the image to use, CPU/memory, where logs go, and more
- Task: an actual instantiation of a task definition within a cluster1
- Service: a mechanism that keeps a specified number of tasks running. If a task goes down, the service automatically launches a replacement1
- Cluster: a logical grouping that bundles services and standalone tasks together1
This chapter covers the cluster and task definition, plus a one-off task for verification. Running as an always-on service is left to Chapter 10. The plan is to solidify the task definition and the permissions/logging foundation it needs in Part 1, then turn it into a persistent service connected to the ALB in Part 2.
8.2. Why Fargate
ECS has multiple launch types that decide where containers run. This series uses Fargate, as foreshadowed in Chapter 1.2
The other major option is the EC2 launch type. With the EC2 launch type, you have to provision the EC2 instances that host the containers yourself, and continuously handle OS patching and instance-size tuning. Fargate hands this management off to AWS: it's a serverless runtime foundation where each task gets its own dedicated Linux kernel, CPU, memory, and network interface, none of which are shared with other tasks.3 Given Tasuku's scale (a few dozen tenants, on the order of a few hundred concurrent users), there's no reason to pay the cost of managing instances, so we choose Fargate.
8.3. ECR: where images are stored
ECR (Elastic Container Registry) is AWS's registry for storing container images. The image the task definition references is placed in this repository.
As mentioned in Chapter 1, Section 1.3, this series doesn't write Tasuku's application code. So instead of a real web/api image, this chapter's ECR repository holds a placeholder image for verification. The image we use is explained in Section 8.9.
To push an image to ECR, you first need to authenticate the Docker client against the registry. You get the authentication token with the aws ecr get-login-password command and pipe it into docker login.4
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com
This token expires after 12 hours.4 Keep in mind that long-running work will require re-authentication.
For the repository's image_tag_mutability (whether tags can be overwritten), if omitted the default is MUTABLE (tags can be overwritten).5 In production, there's room to consider fixing tags with IMMUTABLE, but this chapter focuses on learning the foundation, so we proceed with the default.
8.4. Execution role and task role
In Chapter 3, Section 3.4, we foreshadowed that we'd need two kinds of IAM roles for ECS. Here we look at their concrete values.
The task execution role is the role that lets the ECS container agent and the Fargate agent call AWS APIs on your behalf. It's used for pulling images from ECR and for sending logs to CloudWatch Logs via the awslogs driver. Code running inside the container cannot access this role's permissions directly.6
The task role is the permission set used by the application code running inside the container itself. It's a separate thing from the execution role, and best practice is to prepare an individual role per task definition and grant only the minimum permissions needed.7 This chapter doesn't attach any policy to the task role yet. As the preview table in Chapter 3, Section 3.4 spelled out, adding permissions to the task role happens in Chapter 10 (access to the S3 attachment bucket). This chapter only creates the empty shell of the role and its trust policy.
For both roles, the trust policy's Principal is ecs-tasks.amazonaws.com.8 This service principal restricts who can assume the role to "ECS tasks."
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
8.5. Designing execution role permissions: managed policy or custom policy
One way to grant permissions to the task execution role is to use an AWS-provided managed policy called AmazonECSTaskExecutionRolePolicy. This policy's content is limited to six actions for ECR authentication/image retrieval and writing logs to CloudWatch Logs.9
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
However, attaching this managed policy as-is isn't an option for Tasuku's terraform executor. In Chapter 3, we restricted iam:AttachRolePolicy to only our own policies under /tasuku/, so the terraform executor can't freely attach arbitrary policies, including AWS managed policies. Loosening this restriction would require rewriting the content of the terraform_iam_management policy itself, which needs the iam:CreatePolicyVersion permission.10 We deliberately didn't grant this permission, per the design decision in Chapter 3, Section 3.6, to block any path by which the terraform executor could rewrite its own permissions. In other words, as long as we stick with the established terraform executor, switching to the managed policy is simply not something apply can do.
So in this chapter, we define permissions equivalent to AmazonECSTaskExecutionRolePolicy as our own new aws_iam_policy under /tasuku/, and attach it to the execution role. This approach doesn't touch Chapter 3's policy at all, and stays entirely within the already-permitted iam:CreatePolicy and iam:AttachRolePolicy permissions. On top of that, defining it ourselves lets us scope the permissions down to the individual ECR repository and log group. ecr:GetAuthorizationToken is an action that doesn't support resource-level permissions, so Resource "*" is required.11 On the other hand, the pull-related actions ecr:BatchCheckLayerAvailability/ecr:GetDownloadUrlForLayer/ecr:BatchGetImage can be scoped to the repository's ARN.12 logs:CreateLogStream/logs:PutLogEvents can likewise be scoped by appending :* to the end of the log group ARN.13
The fact that our own policy's content doesn't track the AWS managed policy's permissions (if AWS extends the policy's contents in the future, it won't automatically be reflected here) is the flip side of this approach's trade-off. When we add read permissions from Secrets Manager to the execution role in Chapter 10, we'll likewise add it as a separate new policy rather than rewriting this policy itself. Attaching multiple policies to a single role is a standard, supported way to use IAM.
8.6. Designing logs: CloudWatch Logs and awslogs
To send a container's standard output to CloudWatch Logs, specify the awslogs driver in the task definition's log configuration. The options you need are awslogs-region to indicate the destination region (required)14, awslogs-group to indicate the destination log group, and awslogs-stream-prefix, which you add to make log streams easier to identify.14
We create the log group explicitly in this chapter's Terraform code. For a CloudWatch Logs log group, if you don't specify a retention period, the default behavior is "never expire."15 Since letting logs accumulate indefinitely drives up cost, we set the retention period explicitly.
8.7. Security group for ECS tasks
In the security group chain diagram in Chapter 4, Section 4.7, the SG for ECS tasks was foreshadowed as allowing ingress only from "the ALB's SG." Here we build it exactly that way. Just like the ALB in Chapter 6, we don't rely on the VPC's default security group; we create a new, explicit SG.
Ingress allows only port 3000 from the ALB's SG. This is the port number that the "web" container listens on, per Chapter 1, Section 1.3. The task that runs in this chapter is the placeholder image explained later, which doesn't actually listen on any port. This ingress rule is preparation put in place ahead of time, so that the web-equivalent service can receive forwarded traffic from the ALB in Chapter 10.
Egress allows only port 443. This is the HTTPS traffic the Fargate task uses to pull images from ECR and put logs to CloudWatch Logs from the private-app subnet.16 Since we use the route via the NAT Gateway set up in Chapter 4 as-is, this chapter doesn't add a VPC endpoint. Resolving domain names (DNS) requires communication on port 53, but since traffic to the Amazon DNS server that the VPC provides can't be filtered by a security group or network ACL, we don't include a DNS-bound egress rule in this list.17
8.8. Terraform implementation
We write everything covered so far into a new file called ecs.tf.
resource "aws_ecr_repository" "app" {
name = "${local.name_prefix}-app"
tags = {
Name = "${local.name_prefix}-app"
}
}
resource "aws_ecs_cluster" "main" {
name = "${local.name_prefix}-cluster"
tags = {
Name = "${local.name_prefix}-cluster"
}
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${local.name_prefix}-app"
retention_in_days = 30
tags = {
Name = "${local.name_prefix}-app-logs"
}
}
Since the trust policy is common to both the execution role and the task role, we define it once as a data source and reference it from both roles.
data "aws_iam_policy_document" "ecs_tasks_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_task_execution" {
name = "${local.name_prefix}-ecs-task-execution-role"
path = "/${var.project}/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
tags = {
Name = "${local.name_prefix}-ecs-task-execution-role"
}
}
resource "aws_iam_role" "ecs_task" {
name = "${local.name_prefix}-ecs-task-role"
path = "/${var.project}/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
tags = {
Name = "${local.name_prefix}-ecs-task-role"
}
}
The custom policy decided in Section 8.5 is defined and attached as follows.
resource "aws_iam_policy" "ecs_task_execution" {
name = "${local.name_prefix}-ecs-task-execution-policy"
path = "/${var.project}/"
description = "Custom permissions equivalent to AmazonECSTaskExecutionRolePolicy, scoped down to the individual ECR repository and log group"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "EcrAuth"
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken"]
Resource = "*"
},
{
Sid = "EcrPull"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
]
Resource = aws_ecr_repository.app.arn
},
{
Sid = "LogsWrite"
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${aws_cloudwatch_log_group.app.arn}:*"
},
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = aws_iam_policy.ecs_task_execution.arn
}
We build the security group according to the plan from Section 8.7.
resource "aws_security_group" "ecs_tasks" {
name = "${local.name_prefix}-ecs-tasks-sg"
description = "SG for ECS tasks. Allows ingress only from the ALB's SG"
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-ecs-tasks-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ecs_tasks_from_alb" {
security_group_id = aws_security_group.ecs_tasks.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "Allowed only from the ALB (used once Chapter 10 turns this into the web service and switches the forward target)"
}
resource "aws_vpc_security_group_egress_rule" "ecs_tasks_https" {
security_group_id = aws_security_group.ecs_tasks.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "HTTPS to the ECR API and CloudWatch Logs API"
}
Finally, the task definition. Fargate requires awsvpc for network_mode, and in the Terraform schema too, when requires_compatibilities is FARGATE, cpu, memory, and runtime_platform.operating_system_family all become required.18 For cpu/memory, we use the smallest valid Fargate combination, 256 (0.25 vCPU)/512 MiB.19
resource "aws_ecs_task_definition" "app" {
family = "${local.name_prefix}-app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
container_definitions = jsonencode([
{
name = "app"
image = "${aws_ecr_repository.app.repository_url}:latest"
essential = true
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "app"
}
}
}
])
tags = {
Name = "${local.name_prefix}-app"
}
}
The container definition has no portMappings because the image used in Section 8.9 doesn't listen on any port at all.
Finally, we add the values that later chapters will reference to outputs.tf.
output "ecs_cluster_id" {
description = "ECS cluster ID (Chapters 9/10 also use the same cluster)"
value = aws_ecs_cluster.main.id
}
output "ecr_repository_url" {
description = "ECR repository URL (used for the push procedure and the task definition's image reference)"
value = aws_ecr_repository.app.repository_url
}
output "ecs_task_execution_role_arn" {
description = "ECS task execution role ARN (reused in the task definitions for the web/api services in Chapter 10)"
value = aws_iam_role.ecs_task_execution.arn
}
output "ecs_task_role_arn" {
description = "ECS task role ARN (Chapter 10 adds access permissions to the S3 attachment bucket)"
value = aws_iam_role.ecs_task.arn
}
output "ecs_tasks_security_group_id" {
description = "ECS task SG ID (referenced as the ingress source for the RDS SG in Chapter 9)"
value = aws_security_group.ecs_tasks.id
}
output "ecs_task_definition_arn" {
description = "Task definition ARN (referenced by run-task and the service definition in Chapter 10)"
value = aws_ecs_task_definition.app.arn
}
8.9. Verification
Run terraform fmt -check -recursive -diff and terraform validate to confirm the syntax and provider schema are consistent. As before, this chapter's verification stops at validate; plan and apply aren't included.
Here are the steps if you actually apply and check it for yourself. First, since Tasuku's application image doesn't exist yet, we substitute Docker's official hello-world image. This image is a single static binary; when you run it, it prints text to standard output and exits immediately.20 The fact that it doesn't listen on a port and doesn't stay running fits exactly with the flow we want to confirm in this chapter: a task starts, its logs arrive, and it exits normally. Since hello-world is a multi-architecture image, if you don't specify --platform, the one matching the host machine's CPU architecture is selected automatically. Because the task definition fixes runtime_platform.cpu_architecture to X86_64 (more on this below), if you're pushing from an ARM64 host (such as Apple Silicon), explicitly specify x86_64 with --platform.
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com
docker pull --platform linux/amd64 hello-world
docker tag hello-world:latest <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:latest
docker push <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:latest
Once you've pushed the image, launch a one-off task with run-task. awsvpcConfiguration requires subnets, and if you omit securityGroups, the VPC's default security group ends up being used, so we specify the SG we created in this chapter explicitly.21 Use the values obtained from terraform output for the subnet ID and the SG ID.
aws ecs run-task \
--cluster tasuku-dev-cluster \
--task-definition tasuku-dev-app \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[<private-app subnet ID>],securityGroups=[<ecs_tasks SG ID>],assignPublicIp=DISABLED}"
The task transitions through the states PROVISIONING → PENDING → ACTIVATING → RUNNING → DEACTIVATING → STOPPING → DEPROVISIONING → STOPPED.22 Check lastStatus with aws ecs describe-tasks, and once it becomes STOPPED, confirm that containers[].exitCode is 0. If you also run aws logs tail /ecs/tasuku-dev-app, you can confirm that hello-world's standard output has been recorded in the log stream.
As with the hosted zone and ACM in this chapter, we don't guarantee the outcome of actually applying to AWS and running through this procedure. This is the same verification level as through Chapter 7.
Summary
- We organized the relationship between ECS clusters, task definitions, tasks, and services, and pushed always-on operation as a service off to Chapter 10
- Fargate is a serverless runtime foundation where each task has its own dedicated kernel, CPU, memory, and network interface, and it doesn't require the instance management that the EC2 launch type does
- We defined the task execution role's permissions as our own policy under
/tasuku/, rather than an AWS managed policy. This doesn't conflict with Chapter 3's permission constraints, and lets us scope permissions down to the individual repository and log group - We created the task role with only a trust policy; access permissions to the S3 attachment bucket are added in Chapter 10
- The SG for ECS tasks was newly created to allow only ingress from the ALB's SG, and it doesn't depend on the default SG
- We performed verification with a one-off
run-taskusing thehello-worldimage, confirming the whole flow from task startup to log arrival
Next steps
- Chapter 9: RDS: Subnet groups and Multi-AZ, Secrets Manager integration for the master password, and completing the security group chain
- Chapter 7: Route 53 / ACM: DNS delegation and ACM's DNS validation, and adding the port 443 listener to enable HTTPS
- Series table of contents: The structure of all 12 chapters and how to read through them