Crear imágenes Docker de una app SPA pura y… como configurarla

El otro día habé de como crear imágenes Docker para las aplicaciones SPA de .NET Core. Hoy quiero comentaros como crear imágenes Docker para aplicaciones SPA puras y un tema importante al respecto: como configurarlas.

Xavi me preguntó por Twitter cual era la utilidad de usar aplicaciones SPA servidas por .NET Core (o para el caso que nos ocupa cualquier otro Backend como uno Node):

Yo le respondí a Xavi que una utilida es si vas a servir una API de consumo propio de la SPA. En este caso, el mismo Backend que te sirve la API te sirve también la SPA. Es una solución simple y efectiva y también te olvidas de CORS p. ej. porque el origen de la SPA y de la API es el mismo.

Pero, en general, Xavi tiene razón: lo interesante de una app SPA pura es servirla directamente: a fin de cuentas se trata de ficheros estáticos (html, css, js). Se podría servir perfectamente desde un CDN. Pero y si necesitamos usar Docker?

Pues en este caso nada más sencillo: lo habitual es usar una imagen de node para el stage de build y una de nginx (o similar) para la imagen final. Realmente tu imagen final es un nginx, su configuración y todos los ficheros estáticos de tu app:

FROM node:10-alpine as build
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm install
COPY . . 
RUN npm run build

FROM nginx:stable as final
WORKDIR /web
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build .

Como puedes ver no tiene ningún secreto: En el stage de build lanzo los comandos necesarios (en este ejemplo básicamente npm run build) y copio el resultado al stage final que es una imagen de NGINX. Claro, debo copiar el fichero de configuración de nginx. Uno minimalista sería:

server {
    listen 80;
    server_name _;
    root /web;
    index index.html;
    location / {
        try_files $uri /index.html;
    }
}

Este fichero de nginx o bien carga un fichero que exista o en caso contrario sirve “index.html”. Y listos.

Hasta ahí todo bien, esa era la parte fácil. Ahora la pregunta del millón: ¿como configuramos nuestra SPA?

Configuración de una SPA pura ejecutándose en Docker

Nuestra SPA necesita algunos parámetros de configuración: p. ej. las URLs de las APIs a las que debamos acceder y quizá alguna otra cosilla más. Es importante recalcar que hablo de configuración pública, no secretos. Es decir, si mi SPA requiere algún secreto, tipo un token de Google Maps p. ej. debe obtenerlo siempre llamando a una API autenticada. Ahora bien, la URL de esta API si que forma parte de la configuración de la SPA, ya que es un dato público (no secreto).

Si eres muy frontender igual te sorprendas que discutamos como configurar una aplicación SPA. En muchos casos es habitual hacer esa configuración al construir la aplicación. Es decir, lanzo un comando tipo “npm run build:dev” y eso me genera una versión de la app configurada contra el entorno de Dev y si lanzo “npm run build:prod” tengo una versión configurada para el entorno de producción. Si me preguntas qué hay de malo en esa aproximación, te responderé que en general nada y que funciona bien. Un sistema de builds puede construir las distintas versiones y desplegarlas en distintos entornos. Todo OK, ¿verdad?. Pues la verdad es que esta visión no encaja para nada con Docker. ¿Por qué? Pues porque una de las reglas de oro que debes intentar seguir siempre es que la misma imagen que despliegas en un entorno es la que despliegas en otro. Eso significa que no debes construir una imagen para desarrollo (generada a partir de “npm run build:dev” en el stage de build) y otra para otro entorno, como producción. La imagen Docker debe ser única, debe ser generada una sola vez y desplegada en distintos entornos. La configuración la debe proveer el entorno.

Claro, si tienes una SPA servida por .NET Core u otro Backend puedes hacer un truco muy sencillo: desde el Backend lees las variables de entorno que tienen la configuración, montas un endpoint (p. ej. en /api/config) y en dicho endpoint sirves la configuración. Luego, solo debes llamar a ese endpoint al inicializar tu SPA y listos. Es sencillo y poco doloroso. Pero en una imagen pura SPA no puedes usar esa técnica, básicamente porque no tienes nada con qué servir una API. ¿Entonces? ¿Qué puedes hacer?

Bueno, pues aunque no puedes usar esta técnica directamente, si que puedes “simularla”. ¿Como? En nuestro caso podemos configurar Nginx para que, dada una petición concreta (p. ej. /api/config) nos sirva un fichero estático. Observa que eso es posible porque usamos un servidor tipo Nginx, con un CDN no podríamos (aunque como comenté antes, si despliegas en un CDN lo habitual es generar la configuración al construir la aplicación).

Así, podríamos configurar Nginx para que si llega una petición a /api/conf sirva el fichero estático /app/conf.js:

location /api/conf.json { 
   alias /app/config/conf.json
}

NGinx servirá el fichero “/app/config/conf.json” cada vez que la aplicación web llame a “/api/conf.json”. Fantástico, ahora solo tenemos que ver como podemos “inyectar” un fichero conf.json configurado según el entorno.

Pues se me ocurren dos opciones:

Opción 1: Usar fichero con tokens y sustituirlos al iniciar la aplicación

Esta aproximación es muy sencilla, pero oye, funciona. Despliegas un fichero en la imagen (p. ej. en /app/config/template.json) que tenga ciertos tokens y al iniciar el contenedor usas las variables de entorno para sustituir esos tokens con los valores de dichas variables y generas el fichero final.

Y eso es extremadamente simple. Si tu fichero tiene el código:

{
   "some-key": "$XXX"
}

Puedes usar envsubst para sustituir los tokens $XXX por el valor de la variable de entorno XXX. Así el comando que debes usar es “cat template.json | envsubst > conf.js”. Observa la imagen para ver como funciona envsubst:

Salida de envsubst

Por lo tanto te puedes hacer un shell script(p. ej. llamado tokenize.sh) que invoque esos comandos y que el punto de entrada de tu imagen sea algo como:

CMD tokenize.sh & nginx -g daemon off;

De este modo se ejecuta tu shell script que usando envsubst te genera la configuración y luego lanza Nginx.

Este mcanismo es muy eficiente y tiene cero overhead: se genera la configuración al iniciar el contenedor (basándose en las variables de entorno) y listos. Por supuesto debes asegurarte que envsubst está disponible en tu imagen.

Opción 2: Usar bind mounts, volúmenes, …

Esa es otra opción para configurar nuestra aplicación. En este caso, en lugar de sustituír tokens en un fichero, vamos a usar bind mounts, para que el contenedor acceda a un fichero proveído por el host. Si estás en local, usarías bind mounts de Docker. Así, en tu máquina, podrías tener un directorio cfg con varios subdirectorios y su fichero de configuración (p. ej. cfg/dev/conf.json y cfg/qa/conf.json). Cuando quieras ejecutar contra un determinado entorno te basta con lanzar el contenedor usando el parámetro “-v” para mapear el fichero que quieras al fichero “/app/config/conf.json” (o el que sea, claro).

Para más comodidad puedes usar compose y una sección volumes de tu contenedor:

volumes:
  - ./cfg/dev:/app/config/

En este caso mapeamos el directorio local cfg/dev al directorio /app/config del contenedor.

En entornos productivos, ya depende del entorno, pero si usas Kubernetes, pues puedes crear un config map con el contenido del fichero y luego desplegar el config map como un fichero local en el contenedor. Es una solución sencilla y elegante. Echa un vistazo a la documentación de ConfigMaps para ver como funciona esa técnica.

Espero que te sea útil, y como siempre: si tienes cualquier comentario ¡no te cortes!

 

Deja un comentario

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