Kubernetes: actualizaciones continuas sin tiempo de inactividad

6 de octubre de 2017

Rolling Update sólo se asegura de que Kubernetes detiene los pods de forma continua - uno por uno, asegurándose siempre de que hay la cantidad mínima deseada de pods en ejecución. Esto puede parecer suficiente para despliegues sin tiempo de inactividad. Pero, como siempre, no es tan sencillo.

Durante la actualización Kubernetes tiene que terminar las versiones antiguas de los pods - después de todo eso es lo que quieres. Pero eso es un problema cuando su pod está siendo terminado en medio del procesamiento de peticiones HTTP. Y eso puede suceder cuando su aplicación no está cooperando con Kubernetes. En este artículo voy a ver por qué sucede esto y cómo solucionarlo.

El problema

El problema se nos hizo evidente cuando recientemente trasladamos uno de nuestros servicios más conectados a nuestro clúster Kubernetes. El traslado se realizó sin problemas, pero al cabo de un tiempo empezamos a ver errores de conexión aparentemente aleatorios con este servicio en toda nuestra plataforma.

Después de algunas investigaciones nos dimos cuenta de que cuando desplegamos una nueva actualización en el servicio, existe la posibilidad de que algunos de los otros servicios fallen algunas peticiones durante el despliegue.

Para probar esa hipótesis hice una prueba sintética usando wrk y activando la rolling update al mismo tiempo:

wrk https://www.server.com/api &
sleep 1 && kubectl set image deployment/api api=driftrock/api:2

El resultado confirmó el problema:

Ejecutando prueba de 10s @ https://www.server.com/api
2 hilos y 10 conexiones
Thread Stats Avg Stdev Max +/- Stdev
Latency 290.50ms 176.36ms 1.19s 87.78%
Req/Sec 19.39 9.31 49.00 47.06%
368 requests in 10.10s, 319.48KB read
Non-2xx or 3xx responses: 19
Peticiones/seg: 36,44
Transferencia/seg: 31,63KB

Veamos la cuestión con más detalle en este artículo.

Proceso de terminación de pods Kubernetes

Veamos primero cómo Kubernetes termina los pods. Será crucial entender cómo la aplicación debe manejar la terminación.

Según la documentación https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods sigue estos pasos (más o menos):

  1. Si el pod tiene servicios de red, kubernetes deja de enrutar nuevas conexiones al pod de terminación. Las conexiones ya establecidas se dejan intactas y abiertas.
  2. Kubernetes envía la señal TERM al proceso raíz de cada contenedor en el pod, asumiendo que los contenedores comenzarán a detenerse. La señal que envía no se puede configurar.
  3. Espera el tiempo especificado en terminationGracePeriodSeconds del pod (30 segundos por defecto). Si los contenedores siguen funcionando en este punto, envía KILL terminando los contenedores sin darles la oportunidad de un respiro más.

Tiene buena pinta. ¿Cuál es el problema?

Parece que Kubernetes hace todo lo necesario - detiene el envío de nuevas conexiones y da a la aplicación 30 segundos para detener el trabajo de procesamiento en curso.

Sin embargo, hay dos inconvenientes importantes. La primera son las conexiones HTTP keep-alive y la segunda es cómo reaccionan las aplicaciones a la señal TERM.

Conexiones Keep-alive

Como pudimos ver arriba, Kubernetes mantiene cualquier conexión de red establecida intacta durante la terminación. Eso tiene sentido - Kubernetes le da los 30 segundos a la aplicación para decirle a sus clientes que se desconecten o que envíen la respuesta.

Pero si la aplicación no hace nada, la aplicación estará recibiendo nuevas peticiones a través de la conexión abierta felizmente hasta que los 30 segundos se agoten. Así que cuando el cliente envía una nueva solicitud justo milisegundo antes de que Kubernetes termine el contenedor utilizando la señal KILL, el cliente experimentará la caída de la conexión en lugar de recibir la respuesta.

