HttpClient en C# y servicios de Docker escalados.

En este post vamos a ver como escalar servicios, tanto en Compose, como en Swarm como en Kubernetes y luego veremos algunas consideraciones cuando usemos HttpClient desde el cliente al acceder a un servidor escalado.

Nos centramos en el escenario de escalado básico, es decir, sin demasiada lógica.

[Autobombo]: Si estás interesado en temas de Docker y Kubernetes, échale un vistazo a mi curso de Docker y Kubernetes en CampusMVP.

Para ello vamos a verlo en un ejemplo: Vamos a empezar creando un servidor nodejs que será el que escalaremos. Su código es muy, muy simple:

const express = require('express');
const os = require("os");
const app = express();
let counter = 0;

app.get('/', function (req, res) {
  const machine = os.hostname();
  counter++;
  res.send(`Hello from ${machine}. You called me ${counter} times.`);
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

Este código levanta un servidor que escucha por el puerto especificado y que simplemente muestra el nombre de la máquina y un contador que se va incrementando.

Ahora creamos un Dockerfile para poder construir la imagen:

FROM node:8.9-alpine
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"]
RUN npm install --production --silent && mv node_modules ../
COPY . .
EXPOSE 3000
ENTRYPOINT [ "node", "index.js" ]

Ahora vamos a crear un cliente en .NET que será un servicio que llamará a este servidor y nos devolverá su resultado. Para ello creamos una web en ASP.NET Core muy sencillita. Partiendo de la plantilla de proyecto de ASP.NET Core Empty, simplemente modificamos la clase Startup:

public class Startup
{ 
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration) => Configuration = configuration;
    public void ConfigureServices(IServiceCollection services)
    {
    }
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.Run(async (context) =>
        {
            var client = new HttpClient();
            var response = await client.GetAsync(Configuration["helloserver"]);
            var serverData = await response.Content.ReadAsStringAsync();
            var toSend = new
            {
                Client = new { Name = Environment.MachineName },
                ServerData = serverData
            };
            context.Response.ContentType = "text/json";
            await context.Response.WriteAsync(JsonConvert.SerializeObject(toSend));
        });
    }
}

Puedes usar tanto NetCore 2.0 como 2.1, el código es el mismo.

No tiene más: simplemente a cada petición responde con un json que contiene dos campos: Client con el nombre de la máquina del cliente y ServerData con la respuesta del servidor. Finalmente creamos un Dockerfile para el proyecto:

FROM microsoft/dotnet:2.1-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80

FROM microsoft/dotnet:2.1-sdk AS build
WORKDIR /src
COPY netclient/netclient.csproj netclient/
RUN dotnet restore netclient/netclient.csproj
COPY . .
WORKDIR /src/netclient
RUN dotnet build netclient.csproj -c Release -o /app

FROM build AS publish
RUN dotnet publish netclient.csproj -c Release -o /app

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "netclient.dll"]

Para ponerlo todo en marcha podemos usar un fichero compose como el siguiente:

version: '3.0'

services:
  server:
    image: helloserver
    build:
      context: ./server
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
    ports:
      - "3000"
  netclient:
    image: netclient
    build:
      context: ./netclient
      dockerfile: netclient/Dockerfile
    environment:
      helloserver: http://server:3000
    ports:
      - 8000:80

Si ahora lo ejecutamos (docker-compose up) y accedemos al cliente, vemos como nos devuelve la salida del servidor. Todo correcto:

Salida de curl donde se ve que el cliente habla con el servidor

Escalando en compose

Escalar en compose es fácil, para ello puedes poner la aplicación en marcha usando:

docker-compose up --scale server=10

Eso te levantará el cliente y 10 servidores:

Escalado en compose

(Si te da errores de puerto, asegúrate que en el docker-compose no tienes el puerto 3000 del servidor mapeado a ningún puerto del host. Compose no puede escalar servicios si tienes los puertos mapeados, ya que todas las N instancias intentarían usar el mismo puerto del host).

Si ahora ejecutas varias veces el cliente verás como la salida es correcta: te van respondiendo distintos servidores, pero sin ningún orden en concreto (en la imagen uno ha respondido dos veces y otros ninguno):

Responden varios servidores

Escalando en swarm

Dado que tenemos un fichero compose es fácil pasar a modo swarm y usar escalado de swarm en lugar compose. Para ello debes poner Docker “en modo swarm” tecleando “docker swarm init“. Con eso has creado un clúster de Swarm con una sola máquina (la tuya). Puedes obviar la salida del comando ya que no agregaremos más máquinas al clúster.

El siguiente paso es desplegar en Swarm. Swarm usa los ficheros de compose, así que desplegar es muy fácil:

docker stack deploy httpclient --compose-file docker-compose.yml

Es importante que tengas las imágenes construídas en tu máquina, ya que Swarm (a diferencia de compose) no puede construir imágenes. Ahora debemos escalar el servicio servidor a 10 instancias, con el comando “docker service scale httpclient_server=10” (httpclient_server es el nombre del servicio):

