Migraciones de bases de datos en Kubernetes con Helm

Probablemente esto te suene. Muchos de tus servicios de backend usan Postgres, MySQL o algún otro tipo de base de datos relacional. Los desarolladores hacen constantes cambios a las aplicaciones para mejorar el rendimiento, mejorar la funcionalidad, arreglar bugs… y algunas veces eso implica hacer cambios a la base de datos.

La mejor forma de hacer esto es usar migraciones. Es más seguro. Puedes hacer un seguimiento de los cambios, tenerlos como parte de tu repositorio… También puedes ver quién ha hecho qué cambios y cuándo, etc.

Las migraciones se pueden ejecutar al iniciar las aplicaciones. Sin embargo, cuando las tenemos en Kubernetes, hay una serie de problemas si se hace así.

  1. Si las migraciones se ejecutan al iniciar una aplicación eso significa que siempre que una vaina se muera y sea reiniciada el proceso se intentará lanzar otra vez. Incluso si no hay nada que migrar, tendrá que tocar la base de datos y comprobarlo de todas formas.
  2. Lo mismo ocurre cuando escalamos y levantamos más vainas.
  3. Dos o más vainas iniciándose al mismo tiempo pueden ejecutar la misma migración a la vez, lo que puede llevar a errores.

Si definimos las aplicaciones que tenemos en Kubernetes con Helm podemos usar un mecanismo muy interesante, los Chart Hooks.

Utilizar hooks nos permite controlar ciertos momentos del ciclo de vida de la release. En nuestro ejemplo lo haríamos de la siguiente forma:

  • Utilizar un hook para crear secretos con los que pasar la información que necesitamos para ejecutar las migraciones (usuario y contraseña de la BD, host, etc.). La razón por la que tenemos que hacer esto es que los hooks se crean antes que cualquier otro componente de la release, así que el Job que usaremos para lanzar las migraciones no puede acceder a los secretos que crearemos como parte de la misma. Estos vendrán más tarde.
  • Utilizar un Job para ejecutar las migraciones.

Echemos un vistazo a los secretos primero.

apiVersion: v1
kind: Secret
metadata:
  name: {{ template "some-service.fullname" . }}-migrations
  labels:
  {{ - include "some-service.someLabels"  . | nindent 4 }}
annotations:
    "helm.sh/hook": pre-install, pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": before-hook-creation, hook-succeeded
type: Opaque
data:
  {{- range $key, $value := .Values.config }}
  {{ $key }}: {{ $value | b64enc | quote }}
  {{- end -}}

Resumiendo lo que hay arriba. Leemos los valores de una sección config del fichero values.yaml (o como se llame tu values file). Este fichero está encriptado y sólo se desencripta cuando es necesario usarlo. Hablaré de cómo hacer eso en un post futuro. Lo imporante de ejemplo son las anotaciones.

annotations:
    "helm.sh/hook": pre-install, pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": before-hook-creation, hook-succeeded
  • «helm.sh/hook»: pre-install, pre-upgrade
    • pre-install: El hook se ejecutará después de renderizar las plantillas, pero antes de que se creen otros elementos en Kubernetes.
    • pre-upgrade: Se ejecuta cuando se solicita una actualización. Después de renderizar las plantillas pero antes de que se actualicen otros elementos.
  • "helm.sh/hook-weight": "-1"
    • Esto controla el orden de ejecución del hook dependiendo de su valor (peso).
    • Por defecto es 0.
    • Se ordenan de positivo a negativo.
  • "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
    • before-hook-creation
      • Este es el valor por defecto. Elimina el recurso anterior antes de lanzar un nuevo hook.
      • Esto es muy útil si el hook falla y quieres ver los logs de una vaina, comprobar cuáles eran los valores…
      • Si algo falla los elementos del hook no serán eliminados, se borrarán la siguiente vez que se solicite el hook.
    • hook-succeeded
      • Borra el hook cuando finaliza correctamente.
      • Me gusta usar esto para no tener elementos completados que ya no son necesarios en el clúster.

Vamos ahora a ver el job que ejecuta las migraciones:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ template "some-service.shortname" . }}-postgres-loader
  labels:
    {{- include "some-service.someLabels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": pre-install, pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation, hook-succeeded
spec:
  template:
    metadata:
      labels:
        {{- include "some-service.someLabels" . | nindent 8 }}
        app: postgres-loader
    spec:
      containers:
        - name: {{ template "some-service.shortname" . }}-postgres-loader-load
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: Always
          env:
          {{- range $key, $value := .Values.config }}
            - name: {{ $key }}
              valueFrom:
                secretKeyRef:
                  name: {{ template "some-service.fullname" $ }}-migrations
                  key: {{ $key -}}
          {{- end }}
          command:
            - sh
          args:
            - -c
            - yarn db:migrations:run
      restartPolicy: OnFailure
  backoffLimit: 5

En este caso las migraciones usarán la misma imagen que tenemos para nuestra aplicación. Lo importante es reemplazar punto de entrada para que en vez de iniciar la aplicación sólo corramos las migraciones.

Dividiéndolo en pedazos más pequeños:

  annotations:
    "helm.sh/hook": pre-install, pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation, hook-succeeded

Ya expliqué las anotaciones cuando hablamos de los secretos. La única diferencia es que en este caso su peso es 0. Esto significa que los secretos (-1) se crearon antes y que el job puede acceder a ellos.

          env:
          {{- range $key, $value := .Values.config }}
            - name: {{ $key }}
              valueFrom:
                secretKeyRef:
                  name: {{ template "some-service.fullname" $ }}-migrations
                  key: {{ $key -}}
          {{- end }}
          command:

Leemos las variables de entorno de los secretos creados por el hook.

          command:
            - sh
          args:
            - -c
            - yarn db:migrations:run
  • command es el equivalente al entrypoint de Docker. Como puedes ver, he sobrescrito el valor por defecto con sh. De esta forma, no iniciará la aplicación.
  • Y justo debajo le pasamos los argumentos que necesitamos para lanzar las migraciones, en nuestro caso yarn db:migrations:run.

¡Y ya está! Este es sólo un pequeño ejemplo de cómo puedes usar migraciones utilizando Helm. Una cosa importante, recuerda que las vas a ejecutar antes de que tus nuevas vainas, con tu nueva versión, se hayan desplegado, así que durante unos segundos/minutes la versión anterior de tu aplicación va a interactuar con la nueva versión de la base de datos. Recuerda hacer tus migraciones retrocompatibles .


Muchas gracias por leer mi blog. Espero que este artículo te sea útil. Por favor, déjame un comentario y haz cualquier pregunta si tienes dudas.

Deja una respuesta

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