Reduce el tamaño de tus imágenes con Dockerfiles multi-stage

Cuando imparto las sesiones de Docker en el bootcamp DevOps de Lemoncode soy súper pesada repitiendo una y otra vez lo importante que es que las imágenes de nuestras aplicaciones sean lo más pequeñas posible. Esto debe convertirse en casi una obsesión si queremos que nuestra aplicación tenga éxito por varios motivos, ya que cuanto más pequeña sea:

  1. Menos tiempo tardará en descargarse.
  2. Menos tardará en cargar en memoria, ya que tendrá solo lo necesario.
  3. Menores serán las vulnerabilidades a las que podamos estar expuestos.

Es por ello que hoy quiero hablarte de los Dockerfiles multi-stage y cómo nos ayudan a cumplir este objetivo.

La aplicación de ejemplo

Para poder explicarte todo esto, vamos a verlo con un ejemplo súper simple en Node.js, y una necesidad de testing, ya que para que puedas entender esto con Docker, debes poder entenderlo sin ello 🙂 Esta es la aplicación que voy a contenerizar (el código del ejemplo lo tienes en mi GitHub):

/* global __dirname */
var express = require('express');
var app = express();

app.get('/', function (req, res) {
  res.sendFile(__dirname + '/index.html')
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

Básicamente es el código de ejemplo cuando empiezas con Express.js 🙂 Por otro lado, he añadido las siguientes dependencias como parte de la aplicación:

{
  "name": "hello-world",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "start-dev": "nodemon server.js",
    "test": "./node_modules/.bin/eslint ."
  },
  "keywords": [],
  "author": "Gisela Torres",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "eslint": "^7.32.0",
    "nodemon": "^2.0.12"
  }
}

Como ves, solo necesito el módulo de express en el entorno productivo, pero utilizo nodemon para visualizar mis cambios rápidamente y ESLint para comprobar que el código que está escrito siga las buenas prácticas. De hecho, después de ejecutar npm install, si lanzo npm test veré que tengo algunos fallitos en mi código:

Ejecución de eslint sin dockerizar

Esto ocurre porque tengo además un archivo llamado .eslintrc.js donde he configurado qué reglas quiero que se cumplan en mi código (en este ejemplo, tener que poner el punto y coma después de cada sentencia y utilizar comillas dobles para las cadenas):

module.exports = {
    "env": {
        "browser": true,
        "commonjs": true,
        "es2021": true
    },
    "extends": "eslint:recommended",
    "parserOptions": {
        "ecmaVersion": 12
    },
    "rules": {
        "semi" : ["error","always"], 
        "quotes": ["error", "double"],
        "no-debugger": ["error"],
        "no-console": ["warn"]
    },
    "globals": {
        "_": false
    }
};

Ahora bien, para dockerizarlo puedo generarme la definición del Dockerfile de forma manual o incluso puedo utilizar la extensión de Visual Studio Code, que me ayuda con el proceso. En cualquier caso, un Dockerfile productivo, proporcionado por VS Code, es como el siguiente:

#This is the image that I use as base
FROM node:14-alpine

ENV NODE_ENV production

#Set the /app path as the working directory to host my application, install dependencies, etc..
WORKDIR /app

#Copy the files package.json and package-lock.json(the asterisk if it exists) in the root of my working directory, /app.
COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"]

#Install the dependencies of my application, by executing the command npm install
RUN npm install --silent --production && mv node_modules ../

#Copy the rest of the files to the /app directory
COPY . .

#Add as metadata the port my application listens on
EXPOSE 3000

#Add one more metadata which is which command will be executed when a container is generated from this image
CMD ["npm", "start"]

Este como ves, tiene configurada la variable de entorno NODE_ENV a production y en la instalación de los paquetes con npm install se especifica el parámetro –production, para que solo se instalen aquellas librerías que estén en el apartado dependencies pero no en devDependencies. Cuando generas una imagen con esta configuración:

docker build -t hello-world:prod .

Esto produce una con un tamaño de 120MB:

Peso de la imagen hello-world:prod

El problema en este caso es que si quiero ejecutar ESLint, para que antes de generarse la imagen se compruebe que la misma va a cumplir con las reglas que he añadido en el archivo.eslintrc.js, necesito instalar también las dependencias del apartado devDependencies, por lo que debería modificar mi Dockerfile de la siguiente manera:

#This is the image that I use as base
FROM node:14-alpine

# ENV NODE_ENV production

#Set the /app path as the working directory to host my application, install dependencies, etc..
WORKDIR /app

#Copy the files package.json and package-lock.json(the asterisk if it exists) in the root of my working directory, /app.
COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"]

#Install the dependencies of my application, by executing the command npm install
# RUN npm install --silent --production && mv node_modules ../
RUN npm install

#Copy the rest of the files to the /app directory
COPY . .

#Execute the tests
RUN npm run test

#Add as metadata the port my application listens on
EXPOSE 3000

#Add one more metadata which is which command will be executed when a container is generated from this image
CMD ["npm", "start"]

Si haces esto, ahora verás que durante la generación de la imagen se ejecutará ESLint para comprobar que todo está OK (o no):

Generación de la imagen con las devDependencies y ejecutando npm test

Como ves, el proceso se para porque ocurren los mismos errores que te pasaron antes de dockerizar la aplicación. Esto está bien porque nos aseguramos de que el código contenido en la nueva imagen no está de cualquier manera. Por supuesto, esta comprobación también podrías hacerla en dos pasos, tanto en local como en tu herramienta de CI/CD. Es decir, podrías primero lanzar npm run test y posteriormente, si todo va bien, ejecutar docker build. Sin embargo, si queremos que todo esté auto contenido esta sería la forma. Así obligas a los desarrolladores en su entorno local a que, para poder crear la imagen, sigan las reglas establecidas. El problema viene ahora cuando, si comentas las reglas que están produciendo estos errores:

module.exports = {
    "env": {
        "browser": true,
        "commonjs": true,
        "es2021": true
    },
    "extends": "eslint:recommended",
    "parserOptions": {
        "ecmaVersion": 12
    },
    "rules": {
        // "semi" : ["error","always"], 
        // "quotes": ["error", "double"],
        "no-debugger": ["error"],
        "no-console": ["warn"]
    },
    "globals": {
        "_": false
    }
};

Y ejecutas de nuevo la generación de la imagen:

docker build -t hello-world:dev . -f Dockerfile.dev

esta acabará correctamente pero fíjate que el tamaño de la nueva versión, hello-world:dev, es de 138MB, 18MB más respecto a la que solo tiene las dependencias productivas:

Peso de la imagen hello-world:dev

En este caso, por haber querido ejecutar ESLint, hemos conseguido una imagen con un peso mayor, que por un lado nos asegura que mi código está bien escrito pero por otro me está perjudicando en el tamaño final de la imagen, además de traer ciertos riesgos de seguridad, si alguna de esas dependencias resulta tener algún tipo de vulnerabilidad. Aquí es donde entra en juego los Dockerfile multi-stage.

Dockerfile multi-stage

Para hacer que estas dos necesidades convivan lo que hacemos es uso de lo que se conoce como un Dockerfile Multi-stage. Lo que nos permite es poder usar varias instrucciones de tipo FROM dentro de nuestro Dockerfile, lo que genera diferentes fases dentro del proceso de creación de la imagen. La gracia está en que en el último FROM, o fase, puedes tener una imagen más ligera, ya que todas las herramientas que has ido instalando por el camino se han quedado en fase anteriores que no formarán parte de la imagen final, ya que se van creado contenedores intermedios que ejecutan las herramientas y configuraciones que hemos ido necesitando en algún momento del proceso. Vamos a verlo con el ejemplo que hemos seguido hasta ahora:

# ---- Base Node ----
FROM node:14-alpine AS base
# set working directory
WORKDIR /app

#
# ---- Dependencies ----
FROM base AS dependencies
# Copy project file
COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"]

#
# ---- Test ----
# run linters, setup and tests
FROM dependencies AS test
# Install ALL node_modules, including 'devDependencies'
RUN npm install
COPY . .
#Execute the tests
RUN  npm run test


#
# ---- Release ----
FROM base AS release
#Add environment variables
ENV NODE_ENV production
# copy production node_modules
COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] --from=dependencies
#Install app dependencies
RUN npm install --silent --production && mv node_modules ../
# copy app sources
COPY . .
# expose port and define CMD
EXPOSE 3000
#specify what command it'll execute when you create a container
CMD ["npm", "start"]

En este Dockerfile podemos decir que tenemos 4 instrucciones FROM, por lo que tenemos cuatro fases:

  1. base: en esta fase definimos cuál será la imagen que usaremos en la imagen final, así como el directorio de trabajo.
  2. dependencies: la utilizamos para copiar los archivos que definen las dependencias de nuestra aplicación. Como ves, en este caso nos apoyamos en la fase anterior, tomando todo lo hecho en base.
  3. test: en este caso lo que hacemos es usar lo generado en la fase dependencies para nuestro FROM, instalamos todas las dependencias, las productivas y las de desarrollo, para posteriormente poder lanzar npm run test y poder ejecutar ESLint. Si todo va bien, continuará con la siguiente fase. De no ser así, el proceso se parará aquí.
  4. release: si todo lo anterior ha terminado con éxito, en la fase final nos apoyamos en la fase base, pero copiamos de la fase dependencies los archivos relacionados con las dependencias en el directorio de trabajo. Sin embargo, no queremos coger los archivos descargados en la fase de test, porque tiene más de lo que necesitamos para producción (los módulos nodemon y ESLint), por lo que hacemos de nuevo la instalación pero solo con lo productivo. Para finalizar, añadimos el resto de archivos y los metadatos generados por EXPOSE y CMD.

Para ver el resultado, lanza la generación de la imagen, con la etiqueta multi-stage (por comparar) con el siguiente comando:

DOCKER_BUILDKIT=0 docker  build -t hello-world:multi-stage . -f Dockerfile.multistage

Nota: En este ejemplo estoy deshabilitando buildkit (DOCKER_BUILDKIT=0) ya que en las últimas versiones todo aquel stage que no afecte al final se obviará, pero hay escenarios como el que explico donde no quiero que se salten, ya que se utilizan para la parte de testing. Existen diferentes formas de hacer que eso no ocurra, pero para este artículo la más sencilla es simplemente deshabilitando esta opción.

Ahora en el output de la generación de esta imagen será como el siguiente:

Salida de la generación de la imagen con el Dockerfile multi-stage

Como resultado tendrás una imagen del mismo tamaño que la de hello-world:prod, aunque hayas hecho por el camino uso de las dependencias de hello-world:dev:

El peso de la imagen hello-world:prod y hello-world:multi-stage es el mismo

Por otro lado, como ves en la salida del comando docker images, ahora tienes varias imágenes sin nombre. Estas son las generadas en las fases intermedias y se conocen como danglings. Si quisieras eliminarlas de forma rápida puedes hacerlo con este comando:

docker image prune

El código de ejemplo lo tienes en mi GitHub.

¡Saludos!

logo lemoncode

 

 

Bootcamp DevOps

Si tienes ganas de meterte en el área de DevOps, formo parte del
equipo de docentes del Bootcamp DevOps Lemoncode, ¿Te animas a
aprender con nosotros?

Más Info