Using Docker Multi-Stage for Building Windows Images

Hello everyone! My name is Andrey, and I work as a DevOps engineer at Exness in the development team. My main activities are related to building, deploying, and maintaining applications in Docker on the Linux operating system. Not long ago, I faced a task with the same activities, but the target OS of the project became Windows Server and a set of projects in C++. This was my first in-depth interaction with Docker containers on the Windows OS and with C++ applications in general. As a result, I gained interesting experience and learned about some intricacies of containerizing applications on Windows.

Using Docker Multi-Stage for Building Windows Images

In this article, I want to share the challenges I faced and how I managed to solve them. I hope this will be useful for addressing your current and future tasks. Happy reading!

Why containers?

The company has an existing container orchestrator infrastructure using Hashicorp Nomad along with related components—Consul and Vault. Therefore, containerizing applications was chosen as a unified method for delivering a ready solution. Since the project infrastructure includes Docker hosts with versions of Windows Server Core 1803 and 1809, it is necessary to build separate versions of Docker images for 1803 and 1809. In version 1803, it is important to remember that the build revision number of the Docker host must match the revision number of the base Docker image and the host where the container from this image will be launched. Version 1809 does not have this disadvantage. You can read more about it. here.

Why multi-stage?

Development engineers have limited or no access to build hosts, and there is no opportunity to quickly manage the set of components for building applications on these hosts, for example, to install additional toolsets or workloads for Visual Studio. Therefore, we decided to install all the necessary components for building the application into the build Docker image. If needed, we can quickly modify just the Dockerfile and initiate the pipeline for creating this image.

From theory to practice

In an ideal Docker multi-stage build of an image, the environment setup for building the application occurs within the same Dockerfile script as the application build itself. However, in our case, an intermediate step has been added, namely, a preliminary Docker image creation step containing everything necessary for building the application. This was done to leverage Docker's caching capabilities to reduce the time required for installing all dependencies.

Let's break down the key aspects of the Dockerfile script for creating this image.

To create images for different OS versions in the Dockerfile, you can define an argument through which the version number is passed during the build, which also serves as the tag for the base image.

A complete list of Microsoft Windows Server image tags can be found here.

ARG WINDOWS_OS_VERSION=1809
FROM mcr.microsoft.com/windows/servercore:$WINDOWS_OS_VERSION

By default, commands in the instruction RUN within the Dockerfile in Windows OS are executed in the cmd.exe console. For ease of script writing and extending the functionality of the commands used, we will override the command execution console to PowerShell via the instruction SHELL.

SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop';"]

The next step is to install the package manager Chocolatey and the required packages:

COPY chocolatey.pkg.config .
RUN Set-ExecutionPolicy Bypass -Scope Process -Force ;
    [System.Net.ServicePointManager]::SecurityProtocol = 
    [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 ;
    $env:chocolateyUseWindowsCompression = 'true' ;
    iex ((New-Object System.Net.WebClient).DownloadString( 
      'https://chocolatey.org/install.ps1')) ;
    choco install chocolatey.pkg.config -y --ignore-detected-reboot ;
    if ( @(0, 1605, 1614, 1641, 3010) -contains $LASTEXITCODE ) { 
      refreshenv; } else { exit $LASTEXITCODE; } ;
    Remove-Item 'chocolatey.pkg.config'

To install packages using Chocolatey, you can simply pass them as a list or install one at a time if unique parameters need to be specified for each package. In our case, we used a manifest file in XML format, which lists the required packages and their parameters. Its content looks like this:

Next, we will set up the application build environment, specifically MS Build Tools 2019 — a lightweight version of Visual Studio 2019 that contains the minimal set of components required for compiling code.
For the full operation of our C++ project, we will need additional components, namely:

  • C++ Tools Workload
  • Toolset v141
  • Windows 10 SDK (10.0.17134.0)

You can install the extended toolset automatically using a configuration file in JSON format. The contents of the configuration file are:

A complete list of available components can be found on the documentation site. Microsoft Visual Studio.

{
  "version": "1.0",
  "components": [
    "Microsoft.Component.MSBuild",
    "Microsoft.VisualStudio.Workload.VCTools;includeRecommended",
    "Microsoft.VisualStudio.Component.VC.v141.x86.x64",
    "Microsoft.VisualStudio.Component.Windows10SDK.17134"
  ]
}

The installation script is executed in the dockerfile, and for convenience, the path to the build tools executables is added to the environment variable. PATH. It is also advisable to remove unnecessary files and directories to reduce the size of the image.

COPY buildtools.config.json .
RUN Invoke-WebRequest 'https://aka.ms/vs/16/release/vs_BuildTools.exe' 
      -OutFile '.vs_buildtools.exe' -UseBasicParsing ;
    Start-Process -FilePath '.vs_buildtools.exe' -Wait -ArgumentList 
      '--quiet --norestart --nocache --config C:buildtools.config.json' ;
    Remove-Item '.vs_buildtools.exe' ;
    Remove-Item '.buildtools.config.json' ;
    Remove-Item -Force -Recurse 
      'C:Program Files (x86)Microsoft Visual StudioInstaller' ;
    $env:PATH = 'C:Program Files (x86)Microsoft Visual Studio2019BuildToolsMSBuildCurrentBin;' + $env:PATH; 
    [Environment]::SetEnvironmentVariable('PATH', $env:PATH, 
      [EnvironmentVariableTarget]::Machine)

At this stage, our image for compiling the C++ application is ready, and we can proceed to create the docker multi-stage build of the application.

Multi-stage in action

We will use the created image with all the tools on board as the build image. As in the previous dockerfile script, we will add the ability to dynamically specify the version number/tag of the image for convenient reuse of the code. It is important to add the label as builder to the build image in the instruction FROM.

ARG WINDOWS_OS_VERSION=1809
FROM buildtools:$WINDOWS_OS_VERSION as builder

It's time to build the application. It's quite simple: copy the source code and everything related to it, and start the compilation process.

COPY myapp .
RUN nuget restore myapp.sln ;
    msbuild myapp.sln /t:myapp /p:Configuration=Release

The final stage of creating the final image is specifying the base image of the application, where all the compilation artifacts and configuration files will reside. To copy the compiled files from the intermediate build image, you need to specify the parameter --from=builder in the instruction COPY.

FROM mcr.microsoft.com/windows/servercore:$WINDOWS_OS_VERSION

COPY --from=builder C:/x64/Release/myapp/ ./
COPY ./configs ./

Now we just need to add the necessary dependencies for our application to work and specify the startup command through the instructions ENTRYPOINT or CMD.

Conclusion

In this article, I explained how to create a complete C++ application compilation environment within a container on Windows and how to leverage docker multi-stage builds to create full images of our application.

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster