Gestionar el tráfico externo y entre los pods con Open Service Mesh

Cuanto más te vas adentrando en el mundo de los contenedores más requerimientos van pasando por tu cabeza, y la de tu organización, como por ejemplo desde el punto de vista de networking: cómo me aseguro de que la comunicación entre mis pods sea segura, como limitar de manera sencilla qué servicios deben de hablar entre ellos (por defecto, todos pueden hablar con todos) o cómo puedo obtener la trazabilidad de estos, y entre sí, con el objetivo de saber que todo marcha bien. Por todo ello, existe lo que se conoce como service mesh o malla de servicios. Hoy quiero contártelo basándome en Open Service Mesh.

¿Qué es Open Service Mesh?

Por todo lo comentado durante la introducción, esta es una de las implementaciones de la especificación Service Mesh Interface que tiene como objetivo hacerte la vida más fácil frente los requisitos anteriores. Fue desarrollada por Microsoft, aunque la comunidad ha estado muy activa en aportar su granito de arena. A día de hoy cubre las características principales como la gestión del tráfico, observabilidad o el cifrado del tráfico. Hoy voy a centrarme en contarte la aplicación que voy a utilizar de ejemplo, cómo configurar OSM en tu clúster y cómo empezar a gestionar el tráfico tanto de fuera como de dentro del mismo.

Aplicación de ejemplo

Para mostrarte los siguientes puntos, voy a utilizar una aplicación de ejemplo parecida a la documentación oficial de Open Service Mesh, solo que en Node.js y algo más simplificada, en cuanto a componentes, para que puedas entender también qué es lo que hace esta. El código del ejemplo lo tienes en este repo de mi GitHub. Esta está compuesta de tres piezas:

Aplicación de ejemplo con la que jugar con Open Service Mesh
Aplicación de ejemplo con la que jugar con Open Service Mesh

bookstore se trata de una API que tiene dos llamadas que otros servicios pueden realizar: /api/buy que simula la compra de libros y /api/steal que los roba.

const express = require('express');
const app = express();
app.use(express.static('public'));
app.set('view engine', 'ejs');
const PORT = process.env.PORT || 3000;
let booksSold = 0;
let booksStolen = 0;
app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});
// GET /
// Returns a index.html file with the booksSold variable
app.get('/', (req, res) => {
    // res.sendFile(__dirname + '/index.html');
    res.render('index', { booksSold });
});
// GET /api/buy
// This route should add new book sold and return the number of books sold
app.get('/api/buy', (req, res) => {
    booksSold++;
    console.log(`Books sold: ${booksSold}`);
    return res.send({
        booksSold
    });
});

// GET /api/books
// This route should return the number of books sold
app.get('/api/steal', (req, res) => {
    booksStolen++;
    console.log(`Books stolen: ${booksStolen}`);
    return res.send({
        booksStolen
    });
});

La misma tiene una interfaz, (views/index.ejs), que tiene esta pinta:

Interfaz de bookstore

bookbuyer es un comprador lícito que cada poco tiempo hace una llamada para comprar libros a través de bookstore.

const express = require('express'),
    axios = require('axios');
const app = express();
app.use(express.static('public'));
app.set('view engine', 'ejs');
const PORT = process.env.PORT || 4000,
    API_URL = process.env.BOOKSTORE_URL || 'http://localhost:3000';
app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});
// GET /
// Returns a index.html file with the booksSold variable
app.get('/', (req, res) => {
    // Call the bookstore api to buy a book
    axios.get(`${API_URL}/api/buy`).then(response => {
            let booksSold = response.data.booksSold;
            console.log(`Book sold: ${booksSold}`);
            res.render('index', { booksSold, error: "" });
        })
        .catch(error => {
            console.log(error);
            res.render('index', { booksSold: null, error });
        });
});

el cual tiene una interfaz como la siguiente:

Interfaz de bookbuyer

bookthief es un ladrón que si bien compra algún que otro libro, también intenta robarlos siempre que puede.

const express = require('express'),
    axios = require('axios');
const app = express();
app.use(express.static('public'));
app.set('view engine', 'ejs');
const PORT = process.env.PORT || 4001,
    API_URL = process.env.BOOKSTORE_URL || 'http://localhost:3000';

app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});
// GET /
// Returns a index.html file with the booksSold variable
app.get('/', (req, res) => {
    // Call the bookstore api to buy a book
    axios.get(`${API_URL}/api/steal`).then(response => {
            let booksStolen = response.data.booksStolen;
            console.log(`Book sold: ${booksStolen}`);
            axios.get(`${API_URL}/api/buy`).then(response => {
                let booksSold = response.data.booksSold;
                res.render('index', { booksSold, booksStolen, error: "" });
            });
        })
        .catch(error => {
            console.log(error);
            if (error.response.status === 404) {
                axios.get(`${API_URL}/api/buy`).then(response => {
                    let booksSold = response.data.booksSold;
                    res.render('index', { booksSold, booksStolen: null, error: "You cannot steal more books!" });
                });
            } else {
                res.render('index', { booksSold: null, error });
            }
        });
});

al que le corresponde esta última interfaz:

Interfaz bookthief

Inicialmente todos los componentes de esta aplicación pueden comunicarse entre ellos sin problemas y hacer todas las llamadas pertinentes.

Cómo instalar Open Service Mesh en tu clúster

Si trabajamos con AKS podemos beneficiarnos de la integración que tenemos con este utilizando el add-on open-service-mesh de la siguiente forma:

RESOURCE_GROUP="<YOUR_RESOURCE_GROUP>"
AKS_NAME="<YOUR_AKS_NAME>"
LOCATION="<YOUR_LOCATION>"
az group create --name $RESOURCE_GROUP --location $LOCATION
az aks create \
  --resource-group $RESOURCE_GROUP \
  --name $AKS_NAME \
  --enable-addons open-service-mesh

En caso contrario, puedes instalarlo fácilmente con estos pasos:

# Download and install OSM command-line tool
curl -L https://github.com/openservicemesh/osm/releases/download/v1.0.0/osm-v1.0.0-darwin-amd64.tar.gz | tar -vxzf -
# Export osm to the PATH
export PATH=$PATH:./darwin-amd64/
osm version
# Install OMS on Kubernetes
export osm_namespace=osm-system # Replace osm-system with the namespace where OSM will be installed
export osm_mesh_name=osm # Replace osm with the desired OSM mesh name
osm install \
--mesh-name "$osm_mesh_name" \
--osm-namespace "$osm_namespace" \
--set=osm.enablePermissiveTrafficPolicy=true \
--set=osm.deployPrometheus=true \
--set=osm.deployGrafana=true \
--set=osm.deployJaeger=true
# Check OMS status
kubectl get pods -n "$osm_namespace"

Como ves, lo primero que hago es descargarme el ejecutable correspondiente para mis sistema operativo, en este caso MacOS (sustituye darwin por tu sistema operativo (windows, linux) en el caso de no utilizar este) y acto seguido lo descomprimo el contenido. Por comodidad, exporto a la variable PATH la ubicación donde está el binario y así podré usar directamente el comando osm.
Una vez que tengo la herramienta, ahora ya puedo instalar Open Service Mesh en el clúster que tengo actual en mi contexto, con osm install. Para este ejemplo, y los siguientes, he configurado algunos parámetros como el modo enablePermissiveTrafficPolicy a true, que más adelante te explicaré para qué es, despliega Prometheus, Grafana y Jaeger. Por último, compruebo los pods que se han desplegado en el namespace elegido para la instalación y compruebo que todo está up & running. Si es así ¡Ya tienes todo listo para comenzar a jugar!

Desplegar la aplicación de ejemplo

Para que tengamos algo con lo que trastear, vamos a desplegar la aplicación mencionada anteriormente, para ello, si has hecho copia de mi repo puedes hacerlo fácil de la siguiente forma:

Primero debes generar las imágenes de los contenedores Docker que la forman:

# My own app in Node.js
docker build -t YOUR_DOCKER_HUB_USER/bookstore bookstore/.
docker build -t YOUR_DOCKER_HUB_USER/bookbuyer bookbuyer/.
docker build -t YOUR_DOCKER_HUB_USER/bookthief bookthief/.