En el mundo HTTP esto afecta no sólo a los clientes del navegador, donde unas pocas conexiones caídas pueden no ser un desastre. La mayoría de las aplicaciones se ejecutan detrás de un equilibrador de carga. En la mayoría de los casos LB se conecta al backend usando una conexión keep-alive. Esto afecta también a las llamadas a la API, incluso si esas llamadas se realizan normalmente como conexiones separadas desde el cliente al lado público del Balanceador de Carga.

TERM mal manejo de la señal

Ahora puedes estar pensando - espera un momento, ¿por qué la aplicación no se detiene? ¿Por qué sigue recibiendo nuevas peticiones después de recibir la señal TERM hasta la última instrucción de la CPU?

El problema está en que diferentes aplicaciones dentro de diferentes configuraciones de imagen Docker manejarán la señal de diferentes maneras, otras.

En esta investigación noté 3 casos diferentes de cómo la aplicación maneja la señal TERM:

  • lo ignora - por ejemplo el framework Phoenix de Elixir no tiene incorporado graceful termination(ver aquí)
  • hace otra cosa - por ejemplo nginx se termina a sí mismo inmediatamente(es decir, sin gracia)(ver aquí)
  • o se ejecuta con shell en el contenedor Docker (por ejemplo /bin/sh -c "rails s"), por lo que la aplicación no recibe la señal en absoluto.

Cualquiera de estos casos lleva a que el problema descrito sobre las conexiones keep-alive tenga efecto. Veamos cómo evitarlo y hacer que las aplicaciones estén listas para la actualización continua de kubernetes.

Zero-downtime ready pod™.

De lo anterior podemos tener una idea de lo que hay que cumplir para que los pods estén realmente listos para las actualizaciones en Kubernetes. Permítanme tratar de ponerlo en puntos:

  • Si el pod dispone de servicio de red, necesita vaciar las conexiones con gracia después de recibir la señal TERM
  • Si su aplicación tiene incorporada la gestión de la señal TERM, asegúrese de que la recibe cuando se ejecuta en el contenedor Docker
  • Configure el terminationGracePeriodSeconds de acuerdo con lo que se espera que sea el tiempo máximo de finalización de cualquier trabajo que se esté procesando actualmente. Esto depende de las cargas de trabajo de su aplicación.
  • Tenga al menos 2 (pero idealmente 3) réplicas en ejecución, y configure el despliegue para mantener al menos 1 pod en ejecución durante las actualizaciones.

Cuando se cumplan todas las condiciones, Kubernetes se ocupará del resto y se asegurará de que las actualizaciones continuas sean realmente de tiempo de inactividad cero, tanto para la aplicación de red como para las cargas de trabajo en segundo plano de larga duración.

Veamos ahora cómo conseguir estas condiciones.

Vaciado de conexiones (usando proxy inverso nginx)

Desafortunadamente no todas las aplicaciones pueden ser (fácilmente) actualizadas para drenar las conexiones. Como se mencionó anteriormente Phoenix framework no tiene soporte incorporado. Para evitar lidiar con tales discrepancias a través de diferentes tecnologías, podemos crear una imagen simple de nginx, que se encargará de esto universalmente.

La lectura de la documentación sobre cómo drenar las conexiones me dice

nginx puede ser controlado con señales al proceso principal:
TERM, INT fast shutdown
QUIT graceful shutdown

¡Genial! Tiene señal de apagado graceful. Pero espera... Tenemos que enviarle la señal QUIT para hacerlo, pero Kubernetes sólo enviará TERM que también sería recibido por Nginx pero con un resultado diferente - terminando inmediatamente.

Para superar esto escribí un pequeño script de shell que envuelve el proceso nginx y traduce la señal TERM a la señal QUIT:

#!/bin/sh

