Usar vscode para depurar tus contenedores netcore

Una de las ventajas que tiene Visual Studio 2017 es el soporte de depuración para contenedores netcoreA partir de la versión 15.7 el soporte está relativamente maduro soportando algunos escenarios que daban errores en versiones anteriores (p. ej. dos servicios compose usando la misma imagen).

Pero… ¿y si no podemos/queremos usar Visual Studio? ¿Tenemos alguna alternativa? Pues sí: usar visual studio code, y aunque el workflow no es tan sencillo como en Visual Studio, al final se puede conseguir algo similar: ejecutar y depurar un contenedor. ¡Vamos allá!

Lo primero es, por supuesto, partir de un proyecto netcore. Vamos a hacerlo todo desde cero. En mi post uso .net core 2.1, pero en .net core 2.0 es casi lo mismo, salvo que en el Dockerfile cambiarían las imágenes base por supuesto. Es interesante (aunque no imprescindible) tener instalada la extensión de Docker para Visual Studio Code.

Lo primero es crear el proyecto netcore que vamos a usar, en este caso una API REST usando netcore. Vamos a ver el ejemplo con una API REST aunque es extrapolable al resto de proyectos. Así lo primero es situarnos en una carpeta vacía y usar «dotnet new webapi -n MyApi» para crear el API REST llamada «MyApi». Ahora ya podemos usar code y abrir la carpeta raíz. Si tienes la extensión de C# instalada (¿la tienes, verdad?) code te pedirá de añadir los assets necesarios para compilar y ejecutar el proyecto y te creará una carpeta .vscode con los ficheros necesarios para depurar el proyecto en local.

Importante: Si usas netcore2.1 comenta la siguiente línea de Startup.cs:

app.UseHttpsRedirection();

Esta línea fuerza siempre la redirección de http a https (además del uso de hsts en entornos productivos), pero eso nos dará más quebraderos de cabeza. En un futuro post veremos como podemos depurar nuestros contenedores que usen https, pero por simplicidad, en este primero no vamos a verlo.

Bueno, perfecto, podemos ejecutar y depurar la aplicación en vscode, eso no es nada nuevo. Ahora el siguiente paso es crear un Dockerfile y un fichero compose. Lo primero es crear el Dockerfile, que tenemos que hacerlo a mano (la extensión de Docker de vscode permite crear Dockerfiles, pero no soporta ni multi-staging ni netcore 2).

Así que nada, lo creamos a mano. Empezamos por un multi-stage tradicional de netcore:

ARG config=Debug
FROM microsoft/dotnet:2.1-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80

FROM microsoft/dotnet:2.1-sdk AS restore
WORKDIR /src
COPY MyApi.csproj .
RUN dotnet restore -nowarn:msb3202,nu1503

FROM restore as build
ARG config
COPY . .
RUN dotnet restore 
RUN dotnet build --no-restore -c $config -o /app

FROM build AS publish
ARG config
RUN dotnet publish --no-restore -c $config -o /app

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

Y el correspondiente fichero compose:

version: '3.4'

services:
  api:
    image: myapi
    build:
      context: MyApi
      dockerfile: Dockerfile
    ports:
      - 5000:80

(En mi caso, el fichero «docker-compose.yml» está en la carpeta raíz):

Bien, ahora podemos poner en marcha y parar nuestro contenedor, usando compose. Veamos ahora como enchufarlo a vscode.

Descargar el depurador remoto para Linux

Claro, necesitamos el depurador remoto de netcore, para poder depurar nuestros contenedores. Necesitamos la versión de Linux ya que nuestros contenedores son Linux.

Si tienes Visual Studio 2017 quizás lo tengas en tu carpeta de usuario. En mi caso lo tengo en c:\users\etoma\vsdbg\vs2017u5 ya que visual studio lo ha descargado allí.

Pero bueno, ningún problema, vamos a descargarlo en una ubicación conocida. Hay dos versiones del depurador remoto, la «antigua» llamada clrdbg y la nueva, llamada vsdbg. Vamos a descargarnos esa última:

curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v vs2017u5 -l ~/vsdbg

