Windows Containers in Kubernetes
Kubernetes supports Windows Server container workloads through Windows worker nodes. A production Kubernetes cluster running Windows containers has a mixed-OS architecture: the control plane (API server, etcd, scheduler, controller manager) runs on Linux nodes, while Windows-specific workloads are scheduled onto Windows Server 2022 worker nodes. This article walks through building a Windows container image, pushing it to a registry, writing the Kubernetes manifests, and deploying to a mixed cluster.
Before proceeding, ensure you have a functional Kubernetes cluster with at least one Windows Server 2022 worker node already joined. Node setup involves installing the Windows node prerequisites (containerd, kubelet, kube-proxy, wins), joining the cluster, and applying the appropriate CNI plugin. Flannel with host-gateway mode and Calico are the two most commonly used CNI plugins for mixed Windows/Linux clusters.
Building a Windows Container Image
Windows container images must be built on a Windows host. You cannot build a Windows container image on a Linux host. The build host should run Windows Server 2022 with Docker or containerd installed, and the base image must match a compatible Windows Server version.
Create a Dockerfile for a simple ASP.NET application:
# Dockerfile for a Windows container ASP.NET app
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS build
WORKDIR /src
COPY ["MyApp/MyApp.csproj", "MyApp/"]
RUN dotnet restore "MyApp/MyApp.csproj"
COPY . .
WORKDIR "/src/MyApp"
RUN dotnet build "MyApp.csproj" -c Release -o /app/build
# Stage 2: Publish
FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish
# Stage 3: Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-nanoserver-ltsc2022 AS final
WORKDIR /app
EXPOSE 80
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Build the image on the Windows Server 2022 build host:
cd C:ProjectsMyApp
docker build -t registry.internal.example.com/myteam/myapp:1.0 .
Important: Windows container images come in two variants — Windows Server Core (windowsservercore) and Nano Server (nanoserver). Nano Server is smaller and has a lower attack surface, but it lacks many Win32 APIs that some applications require. Server Core is larger but broadly compatible. ASP.NET Core applications typically run on Nano Server. Legacy .NET Framework applications require Server Core or the full Windows Server base image.
Windows container images are version-tied. An image built with the ltsc2022 base image will only run on Windows Server 2022 nodes. It will be rejected by Windows Server 2019 nodes. This version matching is enforced by the container runtime and cannot be bypassed.
Pushing the Image to a Registry
After building, push the image to your private registry or a cloud registry. Ensure the Windows build host trusts the registry’s TLS certificate (see the private registry article for certificate installation steps).
# Log in to the registry
docker login registry.internal.example.com
# Push the image
docker push registry.internal.example.com/myteam/myapp:1.0
# Optionally, also push with a 'latest' tag
docker tag registry.internal.example.com/myteam/myapp:1.0 registry.internal.example.com/myteam/myapp:latest
docker push registry.internal.example.com/myteam/myapp:latest
For Kubernetes to pull from a private registry, you must create an image pull secret in the namespace where the workload will run:
kubectl create secret docker-registry regcred
--docker-server=registry.internal.example.com
--docker-username=myteam-deployer
--docker-password=DeployerPassword123!
[email protected]
--namespace=production
Creating the Kubernetes Deployment YAML
A Kubernetes Deployment for Windows containers requires a nodeSelector or node affinity rules to ensure the pod is scheduled only onto Windows nodes. Without this, the scheduler may attempt to run the pod on a Linux node, where it will fail because the Windows container image is incompatible with the Linux kernel.
Create a file named myapp-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: production
labels:
app: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
# Required: schedule only on Windows nodes
nodeSelector:
kubernetes.io/os: windows
# Tolerate the Windows node taint if applied
tolerations:
- key: "os"
operator: "Equal"
value: "windows"
effect: "NoSchedule"
imagePullSecrets:
- name: regcred
containers:
- name: myapp
image: registry.internal.example.com/myteam/myapp:1.0
ports:
- containerPort: 80
protocol: TCP
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ASPNETCORE_URLS
value: "http://+:80"
The nodeSelector: kubernetes.io/os: windows label is automatically applied to Windows nodes by the Kubernetes node lifecycle controller. You do not need to set this label manually.
Creating the Kubernetes Service
A Service exposes the Deployment’s pods to network traffic. For internal access within the cluster, use a ClusterIP Service. For external access, use LoadBalancer (if your cluster has a load balancer controller) or NodePort.
apiVersion: v1
kind: Service
metadata:
name: myapp-svc
namespace: production
spec:
selector:
app: myapp
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
type: ClusterIP
If you need to expose the application externally on a specific port of the Windows node:
type: NodePort
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
nodePort: 30080
Deploying to the Cluster
Apply both manifests from a machine with kubectl configured against your cluster:
kubectl apply -f myapp-deployment.yaml
kubectl apply -f myapp-service.yaml
# Watch pods start up
kubectl get pods -n production -w
# Check deployment status
kubectl rollout status deployment/myapp -n production
Windows pods take longer to start than Linux pods because the container runtime must extract and layer the Windows base image (which can be several gigabytes on first pull). On subsequent starts, the image layers are cached on the node and startup is faster. Expect 2–5 minutes for the first pod pull; subsequent starts typically complete in 30–90 seconds.
Resource Requests and Limits for Windows Containers
Windows containers support CPU and memory resource requests and limits in the same way as Linux containers, but with some behavioural differences. CPU limits on Windows containers are enforced through Windows Job Object scheduling weights, not CFS bandwidth controls as used on Linux. This means Windows CPU limiting is less precise than Linux, and a container may briefly exceed its CPU limit before being throttled.
Memory limits on Windows containers are enforced by the Windows kernel. When a container exceeds its memory limit, the container process is terminated (OOMKill equivalent). Always set appropriate memory limits to prevent a runaway process from exhausting the Windows node’s memory.
A practical resource sizing guide for ASP.NET Core applications on Windows Server 2022:
resources:
requests:
memory: "256Mi" # Typical ASP.NET Core app baseline
cpu: "250m" # 0.25 vCPU
limits:
memory: "1Gi" # Allow headroom for request spikes
cpu: "1000m" # 1 full vCPU maximum
Windows containers also have a baseline memory overhead from the Windows runtime components loaded into each container. Even an empty Windows Nano Server container consumes approximately 150–200 MB of memory at startup. Set your requests accordingly to avoid pod eviction due to insufficient memory headroom.
ConfigMap and Secret Injection
Kubernetes ConfigMaps and Secrets can be injected into Windows containers as environment variables or as volume-mounted files, just as with Linux containers.
Create a ConfigMap for application configuration:
kubectl create configmap myapp-config
--from-literal=DatabaseHost=sqlserver.internal.example.com
--from-literal=DatabasePort=1433
--from-literal=LogLevel=Information
--namespace=production
Create a Secret for sensitive values:
kubectl create secret generic myapp-secrets
--from-literal=DatabasePassword=SecureSqlPassword!
--from-literal=ApiKey=abcdef1234567890
--namespace=production
Reference these in the Deployment spec under the container’s env section:
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: myapp-config
key: DatabaseHost
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: DatabasePassword
Volume-mounted ConfigMaps and Secrets work on Windows containers but require the mount path to use Windows-style paths. Specify the mount path as a Windows path:
volumeMounts:
- name: config-volume
mountPath: "C:\app\config"
volumes:
- name: config-volume
configMap:
name: myapp-config
Windows Container Logging to Stdout
Kubernetes collects container logs from stdout and stderr. Windows containers support this, but some Windows applications write logs to the Windows Event Log or to files rather than stdout. For Kubernetes log collection to work correctly, your application must write to stdout/stderr.
ASP.NET Core applications configured with the console logging provider write to stdout automatically. Verify this in your Program.cs:
// Program.cs — ensure console logging is configured
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
For legacy applications that write to files, you can use a sidecar logging container or configure a log forwarder (such as Fluentbit for Windows) to tail the log files and emit them to stdout. View logs with:
kubectl logs -n production deployment/myapp --tail=100 -f
Updating Deployments with kubectl rollout
To update a running deployment to a new image version, update the image tag in the Deployment manifest and apply, or use the kubectl set image command:
# Update to a new image version
kubectl set image deployment/myapp
myapp=registry.internal.example.com/myteam/myapp:1.1
-n production
# Monitor the rolling update progress
kubectl rollout status deployment/myapp -n production
# If the new version is broken, roll back immediately
kubectl rollout undo deployment/myapp -n production
# View rollout history
kubectl rollout history deployment/myapp -n production
The default rolling update strategy creates new pods before terminating old ones. Because Windows pods have a longer startup time, set an appropriate minReadySeconds value to prevent Kubernetes from marking a new Windows pod as available before it has fully initialised:
spec:
minReadySeconds: 60
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
With maxUnavailable: 0 and maxSurge: 1, the rolling update adds one new pod before removing one old pod, ensuring there is always at least the desired number of pods serving traffic throughout the update. This is the recommended strategy for Windows container deployments where pod startup latency is a factor.