ASP.NET Core: Error de npm al generar una imagen Docker de un proyecto creado con la plantilla de React

He visto este problema con un proyecto generado a partir de la plantilla de SPA de React, pero quizá puede aplicar a otras plantillas de SPA (como Angular).

El error se puede reproducir muy fácilmente. Desde un directorio vacío puedes crear una SPA de react:

dotnet new react --name testspa
dotnet new sln --name testspa
dotnet sln add testspa\testspa.csproj

Con eso tenemos el proyecto «testspa.csproj» y una solución de Visual Studio (testspa.sln) para abrirla con Visual Studio y que este nos genere el Dockerfile. Para ello abre la solución con Visual Studio y usa la opción «Add -> Docker Support» para que Visual Studio nos cree el Dockerfile:

Opción Add -> Docker Support

Eso nos creará el «Dockerfile» estándard:

FROM microsoft/dotnet:2.2-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM microsoft/dotnet:2.2-sdk AS build
WORKDIR /src
COPY ["testspa/testspa.csproj", "testspa/"]
RUN dotnet restore "testspa/testspa.csproj"
COPY . .
WORKDIR "/src/testspa"
RUN dotnet build "testspa.csproj" -c Release -o /app

FROM build AS publish
RUN dotnet publish "testspa.csproj" -c Release -o /app

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "testspa.dll"]

A priori parece todo correcto, y es que este esquema multistage es «generalmente correcto» para la gran mayoría de proyectos netcore:

  1. Usa la imagen del SDK para ejecturar un «dotnet restore» y un «dotnet build»
  2. Luego realiza el «dotnet publish» en un nuevo stage
  3. Finalmente copia el resultado del publish en una imagen que parte del runtime

El problema es que… no funciona. Lo puedes comprobar tu mismo lanzando el siguiente comando:

docker build -t testspa -f testspa\Dockerfile .

Este comando debes lanzarlo desde el directorio raíz (el directorio donde está el fichero sln, no el fichero csproj). Eso es porque los Dockerfile que genera VS toman como contexto de build el directorio donde está la solución, no cada uno de los proyectos. No es algo que personalmente me guste, pero VS lo hace porque es la única manera de poder generar imágenes de proyectos que tengan referencias a otros proyectos de la solución.

Bueno… al cabo de un ratillo la build reventará con ese error:

Step 11/17 : RUN dotnet build "testspa.csproj" -c Release -o /app
 ---> Running in eccd6da4ab29
Microsoft (R) Build Engine version 15.9.20+g88f5fadfbe for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 795.87 ms for /src/testspa/testspa.csproj.
The command '/bin/sh -c dotnet build "testspa.csproj" -c Release -o /app' returned a non-zero code: 137

¿La razón del fallo? Pues que la imagen del SDK de netcore no contiene nodejs. ¿Y porque se necesita nodejs? Pues porque quien generó la plantilla de proyecto pensó que era buena idea que al publicar el proyecto (con «dotnet publish») se ejecutasen las sentencias npm necesarias para generar los bundles de javascript. Y por eso tenemos lo siguiente en el fichero csproj:

<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
  <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
  <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />

  <!-- Include the newly-built files in the publish output -->
  <ItemGroup>
    <DistFiles Include="$(SpaRoot)build\**" />
    <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
      <RelativePath>%(DistFiles.Identity)</RelativePath>
      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </ResolvedFileToPublish>
  </ItemGroup>
</Target>

Esta tarea se lanza en el proceso de publicación (no de build) y observa como se ejecuta el «npm install» y el «npm run build» que son las tareas necesarias para construir los bundles de JavaScript.

Por lo tanto en tu máquina un «dotnet publish» funcionará, ya que tendrás node instalado, pero cuando eso se ejecuta en la imagen Docker del SDK, eso no funciona porque nodejs no existe.

¿Como podemos solucionarlo? Por suerte la solución es bastante fácil. Primero editamos el fichero csproj para que esta tarea se ejecute solo si una determinada variable de entorno no existe:

<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish" Condition=" '$(BuildingDocker)' == '' ">

Observa el «Condition«, para que se ejecute solo si la variable BuildingDocker tiene algún valor (da igual cual).  De este modo en nuestra máquina local todo seguirá funcionando igual. Vayamos ahora a por el Dockerfile. Ojo con la sintaxis de Condition que msbuild es muy puñetero y deben respetarse los espacios en blanco.

El Dockerfile hay que modificarlo bastante. Por un lado debemos usar la imagen del SDK de netcore para compilar el proyecto, pero también necesitamos la imagen de node para las tareas de npm. Finalmente el resultado de ambos stages los combinaremos en una imagen con el runtime de netcore:

FROM microsoft/dotnet:2.2-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

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

FROM microsoft/dotnet:2.2-sdk AS build
ENV BuildingDocker true
WORKDIR /src
COPY ["testspa/testspa.csproj", "testspa/"]
RUN dotnet restore "testspa/testspa.csproj"
COPY . .
WORKDIR "/src/testspa"
RUN dotnet build "testspa.csproj" -c Release -o /app

FROM build AS publish
RUN dotnet publish "testspa.csproj" -c Release -o /app

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
COPY --from=build-node /ClientApp/build ./ClientApp/build
ENTRYPOINT ["dotnet", "testspa.dll"]

En el stage build-node usamos la imagen de Node para ejecutar las tareas «npm install» y «npm run build» que construyen los bundles y los dejan en ClientApp/build.

Luego en el stage build, usamos el SDK de dotnet para hacer el «dotnet build» y el «dotnet publish» pero observa como usamos ENV para definir la variable de entorno «BuildingDocker» con el valor de true. Gracias a tener definida esa variable de entorno en el contenedor, la tarea PublishRunWebpack del csproj no se ejecuta, por lo que no recibiremos error alguno.

Finalmente en el último stage combinamos la parte generada por nodejs (ClientApp/build) y la parte generada por el dotnet publish (todo lo demás) para tener toda la aplicación junta.

Conclusiones

Nada nuevo, pero recuerda que los Dockerfiles que crea Visual Studio son poco más que una plantilla que funciona en un gran número de casos pero hay varias casuísticas en las que los ficheros generados no funcionan correctamente. Otra cosa que debes recordar es que en general, dado que se intenta que las imágenes sean lo más pequeñas posibles, se dedican a hacer una sola cosa (tómatelo como un SRP aplicado a las imágenes de Docker): por eso la imagen del SDK de ASP.NET Core no tiene nodejs. En casos de qué combines varias herramientas para la construcción de un proyecto, en Docker lo que se hace es combinar N imágenes en N stages y al final agregar los resultados.

¡Un saludo!

Deja un comentario

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