Docker multi-stage build y Windows Server Containers

Trabajar con Windows Server Containers puede suponer un reto en lo que a tamaño de imagen se refiere. Sin embargo, el multi-stage build de Docker viene a ponernos las cosas un poco más fáciles a la hora de optimizar su tamaño.

6 min de lectura
Docker multi-stage build y Windows Server Containers

Ya he hablado en otras ocasiones acerca de los Windows Server Containers. Gracias al gran esfuerzo que Microsoft ha invertido en transformar Windows para que sea compatible con esta tecnología, podemos dockerizar también nuestras aplicaciones Windows de una forma totalmente similar a como lo hacemos con Linux.

Generando Dockerfiles a partir del código fuente de una aplicación

Cuando generamos un Dockerfile, una aproximación realmente común es que este se conecte a un repositorio de código, descargue la última versión de la aplicación, la compile, elimine las herramientas de desarrollo y finalmente genere una imagen que arranca el binario que hemos generado.

Un ejemplo de este proceder se puede encontrar en las imágenes basadas en Linux que servidor mismamente construye, como puede ser la de rAthena, que podemos encontrar en Github. Veamos la parte del Dockerfile que hace las operaciones que he mencionado:

RUN mkdir -p /opt/rAthena \
    && apk update \
    && apk add --no-cache git make gcc g++ mariadb-dev mariadb-client-libs zlib-dev pcre-dev libressl-dev pcre libstdc++ nano dos2unix mysql-client bind-tools \
    && git clone https://github.com/rathena/rathena.git /opt/rAthena \
    && cd /opt/rAthena \
    && if [ ${PACKET_OBFUSCATION} -neq 1 ]; then sed -i "s|#define PACKET_OBFUSCATION|//#define PACKET_OBFUSCATION|g" /opt/rAthena/src/config/packets.hpp; fi \
    && if [ ${PACKET_OBFUSCATION} -neq 1 ]; then sed -i "s|#define PACKET_OBFUSCATION_WARN|//#define PACKET_OBFUSCATION_WARN|g" /opt/rAthena/src/config/packets.hpp; fi \
    && ./configure --enable-packetver=${PACKETVER} \
    && make clean \
    && make server \
    && chmod a+x login-server && chmod a+x char-server && chmod a+x map-server \
    && apk del git make gcc g++ mariadb-dev zlib-dev pcre-dev libressl-dev

Efectivamente, instalamos todas las herramientas de desarrollo y compiladores, clonamos el repositorio de rAthena, iniciamos la compilación y finalmente limpiamos todas las herramientas de desarrollo para que no engorde innecesariamente nuestra imagen Docker, que siendo Alpine Linux ocuparía sólo unos 5 MB de base.

Hasta aquí todo ok, ¿verdad? ¡Vamos a Windows Server Containers!

Compilando nuestra aplicación desde Windows Server Containers

Conceptualmente podemos hacer exactamente lo mismo en Windows Server Containers, pero... Hay algunas cosas a tener en cuenta.

La primera de todas es que cuando estamos en Windows, es relativamente frecuente que la aplicación a compilar esté desarrollada con Visual Studio. Visual Studio provee de las herramientas necesarias para que nuestra aplicación pueda ser compilada desde línea de comando y sin intervención del IDE gráfico, todo orquestado por msbuild.exe.

¡Pero vamos más allá! Microsoft provee de un paquete de instalación con todas estas herramientas -que son las que nos interesan en nuestro Dockerfile- sin incluir el IDE de Visual Studio.

Así, pues, ¿qué aspecto tendría un Dockerfile con estas herramientas de compilación? Microsoft nos ofrece uno oficial, y nos advierte que debe construirse agregando el modificador -m 2GB para que nuestro contenedor no se quede sin memoria RAM durante el proceso de instalación (el valor por defecto es 1 GB), quedando el comando de la siguiente manera: docker build -t buildtools2019:latest -m 2GB .

# escape=`

# Use the latest Windows Server Core image with .NET Framework 4.8.
FROM mcr.microsoft.com/dotnet/framework/sdk:4.8-windowsservercore-ltsc2019

# Restore the default Windows shell for correct batch processing.
SHELL ["cmd", "/S", "/C"]

# Download the Build Tools bootstrapper.
ADD https://aka.ms/vs/16/release/vs_buildtools.exe C:\TEMP\vs_buildtools.exe

# Install Build Tools excluding workloads and components with known issues.
RUN C:\TEMP\vs_buildtools.exe --quiet --wait --norestart --nocache `
    --installPath C:\BuildTools `
    --all `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.10240 `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.10586 `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.14393 `
    --remove Microsoft.VisualStudio.Component.Windows81SDK `
 || IF "%ERRORLEVEL%"=="3010" EXIT 0

# Start developer command prompt with any other commands specified.
ENTRYPOINT C:\BuildTools\Common7\Tools\VsDevCmd.bat &&

# Default to PowerShell if no other command specified.
CMD ["powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]

Aquí nos encontramos con dos situaciones interesantes:

  • A diferencia de como nos ocurría con Linux, no es tan trivial y directo eliminar limpiamente todas las herramientas de compilación. Podemos invocar de nuevo el instalador, pero sabiendo que no podemos permitirnos reinicios y que invertiremos otros 20 minutos esperando su instalación, no parece un proceso especialmente óptimo...
  • La imagen resultante "lista para compilar" ocupa la friolera de 36 GB aproximadamente.

dockermultistage_windows1-1

¡Estamos ante un caso de libro en el que nos podemos beneficiar del Multi-Stage Build de Docker!

Docker multi-stage build

El multi-stage build es una característica que tiene Docker desde su versión 17.05 y que permite ni más ni menos que incorporar más de un FROM en un único Dockerfile, habitualmente con la finalidad de generar una imagen previa que lleve a cabo un trabajo cuyo resultado incorporemos a la imagen final. El objetivo no es otro que construir imágenes Docker eficientes de forma más sencilla y legible.

En el ámbito de los Windows Server Containers, ya hemos visto como las herramientas de desarrollo de .NET Standard nos ocupan la friolera de 36 GB, y teniendo en cuenta que la imagen base de Windows Server Core ocupa 8 GB aproximadamente, seguro que queremos ahorrarnos esos 28 GB restantes, ¿verdad?

Para ilustrar la situación he querido coger algún ejemplo simpático de aplicación .NET Standard para seguir todo el proceso, y he optado por el repo de Github NikolayIT/CSharpConsoleGames, que es una solución de Visual Studio que genera 5 juegos de consola hechos en .NET Standard.

¿Cómo quedaría esta aplicación en un multi-stage build? De la siguiente manera:

# escape=`

# STAGE 1
FROM mcr.microsoft.com/dotnet/framework/sdk:4.8-windowsservercore-ltsc2019 AS build
ARG GIT_DOWNLOAD_URL="https://github.com/git-for-windows/git/releases/download/v2.22.0.windows.1/Git-2.22.0-64-bit.exe"

SHELL ["cmd", "/S", "/C"]

ADD https://aka.ms/vs/16/release/vs_buildtools.exe C:\TEMP\vs_buildtools.exe

RUN C:\TEMP\vs_buildtools.exe --quiet --wait --norestart --nocache `
    --installPath C:\BuildTools `
    --all `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.10240 `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.10586 `
    --remove Microsoft.VisualStudio.Component.Windows10SDK.14393 `
    --remove Microsoft.VisualStudio.Component.Windows81SDK `
 || IF "%ERRORLEVEL%"=="3010" EXIT 0

WORKDIR C:\\TEMP

RUN curl.exe -L %GIT_DOWNLOAD_URL% --output git-setup.exe `
    && git-setup.exe /NORESTART /SILENT /LOG /SUPRESSMSGBOXES /RESTARTAPPLICATIONS `
    && del /q git-setup.exe

RUN git clone https://github.com/NikolayIT/CSharpConsoleGames.git `
    && cd CSharpConsoleGames\ `
    && C:\BuildTools\Common7\Tools\VsDevCmd.bat `
    && msbuild CSharpConsoleGames.sln /t:Clean,Build /m /p:Configuration=Release /p:Platform="Any CPU"

