Construcción de imágenes multi etapa en Docker

Incluye lo justo y necesario en la imagen de Docker que subes a producción. Hoy te cuento como hacer tus imágenes más livianas y pro-producción.
Construcción de imágenes multi etapa en Docker

Con la llegada de Docker 17.05 se añade una nueva (y extremadamente útil) funcionalidad llamada multi-stage builds, o lo que es lo mismo en español: construcción multi-etapa de imágenes. Esta nueva funcionalidad nos aporta muchísimas ventajas a la hora de construir una imagen, sobre todo de cara a subir tu software a producción. Voy a intentar explicarte qué nos aporta y porqué deberías usarlo.

Porqué surge

En la vida de todo proyecto de software llega un momento en el que tenemos que subirlo a producción. Eso quiere decir que toca montar una imagen reducida de tu código si estás trabajando en Docker. En la imagen de desarrollo instalaste herramientas de depuración  y utilidades que te ayudaban a desarrollar más rápido o algunas herramientas que solo ibas a utilizar en desarrollo (como sass o webpack). En producción no vas a depurar ni vas a generar releases con webpack, por lo que ¿ qué sentido tiene mantenerlo en producción ? Lo único que se consigue manteniendo estas cosas en producción es aumentar el peso de la imagen y hacer más brechas en la seguridad del contenedor. Por lo tanto, debemos crear una imagen productiva sin estas utilidades.

Hasta hace poco, una forma muy común de hacerlo era creando dos ficheros Dockerfile: uno para desarrollo (con todos los "andracapadres" y herramientas de ayuda al programador) y otra para producción (con únicamente lo que se está usando). Así conseguíamos aligerar el peso de la imagen, pero al haber dos ficheros, hay que mantener dos lógicas diferentes y seguimos enfrentándonos a otro problema: los lenguajes compilados.

El código en C o Golang no sirve "para nada" sin que sea compilado. Por lo tanto, a la hora de construir una imagen teníamos que utilizar la imagen del compilador y después compilar el proyecto. Pero... ¿ por qué incluir el compilador en la imagen que voy a subir a producción si no voy a volver a compilarlo más que una vez ?

La solución: etapas intermedias

Gracias a la funcionalidad multi-stage o multi etapa somos capaces de crear imágenes "intermedias" que hagan su trabajo y después, simplemente, nos olvidamos de ella.

Por un lado, el proceso de construcción de JS o PHP es sencillo: creamos una imagen con todas las dependencias que tenemos en fase de desarrollo, como por ejemplo webpack o la instalación del "compilador" de TypeScript. Con el código fuente original, lanzamos este webpack o generamos el código JS resultante de la transpilación de TypeScript y (aquí viene la magia) el código final lo copiamos a otra nueva imagen. Esta imagen intermedia nunca se subirá a producción; se subirá la que contiene exclusivamente el código final. Solo haciendo esto, nos hemos quitado el peso de TypeScript, webpack, postcss, xdebug, etc. El resultado es una imagen final con el código final. Simple, ¿ a qué sí ?

Por otro lado, volvamos al ejemplo de C o Golang. Con esta nueva funcionalidad multietapa, creamos una imagen intermedia con el compilador y el código fuente y, el resultado (el código máquina) lo copiamos a una nueva imagen de Docker. Así, la imagen de producción solo contiene el código final (nunca el compilador o el código fuente original, ni el código fuente de las librerías).

En ambos casos, el secreto de la construcción multi etapa se basa en una idea: copiar desde una imagen temporal hasta otra (que puede ser temporal o la definitiva para producción), de esta forma podemos desechar la primera porque solo nos interesa la segunda. A continuación te muestro dos ejemplos: uno para Node.js y otro para Golang.

Un ejemplo multi-stage para Nodejs

Tenemos un proyecto en Typescript o con módulos o herramientas de desarrollo, que solo se usarán en la construcción inicial del proyecto para producción. Antes del multi-stage, una forma de gestionar esto sería tener una imagen que instalase Nodemon (para reejecutar Node.js cada vez que se cambia el código) y las herramientas de desarrollo (como TypeScript). Por otro lado, tendríamos una imagen que solo contuviese el código final. Lo podríamos automatizar con Bash o alguna utilidad, pero ... es engorroso.

Con Multi stage, creamos una imagen temporal que tenga todas las herramientas de desarrollo (como Nodemon o TypeScript) y otra que solo tuviese el código final de producción (obtenido de la imagen de desarrollo).

#Dockerfile resumido para focalizar en el multistage
#Primera etapa: construimos el código con TS
FROM node:10.15.2 as develop-step

WORKDIR /app
COPY package*.json ./
COPY tsconfig.json ./
RUN npm install
COPY ./src ./src
RUN npm run build

#Segunda etapa: Copiamos el código final anterior a esta imagen
FROM node:10.15.2 as production

WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY --from=develop-step /app/build ./build
EXPOSE 80
CMD npm run prod

Así, creamos una primera imagen que instale el compilador de Typescript, la librería de testing para comprobar que nuestro código es correcto y todas las dependencias de desarrollo que necesitamos para que  genere el código final de nuestro proyecto. Este código final está en el directorio "build".

Posteriormente, creamos una imagen de Node.js vacía, instalamos las dependencias exclusivamente de producción y copiamos el código final ya compilado desde la primera imagen hasta esta segunda imagen. Resultado final: no hemos incluido nada más que nuestro código final.

Un ejemplo multi-stage para Golang

Golang es un lenguaje que debería ser compilado para poder ser ejecutado (aunque puede funcionar con un entrono de ejecución concreto, pero la idea es ejecutarlo sin dependencias, como un binario). De forma análoga a lo que ocurría antes, necesitamos dos imágenes: una que haga el trabajo pesado y otra, mucho más ligera, que simplemente reciba el código final. Esta última será la que subamos a producción.

La primera imagen se construye sobre la imagen del compilador de Golang e incluye el código fuente del programa. Esta ocupará con casi toda seguridad más de 250 MB. La segunda, que solo contiene el binario final no debería pasar de un par de decenas (puede variar según dependencias). En cualquier caso, nos hemos quitado 200 MB de espacio que no íbamos a utilizar.

# Dockerfile resumido.

# Etapa de construcción y compilación
FROM golang:alpine AS builder
ADD . /src
RUN cd /src && go build -o myapp

# Etapa final
FROM alpine
WORKDIR /app
COPY --from=builder /src/goapp /app/
ENTRYPOINT ./myapp

El resultado es parecido al del ejemplo de Node.js. Mientras que una imagen temporal hace todo el trabajo de compilación, la otra simplemente guarda el binario sin nada más. Evitamos así incrementar el espacio de la imagen que vamos a enviar a producción (y más cuando ese espacio procede de cosas que no vamos a utilizar).