# Configurar trampa para señal TERM (= 15), y enviar QUIT a nginx en su lugar
trap "echo SIGTERM trapped. Signalling nginx with QUIT.; kill -s QUIT \$(cat /var/run/nginx.pid)" 15

# Inicia el comando en segundo plano - por lo que el shell está en primer plano y recibe señales (de lo contrario ignora las señales)
nginx "$@" &

CHILD_PID=$!
echo "Nginx iniciado con PID $CHILD_PID"

# Espera al hijo en bucle - aparentemente la señal QUIT a nginx causa la salida `wait` a
#, incluso si el proceso sigue en ejecución. Así que añadamos también un bucle hasta que el proceso
# exista
while kill -s 0 $CHILD_PID; do wait $CHILD_PID; done

echo "Process $CHILD_PID exited. Saliendo también..."

Añadiendo un simple default.conf con la configuración del proxy inverso podemos hacer el Dockerfile para nuestra imagen proxy:

FROM nginx:mainline-alpine

COPY default.conf /etc/nginx/conf.d/
ADD trap_term_nginx.sh /usr/local/bin/

CMD [ "/usr/local/bin/trap_term_nginx.sh", "-g", "daemon off;" ]

El envío de la señal TERM al nuevo contenedor hará que nginx se detenga con elegancia, es decir, esperará hasta que las solicitudes activas sean respondidas y no aceptará nuevas solicitudes a través de las conexiones existentes. Entonces se detendrá por sí mismo.

Hemos abierto la imagen y se puede encontrar en Docker Hub: https://hub.docker.com/r/driftrock/https-redirect-proxy/

Garantizar que el proceso recibe la señal

Otra condición que describí anteriormente es asegurarnos de que el proceso recibe la señal cuando sabemos que la aplicación está lista para manejarla.

Por ejemplo sidekiq ya maneja TERM como queremos. Esto puede parecer que no hay nada extra que hacer. Desafortunadamente en un entorno Docker como es Kubernetes, uno tiene que ser muy cuidadoso para no cometer errores involuntarios.

El problema es cuando el contenedor está configurado para ejecutar comandos usando shell. Por ejemplo:

comando: ["/bin/sh", "-c", "rake db:migrate && sidekiq -t 30"]

En este caso el proceso raíz del contenedor será /bin/sh. Como se describió anteriormente Kubernetes le envía la señal. Sin embargo, lo que no está claro a primera vista es que el shell UNIX ignorará las señales cuando haya un proceso hijo ejecutándose en él. No la reenvía al hijo, ni hace nada más. Eso hará que la señal no sea enviada a nuestra aplicación - sidekiq en el ejemplo anterior.

Hay dos maneras de solucionar esto. La forma más simple es instruir al shell para que se reemplace a sí mismo con el último comando usando el comando exec:

comando: ["/bin/sh", "-c", "rake db:migrate && exec sidekiq -t 30"]

Pero si puedes, lo mejor es evitar el uso de una envoltura de shell en absoluto. Ejecute el comando directamente como primer proceso y utilice los contenedores Init de Kubernetes para los comandos que desee ejecutar antes de que se inicie la aplicación.

Ajuste del tiempo de parada cero listo pod™.

Cuando tenemos proxy para manejar conexiones keep-alive en aplicaciones HTTP y sabemos cómo asegurarnos de que otras aplicaciones recibirán la señal TERM para detenerse con gracia, podemos configurar nuestro Zero-downtime ready pod™.

Lo primero es configurar el proxy nginx. Añádelo como otro contenedor a tu pod. El proxy asume que su aplicación está escuchando en el puerto 8080, y él mismo escuchará en el puerto 80. Si sus servicios ya están configurados para el puerto 80, entonces usted no tiene que hacer nada más, sólo tiene que añadir el contenedor (Nota al margen: nos parece útil para nombrar el contenedor de la misma como el contenedor de aplicación, con -proxy sufijo).

