Shipping It

Ship the orders API as a non-root container to Kubernetes through a CI pipeline, with migrations, graceful shutdown and a rollout checklist.

Syntaxdotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer -p:ContainerRepository=orders-api \ -p:ContainerFamily=noble-chiseled -p:ContainerImageTags=$GITHUB_SHA kubectl rollout status deployment/orders-api --timeout=5m

Shipping the project means four artefacts that change together: a container image, Kubernetes manifests, a migration bundle and a pipeline that builds, tests and releases them in the right order. The goal is that a release is boring: nobody notices it, no request fails, and rolling back is one command.

The image

A multi-stage Dockerfile restores in the mcr.microsoft.com/dotnet/sdk:10.0 image, copying the project files first so the restore layer is cached until a package changes, then publishes into mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled. Chiseled images have no shell and no package manager, so there is far less to patch and to exploit. The API needs no ICU because it formats nothing by culture; an app that does uses -noble-chiseled-extra. Since .NET 8 the images listen on 8080 (ASPNETCORE_HTTP_PORTS) and include the non-root user app, UID 1654. dotnet publish /t:PublishContainer builds an equivalent image without a Dockerfile; choose it when you have no extra files or OS packages to add.

The manifests

A Deployment with at least three replicas, maxUnavailable: 0 so capacity never drops during a rollout, CPU and memory requests (the .NET GC sizes its heaps from the container's memory limit), startup, liveness and readiness probes on the two health endpoints from the observability lesson, and configuration from a ConfigMap plus a Secret for the connection string. A PodDisruptionBudget keeps node drains from taking all replicas at once.

Graceful shutdown

When a pod is deleted, Kubernetes removes it from the Service's endpoints and sends SIGTERM at roughly the same time, and the endpoint removal takes a moment to reach every proxy. An app that stops accepting connections instantly drops requests still being routed to it. The fix is a preStop sleep of a few seconds before SIGTERM; chiseled images have no sleep binary, so use the native sleep lifecycle action rather than an exec command. After SIGTERM, ASP.NET Core stops accepting new connections, lets in-flight requests finish, then stops hosted services, all within HostOptions.ShutdownTimeout (30 seconds by default). Keep terminationGracePeriodSeconds above preStop plus that timeout, or the kubelet kills the process mid-order.

The pipeline

On a pull request: restore, build with warnings as errors, unit and integration tests (Testcontainers needs Docker on the runner), NuGet audit. On main: the same plus the migration bundle, then a release job that waits for approval on the production environment, pushes an image tagged with the commit SHA (never only latest), runs the bundle and rolls out. The release job runs on a self-hosted runner inside the cluster's network, because the production database must never be opened to hosted runners on the internet. A staging environment is the same job pointed at another environment, run first.

Rollout checklist

CheckWhy
Migration is backwards compatible with the running imageOld pods keep serving while the new ones start
Image referenced by digest, or by a SHA tag in a registry with immutable tagsA re-pushed tag cannot change what runs
Readiness passes before traffic; maxUnavailable: 0No capacity dip and no cold pods in rotation
preStop sleep and grace period above ShutdownTimeoutNo dropped in-flight orders
Dashboards: 5xx rate, p99 latency, orders.placedDecide in minutes whether to roll back
kubectl rollout undo testedRollback must not need a new build

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Let an order placement that started 200 ms before SIGTERM commit and return 201 while the pod shuts down during a rolling update.
  • Release orders-api by commit SHA so `kubectl rollout undo` returns to the exact previous image without rebuilding anything.
  • Run the migration bundle once from the pipeline before the rollout, instead of letting three replicas race to migrate at startup.

More examples

Dockerfile: restore cache, chiseled runtime, non-root

<code>dotnet publish</code> builds Release by default, so no <code>-c</code> flag is needed. The restore layer only rebuilds when a <code>.csproj</code> or the central package file changes, which takes most CI builds from minutes to seconds. <code>USER $APP_UID</code> runs as UID 1654; Kubernetes can then enforce <code>runAsNonRoot</code>, which needs a numeric user to verify. There is no <code>HEALTHCHECK</code>: Kubernetes ignores it and uses its own probes.

Example · dockerfile
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Project files first: this layer is reused until a package reference changes.
COPY Directory.Packages.props Directory.Build.props global.json ./
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/
RUN dotnet restore src/Orders.Api/Orders.Api.csproj

COPY src/ src/
RUN dotnet publish src/Orders.Api/Orders.Api.csproj --no-restore -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Orders.Api.dll"]

Deployment, Service and disruption budget

The grace period of 40 seconds covers the 5-second preStop sleep plus the 25-second <code>ShutdownTimeout</code> with room to spare. There is a memory limit but no CPU limit: CPU throttling causes latency spikes, while the memory limit is what the GC uses to size its heaps. The startup probe gives a cold pod up to 60 seconds before liveness takes over, and the connection string arrives from a Secret through the <code>__</code> environment variable convention. Each probe sends <code>Host: orders-api</code> because the kubelet would otherwise send the pod IP, which the production <code>AllowedHosts</code> list rejects with 400.

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 3
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  selector:
    matchLabels: { app: orders-api }
  template:
    metadata:
      labels: { app: orders-api }
    spec:
      terminationGracePeriodSeconds: 40
      securityContext: { runAsNonRoot: true }
      containers:
        - name: api
          image: registry.example.com/shop/orders-api:3f9c2d1
          ports: [{ containerPort: 8080 }]
          env:
            - { name: ASPNETCORE_ENVIRONMENT, value: Production }
            - { name: OTEL_EXPORTER_OTLP_ENDPOINT, value: "http://otel-collector.observability:4317" }
            - name: ConnectionStrings__Orders
              valueFrom: { secretKeyRef: { name: orders-db, key: connection-string } }
          resources:
            requests: { cpu: 250m, memory: 256Mi }
            limits: { memory: 512Mi }
          startupProbe:
            httpGet: { path: /health/live, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet: { path: /health/live, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /health/ready, port: 8080, httpHeaders: [{ name: Host, value: orders-api }] }
            periodSeconds: 5
            timeoutSeconds: 3
          lifecycle:
            preStop:
              sleep: { seconds: 5 }
---
apiVersion: v1
kind: Service
metadata:
  name: orders-api
spec:
  selector: { app: orders-api }
  ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: orders-api
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: orders-api }

GitHub Actions: test, image, migrate, deploy

Pull requests run only the build job; pushes to main also release. The build job follows Build and Test in CI (locked restore, every step in Release, tests on the build that ships), and the publish passes <code>--os linux --arch x64</code> as Publishing a Container Without a Dockerfile requires, so the image never takes the runner's CPU architecture. The <code>production</code> environment can require a reviewer before the release job starts. Migrations run after the image is pushed and before the rollout, so old pods meet the new schema first, which is why every migration must be backwards compatible. A rollout that does not become ready within five minutes is undone automatically and the job fails. The release job runs on a self-hosted runner inside the cluster's network, so the bundle reaches the database without the database being exposed to GitHub-hosted runners. Cluster credentials for <code>kubectl</code> come from the environment's secrets or OIDC, never from the repository.

Example · yaml
name: orders-api
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json
      - run: dotnet restore --locked-mode
      - run: dotnet build -c Release --no-restore -warnaserror
      - run: dotnet test -c Release --no-build
      - name: Build migration bundle
        run: |
          dotnet tool restore
          dotnet ef migrations bundle --project src/Orders.Api --runtime linux-x64 --self-contained -o efbundle --force
      - uses: actions/upload-artifact@v4
        with: { name: efbundle, path: efbundle }

  release:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: [self-hosted, linux, prod-network]
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          global-json-file: global.json
      - uses: actions/download-artifact@v4
        with: { name: efbundle }
      - run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u ci --password-stdin
      - run: >
          dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer
          -p:ContainerRegistry=registry.example.com -p:ContainerRepository=shop/orders-api
          -p:ContainerFamily=noble-chiseled -p:ContainerImageTags=${GITHUB_SHA::7}
      - run: chmod +x efbundle && ./efbundle --connection "${{ secrets.ORDERS_DB_MIGRATOR }}"
      - run: |
          kubectl set image deployment/orders-api api=registry.example.com/shop/orders-api:${GITHUB_SHA::7}
          kubectl rollout status deployment/orders-api --timeout=5m || (kubectl rollout undo deployment/orders-api && exit 1)

Discussion

  • Be the first to comment on this lesson.