Blog de Amazon Web Services (AWS)

Picturesocial – Cómo añadir capacidad de cómputo just-in-time a un clúster de Kubernetes

Por Jose Yapur – Sr. Developer Advocate, AWS

 

Incertidumbre, usamos matemáticas, pensamiento positivo, plegarias, o lo que esté dentro de nuestro alcance para tener certeza, incluso si el resultado es bueno o malo. Cada nueva creación es incierta, por eso creamos frameworks, recolectamos experiencias pasadas y expectativas para tener control de lo que parece impredecible. Esto no es extraño a las API’s, usamos frameworks para predecir la demanda, y algo de matemáticas para cosas como la ley de Amdahl para predecir el consumo de cómputo. Pero ahora mismo, el problema no solo es predecir la demanda sino también tener la capacidad de cómputo disponible exactamente cuando es necesaria o Just-in-time (Justo a tiempo)

Esto podría parecer obvio para cargas de trabajo Serverless, debido a que es administrado por el Cloud Provider, pero para Kubernetes usamos estrategias de escalamiento, que exploramos en posts anteriores, como HPA (Horizontal Pod Autoscaler) para escalar nuestros deployments, esos HPA necesitan capacidad de cómputo del Node Group para agendar nuevas pods, de lo contrario son desalojadas. Veamos el siguiente gráfico para entender cómo funciona.

  1. Tenemos un nodo con 4 pods y alrededor del 35% de poder de cómputo disponible para escalar.
  2. Un incremento en la demanda hizo que el deployment escale a una réplica extra, ahora tenemos un nodo con 5 pods y alrededor del 17% del cómputo libre para escalar.
  3. Otro incremento en la demanda hace escalar el deployment y ahora tenemos un nodo con 6 pods y alrededor de 5% de cómputo disponible.
  4. La demanda sigue aumentando y las reglas de auto escalamiento fuerzan a Kubernetes a añadir más pods, sin embargo, ya no tenemos poder de cómputo para servir la demanda y las pods nuevas comienzan a ser desalojadas.
  5. Kubernetes se da cuenta que necesita aumentar el cómputo y debido a las reglas de auto escalamiento de nodos, agrega una nueva instancia de EC2 y despliega el Pod en el nuevo nodo.

 

Ese fue un vistazo de cómo funciona, pero en la vida real ese extra nodo que debe ser desplegado para cubrir la demanda, demorará más de 10 minutos en estar listo, sin embargo, los usuarios ya están conectados y podríamos perder su confianza por no servir a la demanda.  Acá es donde algo que observe los recursos agregados y las peticiones de nuevas pods para tomar decisiones sobre creación y terminación de nodos para minimizar la latencia y costos de infraestructura sería súper útil. Acá es donde Karpenter viene al rescate.

Karpenter es un proyecto open source creado por AWS y que ayuda a resolver este problema por medio de nodos Just-in-time que sirven a la demanda incierta. Hoy vamos a aprender cómo implementarlo y cómo se ve en tu clúster de Kubernetes.

 

Pre-requisitos

Paso a paso

  • Empezamos abriendo nuestro terminal y creando las variables que especificarán la versión de Karpenter, la región, que en nuestro caso es us-east-1, el nombre del clúster de EKS y finalmente obtendrá el perfil de AWS CLI con el que estamos logueados.
export KARPENTER_VERSION=v0.16.0
export AWS_REGION=us-east-1
export CLUSTER_NAME=ekspicturesocial02
export ACCOUNT_ID=$(aws sts get-caller-identity —output text —query Account)

 

  • Añadimos Karpenter como extensión de clúster ejecutando el siguiente script de CloudFormation:
TEMPOUT=$(mktemp)

curl -fsSL https://karpenter.sh/"${KARPENTER_VERSION}"/getting-started/getting-started-with-eksctl/cloudformation.yaml  > $TEMPOUT \
&& aws cloudformation deploy \
  --stack-name "Karpenter-${CLUSTER_NAME}" \
  --template-file "${TEMPOUT}" \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides "ClusterName=${CLUSTER_NAME}"
  • Así es como se ve el archivo de CloudFormation descargado de Karpenter.sh
AWSTemplateFormatVersion: "2010-09-09"
Description: Resources used by https://github.com/aws/karpenter
Parameters:
  ClusterName:
    Type: String
    Description: "EKS cluster name"
Resources:
  KarpenterNodeInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      InstanceProfileName: !Sub "KarpenterNodeInstanceProfile-${ClusterName}"
      Path: "/"
      Roles:
        - Ref: "KarpenterNodeRole"
  KarpenterNodeRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "KarpenterNodeRole-${ClusterName}"
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                !Sub "ec2.${AWS::URLSuffix}"
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonEKS_CNI_Policy"
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonEKSWorkerNodePolicy"
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore"
  KarpenterControllerPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub "KarpenterControllerPolicy-${ClusterName}"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Resource: "*"
            Action:
              # Write Operations
              - ec2:CreateLaunchTemplate
              - ec2:CreateFleet
              - ec2:RunInstances
              - ec2:CreateTags
              - ec2:TerminateInstances
              - ec2:DeleteLaunchTemplate
              # Read Operations
              - ec2:DescribeLaunchTemplates
              - ec2:DescribeInstances
              - ec2:DescribeSecurityGroups
              - ec2:DescribeSubnets
              - ec2:DescribeImages
              - ec2:DescribeInstanceTypes
              - ec2:DescribeInstanceTypeOfferings
              - ec2:DescribeAvailabilityZones
              - ec2:DescribeSpotPriceHistory
              - ssm:GetParameter
              - pricing:GetProducts
          - Effect: Allow
            Action:
              - iam:PassRole
            Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/KarpenterNodeRole-${ClusterName}"

 

  • Usaremos la herramienta de línea de comandos eksctl para crear la identidad IAM que se ira asociada con nuestro clúster de EKS. Esto creará el rol de Karpenter en nuestros nodos mientras que usa Config Map para permitir a los nodos ser manejados por el clúster.
eksctl create iamidentitymapping \
  --username system:node:{{EC2PrivateDNSName}} \
  --cluster  ${CLUSTER_NAME} \
  --arn "arn:aws:iam::${ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}" \
  --group system:bootstrappers \
  --group system:nodes
  • Una vez que ejecutamos el comando podemos revisar si el Config Map está listo ejecutando el siguiente comando y mirando si existe un elemento llamado Karpenter-{algunTextoRaro} 😆

kubectl describe configmap -n kube-system aws-auth

  • Crearemos un proveedor de Open ID Connect (OIDC) para nuestro clúster, esto es necesario para federar la identidad de IAM con la de Kubernetes RBAC.

eksctl utils associate-iam-oidc-provider --cluster ${CLUSTER_NAME} --approve

  • Y ejecutamos el siguiente comando, que creará el Kubernetes Service Account que le dará permisos a la identidad que creamos previamente para añadir nuevas instancias.
eksctl create iamserviceaccount \
  --cluster "${CLUSTER_NAME}" --name karpenter --namespace karpenter \
  --role-name "${CLUSTER_NAME}-karpenter" \
  --attach-policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/KarpenterControllerPolicy-${CLUSTER_NAME}" \
  --role-only \
  --approve

export KARPENTER_IAM_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${CLUSTER_NAME}-karpenter"

 

  • Usaremos Helm para instalar las dependencias de Karpenter (Config Maps, Pods, Services, etc), pero primero agregamos el chart de Karpenter. Si no conoces Helm, es un gestor de paquetes de Kubernetes, parte de la CNCF, y la verdad es que simplifica mucho el despliegue de multicomponentes, ambientes, canary deployments entre otros, por lo que recomiendo darle una mirada a este blog.

helm repo update
helm repo add karpenter https://charts.karpenter.sh/

  • Ahora instalamos todo con Helm ejecutando el siguiente comando. Todo se aprovisionará en el namespace karpenter.
helm upgrade --install --namespace karpenter --create-namespace \
  karpenter karpenter/karpenter \
  --version ${KARPENTER_VERSION} \
  --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${KARPENTER_IAM_ROLE_ARN} \
  --set clusterName=${CLUSTER_NAME} \
  --set clusterEndpoint=$(aws eks describe-cluster --name ${CLUSTER_NAME} --query "cluster.endpoint" --output json) \
  --set defaultProvisioner.create=false \
  --set aws.defaultInstanceProfile=KarpenterNodeInstanceProfile-${CLUSTER_NAME} \
  --wait # for the defaulting webhook to install before creating a Provisioner

 

  • Vamos a revisar que todo esté correctamente instalado ejecutando el comando dentro del namespace.

kubectl get all -n karpenter

  • Ahora que ya tenemos todo, solo nos falta el aprovisionador de nodos a demanda. El siguiente YAML nos ayudará con eso, y básicamente lo que hace es: a/Requirements: implementa nuevos nodos a demanda de tamaño extra-large o más grandes, b/ Limits: el aprovisionador no usará más de 1000 cores virtuales y 1000GB de RAM, c/ ttlSecondsAfterEmpty: Cuántos segundos hasta que un nodo vacío es des aprovisionado. d/ ttlSecondsUntilExpired: Es la cantidad de segundos antes de que un nodo expire y sus pods sean drenadas, esto ocurrirá incluso si están en uso. Este YAML de ejemplo fue tomado del EKS Workshop, donde puedes aprender todo sobre EKS paso a paso.
cat <<EOF | kubectl apply -f -
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  labels:
    intent: apps
  requirements:
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["on-demand"]
    - key: karpenter.k8s.aws/instance-size
      operator: NotIn
      values: [nano, micro, small, medium, large]
  kubectl get all -n karpenter:
    resources:
      cpu: 1000
      memory: 1000Gi
  ttlSecondsAfterEmpty: 30
  ttlSecondsUntilExpired: 2592000
  providerRef:
    name: default
---
apiVersion: karpenter.k8s.aws/v1alpha1
kind: AWSNodeTemplate
metadata:
  name: default
spec:
  subnetSelector:
    alpha.eksctl.io/cluster-name: ${CLUSTER_NAME}
  securityGroupSelector:
    alpha.eksctl.io/cluster-name: ${CLUSTER_NAME}
  tags:
    KarpenerProvisionerName: "default"
    NodeType: "karpenter-workshop"
    IntentLabel: "apps"
EOF

ADVERTENCIA: El siguiente deployment podría representar un consumo monetario importante, por lo que es importante que una vez que termines este paso a paso, lo elimines.

  • Es momento de probar si todo este paso a paso valió la pena 😆, vamos a desplegar la imagen de container pause, esta imagen hará de trigger la creación de nuevos nodos, los cuales deberían estar disponibles rápidamente. Para que funcione vamos a cambiar la cantidad de réplicas de 0 a al menos 1.

 

cat <<EOF > inflate.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      nodeSelector:
        intent: apps
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.2
          resources:
            requests:
              cpu: 1
              memory: 1.5Gi
EOF
kubectl apply -f inflate.yaml

 

  • Una vez que ejecutes este comando, tendrás al menos un nuevo nodo en los siguientes minutos, el cuál ingresará al node group en estado “Ready”. Así es como funciona Just-in-time con Kubernetes y Karpenter. Nos permite escalar hasta donde queramos de una forma muy sencilla y ágil, para servir a la demanda incierta y poner los esfuerzos en la innovación de la aplicación y no en la operación de la infraestructura.
  • No olvides eliminar el deployment de pause, ejecutando el siguiente comando:

kubectl delete -f inflate.yaml

Hemos llegado al final de este post y estamos a un post más del final de esta serie de contenido, espero que hayas podido aprender y disfrutar de este viaje tanto como yo. En el siguiente post, hablaremos todo sobre arquitectura, y un wrap up de este maravilloso año para Picturesocial.

 

 


Sobre el autor

José Yapur es Senior Developer Advocate en AWS con experiencia en Arquitectura de Software y pasión por el desarrollo especialmente en .NET y PHP. Trabajó como Arquitecto de Soluciones por varios años, ayudando a empresas y personas en LATAM.