Skip to content
academia.sh

Lesson 09 / 16

Load Balancer and Reverse Proxy

Explaining two components with the same program and where the distinction actually lies: a single relay program taking on both roles through its rule table, measuring what splitting the roles into separate processes versus gathering them into one changes through hop chains and the effect of killing a process, writing the distinction as responsibility and location, and billing the cost of adding a hop against the introductory course's composite availability computation.

Contents

The previous three lessons built the balancer as a routing device: it takes a request, applies a rule, forwards it to a process, and writes the response back. The same definition was given for another component in the Server-Side Fundamentals course. A reverse proxy also takes a request, forwards it to one of several backend servers by a rule, and writes the response back. The two definitions do not come apart.

This lesson starts by accepting the overlap and looks for the distinction somewhere else. Neither is redefined; what is measured is what changes when the two roles are gathered into the same process versus split across separate processes.

Same Program, Two Rule Tables

The shortest proof of the overlap is a single program. The relay below takes a rule table: a path prefix, a target list. If the list has one element, the work done is routing — which component. If the list holds several copies of the same component, the work done is distribution — which copy. The two can sit side by side in the same table.

// proxy/relay.mjs — a single relay program. The rule table describes both roles:
//   "/assets=8891;*=8850"            -> routing to separate components by path (reverse proxy)
//   "*=8892,8893,8894"               -> distributing across copies of the same component (load balancer)
//   "/assets=8891;*=8892,8893,8894"  -> both at once (a combined process)
// Usage: node proxy/relay.mjs <port> <name> <rule>
import { createServer, request } from "node:http";

const [port, name, ruleText] = [Number(process.argv[2]), process.argv[3], process.argv[4] ?? ""];
const RULES = ruleText.split(";").filter(Boolean)
  .map((p) => { const [prefix, list] = p.split("="); return { prefix, targets: list.split(",").map(Number), turn: 0 }; });

if (Number.isInteger(port) === false || RULES.length === 0) {
  console.log("usage: node proxy/relay.mjs <port> <name> <prefix=target[,target];...>");
} else createServer((clientReq, clientRes) => {
  clientRes.sendDate = false;
  const path = new URL(clientReq.url, "http://local").pathname;
  const rule = RULES.find((r) => r.prefix !== "*" && path.startsWith(r.prefix)) ?? RULES.find((r) => r.prefix === "*");
  if (rule === undefined) { clientRes.writeHead(404).end("no rule"); return; }
  const target = rule.targets[rule.turn++ % rule.targets.length];
  const previous = clientReq.headers["x-hop"];
  const backendReq = request({ port: target, path: clientReq.url, method: clientReq.method,
    headers: { ...clientReq.headers, "x-hop": previous === undefined ? name : `${previous} > ${name}` } },
  (backendRes) => { clientRes.writeHead(backendRes.statusCode, backendRes.headers); backendRes.pipe(clientRes); });
  backendReq.on("error", () => clientRes.writeHead(502, { "x-hop": `${name} > unreachable` }).end("none"));
  clientReq.pipe(backendReq);
}).listen(port, "127.0.0.1");

The processes at the end of the chain only report their names; the x-hop header accumulates through the request and carries the hops the request touched.

// proxy/endpoint.mjs — the process at the end of the chain: adds its name to the x-hop header
import { createServer } from "node:http";

const [port, name] = [Number(process.argv[2]), process.argv[3]];

if (Number.isInteger(port) === false) console.log("usage: node proxy/endpoint.mjs <port> <name>");
else createServer((req, res) => {
  res.sendDate = false;
  const previous = req.headers["x-hop"];
  res.writeHead(200, { "content-type": "application/json",
    "x-hop": previous === undefined ? name : `${previous} > ${name}` });
  res.end(JSON.stringify({ state: "at transit hub", zone: "35" }));
}).listen(port, "127.0.0.1");
// proxy/client.mjs — tries both paths, prints the touched hop chain and the answered request count
import { request } from "node:http";

const [port, label] = [Number(process.argv[2]), process.argv[3] ?? "-"];
const PATHS = ["/assets/style.css", "/tracking"];
const COUNT = 6;

function one(path) {
  return new Promise((resolve) => {
    const r = request({ port, path }, (res) => {
      res.resume();
      res.on("end", () => resolve({ code: res.statusCode, hop: res.headers["x-hop"] ?? "-" }));
    });
    r.on("error", () => resolve({ code: 0, hop: "no connection" }));
    r.end();
  });
}

if (Number.isInteger(port) === false) console.log("usage: node proxy/client.mjs <port> <label>");
else for (const path of PATHS) {
  const chain = new Map();
  let answered = 0;
  for (let i = 0; i < COUNT; i += 1) {
    const s = await one(path);
    if (s.code === 200) answered += 1;
    chain.set(s.hop, (chain.get(s.hop) ?? 0) + 1);
  }
  const d = [...chain].sort().map(([a, n]) => `${a} (${n})`).join("  |  ");
  console.log(`${label.padEnd(22)} ${path.padEnd(17)} ${String(answered)}/${COUNT}  ${d}`);
}

