Docker: Reducir el tamaño de un contenedor

En entornos de contenedores (Docker o Kubernetes) necesitamos desplegar rápidamente y lo más importante es el tamaño de los mismos. Debemos reducirlos para que la descarga de los mismos desde el registro y su ejecución es más lenta cuanto mayor es el contenedor y eso afecte lo mínimo a la complejidad de las relaciones entre servicios.

Para una demostración de una solución basada en PowerDNS me encuentro que el contenedor original del servicio PowerDNS-Admin ( https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/docker/Production/Dockerfile)tiene los siguientes detalles:

  • El desarrollador es muy activo e incluye código python, javascript (nodejs) y css. Las imágenes en docker.hub están obsoletas respecto al código.
  • El Dockerfile de producción no genera una imagen válida
  • Está basada en Debian Slim, que aunque elimina gran cantidad de ficheros

En Docker Hub existen imágenes, pero pocas son recientes o no usan el repositorio original por lo que el resultado es de una versión antigua del código. Por ejemplo la imagen más descargada ( really/powerdns-admin) no tiene en cuenta las modificaciones del último año y no usa yarn para la capa nodejs.

Primer paso: Decidir si crear una nueva imagen Docker

A veces es cuestión de la necesidad o del tiempo, en este caso se ha decidido crear una imagen nueva teniendo en cuenta lo indicado anteriormente. Como requisitos mínimos necesitamos una cuenta en GitHub (https://www.github.com), una cuenta en Docker Hub ( https://hub.docker.com/) y conocimientos básicos de git así como avanzados de crear Dockerfile.

En este caso se crea https://github.com/aescanero/docker-powerdns-admin-alpine y https://cloud.docker.com/repository/docker/aescanero/powerdns-admin y se enlazan para poder crear automáticamente las imágenes cuando se suba un Dockerfile a GitHub.

Segundo paso: Elegir la base de un contenedor

Utilizar una base de muy pequeño tamaño y orientada a la reducción de cada uno de los componentes (ejecutables, librerías, etc) que se van a utilizar es el requisito mínimo para reducir el tamaño de un contenedor, y la elección debe ser siempre utilizar Alpine ( https://alpinelinux.org/ ).

Además de los puntos ya indicados, las distribuciones estándar (basadas en Debian o en RedHat) requieren de un gran número de elementos para la gestión de paquetería, sistema base, librerías auxiliares, etc. Alpine elimina dichas dependencias y suministra un sistema de paquetes simple que permite identificar que grupos de paquetes se han instalado juntos para poder eliminarlos juntos (muy útil para desarrollos como veremos más adelante)

El uso de Alpine puede reducir hasta un 40% el tamaño y el tiempo de despliegue de un contenedor.

Otra opción de reciente aparición a tener en cuenta es UBI de Redhat, especialmente interesante es ubi-minimal ( https://www.redhat.com/en/blog/introducing-red-hat-universal-base-image ).

Tercer paso: Instalar paquetes mínimos

Uno de los puntos fuertes de Alpine es una sistema de paquetería simple y eficiente que además de reducir el tamaño reduce el tiempo de creación del contenedor.

Los paquetes que necesitemos instalar los vamos dividir en dos bloques, el bloque de paquetes que se van a quedar en el contenedor y aquellos que nos van a servir para desarrollar el contenedor:

FROM alpine:3.10
MAINTAINER "Alejandro Escanero Blanco <aescanero@disasterproject.com>"

RUN apk add --no-cache python3 curl py3-mysqlclient py3-openssl py3-cryptography py3-pyldap netcat-openbsd \
  py3-virtualenv mariadb-client xmlsec py3-lxml py3-setuptools
RUN apk add --no-cache --virtual build-dependencies libffi-dev libxslt-dev python3-dev musl-dev gcc yarn xmlsec-dev

La primera linea le va a decir a Docker cual es la base a utilizar (Alpine versión 3.10), la segunda es información de quien crea el Dockerfile y a partir de la tercera vamos creando el guión de lo que se va a ir haciendo para generar el contenedor.

El primer RUN instala los paquetes básicos de python3 con soporte SSL, Mysql y LDAP con el comando “apk add” y una característica muy importante de apk: “–no-cache” que evita guardar archivos descargados como indices de paquetes o temporales.

La segunda linea RUN tiene otra característica muy útil de apk: “–virtual NOMBRE_PAQUETE_VIRTUAL”, la lista de paquetes a instalar se asigna a un único nombre que nos permitirá eliminar todos los paquetes que se instalen en este paso. Así que está lista incluirá todos los paquetes que sirven para crear un desarrollo y después eliminaremos.

Cuarto paso: Python

Python es un lenguaje de programación con una enorme cantidad de módulos, habitualmente los que se requieren para una aplicación han de estar en un fichero llamado requirements.txt y se instalan con un sistema de paquetes llamado pip (pip2 para Python2, pip3 para Python3).

Nos encontramos con otro sistema de paquetes, que a diferencia de los habituales (apk, deb, rpm) descargará los fuentes y si requiere de binarios procederá a compilarlos. Esto nos obliga a disponer un entorno de desarrollo al que en el paso anterior hemos etiquetado para poder eliminar dicho entorno.

Uno de los problemas que podemos encontrarnos es aquel en que el sistema de gestión de paquetes pip es lento, pero existe paquete (caso de pye_lxml que es a que pip3 lo instale) o el caso de algún proceso de instalación desde fuentes como el caso de python_xmlsec que instalamos rápidamente con una única linea:

RUN mkdir /xmlsec && curl -sSL https://github.com/mehcode/python-xmlsec/archive/1.3.6.tar.gz | tar -xzC /xmlsec --strip 1 && cd /xmlsec && pip3 install --no-cache-dir .

Otro proceso que acelera la creación de un contenedor es no usar git cuando esto sea posible, al igual que en el caso anterior para powerdns-admin haremos lo mismo, eliminamos de requirements.txt los paquetes ya instalador y ejecutamos el instalador de paquetes python al igual que en Alpine sin que guarde cache de ningún tipo con “–no-cache-dir”

RUN mkdir -p /opt/pdnsadmin/ && cd /opt/pdnsadmin \
  && curl -sSL https://github.com/ngoduykhanh/PowerDNS-Admin/archive/master.tar.gz | tar -xzC /opt/pdnsadmin --strip 1 \
  && sed -i -e '/mysqlclient/d' -e '/pyOpenSSL/d' -e '/python-ldap/d' requirements.txt \
  && pip3 install --no-cache-dir -r requirements.txt

Por último debemos tener en cuenta que las aplicaciones python suelen usar virtualenv, debemos minimizar su efecto indicando que utilice los paquetes del sistema y no instale en el directorio local con “virtualenv –system-site-packages –no-setuptools –no-pip”. El contenedor ya es de por si un entorno virtual, no es necesario añadir más capas.

Quinto paso: Nodejs

El ultimo paso es la optimización de las tareas de despliegue de las librerias javascript y las hojas de estilo, para ello debemos comprobar si usa npm o yarn para la instalación de paquetes. Siendo recomendable el uso de yarn por ser más eficiente en la descarga de los paquetes y la capacidad de eliminar duplicados dentro del árbol de paquetes descargados.

Como la aplicación para instalarse utiliza “flask assets”, cuya función es unificar las librerias javascript y las horas de estilo que va a usar la aplicación, hemos de revisar que elementos de la carpeta node_modules usarán dichas librerias javascript y las hoja de estilo, en el caso de powerdns-admin son bootstrap, font-awesome, icheck, ionicons y multiselect, así como algún fichero más que guardamos en una carpeta temporal para después volverlo a su lugar original. El resto de elementos instalado así como el caché de yarn (yarn cache clean) se eliminan.

Sexto paso: otros recursos

Por último queda revisar que ocurre al iniciar el contenedor la aplicación y ver que aplicaciones pueden ser sustituidas por elementos ya instalados.

En este caso el punto de entrada del contenedor es un fichero entrypoint.sh que ejecuta una serie de consultas de base de datos antes de lanzar el servicio. La primera consulta es saber si el puerto está abierto y lo hace con netcat, pero los requisitos de netcat son tan exiguos que no tiene sentido su sustitución.

Sin embargo si tenemos un candidato con mariadb-client, ya que solo se utiliza para lanzar dos consultas SQL simples. Crear un script en python que haga exactamente lo mismo es fácil y reduce de manera visible (unos 20MB) el tamaño del contenedor.

Conclusiones

La diferencia entre un contenedor sin optimizar y otro optimizado puede ser realmente importante afectado a los tiempos de creación, descarga y despliegue. Como ejemplo (usando el entorno de pruebas explicado en https://www.disasterproject.com/index.php/2019/07/03/elegir-entre-docker-o-podman-para-entornos-de-pruebas-y-desarrollo/) en el siguiente vídeo vemos la creación del contenedor sin optimizar:

Y aquí optimizado:

Como vemos hay dos grandes diferencias, la primera es que la imagen no optimizada se genera en 6:51 y la optimizada en 2:35. La segunda y más importante es el tamaño, la primera son 1.11 GB y la optimizada 321 MB ambas sin comprimir. El almacenamiento de los servidores también lo agradece.

Como lanzar un paquete (Chart) de Helm sin instalar Tiller

Uno de los detalles más interesantes que me he encontrado al usar K3s (https://k3s.io/) es la manera de desplegar Traefik, en el cual utiliza un esquema (chart) de Helm (nota: en Kubernetes el comando a ejecutar es sudo kubectl, pero en k3s kubectl está integrado para que use menos recursos)..

$ sudo k3s kubectl get pods -A
NAMESPACE        NAME                              READY   STATUS      RESTARTS   AGE
...
kube-system      helm-install-traefik-ksqsj        0/1     Completed   0          10m
kube-system      traefik-9cfbf55b6-5cds5           1/1     Running     0          9m28s
$ sudo k3s kubectl get jobs -A
NAMESPACE      NAME                        COMPLETIONS   DURATION   AGE
kube-system    helm-install-traefik        1/1           50s        12m

Nos encontramos que helm no está instalado, pero si vemos un job que ejecuta el cliente de helm para que podamos disponer de su potencia sin la necesidad de tener corriendo tiller (el servidor de helm) que por supuesto utiliza recursos y de esta manera nos los ahorramos, pero ¿Como funciona?

Klipper Helm

Lo primero que vemos es el uso de un job (tarea que habitualmente se ejecuta una única vez en forma de contenedor) basado en la imagen “rancher/klipper-helm” (https://github.com/rancher/klipper-helm) que ejecuta un entorno helm con solamente descargarlo y ejecutar un único script: https://raw.githubusercontent.com/rancher/klipper-helm/master/entry

Como requisito si va a requerir una cuenta de sistema con permisos de administrador en el espacio de kube-system, para traefik es:

$ sudo k3s kubectl get clusterrolebinding helm-kube-system-traefik -o yaml
...
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: helm-traefik
  namespace: kube-system

Lo que debemos tener en cuenta es la necesidad de crear la cuenta de servicio y al terminar la tarea de instalación con helm eliminarla ya que no será necesaria hasta otra operación de eliminación o actualización.
Como ejemplo vamos a crear una tarea para instalar un servicio weave-scope utilizando el chart helm (https://github.com/helm/charts/tree/master/stable/weave-scope)

Creación del servicio

Creamos un espacio de trabajo para aislar el nuevo servicio (namespace en kubernetes, proyecto en Openshift) que llamaremos helm-weave-scope:

$ sudo k3s kubectl create namespace helm-weave-scope
namespace/helm-weave-scope created

Creamos una nueva cuenta de sistema y le asignamos los permisos de administrado:

$ sudo k3s kubectl create serviceaccount helm-installer-weave-scope -n helm-weave-scope
serviceaccount/helm-installer-weave-scope created
$ sudo k3s kubectl create clusterrolebinding helm-installer-weave-scope --clusterrole=cluster-admin --serviceaccount=helm-weave-scope:helm-installer-weave-scope
clusterrolebinding.rbac.authorization.k8s.io/helm-installer-weave-scope created

Nuestro siguiente paso es crear la tarea, para ello la creamos en el fichero tarea.yml:

---
apiVersion: batch/v1
kind: Job
metadata:
  name: helm-install-weave-scope
  namespace: helm-weave-scope
spec:
  backoffLimit: 1000
  completions: 1
  parallelism: 1
  template:
    metadata:
      labels:
        jobname: helm-install-weave-scope
    spec:
      containers:
      - args:
        - install
        - --namespace
        - helm-weave-scope
        - --name
        - helm-weave-scope
        - --set-string
        - service.type=LoadBalancer
        - stable/weave-scope 
        env:
        - name: NAME
          value: helm-weave-scope
        image: rancher/klipper-helm:v0.1.5
        name: helm-weave-scope
      serviceAccount: helm-installer-weave-scope
      serviceAccountName: helm-installer-weave-scope
      restartPolicy: OnFailure

La ejecución

Y lo lanzamos con:

$ sudo k3s kubectl apply -f tarea.yml
job.batch/helm-install-weave-scope created

Lo que va a lanzar todos los procesos que el chart tenga:

# k3s kubectl get pods -A -w
NAMESPACE          NAME                                   READY   STATUS      RESTARTS   AGE
helm-weave-scope   helm-install-weave-scope-vhwk2         1/1     Running     0          9s
helm-weave-scope   weave-scope-agent-helm-weave-scope-lrfs2   0/1     Pending     0          0s
helm-weave-scope   weave-scope-agent-helm-weave-scope-drl8v   0/1     Pending     0          0s
helm-weave-scope   weave-scope-agent-helm-weave-scope-lrfs2   0/1     Pending     0          0s
helm-weave-scope   weave-scope-agent-helm-weave-scope-drl8v   0/1     Pending     0          0s
helm-weave-scope   weave-scope-frontend-helm-weave-scope-844c4b9f6f-d22mn   0/1     Pending     0          0s
helm-weave-scope   weave-scope-frontend-helm-weave-scope-844c4b9f6f-d22mn   0/1     Pending     0          0s
helm-weave-scope   weave-scope-agent-helm-weave-scope-lrfs2                 0/1     ContainerCreating   0          1s
helm-weave-scope   weave-scope-agent-helm-weave-scope-drl8v                 0/1     ContainerCreating   0          1s
helm-weave-scope   weave-scope-frontend-helm-weave-scope-844c4b9f6f-d22mn   0/1     ContainerCreating   0          1s
helm-weave-scope   helm-install-weave-scope-vhwk2                           0/1     Completed           0          10s
helm-weave-scope   weave-scope-agent-helm-weave-scope-lrfs2                 1/1     Running             0          13s
helm-weave-scope   weave-scope-agent-helm-weave-scope-drl8v                 1/1     Running             0          20s
helm-weave-scope   weave-scope-frontend-helm-weave-scope-844c4b9f6f-d22mn   1/1     Running             0          20s

El resultado

Podemos observar la correcta instalación de la aplicación sin la necesidad de instalar Helm ni tiller corriendo en el sistema:

# k3s kubectl get services -A -w
NAMESPACE          NAME                           TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                                     AGE
helm-weave-scope   helm-weave-scope-weave-scope   LoadBalancer   10.43.182.173   192.168.8.242   80:32567/TCP                                7m5s
Como lanzar un paquete (Chart) de Helm sin instalar Tiller 1

Actualización

Con la llegada de Helm v3 no se hace necesario Tiller y su uso es mucho más sencillo. Para explicar su funcionamiento, se explica como se hizo un Chart para PowerDNS en Helm v3 para desplegar PowerDNS sobre Kubernetes, esto también evita el uso de Klipper evitando jobs no requeridos.

Volver arriba