Escalado con Swarm

Si ahora vas lanzando otra vez peticiones a “http://localhost:8000” verás como te van respondiendo los servicios uno tras otro. Hay una diferencia con el caso de compose: cada vez responde un servicio distinto, y cuando han respondido todos, vuelta a empezar. Es decir, es un round-robin puro: La primera petición se enruta al primer servicio, la segunda al segundo y así hasta que todos los servicios han ejecutado una petición, donde entonces la siguiente se vuelve a enrutar al primero de nuevo y vuelta a empezar.

Una vez hecho el experimento puedes abandonar (y destruír) el cluster de Swarm usando “docker swarm leave –force“.

Escalando en Kubernetes

Vamos ahora a repetir el experimento en Kubernetes. En este caso, necesitamos los ficheros de despliegue de Kubernetes y además tener las imágenes en algún registro (no basta con que estén en nuestra máquina).

Los ficheros de despliegue de Kubernetes son muy sencillos: dos deployments y dos servicios. En este caso los he agrupado todos juntos en un mismo fichero (k8s.yml):

apiVersion: v1
kind: Service
metadata:
  labels:
    component: server
  name: server
spec:
  ports:
  - port: 80
  selector:
    component: server
---
apiVersion: v1
kind: Service
metadata:
  labels:
    component: client
  name: client
spec:
  type: NodePort
  ports:
  - port: 80
  selector:
    component: client
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: server
spec:
  template:
    metadata:
      labels:
        component: server
    spec:
      containers:
      - name: helloserver
        image: dockercampusmvp/helloserver
        env:
        - name: PORT
          value: "80"
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: client
spec:
  template:
    metadata:
      labels:
        component: client
    spec:
      containers:
      - name: helloclient
        image: dockercampusmvp/helloclient:2.1
        env:
        - name: helloserver
          value: http://server