Puedes probar si quieres en local para comprobar que todo funciona bien:

# Try to execute the app in Docker
# Create a network for the containers
docker network create bookstore-net
# Create the containers
docker run -d --name bookstore -p 8080:3000 --network bookstore-net 0gis0/bookstore 
docker run -d --name bookbuyer -p 8081:4000 --network bookstore-net 0gis0/bookbuyer
docker run -d --name bookthief -p 8082:4001 --network bookstore-net 0gis0/bookthief

y si todo está OK publicarlo en tu cuenta de Docker Hub:

# Publish images in Docker Hub
docker push YOUR_DOCKER_HUB_USER/bookstore
docker push YOUR_DOCKER_HUB_USER/bookbuyer
docker push YOUR_DOCKER_HUB_USER/bookthief

Por último, para desplegarlo en tu clúster de Kubernetes puedes hacerlo de manera rápida con este comando:

# Deploy manifests in AKS/K8s cluster
kubectl apply -f manifests/.

Los manifiestos son muy sencillos:

# Create Namespace
apiVersion: v1
kind: Namespace
metadata:
  name: bookbuyer
---
# Create bookbuyer Service Account
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookbuyer
  namespace: bookbuyer
---
# Create bookbuyer Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookbuyer
  namespace: bookbuyer
spec:
  replicas: 1
  selector:
    matchLabels:
      app: bookbuyer
      version: v1
  template:
    metadata:
      labels:
        app: bookbuyer
        version: v1
    spec:
      serviceAccountName: bookbuyer
      containers:
        - name: bookbuyer
          image: 0gis0/bookbuyer
          imagePullPolicy: Always
          env:
            - name: BOOKSTORE_URL
              value: http://bookstore.bookstore.svc.cluster.local
---
# Create bookbuyer Service
apiVersion: v1
kind: Service
metadata:
  name: bookbuyer
  namespace: bookbuyer
  labels:
    app: bookbuyer
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 4000
      name: bookbuyer-port
  selector:
    app: bookbuyer

Estos constan de un namespace para cada pieza de la arquitectura, una service account que se utiliza con cada Deployment, el Deployment y un Service de tipo LoadBalancer para que tengas acceso de forma sencilla a cada uno de los componentes y poder ver la interfaz sin tener que hacer nada más. En el caso de bookbuyer y bookthief se les pasa como variable de entorno la URL del servicio de bookstore para que puedan consultar la API. En este momento, si utilizas las IPs públicas asociadas a cada uno de los servicios podrás acceder a ellos sin problemas.

Habilitar Open Service Mesh en los namespaces de los componentes

Open Service Mesh puede convivir en un clúster donde algunos componentes estén bajo su gestión y otros no. Para hacer esta distinción, él solo se fija en aquellos namespaces que estén marcados con la etiqueta openservicemesh.io/monitored-by=osm. Para hacer esta tarea de forma sencilla la herramienta osm la podemos utilizar de la siguiente manera:

# Now we enable osm for their namespaces
osm namespace add bookstore bookbuyer bookthief
# Check what namespaces are monitored by osm
osm namespace list

Si haces un describe de cualquiera de ellos comprobarás que tienes dos etiquetas adicionales relacionadas con Open Service Mesh:

Etiqueta y anotación adicionales de Open Service Mesh en los namespaces
Etiqueta y anotación adicionales de Open Service Mesh en los namespaces

La primera de ellas, como te decía, hace estos namespaces queden monitorizados. La anotación lo que indica es que a cada deployment se le inyecte el sidecar que hará de proxy de estos. Como muchos otros service mesh, este también utiliza Envoy para estos contenedores. Sin embargo, si ahora compruebas el numero de contenedores por cada pod comprobarás que solo tienes uno, y no hay rastro del sidecar:

kubectl get pods -n bookstore
kubectl get pods -n bookbuyer
kubectl get pods -n bookthief

Esto es porque el cambio no surgirá hasta que haya un nuevo redespliegue de estos. Puedes forzarlos brutamente eliminando los pods de cada namespace:

kubectl rollout restart deployment/bookthief -n bookthief
kubectl rollout restart deployment/bookbuyer -n bookbuyer
kubectl rollout restart deployment/bookstore -n bookstore

Si vuelves a comprobar de nuevo verás que ahora cada pod tiene dos contenedores, el de tu aplicación y el de Envoy. Nuestra foto cambiar ahora así:

Open Service Mesh habilitado en los namespaces de los servicios
Open Service Mesh habilitado en los namespaces de los servicios

Otra de las cosas que han ocurrido durante esta monitorización por parte de OSM es que las IPs públicas asociadas a los servicios de cada componente han dejado de funcionar, por lo que hemos perdido el acceso a nuestras aplicaciones. Vamos a ver cómo lo arreglamos.

Acceder desde fuera del clúster con Open Service Mesh

Como ahora ya sabes, el punto principal de utilizar este tipo de malla de servicios es mejorar la seguridad desde el punto de vista de comunicaciones y es por ello que siempre se restringe todo tipo de acceso, a no ser que se diga lo contrario. A partir de ahora debemos de gestionar mejor el acceso externo a nuestro clúster. Para ello lo primero que vamos a hacer es crear un Ingress de la misma forma que ya te expliqué hace mucho tiempo, en este ejemplo con Nginx:

# Configure nginx ingress controller
kubectl create ns ingress-nginx
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install osm-nginx-ingess ingress-nginx/ingress-nginx --namespace ingress-nginx

Con estos pocos pasos ya tenemos nuestro ingress funcionando.

Ahora, lo siguiente que necesitas es generar una regla de Ingress por cada uno de los servicios. Este sería el ejemplo para bookbuyer:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: osm-ingress
  namespace: bookbuyer
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - host: bookbuyer.<NGINX_INGRESS_PUBLIC_IP>.nip.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: bookbuyer
                port:
                  number: 80

Nota: La IP pública de tu ingress controller la puedes encontrar a través de este comando:

# Get nginx ingress controller public IP
kubectl get svc -n ingress-nginx osm-nginx-ingess-ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'

Por otro lado, además de esta regla que indica cómo se accede a cada uno de los servicios, usando nip.io como te conté en este otro artículo, debemos añadir un recurso más, que forma parte de Open Service Mesh llamado IngressBackend que permitirá aquellas peticiones que provengan del Ingress controller tener acceso al backend que necesitamos:

kind: IngressBackend
apiVersion: policy.openservicemesh.io/v1alpha1
metadata:
  name: bookbuyer-external-access
  namespace: bookbuyer
spec:
  backends:
    - name: bookbuyer
      port:
        number: 4000 # targetPort of the service
        protocol: http
  sources:
    - kind: Service
      name: osm-nginx-ingess-ingress-nginx-controller
      namespace: ingress-nginx

Es importante tener en cuenta que el puerto que se indica en esta configuración no es por el que escucha el servicio sino el puerto objetivo del contenedor, en este ejemplo el 4000.

Aquí tienes el resto de configuraciones para cada uno de los componentes. Para aplicar todas ellas puedes hacerlo fácilmente a través de este comando:

# Create a ingress rules and ingress backends
kubectl apply -f ingress/.

A partir de este momento ya podrás acceder a cada uno de los servicios, a través del ingress, utilizando estas URLS:

# Now you can access using this URLs
http://bookstore.<INGRESS_PUBLIC_IP>.nip.io/
http://bookbuyer.<INGRESS_PUBLIC_IP>.nip.io/
http://bookthief.<INGRESS_PUBLIC_IP>.nip.io/

Pero ya no podrás acceder directamente a través de su IP pública. De hecho lo interesante en este tipo de escenarios es que el tipo de servicio sea ClusterIP y que la única IP pública sea la del ingress, pero para la demo y la explicación era muy útil explicarlo así 😊

Representado gráficamente esta sería la foto que quedaría ahora:

Acceso a los servicios a través del ingress
Acceso a los servicios a través del ingress

Traffic Access

Para terminar con este artículo, te voy a contar cómo limitar el acceso entre los componentes. Ya hemos visto como este se limita de manera automática desde el exterior pero, ahora que puedes acceder a ellos, todavía puedes ver cómo se siguen comunicándose sin problemas. Esto es así porque cuando instalaste Open Service Mesh en tu clúster habilitaste la opción enablePermissiveTrafficPolicy a true. Puedes comprobar su estado actual con este comando:

# Check permissive mode
kubectl get meshconfig osm-mesh-config -n osm-system -o jsonpath='{.spec.traffic.enablePermissiveTrafficPolicyMode}{"\n"}'

Esto significa que no tiene en cuenta a priori que no haya ninguna regla que permita la comunicación o la limite. Sin embargo, si modificamos este valor a false:

# Change to permissive mode to false
kubectl patch meshconfig osm-mesh-config -n osm-system -p '{"spec":{"traffic":{"enablePermissiveTrafficPolicyMode":false}}}'  --type=merge

Te darás cuenta de que bookbuyer y bookthief ya no podrán comunicarse más con bookstore, dando este error en el caso de bookbuyer:

bookbuyer no tiene acceso a bookstore

Este es el modo habitual en el que debería de estar tu configuración una vez que tengas definido quién tiene que hablar con quién, pero no siempre lo tenemos configurado nada más adentrarnos en este mundo y necesitamos que todo siga funcionando.

La configuración en este sentido consta de dos partes. Por un lado tenemos el recurso llamado TrafficTarget que va a indicar con quién nos queremos comunicar, a través de su service account (que si recuerdas generamos al inicio), cuáles son las fuentes o componentes interesados en que esa comunicación se de y cuáles son las reglas en esa comunicación,:

kind: TrafficTarget
apiVersion: access.smi-spec.io/v1alpha3
metadata:
  name: bookstore
  namespace: bookstore
spec:
  destination:
    kind: ServiceAccount
    name: bookstore
    namespace: bookstore
  sources:
  - kind: ServiceAccount
    name: bookbuyer
    namespace: bookbuyer
  - kind: ServiceAccount
    name: bookthief
    namespace: bookthief
  rules:
  - kind: HTTPRouteGroup
    name: bookstore-service-routes
    matches:
    - buy-books
    - steal-books

En este ejemplo el destino es bookstore, por lo que usamos la service account con el mismo nombre en su namespace, las fuentes son tanto bookbuyer como bookthief, y por último tenemos un apartado para las reglas donde en este ejemplo tenemos una del tipo HTTPRouteGroup con dos reglas en concreto, buy-books y steal-books. Este recurso se genera aparte y es como el siguiente:

apiVersion: specs.smi-spec.io/v1alpha4
kind: HTTPRouteGroup
metadata:
  name: bookstore-service-routes
  namespace: bookstore
spec:
  matches:  
  - name: buy-books
    pathRegex: "/api/buy"    
    methods:
    - GET
  - name: steal-books
    pathRegex: "/api/steal"
    methods:
    - GET

La primera regla, buy-books, va a permitir hacer llamadas con el método GET a la ruta /api/buy de bookstore y la segunda, steal-books, a la ruta /api/steal, con el objetivo de que todo funcione como hasta ahora. Si aplicas los cambios:

# Now you need to create traffic policies
kubectl apply -f traffic-access/.

Comprobarás que efectivamente la comunicación se reanuda y todo vuelve a la normalidad. Sin embargo, sim comento la regla steal-books, verás que bookthief sigue pudiendo comprar libros pero ya no se le permite robar:

kind: TrafficTarget
apiVersion: access.smi-spec.io/v1alpha3
metadata:
  name: bookstore
  namespace: bookstore
spec:
  destination:
    kind: ServiceAccount
    name: bookstore
    namespace: bookstore
  sources:
  - kind: ServiceAccount
    name: bookbuyer
    namespace: bookbuyer
  - kind: ServiceAccount
    name: bookthief
    namespace: bookthief
  rules:
  - kind: HTTPRouteGroup
    name: bookstore-service-routes
    matches:
    - buy-books
    # - steal-books

Con todo ello, conseguimos que la foto quede de la siguiente manera:

Restringir y permitir comunicaciones entre los componentes
Restringir y permitir comunicaciones entre los componentes

Tienes todos los pasos aquí, en el repo de GitHub.

¡Saludos!