# STAGE 2
FROM mcr.microsoft.com/dotnet/framework/runtime:4.8

COPY --from=build C:\\TEMP\\CSharpConsoleGames\\Tetris\\bin\\Release\\Tetris.exe C:\\CSharpConsoleGames\\Tetris.exe
COPY --from=build C:\\TEMP\\CSharpConsoleGames\\PingPong\\bin\\Release\\PingPong.exe C:\\CSharpConsoleGames\\PingPong.exe
COPY --from=build C:\\TEMP\\CSharpConsoleGames\\Cars\\bin\\Release\\Cars.exe C:\\CSharpConsoleGames\\Cars.exe
COPY --from=build C:\\TEMP\\CSharpConsoleGames\\Tron\\bin\\Release\\Tron.exe C:\\CSharpConsoleGames\\Tron.exe
COPY --from=build C:\\TEMP\\CSharpConsoleGames\\Snake\\bin\\Release\\Snake.exe C:\\CSharpConsoleGames\\Snake.exe

WORKDIR C:\\CSharpConsoleGames\\
RUN echo @ECHO OFF > menu.bat `
    && echo ECHO CSharpConsoleGames: https://github.com/NikolayIT/CSharpConsoleGames >> menu.bat `
    && echo ECHO =================================================================== >> menu.bat `
    && echo ECHO 1. Tetris >> menu.bat `
    && echo ECHO 2. PingPong >> menu.bat `
    && echo ECHO 3. Cars >> menu.bat `
    && echo ECHO 4. Tron >> menu.bat `
    && echo ECHO 5. Snake >> menu.bat `
    && echo ECHO ======================== >> menu.bat `
    && echo SET /P GAME="Select game: " >> menu.bat `
    && echo IF %GAME%==1 ( >> menu.bat `
    && echo     tetris.exe >> menu.bat `
    && echo ) ELSE IF %GAME%==2 ( >> menu.bat `
    && echo     PingPong.exe >> menu.bat `
    && echo ) ELSE IF %GAME%==3 ( >> menu.bat `
    && echo     Cars.exe >> menu.bat `
    && echo ) ELSE IF %GAME%==4 ( >> menu.bat `
    && echo     Tron.exe >> menu.bat `
    && echo ) ELSE ( >> menu.bat `
    && echo     Snake.exe >> menu.bat `
    && echo ) >> menu.bat

CMD C:\\CSharpConsoleGames\\menu.bat

Si observamos el resultado, no hay tanta ciencia de cohetes aquí. En el STAGE 1 generamos una imagen Docker con el SDK de .NET Standard, al cual le incorporamos las herramientas de compilación de Visual Studio y git. Tras ello clonamos el repositorio de Github e iniciamos la compilación con msbuild CSharpConsoleGames.sln /t:Clean,Build /m /p:Configuration=Release /p:Platform="Any CPU". Hecho esto, no necesitamos la imagen de desarrollo para nada más.

En el STAGE 2 cambiamos la imagen base de SDK a Runtime y copiamos los binarios generados de la imagen anterior a la nueva. Y así automáticamente descartamos la imagen del STAGE 1, que sirvió a su proposito y nos quedamos con la del Runtime. ¿Y qué conseguimos? Una imagen que "solo" ocupa 7,8 GB, que si bien en términos de Docker se puede antojar enorme, es bastante menos que los 36 GB de las herramientas de desarrollo.

dockermultistage_windows2

Conclusiones

El multi-stage build es una característica de Docker que nos hace la vida mucho más fácil a la hora de construir imágenes óptimas, y aunque tiene muchísimos usos desde el punto de vista de Linux, realmente brilla cuando trabajamos con contenedores basados en Windows.

Happy dockering!