Search code examples
kubernetesblazor-server-sideminikubenginx-ingress

How to correctly deploy a Blazor Server app on Kubernetes


I am trying to deploy a test Blazor server-side application in a local Kubernetes cluster. I have it configured such that I can access it via either the service or an nginx-ingress. However, both routes have their own problems that make the application not function properly once served.

When served over the service, it looks like this. I believe this is how it is supposed to look. However, there are a bunch of errors that make it so that I can't intereact with the page at all. Errors:

blazor.server.js:1 WebSocket connection to 'ws://172.25.217.142:30500/_blazor?id=1qrCqjxomf15Rd6QTY11rw' failed: 
(anonymous) @ blazor.server.js:1
blazor.server.js:1 [2024-01-23T20:20:31.036Z] Information: (WebSockets transport) There was an error with the transport.
blazor.server.js:1 [2024-01-23T20:20:31.036Z] Error: Failed to start the transport 'WebSockets': Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.
log @ blazor.server.js:1
blazor.server.js:1 [2024-01-23T20:20:31.107Z] Warning: Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.
log @ blazor.server.js:1
:30500/_blazor?id=eC3fba0OdI6vu_KNZl9_Kg:1 
        
        
       Failed to load resource: the server responded with a status of 404 (Not Found)
blazor.server.js:1 Uncaught (in promise) Error: No Connection with that ID: Status code '404'
    at ut.send (blazor.server.js:1:39443)
    at async rt (blazor.server.js:1:36427)
    at async _t._sendLoop (blazor.server.js:1:61855)
:30500/_blazor?id=eC3fba0OdI6vu_KNZl9_Kg:1 
        
        
       Failed to load resource: the server responded with a status of 404 (Not Found)
blazor.server.js:1 Uncaught (in promise) Error: No Connection with that ID: Status code '404'
    at ut.send (blazor.server.js:1:39443)
    at async rt (blazor.server.js:1:36427)
    at async _t._sendLoop (blazor.server.js:1:61855)
:30500/_blazor?id=eC3fba0OdI6vu_KNZl9_Kg:1 
        
        
       Failed to load resource: the server responded with a status of 404 (Not Found)
blazor.server.js:1 Uncaught (in promise) Error: No Connection with that ID: Status code '404'
    at ut.send (blazor.server.js:1:39443)
    at async rt (blazor.server.js:1:36427)
    at async _t._sendLoop (blazor.server.js:1:61855)
:30500/_blazor?id=eC3fba0OdI6vu_KNZl9_Kg:1 
        
        
       Failed to load resource: the server responded with a status of 404 (Not Found)
blazor.server.js:1 Uncaught (in promise) Error: No Connection with that ID: Status code '404'
    at ut.send (blazor.server.js:1:39443)
    at async rt (blazor.server.js:1:36427)
    at async _t._sendLoop (blazor.server.js:1:61855)

It appears that there is some problem with the WebSockets, but I don't know how to fix that.

When served over the ingress, it looks like this. This is obviously not correct, but I'm only getting one error:

blazor.server.js:2 Uncaught SyntaxError: Unexpected token '<'

I don't know what file that is because I can't open it from the DevTools Console and I am unable to locate it in the DevTools Source pane. It doesn't appear to be in the codebase for the application either.

I am running Minikube on Windows Hyper-V. The application is the default server-side Blazor application from Visual Studio 2022. I made some alterations to the default Dockerfile though:

#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebApp.csproj", "WebApp/"]
RUN dotnet restore "./WebApp/./WebApp.csproj"
COPY . .
WORKDIR "/src/WebApp"
RUN dotnet build "./WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApp.dll"]

Any clues as to what could be causing these two issues?

Edit: Here are my Kubernetes .yaml files:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: blazor
  labels:
    app: blazor
spec:
  replicas: 3
  selector:
    matchLabels:
      app: blazor
  template:
    metadata:
      labels:
        app: blazor
    spec:
      containers:
      - name: blazor
        image: registry/...
        ports:
        - containerPort: 80
      imagePullSecrets:  
      - name: test-webapp

apiVersion: v1
kind: Service
metadata:
  labels:
    app: blazor
  name: blazor
  namespace: default
spec:
  ports:
  - nodePort: 30500
    port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: blazor
  type: NodePort
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: blazor-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$1
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "affinity"
    nginx.ingress.kubernetes.io/session-cookie-expires: "14400"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "14400"
spec:
  rules:
    - host: blazor.test
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: blazor
                port:
                  number: 80

Solution

  • We have 2 issues here

    • Ingress route
    • SignalR with LoadBalancer

    Ingress route

    nginx.ingress.kubernetes.io/rewrite-target: /$1
    

    Rewrite has $1 it means to place first capturing group, but what is it? Well it's something that can be used only when nginx.ingress.kubernetes.io/use-regex is added, so it just doesn't make sens, when request is send to get for file like /styles.css it would end up with something like /$1styles.css in Blazor app or anything else but not valid from your app point of view.

    You can have for example

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: blazor-ingress
      annotations:
        nginx.ingress.kubernetes.io/rewrite-target: /
        nginx.ingress.kubernetes.io/affinity: "cookie"
        nginx.ingress.kubernetes.io/session-cookie-name: "affinity"
        nginx.ingress.kubernetes.io/session-cookie-expires: "14400"
        nginx.ingress.kubernetes.io/session-cookie-max-age: "14400"
    spec:
      rules:
        - host: blazor.test
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: blazor
                    port:
                      number: 80
    

    And everything should work, but rewrite /(path: /) to /(rewrite-target: / end with what we had at beginning, so removing line with rewrite-target has exactly same effect.

    Another example using regex

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: blazor-ingress
      annotations:
        nginx.ingress.kubernetes.io/use-regex: "true"
        nginx.ingress.kubernetes.io/rewrite-target: /$1
        nginx.ingress.kubernetes.io/affinity: "cookie"
        nginx.ingress.kubernetes.io/session-cookie-name: "affinity"
        nginx.ingress.kubernetes.io/session-cookie-expires: "14400"
        nginx.ingress.kubernetes.io/session-cookie-max-age: "14400"
    spec:
      rules:
        - host: blazor.test
          http:
            paths:
              - path: /(.*)
                pathType: Prefix
                backend:
                  service:
                    name: blazor
                    port:
                      number: 80
    

    In this case in path we have /(.*) and we have one capturing group what is (.*)(to make path / work correctly you must have * instead of +), then rewrite comes in and it says /$1 as we have regex enabled in place of $1 the value stored in first capturing group will be used here.

    SignalR with LoadBalancer

    SignalR to establish connection with with Blazor backend needs to send 2 request

    • Negotiate
    • Connect

    The first one will send connectionToken in the response then this token will be used to connect, so if you have more then one replica there is a chance(with number of replica it's growing) that those two requests will end in two different one. To resolve it Microsoft suggest suggest using some additional annotations to force ingress(nginx) to use always the same replica based on cookie, so Negotiate and Connect will always hit the same replica and issue will not occur(also some GitHub issue about it.

    To answer the question why it doesn't work with NodePort as we already got it sends two request they have to hit the same replica, but service NodePort doesn't have feature like nginx.ingress.kubernetes.io/affinity to force requests to the same replica all the time, and there is no solution for it expect using just one replica(or at least I don't know).