No artigo de hoje, vamos apresentar um tutorial para criar uma infraestrutura na AWS utilizando script Terraform com o objetivo de executar aplicações Dockers em cluster no ECS Fargate. Este mecanismo é interessante, pois a própria AWS gerencia as máquinas virtuais que irão ficar os contêineres.
Já o Terraform é bastante utilizado para criar e alterar a infraestrutura de cloud computing pública de forma segura e eficiente. O script facilita o fluxo de trabalho, pois o código passa a ter controle de versão e assim é possível reproduzir em diversos ambientes de desenvolvimento, homologação e produção.
Se você já tem conhecimento com Docker e Terraform, vale a pena conhecer esta forma de executar aplicações na AWS. Se não, o blog da Zup tem artigos sobre Docker e conteúdos sobre Terraform que te ajudarão a conhecer um pouco mais da tecnologia abordada aqui.
O projeto final criado neste artigo está disponível neste repositório no GitHub.
O tutorial
O tutorial será dividido em duas etapas:
- A primeira etapa é criar uma aplicação Docker usando Node.js e iremos subir a imagem no ECR (Amazon Elastic Container Registry);
- A segunda etapa é criar uma infraestrutura na AWS conforme o diagrama e visualizar no navegador a aplicação sendo executada.
Pré-requisitos
Antes de começar é importante que tenha algumas aplicações instaladas no seu computador, neste tutorial estamos utilizando o Docker (versão 20.10.12), AWS CLI (versão 2.9.8), Terraform (versão 1.3.7) e Node.JS.
Além disso, na AWS é necessário configurar as permissões no IAM (Identity and Access Management). Ao criar um usuário no IAM, deve-se criar uma chave AWS (access keys) que pode ser inserida no arquivo terraform.tfvars e esta chave serve para conectar o Terraform com a AWS.
Também temos que definir as permissões de acesso por meio das políticas (Policy), por ser um tutorial de teste foi definida a seguinte lista: AmazonEC2FullAccess, AmazonECS_FullAccess, AmazonVPCFullAccess, EC2InstanceProfileForImageBuilderECRContainerBuilds, IAMFullAccess.
Inclusive, se quiser saber mais sobre IAM, você pode ouvir o nosso podcast. Temos um episódio específico sobre o tema:
Back-end
- Primeiro vamos criar uma imagem no Docker, para isso vamos desenvolver uma aplicação simples WEB utilizando Node.JS com Express. Desta forma, crie uma pasta chamada container_docker e uma inicialização de pacotes npm juntamente com o Express.
- Agora execute npm init –y e depois npm install express. Em seguida, crie um arquivo index.js com o seguinte código abaixo. Caso necessite conferir se está funcionando, utilize o comando node index.js e acesse no navegador utilizando a url http://localhos t:3000.
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(port, () => console.log(`listening on port 3000`));
3. Precisamos criar o nosso arquivo chamado Dockerfile, que é o documento utilizado para definir os comandos no contêiner.
FROM node:12.7.0-alpine
WORKDIR '/app'
COPY package.json .
RUN yarn
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
Implementação do ECR Terraform
Na segunda etapa deste tutorial, vamos utilizar o serviço AWS ECR, que são repositórios para armazenar e gerir imagens de contêineres. Então, vamos começar a fazer um script Terraform separado da criação da infraestrutura ECS.
- Crie uma pasta chamada ecr para colocarmos os scripts de contêineres;
- Agora crie o arquivo ecr.tf com o nome do repositório e a sua política;
- Definimos em sua política a retenção das 10 últimas imagens compiladas, assim, as versões mais antigas serão expiradas. O arquivo main.tf e variables.tf estão disponíveis no repositório e contém informações de bibliotecas e acessos da aws, bem como as variáveis que são utilizadas neste projeto.
resource "aws_ecr_repository" "this" {
name = var.container_image
}
resource "aws_ecr_repository_policy" "this" {
repository = aws_ecr_repository.this.name
policy = <<EOF
{
"Version": "2008-10-17",
"Statement": [
{
"Sid": "policy ecr access repository",
"Effect": "Allow",
"Principal": "*",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:CompleteLayerUpload",
"ecr:GetDownloadUrlForLayer",
"ecr:GetLifecyclePolicy",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
]
}
]
}
EOF
}
resource "aws_ecr_lifecycle_policy" "this" {
repository = aws_ecr_repository.this.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "last 10 docker images"
action = {
type = "expire"
}
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
}]
})
}
4. Para executar o script Terraform siga os seguintes comandos:
terraform init -backend=true -backend-config="backend.hcl"
terraform plan
terraform apply
Subindo imagem Docker
Ao finalizar o processo de criação do nosso ECR, teremos a infraestrutura do repositório criada no console da AWS da região que estamos usando US East (N. Virginia).
Ao selecionar o checkbox do repositório temos no menu a opção habilitada para selecionar o View Push Commands que abrirá um modal com os comandos necessários para subir a nossa imagem do Docker. Note que temos os comandos para Linux/MacOS e também Windows. Abaixo mostramos os comandos em Linux/MacOS necessários para enviar a imagem Docker para o repositório ECR. Lembre-se de ter Docker e AWS CLI instalado localmente. Após instalar e configurar a AWS CLI iremos autenticar com o Docker. Nos comandos abaixo será necessário alterar para seu ID de conta da AWS e a região que está utilizando.
aws ecr get-login-password --region region | docker login --username AWS --password-stdin aws_account_id.dkr.ecr.region.amazonaws.com
Depois entre na pasta da aplicação Node e iremos executar o build do projeto.
docker build -t my-app-container .
Depois que a construção da imagem estiver pronta, vamos definir uma tag para a imagem para subir para o repositório.
docker tag my-app-container:latest aws_account_id.dkr.ecr.region.amazonaws.com/my-app-container:latest
Por fim, vamos enviar a imagem para o repositório ECR da AWS.
docker push aws_account_id.dkr.ecr.region.amazonaws.com/my-app-container:latest
Implementação do ECS Fargate Terraform
Agora vamos criar nossa infraestrutura ECS Fargate, uma tecnologia que gerencia os contêineres sem a necessidade de gerenciar as máquinas virtuais, pois a própria AWS se encarrega de alocar os recursos necessários.
- Neste script, vamos criar uma rede VPC para esse projeto, e teremos um load balancer que irá redirecionar as requisições para os contêineres do ECS. Para facilitar nosso estudo, vamos deixar em uma subnet pública e com IPs públicos alocados para o contêiner ter acesso a internet;
- Vamos criar uma nova pasta chamada ecs e colocar os scripts da infraestrutura do ECS. Crie um arquivo locals.tf que será responsável por manter as tags comuns em todo projeto e variáveis da subnet.
locals {
subnet_ids = { for k, v in aws_subnet.this : v.tags.Name => v.id }
common_tags = {
Project = "ECS Fargate"
CreatedAt = "2022-12-17"
ManagedBy = "Terraform ZUP"
Owner = "Caio Thomas Oliveira"
Service = "ECS Fargate"
}
}
3. Vamos criar a vpc.tf, que será a parte de rede da AWS, onde iremos colocar nossos contêineres. Então teremos um internet gateway associado a VPC com a nossa subnet pública e as rotas de tabelas (Route Tables) associadas. Vale lembrar que, neste tutorial, vamos utilizar apenas as subnets públicas para facilitar o estudo.
resource "aws_vpc" "this" {
cidr_block = "192.168.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.common_tags, { Name : "Terraform-ECS-Zup VPC" })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, { Name : "Terraform-ECS-Zup IGW" })
}
resource "aws_subnet" "this" {
for_each = {
"pub_a" : ["192.168.1.0/24", "${var.aws_region}a", "Public A"]
"pub_b" : ["192.168.2.0/24", "${var.aws_region}b", "Public B"]
}
vpc_id = aws_vpc.this.id
cidr_block = each.value[0]
availability_zone = each.value[1]
tags = merge(local.common_tags, { Name = each.value[2] })
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = merge(local.common_tags, { Name = "Terraform-ECS-Zup Public" })
}
resource "aws_route_table_association" "this" {
for_each = local.subnet_ids
subnet_id = each.value
route_table_id = aws_route_table.public.id
}
4. Neste momento, vamos criar nosso Load Balancer (ALB), que será responsável por redirecionar as requisições para os contêineres. Nós utilizamos o Security Group com o objetivo de liberar a porta 80 onde a pessoa usuária final irá fazer as requisições.
resource "aws_lb" "this" {
name = "Terraform-ECS-Zup-ALB"
security_groups = [aws_security_group.alb.id]
load_balancer_type = "application"
subnets = [aws_subnet.this["pub_a"].id, aws_subnet.this["pub_b"].id]
tags = merge(local.common_tags, { Name = "Terraform ECS ALB" })
}
resource "aws_lb_target_group" "this" {
name = "ALB-TG"
port = 80
protocol = "HTTP"
target_type = "ip"
vpc_id = aws_vpc.this.id
health_check {
healthy_threshold = "3"
interval = "30"
protocol = "HTTP"
matcher = "200,301,302"
path = "/"
timeout = "5"
unhealthy_threshold = "5"
}
}
resource "aws_lb_listener" "this" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}
resource "aws_security_group" "alb" {
name = "Terraform-ECS-Zup-ALB-SG"
description = "SG-ALB-ZUP"
vpc_id = aws_vpc.this.id
ingress {
protocol = "tcp"
from_port = 80
to_port = 80
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.common_tags, { Name : "Terraform ECS ALB-SG" })
}
5. No arquivo main.tf, vamos criar nosso ECS cluster, definido a task com a imagem do repositório; as portas do contêiner, que no caso é 3000; quantidade de memória; e a CPU;
6. Também definimos o ECS service que é do tipo FARGATE, as subnets que serão públicas e os IPs públicos para acesso de internet dentro do contêiner;
7. Além disso, definimos o Security Group no ALB (load balancer) que possui objetivo de controlar o tráfego de entrada e saída, bloquear portas e blocos de IPs.
resource "aws_ecs_cluster" "this" {
name = var.cluster_name
}
resource "aws_ecs_task_definition" "this" {
family = var.cluster_task
container_definitions = <<DEFINITION
[
{
"name": "${var.cluster_task}",
"image": "${var.image_url}",
"essential": true,
"portMappings": [
{
"containerPort": ${var.container_port},
"hostPort": ${var.container_port}
}
],
"memory": ${var.memory},
"cpu": ${var.cpu}
}
]
DEFINITION
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
memory = var.memory
cpu = var.cpu
execution_role_arn = aws_iam_role.ecsTaskExecutionRole.arn
}
resource "aws_iam_role" "ecsTaskExecutionRole" {
name = "ecsTaskExecutionRole2"
assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}
data "aws_iam_policy_document" "assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy_attachment" "ecsTaskExecutionRole_policy" {
role = aws_iam_role.ecsTaskExecutionRole.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_service" "this" {
name = var.cluster_service
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
launch_type = "FARGATE"
scheduling_strategy = "REPLICA"
desired_count = var.desired_capacity
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = aws_ecs_task_definition.this.family
container_port = var.container_port
}
network_configuration {
subnets = [aws_subnet.this["pub_a"].id, aws_subnet.this["pub_b"].id]
security_groups = [aws_security_group.this.id]
assign_public_ip = true
}
}
resource "aws_security_group" "this" {
name = "Terraform-ECS-Zup TASK SG"
description = "Terraform-ECS-Zup SG"
vpc_id = aws_vpc.this.id
ingress {
protocol = "tcp"
from_port = var.container_port
to_port = var.container_port
security_groups = [aws_security_group.alb.id]
}
egress {
protocol = "-1"
from_port = 0
to_port = 0
cidr_blocks = ["0.0.0.0/0"]
}
}
8. No arquivo variables.tf será necessário pegar o link da imagem do Docker no ECR e definir na variável “image_url”. Neste arquivo também temos as chaves AWS necessárias para executar o Terraform e outras definições que poderão ser alteradas de acordo com o projeto, como: CPU, memória e nomes de tarefas, cluster e serviços;
9. Utilizando o comando da AWS CLI via terminal aws ecs list-clusters, temos a resposta do nome do nosso cluster definido como: my-cluster. Para ver as instâncias que estão em execução utilize o comando aws ecs list-tasks –cluster my-cluster;
10. Note que temos como resultado a task executando três instâncias Docker em servidores ECS Fargate diferentes para garantir a disponibilidade. Usando o comando aws ecs describe-services –service my-first-service –cluster my-cluster temos o retorno de informações importantes que também podem ser vistas no console da AWS. São detalhes do processo, no qual temos a VPC, que encontra a aplicação com as subnets públicas que definimos. Também ficou definido o IP público para ter acesso a internet para montar o contêiner;
11. Ao executarmos o comando aws elbv2 describe-load-balancers –name Terraform-ECS-Zup-ALB, conseguimos recuperar informações do load bancers criado, no qual é responsável por receber as requisições na porta 80 e redirecionar para determinado contêiner na porta 3000. É importante lembrar que este serviço possui as definições de configuração do Target com o Health Checks;
12. Tal tarefa possui a função de requisitar periodicamente o status do servidor e validar se o serviço está ativo e, assim, garantir que existem contêineres funcionando de acordo com o número definido de instâncias desejadas. Por fim, no DSN name da resposta, temos a URL para conseguirmos acessar a aplicação pelo navegador.
Pontos de atenção
- Sobre as variáveis aws_access_key e aws_secret_key, o recomendável é utilizar por meio da configuração de ambiente para evitar problemas de segurança da cloud. Tenha sempre cuidado ao publicar esse tipo de informações em códigos públicos e privados;
- Não estamos liberando acessos aos contêineres em seus respectivos Ip públicos apesar de estar em uma subnet pública. Caso deseje, é necessário habilitar no Security Group.
Conclusão
O artigo mostra uma forma simples de criar uma infraestrutura para executar contêineres Docker na AWS. Uma grande vantagem é a escalabilidade e a disponibilidade de sua aplicação quando se tem grande demanda de acesso.
Além disso, o uso do ECS Fargate possibilita o gerenciamento de servidores onde serão alocados os recursos e a orquestração de contêiner definidas nas configurações. Ao utilizar o Terraform, possibilitamos a manutenção para futuras mudanças que poderão ser executadas por meio de scripts.
Uma outra abordagem do ECS é utilizar uma subnet privada com um NAT ligado a uma subnet pública e este conectado a subnet privada, ou adotar uma outra solução que é utilizar o AWS Private Link para conectar serviços AWS ou terceiros sem a necessidade de expor na internet.
Por fim, caso deseje destruir toda a infraestrutura do ambiente criada neste tutorial basta utilizar o comando do terraform destroy.
Esperamos que tenha apreciado o tutorial que preparamos e aguardamos a sua opinião nos comentários do artigo. Por hoje é isso, continue acompanhando a nossa central de conteúdos!