Issues

Non-standard Usage of Umbraco Hosted in Kubernetes

Umbraco 9 was released on September 28, 2021. Umbraco 10 was released on June 16, 2022. These releases use .NET 5 and .NET 6, respectively. It is an undeniably big step up for Umbraco, as it opens the way to fully run it in Linux, Docker, and Kubernetes environments. To be honest, Umbraco 9 passed me by, but the 10th version made me happy with its working speed, easy assembly, and the ability to use Linux as a build agent and hosting environment. And it gives room for experiments!

The problem: UKAD is a big enough company to have some complications in managing the equipment. We tried different solutions available on the market, but there always was something wrong with them, either the necessary features were missing, or they were too cumbersome. And commonly they had too many features that will never be used, making the system unusable and confusing the editor with an overflow of buttons and pickers.

Using such systems was a real torture, so we came up with a crazy idea: what if we try to make our own inventory system based on Umbraco? Yes, it's a content management system, so what? Computers in the office are also content in some way. A crazier idea followed next: let's host our solution in Kubernetes. We have never done this before, so it will be an interesting experience.

Application Functionality

It all started with the requirements. The company has different departments with many employees using tons of equipment: personal computers, laptops, phones, and lots of other stuff which needs to be systematized somehow. Also, we want to make the life of our system administrators as easy as possible and minimize the time of assigning a device to the user. And it would be nice to have a history of the movement of equipment within the company. For example, when the company purchases a new computer for a developer and gives an older one to the office manager, and the office manager's old computer is decommissioned or put up for sale.

Then, one of the mandatory requirements was the ability of the end user to see all the equipment that he owns at the moment. Not all existing systems provide this, although it is really important to reduce chaos.

In this way, what was a simple task grew into a full-fledged project. We conducted a meeting with the team, discussed the functionality, tested it, and discussed it again. As the project should be delivered as quickly as possible and with minimal effort, because we have lots of other tasks, in most cases we used the ready-made Umbraco functionality.

How it works

One of the typical problems is the naming of assets. For example, a processor can be listed as "i7-12700K" or as "Intel Core i7 12700K". It plays zero impact on the sense of the device but adds some mess to statistics with each entry. Therefore, instead of a manual listing of each piece of equipment, we made repositories with predefined components. It allows us to select a component from already added and solves the problems of content duplication and the human factor.

The repository contains almost all the equipment and components that have to be inventoried: CPU, RAM, displays, and so on. Everything that we do not monitor (headphones, mice, keyboards and other consumables) is simply added as generic, and does not have a repository, like Generic headset. This approach allows systematizing and reuse of any components.

Also, we have another repository that contains all the manufacturers of equipment we use. Any value that can be reused and picked from the list instead of typing is added to the repository and any known parameter wrapped in a dropdown in advance.

Once a component is added, we can use it as many times as we need.

From the company’s point of view, there are different departments in which people work, and each department is represented by a root node in the CMS. Under it, we can create User doctypes for employees and list there all the used equipment.

But here comes the first disadvantage of using Umbraco: asset names must be unique. When adding two identical RAM sticks, "(1)" appears next to the second one. It is not critical right now, but we must recognize this shortcoming.

Client part

For the client part, we have a separate Blazor application, which is integrated with other our services, and Umbraco is only used as a backend to make changes. Access to the admin panel is available only to employees responsible for moving equipment.

Authorization in Umbraco

OpenID Connect (OIDC) is an authentication protocol based on the OAuth2 protocol, which is used for authorization. We are using Azure OIDC for all our services to authenticate users.

It allows us to use RBAC (role-based access control) and authorize in Umbraco only system administrators. Direct users can only view their information via frontend service.

Build, Deployment, and Hosting

This project will not be highly loaded, so there is nothing to talk about scaling. Within this project, it’s also a good strategy because in the case of scaling we also need to care about cache, search engine, and many more things, which aren’t clearly known. So, let's focus on the fact that only one pod is used in Kubernetes. Nginx-Ingress is used as a load balancer. To issue certificates, we use cert-manager, and deployments take over GitHub actions.

I don’t think that it’s a good idea to focus on setting up a Kubernetes cluster, there are a lot of guides and documentation on the Internet. So, I only hint that I really like to use Terraform and Helm to initialize the cluster.