Efectivamente, las instrucciones son para linux, así que necesitas uno (https://aka.ms/getvsdbgsh se descarga un fichero .sh). Por supuesto podemos usar el WSL en Windows 10, así que nada. Abre un terminal WSL, vete a ~ y ejecutalo:

Usar WSL para instalar vsdbg

Bien, ahora solo debes copiar el contenido de ~/vsdbg a tu perfil de usuario de windows, usando (usa tu perfil de usuario, claro):

cp vsdbg/ /mnt/c/Users/etoma/vsdbg-core -r

Y ahora en tu perfil de usuario de Windows tendrás la carpeta vsdbg-core con el depurador remoto.

«Simular» lo que hace VS2017

Para depurar contenedores VS2017 lo que hace es tunear los contenedores (a partir de un docker-compose que gestiona él) y añadir algunos volúmenes (el código fuente, el depurador remoto y la cache de nuget). Nosotros podemos simular lo mismo «a mano». Así, create otro fichero compose y llámalo docker-compode.debug.yml con el siguiente contenido:

version: '3.4'

services:
  api:
    image: myapi:dev
    build:
      target: base
    labels:
      - "com.microsoft.visualstudio.targetoperatingsystem=linux"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_USE_POLLING_FILE_WATCHER=1
    volumes:
      - .:/app
      - ~/.nuget/packages:/root/.nuget/packages:ro
      - ~/vsdbg-core:/vsdbg:ro
    entrypoint: tail -f /dev/null

Este fichero compose intenta simular lo que hace VS2017:

  • Forzamos el tag dev en la imagen
  • Importante: Indicamos que el stage a usar es base. Eso hace que NO SE ejecute ninguno de los otros stages del Dockerfile. Observa que el stage «base» básicamente parte de la imagen de runtime de netcore
  • Añadimos variables de entorno para forzar el entorno de desarrollo y usar el file watcher
  • Dado que partimos del stage «base» que es, básicamente, la imagen del runtime de netcore usamos volúmenes para
    • Mapear el directorio local con el código al directorio /app del contenedor
    • Mapear el directorio de cache de paquetes de nuget local al directorio de la cache de paquetes de nuget del contenedor
    • Y por último, mapeamos el directorio donde tenemos el depurador remoto de netcore a un directorio del contenedor.
  • Finalmente establecemos tail -f /dev/null como entrypoint del cotnenedor. Esto lo que hace es que «el contenedor se espera eternamente».

Recapitulemos: si usamos este nuevo compose, tendremos un contenedor que es básicamente la imagen del runtime de netcore, con los ficheros de código fuente mapeados, con el depurador remoto instalado y ejecutándose ad-eternum.

Puedes verificar que todo funciona usando:

docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d

Y luego con un «docker ps» deberías ver el contenedor myapi:dev. Si abres una sesión interactiva contra él (docker exec -it <id-contenedor> /bin/bash) verás los ficheros en él:

Genial. Nuestro contenedor de depuración está listo. Vamos a configurar code para usarlo y enchufarse a él 🙂

Para ello debemos editar el fichero .vscode/launch.json y añadir una nueva entrada:

{
    "name": ".NET Core Attach (docker)",
    "type": "coreclr",
    "request": "launch",
    "preLaunchTask": "build",
    "program": "/app/MyApi/bin/Debug/netcoreapp2.1/MyApi.dll",
    "args": [],
    "cwd": "/app/MyApi",
    "stopAtEntry": false,
    "console": "internalConsole",
    "pipeTransport": {
        "pipeCwd": "${workspaceRoot}",
        "pipeProgram": "docker",
        "pipeArgs": [
            "exec", "-i", "dockercode_api_1" ,"${debuggerCommand}"
         ],
         "quoteArgs": false,
        "debuggerPath": "/vsdbg/vsdbg"
    }
},

Comento las partes más importantes:

  • program: Programa a ejecutar dentro del contenedor
  • cwd: Directorio de ejecución dentro del contenedor
  • pipeTransport: Las opciones para usar docker
    • pipeProgram: Indicamos que usaremos docker
    • pipeArgs: Array de argumentos que le pasamos a docker. Básicamente es ejecutar el depurador remoto, en el contenedor. Observa que tenemos el nombre del contenedor (dockercode_api_1). La variable $debuggerCommand la pasa Visual Studio Code y contiene el comando para lanzar el depurador remoto.
    • quoteArgs: Ponlo a false, si no lo pones recibirás errores de sintaxis y/o directorios no encontrados
    • debuggerPath: Ubicación del ejecutable vsdbg en el contenedor

Ahora ya podemos probarlo. Para ello debemos:

  1. Ejecutar el docker-compose up desde una terminal. Esto crea el contenedor que, recuerda, se queda levantado ad-eternum o sea que te puedes olvidar de él.
  2. Para depurar selecciona la opción «.NET Core Attach (docker)» y listos. Visual Studio code compilará tu proyecto y lo depurará en el contenedor.

Hay dos problemas con esta aproximación, uno poco importante y otro más chungo que no he podido resolver.

El primero es que necesitas una definición en launch.json para cada contenedor, ya que el nombre de este está a fuego. No hay manera (que yo sepa) de que vscode te «pregunte» por qué contenedor quieres depurar (recuerda que, básicamente, son todos ellos idénticos).

Vale y ahora… el segundo problema

No he podido conseguir  hacer funcionar el soporte multi-contenedor 🙁

Pues eso… que no he sido capaz de depurar una solución multi-contenedor (entiéndase eso por depurar N contenedores a la vez). Os cuento la aproximación que he seguido, el error que me da y el porque creo que es debido.

Mi intención era aprovecharme de las compound launch actions de visual studio code. Para la prueba necesitamos crear otro proyecto y lo llamamos ApiServerModificamos el código de ValuesController para que sea:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    // GET api/values
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new {
            Server = Environment.MachineName,
            Date = DateTime.UtcNow
        });
    }
}

