Como criar um cluster EKS com Terraform
Faz bastante tempo que não apareço por aqui, hein?! :)
Bom, se você está dedicando parte do seu tempo para ler este artigo, eu só tenho que agradecer. Muito obrigado por confiar no meu trabalho e continuar junto comigo.
Em Junho de 2020 eu fiz a minha transição de carreira para o mundo de DevOps, ainda atuando em um ambiente mais de suporte, porém focando em tecnologias de containers, ambientes distribuídos, etc. Por conta disso, tenho estudado cada vez mais afundo sobre IaC e um dos projetos que eu estava pensando em desenvolver era justamente a criação de um cluster EKS na AWS usando Terraform.
Li bastante na documentação oficial, códigos de outras pessoas e artigos técnicos. Tudo começou a fazer sentido quando estudei mais afundo sobre AWS e redes de computadores (sim, a base faz muito falta). Então, o meu objetivo neste artigo é fazer um baby steps sobre a configuração do EKS e como fazer isso usando o Terraform.
Se isso te interessou, segue comigo…
Agradecimento
Meu agradecimento aqui vai para o Matheus Fidelis. Ele iniciou um projeto chamado de Terraformando o EKS no YouTube, ao qual ele compartilhou o código Terraform que desenvolveu no GitHub.
Foi baseado neste código que eu consegui desenvolver o projeto. Eu ia codando, lendo documentações, entendendo, mas o código dele sempre foi a base da “verdade” por onde eu confirmava se estava fazendo certo. Muito obrigado!
Enjoy!
Requisitos
Vou listar aqui abaixo alguns requisitos para você entender melhor tudo isso.
Arquitetura do EKS
Por trás de um cluster EKS existem vários detalhes, principalmente de redes que precisam estar funcionais antes de tudo. Você pode ler mais sobre isso aqui.
Veja a imagem a seguir que representa a arquitetura:
Deixa eu clarificar alguns pontos importantes aqui:
- O EKS control plane é onde estarão os componentes “master” do cluster, sendo o controller, scheduler, etcd… E isso é gerenciado pela própria AWS, ou seja, você não terá acesso a essas instâncias. Você vai gerenciar apenas os workers…
- Precisamos antes de tudo, criar uma VPC para ter uma “rede isolada” pro cluster. Como se fosse a rede do nosso datacenter
- Veja que dentro dessa VPC, existem as Availability Zones (AZ), e o cluster EKS exige pelo menos duas AZs para ter alta disponibilidade.
- Para um setup de produção, é recomendado duas subnets em cada AZ: uma privada e uma pública. Isso porque os pods vão rodar internamente nas subnets privadas, enquanto os Ingress e Load Balancer (que irão exportar a aplicação para o mundo real) serão alocadas nas subnets públicas.
Terraform Modules
Todo o código desenvolvido foi feito usando Terraform Modules, isto é, você consegue reusar o código já feito e separar em várias pequenas partes mais fáceis de manter.
Vou deixar aqui algumas leituras recomendadas:
Recomendo a leitura desta documentação. Veja sobre Public and private subnets que é o setup recomendado.
Estrutura do projeto
Essa aqui foi a estrutura do projeto que eu utilizei… Você já pode criar todos os arquivos na sua máquina e depois alimentar com o conteúdo ao longo do artigo.
├── modules
│ ├── master
│ │ ├── eks-master.tf
│ │ ├── iam.tf
│ │ ├── output.tf
│ │ ├── security-group.tf
│ │ └── variables.tf
│ ├── network
│ │ ├── internet-gateway.tf
│ │ ├── nat-gateway.tf
│ │ ├── output.tf
│ │ ├── private.tf
│ │ ├── public.tf
│ │ ├── variables.tf
│ │ └── vpc.tf
│ └── node
│ ├── iam.tf
│ ├── node-group.tf
│ └── variables.tf
├── modules.tf
├── provider.tf
└── variables.tf
Apenas para clarificar:
- Veja que temos uma pasta com os módulos, e para cada módulo também temos uma pasta com o nome e os arquivos .tf dentro
- O módulo de network contém toda a configuração de rede (vpc, subnet, internet gateway, nat gateway, rotas, etc)
- O módulo master contém a configuração do control plane do EKS
- O módulo node contém as configurações dos workers
- Por último temos os arquivos na raíz do projeto que farão o uso e a junção de todos os módulos
Módulo de Network
vpc.tf
resource "aws_vpc" "eks_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = format("%s-vpc",var.cluster_name)
}
}
Aqui é a criação da rede do nosso “datacenter” onde tudo vai ficar armazenado. Veja que estou colocando uma tag usando uma variável (você verá ela no final). A tag está usando a função format que vai anexar ao final da string. Por exemplo, se o nome do cluster for “eks-demo”, a VPC vai se chamar “eks-demo-vpc”, assim conseguimos manter um padrão de nomenclatura ao longo do projeto. Isso vai ser usado em todo o código.
public.tf
resource "aws_subnet" "eks_subnet_public_1a" {
vpc_id = aws_vpc.eks_vpc.id
cidr_block = "10.0.3.0/24"
availability_zone = format("%sa", var.region)
map_public_ip_on_launch = true
tags = {
Name = format("%s-subnet-public-1a", var.cluster_name)
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_subnet" "eks_subnet_public_1b" {
vpc_id = aws_vpc.eks_vpc.id
cidr_block = "10.0.4.0/24"
availability_zone = format("%sb", var.region)
map_public_ip_on_launch = true
tags = {
Name = format("%s-subnet-public-1b", var.cluster_name)
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_route_table_association" "eks_public_rt_association_1a" {
subnet_id = aws_subnet.eks_subnet_public_1a.id
route_table_id = aws_route_table.eks_public_rt.id
}
resource "aws_route_table_association" "eks_public_rt_association_1b" {
subnet_id = aws_subnet.eks_subnet_public_1b.id
route_table_id = aws_route_table.eks_public_rt.id
}
Este é o código da nossa subnet pública…
- Estamos criando duas subnets, uma em cada AZ (datacenter físico diferente na mesma região)
- Estamos usando a opção
map_public_ip_on_launch
que vai habilitar o uso de IP público em cada instância nessa subnet - Veja as tags “kubernetes.io/cluster”… Isso é obrigatório no cluster EKS. É com base nessa tag que o cluster EKS sabe que pode usar a subnet. Leia mais sobre isso aqui
- Depois temos a associação da subnet em uma route table, isto é, as máquinas nessa subnet terão de respeitar as rotas do objeto
eks_public_rt
private.tf
resource "aws_subnet" "eks_subnet_private_1a" {
vpc_id = aws_vpc.eks_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = format("%sa", var.region)
tags = {
Name = format("%s-subnet-private-1a", var.cluster_name)
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_subnet" "eks_subnet_private_1b" {
vpc_id = aws_vpc.eks_vpc.id
cidr_block = "10.0.2.0/24"
availability_zone = format("%sb", var.region)
tags = {
Name = format("%s-subnet-private-1b", var.cluster_name)
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_route_table_association" "eks_private_rt_association_1a" {
subnet_id = aws_subnet.eks_subnet_private_1a.id
route_table_id = aws_route_table.eks_nat_rt.id
}
resource "aws_route_table_association" "eks_private_rt_association_1b" {
subnet_id = aws_subnet.eks_subnet_private_1b.id
route_table_id = aws_route_table.eks_nat_rt.id
}
Aqui é basicamente a mesma coisa do exemplo acima, exceto pelo fato de que vamos adicionar as duas subnets privada na route table eks_nat_rt
.
Ah, outro ponto é que não existe a opção map_public_ip_on_launch
, ou seja, as máquinas nessa subnet não terão IP público.
internet-gateway.tf
resource "aws_internet_gateway" "eks_ig" {
vpc_id = aws_vpc.eks_vpc.id
tags = {
Name = format("%s-internet-gateway", var.cluster_name)
}
}
resource "aws_route_table" "eks_public_rt" {
vpc_id = aws_vpc.eks_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.eks_ig.id
}
tags = {
Name = format("%s-public-rt", var.cluster_name)
}
}
Perceba que estamos criando um Internet Gateway e atrelando ele na route table eks_public_rt
. Isso significa que as instâncias na subnet pública poderão se comunicar (incoming e outgoing) com o mundo a fora passando pelo Internet Gateway.
nat-gateway.tf
resource "aws_eip" "eks_eip" {
vpc = true
tags = {
"Name" = format("%s-elastic-ip", var.cluster_name)
}
}
resource "aws_nat_gateway" "eks_nat_gw" {
allocation_id = aws_eip.eks_eip.id
subnet_id = aws_subnet.eks_subnet_public_1a.id
tags = {
Name = format("%s-nat-gateway", var.cluster_name)
}
}
resource "aws_route_table" "eks_nat_rt" {
vpc_id = aws_vpc.eks_vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.eks_nat_gw.id
}
tags = {
Name = format("%s-private-rt", var.cluster_name)
}
}
Aqui há algumas mudanças…
- Alocamos um Elastic IP (um IP externo fixo que nunca muda)
- Criamos um NAT Gateway na subnet pública
- Criamos uma route table
eks_nat_gw
que direciona o tráfego externo para o NAT Gateway
Isso quer dizer que as VMs dentro da subnet privada poderão se comunicar com o mundo afora pelo NAT Gateway, que vai fazer NAT pro Elastic IP que alocamos, porém, não receberá tráfego interno.
Essa é a grande diferença entre o Internet Gateway e o NAT Gateway. O Internet Gateway envia e recebe (rede pública), o NAT Gateway só envia (rede privada).
variables.tf
variable "cluster_name" {}
variable "region" {}
Aqui apenas declaramos as variáveis usadas no código. Elas recebem valor apenas quando o módulos os chama.
output.tf
output "vpc_id" {
value = aws_vpc.eks_vpc.id
}
output "private_subnet_1a" {
value = aws_subnet.eks_subnet_private_1a.id
}
output "private_subnet_1b" {
value = aws_subnet.eks_subnet_private_1b.id
}
Essa é a parte fundamental de usar programação com módulos. É baseado nesse arquivo que vamos coletar as saídas do módulos para usar no EKS.
Ou seja, vamos chamar o módulo de network, ele vai retornar qual é a VPC e as subnets privadas criadas, que são as informações que precisamos para instanciar o cluster EKS.
Uffa! Acabamos o módulo de network…
Módulo do Master
iam.tf
resource "aws_iam_role" "eks_master_role" {
name = format("%s-master-role", var.cluster_name)
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "eks.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "eks_cluster_cluster" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.eks_master_role.name
}
resource "aws_iam_role_policy_attachment" "eks_cluster_service" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSServicePolicy"
role = aws_iam_role.eks_master_role.name
}
Essa é a parte chata… Para ter permissão de criar as EC2, fazer auto scaling e tals, o EKS precisa de uma role que permita fazer isso. Neste caso, estamos criando uma role que referencia o serviço do EKS.
Depois adicionamos as permissões AmazonEKSClusterPolicy e AmazonEKSServicePolicy na role.
Leia mais sobre isso aqui.
security-group.tf
resource "aws_security_group_rule" "eks_sg_ingress_rule" {
cidr_blocks = ["0.0.0.0/0"]
from_port = 443
to_port = 443
protocol = "tcp"
security_group_id = aws_eks_cluster.eks_cluster.vpc_config[0].cluster_security_group_id
type = "ingress"
}
Esse security group vai permitir a conexão HTTPS externa.
master.tf
resource "aws_eks_cluster" "eks_cluster" {
name = var.cluster_name
role_arn = aws_iam_role.eks_master_role.arn
version = var.kubernetes_version
vpc_config {
subnet_ids = [
var.private_subnet_1a,
var.private_subnet_1b
]
}
depends_on = [
aws_iam_role_policy_attachment.eks_cluster_cluster,
aws_iam_role_policy_attachment.eks_cluster_service
]
}
Aqui é onde criamos o cluster EKS de fato…
- Note que estamos atrelando a role que criamos acima nesse cluster
- Estamos também distribuindo em duas subnets privadas em diferentes availability zones
Lembram que na subnet pública nós colocamos uma tag chamada kubernetes.io? Pois é, quando criarmos um Ingress, ele vai procurar por uma subnet pública com aquela tag, por isso não precisamos referenciar aqui, mantemos somente na rede privada.
Criei também uma dependência na role, porque na hora de remover com o Terraform, eu quero garantir que o cluster seja removido primeiro e depois a role. Imagina se eu removo a role e depois não tenho permissão para remover o cluster? Que dor de cabeça hein…
variables.tf
variable "cluster_name" {}
variable "kubernetes_version" {}
variable "private_subnet_1a" {}
variable "private_subnet_1b" {}
Aqui é onde declaramos as variáveis usadas no módulo.
output.tf
output "cluster_name" {
value = aws_eks_cluster.eks_cluster.id
}
Isso é bastante importante… Estou enviando o ID do cluster que foi criado para a saída do módulo. Isso porque o grupo de workers que vamos criar precisa estar atrelado a um cluster, logo, precisamos do ID dele!
Módulo do Node
iam.tf
resource "aws_iam_role" "eks_node_role" {
name = format("%s-node-role", var.cluster_name)
assume_role_policy = jsonencode({
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
Version = "2012-10-17"
})
}
resource "aws_iam_role_policy_attachment" "eks_AmazonEKSWorkerNodePolicy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
role = aws_iam_role.eks_node_role.name
}
resource "aws_iam_role_policy_attachment" "eks_AmazonEKS_CNI_Policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
role = aws_iam_role.eks_node_role.name
}
resource "aws_iam_role_policy_attachment" "eks_AmazonEC2ContainerRegistryReadOnly" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
role = aws_iam_role.eks_node_role.name
}
Aqui é o mesmo esquema do cluster, porém, estou atribuindo as policies necessárias para que os workers funcionem.
Leia aqui sobre as roles necessárias para os nodes.
node-group.tf
resource "aws_eks_node_group" "eks_node_group" {
cluster_name = var.cluster_name
node_group_name = format("%s-node-group", var.cluster_name)
node_role_arn = aws_iam_role.eks_node_role.arn
subnet_ids = [
var.private_subnet_1a,
var.private_subnet_1b
]
scaling_config {
desired_size = var.desired_size
max_size = var.max_size
min_size = var.min_size
}
depends_on = [
aws_iam_role_policy_attachment.eks_AmazonEKSWorkerNodePolicy,
aws_iam_role_policy_attachment.eks_AmazonEKS_CNI_Policy,
aws_iam_role_policy_attachment.eks_AmazonEC2ContainerRegistryReadOnly
]
}
Note o seguinte:
- Estou atrelando esse node group no cluster referenciado pelo parâmetro
cluster_name
- Também estou distribuindo em duas subnets privadas
- Estou passando alguns parâmetros de escala horizontal, por exemplo… “quero no mínimo 3 nodes, sendo que o ideal seria 5 e o máximo será 20”. Assim o EKS sabe até onde ele pode escalar para não estourar o budget
variables.tf
variable "cluster_name" {}
variable "private_subnet_1a" {}
variable "private_subnet_1b" {}
variable "desired_size" {}
variable "max_size" {}
variable "min_size" {}
Aqui estão as variáveis que vamos usar no módulo.
Show de bola, agora já estamos toda a estrutura de network, o cluster em si e até mesmo os workers, tudo seperado em módulos…
Chegou a hora de juntar todos eles para criar a nossa infraestrutura.
Execução
provider.tf
provider "aws" {
region = var.region
}
Um arquivo separadinho para o provider…
modules.tf
module "network" {
source = "./modules/network"
cluster_name = var.cluster_name
region = var.region
}
module "master" {
source = "./modules/master"
cluster_name = var.cluster_name
kubernetes_version = var.kubernetes_version
private_subnet_1a = module.network.private_subnet_1a
private_subnet_1b = module.network.private_subnet_1b
}
module "node" {
source = "./modules/node"
cluster_name = module.master.cluster_name
private_subnet_1a = module.network.private_subnet_1a
private_subnet_1b = module.network.private_subnet_1b
desired_size = var.desired_size
min_size = var.min_size
max_size = var.max_size
}
Aqui é onde toda a mágica acontece…
- No primeiro módulo estamos informando o
cluster_name
e aregion
que queremos utilizar (lembra que essas são as variáveis necessárias pro módulo funcionar declaradas novariables.tf
) - Segundamente, criamos o cluster EKS informando os IDs das redes privadas que foram criadas pelo módulo acima… Isso cria uma interdependência entre eles e garante que o módulo
master
depende do módulonetwork
- Por último, criamos o node group apontando para as mesmas subnets privadas do módulo
network
e também usando o ID do cluster da saída do módulomaster
. Isto é, o módulonode
depende dos outros módulos
Enfim podemos executar o nosso projeto…
$ terraform plan
$ terraform apply
E aí, curtiu? Deixa um comentário aqui em baixo.
Buy me a coffee