Sitemap
Google Cloud - Community

A collection of technical articles and blogs published or curated by Google Cloud Developer Advocates. The views expressed are those of the authors and don't necessarily reflect those of Google.

Sail Sharp, 12 tips to optimize and secure your .NET containers for Kubernetes

17 min readApr 1, 2023

--

Updated on January 7th, 2026— .NET 10 and now using the dhi.io container images.

Press enter or click to view image in full size
Image

In February 2021, I got this opportunity to deliver this talk Sail Sharp, .NET Core & Kubernetes for the .NET Meetup in Quebec city (it was in French). I illustrated the best practices to prepare any .NET applications for Kubernetes. I was using the cartservice app (in .NET) from the very popular Online Boutique sample apps.

Since then, I have been one of the top contributors to the Online Boutique repository. I contributed to the golang, python, dotnet, java and nodejs apps. This repo is my playground. I have been learning a lot by contributing to this repo. Most of my contributions were about optimizing and securing the container images for all these apps.

Here is the high-level timeline of my contributions related to the cartservice app:

As a side note, I started my career with the .NET Framework version 3.0 (only on Windows at that time) back in 2006! Since then, I have been amazed about the evolution of the .NET ecosystem. And to be honest all of these contributions gave me a reason to stay up-to-date and have a lot of fun while learning more about containers and Kubernetes! :)

Wow! Quite a ride, isn’t it?

Today, in this blog post, I will highlight 12 tips to optimize and secure your .NET containers based on what I have learned with all of that:

  1. Use the multi-stage build approach
  2. Provide Multi-architectures support
  3. Reduce the size of the bundled application
  4. Reduce the size with alpine
  5. Reduce the surface of attack with distroless
  6. Use immutable base container images
  7. Update dependencies with Dependabot or Renovate
  8. Reduce the size of your final container with .dockerignore
  9. Secure unprivileged/non-root container
  10. Protect read-only container filesystem
  11. Optimize the size with the dotnet/runtime-deps
  12. Accelerate startup time with compiled native code (AOT)

tl,dr

If you want to see the final Dockerfile and the Deployment manifest to deploy a secure and optimized .NET application in Kubernetes, feel free to directly jump to the end of this blog post.

Disclaimer

Whereas most of the concepts could be applicable to Windows containers, this blog post is only covering Linux containers.

Create a minimal ASP.NET app

Create a folder where we will drop all the files needed for this blog post:

mkdir my-sample-app
cd my-sample-app

Create a minimal and simple ASP.NET app that we will use for this blog post.

Program.cs:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/healthz");
app.MapGet("/", () => "Hello, World!");
app.Run();

my-sample-app.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

1. Use the multi-stage build approach

Create our first Dockerfile with a multi-stage build. That’s not the final one, until then, please bear with me:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /app
COPY my-sample-app.csproj .
RUN dotnet restore my-sample-app.csproj \
--use-current-runtime
COPY . .
RUN dotnet publish my-sample-app.csproj \
--use-current-runtime \
-c release \
-o /my-sample-app \
--no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=builder /my-sample-app .
ENTRYPOINT ["/app/my-sample-app"]

This Dockerfile is already using the multi-stage build, which optimizes the final size of the image by layering the build and leaving only required artifacts.

Let’s build this container image locally:

docker build \
-t my-sample-app:init \
--sbom=true \
--provenance=mode=max \
.

Note: I’m using the highly recommended --sbom=true --provenance=mode=max parameters to generate SBOMs, enhancing transparency and facilitating the proactive vulnerabilities management.

We can see that the size of the container image is 343 MB locally on disk.

You can now run this container:

docker run -d -p 8080:8080 my-sample-app:init

Important to notice here, that since .NET 8, the default port is not anymore 80 (privileged) but is now 8080 (unprivileged), great to see security best practices applied by default here!

You can now test that this container is working successfully, curl localhost:8080:

Hello, World!

Great, congrats!