The setup runs the same job under two layouts. In the separate layout there are two relay processes: rp splits the content path from the application path, lb distributes across the copies. In the combined layout, a single rplb process does both. In each layout, the process holding the replica list is killed and which path stays up is counted.

# measure.sh — the same job under two layouts: two separate relay processes, then one combined process.
# In the second stage, the process holding the replica list is killed and which path stays up is counted.
node proxy/endpoint.mjs 8891 static & S=$!
node proxy/endpoint.mjs 8892 k1 & K1=$!
node proxy/endpoint.mjs 8893 k2 & K2=$!
node proxy/endpoint.mjs 8894 k3 & K3=$!

node proxy/relay.mjs 8860 rp "/assets=8891;*=8850" & RP=$!
node proxy/relay.mjs 8850 lb "*=8892,8893,8894" & LB=$!
sleep 1
node proxy/client.mjs 8860 "separate: rp + lb"
kill $LB; sleep 0.5
node proxy/client.mjs 8860 "separate: lb killed"
kill $RP; sleep 0.3

node proxy/relay.mjs 8861 rplb "/assets=8891;*=8892,8893,8894" & COMBINED=$!
sleep 1
node proxy/client.mjs 8861 "combined: rplb"
kill $COMBINED; sleep 0.5
node proxy/client.mjs 8861 "combined: killed"

kill $S $K1 $K2 $K3
separate: rp + lb      /assets/style.css 6/6  rp > static (6)
separate: rp + lb      /tracking         6/6  rp > lb > k1 (2)  |  rp > lb > k2 (2)  |  rp > lb > k3 (2)
separate: lb killed    /assets/style.css 6/6  rp > static (6)
separate: lb killed    /tracking         0/6  rp > unreachable (6)
combined: rplb         /assets/style.css 6/6  rplb > static (6)
combined: rplb         /tracking         6/6  rplb > k1 (2)  |  rplb > k2 (2)  |  rplb > k3 (2)
combined: killed       /assets/style.css 0/6  no connection (6)
combined: killed       /tracking         0/6  no connection (6)

Two Numbers, Two Directions

These numbers are in the measurement class and are deterministic. The two layouts rank in opposite order on two separate columns.

Hop count. In the separate layout, a tracking request touches three hops: rp > lb > k1. In the combined layout, two hops: rplb > k1. The content path is two hops in both. Every hop means a network jump, a parse, and a rewrite; the separate layout puts the same request through one extra pair of hands.

Isolation. When the process holding the replica list is killed, the content path in the separate layout keeps answering 6/6, and only the tracking path drops to 0/6 — and even that drop comes back through rp as a 502, meaning the client gets a response. In the combined layout both drop to 0/6 and the client gets no response at all, because there is no port left to connect to. A change to the replica list is a configuration change and a common one; in the combined process, that change also stops the content path.

The two numbers are two faces of the same decision: splitting adds a hop, merging adds a failure surface.

Where the Distinction Lies

Since the same program can take on both roles, the distinction is not in the program. It is in two other places.

Responsibility. The reverse proxy answers “which component”: a path starting with /assets goes to the content process, everything else to the application. The prefixes in the rule table point to components doing different jobs, and their target lists have one element. The load balancer answers “which copy”: its target list is made of processes doing the same job, and which one gets picked does not change the outcome of the request. In the first question, a wrong choice is wrong behavior; in the second, it is only a different copy.

Location. The reverse proxy sits at the application boundary: it presents the system’s outer face as a single path space and hides the component split behind it. The load balancer sits at the replica boundary: it hides how many copies a component runs on. The two forms of hiding operate at different scales, which is why the two can sit at different points in the chain — the rp > lb order in the measurement is an expression of that; the reverse order would not make sense.

A single process taking on both does not remove this distinction, it only writes both into the same configuration file. What keeps the distinction visible is the shape of the rule table: which row’s prefix is a component split, and which row’s target list is a set of copies.

Back to the Computation

The bill for splitting showed up in the hop count; that number’s counterpart in service availability was already established in C01. The Availability in Numbers lesson took the tracking path as a four-component series chain and, at 99.9 percent per component, found a composite availability of 99.6006 percent and a monthly outage of 172.5 minutes. The relay processes are new components added to that chain.

// proxy/chain.mjs — the cost of adding a hop in the C01 composite availability calculation
const MONTH = 30 * 24 * 60;        // minutes
const BASE = 4;                    // C01 Availability in Numbers: four-component tracking path
const COMPONENT = 0.999;           // C01 assumption: service availability per component

const series = (a, n) => a ** n;
const replicated = (a, k) => 1 - (1 - a) ** k;
const minutes = (a) => (1 - a) * MONTH;

