Skip to content

Step 3 of 7 in the DevOps journey · see the full journey

2025

Infrastructure Insight

A containerised diagnostic app whose nginx balancer reweights its upstreams from Prometheus.

Overview

I built a diagnostic application with a Go API and Express frontend, then used host metrics to adjust Nginx routing. The local VM lab also let me check backup restoration and investigate the boundary between container networking and application access.

context
Coursework
status
Archived learning project
role
Solo
started
Apr 2025
setting
Local VM and Docker lab for a diagnostic application, adaptive Nginx balancing and backup restoration.
built with
  • Go 1.24
  • gopsutil
  • Node.js 22
  • Express
  • Nginx 1.25
  • Prometheus
  • +3

Key decisions

Turn host metrics into routing weights
A scheduled Python script reads node_load1 from Prometheus and generates weighted Nginx upstream entries for the two web servers.
Separate host information from container identity
The diagnostic API uses gopsutil and an explicit host OS-release mount, rather than treating the container hostname as the host operating system.
Check a restore as well as configuring backups
I used a test file to exercise backup, deletion and restoration over rsync, then configured the job to run from cron.

Demonstrated results

Routing weights responded to an induced load
The journal records stress-ng on one web server, a lower weight for that host and a return towards balanced weights after the load stopped.
A deleted test file was restored
The recorded restore exercise recovered the test file from the backup host and checked its return on the application server.

Known limitations

The routing demonstration was qualitative
Captured numeric load samples and request-distribution measurements are absent. The interactive uses illustrative inputs to the documented formula.
Backend access over the private VPN remained open
Header matching in packet-filter rules blocked legitimate connections. Those attempts were reverted; the documented private-network limitation remains.
Candidate configuration validation needs strengthening
The archived script runs nginx -t before moving the temporary file into place. That sequence alone does not demonstrate validation of the newly generated candidate.

The formula

The adaptive load balancer is the reason this stage exists. A cron job reads node_load1 per web server from the Prometheus API and rewrites the Nginx upstream block:

weight = clamp(10 − round(node_load1 × 5), 1, 20)

Lower load produces a higher weight. The archived update sequence is:

  1. write the generated block to a temporary file
  2. nginx -t: on failure, log, discard, and do not reload
  3. mv over the live config
  4. systemctl reload nginx for a graceful reload

The updater's sudoers entry names three permitted command forms:

devops ALL=(root) NOPASSWD: /usr/sbin/nginx -t
devops ALL=(root) NOPASSWD: /bin/mv /home/devops/frontend_upstream_servers.conf.tmp /etc/nginx/conf.d/frontend_upstream_servers.conf
devops ALL=(root) NOPASSWD: /bin/systemctl reload nginx

Note the mv rule names both arguments. An earlier version matched only the command, and stopped matching the moment the temporary path changed.

The diagnostic application

The Go backend exposes system information through gopsutil. A bind mount of /etc/os-release provides the host's OS metadata explicitly; a container's hostname is a different piece of information. The Express frontend presents the diagnostic data through the application gateway.

The load experiment

The journal records stress-ng --cpu 1 --timeout 180s on web-server-1. Subsequent updater runs assigned that server a lower weight, with more traffic observed at web-server-2. After stopping the load, weights returned towards a more balanced state. These were observed config and browser checks, without a captured request-count series or benchmark showing a throughput improvement.

A file-restore check

I created important_config.txt on the application server, backed it up, checked its contents on the backup server and deleted the original. An rsync restore then returned the file to the application server. This verifies that file-restoration path, rather than complete recovery of a lost VM or database.

Cron exposed a separate permissions issue when reading protected configuration. The recorded fix configured the remote rsync command to use its permitted sudo path.

Configuration validation sequence

The script writes /home/devops/frontend_upstream_servers.conf.tmp, runs the main nginx -t check, moves the candidate over the live include and then reloads Nginx. Its comment assumes the temporary file is included indirectly, but the script does not explicitly point the test at a configuration containing that candidate. The sequence should not be presented as proof that the new include was validated before installation. It also reports a reload failure after the file has already been replaced, leaving recovery for inspection.

The rule that blocked everything

The intent was to gate the backend API on a secret header, so that only the load balancer could reach it:

proxy_set_header X-Secret-Handshake "I-Am-The-Trusted-Proxy";
sudo iptables -I DOCKER-USER 1 -p tcp --dport 5000 -m string --algo bm \
  ! --string "X-Secret-Handshake" -j DROP

The rule dropped connections before HTTP data was available:

The DROP rule was too effective. It was inspecting the very first packets of the TCP three-way handshake (SYN, SYN-ACK). These initial packets are for establishing a connection and contain no HTTP data, and therefore, no secret header. The firewall was dropping the connection before it could even be established.

