Howto configure loadbalancing with upstream in nginx

May your k3s/k8s nodes never fail, but if they will, never fear upstream is here

Prerequisites:

  • Have a nginx vm, container or pod / deployment
  • Have at least 2 servers for backend purposes

Introduction

As I like to toy around with salt I decided to leave my nginx vm intact rather than migrate it to a k8s deployment. However when I added an extra node to my k3s cluster, it made me realize I should have som form of loadbalancing in the event that one of the nodes goes down. There are solutions like metallb. But this as just as good.

Please be aware that however loadbalancing is possible the focus of this article lies in failover

Upstream explained

Nginx upstream is a tool that gives you the ability to loadbalance. BAsically this means that you configure 1n (or more) backend servers to which it can failover. There are several possibilites in loadbalancing which nginx offers:
please note that I only use the opensource variant of nginx and not nginx plus

  1. Round Robin – Requests are distributed evenly across the servers, with server weights taken into consideration. This method is used by default (there is no directive for enabling it):
upstream backend {
   # no load balancing method is specified for Round Robin
   server backend1.example.com;
   server backend2.example.com;
}
  1. Least Connections – A request is sent to the server with the least number of active connections, again with server weights taken into consideration:
upstream backend {
    least_conn;
    server backend1.example.com;
    server backend2.example.com;
}
  1. IP Hash – The server to which a request is sent is determined from the client IP address. In this case, either the first three octets of the IPv4 address or the whole IPv6 address are used to calculate the hash value. The method guarantees that requests from the same address get to the same server unless it is not available.
upstream backend {
    ip_hash;
    server backend1.example.com;
    server backend2.example.com;
}

If one of the servers needs to be temporarily removed from the load‑balancing rotation, it can be marked with the down parameter in order to preserve the current hashing of client IP addresses. Requests that were to be processed by this server are automatically sent to the next server in the group:

upstream backend {
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com down;
}
  1. Generic Hash – The server to which a request is sent is determined from a user‑defined key which can be a text string, variable, or a combination. For example, the key may be a paired source IP address and port, or a URL:
upstream backend {
    hash $request_urI consistent;
    server backend1.example.com;
    server backend2.example.com;
}

I think in most cases round-robin will suffice. Especially in a homelab ;). However each possibilty has it’s own usescase and you should decide for yourself which one is best.

Additional notable features:

  1. Server Weights

By default, NGINX distributes requests among the servers in the group according to their weights using the Round Robin method. The weight parameter to the server directive sets the weight of a server; the default is 1:

upstream backend {
    server backend1.example.com weight=5;
    server backend2.example.com;
    server 192.0.0.1 backup;
}

What this does is the following. For each 6 requests, 5 are sent to backend1 and 1 is sent to backend2. The server marked with backup will only start serving requests when backend1 and backend2 are down.

  1. Server Slow-Start

The server slow‑start feature prevents a recently recovered server from being overwhelmed by connections, which may time out and cause the server to be marked as failed again.

upstream backend {
    server backend1.example.com slow_start=30s;
    server backend2.example.com;
    server 192.0.0.1 backup;
}

Usecase: loadbalancing k8s pods

I have the following situation:

  • 1 k3s cluster
  • 2 k3s nodes

I want to be able to failover or loadbalance. Failover means that when one of the nodes goes down. The other one will take over. This will make sure that the applications in the k8s deployment stays reachable. However deployments are reached typically via ingress through a service and not based on <ipaddres>:<port>, but via <sub><domain>.<ext>. This means that instead of balancing on <ipaddress>:<port>, you should also think about proper fqdn’s for your hosntames.

At my home I have configured this as follows in my BIND DNS servers.

  • domain.local
  • domain.net

Now let’s say we have portainer deployment. Which should be reachable at all times.

Which means I have 2 domains I maintain in my DNS. Underneath are the examples of the DNS records for my nginx, k3s node1 and k3s node2.

$ORIGIN .
$TTL 86400      ; 1 day
domain.local            IN SOA  ns1.domain.local. hostmaster.domain.local. (
                                2021102901 ; serial
			 	                        300	   ; refresh (5 minutes)
                                600        ; retry (10 minutes)
                                604800     ; expire (1 week)
                                86400      ; minimum (1 day)
                                )
                        NS      ns1.domain.local.
                        NS      ns2.domain.local.
$ORIGIN domain.local.
ns1                     A       192.168.2.4
ns2                     A       192.168.2.5

; machines
; 168.1.*
nginx                   A       192.168.1.101
k3snode1                A       192.168.1.123
k3snode2                A       192.168.1.124

; 168.2.*
DNS1                    A       192.168.2.4
DNS2                    A       192.168.2.5

As you can see there are only DNS records for the nginx vm, k3s nodes and DNS VM’s.

However in the domain.net config, things already look different:

$ORIGIN .
$TTL 86400      ; 1 day
domain.net              IN SOA  ns1.domain.net. hostmaster.domain.net. (
                                2021102901 ; serial
                                300	   ; refresh (5 minutes)
                                600        ; retry (10 minutes)
                                604800     ; expire (1 week)
                                86400      ; minimum (1 day)
                                )
                        NS      ns1.domain.net.
                        NS      ns2.domain.net.
$ORIGIN domain.net.
ns1                     A       192.168.2.4
ns2                     A       192.168.2.5

; nginx proxy:
nginx                   A       192.168.1.104

; apps / websites
portainer  	            CNAME   nginx

; nginx CNAMES
proxy                   CNAME   nginx

Here we can see that the portainer is pointing to nginx because of the .net domain (the use of .net is because of let’s encrypt and it looks cooler ;) )

In another blog post I have explained how I deploy nginx vhosts with salt. Please refer to the examples / configuration in this post.
Underneath you can see the eventual configuraton for portainer. The programming language is jinja2 as the template is used in salt.

When configuring upstream a couple things are important. Ofcourse make sure that the right fqdn’s are used behind server. But also to specify which port they backend should respond too. Also bear in mind that this is configured specifically for k8s pod backends. Nginx will ‘read’ backend(-whatevervalue) as a variable, so you don’t need to worry about it. As long as you keep it unique for each vhost. Trust me, you will get duplicate errors if you don’t ;)

In the server configuration part. change the hostname / ipaddress to the variable which you choose to name at upstream.

These lines which for this blog are most import::
note: please be aware that I cut some extra lines to make the changes visibible