(Los ficheros son para MiniKube. Si usas AKS o GCE entonces solo debes cambiar “NodePort” por “LoadBalancer” para que el servicio se exponga al exterior.

Una vez instalado este fichero en el servidor, puedes escalar el servicio server usando “kubectl scale –replicas=10 deployments/server”:

Escalado en k8s

Ahora puedes usar el comando minikube service client que te abrirá un navegador a la IP de este servicio (o bien mirar la IP del nodo de minikube y el puerto del servicio, lo que es equivalente. En el caso de usar LoadBalancer simplemente mira en la columna “EXTERNAL-IP” del comando “kubectl get svc“). Al igual que antes te irán respondiendo distintos servidores:

Respondiendo varios servidores en k8s

Usando HttpClient “de la forma correcta”

Vale, si arrugaste la nariz cuando viste el código del cliente, entonces ya sabes que NO HE USANDO HTTPCLIENT DE LA FORMA RECOMENDADA. Por si acaso, recordad: HttpClient debe usarse como singleton, ya que ir creando instancias de este puede llevarnos a problemas por exhaurir todos los sockets. De hecho la documentación oficial lo deja bastante claro:

HttpClient is intended to be instantiated once and re-used throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads. This will result in SocketException errors.

Para más información consultad ese artículo.

Bien, he modificado el código para usar mi cliente de la forma recomendada, simplemente creando un HttpClient en el constructor de Startup:

private readonly HttpClient _client;
public Startup(IConfiguration configuration)
{
    _client = new HttpClient();
    Configuration = configuration;
}

Y usando siempre este mismo _client. Y desplegando eso en k8s el resultado es:

escalado en k8s - responde siempre el mismo

¡Siempre nos está respondiendo el mismo servidor! ¿Qué está ocurriendo? (Si lo pruebas con compose ocurre lo mismo).

Ahora bien, si usamos varios clientes a la vez, el resultado no es tan dramático, aunque sigue siendo lejos de lo deseable:

En esta imagen ha abierto seis terminales y he introducido el mismo comando en ellas, para hacer 100 peticiones y contar cuantas veces me responde el mismo servidor. Se puede ver que las 600 peticiones totales se las reparten entre 3 servidores. Por otro lado si solo abres un terminal y haces 600 peticiones de golpe irán todas al mismo servidor. Curioso.

Bueno.. vamos a ver como funciona el escalado en Compose/Swarm y luego en Kubernetes.

Como funciona el escalado en Compose/Swarm

Tanto Compose como Swarm hacen lo mismo: agregan varias entradas  a la tabla de DNS. Para verlo, con la aplicación de compose en marcha y el servicio server escalado, abre una sesión ineractiva contra el cliente usando “docker exec -it <id-contenedor-client> /bin/bash“.

Ahora solo debes instalar nslookup:

apt-get update
apt-get install dnsutils -y

Y ya podemos usar nslookup server para ver el resultado:

nslookup en compose

Observa como hay diez entradas en la tabla de DNS del cliente, apuntando a las 10 ips internas de los 10 contenedores que ejecutan el servidor. Cada vez que hago nslookup el orden cambia. La idea es que si elijes la primera IP de la lista, cada vez obtienes una IP distinta. Bien.

Como funciona el escalado en Kubernetes

Kubernetes funciona distinto que Compose/Swarm pero el efecto final es parecido (y además tiene varios modos de escalado). Al igual que antes necesitamos instalar nslookup en el contenedor que ejecuta el cliente. Para ello miramos cual es el pod que ejecuta el cliente usando el comando “kubectl get pods | findstr -I client” y luego ejecutando un “kubectl exec -it <nombre-pod> /bin/bash” para abrir una sesión interactiva con ese pod:

Abriendo sesión interactiva en k8s

Ahora, al igual que antes, instalamos nslookup en este contenedor, tecleando:

apt-get update
apt-get install dnsutils -y

Y ya podemos usar nslookup server para ver el resultado:

nslookup en k8s

Hay una sola entrada en el DNS que apunta a la IP (interna) del servicio. En efecto, en Kubernetes los servicios son los encargados de agrupar los distintos pods en una sola IP.

Por supuesto si dentro del pod ejecutas varias veces “curl http://server” (la imagen base de Net Core tiene curl instalado, así que lo tenemos disponible), verás como, efectivamente, nos van respondiendo servidores distintos (como era de esperar)

Vamos a ver como escala el servicio. Para ello debemos ejecutar comandos no en ningún pod, si no en el nodo de Kubernetes. Si usas minikube puedes usar simplemente “minikube ssh” para abrir una sesión SSH contra el (único) nodo de Minikube. Si usas otro Kubernetes, debes mirar las instrucciones de tu proveedor, ya que cada caso es distinto.

Una vez en el nodo teclea “iptables-save | grep server” (quizá debas usar sudo) para ver el contenido:

iptables en k8s

Por defecto los servicios en MiniKube funcionan en el modo iptables. En este modo, se instalan reglas de iptables que redirigen las peticiones a la IP del servicio a uno de los pods subyacentes (de forma aleatoria).

No soy un experto, ni de lejos, en iptables y todo eso (lamentablemente) se me escapa un poco, pero esas reglas de iptables son las que redirigen el tráfico desde la IP virtual del servicio a alguna de las IPs reales.

Vale… ¿y como solucionar ese problema?

Bueno, este problema se debe a que el objeto HttpClient (el único que tenemos ahora) reutiliza, en algunos casos, la conexión. Eso es para mejorar el rendimiento, a costa de rompernos este balanceo basado en iptables o múltiples entradas de DNS.

La solución pasa por usar la línea:

_client.DefaultRequestHeaders.ConnectionClose = true;

Justo después de crear el objeto HttpClient (en el constructor de Startup) para así forzar que HttpClient cierre la conexión. Ojo, que eso penaliza el rendimiento. Con esa línea agregada el resultado es el siguiente:

Ahora sí que vemos que van respondiendo todos los servidores, consiguiéndose el objetivo con el escalado. Si lo pruebas en Kubernetes el resultado es muy parecido:

Ahora en k8s también responden varios servicios

En resumen, hemos visto lo siguiente:

  1. Como escalar en Compose
  2. Como escalar en Swarm
  3. Como escalar en Kubernetes
  4. Qué hace el escalado en Compose/Swarm
  5. Qué hace el escalado en Kubernetes
  6. El detalle a tener en cuenta si usamos esos mecanismos de escalado y usamos HttpClient como singleton (la forma recomendada)

Por supuesto este escenario de escalado que hemos visto es el más simple, para escenarios más avanzados podemos o bien desplegar contenedores que realicen tareas de load balancer o bien, en el caso de Kubernetes, usar el modo de servicios ipvs.

Os dejo lo siguiente por si queréis experimentar:

  1. El código fuente del servicio y cliente (netcore 2.0 y 2.1). Con sus dockerfiles
  2. Los ficheros de compose/swarm y Kubernetes
  3. Para Kubernetes, he puesto las imágenes en el DockerHub:
    1. dockercampusmvp/helloclient:2.0 -> Versión inicial del cliente en netcore 2.0
    2. dockercampusmvp/helloclient:2.1 -> Versión inicial del cliente en netcore 2.1 (el tag latest apunta a esa versión)
    3. dockercampusmvp/helloclient:2.1-singleton -> Cliente en 2.1 y con HttpClient en singleton
    4. dockercampusmvp/helloclient:2.1-singleton-close -> Cliente en 2.1 y con HttpClient en singleton y cerrando la conexión
    5. dockercampusmvp/helloserver:latest -> Servidor nodejs

¡Y, por supuesto, si alguien ha experimentado otros comportamientos con el tema del escalado, que se sienta libre de poner un comentario!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *