Containers

Maintaining Transport Layer Security all the way to your container: using the Application Load Balancer with Amazon ECS and Envoy

NOTICE: October 04, 2024 – This post no longer reflects the best guidance for configuring a service mesh with Amazon ECS and its examples no longer work as shown. Please refer to newer content on Amazon ECS Service Connect.

——–

This post is contributed by Sri Mallu, Re Alvarez-Parmar, and Sahil Thapar

Application Load Balancer has been an instrumental element when it comes to building highly available, scalable, and secure applications. AWS customers can rely on ALB to perform functions that have been traditionally implemented in application’s code. Let’s take connection security as an example, ALB can be used to offload the work of encryption and decryption so that your applications can focus on business logic.

The Application Load Balancer allows you to create an HTTPS listener, which uses encrypted connections (also known as SSL offload). This feature enables traffic encryption between your load balancer and the clients that initiate SSL or TLS sessions. When you create an HTTPS listener, you deploy a SSL/TLS server certificate on your load balancer. The load balancer uses this server certificate to terminate the front-end connection and then decrypt requests from clients before sending them to the targets. Why does ALB need to decrypt requests?

The reason why ALB needs to decrypt the request is because it operates at the application layer of the Open Systems Interconnection (OSI) model and needs to inspect the requests to perform request routing. You can use ALB to route requests based on HTTP headers, methods, query parameters, and source IP CIDRs. That’s why when you use ALB to load balance your applications, SSL/TLS termination is done at ALB, and typically the connection between ALB and the backend application is left unencrypted. Terminating secure connections at the load balancer and using HTTP on the backend might be sufficient for your application. Network traffic between AWS resources can only be listened to by the instances that are part of the connection.

However, if you are developing an application that needs to comply with strict external regulations, you might be required to secure all network connections.

I am going to show you how to encrypt connection between clients and the load balancer and from the load balancer to your application container running in an ECS cluster. In this post, we run ECS containers on Fargate but the solution applies to ECS containers running on EC2 as well. To follow along you will need a VPC with public and private subnets, appropriate route tables, an internet gateway, and NAT gateway(s).

We will also use Envoy as a front proxy that terminates TLS and we will run Envoy as a sidecar along with the application container. With this method, we do not need to handle encryption in the application code. Envoy will send traffic, unencrypted, to the application container over localhost.

Prerequisites

In order to successfully carry out steps outlined:

Architecture

?You may use this template as a base line for the setup.

We will start by creating a self-signed certificate, this will be embedded into the Envoy container image. Then we will create an ACM certificate using the same private key and certificate and import it into ACM. We will use this ACM certificate on the ALB as the server certificate.

Even though this solution uses a self-signed certificate, you can use an ACM private CA to generate the certificate, as well. You cannot use ACM public Certificate Authority (CA) for this solution, as it does not allow you to export private keys. However, you can use any commercially available trusted CA that exports private keys.

?Since we are using a self-signed certificate, if you access the application from a browser, you will need to set up the trust in the browser. This is not required if you use a commercial trusted CA.

Tutorial

Let’s define a few environment variables that will be used throughout the tutorial.

##Export region and account
export account=<account> # <- Your account number here
export AWS_REGION=<AWS Region> # <- Your AWS Region

##Export key networking constructs
export private_subnet1="subnet-0cb23e7b2da6116ec" # Subsitute these values with your VPC subnet ids
export private_subnet2="subnet-01446062d07790b98"
export public_subnet1="subnet-092e36d83b9d0fd51"
export public_subnet2="subnet-0a41fcbbe7add4b76"
export sg=sg-0ea3f8730146cc784   ##Wide open SG for ALB All All 0.0.0.0/0
export vpcId=vpc-0a0598d1d7d1dd8b3 # <- Change this to your VPC id

##Service name and domain to be used
export service_name=ecs-encryption
export dns_namespace=awsblogs.info  ##This should be your domain

If you are using the CloudFormation template mentioned above, an ECS task execution IAM role will be created for you. If you are creating the role yourself, please verify that it has the following permissions and trust policy.

