Run ASP.NET WebForms site in Kubernetes

I did not believe but it did worked out

After adding Windows node pool to cluster and playing with sample project I was able to get it up and running

Here are samples which were used

Dockerfile

Here we are adding optional log monitoring, which will forward all logs to stdout, so we can use kubectl logs to see whats going on

FROM mcr.microsoft.com/dotnet/framework/sdk:4.8 as build
WORKDIR /code

COPY App.sln .
COPY App/App.csproj App/App.csproj
COPY App/packages.config App/packages.config
COPY Lib/Lib.csproj Lib/Lib.csproj
COPY Lib/packages.config Lib/packages.config
RUN nuget restore App.sln

COPY . .
RUN msbuild App/App.csproj /p:PublishUrl=publish /p:DeployOnBuild=true /p:Configuration=Release /p:WebPublishMethod=FileSystem /p:DeployTarget=WebPublish


FROM mcr.microsoft.com/dotnet/framework/aspnet:4.8-windowsservercore-ltsc2019

# Optional: will forward logs to stdout, so we can use kubectl logs to see whats going on
WORKDIR /LogMonitor
ADD https://github.com/microsoft/windows-container-tools/releases/download/v1.2/LogMonitor.exe .
COPY LogMonitorConfig.json .

WORKDIR /
COPY entrypoint.ps1 .

WORKDIR /inetpub/wwwroot
COPY --from=build /code/App/publish .

ENTRYPOINT ["/LogMonitor/LogMonitor.exe", "powershell.exe", "-f", "/entrypoint.ps1"]

entrypoint.ps1

Once again, it is kind of optional, this script replaces app settings with corresponding environment variables like it is done in dotnet core. Technically we may rewrite app to do it out of the box, but in my case I wish not to touch app.

$webConfigPath = Get-ChildItem -Include Web.config -Recurse | Select-Object -First 1 -ExpandProperty FullName
$webConfig = [xml](Get-Content $webConfigPath)

foreach($appSetting in $webConfig.configuration.appSettings.add) {
    $value = [Environment]::GetEnvironmentVariable($appSetting.key)
    if ($value) {
        $appSetting.value = $value
        Write-Host "$($appSetting.key): $value"
    }
}

foreach($connectionString in $webConfig.configuration.connectionStrings.add) {
    $value = [Environment]::GetEnvironmentVariable($connectionString.name)
    if ($value) {
        $connectionString.connectionString = $value
        Write-Host "$($connectionString.name): $value"
    }
}

$webConfig.configuration.'system.web'.compilation.RemoveAttribute('debug')

$value = [Environment]::GetEnvironmentVariable('AUTHENTICATION_FORMS_NAME')
if ($value) {
    $webConfig.configuration.'system.web'.authentication.forms.SetAttribute('name', $value)
    Write-Host "authentication.forms.name: $value"
}

$value = [Environment]::GetEnvironmentVariable('AUTHENTICATION_FORMS_DOMAIN')
if ($value) {
    $webConfig.configuration.'system.web'.authentication.forms.SetAttribute('domain', $value)
    Write-Host "authentication.forms.domain: $value"
}

$webConfig.Save($webConfigPath)

/ServiceMonitor.exe w3svc

github-action.yml

Nothing special in github action, the only important change is that we are using windows-latest instead of ubuntu-lates. In my case I was on a macbook so literaly had no chance to build it localy.

name: experiment

on:
  push:
    branches: [ main ]

env:
  IMAGE: gcr.io/my/app

jobs:

  experiment:
    runs-on: windows-latest
    if: github.actor != 'dependabot[bot]'
    steps:
      - uses: actions/checkout@v3

      - name: variables
        uses: rabotaua/actions-version@main
        with:
          version: 4.1

      - name: docker build
        run: docker build -t $env:IMAGE .

      - name: docker login
        run: $env:GOOGLE_KEY | docker login --username _json_key --password-stdin https://gcr.io
        env:
          GOOGLE_KEY: ${{ secrets.GOOGLE_KEY }}

      - name: docker push
        run: |
          docker tag "$($env:IMAGE)" "$($env:IMAGE):$($env:VERSION)"
          docker push "$($env:IMAGE):$($env:VERSION)"

      - name: latest
        if: github.ref == 'refs/heads/master'
        run: |
          docker tag "$($env:IMAGE)" "$($env:IMAGE):latest"
          docker push "$($env:IMAGE):latest"

kube.yml

And here is our kubernetes manifest. There is nothing special inside, except node selectors asking to run this deploymen on windows nodes.

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  labels:
    app: demo
spec:
  replicas: 1
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
    type: RollingUpdate
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
      - name: demo
        image: gcr.io/my/app:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 80
        env:
        - name: AUTHENTICATION_FORMS_DOMAIN
          value: ".mac-blog.org.ua"
        - name: AUTHENTICATION_FORMS_NAME
          value: "FOO"
        - name: DATABASE
          value: "Server=localhost;Initial Catalog=Demo;User ID=foo"
        - name: ReleaseVersion
          value: "123"
        resources:
          limits:
            cpu: 500m
            memory: 500Mi
          requests:
            cpu: 50m
            memory: 100Mi
        readinessProbe:
          failureThreshold: 1
          periodSeconds: 5
          timeoutSeconds: 1
          successThreshold: 1
          tcpSocket:
            port: 80
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /login.aspx
            port: 80
            scheme: HTTP
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 5
        startupProbe:
          failureThreshold: 10
          httpGet:
            path: /login.ashx
            port: 80
            scheme: HTTP
          periodSeconds: 10
          successThreshold: 1
          timeoutSeconds: 5
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: agentpool
                    operator: In
                    values:
                      - win
                  - key: kubernetes.io/os
                    operator: In
                    values:
                      - windows
---
apiVersion: v1
kind: Service
metadata:
  name: demo
spec:
  type: ClusterIP
  selector:
    app: demo
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo
spec:
  ingressClassName: external
  rules:
  - host: demo.mac-blog.org.ua
    http:
      paths:
      - backend:
          service:
            name: demo
            port:
              number: 80
        path: /
        pathType: ImplementationSpecific

It was running in production more than month at moment of writing this, and the only one complain was when app was OOM killed so just added more resources, in all other aspects its seems to be working solution

Notes

  • Thanks to ingress we got some basic metrics for this app even so it does not have prometheus
  • To get metrics about resources windows exporter daemon set should be deployed
  • Thanks to kubernetes we have warmup with startup probe, rolling update and autoscale