Building .Net Core apps in Docker with VSCode on Mac or Windows

We can develop applications inside Docker containers and completely decouple our host machine from any SDK or development environments. This means that when using frameworks such as .Net Core, we no longer need to have it installed or running on the host machine. Instead, we get the .Net Core SDK docker image and develop within that container.

In the following examples I’m using a Mac, which does not have .Net Core installed on it*, to run a .Net Core container and develop apps on it. I can also easily open and work on the exact same projects on my PC or Ubuntu machines.

*Though initially the .net framework is not installed on the Mac, I’m using Visual Studio Code and in particular, a VSCode extension called “C# Extension” by Omnisharp which does require the .net framework installed in order for it to work. So technically I am installing the .net framework onto the Mac. However, the apps below are not using this. They are completely separated and running in the container. Had I choose to develop without the C# Extension my Mac would be 100% free of .net… 🙂

 

The source code can be found here:
https://github.com/johnlee/dockerdotnetcorevscode

 

Prerequisites

I use the following tools on my Mac (as well as PC) for these sample apps.

  • Docker Client (v18+)
  • Visual Studio Code
    • C# Extension (by Ominsharp, uses Roslyn compiler)
    • Docker Extension (by Docker)
  • vsdbg (for Linux based platforms)

 

Setting up VSCode

I am using Visual Studio Code for these projects. VSCode is versatile and can be used across platforms for numerous types of applications and programming languages. One of VSCode’s benefits is it is very lightweight initially – not much more than a fancy text editor. But with the use of ‘extensions’, the IDE can be extended to support many different types of languages, frameworks and tools. In these projects I use VSCode with C# and Docker extensions. This allows VSCode to recognize these languages, file types, etc to execute builds and debugging.

I will be running through 3 different apps – console, mvc and webapi. These were all created using the dotnet cli and their template projects. Look here for more information on how to create the template projects.

https://solidfish.com/dotnet-core-cli/

 

Debugging

Since we are using Visual Studio Code C# Extensions – it comes already with a remote debugger called vsdbg. When create or open a .net app in VSCode with this extension enabled, it will automatically prompt you to include files for debugging the project.

Clicking ‘Yes’ on this will add the following files:

  • .vscode/launch.json
  • .vscode/task.json

These files include the configuration information for running tasks such as the debugger. Omnisharp will configure the files based on the project type. More information about this can be found at the link below and will be shown further in the sections below:

https://code.visualstudio.com/docs/editor/tasks

Note that the launch.json file is used by VSCode for debugging in other languages and frameworks as well, such as Node.js, which is shown in the article linked above.

 

A Console App

The first example is a console app using the templated project created by dotnet cli. Once I open the project, the C# Extension will prompt me to add the launch.json and tasks.json files (as discussed above).  Looking at the launch.json file we can see a definition created by Omnisharp on how to run the debugger.

launch.json:
{
   // Use IntelliSense to find out which attributes exist for C# debugging
   // Use hover for the description of the existing attributes
   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
   "version": "0.2.0",
   "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            // If you have changed target frameworks, make sure to update the program path.
            "program": "${workspaceFolder}/bin/Debug/netcoreapp2.1/console.dll",
            "args": [],
            "cwd": "${workspaceFolder}",
            // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window
            "console": "internalConsole",
            "stopAtEntry": false,
            "internalConsoleOptions": "openOnSessionStart"
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        }
    ,]
}

Note that out-of-the-box we are given two different debugging configurations. The first one is the ‘launch’ configuration that runs the app through it’s current workplace. It references a task called ‘build’ as part of the ‘preLaunchTask’. This task is defined in the task.json file below. The second configuration is ‘attach’, which can attach to an existing process running the app. We will be leveraging this later when debugging out of the Docker container. This is discussed further later below.

task.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/console.csproj"
            ],
            "problemMatcher": "$msCompile"
        }
    ]
}

There is a single task defined for running the ‘build’. For more information on how the tasks.json is built, refer to the articles below. Note that for the C# Extension we are using the Rosyln compiler.

https://code.visualstudio.com/docs/editor/tasks
https://code.visualstudio.com/docs/editor/tasks-appendix
https://code.visualstudio.com/docs/languages/csharp

 

Before we get to setting up Docker, I’ve added a few more lines to the main program file so I can demonstrate debugging.

static void Main(string[] args)
{
    DateTime now = DateTime.Now;
    string today = now.ToString("dd/MM/yyyy");
    int iteration = 0;

    Console.WriteLine("Hello World!");
    Console.WriteLine($"Today is {today}");
    Console.WriteLine("Starting Loop");

    while (iteration < 100) 
    {
        string time = DateTime.Now.ToString("h:mm:ss tt");
        Console.WriteLine($"T: {time}");
        Thread.Sleep(1000);
        iteration++;
    }
}

 

