Como Arquiteto Cloud, uma das minhas principais funções é garantir a alta disponibilidade de nossos serviços para ajudar nossa empresa a atingir suas metas de negócios.
No ano passado, um desses objetivos era reduzir os custos de cloud, pois nossos serviços devem atender a SLAs rigorosos com um ambiente de produção altamente disponível.
Neste artigo, vou orientá-lo sobre como reduzir significativamente os custos em seus clusters EKS, usando instâncias spot do AWS EC2 e, esperamos, dar a você a confiança necessária para usar instâncias spot com nodepool altamente disponíveis no ambiente de produção.
O que são instâncias spot ?
As instâncias spot do EC2 representam capacidade de computação na AWS, onde são oferecidas com desconto de 60 a 80% sobre o preço sob demanda.
São gerenciados em nodepool de instâncias spot, onde são conjuntos de instâncias do EC2 com o mesmo tipo de instância solicitada, sistema operacional e zona de disponibilidade (AZ).
Se um nodepool de instâncias spot não estiver mais disponível, a instância spot poderá ser interrompida, recebendo uma notificação de encerramento com um aviso de dois minutos antes de ser encerrada.
Provisionamento de instância spot
Com as instâncias spot, cada tipo de instância em cada zona de disponibilidade é um nodepool com seu próprio preço spot, com base na capacidade disponível.
Infelizmente, o componente autoscaler de nodes (Cluster Autoscaler), não oferece suporte a spot Fleet, portanto, teremos que escolher uma estratégia diferente para executar instâncias spot: AWS Auto Scaling Groups (ASG).
Auto Scaling Groups
Um ASG contém uma coleção de instâncias do Amazon EC2 que são tratadas como um grupo lógico, pois nesta atuação específica usamos o kops para configurar nossos clusters, então demonstrarei como instalar ASGs de instâncias spot com kops InstanceGroups.
Não vamos nos aprofundar em kops neste artigo, pois se você usar outras ferramentas de instalação do Kubernetes, também poderá configurar seus ASGs para serem executados em instâncias spot com pequenos ajustes de configuração, portanto, isso não será abordado aqui.
Criaremos o InstanceGroup com o comando abaixo através de seu manifesto a seguir:
kops create ig spot-nodes-xlarge
apiVersion: kops.k8s.io/v1alpha2
kind: InstanceGroup
metadata:
labels:
kops.k8s.io/cluster: sample.prod.k8s.local
name: spot-nodes-xlarge
spec:
image: kope.io/k8s-1.15-debian-stretch-amd64-hvm-ebs-2020-07-20
machineType: c3.xlarge
maxSize: 10
minSize: 0
mixedInstancesPolicy:
instances:
- c3.xlarge
- c4.xlarge
- c5.xlarge
- c5a.xlarge
onDemandAboveBase: 0
onDemandBase: 0
spotAllocationStrategy: capacity-optimized
nodeLabels:
kops.k8s.io/instancegroup: spot-nodes-xlarge
lifecycle: "spot"
role: Node
subnets:
- us-east-1a
- us-east-1b
- us-east-1c
Após a configuração deste InstanceGroup, o Kops criará um EC2 ASG com uma mixedInstancesPolicy
, utilizando vários tipos de instância spot em um InstanceGroup.
A estratégia de alocação "capacity-optimized"
, permite que o ASG selecione os tipos de instância com a maior capacidade disponível durante a expansão.
Isso reduzirá a chance de interrupções do spot.
Devido às limitações do Cluster Autoscaler, onde qual tipo de instância expandir, seria importante escolher instâncias do mesmo tamanho (vCPU e memória) para cada InstanceGroup.
Podemos usar o amazon-ec2-instance-selector para nos ajudar a selecionar os tipos e famílias de instâncias relevantes com um número suficiente de vCPUs e memória. Por exemplo, para obter um grupo de instâncias com 8 vCPUs e 32 GB de RAM, podemos executar o seguinte comando:
ec2-instance-selector --vcpus 8 --memory 32768 --gpus 0 --current-generation true -a x86_64 --deny-list '.*n.*'
Cluster Autoscaler
Cluster Autoscaler (CA) é uma ferramenta que dimensiona automaticamente o tamanho do cluster, alterando a capacidade desejada dos ASGs.
Ele aumentará o cluster quando houver pods que não funcionem devido a recursos insuficientes e o reduzirá quando houver nodes no cluster que foram subutilizados por um longo período de tempo.
Na seção anterior, criamos um InstanceGroup de nodes, mas na maioria dos casos um InstanceGroup não é suficiente e precisaremos de mais grupos, por exemplo, para diferentes tamanhos de máquina, nodes com GPU ou para um grupo com uma única AZ para dar suporte a volumes persistentes.
Quando houver mais de um grupo de nodes e a CA identificar que precisa aumentar o cluster devido a pods não programáveis, ela terá que decidir qual grupo expandir. Queremos que a CA sempre prefira adicionar instâncias spot
em vez de On-demand
.
A CA usa o expansor para escolher qual grupo dimensionar, com isso a AWS com o CA oferece 4 estratégias de expansão diferentes para selecionar o grupo de nodes, ao qual novos nodes serão adicionados:
- Aleatório (padrão);
- Mais pods;
- Menos pods;
- Desperdício e Prioridade.
Os expansor podem ser selecionados, configurando através dos argumentos da CA, ou seja,
--expander=priority
O Priority Expansor seleciona o grupo de node que recebeu a prioridade mais alta pelo usuário nos valores armazenados em ConfigMap, que deve ser criado antes do pod de CA e deve ser nomeado como cluster-autoscaler-priority-expander
O exemplo do ConfigMap é o seguinte:
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-autoscaler-priority-expander
namespace: kube-system
data:
priorities: |-
20:
- spot-nodes.*
10:
- .*
Ao definir .*spot-nodes.*
(regex para nomes de grupos de nodes), com a prioridade mais alta, informamos ao CA Expander para sempre preferir expandir os grupos de nodes spot.
A CA respeita o nodeSelector
e o requiredDuringSchedulingIgnoredDuringExecution nodeAffinity
, portanto, considerará apenas Após a configuração deste InstanceGroup, a kops criará um EC2 ASG com uma mixedInstancesPolicy
, utilizando vários tipos de instância spot em um InstanceGroup que satisfaçam esses requisitos de expansão.
Se não houver instâncias spot disponíveis, a CA não conseguirá escalar os grupos spot e, em vez disso, escalará os grupos sob demanda com menos prioridade. Com essa abordagem, você obterá um mecanismo de retorno completo e automático para o mecanismo sob demanda.
Instance Termination Handler
Agora vamos preparar nosso cluster para lidar com interrupções spot.
Usaremos o AWS Node Termination Handler para essa finalidade, pois executará um pod em cada node de instância spot (um DaemonSet) que detecta um aviso de interrupção S pot observando os metadados do AWS EC2.
Um novo recurso chamado EC2 Instance rebalance recommendation
na tradução livre "Recomendação de rebalanceamento de instância do EC2", foi anunciado recentemente pela AWS.
Um sinal notifica você quando uma instância spot está em alto risco de interrupção.
Esse sinal pode chegar antes do aviso de interrupção da instância spot de dois minutos, dando a você a oportunidade de reequilibrar proativamente seu nodepool antes do aviso de interrupção.
Se um aviso de recomendação de interrupção ou rebalanceamento for detectado, será acionadouma drenagem do node.
A drenagem do node despeja com segurança todos os pods hospedados nele. Quando um pod é despejado usando a API de despejo, ele é encerrado normalmente, respeitando a configuração de terminationGracePeriodSeconds em seu PodSpec.
Cada um dos pods removidos será reprogramado em um node diferente para que todas as implantações voltem à capacidade desejada.
Um exemplo simples de instalação do Helm aws-node-termination-handler
é o seguinte:
helm repo add eks https://aws.github.io/eks-charts
helm repo update
helm upgrade --install aws-node-termination-handler \
--namespace kube-system \
--set nodeSelector.lifecycle=spot \
--set enableSpotInterruptionDraining="true" \
--set enableRebalanceMonitoring="true" \
eks/aws-node-termination-handler
Prevenção de inatividade de serviço
Até agora, temos um cluster híbrido que pode dimensionar automaticamente instâncias spot, fazer fallback para sob demanda, se necessário, e lidar com despejos de pod normal quando um node spot é recuperado.
Em um ambiente de produção em que muitos serviços precisam permanecer ativos 100% do tempo, a drenagem de nodes aleatórios pode levar a uma catástrofe com bastante facilidade.
Por exemplo, se:
- Todas as réplicas do pod permanecem em um único nodepool de instâncias spot (mesmo tipo de máquina e AZ), com maiores chances de serem recuperadas ao mesmo tempo;
- Todas as réplicas do pod permanecem em nodes que estão sendo recuperados simultaneamente pela AWS e podem ser despejados ao mesmo tempo. Quando as novas réplicas estiverem agendadas e prontas em um node diferente, o serviço terá zero endpoints;
- Os pods reprogramados estão aguardando em um estado pendente por mais de dois minutos para que novos nodes ingressem no cluster. Isso também pode levar a zero endpoints;
Nas seções a seguir, você entenderá melhor como evitar que esses cenários ocorram.
Affinity Rules
Pod affinity and anti-affinity do pod são regras que permitem especificar como os pods devem ser agendados em relação a outros pods.
As regras são definidas usando custom labels
em nodes e seletores de label selectors
em pods.
Por exemplo, usando regras de afinidade, você pode distribuir pods de um serviço entre nodes ou AZ.
Há dois tipos de regras de afinidade de pod:
- Preferencial;
- Obrigatória;
Preferencial especifica que o scheduler tentará impor as regras, mas não há garantia. Obrigatório, por outro lado, especifica que a regra deve ser atendida antes que um pod possa ser agendado.
No exemplo a seguir, usamos o tipo podAntiAffinity
preferido:
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
selector:
matchLabels:
app: redis
replicas: 3
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis-server
image: redis:6.0-alpine
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 30
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- "redis"
topologyKey: failure-domain.beta.kubernetes.io/zone
- weight: 20
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- "redis"
topologyKey: beta.kubernetes.io/instance-type
- weight: 10
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- "redis"
topologyKey: kubernetes.io/hostname
Ao definir pesos diferentes, o scheduler tentará primeiro distribuir essas 3 réplicas redis em diferentes AZs (label do node failure-domain.beta.kubernetes.io/zone
).
Se não houver espaço disponível em zonas separadas, ele continuará tentando agendá-los em diferentes tipos de instância (label do node do tipo de instância).
Por fim, se nenhum local estiver disponível em AZs ou tipos de instância separados, ele tentará distribuir as réplicas em nodes separados (label do node do nome do host).
Especificar essas regras para implantações críticas nos ajudará a distribuir os pods de acordo com a lógica do pool de instâncias spot e minimizar a chance de vários encerramentos do mesmo componente ao mesmo tempo.
PodDisruptionBudge
PodDisruptionBudget (PDB) é um objeto de API que indica o número máximo de interrupções que podem ser causadas a uma coleção de pods. O PDB pode nos ajudar a limitar o número de despejos simultâneos e evitar uma interrupção do serviço.
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
name: redis-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: redis
No exemplo acima, estamos configurando a API de despejo para negar interrupções de pods redis se houver apenas um pod pronto no cluster.
Portanto, se, por exemplo, o deployment do redis tiver 3 réplicas em um ou vários nodes que estão sendo drenados simultaneamente, o Kubernetes primeiro despejará dois pods e depois continuará para o terceiro, somente depois que um dos pods reprogramados estiver pronto em outro node.
Você só pode especificar maxUnavailable
ou minAvailable
em um único PDB. Ambos podem ser expressos como números inteiros ou como porcentagem.
Super Provisionamento do Cluster
Depois que uma instância spot for recuperada e o node estiver sendo drenado, o Kubernetes tentará agendar os pods removidos.
Na maioria das vezes, devido ao tamanho do cluster que vem com a CA, o agendador não encontrará espaço suficiente para todos os pods removidos e alguns deles aguardarão em estado pendente até que a CA acione uma expansão e novos nós estejam prontos.
Esses preciosos minutos de espera podem ser evitados implementando o headroom do cluster
(ou super provisionamento de cluster).
Antes de entrarmos na implementação, você deve estar familiarizado com o Pod Priority
.
Em resumo, os pods podem ter prioridade. Se um pod não puder ser agendado, o k8s pode despejar pods de prioridade mais baixa para possibilitar o agendamento de um pod pendente de prioridade mais alta.
Para implementar um headroom
de cluster, executamos pods de cluster-overprovisioning "fictício"
, com baixa
prioridade para reservar espaço extra no cluster. Esses pods salvarão o local necessário para pods críticos que são despejados quando um nó está sendo drenado. Os pods de provisionamento excessivo obterão valores de solicitação de recursos e executarão um processo linux de pause
, para que eles economizem ativamente espaço extra no cluster sem consumir nenhum recurso.
Quando os pods de provisionamento excessivo são substituídos por pods de alta prioridade, seu status muda para pendente e eles se tornam os que aguardam novos nodes em vez do workload crítico.
A maior parte disso pode ser feito com o helm chart do cluster-overprovisioner que adicionará duas PriorityClasses
e o deployment do over-provisioner configurada com uma baixa priorityClass
.
O PriorityClass
mais alto
criado aqui será o globalDefault
, para que todos os pods sem um conjunto de priorityClassName
sejam mais altos do que os pods com provisionamento excessivo.
values.yaml
para o chart do helm:
fullnameOverride: "overprovision"
deployments:
- name: spot
replicaCount: 1
resources:
requests:
cpu: 2
memory: 4Gi
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: lifecycle
operator: In
values:
- "spot"
Para garantir que as réplicas de cluster-overprovisioning
contem escalas automáticas com base no tamanho das instâncias spot do cluster, podemos implantar uma ferramenta muito útil chamada cluster-proportional-autoscaler
que permite dimensionar uma deployment com base no tamanho do cluster.
Execute em seu cluster com os seguintes argumentos:
/cluster-proportional-autoscaler
--namespace={{ .Release.Namespace }}
--configmap=overprovisioning-scale
--target=deployment/overprovision-spot
--nodelabels=lifecycle=spot
--logtostderr=true
--v=1
Com um ConfigMap:
kind: ConfigMap
apiVersion: v1
metadata:
name: overprovisioning-scale
namespace: {{ .Release.Namespace | quote }}
data:
linear: |-
{
"coresPerReplica": 50
}
Neste exemplo, definimos o cluster-proportional-autoscaler
para dimensionar o deployment do ponto de cluster-overprovisioning
para uma réplica para cada 50 núcleos de CPU de todos os nodes da instância spot.
As configurações do coresPerReplica
no cluster-overprovisioning devem ser ajustadas com base em suas necessidades de espaço livre.
Considerações
Nos casos abaixo, você pode considerar a não execução de alguns aplicativos em instâncias spot:
Seu Deployment tem apenas uma réplica
:O tempo de inicialização leva mais de dois minutos
:Seu aplicativo não pode tolerar nenhum desligamento não normal
:
Com um único pod, seu aplicativo não pode ser protegido pelo PDB e não é altamente disponível. Depois que esse pod permanecer em um node que está sendo drenado, seu deployment terá zero réplicas disponíveis até que o pod de substituição fique pronto em outro lugar.
Isso pode levar a zero réplicas disponíveis, porque os pods não despejados protegidos pelo PDB ainda estão hospedados em uma instância spot que pode ser encerrada pela AWS em dois minutos.
Desligamentos não normais podem acontecer em condições raras, como sinal de encerramento para o pod devido ao atraso de despejo do PDB ou se o tempo de desligamento normal do seu aplicativo estiver muito demorado.