{# if using multiple vhosts, then name the upstream to upstream-appname here to prevent duplicate errors #}
upstream backend-<yourapporwebsite>{
        server k3snode1.domain.local:{{ port }} weight=5;
        server k3snode2.domain.local:{{ port }};
    }
{% endif %}

```bash
server {
        # SSL configuration
        listen 443 ssl http2;
        
        location / {
        
                proxy_pass {{ protocol }}://backend-<yourapporwebsite>;
        }
}

Before configuring upstream, your configuration would look like this:

server {
        # SSL configuration
        listen 443 ssl http2;
        
        location / {
        
                proxy_pass {{ protocol }}://{{ ipaddress }}:{{ port }};
        }
}

The configuration as deployed in nginx will look as followed:

$ cat /etc/nginx/sites-enabled/portainer.conf
# Managed by salt #

map $http_upgrade $connection_upgrade {
default Upgrade;
''      close;
}

upstream backend-portainer{
server k3snode1.domain.local:443 weight=5;
server k3snode2.domain.local:443;
}

server {
# SSL configuration
      listen 443 ssl http2;
      
      server_name portainer.domain.net;
            
      client_max_body_size 520M;

      # 443 logging
      error_log /etc/nginx/logs/portainer_error_443.log warn;
      access_log /etc/nginx/logs/portainer_access_443.log;

      location / {
                proxy_read_timeout 900s;
                proxy_set_header Host $host;
                proxy_set_header X-Forwarded-Proto $scheme;
                proxy_set_header X-Forwarded-Port $server_port;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection $connection_upgrade;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-Host $host;
                proxy_set_header X-Forwarded-Server $proxy_add_x_forwarded_for;
                proxy_pass_request_headers on;
                add_header X-location websocket always;
                
                proxy_pass https://backend-portainer;
                proxy_ssl_verify off;
                #proxy_buffering off;
                
                fastcgi_buffers 16 16k; 
                fastcgi_buffer_size 32k;
                
                proxy_buffering         on;
                proxy_buffer_size       128k;
                proxy_buffers           4 256k;
                proxy_busy_buffers_size 256k;
      }

      ssl_certificate      /etc/letsencrypt/live/domain.net/fullchain.
      ssl_certificate_key  /etc/letsencrypt/live/domain.net/privkey.pem;
      
      #ssl_protocols TLSv1.2 TLSv1.3;
      ssl_prefer_server_ciphers on;
      ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
      ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0
      ssl_session_cache shared:SSL:10m;
      ssl_session_tickets off; # Requires nginx >= 1.5.9
      add_header X-Frame-Options SAMEORIGIN;
      add_header X-Content-Type-Options nosniff;
}
{% endif %}

server {
       listen 80;

       server_name portainer.domain.net;
       
       # this is not needed in http
       # fastcgi_buffers 16 16k;
       # fastcgi_buffer_size 32k;
       
       # redirect to https
       location / {
       return 301 https://$host$request_uri;
       }
       
       #logging:
       error_log /etc/nginx/logs/portainer_error.log warn;
       access_log /etc/nginx/logs/portainer_access.log;
}

k8s deployment example

For those of you interested. Underneath you can find the yaml files of my ingress, service and deployment for portainer. Try it out. Please keep in mind that I use custom storage. You’ll have to change that part to your personal situation.

Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portainerx
  namespace: portainer
  annotations:
    kubernetes.io/ingress.class: "traefik"
    traefik.frontend.passHostHeader: "true"
    traefik.backend.loadbalancer.sticky: "true"
spec:
  rules:
  - host: portainer.domain.net
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: portainerx-service
            port: 
              number: 9000

Service:

apiVersion: v1
kind: Service
metadata:
  namespace: portainer
  name: portainerx-service
spec:
  type: NodePort
  selector:
    app: portainerx
  ports:
    - name: http 
      port: 9000
      targetPort: 9000
      nodePort: 30778
      protocol: TCP
    - port: 8000
      targetPort: 8000
      protocol: TCP
      name: edge
      nodePort: 30775

Deployment:

apiVersion: v1
kind: Namespace
metadata:
  name: portainer
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: portainer-sa-clusteradmin
  namespace: portainer
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: portainer-crb-clusteradmin
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: portainer-sa-clusteradmin
  namespace: portainer
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: portainerx
  namespace: portainer
  labels:
    app: portainerx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: portainerx
  template:
    metadata:
      labels:
        app: portainerx
    spec:
      restartPolicy: Always
      serviceAccountName: portainer-sa-clusteradmin
      volumes:
        - name: iscsi-data-portainer
          iscsi:
            targetPortal: 1.1.1.1:3260
            iqn: <someiqn>:k3s-portainer
            lun: 5
            fsType: xfs
            readOnly: false
            chapAuthSession: false
      containers:
      - name: portainerx
        image: "portainerci/portainer:develop"
        imagePullPolicy: "Always"
        ports:
          - containerPort: 9000
            name: http-9000
            protocol: TCP
          - name: tcp-edge
            containerPort: 8000
            protocol: TCP
        volumeMounts:
          - name: iscsi-data-portainer
            mountPath: "/data"

note: I use the develop tag on the portainer image to meet specific k3s needs

Used documentation: