Let's take a look at different options for deploying an ASP.NET
application in a container on Linux, along with the benefits and tradeoffs. The example application I'll be using is an F# ASP.NET demo application:
github.com/tyauvil/fsharp-beats
Container deployment is converging on some best practices for building application containers. Specifically, minimizing or eliminating dependencies and optimizing for size. This has two major benefits, less attack surface and faster deployments. To accomplish this with Rust or Go, the standard practice is to compile an application as a statically compiled binary and copying the resulting artifact to a scratch container so the end result is a small container with a minimal attack surface. That is not possible in .NET Core 3 but is a planned feature of .NET Core 5, however that feature is not available as of the currently available .NET Core 5.0 Preview 1.
The smallest possible deployment image uses the PublishSingleFile
parameter in the build process. This creates a compressed executable file that packages all dependencies into a zip file and decompresses them to a temporary directory in the filesystem on first execution. The container will still need a handful of dynamic libraries in order to run. One needs to be aware that the optional Alpine container uses the musl
C library which is different from the GNU C library used in Debian, the standard container OS. A single file executable compiled for Alpine will not run on Debian and vice versa.
.NET Core Runtime parameters:
Alpine: linux-musl-x64
Debian: linux-x64
Two stage container build
Unlike a Python container for example, an ASP.NET
container benefits greatly from a two stage build. The first image has all of the .NET Core compiler components and during the build several hundred megabytes of temporary files are created. These are discarded when the compiled application is copied over to the runtime container.
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env
WORKDIR /app
# Copy fsproj and restore as distinct layers
COPY *.fsproj ./
RUN dotnet restore
# Copy everything else and build
COPY . ./
RUN dotnet publish -c Release --runtime linux-x64 -p:PublishReadyToRun=true \
-o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=build-env /app/out .
# install tini to act as init
RUN apt-get update && apt-get install -y tini &&\
rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--", "dotnet", "fsharp-beats.dll"]
The build adds about 111MB to the mcr.microsoft.com/dotnet/core/aspnet:3.1
container image for a total of 322MB. The main benefit of the two stage build is a savings of ~1.2GB over the intermediate container which grows to 1.52GB during the build process.
$ podman images
REPOSITORY TAG IMAGE ID CREATED SIZE
fsharp-beats latest 1d1fe4deb61a 2 hours ago 322 MB
<none> <none> b3786230b0b1 2 hours ago 1.52 GB
dotnet/core/sdk 3.1 336698ad1713 7 hours ago 703 MB
dotnet/core/aspnet 3.1 9ac62e540b12 7 hours ago 211 MB
Build using the PublishSingleFile
parameter the build process creates a single compressed executable that can be packaged in a container with minimal dependencies. Microsoft publishes the mcr.microsoft.com/dotnet/core/runtime-deps
container image expressly for the purpose of running these single file executables. It won't run conventionally compiled .NET Core applications. In this example I use the 3.1-alpine
tag which is only 10.5MB, one tenth the size of the Debian based runtime-deps
container.
$ podman images | grep runtime-deps
REPOSITORY TAG IMAGE ID CREATED SIZE
runtime-deps 3.1-alpine cd1cb1f0cfc3 24 hours ago 10.5 MB
runtime-deps 3.1 b52283a2b18f 24 hours ago 114 MB
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-alpine AS build-env
WORKDIR /app
# Copy fsproj and restore as distinct layers
COPY *.fsproj ./
RUN dotnet restore
# Copy everything else and build
COPY . ./
RUN dotnet publish -c Release -r linux-musl-x64 -p:PublishSingleFile=true -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/core/runtime-deps:3.1-alpine
COPY --from=build-env /app/out/fsharp-beats /usr/local/bin/
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--", "fsharp-beats"]
There are pretty big image size savings by using the PublishSingleFile
parameter. This build is also using an Alpine container with a few additional C libraries that are required because the binary is not statically linked, a feature coming in .NET Core 5.0.
$ podman images | grep singleFile
REPOSITORY TAG IMAGE ID CREATED SIZE
fsharp-beats singleFile 4c781f411c21 15 seconds ago 74.5 MB
Kubernetes
Now to deploy to Kubernetes. There are a few considerations here, since we are definining a port other than 80
because we are running the conatiner as a non-root user, we need to pass in an environment variable to the container to configure ASP.NET
to listen on a different port with the ASPNETCORE_URLS
variable. This is similar to FLASK_URL
in a Python flask application. We also specify a liveness probe to check a route that returns 200, which confirms the application is running. Bypassing the /
route lowers the performance impact of the liveness probe. The liveness probe can also use custom headers which can be used to perform certain functions in the application or make it easy to filter them out while looking at application logs.
apiVersion: apps/v1
kind: Deployment
metadata:
name: fsharp-beats-deployment
spec:
selector:
matchLabels:
app: fsharp-beats
replicas: 2
template:
metadata:
labels:
app: fsharp-beats
spec:
securityContext:
runAsUser: 65534 # run as the nobody/nogroup user
containers:
- name: fsharp-beats
image: "tyauvil/fsharp-beats:latest"
ports:
- containerPort: 5000
env:
- name: ASPNETCORE_URLS
value: "http://+:5000"
- name: liveness
image: k8s.gcr.io/liveness
args:
- /server
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 15
Since this application is designed to run in Kubernetes, logging is done directly to standard out (or the console). This allows one to view logs from the cli.
let configureLogging (builder : ILoggingBuilder) =
// Set up the Console logger
builder.AddConsole() |> ignore
After deploying the container we can see that the liveness probe is working as expected and we get the correct output from the root path of the demo application. This also shows that ASP.NET
applications can run perfectly happy in a Linux container running in Kubernetes without any workarounds. The "cloud native" single binary deploy is coming in the next version however .NET Core is perfectly suitable for deploying in containers today.
$ kubectl apply -f k8s/deployment.yml
$ kubectl expose deployment fsharp-beats-deployment --type=NodePort
$ curl http://192.168.39.213:31654
@035.beats
$ kubectl logs fsharp-beats-deployment-7c5d9c4dcb-j9dww fsharp-beats | tail -8
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET http://192.168.39.213:31654/
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished in 0.6899ms 200 text/plain; charset=utf-8
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET http://10.1.0.38:5000/healthz
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished in 0.2705ms 200 text/plain; charset=utf-8
I hope this blog post shows how running .NET Core applications on Linux/Kubernetes is relatively straightforward and has very few caveats. There is a big opportunity here to migrate .NET applications from Windows to Linux for both reliability and cost savings reasons. This is no longer Steve Ballmer's Microsoft.
References:
Making a tiny .NET Core 3.0 entirely self-contained single executable
Top comments (0)