Setup Docker

Ok – finally lets do some Docker! Since I have the Docker extensions installed for VSCode, I can use the VSCode Command Palette (CMD+SHIFT+P) to add Dockerfiles. From the command line enter “Docker” and select the “Add Docker files to workspace”.

More information on this can be found here:

https://code.visualstudio.com/docs/azure/docker

It creates the following Dockerfile:

FROM microsoft/dotnet:2.1-runtime AS base
WORKDIR /app
EXPOSE 80

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

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

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

 

At this point we could create and run the Docker container. This can be done through the command palette by entering “Docker”. You will find several Docker commands in the palette, including ‘build’ and ‘run’.

We could also run this outside of VSCode on a terminal as shown below (this is assuming you built the dotnet image and called it console, as instructed by the Dockerfile):

docker run --rm -it -p 80:80/tcp console:latest

We’ve now ‘dockerized’ our app and can continue making changes. To run the app we rebuilt the Docker container (just select Docker: Run on the command palette). Docker is smart enough to know which parts of it’s intermediate layers needs to be rebuilt and doesnt rebuild everything. For more information on how Docker builds images refer to my Docker post below:

https://solidfish.com/dotnet-core-and-docker/

 

Is that it? No!

So although we are able to build and run the console app out of a Docker container – we are not debugging in the same environment! Recall from earlier that the C# Extension setup debugging through the host environment. This is now inconsistent with the build and run environments. We need to run debugging in the same Docker container. This is where the remote debugging comes into play.

To debug within the Docker container we first need to install a debugger on it. The dotnet 2.1sdk image doesn’t include it. Also, since our container is running a Linux image – we need to install a linux based debugger. I will use vsdbg. More information about this can be found here:

https://code.visualstudio.com/docs/editor/debugging

 

Debugging out of Docker

To run our remote debugger out of docker, we need to first a new image with .net 2.1sdk and the vsdbg. From this image we can then copy our source code and run the .net build and debug. VSCode will attach to this running container and we’ll be debugging.

To set this up, I started by creating two(2) new Dockerfiles for the project. These are:

docker-vsdbg.Dockfile
# Inspired by: https://github.com/sleemer/docker.dotnet.debug
FROM microsoft/dotnet:2.1-sdk AS base

# Setup vsdbg
WORKDIR /vsdbg
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
       unzip \
    && rm -rf /var/lib/apt/lists/* \
    && curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg

ENTRYPOINT ["/bin/bash"]

docker-debug.Dockerfile
FROM dotnet21sdk.vsdbg:latest AS base
ENV NUGET_XMLDOC_MODE skip

# Set working directory
RUN mkdir /app
WORKDIR /app

COPY *.csproj /app
RUN dotnet restore

COPY . /app
RUN dotnet publish -c Debug -o out

# Kick off a container just to wait debugger to attach and run the app
ENTRYPOINT ["/bin/bash", "-c", "sleep infinity"]

As noted in the above comments, I created the docker-vsdbg.Dockerfile based on a sample project shown in that link. This Dockerfile will create my second base image which contains the dotnet:2.1 SDK and has the /vsdbg installed on top of it. (It also updates the image and installs unzip in order to unpackage the vsdbg).

I also renamed the original Dockerfile (created by Omnisharp) to docker-publish.Dockerfile. This Dockerfile would be used for publishing purposes only.

Now since I have Dockerfile files to create the images, I need a way to run this. I’ve created a script to do the docker build up, which the VSCode debugger will call. (Actually its two scripts to support Linux and Windows host machines).  Below is the linux version of that script. Again – it was inspired from the link shown below.

runDockerDebug.sh
# Inspired by: https://github.com/sleemer/docker.dotnet.debug
$baseImageName = "dotnet21sdk.vsdbg"
$imageName = "console.vsdbg"
$containerName = "console"

killContainers () {
  docker rm --force $(docker ps -q -a --filter "ancestor=$containerName")
}

# Removes the Docker image
removeImage () {
  docker rmi $imageName
}

# Builds the Docker image.
buildImage () {
  if [[ "docker images -q $baseImageName" == "" ]]; then
    echo "Building $baseImageName"
    docker build --rm -f "docker-vsdbg.Dockerfile" -t $baseImageName .
  fi
  echo "Building $imageName"
  docker build --rm -f "docker-debug.Dockerfile" -t $imageName .
}

# Runs a new container
runContainer () {
  echo "Running $containerName"
  docker run --rm -d -p 80:80/tcp --name $containerName $imageName
}

# Shows the usage for the script.
showUsage () {
  echo "Usage: runDocker.sh [COMMAND]"
  echo "    Runs command"
  echo ""
  echo "Commands:"
  echo "    cleanup: Kill and remove docker image(s)."
  echo "    debug: Builds the debug image and runs docker container."
  echo ""
  echo "Example:"
  echo "    ./runDocker.sh debug"
  echo ""
}

if [ $# -eq 0 ]; then
  showUsage
else
  case "$1" in
    "cleanup")
            echo "Starting cleanup"
            killContainers
            removeImage
            ;;
    "debug")
            echo "Starting debugger"
            killContainers
            buildImage
            runContainer
            ;;
    *)
            showUsage
            ;;
  esac
fi

This script will check for existing images, cleanup then create the new images. Note that the vsdbg image is only created if not exist. I dont create it again since its contents can be reused in the following projects.

The last step is to wire VSCode to run these scripts and attach to the Docker container with it’s debugger. We do this by updating the launch.json and task.json files. The task.json file is what actually calls the above script. Also – task.json is able to distinguish different host platforms and so I have different scripts defined below for windows vs osx vs linux.

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/console.csproj"
            ],
            "problemMatcher": "$msCompile"
        },
        {
            "label": "buildDocker",
            "type": "shell",
            "windows": {
                "options": {
                    "cwd": "${workspaceFolder}"
                },
                "command": "${workspaceFolder}/scripts/runDockerDebug.ps1 -command debug -srcpath ${workspaceFolder}"
            },
            "osx": {
                "command": "${workspaceFolder}/scripts/runDockerDebug.sh debug"
            },
            "linux": {
                "command": "${workspaceFolder}/scripts/runDockerDebug.sh debug"
            }
        }
    ]
}

The launch.json configures how VSCode can do debugging. VSCode supports two different approaches to debugging – launch and attach. Taken from the article linked above, the differences are:

The best way to explain the difference between launch and attach is think of a launch configuration as a recipe for how to start your app in debug mode before VS Code attaches to it, while an attach configuration is a recipe for how to connect VS Code’s debugger to an app or process that’s already running.

For our example, we will be taking the launch approach where VSCode will spin up the container and call the remote debugger. I take the existing launch.json and append the section highlighted below.

{
   // Use IntelliSense to find out which attributes exist for C# debugging
   // Use hover for the description of the existing attributes
   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
   "version": "0.2.0",
   "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            // If you have changed target frameworks, make sure to update the program path.
            "program": "${workspaceFolder}/bin/Debug/netcoreapp2.1/console.dll",
            "args": [],
            "cwd": "${workspaceFolder}",
            // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window
            "console": "internalConsole",
            "stopAtEntry": false,
            "internalConsoleOptions": "openOnSessionStart"
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        },
        {
            "name": ".NET Core Launch DOCKER (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "buildDocker",
            "program": "/app/out/console.dll",
            "args": [],
            "cwd": "/app/out",
            "sourceFileMap": {
                "/app": "${workspaceFolder}"
            },
            "pipeTransport": {
                "pipeProgram": "docker",
                "pipeCwd": "${workspaceFolder}",
                "pipeArgs": [
                    "exec -i console"
                ],
                "quoteArgs": false,
                "debuggerPath": "/vsdbg/vsdbg"
            }
        }
    ,]
}

That is about it. We are now ready to run the debugger through VSCode. I select “Start Debugging” (F5) from the menu and we see the scripts run, create the docker images, and then VSCode attaches to it and we get debugger running in the Debug Console.

 

WebApi and MVC

Once we have the console app running, its very easy to set this up for WebApi and MVC type projects. Matter of fact, everything is identical except for changing the image and container names in the “runDockerDebug” script. The debugging process works the same whether console, mvc or webapi.

For webapi, I set the following names in runDockerDebug.sh file.

...
$global:imageName = "webapi.vsdbg"
$global:containerName = "mvc"
...

And for mvc, I set as so.

...
$global:imageName = "mvc.vsdbg"
$global:containerName = "mvc"
...

 

Full working source code can be found here:

https://github.com/johnlee/dockerdotnetcorevscode

 

 

References

Working with Docker is VSCode
https://code.visualstudio.com/docs/azure/docker

Debugging in container and VSCode
https://github.com/sleemer/docker.dotnet.debug

Debugging in container and VSCode
http://blog.jonathanchannon.com/2017/06/07/debugging-netcore-docker/

Omnisharp (some issues when used with Docker containers though)
https://github.com/OmniSharp/omnisharp-vscode

Debug .Net Core app in Docker
https://www.richard-banks.org/2018/07/debugging-core-in-docker.html

How VSCode Debugger Works – Debug Adapters
https://code.visualstudio.com/docs/extensions/example-debuggers

Debugging and running unit tests in Docker
https://techblog.dorogin.com/running-and-debugging-net-core-unit-tests-inside-docker-containers-48476eda2d2a

Debugging with Visual Studio 2017
https://github.com/Microsoft/MIEngine/wiki/Offroad-Debugging-of-.NET-Core-on-Linux—OSX-from-Visual-Studio

eof