2. Provide Multi-architectures support

At this stage you may want to run this container on arm64 arch, locally (MacOS) or to save some cost with the Nodes of your Kubernetes cluster.

For this we need to apply these changes in the Dockerfile:

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
ARG TARGETARCH
WORKDIR /app
COPY my-sample-app.csproj .
RUN dotnet restore my-sample-app.csproj \
-a $TARGETARCH
COPY . .
RUN dotnet publish my-sample-app.csproj \
-a $TARGETARCH \
-c release \
-o /my-sample-app \
--no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=builder /my-sample-app .
ENTRYPOINT ["/app/my-sample-app"]

You can build your container like this now:

docker build \
-t my-sample-app:init \
--sbom=true \
--provenance=mode=max \
.

docker build \
-t my-sample-app:init \
--sbom=true \
--provenance=mode=max \
--platform linux/arm64
.

docker build \
-t my-sample-app:init \
--sbom=true \
--provenance=mode=max \
--platform linux/amd64
.

You can now distribute your container image as one image supporting multi-platforms in it’s manifest:

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: my-sample-app:init
provenance: mode=max
sbom: true

Once published, you’ll be able to see the multi-platforms support of your container image:

docker manifest inspect my-sample-app:init | grep architecture
"architecture": "amd64",
"architecture": "arm64",

3. Reduce the size of the bundled application

When using dotnet publish, we can use different features to optimize the size of the bundled application:

Should you use self-contained or framework-dependent publishing in Docker images?

Update the my-sample-app.csproj file by adding this entry: <SelfContained>true</SelfContained>. Or you can update the Dockerfile by adding --self-contained true to the dotnet publish instruction.

We can see that the size of the container image is now 503 MB (+ 160 MB) on disk locally.

Update the my-sample-app.csproj file by adding this entry: <PublishTrimmed>true</PublishTrimmed> and <TrimMode>full</TrimMode>. Or you can update the Dockerfile by adding -p:PublishTrimmed=true -p:TrimMode=full to the dotnet publish instruction.

We can see that the size of the container image is now 387 MB (- 116 MB) on disk locally.

Note: If your application doesn’t work with <TrimMode>full</TrimMode>, you can use <TrimMode>partial</TrimMode> instead.

Update the my-sample-app.csproj file by adding this entry: <PublishSingleFile>true</PublishSingleFile>. Or you can update the Dockerfile by adding -p:PublishSingleFile=true to the dotnet publish instruction.

We can see that the size of the container image is now 374 MB (- 13 MB) on disk locally. Nice!

4. Reduce the size with alpine

To reduce the surface of attack or to avoid dealing with security vulnerabilities debt, using the smallest base image is a must.

You can find all the dotnet container images available here:

We can choose the alpine one: dotnet/sdk:10.0-alpine3.22 and dotnet/runtime:10.0-alpine3.22, if we rebuild the image we could see that now the size of the container image is down to 168 MB (- 202 MB). What?! Yes! 😍

Here is our Dockerfile at this stage:

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine3.22 AS builder
ARG TARGETARCH
WORKDIR /app
COPY my-sample-app.csproj .
RUN dotnet restore my-sample-app.csproj \
-a $TARGETARCH
COPY . .
RUN dotnet publish my-sample-app.csproj \
-a $TARGETARCH \
-c release \
-o /my-sample-app \
--no-restore

FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine3.22
WORKDIR /app
COPY --from=builder /my-sample-app .
ENTRYPOINT ["/app/my-sample-app"]

Knowing that Microsoft is providing the default alpine container images without tzdata and icu to optimize the container image size. More info here and here. The most common use cases out there need globalization and timezone capabilities, that’s why I’ll illustrate below the -extra container images variants (including both tzdata and icu).

For that you can change the final base image by mcr.microsoft.com/dotnet/runtime:10.0-alpine3.22-extra instead. Once rebuilt the container image size on disk is now at 221 MB. Still a great optimization!

5. Go distroless!

We can do even more, even better. We can go distroless. I wrote another blog post to share some learnings and comparisons between distroless and alpine here.

We have a couple of distrolessoptions listed below. Knowing that Microsoft is providing the default distroless container images without tzdata and icu to optimize the container image size. More info here and here. The most common use cases out there need globalization and timezone capabilities, that’s why I’ll illustrate below the -extra container images variants (including both tzdata and icu).

  • noble-chiseled (Ubuntu): mcr.microsoft.com/dotnet/sdk:10.0-noble and mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled-extra, if we rebuild the image we could see that now the size of the container image is down to 223 MB.
  • azurelinux3.0-distroless (Azure Linux): mcr.microsoft.com/dotnet/sdk:10.0-azurelinux3.0 and mcr.microsoft.com/dotnet/runtime:10.0-azurelinux3.0-distroless-extra, if we rebuild the image we could see that now the size of the container image is down to 243 MB.
  • dhi.io (Docker Hardened Image — DHI): we have two options here: alpine (dhi.io/dotnet:10.0-sdk-alpine3.22 and dhi.io/dotnet:10.0-alpine3.22) or debian (dhi.io/dotnet:10.0-sdk-debian13 and dhi.io/dotnet:10.0-debian13). If we rebuild the image we could see that now the size of the container image is at 201 MB for the alpine one and 244 MB for the debian one. That’s good to see that with dhi.io there either the glibc or musl options.

Very impressive! Isn’t it?!

Here is where we are at in terms of container image size at this stage:

  • alpine (extra) → 221 MB
  • noble-chiseled (extra) → 223 MB
  • azurelinux (extra) → 243 MB
  • dhi.io (alpine) → 201 MB
  • dhi.io (debian) → 244 MB

With that being said, what are the differences between them? Should we take the alpine one because that’s the smallest image?

Good questions, glad you asked!

Let’s see the main issue with the alpine (extra) one in this context. If we use syft with this image we could see this:

[22 packages]
NAME VERSION TYPE
Microsoft.NETCore.App.Runtime.linux-musl-x64 10.0.2 dotnet
alpine-baselayout 3.7.0-r0 apk
alpine-baselayout-data 3.7.0-r0 apk
alpine-keys 2.5-r0 apk
alpine-release 3.22.2-r0 apk
apk-tools 2.14.9-r3 apk
busybox 1.37.0-r19 apk
busybox-binsh 1.37.0-r19 apk
ca-certificates-bundle 20250911-r0 apk
icu-data-full 76.1-r1 apk
icu-libs 76.1-r1 apk
libapk2 2.14.9-r3 apk
libcrypto3 3.5.4-r0 apk
libgcc 14.2.0-r6 apk
libssl3 3.5.4-r0 apk
libstdc++ 14.2.0-r6 apk
musl 1.2.5-r10 apk
musl-utils 1.2.5-r10 apk
scanelf 1.3.8-r1 apk
ssl_client 1.37.0-r19 apk
tzdata 2025c-r0 apk
zlib 1.3.1-r2 apk

What does that mean?

We can see that this container image contains busybox. This means that someone getting into your running container has access to a shell, can use tool like wget to download malicious files, etc. It’s a huge security risk here. Another important point is the fact that alpine is based on musl, on the other hand, the distroless ones are based on glibc. This blog post: Why I Will Never Use Alpine Linux Ever Again highlights some known issues with alpine/musl. Good to keep in mind too.

azurelinux3.0-distroless, noble-chiseled and dhi.io are very attractive because they are bringing the concept of distroless. They are container images that do not contain the complete or full-blown OS with system utilities installed. This blog post Image sizes miss the point explains really well why the distroless ones are very attractive:

To reduce debt, reduce image complexity not [just] size.

By using a tool like syft, we could see that the distroless ones are less complex than the alpine one, with less dependencies, reducing the debt and surface of risks. See results below.

For the image based on azurelinux3.0-distroless (extra):

[19 packages]
NAME VERSION TYPE
Microsoft.NETCore.App.Runtime.linux-x64 10.0.1 dotnet
SymCrypt 103.8.0-1.azl3 rpm (+1 duplicate)
SymCrypt-OpenSSL 1.9.3-1.azl3 rpm (+1 duplicate)
azurelinux-release 3.0-37.azl3 rpm
distroless-packages-minimal 3.0-5.azl3 rpm
filesystem 1.1-21.azl3 rpm
glibc 2.38-16.azl3 rpm (+1 duplicate)
icu 72.1.0.3-2.azl3 rpm (+1 duplicate)
libgcc 13.2.0-7.azl3 rpm
libstdc++ 13.2.0-7.azl3 rpm
openssl 3.3.5-1.azl3 rpm (+1 duplicate)
openssl-libs 3.3.5-1.azl3 rpm
prebuilt-ca-certificates 2179050:3.0.0-14.azl3 rpm
tzdata 2025b-1.azl3 rpm

For the image based on noble-chiseled (extra):

[13 packages]
NAME VERSION TYPE
Microsoft.NETCore.App.Runtime.linux-x64 10.0.1 dotnet
base-files 13ubuntu10.3 deb
ca-certificates 20240203 deb
gcc-14 14.2.0-4ubuntu2~24.04 deb
gcc-14-base 14.2.0-4ubuntu2~24.04 deb
libc6 2.39-0ubuntu8.6 deb
libgcc-s1 14.2.0-4ubuntu2~24.04 deb
libicu74 74.2-1ubuntu3.1 deb
libssl3t64 3.0.13-0ubuntu3.6 deb
libstdc++6 14.2.0-4ubuntu2~24.04 deb
openssl 3.0.13-0ubuntu3.6 deb
tzdata 2025b-0ubuntu0.24.04.1 deb
tzdata-legacy 2025b-0ubuntu0.24.04.1 deb

For the image based on dhi.io (alpine):

[17 packages]
NAME VERSION TYPE
Microsoft.NETCore.App.Runtime.alpine.3.23-x64 10.0.1 dotnet
alpine-baselayout-data 3.7.0-r0 apk
brotli-libs 1.1.0-r2 apk
ca-certificates-bundle 20250911-r0 apk
dotnet-host 10.0.1-r0 apk
dotnet10-hostfxr 10.0.1-r0 apk
dotnet10-runtime 10.0.1-r0 apk
icu-data-full 76.1-r1 apk
icu-libs 76.1-r1 apk
libcrypto3 3.5.4-r0 apk
libgcc 14.2.0-r6 apk
libssl3 3.5.4-r0 apk
libstdc++ 14.2.0-r6 apk
lttng-ust 2.13.9-r0 apk
musl 1.2.5-r10 apk
tzdata 2025c-r0 apk
zlib 1.3.1-r2 apk

For the image based on dhi.io (debian):

[55 packages]
NAME VERSION TYPE
Microsoft.NETCore.App.Runtime.linux-x64 10.0.1 dotnet
base-files 13.8+deb13u2 deb (+1 duplicate)
ca-certificates 20250419 deb (+1 duplicate)
debconf 1.5.91 deb (+1 duplicate)
gawk 1:5.2.1-2+b1 deb (+1 duplicate)
gcc-14-base 14.2.0-19 deb (+1 duplicate)
libc6 2.41-12 deb (+1 duplicate)
libcom-err2 1.47.2-3+b3 deb (+1 duplicate)
libgcc-s1 14.2.0-19 deb (+1 duplicate)
libgmp10 2:6.3.0+dfsg-3 deb (+1 duplicate)
libgssapi-krb5-2 1.21.3-5 deb (+1 duplicate)
libicu76 76.1-4 deb (+1 duplicate)
libk5crypto3 1.21.3-5 deb (+1 duplicate)
libkeyutils1 1.6.3-6 deb (+1 duplicate)
libkrb5-3 1.21.3-5 deb (+1 duplicate)
libkrb5support0 1.21.3-5 deb (+1 duplicate)
libmpfr6 4.2.2-1 deb (+1 duplicate)
libreadline8t64 8.2-6 deb (+1 duplicate)
libsigsegv2 2.14-1+b2 deb (+1 duplicate)
libssl3t64 3.5.4-1~deb13u1 deb (+1 duplicate)
libstdc++6 14.2.0-19 deb (+1 duplicate)
libtinfo6 6.5+20250216-2 deb (+1 duplicate)
libzstd1 1.5.7+dfsg-1 deb (+1 duplicate)
openssl 3.5.4-1~deb13u1 deb (+1 duplicate)
openssl-provider-legacy 3.5.4-1~deb13u1 deb (+1 duplicate)
readline-common 8.2-6 deb (+1 duplicate)
tzdata 2025b-4+deb13u1 deb (+1 duplicate)
zlib1g 1:1.3.dfsg+really1.3.1-1+b1 deb (+1 duplicate)

That’s not all, let’s illustrate what “0 CVEs” means for the distroless base images. Let’s give trivy a try for these three container images, here below is the summary of the associated scans:

  • For the image based on alpine (extra):
my-sample-app:alpine (alpine 3.22.2)

Total: 6 (UNKNOWN: 0, LOW: 3, MEDIUM: 3, HIGH: 0, CRITICAL: 0)

┌───────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├───────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ busybox │ CVE-2024-58251 │ MEDIUM │ fixed │ 1.37.0-r19 │ 1.37.0-r20 │ In netstat in BusyBox through 1.37.0, local users can launch │
│ │ │ │ │ │ │ of networ... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2024-58251 │
│ ├────────────────┼──────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2025-46394 │ LOW │ │ │ │ In tar in BusyBox through 1.37.0, a TAR archive can have │
│ │ │ │ │ │ │ filenames... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2025-46394 │
├───────────────┼────────────────┼──────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ busybox-binsh │ CVE-2024-58251 │ MEDIUM │ │ │ │ In netstat in BusyBox through 1.37.0, local users can launch │
│ │ │ │ │ │ │ of networ... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2024-58251 │
│ ├────────────────┼──────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2025-46394 │ LOW │ │ │ │ In tar in BusyBox through 1.37.0, a TAR archive can have │
│ │ │ │ │ │ │ filenames... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2025-46394 │
├───────────────┼────────────────┼──────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ ssl_client │ CVE-2024-58251 │ MEDIUM │ │ │ │ In netstat in BusyBox through 1.37.0, local users can launch │
│ │ │ │ │ │ │ of networ... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2024-58251 │
│ ├────────────────┼──────────┤ │ │ ├──────────────────────────────────────────────────────────────┤
│ │ CVE-2025-46394 │ LOW │ │ │ │ In tar in BusyBox through 1.37.0, a TAR archive can have │
│ │ │ │ │ │ │ filenames... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2025-46394 │
└───────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴──────────────────────────────────────────────────────────────┘
  • For the image based on azurelinux3.0-distroless (extra):
my-sample-app:azure (azurelinux 3.0)
====================================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
  • For the image based on noble-chiseled (extra):
my-sample-app:chiseled (ubuntu 24.04)

Total: 2 (UNKNOWN: 0, LOW: 2, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

┌────────────┬────────────────┬──────────┬──────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├────────────┼────────────────┼──────────┼──────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────────┤
│ libssl3t64 │ CVE-2024-41996 │ LOW │ affected │ 3.0.13-0ubuntu3.6 │ │ openssl: remote attackers (from the client side) to trigger │
│ │ │ │ │ │ │ unnecessarily expensive server-side... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2024-41996 │
├────────────┤ │ │ │ ├───────────────┤ │
│ openssl │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
└────────────┴────────────────┴──────────┴──────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────────┘

And the winner is…

With all of that being said (size, number of packages and number of CVEs), for now, for my own context, I have decided to use the dhi.io (alpine). I do think that the noble-chiseled and azurelinux are also good distroless options to consider.

6. Use immutable base images

Use a specific tag or version for your base image, not latest is important for traceability. But a tag or version is mutable, which means that you can’t guarantee which content of the container you are using. Using a digest will guarantee that, a digest is immutable.

Update the Dockerfile with these two base images:

dhi.io/dotnet:10.0.101-sdk-alpine3.22@sha256:f0ff1a953f56559fab4af0947f4b55dcf47939ca05b20db87eef54433e8dbef1
dhi.io/dotnet:10.0.1-alpine3.22@sha256:a0662a13559d66a465a3de00e58476e79671790c6b2d1ac44fee17be0fe1e458

Note: it’s also highly encouraged that you store these two base images in your own private container registry and update your Dockerfile to point to them. You will guarantee their provenance, you will be able to scan them, etc.

7. Update dependencies with Dependabot or Renovate

An important aspect is to keep your dependencies up-to-date in order to fix CVEs, catch new features, etc. One way to help you with that, in an automated fashion, is to leverage tools like Renovate or Dependabot if you are using GitHub.

Here is an example of how you can configure Dependabot to keep your container base images as well as your .NET packages up-to-date:

cat <<EOF > .github/dependabot.yml
version: 2
registries:
dhi:
type: docker-registry
url: dhi.io
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_OAT }}
updates:
- package-ecosystem: "docker"
directory: "/"
registries:
- dhi
schedule:
interval: "daily"
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "daily"
EOF

8. Reduce the size of your final container with .dockerignore

Use a .dockerignore file to ignore files that do not need to be added to the image. For examples, any bin, debug, obj, etc. folders that you may generate and need if you build and test your application locally.

Here is an example of how your .dockerignore could look like:

cat <<EOF > .dockerignore
**/*.sh
**/*.bat
**/bin/
**/obj/
**/out/
Dockerfile*
EOF

9. Secure unprivileged/non-root container

For security purposes, always ensure that your images run as non-root by defining USER in your Dockerfile.

Since .NET 8, ASP.NET Core apps now listen on port 8080 by default. Before that and up until .NET 7, it was listening on port 80. The problem is that port 80 is a privileged port that requires root permission. For making the container unprivileged, even if it’s already by default the case, we will configure its port to 8080:

EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
USER 65532

Note: Until .NET 7, you may have seen something like this: ENV ASPNETCORE_HTTP_URLS=http://*:8080., instead of ENV ASPNETCORE_HTTP_PORTS=8080.

You can now run this container with -u 65532 on port 8080:

docker run \
-d \
-p 80:8080 \
-u 65532 \
my-sample-app
curl localhost:80

10. Protect read-only container filesystem

In .NET 7, to make the container in read-only mode on filesystem, DOTNET_EnableDiagnostics needed to be turned off. DOTNET_EnableDiagnostics is used for debugging, profiling, and other diagnostics.

ENV DOTNET_EnableDiagnostics=0

You can now run this container with --read-only:

docker run \
-d \
-p 80:8080 \
-u 65532 \
--read-only \
my-sample-app
curl localhost:80

This part is not anymore required since .NET 8.

11. Optimize the size with the dotnet/runtime-deps

We are already using the self-deployment approach, so we can also use the dotnet/runtime-deps final base image if we want to get another optimization in size for the container image.

Update the Dockerfile with dotnet/runtime-deps:9.0 as the final base image for example with the noble-chiseled one.

Note that the dhi.io catalog doesn’t support it yet, if that’s something you are interested in, feel free to file an issue here in order to help the team prioritizing it.

12. Accelerate startup time with compiled native code (AOT)

Something you may be looking for, is optimizing the startup time of your app by compiling it with native code with a feature called: Native AOT.

