Using Visual Studio Build tools in Docker on Windows Server 2016

The title of this article might seem oddly specific, but this turned out to be less straightforward than I thought it would (or should) be. I run my Jenkins builds for Visual Studio on a VPS running Windows 2016 server. In the past I was hit by a bug where the Visual Studio build tools installer would crash resulting in a corrupted .NET installation inside of the container. More recently a Windows 2016 security update stirred things up even more.

Recently all my Windows docker jobs on Jenkins started failing. Cmake still worked, but anything related to Visual Studio just crashed immediately. This turned out to be the result of a security update that I had received the night before.

Windows Server container issues with February 11, 2020 security update

My docker image is based on mcr.microsoft.com/dotnet/framework/runtime:4.8-windowsservercore-ltsc2016 and the workaround described on the page seems straightforward. Since I’m manually installing Visual Studio build tools anyway, I figured I didn’t need the dotnet framework base anymore, and I could base my docker image directly on mcr.microsoft.com/windows/servercore:10.0.14393.3504.

However if you do this, you will get hit by a much older bug where the Visual Studio build tools installer will stop seemingly without error. However, after that, nothing related to .NET will work properly anymore in the container. Even a simple echo call in Powershell will result in an error complaining about missing .NET 2.0 (which is strange in itself).

The fix for this is to head over to github and clone the microsoft/dotnet-framework-docker GIT repository. Then go to the 4.8\runtime\windowsservercore-ltsc2016 subfolder and edit the Dockerfile there to use mcr.microsoft.com/windows/servercore:10.0.14393.3504 instead of the default ltsc2016 version. Build this to a local image with a name of your choosing. I called mine robindegen/windowsservercore_net48_runtime. Then rebuild the Visual Studio build tools docker image based on this newly created image. The Visual Studio build tools installation should now succeed and work properly.

For those interrested, here is the Dockerfile I use to install Visual Studio 2019 build tools. It also installs CMake, NASM and everything needed to run a Jenkins slave inside of it. This configuration is based on several online examples. I believe that the slave configuration can be simplified; but for the timebeing this works. The Jenkins JNLP portion is based on an old version of the jenkinsci/docker-jnlp-slave repository.

FROM robindegen/windowsservercore_net48_runtime

# Reset the shell.
SHELL ["cmd", "/S", "/C"]

# Set up environment to collect install errors.
COPY Install.cmd C:\TEMP\
ADD https://aka.ms/vscollect.exe C:\TEMP\collect.exe

# Download channel for fixed install.
ADD https://aka.ms/vs/16/release/channel C:\TEMP\VisualStudio.chman

# Download and install Build Tools for Visual Studio 2019 for native desktop workload.
ADD https://aka.ms/vs/16/release/vs_buildtools.exe C:\TEMP\vs_buildtools.exe
RUN C:\TEMP\Install.cmd C:\TEMP\vs_buildtools.exe --quiet --wait --norestart --nocache `
    --channelUri C:\TEMP\VisualStudio.chman `
    --installChannelUri C:\TEMP\VisualStudio.chman `
    --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended`
    --add Microsoft.VisualStudio.Component.VC.CMake.Project `
    --installPath C:\BuildTools

# Verify if .NET isn't broken (bug with the ltsc2016 docker image)
RUN powershell.exe -Command echo Everything is OK

ADD Cmake C:\Cmake\

ADD Git-2.23.0-64-bit.exe C:\git_install.exe
RUN C:\git_install.exe /SILENT /COMPONENTS="icons,ext\reg\shellhere,assoc,assoc_sh" /PathOption=Cmd

# Install java
ADD jre-8u211-windows-x64.exe c:\jre_install.exe
RUN start /w c:\jre_install.exe /s

# Install nasm
ADD nasm c:\nasm\

ADD ./environment.ps1 c:\environment.ps1
RUN powershell -Command c:\environment.ps1

# Install remoting
ENV SLAVE_FILENAME=slave.jar `
    REMOTING_VERSION=3.29

ENV SLAVE_HASH_FILENAME=$SLAVE_FILENAME.sha1

# Add slave
ADD slave.jar c:\slave.jar
ADD slave-launch.ps1 c:\slave-launch.ps1
ENTRYPOINT powershell.exe -Command c:\slave-launch.ps1

Here is the slave-launch.ps1 file:

# Based of script used by jenkinsci/jnlp-slave
# https://github.com/jenkinsci/docker-jnlp-slave/blob/master/jenkins-slave
#
# Usage : slave-launch.ps1 -Url <url> [-Tunnel <tunnel>] [<secret>] [<agent Name>]
#
# Optional environment variables:
# * JENKINS_TUNNEL      : HOST:PORT for a tunnel to route TCP traffic to a Jenkins host when Jenkins can't be directly accessed over network
# * JENKINS_URL         : alternative Jenkins URL
# * JENKINS_SECRET      : agent secret, used if not already set via command-line argument
# * JENKINS_AGENT_NAME  : agent name, used if not already set via command-line argument
[CmdletBinding(DefaultParametersetName = "WithoutTunnel")]
param (
    [Parameter(Mandatory = $true, Position = 1)]
    [String]
    $Url,

    [Parameter(ParameterSetName = "WithTunnel", Mandatory = $true, Position = 2)]
    [String]
    $Tunnel,

    [Parameter(ParameterSetName = "WithTunnel", Position = 3)]
    [String]
    $Secret1,

    [Parameter(ParameterSetName = "WithTunnel", Position = 4)]
    [String]
    $AgentName1,

    [Parameter(ParameterSetName = "WithoutTunnel", Position = 2)]
    [String]
    $Secret2,

    [Parameter(ParameterSetName = "WithoutTunnel", Position = 3)]
    [String]
    $AgentName2
)

$secret = @{$true = $Secret1; $false = $Secret2}[$Secret1 -ne ""]
$agentName = @{$true = $AgentName1; $false = $AgentName2}[$AgentName1 -ne ""]

if ($env:JENKINS_TUNNEL) {
    $Tunnel = $env:JENKINS_TUNNEL
}

if ($env:JENKINS_URL) {
    $Url = $env:JENKINS_URL
}

if ($env:JENKINS_SECRET -and $secret) {
    Write-Warning "Secret is defined twice, in command-line argument and environment variable"
}
elseif ($env:JENKINS_URL) {
    $secret = $env:JENKINS_URL
}

if ($env:JENKINS_AGENT_NAME -and $agentName) {
    Write-Warning "Agent Name is defined twice, in command-line argument and environment variable"
}
elseif ($env:JENKINS_AGENT_NAME) {
    $agentName = $env:JENKINS_AGENT_NAME
}


$params = @("-headless")

if ($Tunnel) {
    $params += @("-tunnel", $Tunnel)
}

$params += ( `
    "-url", $Url, `
    "-workDir", "C:/Jenkins/Agents", `
    $secret, `
    $agentName `
)

function Show-Commandline {
    $args
}

Show-Commandline "java $javaOpts $jnlpProtocolOpts -cp ./slave.jar hudson.remoting.jnlp.Main" @params

# run slave
. java $javaOpts $jnlpProtocolOpts -cp ./slave.jar hudson.remoting.jnlp.Main @params