Skip to content
academia.sh

Lesson 12 / 18

Service Mesh

Pulling network concerns out of the application code: counting the lines deleted by moving retry, timeout, secure transport, and metric collection into a sidecar, naming the responsibility that stays behind, and measuring the process, hop, and field-knowledge cost of running in a separate process.

Contents

Where the previous lesson left off, there were three aggregation layers, and all three had a bare outbound call: no timeout, no retry, no counter. In a real system, these three jobs would have to be rewritten in every service’s code. In the library loan system, the loan service talks to the fee service; the trip goes over a network, the transport is secure, and the other side occasionally returns a temporary error.

This lesson’s question is: if retry, timeout, secure transport, and metric collection are pulled out of the application code and handed to a separate process running alongside the main one, how many lines get deleted from the application code, what is left behind, and what is the cost. The sidecar and the ambassador were defined in The Traffic Layer course and measured there at the deployment-unit level; here the measure is on the code side. When a process like this runs next to every service, and all of them are given policy from a single place, the layout that results is called a service mesh, and the place the policy is given from is called the control plane.

Assumptions: SB13 the fee service talks over secure transport, and the certificate is generated at measurement time. SB14 the fee service rejects the first two read requests with a temporary error; the payment endpoint processes the record, but its first response is treated as lost. SB15 the measurement does not measure duration; it counts lines, processes, hops, and collections.

The Fee Service

The fee service’s work endpoint runs over secure transport. A plain management endpoint sits alongside it; that endpoint is the measurement rig — it reads and resets the counters.

// mesh/fee.mjs — fee service: work endpoint 8751 over secure transport, management endpoint 8757 plain
import { createServer as tls } from "node:https";
import { createServer } from "node:http";
import { readFileSync } from "node:fs";

const counter = { read: 0, payment: 0, collected: 0 };
const reset = () => Object.keys(counter).forEach((k) => (counter[k] = 0));

tls({ key: readFileSync("mesh/key.pem"), cert: readFileSync("mesh/cert.pem") },
  (q, y) => {
    if (q.url === "/penalty/pay") {
      counter.payment += 1;
      counter.collected += 1;                       // the record is processed; the first response is treated as lost in transit
      const code = counter.payment === 1 ? 503 : 200;
      y.writeHead(code, { "Content-Type": "application/json" });
      return y.end(JSON.stringify({ collected: counter.collected, amount: 12.5 }));
    }
    counter.read += 1;
    const code = counter.read <= 2 ? 503 : 200;  // the first two read requests are temporarily rejected
    y.writeHead(code, { "Content-Type": "application/json" });
    y.end(JSON.stringify(code === 200 ? { balance: 12.5 } : { error: "temporary" }));
  }).listen(8751, "127.0.0.1", () => console.log("fee 127.0.0.1:8751"));

createServer((q, y) => {
  if (q.url === "/reset") reset();
  y.writeHead(200, { "Content-Type": "application/json" });
  y.end(JSON.stringify(counter));
}).listen(8757, "127.0.0.1");

Network Concerns in the Application Code

In the first implementation, the loan service does everything itself. The lines carrying a network concern are marked; the measurement will count that mark.

// mesh/loan-thick.mjs — every network concern lives in the application code, 8752
import { createServer } from "node:http";
import { request } from "node:https";                                   // [tls]
import { readFileSync } from "node:fs";                                 // [tls]

const CA = readFileSync("mesh/cert.pem");                                    // [tls]
const TIMEOUT = 400;                                                    // [timeout]
const RETRY = 3;                                                        // [retry]
const metric = {};                                                      // [metric]

const call = (path, method) => new Promise((resolve, reject) => {       // [tls]
  const q = request({ host: "127.0.0.1", port: 8751, path,              // [tls]
    method, ca: CA }, (y) => {                                          // [tls]
    let g = "";
    y.on("data", (p) => (g += p));
    y.on("end", () => resolve({ code: y.statusCode, body: g }));
  });
  q.setTimeout(TIMEOUT, () => q.destroy(new Error("timeout")));         // [timeout]
  q.on("error", reject);                                                // [timeout]
  q.end();
});

const safe = (method) => method === "GET";        // only an idempotent call is retried [retry]
const attempt = async (path, method, action) => {
  const o = (metric[action] ??= { requests: 0, tries: 0, errors: 0 });   // [metric]
  o.requests += 1;                                                      // [metric]
  let last;
  for (let i = 0; i < (safe(method) ? RETRY : 1); i += 1) {             // [retry]
    o.tries += 1;                                                       // [metric]
    last = await call(path, method);
    if (last.code < 500) break;                                         // [retry]
  }
  if (last.code >= 500) o.errors += 1;                                  // [metric]
  return last;
};

const json = (y, code, g) => {
  y.writeHead(code, { "Content-Type": "application/json" });
  y.end(JSON.stringify(g));
};

createServer(async (q, y) => {
  const [root, member] = q.url.split("/").filter(Boolean);
  if (root === "metric") return json(y, 200, metric);                    // [metric]
  const job = root === "pay" ? { path: "/penalty/pay", method: "POST" }
    : { path: `/penalty/${member}`, method: "GET" };
  const s = await attempt(job.path, job.method, root);
  json(y, s.code, { action: root, code: s.code, body: s.body });
}).listen(8752, "127.0.0.1", () => console.log("loan-thick 127.0.0.1:8752"));

The Same Job, a Thin Application, and a Sidecar

In the second implementation, the business logic stays line-for-line the same; the only thing that changes is the body of the attempt function. The application now makes a plain call to a local address.

// mesh/loan-thin.mjs — network concerns live outside the application, 8753: plain call to a local address
import { createServer } from "node:http";

const attempt = (path, method) => fetch(`http://127.0.0.1:8754${path}`, { method })
  .then(async (y) => ({ code: y.status, body: await y.text() }));

const json = (y, code, g) => {
  y.writeHead(code, { "Content-Type": "application/json" });
  y.end(JSON.stringify(g));
};

createServer(async (q, y) => {
  const [root, member] = q.url.split("/").filter(Boolean);
  const job = root === "pay" ? { path: "/penalty/pay", method: "POST" }
    : { path: `/penalty/${member}`, method: "GET" };
  const s = await attempt(job.path, job.method, root);
  json(y, s.code, { action: root, code: s.code, body: s.body });
}).listen(8753, "127.0.0.1", () => console.log("loan-thin 127.0.0.1:8753"));

Every deleted line moves to the sidecar. What the sidecar sees is a path, a method, and a status code; it does not know what job is being done.

// mesh/sidecar.mjs — sidecar 8754: timeout, retry, secure transport, metrics
import { createServer } from "node:http";
import { request } from "node:https";
import { readFileSync } from "node:fs";

const CA = readFileSync("mesh/cert.pem");
const TIMEOUT = 400;
const RETRY = 3;
const RETRIED_CODE = [502, 503, 504];      // the policy is a status code; it does not know what the job is
const metric = {};

const call = (path, method) => new Promise((resolve, reject) => {
  const q = request({ host: "127.0.0.1", port: 8751, path, method, ca: CA }, (y) => {
    let g = "";
    y.on("data", (p) => (g += p));
    y.on("end", () => resolve({ code: y.statusCode, body: g }));
  });
  q.setTimeout(TIMEOUT, () => q.destroy(new Error("timeout")));
  q.on("error", reject);
  q.end();
});

createServer(async (q, y) => {
  if (q.url === "/metric" || q.url === "/reset") {
    const g = JSON.stringify(metric);
    if (q.url === "/reset") Object.keys(metric).forEach((a) => delete metric[a]);
    y.writeHead(200, { "Content-Type": "application/json" });
    return y.end(g);
  }
  const o = (metric[q.url] ??= { requests: 0, tries: 0, errors: 0 });
  o.requests += 1;
  let last;
  for (let i = 0; i < RETRY; i += 1) {
    o.tries += 1;
    last = await call(q.url, q.method);
    if (!RETRIED_CODE.includes(last.code)) break;
  }
  if (last.code >= 500) o.errors += 1;
  y.writeHead(last.code, { "Content-Type": "application/json" });
  y.end(last.body);
}).listen(8754, "127.0.0.1", () => console.log("sidecar 127.0.0.1:8754"));

Measurement

The measurement runs the same two jobs in both layouts: a balance query and a penalty payment. It then reads the fee service’s counters, the metrics on both sides, and the code’s lines.

// mesh/measure.mjs — two layouts run the same job: outcome, retries, collected, deleted lines
import { readFileSync } from "node:fs";

const get = async (u) => JSON.parse(await (await fetch(u)).text());
const code = async (u) => (await fetch(u)).status;
const meaningful = (d) => readFileSync(d, "utf8").split("\n").map((s) => s.trim())
  .filter((s) => s && !s.startsWith("//") && /[A-Za-z]/.test(s));
const netConcern = (d) => readFileSync(d, "utf8").split("\n")
  .filter((s) => /\/\/ \[/.test(s)).length;                 // line marked as a network concern

const LAYOUT = [["thick application", 8752, null], ["thin + sidecar", 8753, 8754]];
const outcome = [];
for (const [name, app, tool] of LAYOUT) {
  await get("http://127.0.0.1:8757/reset");            // fee counters
  if (tool) await get(`http://127.0.0.1:${tool}/reset`);
  const balance = await code(`http://127.0.0.1:${app}/balance/U-41`);
  const pay = await code(`http://127.0.0.1:${app}/pay/U-41`);
  const counter = await get("http://127.0.0.1:8757/count");
  const metric = await get(`http://127.0.0.1:${tool ?? app}/metric`);
  outcome.push({ name, balance, pay, counter, metric, processes: tool ? 3 : 2, hops: tool ? 2 : 1 });
}

const B = ["balance code", "payment code", "reached fee", "collected", "processes", "local hops"];
console.log(`${"layout".padEnd(18)}${B.map((b) => b.padStart(14)).join("")}`);
for (const s of outcome) {
  console.log(`${s.name.padEnd(18)}${[s.balance, s.pay, s.counter.read + s.counter.payment,
    s.counter.collected, s.processes, s.hops].map((v) => String(v).padStart(14)).join("")}`);
}
console.log("");
for (const s of outcome) {
  const d = Object.entries(s.metric).map(([a, o]) => `${a} ${o.requests}/${o.tries}/${o.errors}`);
  console.log(`${s.name.padEnd(18)} metric (requests/tries/errors): ${d.join("  ")}`);
}

const SERVICE = 5;                        // catalog, membership, loan, fee, notification
const k = meaningful("mesh/loan-thick.mjs"), i = meaningful("mesh/loan-thin.mjs");
const y = meaningful("mesh/sidecar.mjs");
const deleted = k.filter((s) => !i.includes(s));
console.log(`\nloan-thick = ${k.length} lines, loan-thin = ${i.length} lines, ` +
  `deleted = ${deleted.length}, added = ${i.filter((s) => !k.includes(s)).length}`);
console.log(`lines marked as network concerns: application code ${netConcern("mesh/loan-thick.mjs")} -> 0`);
console.log(`network-concern lines for ${SERVICE} services: application side ${SERVICE * deleted.length}, ` +
  `sidecar side ${y.length} (single file)`);
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=localhost" \
  -addext "subjectAltName=IP:127.0.0.1" -keyout mesh/key.pem -out mesh/cert.pem 2>/dev/null
node mesh/fee.mjs > /dev/null &
node mesh/loan-thick.mjs > /dev/null &
node mesh/loan-thin.mjs > /dev/null &
node mesh/sidecar.mjs > /dev/null &
for p in 8752 8753 8754 8757; do
  curl -s --retry 30 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:$p/metric
done
node mesh/measure.mjs
kill $(jobs -p)
layout              balance code  payment code   reached fee     collected     processes    local hops
thick application            200           503             4             1             2             1
thin + sidecar               200           200             5             2             3             2

thick application  metric (requests/tries/errors): balance 1/3/0  pay 1/1/1
thin + sidecar     metric (requests/tries/errors): /penalty/U-41 1/3/0  /penalty/pay 1/2/0

loan-thick = 38 lines, loan-thin = 13 lines, deleted = 28, added = 3
lines marked as network concerns: application code 18 -> 0
network-concern lines for 5 services: application side 140, sidecar side 33 (single file)

Deleted Lines and the Responsibility Left Behind

The application code drops from 38 meaningful lines to 13; 28 lines are deleted, and 3 come in their place. Eighteen of the deleted lines were marked as network concerns, and none of that mark remains in the application code at all. The measure’s real meaning is in the multiplier: because the same 28 lines would be written in each of five services, the total on the application side is 140 lines; on the sidecar side there is a single 33-line file, and its policy is given from the outside.

The responsibility left behind has three items, and none of them can be handed off to the sidecar. First, which path is reached with which method: the knowledge that the /penalty/pay endpoint is a POST lives in the application. Second, what the returned status code means in business terms — what will the application that sees a 503 show the user. Third, and most important, whether retrying a call is safe. In the thick implementation this knowledge is one line, in the safe function; in the thin implementation, that line was deleted and nothing was put in its place.

The cost shows up in two columns. The process count goes from 2 to 3, and the local hops a request touches go from 1 to 2. Requests reaching the fee service go from 4 to 5. This is the code-side counterpart of The Traffic Layer course’s process-per-deployment-unit measure: the same job, with one extra hop.

The metric rows show the loss. In the thick layout, the counters are kept as balance and pay — that is, by the job’s name; in the thin layout, as /penalty/U-41 and /penalty/pay — that is, by the fee service’s path vocabulary. The sidecar can only guess the difference between “a balance query” and “a penalty payment” by looking at the path. Because the member number is part of the path, every member produces a separate metric key, and aggregating those keys without domain knowledge also turns into a job outside the application.

What the Retry Does Not Know

The most expensive column in the table is the collected column. Both layouts made the same two requests; in the thick layout the fee service processed one collection, in the thin layout it processed two.

The reason is where the policy sits. The fee service’s payment endpoint processes the record and returns its first response as 503 — imitating the case where the response is lost in transit. In the thick application, the safe function had closed the POST call off from retrying: the payment was attempted once, a 503 returned to the application, and collected became 1. The sidecar, though, only looks at the status code; it saw 503, retried the same request, got 200 on the second attempt, and returned 200 to the application. The loan service believes the operation succeeded; the member’s penalty has been collected twice, and there is no error logged on either side.

This is the failure mode that is born: a retry decision made at the network layer replays a call that the business layer had closed off from replay. This failure mode did not exist in the thick layout, because the code making the decision had the knowledge of what the call was.

The fix is to call some of the responsibility back. The sidecar’s policy can be narrowed by method, or the application can put an idempotency key on the request and let the fee service drop a repeated collection. Neither is free: the first puts domain knowledge inside the network policy, the second adds a bookkeeping job to the fee service. What the measurement shows is this: network concerns can be pulled out of the application, but the knowledge of which call can be safely repeated cannot be pulled out — it only changes location.

Summary

  • The same job was run in two layouts: application code dropped from 38 lines to 13, 28 lines were deleted and 3 were added; none of the 18 lines marked as network concerns remained in the application.
  • In a five-service system, the same concern holds 140 lines on the application side; on the sidecar side there is a single 33-line file, and its policy is given from the outside.
  • Responsibility left in the application: choosing the path and method, the status code’s business meaning, and whether repeating a call is safe.
  • Cost paid: processes went from 2 to 3, local hops from 1 to 2, requests reaching the fee service from 4 to 5; metric keys fell from the job’s name to the path vocabulary.
  • Failure mode born: a status-code-based retry replayed a payment call that was closed off from repetition; collections became 2 instead of 1, and no error was logged on either side.

Next Step

Both implementations shared a silent assumption: the fee service’s address was written into the code as 127.0.0.1:8751. In the thick application this address sits in the application file, in the thin application in the sidecar file; either way, it is a single constant. But a service may not have a single copy — copies can come and go, and a copy that has left can go on looking valid for a while. This topic’s final lesson takes on resolving the address from outside the code: it builds a registry process, gives records a lease, ties health information to routing, and compares this against fixed configuration through two numbers — the file touched in deployment when a copy is added, and the requests sent to a dead address when a copy dies.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close