Kubernetes Manifest

First of all, we need to describe a service.

apiVersion: v1
kind: Service
metadata:
  name: inventory-backoffice
spec:
  selector:
    app: inventory-backoffice
  ports:
    - port: 80

Here we only have to pay attention to selector.app. This selector can uniquely identify our service among others.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory-backoffice
  labels:
    app: inventory-backoffice
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 50%
  replicas: 1
  selector:
    matchLabels:
      app: inventory-backoffice
  template:
    metadata:
      labels:
        app: inventory-backoffice
    spec:
      containers:
        - name: inventory-backoffice
          image: company/inventory-backoffice:latest
          ports:
            - containerPort: 80
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
            - name: ConnectionStrings__umbracoDbDSN
              valueFrom:
                secretKeyRef:
                  name: inventory-backoffice
                  key: CONNECTION_STRING
            - name: FrontEndUrl
              value: "https://inventory.domain.tld"
      restartPolicy: Always
      imagePullSecrets:
        - name: docker-registry

In deployment, we explicitly indicate that when updating, Kubernetes firstly raises a new pod, and then deletes the older one. We pass the connection string to the database through environment variables and secrets and do not store it in appsettings in the code repository.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: inventory-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-buffer-size: "64k"
spec:
  rules:
    - host: inventory.domain.tld
      http:
        paths:
          - path: /
            pathType: ImplementationSpecific
            backend:
              service:
                name: inventory-webapp
                port:
                  number: 80
    - host: inventory.domain.tld
      http:
        paths:
          - path: /umbraco
            pathType: Prefix
            backend:
              service:
                name: inventory-backoffice
                port:
                  number: 80
    - host: inventory.domain.tld
      http:
        paths:
          - path: /umbraco-signin-oidc
            pathType: Prefix
            backend:
              service:
                name: inventory-backoffice
                port:
                  number: 80
    - host: inventory.domain.tld
      http:
        paths:
          - path: /App_Plugins
            pathType: Prefix
            backend:
              service:
                name: inventory-backoffice
                port:
                  number: 80
  tls:
    - hosts:
        - inventory.domain.tld
      secretName: inventory-webapp-ssl

In Ingress, everything is as usual too. Except for two things. First - we use one domain for frontend and backend parts. So, we need to create separate routing rules for umbraco and frontend parts. Signin and umbraco stuff routes to the umbraco and other goes to the frontend. And the proxy-buffer-size annotation deserves special attention. It will be described in more detail later in the article.

Build and Deployment

As mentioned earlier, GitHub Actions are used as a CI/CD system. First, we declare the secrets (in the repository settings), and the environment variables.

name: Build and deploy production
on:
  push:
    branches: [ "master" ]
  workflow_dispatch:
env:
  REGISTRY_URL: registry.hub.docker.com
  REGISTRY_ORG: company
  SERVICE_NAME: inventory-backoffice
  K8S_CLUSTER: stuff-k8s

Although we are using Docker Hub as a Docker registry, and it is not necessary to write its URL in the image name, I still write it so that the pipeline does not differ from those that use, for example, private ACR. Subsequently, it played a cruel joke.

In the build job, everything is standard: build and push the docker with all the necessary actions, such as login. The Dockerfile will be in the gist, along with all the materials.

jobs:
  build:
    runs-on: [self-hosted, build]
    steps:
      - uses: actions/checkout@v3
      - name: Login to docker registry
        uses: azure/docker-login@v1
        with:
          login-server: ${{ env.REGISTRY_URL }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - name: Build and push docker containers
        uses: docker/build-push-action@v3
        with:
          context: .
          file: Inventory.Presentation/Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_ORG }}/${{ env.SERVICE_NAME }}:latest
            ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_ORG }}/${{ env.SERVICE_NAME }}:${{ github.sha }}

Again, REGISTRY_URL is explicitly specified here, and so far, it doesn't cause any problems.