##Policy
{
    "Statement": [
        {
            "Action": [
                "ecr:GetAuthorizationToken",
                "ecr:BatchCheckLayerAvailability",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "logs:CreateLogStream",
                "logs:PutLogEvents",
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}

##Trust
{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Create a ecsTaskExecutionRoleArn environment variable.

export ecsTaskExecutionRoleArn=arn:aws:iam::551961765653:role/ECS-ENCRYPTION-ECSTaskExecutionRole-URQRCO2HC4E3

ECR repository setup

Create two ECR repositories to store the application and Envoy container images.

Repository 1:

aws ecr create-repository \
--repository-name ${service_name}-blog-app \
--region $region

Output:

{
    "repository": {
        "repositoryArn": "arn:aws:ecr:us-west-1:<account>:repository/ecs-end-end-encryption-blog-app",
        "registryId": "<account>",
        "repositoryName": "ecs-end-end-encryption-blog-app",
        ....
}

Repository 2:

export aws_ecr_repository_url_app=$account.dkr.ecr.$region.amazonaws.com/${service_name}-blog-app

aws ecr create-repository --repository-name ${service_name}-blog-proxy \
--region $region

Output:

{
    "repository": {
        "repositoryArn": "arn:aws:ecr:us-west-1:<account>:repository/ecs-end-end-encryption-blog-proxy",
        "registryId": "<account>",
        "repositoryName": "ecs-end-end-encryption-blog-proxy",
        ...
}
export aws_ecr_repository_url_proxy=$account.dkr.ecr.$region.amazonaws.com/${service_name}-blog-proxy

Certificate setup

Let’s create a key pair and import it as an ACM certificate. We will associate this certificate with the ALB.
The same key and certificate will be used to enable TLS encryption in the Envoy proxy.

mkdir -p docker/certs && cd docker/certs

##Create the config
cat <<EOF > castore.cfg
[ req ]
default_bits = 2048
default_keyfile = my-aws-private.key
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[ req_distinguished_name ]
C = US
ST = VA
L = Richmond
O = awsblogs.info
OU = awsblogs.info
CN= ecs-encryption.awsblogs.info ## Use your domain
emailAddress = user@email.com ## Use your email address
[v3_ca]
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer:always
basicConstraints = CA:true
[v3_req]
## Extensions to add to a certificate request
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
EOF

Use OpenSSL to create the certificate signing authority and then generate the private key and certificate using it.

openssl genrsa -out castore.key 2048
openssl req -x509 -new -nodes -key castore.key -days 3650 -config castore.cfg -out castore.pem

openssl genrsa -out my-aws-private.key 2048
openssl req -new -key my-aws-private.key -out my-aws.csr -config castore.cfg
openssl x509 -req -in my-aws.csr -CA castore.pem -CAkey castore.key -CAcreateserial  -out my-aws-public.crt -days 365
aws acm import-certificate \
--certificate file://my-aws-public.crt \
--private-key file://my-aws-private.key \
--certificate-chain  file://castore.pem \
--region $region

Output:

{
    "CertificateArn": "arn:aws:acm:us-west-1:<account>:certificate/838bca7e-d33b-41e1-b590-97253cd6e506"
}

Export the certificate Arn.

export certificateArn=arn:aws:acm:us-west-1:<account>:certificate/838bca7e-d33b-41e1-b590-97253cd6e506

Let’s verify in the ACM console that the certificate was imported.

Build Docker images and push them to ECR

Let’s create two Docker images, one for Envoy proxy and another for a demo hello application. We will later create a task definition will have this pair of containers defined. Envoy proxy will run as sidecar and route requests to the hello application container over localhost.

Define a simple hello service, with following content:

cd docker

cat <<EOF > service.py
from flask import Flask
import socket

app = Flask(__name__)

@app.route('/service')
def hello():
  return (f'Hello from behind Envoy proxy!!\n')

if __name__ == "__main__":
  app.run(host='0.0.0.0', port=8080, debug=True)
EOF

Create a Dockerfile for the application container.

cat <<EOF > Dockerfile-app
FROM envoyproxy/envoy-alpine-dev:latest

RUN apk update && apk add python3 bash curl; \
      pip3 install -q Flask==0.11.1 requests==2.18.4; \
      mkdir /code

ADD ./service.py /code

EXPOSE 8080

CMD ["python3", "/code/service.py"]
EOF

Create an Envoy configuration file. It will used for proxying and routing requests. All requests from ALB will encrypted using TLS. The proxy will route requests to the application container over HTTP.

cat <<EOF > envoy.yaml
static_resources:
  listeners:
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 443
    filter_chains:
      tls_context:
        common_tls_context:
          tls_certificates:
          - certificate_chain:
              filename: "/etc/ssl/my-aws-public.crt"
            private_key:
              filename: "/etc/ssl/my-aws-private.key"
      filters:
      - name: envoy.http_connection_manager
        config:
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: service
              domains:
              - "*"
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: local_service
          http_filters:
          - name: envoy.router
            config: {}
  clusters:
  - name: local_service
    connect_timeout: 0.5s
    type: strict_dns
    lb_policy: round_robin
    hosts:
    - socket_address:
        address: 127.0.0.1
        port_value: 8080

admin:
  access_log_path: "/dev/null"
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8081
EOF

Create a startup script to run Envoy.

cat <<EOF > start_envoy.sh
#!/bin/sh
/usr/local/bin/envoy -c /etc/envoy.yaml
EOF

Create a Dockerfile for the Envoy proxy.

cat <<EOF > Dockerfile-proxy
FROM envoyproxy/envoy-dev:latest

RUN apt-get update && apt-get -q install -y \
    curl wget jq python \
        python-pip \
        python-setuptools \
        groff \
        less \
        && pip --no-cache-dir install --upgrade awscli
RUN mkdir -p /etc/ssl
ADD start_envoy.sh /start_envoy.sh
ADD envoy.yaml /etc/envoy.yaml
ADD certs /etc/ssl/

RUN chmod +x /start_envoy.sh

ENTRYPOINT ["/bin/sh"]
EXPOSE 443
CMD ["start_envoy.sh"]
EOF

Let’s build the Docker images and push them to ECR.

## Build images locally, make sure you are in the docker folder
docker build -t ${aws_ecr_repository_url_proxy} -f Dockerfile-proxy .
docker build -t ${aws_ecr_repository_url_app} -f Dockerfile-app .

## Verify
docker images | grep $region
 
## Login to ECR
aws ecr get-login --region $region

## Grab the password from output of previous command and execute the following
docker login -u AWS -p <password> https://$account.dkr.ecr.$region.amazonaws.com
Login Succeeded

## Push to ECR
docker push ${aws_ecr_repository_url_proxy}
docker push ${aws_ecr_repository_url_app}

Verify that the images have been pushed to ECR.

Create cluster and task definition

Create a task definition with both the container definitions.

cat <<EOF >  ecs_task_def.template
{
   "containerDefinitions": [
      {
       "logConfiguration": {
         "logDriver": "awslogs",
         "options": {
           "awslogs-group": "/ecs/$service_name",
           "awslogs-region": "$region",
           "awslogs-stream-prefix": "ecs"
         }
       },
       "portMappings": [
         {
           "hostPort": 443,
           "protocol": "tcp",
           "containerPort": 443
         }
       ],
       "cpu": 0,
       "environment": [
         {"name":  "DNS_NAME", "value":  "$service_name.awsblogs.info"}
       ],
       "image": "$aws_ecr_repository_url_proxy:latest",
       "name": "envoy"
     },
     {
       "logConfiguration": {
         "logDriver": "awslogs",
         "options": {
           "awslogs-group": "/ecs/$service_name",
           "awslogs-region": "$region",
           "awslogs-stream-prefix": "ecs"
         }
       },
       "cpu": 0,
       "image": "$aws_ecr_repository_url_app:latest",
       "name": "service"
     }
   ],
   "cpu": "256",
   "taskRoleArn": "$ecsTaskExecutionRoleArn",
   "executionRoleArn": "$ecsTaskExecutionRoleArn",
   "family": "$service_name",
   "memory": "512",
   "networkMode": "awsvpc",
   "requiresCompatibilities": [ 
       "FARGATE" 
    ]

}
EOF

Substitute the environment variables, create a log group, an ECS cluster, and register the task definition.

envsubst <ecs_task_def.template>ecs_task_def.json

export log_group_name=/ecs/ecs-end-end-encryption

aws logs create-log-group \
--log-group-name $log_group_name \
--region $region

export cluster=${service_name}-cluster

aws ecs create-cluster --cluster-name $cluster \
--region $region

Output:

{
    "cluster": {
        "clusterArn": "arn:aws:ecs:us-west-1:<account>:cluster/ecs-encryption-cluster",
        "clusterName": "ecs-encryption-cluster",
        "status": "ACTIVE",
        "registeredContainerInstancesCount": 0,
        "runningTasksCount": 0,
        "pendingTasksCount": 0,
        "activeServicesCount": 0,
        "statistics": [],
        "tags": [],
        "settings": [
            {
                "name": "containerInsights",
                "value": "disabled"
            }
        ],
        "capacityProviders": [],
        "defaultCapacityProviderStrategy": []
    }
}

Register the task definition.

aws ecs register-task-definition \
--cli-input-json file://ecs_task_def.json \
--region=$region

Verify the creation of the task definition on the AWS Management Console.

Create the Application Load Balancer

Create the Application Load Balancer and setup listener rules and target groups for the application.

export alb=${service_name}-alb

aws elbv2 create-load-balancer --name $alb \
--scheme internet-facing \
--subnets $public_subnet1 $public_subnet2 \
--security-groups $sg --region $region

Output:

{
    "LoadBalancers": [
        {
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:loadbalancer/app/ecs-encryption-alb/420cd49c9b77c43b",
            "DNSName": "ecs-encryption-alb-860337044.us-west-1.elb.amazonaws.com",
            ....
        }
    ]
}
export loadbalancerArn=arn:aws:elasticloadbalancing:us-west-1:<account>:loadbalancer/app/ecs-encryption-alb/420cd49c9b77c43b 

aws elbv2 create-target-group \
--name https-target \
--protocol HTTPS \
--port 443 \
--health-check-path /service \
--target-type ip \
--vpc-id $vpcId \
--region $region

Output:

{
    "TargetGroups": [
        {
            "TargetGroupArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:targetgroup/https-target/3e6bed06eed53f98",
            "TargetGroupName": "https-target",
            "Protocol": "HTTPS",
            "Port": 443,
            "VpcId": "vpc-0a0598d1d7d1dd8b3",
            ...
    ]
}
export targetGroupArn=arn:aws:elasticloadbalancing:us-west-1:<account>:targetgroup/https-target/3e6bed06eed53f98

aws elbv2 create-listener --load-balancer-arn $loadbalancerArn \
--protocol HTTPS --port 443  \
--certificates CertificateArn=$certificateArn \
--default-actions Type=forward,TargetGroupArn=$targetGroupArn \
--region $region

Output:

{
    "Listeners": [
        {
            "ListenerArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:listener/app/ecs-encryption-alb/420cd49c9b77c43b/c234e7efab3456d9",
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:loadbalancer/app/ecs-encryption-alb/420cd49c9b77c43b",
            "Port": 443,
            "Protocol": "HTTPS",
            "Certificates": [
                {
                    "CertificateArn": "arn:aws:acm:us-west-1:<account>:certificate/838bca7e-d33b-41e1-b590-97253cd6e506"
                }
            ],
            "SslPolicy": "ELBSecurityPolicy-2016-08",
            "DefaultActions": [
                {
                    "Type": "forward",
                    "TargetGroupArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:targetgroup/https-target/3e6bed06eed53f98",
                    "ForwardConfig": {
                        "TargetGroups": [
                            {
                                "TargetGroupArn": "arn:aws:elasticloadbalancing:us-west-1:<account>:targetgroup/https-target/3e6bed06eed53f98",
                                "Weight": 1
                            }
                        ],
                        "TargetGroupStickinessConfig": {
                            "Enabled": false
                        }
                    }
                }
            ]
        }
    ]
}

Create the service

Create the ECS service definition template. Replace the values in the file to match your account.

cat <<EOF >  ecs_service_def.template
{
    "serviceName": "$service_name-service",
    "cluster": "arn:aws:ecs:$region:$account:cluster/$cluster",
    "taskDefinition": "arn:aws:ecs:$region:$account:task-definition/$service_name",
    "loadBalancers": [
                {
                    "targetGroupArn": "$targetGroupArn",
                    "containerName": "envoy",
                    "containerPort": 443
                }
            ],
    "launchType": "FARGATE",
    "platformVersion": "LATEST",
    "networkConfiguration": {
                "awsvpcConfiguration": {
                    "subnets": [
                        "$private_subnet1", "$private_subnet2"
                    ],
                    "securityGroups": [
                        "$sg"
                    ],
                    "assignPublicIp": "ENABLED"
                }
            },
    "deploymentConfiguration": {
                "maximumPercent": 200,
                "minimumHealthyPercent": 100
            },
    "desiredCount": 2,
    "healthCheckGracePeriodSeconds": 0,
    "schedulingStrategy": "REPLICA",
    "enableECSManagedTags": false,
    "propagateTags": "NONE"
    
}
EOF

And create the ECS Service, using the registered task definition and the Application Load Balancer.

envsubst <ecs_service_def.template>ecs_service_def.json

##create service 
aws ecs create-service --cluster $cluster \
--service-name ${service_name}-service \
--cli-input-json file://ecs_service_def.json \
--region $region

Verify the service in the AWS Management Console.

Setup DNS

Let’s now setup a Route 53 record set on the domain I host on Route 53. I have created a hosted zone. I will create and point a record set to the ALB I created earlier. I have setup the Common Name (CN) on the cert with this domain [ CN= ecs-end-end-encryption.awsblogs.info ]

Test

Let’s test the TLS handshake with the application using a curl command.

echo quit | openssl s_client -showcerts -servername ecs-encryption.awsblogs.info -connect ecs-encryption.awsblogs.info:443 > cacert.pem

Output:

depth=0 C = US, ST = VA, L = Richmond, O = awsblogs.info, OU = awsblogs.info, CN = ecs-encryption.awsblogs.info, emailAddress = user@email.com
verify error:num=18:self signed certificate
verify return:1
depth=0 C = US, ST = VA, L = Richmond, O = awsblogs.info, OU = awsblogs.info, CN = ecs-encryption.awsblogs.info, emailAddress = user@email.com
verify return:1
DONE
##Hit the service 
curl --cacert cacert.pem https://ecs-encryption.awsblogs.info/service

Output:

Hello from behind Envoy proxy!!

There you have it. We encrypted traffic from client to ALB and from ALB to the application (through Envoy front proxy) and we didn’t have to modify the application.

By the way, Envoy has many other uses and many popular Service Meshes use Envoy for data plane. Just like I transparently added the encryption to my application, I could also use Envoy to generate access logs, traces and network metrics. Instead of managing and configuring individual Envoy proxies, Service Meshes give you centralized management of service proxies. Learn more about App Mesh here.

Resources

AWS Cloud Formation templates for ECS infrastructure setup
Maintaining Transport Layer Security
Encryption end-end using NLB
Encryption All The Way To The Container In ECS With Envoy

Sahil Thapar

Sahil Thapar

Sahil Thapar is an Enterprise Solutions Architect. He works with customers on building highly available, scalable, and resilient applications on AWS Cloud. He is currently focused on Containers and Machine Learning solutions.