console.log("layout                   hops  chain components  composite availability  monthly outage  vs base");
const base = series(COMPONENT, BASE);
for (const [name, hops, replicas] of [
  ["no hops (C01)", 0, 1], ["combined process", 1, 1], ["two separate processes", 2, 1],
  ["separate, 2 replicas each", 2, 2], ["combined, 2 replicas", 1, 2]]) {
  const a = base * series(replicated(COMPONENT, replicas), hops);
  console.log(`${name.padEnd(26)}${String(hops).padStart(6)}${String(BASE + hops).padStart(17)}  ` +
    `${(a * 100).toFixed(4).padStart(22)}%  ${minutes(a).toFixed(1).padStart(11)} min  ` +
    `${(minutes(a) - minutes(base)).toFixed(1).padStart(9)} min`);
}

const combined = base * series(COMPONENT, 1), separate = base * series(COMPONENT, 2);
console.log(`\nbill for splitting the roles into two processes = ${(minutes(separate) - minutes(combined)).toFixed(1)} min/month`);
console.log(`same bill when the hops have 2 replicas each = ` +
  `${(minutes(base * series(replicated(COMPONENT, 2), 2)) - minutes(base * series(replicated(COMPONENT, 2), 1))).toFixed(2)} min/month`);
console.log(`C01 monthly outage budget (99.9%) = ${((1 - 0.999) * MONTH).toFixed(1)} min; ` +
  `separate layout's outage is ${(minutes(separate) / ((1 - 0.999) * MONTH)).toFixed(2)}x the budget`);
layout                   hops  chain components  composite availability  monthly outage  vs base
no hops (C01)                  0                4                 99.6006%        172.5 min        0.0 min
combined process               1                5                 99.5010%        215.6 min       43.0 min
two separate processes         2                6                 99.4015%        258.6 min       86.0 min
separate, 2 replicas each      2                6                 99.6004%        172.6 min        0.1 min
combined, 2 replicas           1                5                 99.6005%        172.6 min        0.0 min

bill for splitting the roles into two processes = 43.0 min/month
same bill when the hops have 2 replicas each = 0.04 min/month
C01 monthly outage budget (99.9%) = 43.2 min; separate layout's outage is 5.99x the budget

These numbers are in the computation class. The first three rows give the bill for splitting: with single-copy hops, separating the roles into two processes raises the monthly outage from 215.6 minutes to 258.6 minutes — 43.0 minutes a month. That number is nearly equal to the entire monthly budget C01 set aside for the 99.9 percent target (43.2 minutes). So the decision to separate the roles, by itself, costs about a month’s outage budget.

The fourth and fifth rows show where the bill comes from. When the hops have two replicas each, the same split drops to 0.04 minutes and the two layouts become indistinguishable. The rule from C01’s replica table holds here too: outage share is raised to a power across parallel replicas. The bill for adding a hop does not come from adding the hop — it comes from leaving that hop single-copy.

The last row closes the table with one measure. The separate layout’s monthly outage is 258.6 minutes, 5.99 times the 99.9 percent budget. This does not say the layout is bad; it says a 99.9 percent target cannot be met with 99.9 percent per component — C01 found the same result with four components, and adding two hops sharpens it. When an edge layer is being designed, hop count is not an architectural preference; it is a direct availability line item.

Summary

  • The load balancer and the reverse proxy can be written as the same program: in the rule table, a single-element target list means routing, and a list of copies of the same component means distribution.
  • In the separate layout the tracking request touched three hops (rp > lb > k1); in the combined layout, two (rplb > k1); splitting puts the request through one extra pair of hands.
  • When the process holding the replica list was killed, the content path in the separate layout kept answering 6/6; in the combined layout both paths dropped to 0/6 and the client got no response at all.
  • The distinction is responsibility and location: the reverse proxy answers “which component” at the application boundary, the load balancer answers “which copy” at the replica boundary; a wrong choice is wrong behavior in the first case and only a different copy in the second.
  • Separating the roles with single-copy hops raises monthly outage from 215.6 to 258.6 minutes: 43.0 minutes a month, the entire monthly budget for the 99.9 percent target.
  • With two replicas per hop the same bill drops to 0.04 minutes; the cost of adding a hop comes from leaving it single-copy.

Next Step

The five lessons so far never questioned one thing: the balancer’s choice is free. Round robin, least connections, and both layouts all assumed a request could go to any copy; even consistent hashing, while it wanted the choice to stay stable, did not make it mandatory. There is a case where the replicas stop being each other’s equals: if state left over from a client’s previous request sits in a particular replica’s memory, that client’s next request can no longer go to just any replica. Choice stops being a preference and becomes a constraint. The next lesson names and measures that constraint: it counts how much distribution breaks down, how much state is lost when a replica drops, and why adding a replica does not lower the peak load.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close