To deploy, first, we have to clone the repository again, because the k8s manifest is located there. Then, everything needed for the deployment is set up, and the deployment itself is performed.

  deploy:
    runs-on: [self-hosted, build]
    needs: build
    steps:
      - uses: actions/checkout@v3
      - uses: azure/setup-kubectl@v3
        id: install_kubectl
      - name: Set k8s context
        uses: azure/k8s-set-context@v3
        with:
           method: kubeconfig
           kubeconfig: ${{ secrets.KUBECONFIG }}
      - name: Deploy
        uses: Azure/k8s-deploy@v4
        with:
          name: ${{ env.K8S_CLUSTER }}
          action: deploy
          strategy: basic
          manifests: |
            deployment/k8s/inventory-backoffice.yml
          images: |
            ${{ env.REGISTRY_ORG }}/${{ env.SERVICE_NAME }}:${{ github.sha }}

An attentive reader might have noticed that REGISTRY_URL is not specified in the deployment because the image is specified in the k8s manifest as company/inventory-backoffice. It turns out that the name specified in the manifest and the name specified in Azure/k8s-deploy should match the number of parts. So, if you specify company/inventory-backoffice in the k8s manifest, and registry.hub.docker.com/company/inventory-backoffice in the pipeline, the deploy will do nothing and return deployment.apps/inventory-backoffice unchanged. In this case, there is no error, and it's even worse.

Hosting

The infrastructure is ready, and all configs are written, so it's time to deploy the application. And the first thing that happens after the deployment is an error when trying to log in to /umbraco:

For some reason, in the redirect URI the link uses HTTP even though we have HTTPS. As I understand, ctx.ProtocolMessage.RedirectUri contains scheme HTTP, because Umbraco is running on port 80 in Docker. SSL is not in the container, it is handled by Nginx. I don't know why Umbraco doesn't take X-Forwarded-Proto, but the same problem occurred before when we hosted Umbraco on IIS that had Nginx before it. In those cases, this problem was solved by adding a certificate to IIS, so we used HTTPS everywhere, but adding HTTPS to the container seems not a good idea, although it's possible.

As a workaround, we added the following code to the authentication method:

options.Events.OnRedirectToIdentityProvider = ctx =>
{
    if (!ctx.ProtocolMessage.RedirectUri.Contains(Uri.UriSchemeHttps))
        ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace(Uri.UriSchemeHttp, Uri.UriSchemeHttps);

    return Task.CompletedTask;
};

It changes the scheme to HTTPS when generating a redirect URI. In any case, the redirect URI in the Azure app registration is not allowed to use HTTP, so adding this code makes nothing worse. Anyway, authorization doesn't work without HTTPS.

This problem occurs not only with Azure AD authorization but also when using any payment gateway. Now, we have found only two ways to solve this problem: adding SSL certificates to all web servers along the request path (full-SSL) or replacing the scheme in the redirect URI.

But the problem doesn't end here either. Authorization passed, but Umbraco doesn't show anything good, and an error 502 emerges in Dev Tools:

With closer examination and analysis of the Nginx logs, the next error spotted:

2022/10/02 00:20:17 [error] 5187#5187: *6052719 upstream sent too big header while reading response header from upstream, request: "GET /umbraco/backoffice/umbracoapi/authentication/GetCurrentUser HTTP/2.0", upstream: "http://10.1.104.49:80/umbraco/backoffice/umbracoapi/authentication/GetCurrentUser"

That's why it is necessary to increase the size of the proxy-buffer, which I wrote about in the Kubernetes manifest chapter.

Here, all problems end, and the life of a new application on Umbraco begins. Atypical and bright life.

Instead of a Conclusion

The fact of our success is an achievement as is. Umbraco is flexible enough to serve non-standard projects for developers with a good imagination. And the fact that now we can host it not only on Windows provides even more opportunities for building architecture. We are closer to building high-load, scalable Umbraco-based applications on Kubernetes. Time will tell.

All materials can be found by visiting this link.

Bogdan Kosarevskyi

Bogdan Kosarevskyi is a DevOps engineer in UKAD, a software development firm based in Ukraine. He provides smooth deployment and perfect performance for Umbraco projects, masters new technologies, and creates DIY gadgets. Alongside engineering and contributing to the community, Bogdan enjoys cycling around postindustrial wastelands around Kharkiv's outskirts. He is one of the DevOps folks who believe that DevOps is an ideology, not just a job.

comments powered by Disqus