Ahora modificamos el proyecto api original para que en ValuesController llame a ApiServer usando HttpClient:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly string _serverUrl;
    public ValuesController(IConfiguration cfg) => _serverUrl = cfg["server"];
    // GET api/values
    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var client = new HttpClient();
        var response = await client.GetAsync(_serverUrl + "/api/values");
        var json = await response.Content.ReadAsStringAsync();
        dynamic serverData = JObject.Parse(json);
        return Ok(new {
            serverData = serverData,
            client =  Environment.MachineName
            });
    }
}

Ahora debemos crear un Dockerfile para este segundo proyecto. De hecho es idéntico al Dockerfile anterior, solo cambiando el entrypoint y el COPY del csproj.

Luego modificamos el fichero compose para agregar el nuevo servicio y añadir la entrada server que requiere la api:

version: '3.4'

services:
  api:
    image: myapi
    build:
      context: MyApi
      dockerfile: Dockerfile
    ports:
      - 5000:80
    environment:
      - server=http://server

  server:
    image: myserver
    build:
      context: ApiServer
      dockerfile: Dockerfile
    ports:
      - 5001:80

Y, por supuesto, añadimos el servicio server en el docker-compose.debug.yml:

version: '3.4'

services:
  api:
    image: myapi:dev
    build:
      target: base
    labels:
      - "com.microsoft.visualstudio.targetoperatingsystem=linux"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_USE_POLLING_FILE_WATCHER=1
    volumes:
      - .:/app
      - ~/.nuget/packages:/root/.nuget/packages:ro
      - ~/vsdbg-core:/vsdbg:ro
    entrypoint: tail -f /dev/null

  server:
    image: myserver:dev
    build:
      target: base
    labels:
      - "com.microsoft.visualstudio.targetoperatingsystem=linux"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - DOTNET_USE_POLLING_FILE_WATCHER=1
    volumes:
      - .:/app
      - ~/.nuget/packages:/root/.nuget/packages:ro
      - ~/vsdbg-core:/vsdbg:ro
    entrypoint: tail -f /dev/null    

(Efectivamente, ambas entradas son idénticas, solo cambia el nombre de la imagen)

Vale, ahora ya podemos configurar vscode. Por un lado necesitamos dos tareas de build, para construir de forma separada ambos proyectos, así que modificamos tasks.json para que quede:

"tasks": [
    {
        "label": "build-api",
        "command": "dotnet",
        "type": "process",
        "args": [
            "build",
            "${workspaceFolder}/MyApi/MyApi.csproj"
        ],
        "problemMatcher": "$msCompile"
    },
    {
        "label": "build-server",
        "command": "dotnet",
        "type": "process",
        "args": [
            "build",
            "${workspaceFolder}/ApiServer/ApiServer.csproj"
        ],
        "problemMatcher": "$msCompile"
    }
]

Y ahora necesitamos definir otra tarea de configuración en launch.json para usar el nuevo contenedor. Además necesitamos modificar la primera para que use build-api en lugar de build que ya no tenemos:

    "name": "MyApi (docker) ",
    "type": "coreclr",
    "request": "launch",
    "preLaunchTask": "build-api",
    "program": "/app/MyApi/bin/Debug/netcoreapp2.1/MyApi.dll",
    "args": [],
    "cwd": "/app/MyApi",
    "stopAtEntry": false,
    "console": "internalConsole",
    "pipeTransport": {
        "pipeCwd": "${workspaceRoot}",
        "pipeProgram": "docker",
        "pipeArgs": [
            "exec", "-i", "dockercode_api_1" ,"${debuggerCommand}"
         ],
         "quoteArgs": false,
        "debuggerPath": "/vsdbg/vsdbg"
    }
},
{
     "name": "ApiServer (docker) ",
     "type": "coreclr",
     "request": "launch",
     "preLaunchTask": "build-server",
     "program": "/app/ApiServer/bin/Debug/netcoreapp2.1/ApiServer.dll",
     "args": [],
     "cwd": "/app/ApiServer",
     "stopAtEntry": false,
     "console": "internalConsole",
     "pipeTransport": {
         "pipeCwd": "${workspaceRoot}",
         "pipeProgram": "docker",
         "pipeArgs": [
             "exec", "-i", "dockercode_server_1" ,"${debuggerCommand}"
         ],
         "quoteArgs": false,
         "debuggerPath": "/vsdbg/vsdbg"
     }
 },

Y ahora debemos agregar, en el mismo launch.json la tarea compuesta. Para ello (después del array de configurations) añadimos la sección compounds:

"compounds": [
    {
        "name": "Both containers",
        "configurations": ["MyApi (docker)", "ApiServer (docker)"]
    }
]

Si ahora lanzamos la tarea «Both Containers» eso nos debería permitir depurar ambos contenedores a la vez, pero no: recibiremos un error:

Error: OCI runtime exec failed: exec failed: container_linux.go:348: starting container process caused "no such file or directory": unknown

Se puede ver como intenta ambos docker exec pero recibe un error. Por supuesto, si lanzas cualquiera de las sesiones de forma independiente funciona. Pero si las lanzas a la vez, falla.

Lo que (creo) que pasa es que la primera sesión captura stdin/stdout del terminal de code, y cuando la segunda sesión intenta usar stdin/stdout (para comunicarse via code con el pipe) le da un error.

Lo que se me ha ocurrido (muy listo yo xD) ha sido decirle a code que lance un terminal externo para cada sesión, de este modo cada sesión tendrá un stdin/stdout propio y no debería haber problemas, ¿verdad? Así que añadí la opción:

"console": "externalTerminal"

A ambas tareas para que así cada una tenga su propio terminal. Pero no funciona: la opción console es ignorada si se usa pipeTransport. Mi gozo en un pozo. He visto algunas issues al respecto:

  • https://github.com/Microsoft/vscode/issues/49941 (donde le dicen que eso es de una extensión y que lo diga allí)
  • https://github.com/OmniSharp/omnisharp-vscode/issues/2313: La issue correspondiente en la extensión de Omnisharp (la que gestiona la depuración de netcore)

No he dado con ninguna solución a este problema y no creo que sea abordable sin modificar la extensión de OmniSharp, cosa que desconozco si es posible…

Si alguien encuentra o sabe una solución a este problema: ¡que ponga un comentario! Yo, al menos, le estaré muy agradecido 🙂

 

Deja un comentario

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