Two further attempts followed: a multi-rule stateful approach, and the canonical UFW Docker patch. Both failed. The conclusion drawn is the actual lesson:

the core conflict between the TCP protocol's initial handshake and the firewall's stateless string matching proved to be the insurmountable hurdle

I reverted the filtering attempts and retained the application gateway. This does not make the backend unreachable to VPN clients; that remains a limitation of the lab.

A note on the interactive

LoadBalancerLab implements the real formula above. What it does not have are the measured node_load1 values. The project recorded the verification qualitatively ("script calculated lower weight for web-server-1") without capturing the numbers. The widget therefore runs on clearly-labelled illustrative figures rather than presenting invented numbers as captured ones.

Interactive models

Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.

Load-driven routing weights

LoadBalancerLab

Weights recomputed every 60s from observed load.

6 hostsBack to the original range, with a load balancer that reads Prometheus

Back to the original range, now that stage 2 is a separate environment. The load balancer reads Prometheus and rewrites its own weights every 60 seconds.

observed load → recomputed weight

Weight is inverse share, so the backend taking the most traffic receives the smallest weight. The balancer rewrites these every 60 seconds without a deploy — which is the point of the stage, and also its risk: a metric flapping for thirty seconds is now a routing change nobody made.

5 nodes · 8 edges in this topology

src/content/data/diagrams.ts · TOPOLOGIES

client:visible

Reconstruction of the documented formula. The load figures are illustrative. The project recorded the verification qualitatively, not numerically.
Stack and source figures

how it fits together

application
  • Go 1.24 + gopsutil The diagnostic backend also deployed by the automation stage. Host OS release is read through a bind mount of /etc/os-release, because os.Hostname() inside a container returns the container id.
  • Node.js 22 + Express The frontend, deployed by CI and served behind the load balancer.
load balancing
  • Nginx upstream weights Rewritten from Prometheus every minute on cron, followed by a main-config check, file replacement and reload. The candidate-validation limitation is described in the technical notes.
observability
  • Prometheus + Grafana + Chart.js The stack that starts making decisions rather than only reporting.
resilience
  • rsync on cron Backups with a demonstrated end-to-end restore, because a backup nobody has restored is a hypothesis.
access
  • passwordless sudo The updater-specific sudoers entry lists nginx -t, one specific mv and one systemctl reload. This describes that entry rather than every privilege held by the user.
VMs
5
weight formula
10 − load1 × 5 Clamped to 1–20, recomputed every 60 seconds.
sudo scope
3 commands Three command forms in the updater-specific sudoers entry; not an audit of the user's full privileges.
commits
88
Full issue record · 6 entries

what broke, and what happened

  • 3 fixed / 6
  • 1 workaround / 6
  • 2 unresolved / 6

Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.

  1. unresolved

    A firewall rule gated on an HTTP header killed every connection, including legitimate ones

    The DROP rule inspected the first packets of the TCP handshake, SYN and SYN-ACK, which carry no HTTP data and therefore no secret header. The connection was dropped before the header could ever be sent.

    then: Unresolved by design. Header-gating at packet level cannot work, and the rule was removed rather than replaced with something that looks like a fix.

  2. unresolved

    The same problem survived two more attempts: a more elaborate stateful rule, and the canonical UFW Docker patch

    `This proved to be extremely brittle and still resulted in a blocked connection.`

    then: Both abandoned. Three attempts is the point at which reverting is the cheaper option. The next entry records that.

  3. workaround

    Resolving it was to revert and document the limitation rather than pretend it was solved

    `iptables-persistent` was purged for a clean state. The backend port remains reachable on the private VPN, and the write-up says so.

    then: Deliberately unresolved. `iptables-persistent` was purged and the backend is reachable over the VPN only. The limitation is documented rather than papered over.

  4. fixed

    Nginx refused to include the upstream file, because the global include parsed it at http context

    `nginx: [emerg] directive "server" has no opening "{" in frontend_upstream_servers.conf:4`

    then: The generated `server address weight=...;` entries belong inside an Nginx `upstream` block. An `upstream` block itself belongs in the HTTP context, not inside a virtual-server block.

  5. fixed

    Docker Hub pulls failed through the proxy, because systemd-resolved had no reachable upstream

    `lookup registry-1.docker.io on 127.0.0.53:53: server misbehaving`. Standard HTTP proxies do not handle raw DNS on UDP 53.

    then: DNS pointed at a resolver the proxy can actually reach, since the local stub had nowhere to forward the query.

  6. fixed

    Disabling the NAT interface broke Vagrant management again, in this project too

    Three hypotheses disproven in order. Disabling UFW entirely still timed out, which proved the cause was not a firewall rule. The VM had lost its IPv4 on `enp0s3`.

    then: Same self-inflicted `dhcp4: false` as the previous stage, now caught in one step rather than three.