Building a Docker image
The .NET CLI dotnet publish
command supports creating Docker images without using a Dockerfile. However, to understand Docker, we need to know about Dockerfiles. That’s why we start building a Docker image defining a Dockerfile first.
In this section, we will do the following:
- Create a Dockerfile for the games API
- Build a Docker image using the Dockerfile
- Run the games API with a Docker container
- Create a Docker image using
dotnet publish
Creating a Dockerfile
Docker images are created using instructions in Dockerfiles. Using Visual Studio, you can easily create a Dockerfile from Solution Explorer, using Add | Docker Support. Make sure to select Dockerfile for the Container build type option. Adding a Dockerfile to the Codebreaker.GamesAPI
project creates a multi-stage Dockerfile. A multi-stage Dockerfile creates interim images for different stages.
Base stage
With the following code snippets, the different stages are explained. The first stage prepares a Docker image for the production environment:
Codebreaker.GameAPIs/Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base USER app WORKDIR /app EXPOSE 8080
Every Dockerfile starts with a FROM
instruction. The FROM
instruction defines the base image that is used. mcr.microsoft.com/dotnet/aspnet
is an image optimized for production. With .NET 8, this image is based on Debian 12 (Bookworm). The Debian:12-slim
image is defined by a Dockerfile with FROM scratch
, so this is the root of the hierarchy of instructions. The dotnet/aspnet
image contains the .NET runtime and the ASP.NET Core runtime. The .NET SDK is not available with this image. The AS
instruction defines a name that allows using the result of this stage with another stage. The USER
instruction defines the user that should be used with the instructions of the stage. The WORKDIR
instruction sets the working directory for following instructions. If the directory does not exist in the image, it’s created. The last step in the first stage is the EXPOSE
instruction. With EXPOSE
, you define the ports that the application is listening to. By default, TCP
is used, but you can also specify to have an UDP
receiver.
Note
.NET 8 has some changes with Docker image generation: the default container images run with non-root users (the app user), and the default port is no longer port 80. Port 80 is a privileged port that requires the root user. The new default port is now 8080.
Build stage
The second stage builds the ASP.NET Core application:
Codebreaker.GameAPIs/Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Codebreaker.GameAPIs/Codebreaker.GameAPIs.csproj", "Codebreaker.GameAPIs/"] RUN dotnet restore "./Codebreaker.GameAPIs/Codebreaker.GameAPIs.csproj" COPY . . WORKDIR "/src/Codebreaker.GameAPIs" RUN dotnet build "./Codebreaker.GameAPIs.csproj" -c $BUILD_CONFIGURATION -o /app/build
With the second stage, we ignore the first stage for a moment and use a different base image: dotnet/sdk
. This image contains the .NET SDK and is used to build the application. First, a src
directory is created, and the current directory is set to src
. The ARG
instruction specifies an argument that can be passed when invoking building the Docker image. If this argument is not passed, the default value is Release
. Next, you’ll see multiple COPY
instructions to copy the project files to subfolders within the current directory. The project files contain the package references. In case the dotnet restore
command that is started using the RUN
instruction fails, there’s no need to continue with the next steps. dotnet restore
downloads the NuGet packages. In case you use a different NuGet feed, the Dockerfile needs some changes to copy nuget.config
as well. When dotnet restore
succeeds, the complete source code from .
is copied to the current directory (which is src
at this time) with the COPY
instruction. Next, the working directory is changed to the directory of the games API project, and the dotnet build
command is invoked to create release code for the application. With dotnet build
, the BUILD_CONFIGURATION
argument is used, which was specified with ARG
. Having the release build in the src/app/build
folder is the result for the interim image after the last command.
Note
To make sure that unnecessary files are not copied with instructions such as COPY . .
, the .dockerignore
file is used. Similar to the .gitignore
file where files are specified to be ignored, with a .dockerignore
file, you specify what files should not be copied to the image.
Publish stage
Next, the second stage is used as a base for the third stage:
Codebreaker.GameAPIs/Dockerfile
FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./Codebreaker.GameAPIs.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
The FROM build
instruction uses the result from the previous stage and continues here. The dotnet publish
command results in code that’s needed to publish the application. The files needed for publication are copied to the /src/app/publish
folder. While the working directory was configured with the previous stage, continuing on the build image, the working directory is still set.
Final stage
With the final stage, we continue with the first stage, which was named base
:
Codebreaker.GameAPIs/Dockerfile
FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "Codebreaker.GameAPIs.dll"]
The first instruction with this stage is to set the working directory to app
. Then, referencing the third state with --from=publish
, the /app/publish
directory from the publish
stage is copied to the current directory. The ENTRYPOINT
instruction defines what should be done on running the image: the dotnet bootstrapper
command starts and receives Codebreaker.GameAPIs.dll
as an argument. You can do this from the command line as well: dotnet Codebreaker.GameAPIs.dll
starts the entry point of the application to kick off the Kestrel server, and the application is available to receive requests.
Before building the Dockerfile, make sure the DataStore
configuration in appsettings.json
is set to InMemory
to use the in-memory provider by default when starting the container.
Building a Docker image with a Dockerfile
To build the image, set the current directory to the directory of the solution, and use this command:
docker build . -f Codebreaker.GameAPIs\Dockerfile -t codebreaker/gamesapi:3.5.3 -t codebreaker/gamesapi:latest
With simple Dockerfiles, using just docker build
can be enough to build the image. However, the Dockerfile we use contains references to other projects. With this, we need to pay attention to the context. With the games API service, multiple projects need to be compiled, and the paths used as specified with the Dockerfile use the parent directory. Setting the current directory to the directory of the solution, the context is set to this directory with the first argument after the build command (.
). The -f
option next references the location of the Dockerfile. With the -t
option, the image is tagged. The repository name (codebreaker/gamesapi
) needs to be lowercase, followed by the 3.5.3
tag name and the latest
tag name. Tag names can be strings; there’s no requirement on the version. It’s just a good practice to always tag the latest version with the latest
tag. Specifying the -t
option two times, we get two image names that reference the same image with the same image identifier.
To list the images built, use docker images
. To restrict the output to codebreaker
images, you can define a filter:
docker images codebreaker/*
To check a Docker image for how it was built, we can use the docker
history
command:
docker history codebreaker/gamesapi:latest
Figure 5.3 shows the result of the docker history
command. This shows every instruction from the Dockerfile that was used to build the image. Of course, what you don’t see with the codebreaker/gamesapi
image is the dotnet build
and dotnet restore
commands. The build
and publish
stages have only been used for interim images to build the application and to create the files needed. Comparing the output to the Dockerfile we created, you see ENTRYPOINT
on top, followed by COPY
, WORKDIR
, and so on. With each of these instructions, you can also see the size result of the instruction. The COPY
command copied 3,441 MB into the image. The USER
instruction was the first instruction coming from our Dockerfile; the instructions before that (in the lines below the USER
instruction) show the instructions when the base image was created:
Figure 5.3 – Result of docker history command
To see the exposed ports, environmental variables, the entry point, and more of an image, use the following command:
docker image inspect codebreaker/gamesapi:latest
The result is presented in JSON information and shows information such as exposed ports, environment variables, the entry point, the operating system, and the architecture this image is based on. When running the image, it helps to know what port number needs to be mapped and which environment variables could be useful to override.
Running the games API using Docker
You can start the Docker image of the games API service with the following command:
docker run -p 8080:8080 -d codebreaker/gamesapi:latest
Then, you can check the log output using docker logs <container-id>
(get the ID of the running container with docker ps
). You can interact with the games service using any client using the following HTTP address: http://localhost:8080
.
With the default configuration of the data store, games are just stored in memory. To change this, we need to do the following:
- Configure a network to let multiple Docker containers directly communicate
- Start the Docker container for the SQL Server instance
- Pass configuration values to the Docker container for the games API to use the SQL Server instance
Configuring a network for Docker containers
To let containers communicate with each other, we create a network:
docker network create codebreakernet
Docker supports multiple network types, which can be set using the --driver
option. The default is a bridge network where containers within the same bridge network can directly communicate with each other. Use docker network ls
to show all networks.
Starting the Docker container with SQL Server
To start the Docker container for SQL Server where the database already exists, you can either use the Docker image we committed or access the state of the previous running container. Here, we do the latter, where we define the name sql1
as the name of the container:
docker start sql1
Use docker ps
to check for the running container and see the port mapping as it was defined earlier.
Using the command adds the running container to the codebreakernet
network:
docker network connect codebreakernet sql1
Starting the Docker container with the games API
Now, we need to start the games API but override the configuration values. This can be done by setting environmental variables. Passing environmental variables at the start of a Docker container can not only be done by setting the -e
option but also by using --env-file
and passing a file with environmental variables. This is the content of the gameapis.env
file:
gameapis.env
DataStore=SqlServer ConnectionStrings__GamesSqlServerConnection=sql1,1433;database=CodebreakerGames;user id=sa;password=<enter your password>;TrustServerCertificate=true
Creating the DI container of the application with the default container registers multiple configuration providers. A provider that uses environmental variables wins against the JSON file providers because of the order the providers are configured with the WebApplicationBuilder
class (or the Host
class). The DataStore
key is used to select the storage provider. GamesSqlServerConnection
is a key within the hierarchy of ConnectionStrings
. Using command-line arguments to pass configuration values, you can specify a hierarchy of configuration values using :
as a separator; for example, pass ConnectionStrings:GamesSqlServerConnection
. Using :
does not work everywhere; for example, with environmental variables on Linux. Using __
translates to this hierarchy.
With the environment variables file, the connection to the SQL Server Docker container is using the hostname of the Docker container and the port that is used by SQL Server. Being in the same network, the containers can communicate directly.
Starting the container, the environmental variables file is passed using --env-file
:
docker run -p 8080:8080 -d --env-file gameapis.env -d --name gamesapi codebreaker/gamesapi:latest docker network connect codebreakernet gamesapi
Note
In case you get an error that the container name is already in use, you can stop running containers with docker container stop <containername>
. To remove the state of the container, you can use docker container rm <containername>
or the docker rm <containername>
shorthand notation. To delete the state of all stopped containers, use docker container prune
. When starting a new container with dotnet run
, you can also add the --rm
option to remove a container after exit.
Specifying the name of the gamesapi
container, the container is added to the codebreakernet
network. Now, we have two containers running communicating with each other, and can play games using the games service.
Let’s create another Docker image, but this time without using a Dockerfile.
Building a Docker image using dotnet publish
In this chapter, we use multiple containers running at the same time. A project we didn’t use in previous chapters is Codebreaker.Bot
. This project offers an API and is also a client to the games service – to automatically play games in the background after requested by some API calls. In this section, we’ll build a Docker image for this project – but without creating a Dockerfile first.
Since .NET 7, the dotnet publish
command directly supports creating Docker images without a Dockerfile. Using the docker build
command, we had to pay attention to some specific .NET behaviors, such as the need to compile multiple projects. With this, it was necessary to specify the context when building the image. The .NET CLI knows about the solutions and project structure. The .NET CLI also knows about the default Docker base images that are used to build an application with ASP.NET Core.
Options to configure the generation can be specified in the project file and using the parameters of dotnet publish
. Here are some of the configurations specified in the project file:
<PropertyGroup> <ContainerRegistry>codebreaker/bot</ContainerRegistry> <ContainerImageTags>3.5.3;latest</ContainerImageTags> </PropertyGroup> <ItemGroup> <ContainerPort Include="8080" Type="tcp" /> </ItemGroup>
ContainerImageTags
and ContainerPort
are just two of the elements that are used by dotnet publish
. You can change the base image with ContainerBaseImage
, specify container runtime identifiers (ContainerRuntimeIdentifier
), name the registry (ContainerRegistry
), define environmental variables, and more. See https://learn.microsoft.com//dotnet/core/docker/publish-as-container for details.
With dotnet publish
, the name of the repository is specified:
cd Codebreaker.Bot dotnet publish Codebreaker.Bot.csproj --os linux --arch x64 /t:PublishContainer -c Release
The bot API service communicates with the games service, and the games service uses a container running SQL Server. Now, we already have three Docker containers collaborating. The games API needs a connection to the database, and the bot needs a link to the games API.