TL;DR
This post explains how to optimize a .NET 5 Docker image and address two main concerns. Image size and security vulnerabilities. The article shows how to reduce the size from ~210 to ~58 MB. Additionally, it shows how to remove all 59 vulnerabilities.
What we get from Microsoft
Visual Studio (also Visual Studio for Mac) creates a relatively simple Dockerfile
for .NET projects. It is a good starting point for users new to Docker. However, users should apply several optimizations before using the Dockerfile
to create Docker images for production environments.
Create the .NET 5 Web API project
Different kinds of .NET applications can run in containers. As example for this post, we use a .NET 5 Web API. Use .NET CLI dotnet
or Visual Studio to create a new .NET 5 Web API project.
# create a new Web API project in .NET 5
dotnet new webapi -n ContainerSample -o ContainerSample
Prepare container execution of the API
The default ASP.NET Web API template registers the UseHttpsRedirection
middleware by default. However, when running the application in a container, things like proper HTTPS configuration are in the hosting environment’s responsibility (eg. Kubernetes, Azure Container Instances, or the App Services runtime).
That said, remove the registration of UseHttpsRedirection
from Startup.cs
. The ConfigureServices
method of your Startup.cs
should now look like this:
// Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Thns.ContainerSample v1"));
}
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
Add Docker Support
Both, Visual Studio and Visual Studio for Mac can also add Docker support to different projects. Right-click the project in the IDE and select Add → Docker Support. Consider reading the detailed instructions for Visual Studio.
Both IDEs will generate a Dockerfile
like this:
FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ContainerSample.dll"]
Status Quo
As mentioned during the introduction, we will verify size, function, and vulnerabilities after every optimization. First, we have to build the Docker image using docker
CLI:
# navigate to the project directory
cd ContainerSample
# build the Docker image
docker build . -t container-sample:0.0.1
Docker CLI will transfer all required files and folders to the Docker daemon and start the image build process. In the end you find a new Docker image on your local machine.
Take a look at your local Docker images, which will also display the actual size of the Docker image:
# list all all container-sample docker images
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 1 minute ago 210MB
Test if the container starts successfully:
docker run --rm container-sample:0.0.1
info: Microsoft.Hosting.Lifetime[0]
Now listening on: http://[::]:80
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /app
You can terminate the application again using Ctrl + C
. Because we specified the --rm
flag when running the Docker image, stopping the container will remove it from the list of stopped containers.
Finally, let us take a look at the vulnerabilities of the image by using docker scan
. If you want to learn more about Docker image scanning, you should read my article on Docker image scanning.
# scan image for vulnerabilities
docker scan container-sample:0.0.1
## trimmed scan logs
Tested 94 dependencies for known vulnerabilities, found 59 vulnerabilities.
Bottom line: We end up with a working Docker image that is 210 MB large and has 59 vulnerabilities.
Step 1 - Switch the Linux distribution
First, switch from Debian to Alpine Linux to decrease the size of the resulting Docker image. The .NET team provides base images for a wide variety of operating systems and architectures.
Change the Dockerfile
to match the following:
# Use Alpine Base Image
FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
# Use Alpine Base Image
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ContainerSample.dll"]
Status Quo
To build and verify the current state, use now the 0.0.2
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.2
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 2 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 1 minute ago 108MB
# verify that it works
docker run --rm container-sample:0.0.2
# scan for vulnerabilities
docker scan container-sample:0.0.2
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.
Switching to Alpine Linux reduced our image from 210 to 108 MB. Regarding security vulnerabilities, it is down from 59 to 1.
Step 2 - Optimizing dotnet publish
At this point, optimize how the application is published. Until now, it was published for the Release
configuration, using default settings. However, .NET allows several tweaks when publishing apps.
The following Dockerfile
passes several arguments to dotnet publish
to ensure our app is published as a self-contained executable, targeting the given runtime (alpine-x64
).
Bundle trimming (/p:PublishTrimmed=true
) removes unnecessary framework components from the application bundle to optimize the distributable size. Also, create a single-file distributable by adding /p:PublishSingleFile=true
.
Last but not least, change the ENTRYPOINT
of the Dockerfile
to use the new single-file distributable.
FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build
FROM build AS publish
# optimize dotnet publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
--runtime alpine-x64 \
--self-contained true \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# new ENTRYPOINT (no more .dll is generated)
ENTRYPOINT ["./ContainerSample"]
Status Quo
To build and verify the current state, use now the 0.0.3
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.3
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 3 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 2 minutes ago 108MB
container-sample 0.0.3 f655d10572b0 1 minute ago 148MB
# verify that it works
docker run --rm container-sample:0.0.3
# scan for vulnerabilities
docker scan container-sample:0.0.3
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.
The size of the image went up to 148 MB after this optimization. The final image remains having 1 vulnerability.
Step 3 - Don’t call dotnet restore multiple times
The previous Dockerfiles
interacted with dotnet
several times. Many dotnet
commands execute restore
automatically to ensure all dependencies for your applications are in place. Optimize the Dockerfile
to restore
dependencies only once, which leads to a faster Docker image build-time:
FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
# remove the build stage
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./
# specify target runtime for destroy
RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
# add --no-restore flag to dotnet publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
--no-restore \
--runtime alpine-x64 \
--self-contained true \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["./ContainerSample"]
Status Quo
To build and verify the current state, use now the 0.0.4
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.4
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 4 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 3 minutes ago 108MB
container-sample 0.0.3 f655d10572b0 2 minutes ago 148MB
container-sample 0.0.4 6586a96f6808 1 minute ago 148MB
# verify that it works
docker run --rm container-sample:0.0.4
# scan for vulnerabilities
docker scan container-sample:0.0.4
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.
At this point, neither size nor number of vulnerabilities changed. This step was all about optimizing the image build-time. The image remains at 148 MB and 1 vulnerability.
Step 4 - Switch runtime image to runtime-deps
Using build a self-containing executable, we can switch to slightly different base images for the application. The .NET team offers a dedicated image, which comes with all required dependencies to run a .NET application. However, in contrast to the images used previously, neither the SDK nor the .NET runtime are part of the image. Again, this has a significant impact on the size of the final Docker image.
This is also a excellent time to clean-up the Dockerfile
and remove unnecessary EXPOSE
and WORKDIR
instructions:
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
--no-restore \
--runtime alpine-x64 \
--self-contained true \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true
# use different image
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final
WORKDIR /app
# just expose port 80
EXPOSE 80
COPY --from=publish /app/publish .
ENTRYPOINT ["./ContainerSample"]
Status Quo
To build and verify the current state, use now the 0.0.5
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.5
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 4 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 3 minutes ago 108MB
container-sample 0.0.3 f655d10572b0 2 minutes ago 148MB
container-sample 0.0.4 6586a96f6808 2 minutes ago 148MB
container-sample 0.0.5 5b176150ad0b 1 minute ago 54.9MB
# verify that it works
docker run --rm container-sample:0.0.5
# scan for vulnerabilities
docker scan container-sample:0.0.5
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.
Switching the base image for distribution leads to decreasing the size of the image by ~100 MB. The new Docker image is ~55 MB in size and has 1 known vulnerability.
Step 5 - Don’t run as root
I am sure you have noticed that the Docker image still runs with root
privileges. This is something, we should address.
On Linux, new users are created using the adduser
command. (The gecos
argument prevents the system from asking for additional details like full name, phone and so on…).
Additionally, use chown
to set the new user (dotnetuser
) as owner of the working directory (/app
).
Impersonate into the user context with USER dotnetuser
. Non-root users are not allowed to allocate network ports below 1024
. Use EXPOSE
with port 5000 and instruct Kestrel to run on that port by using the --urls
argument:
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
--no-restore \
--runtime alpine-x64 \
--self-contained true \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final
# create a new user and change directory ownership
RUN adduser --disabled-password \
--home /app \
--gecos '' dotnetuser && chown -R dotnetuser /app
# impersonate into the new user
USER dotnetuser
WORKDIR /app
# use port 5000 because
EXPOSE 5000
COPY --from=publish /app/publish .
# instruct Kestrel to expose API on port 5000
ENTRYPOINT ["./ContainerSample", "--urls", "http://localhost:5000"]
Status Quo
To build and verify the current state, use now the 0.0.6
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.6
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 6 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 5 minutes ago 108MB
container-sample 0.0.3 f655d10572b0 4 minutes ago 148MB
container-sample 0.0.4 6586a96f6808 3 minutes ago 148MB
container-sample 0.0.5 5b176150ad0b 2 minutes ago 54.9MB
container-sample 0.0.6 db9f0a9a0370 1 minute ago 54.9MB
# verify that it works
docker run --rm container-sample:0.0.6
# scan for vulnerabilities
docker scan container-sample:0.0.6
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.
Running with non-root privileges does not affect the size and number of vulnerabilities. The Docker image remains at ~55 MB and having 1 known vulnerability.
Step 6 - Fix Vulnerabilities
We made huge improvements and went down from 58 vulnerabilities to just 1. Fortunately, we get further information from docker scan
about how to deal with the vulnerabilities that are found in the image.
To fix the vulnerability from step 5, use apk
(the package manager of alpine
base image) and update the vulnerable musl
package:
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
--no-restore \
--runtime alpine-x64 \
--self-contained true \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final
RUN adduser --disabled-password \
--home /app \
--gecos '' dotnetuser && chown -R dotnetuser /app
# upgrade musl to remove potential vulnerability
RUN apk upgrade musl
USER dotnetuser
WORKDIR /app
EXPOSE 5000
COPY --from=publish /app/publish .
ENTRYPOINT ["./ContainerSample", "--urls", "http://localhost:5000"]
Status Quo
To build and verify the current state, use now the 0.0.7
tag:
# build the image
cd ContainerSample
docker build . -t container-sample:0.0.7
# check the size
docker image ls | grep container-sample
container-sample 0.0.1 6ba4eeed8e21 7 minutes ago 210MB
container-sample 0.0.2 5696cf7ba51b 6 minutes ago 108MB
container-sample 0.0.3 f655d10572b0 5 minutes ago 148MB
container-sample 0.0.4 6586a96f6808 4 minutes ago 148MB
container-sample 0.0.5 5b176150ad0b 3 minutes ago 54.9MB
container-sample 0.0.6 db9f0a9a0370 2 minutes ago 54.9MB
container-sample 0.0.7 ae9c20b7f786 1 minute ago 57.4MB
# verify that it works
docker run --rm container-sample:0.0.7
# scan for vulnerabilities
docker scan container-sample:0.0.7
## trimmed scan logs
✓ Tested 23 dependencies for known vulnerabilities, no vulnerable paths found.
Removing the last vulnerability from the Docker image results in the Docker image being slightly more prominent. The final Docker image is 57.4MB small and as docker scan
states, no vulnerabilities are left.
Conclusion
You nailed it! You completed seven steps that brought you here, and your Docker image is way smaller and has zero known vulnerabilities. Compare the final result with the first image build by using the standard Dockerfile
. The difference is immersive.
Creating smaller Docker images has several advantages:
- Environments can pull images faster
- Images allocate less storage of your container registry
- CI executions will be faster (push time decreases too)
Hopefully, removing all vulnerabilities from the Docker image does not require any further explanations ;-).