Intro

Las multistage build son compilaciones de imágenes de contenedores en varias fases. Esto nos permite usar otros contenedores para generar los ficheros o binarios necesarios para el contenedor de la siguiente fase, y así reducir el tamaño de la imagen final.

Una de las formas de crear imágenes en varias fases, es dar un nombre a la fase en la línea FROM desde la que importamos la imágen; y luego referenciar dicha fase con el comando COPY para copiar los ficheros generados en la fase anterior:

Por ejemplo:

#Fase builder
FROM node:latest AS BUILDER
...
#Fase RUNNER
FROM node:latest AS RUNNER
...
WORKDIR ./app
COPY --from=BUILDER ./build ./

Multistage build con Typescript

Para ilustrar un caso de uso real, vamos a utilizar una aplicación sencilla escrita en Typescript con Express. Como se está usando Typescript, existe una fase previa de compilación del código typescript a javascript. Ademas, vamos a generar la imagen final con los módulos de node estrictamente necesarios para la aplicación, dejando fuera los ficheros @types y el compilador de typescript. Así tendremos la imagen final lo mas pequeña posible.

La aplicación escuchara por el puerto 3333 a peticiones GET y devolverá la cadena “Hello world” a todas las peticiones. Aquí dejo el enlace a github para ver la aplicación al detalle.

Vamos a por el Dockerfile:

FROM node:latest as BUILDER

WORKDIR /app

COPY ./app ./

RUN npm install
RUN npm run compile
RUN rm -rf node_modules

FROM node:latest as FINAL

WORKDIR /app

COPY --from=BUILDER ./app/build/index.js ./app/package.json ./

RUN npm install --prod

CMD ["node", "index.js"]

Por supuesto los nombres dados a los contenedores en cada fase pueden ser los que queramos.

Fase BUILDER

En la primera fase, el BUILDER, se hacen los siguientes pasos:

  1. Cargamos la imagen base node:latest con FROM y establecemos el nombre de la fase con AS BUILDER
  2. Establecemos el directorio de trabajo del contenedor con WORKDIR hacia /app
  3. Copiar toda la aplicación de la máquina local hacia el contenedor con COPY
  4. Instalar todos los módulos de node declarados en package.json
  5. Lanzar la compilación. (Hay un script declarado en package.json que llama al compilador)
  6. Borrar los node_modules generados en este contenedor, ya que contienen las dependencias de desarrollo.

Fase FINAL

Esta es la fase final que contendrá todos los ficheros del contenedor.

  1. Cargar imagen base con FROM node:latest AS FINAL
  2. De nuevo establecer el directorio del contenedor con WORKDIR en /app
  3. Copiar los ficheros necesarios para la aplicación: package.json y la aplicación compilada
  4. Instalar las dependencias de producción.
  5. Establecer el comando por defecto del contenedor.

Build y run

Ahora que hemos definido los pasos queda construir el contenedor y lanzarlo.

docker build -t ts_multistage:v1 .
docker run -p 3333:3332 ts_multistage:v1 

Para probarlo hacemos una petición http con curl

$ curl -v localhost:3333

*   Trying 127.0.0.1:3333...
* Connected to localhost (127.0.0.1) port 3333 (#0)
> GET / HTTP/1.1
> Host: localhost:3333
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 11
< ETag: W/"b-e1AsOh9IyGCa4hLN+2Od7jlnP14"
< Date: Mon, 08 Nov 2021 01:10:47 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
* Connection #0 to host localhost left intact
Hello world%      

Bola extra: Multistage builds en c++

También se puede utilizar las multistage para aplicaciones de c++. Una utilidad practica que le veo a compilar usando contenedores, es compilar el código fuente con nuevas versiones de gcc, sin tener que esperar que los nuevos compiladores lleguen a nuestra distribución.

En este ejemplo, estamos construyendo un contenedor cuya misión es compilar el código fuente (Fase Builder) y un último contenedor que ejecutará los binarios (FASE FINAL)

Tan solo necesitamos tres ficheros: main.cpp, Makefile y un Dockerfile

main.cpp

#include <iostream>
using namespace std;

int main(void) {
    cout << "Hello world" << endl;
    return 0;
}

Makefile

CC=g++
CPPFLAGS=-g -Wall -std=c++11

main.out: main.cc
 ${CC} ${CPPFLAGS} $^ -o $@

clean:
 rm -f *.o *.out

Dockerfile

FROM gcc:latest as BUILDER

WORKDIR /build

COPY main.cc Makefile ./

RUN apt-get update && \
    apt-get install make

RUN make

FROM ubuntu:latest AS FINAL

WORKDIR /app

COPY --from=BUILDER /build/main.out /app/

CMD ["./main.out"]

https://docs.docker.com/develop/develop-images/multistage-build/