Publishing your app as Native AOT produces an app that’s self-contained and that has been ahead-of-time (AOT) compiled to native code. Native AOT apps have faster startup time and smaller memory footprints.

I learned a lot from this resource too: The minimal API AOT compilation template (andrewlock.net).

To leverage this feature, you will need to remove the <PublishSingleFile>true</PublishSingleFile> entry (or set this property to false) in your .csproj otherwise you will have an error message:

PublishAot and PublishSingleFile cannot be specified at the same time.

Update your .csproj file with:

<PublishSingleFile>false</PublishSingleFile>
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>

You will also need to use the nightly container images because these images supporting AOT are for now only provided in Public Preview. There are some limitations to be aware of with Native AOT deployment.

Note that the dhi.io catalog doesn’t support it yet, if that’s something you are interested in, feel free to file an issue here in order to help the team prioritizing it.

If you rebuild your container image, let’s say based on noble-chiseled, the new container image size will increase around + 10 MB.

That’s a wrap!

Congrats!

With these 11 tips illustrated throughout this blog post, we:

  • Reduced the size of the application bundled and the container itself
  • Reduced the surface of attack of the container image (distroless was chosen)
  • Illustrated tips to improve the day-2 operations in order to keep our dependencies up-to-date
  • Made the container running as unprivileged/non-root in read-only on filesystem

Here is the final Program.cs:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/healthz");
app.MapGet("/", () => "Hello, World!");
app.Run();

Here is the final .csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<PublishSingleFile>true</PublishSingleFile>
</PropertyGroup>
</Project>

Here is the final Dockerfile:

FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10.0.101-sdk-alpine3.22@sha256:f0ff1a953f56559fab4af0947f4b55dcf47939ca05b20db87eef54433e8dbef1 AS builder
ARG TARGETARCH
WORKDIR /app
COPY my-sample-app.csproj .
RUN dotnet restore my-sample-app.csproj \
-a $TARGETARCH
COPY . .
RUN dotnet publish my-sample-app.csproj \
-a $TARGETARCH \
-c release \
-o /my-sample-app \
--no-restore

FROM dhi.io/dotnet:10.0.1-alpine3.22@sha256:a0662a13559d66a465a3de00e58476e79671790c6b2d1ac44fee17be0fe1e458
WORKDIR /app
COPY --from=builder /my-sample-app .
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
USER 65532
ENTRYPOINT ["/app/my-sample-app"]

If you want to deploy this container image in a secure manner locally, here is the associated command:

docker run \
-d \
-p 8080:8080 \
--read-only \
--cap-drop=ALL \
--user=65532 \
my-sample-app
curl localhost:8080

If you want to deploy this container image in a secure manner in Kubernetes, here is the associated Deployment resource:

apiVersion: apps/v1
kind: Deployment
metadata:
name: my-sample-app
labels:
app: my-sample-app
spec:
selector:
matchLabels:
app: my-sample-app
template:
metadata:
labels:
app: my-sample-app
spec:
automountServiceAccountToken: false
securityContext:
fsGroup: 65532
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: my-sample-app
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
image: ghcr.io/mathieu-benoit/my-sample-app:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
nodeSelector:
kubernetes.io/os: linux

You can find all the source code here: mathieu-benoit/sail-sharp.

You can also find the source code of the cartservice gRPC-based app in the OnlineBoutique repository.

Finally, if you want to learn more about how to enforce such unprivileged capabilities for your containers, I invite you to read my other blog post: Improve your Kubernetes security posture, with the Pod Security Admission (PSA).

You are now ready to Sail Sharp! Hope you enjoyed that one! Cheers!

--

--

Google Cloud - Community
Google Cloud - Community

Published in Google Cloud - Community

A collection of technical articles and blogs published or curated by Google Cloud Developer Advocates. The views expressed are those of the authors and don't necessarily reflect those of Google.

Mathieu Benoit
Mathieu Benoit

Written by Mathieu Benoit

SE @ Docker | CNCF Ambassador | GDE Cloud