Generar imágenes Docker de proyectos SPA de netcore

¡Buenas!

Cuando creas un proyecto SPA de netcore, ya sea mediante VS o bien usando dotnet new y alguna plantilla SPA como react (dotnet new react), se genera una estructura parecida a la siguiente:

Estructura ficheros proyecto SPA

La carpeta “ClientApp” contiene todo el código de cliente (javascript, CSS y demás) mientras que el resto es el código netcore que se limita a “lanzar” la SPA.

En Startup.cs se usa el middleware UseSpa que es el encargado de servir el fichero “index.html” y adicionalmente permite usar servidores de desarrollo. P. ej. en el caso de una aplicación react el código es:

app.UseSpa(spa =>
{
    spa.Options.SourcePath = "ClientApp";

    if (env.IsDevelopment())
    {
        spa.UseReactDevelopmentServer(npmScript: "start");
    }
});

Básicamente le indicamos que busque el fichero “index.html” del directorio “ClientApp” y, solo si estamos en desarrollo, use el servidor de desarrollo de React.

Este servidor de desarrollo requiere node, por lo que, en desarrollo necesitamos tener NodeJs instalado. No es ningún problema, ya que si desarrollas una SPA con React, tendrás NodeJs instalado con toda probabilidad.

Ahora bien, en producción no hay necesidad alguna de usar Node: en este caso es imperativo que los bundles se hayan generado y estén en ClientApp/build. Si estamos ejecutando usando dotnet myspa.dll debemos haber generado nosotros los bundles manualmente: no lo hace VS por nosotros.

Pero si publicamos el proyecto (usando dotnet publishentonces sí que se nos generan los bundlesEso es debido a las siguientes líneas del 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>

Eso define una tarea propia, llamada “PublishRunWebpack”· que se ejecutará al publicar la aplicación (no al compilar, al publicar). Esta tarea hace tres cosas:

  1. Ejecuta npm install
  2. Ejecuta npm run build
  3. Añade los ficheros generados en ClientApp\build\ al resultado de publicación para que se copien al paquete de publicación (si no estos ficheros serían ignorados).

Esto funciona perfectamente pero requiere que la máquina que lanza el dotnet publish tenga node instalado. En determinados entornos no hay problemas (p. ej. en el caso de generar una build con VSTS el agente tiene el sdk de netcore y node por lo que todo funcionará), pero en Docker tendremos fricciones.

La razón es que la imagen del SDK de netcore, que usamos para compilar, no tiene nodejs preinstalado (antes de netcore 2.x lo tenía). Es una buena decisión: queremos imágenes pequeñas y nodejs no es necesario para la gran mayoría de proyectos de netcore. Una opción que puedes intentar es instalar nodejs en la imagen del sdk de netcore. De hecho tengo un gist que muestra como hacerlo, pero la realidad es que no es necesario. Es mucho mejor y más fácil usar una multi-stage build con dos pasos de build: en el primero usamos nodejs para crear los bundles, en el segundo netcore sdk para compilar y finalmente lo combinamos todo en un stage final de ejecución:

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

FROM microsoft/dotnet:2.2.100-preview3-sdk-stretch as build-net
WORKDIR /app
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet build
RUN dotnet publish -o /myweb

FROM microsoft/dotnet:2.2.0-preview3-aspnetcore-runtime
WORKDIR /web
COPY --from=build-net /ttweb .
COPY --from=build-node /ClientApp/build ./ClientApp/build
ENTRYPOINT [ "dotnet","myspa.dll" ]

Observa como en el stage “build-node” usamos npm install y npm run build para generar los bundles necesarios. Luego en el stage “build-net” usamos dotnet build y dotnet publish para publicar la web y en el stage “runtime” partimos de la imagen con el runtime de asp.net core y copiamos por un lado los bundles y por otro lado el código netcore compilado.

Y ¡voilá! Listos. Sencillo, ¿verdad? Pues sí, salvo que eso tal cual como está NO FUNCIONA.

¿La razón? Pues lo que he comentado antes: la máquina que hace el “dotnet publish” requiere de nodejs y la imagen microsoft/dotnet no lo incorpora. Por suerte la solución es super sencilla, basta con dos pasos:

Primero edita el csproj y haz que la tarea PublishRunWebpack sea condicional al valor de una variable, p. ej. llamada BuildingDocker:

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

Hemos añadido el atributo Condition. Así de esta manera sólo si BuildindDocker no está definida se ejecutará esta tarea. En caso contrario, esta tarea nos la saltaremos, que es justo lo que queremos en el caso de Docker.

Así, por supuesto el segundo paso es definir esa variable de entorno llamada BuildingDocker en el stage “build-net”:

FROM microsoft/dotnet:2.2.100-preview3-sdk-stretch as build-net
ENV BuildingDocker true
WORKDIR /app
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet build
RUN dotnet publish -o /ttweb

Observa el uso de ENV para definir lavariable de entorno BuildingDocker (ojo que el valor no es relevante, solo que esté definida, así que incluso si la defines con false la tarea no se ejecutará).

¡Y listos! Ahora sí que ¡ya puedes construir tus imágenes Docker de las aplicaciones SPA, sin ningún problema!

Deja un comentario

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