...
contenedores:
...
- nombre: APP-NAME-proxy
imagen: driftrock/https-redirect-proxy
puertos:
- containerPort: 80
...
...

Lo segundo que hay que hacer es asegurarse de que la aplicación está preparada para recibir señales TERM. Como he descrito anteriormente, podemos hacer:

...
contenedores:
- name: APP-NAME
command: ['/bin/sh', '-c', 'rake db:migrate && exec puma -p 8080']
...
...
...

Y eso es todo. Por supuesto, dados sus detalles específicos, puede que tenga que hacer algo ligeramente diferente aquí y allá. Sin embargo, espero que ahora hayas captado la idea.

Resultado

Para probar esto actualizamos los pods con la nueva imagen proxy, luego iniciamos benchmark y hacemos que Kubernetes vuelva a ejecutar rolling update:

Ejecutando prueba de 10s @ https://www.server.com/api
2 hilos y 10 conexiones
Thread Stats Avg Stdev Max +/- Stdev
Latency 249.36ms 95.76ms 842.78ms 90.19%
Req/Sec 20.04 9.21 40.00 54.05%
403 requests in 10.10s, 349.87KB read
Requests/sec: 39.91
Transfer/sec: 34.65KB

Ahora todas las peticiones de benchmark finalizan con éxito incluso cuando los pods están siendo terminados y arrancados durante la prueba de rolling update.

Libera al mono del caos

Después de eso pensé en cómo probar esto. De hecho, cuando el despliegue de Kubernetes está llevando a cabo la actualización continua, hace simplemente la terminación del pod en la parte posterior. Lo mismo que se puede hacer con kubectl delete pod [POD_NAME]. De hecho, los pasos de terminación descritos anteriormente están tomados del artículo llamado "Pod termination process", no "Deployment rolling update process".

Dado que yo estaba interesado si la nueva configuración manejará con sólo matar a las vainas en bucle (sólo asegurándose de que hay al menos una vaina en funcionamiento todo el tiempo), dándoles muy poco tiempo para incluso vivir. En teoría debería funcionar. El pod arranca, recibe quizás una petición y empieza a procesarla. Al mismo tiempo recibe la señal TERM ya que mi mono del caos intentará matarlo. La petición habrá terminado y no se le enviarán nuevas peticiones.

A ver qué pasa:

wrk -t 120 https://www.server.com/api &

while true; do
sleep 5
READY_PODS=$(kubectl get pods -l app=api-server -o json | jq -r ".items | map(select(.metadata | has(\"deletionTimestamp\") | not)) | map(select(.status.containerStatuses | map(.ready) | all)) | .[].metadata.name")
EXTRA_READY_PODS=$(echo $READY_PODS | ruby -e 'puts STDIN.readlines.shuffle[1..-1]' | tr '\n' ' ' )
/bin/sh -c "kubectl delete pods $EXTRA_READY_PODS"
kubectl get pods -l app=api-server
done

Da resultado:

Ejecutando prueba de 120s @ https://www.server.com/api
2 hilos y 10 conexiones
Thread Stats Avg Stdev Max +/- Stdev
Latency 953.28ms 513.90ms 3938.00ms 90.19%
Req/Sec 10.49 9.21 40.00 54.05%
1261 requests in 120.209s, 21.022MB read
Requests/sec: 10.49
Transfer/sec: 175.48KB

Esto muestra que la configuración no deja caer ni una sola conexión, incluso cuando los pods están siendo terminados tan pronto como están listos y empiezan a recibir peticiones. ¡ÉXITO!

Conclusión

En resumen, hemos encontrado algunos principios simples que deben cumplirse para que Kubernetes funcione para nosotros y mantener los despliegues sin tiempo de inactividad. La solución que hemos encontrado funciona incluso en situaciones de estrés.

Gracias por leernos y dinos lo que piensas. Si tienes otra forma de resolver este problema, ¡también nos encantaría saberlo!