Lesson 09 / 30
External Access
Two measures of the path opening to the outside of the cluster: the round added and the number of rules scanned by the four layers — external balancer, ingress rule, service object, and pod — and the difference between path-based and name-based routing in external hosts, identity items, and total surface.
Published Updated
In this lesson
- Counts the layers an incoming external request passes through and measures the round each layer adds and the number of rules it scans
- Compares path-based and name-based routing on two measures: rule count and the number of externally exposed surface items
- Measures the effect of rule order on the routing decision and counts requests that match incorrectly
Contents
Every request in the previous lesson came from inside the cluster. The verifier called the reading collector in the same cluster by its name; both name resolution and round-robin distribution ran inside that boundary. A request arriving at the regional measurement network’s billing interface, though, is external — from a client that does not know the cluster, cannot resolve the service name, and does not see the endpoint list. For this request to reach a pod, the distance between them has to be paved with objects.
The object that paves this is the ingress rule: a list of matches that ties an external name and path to an internal service object. It is not enough on its own; the rule also needs an external host it listens on and a load balancer standing behind that host. This lesson counts that chain layer by layer and compares two routing formats. The configuration examples are written with our own small schema; what is measured is a model, no real cluster runs.
CO19 — the regional measurement network’s externally exposed interfaces number up to twelve, all of them fictional. CO20 — an incoming external request passes through four layers: external load balancer, ingress rule, service object, pod. CO21 — each layer adds one round; the balancer and the ingress rule also scan their own lists in order. CO22 — in path-based routing all services sit behind a single external host, in name-based routing each service has its own external host. CO23 — each external host needs one identity item and one balancer entry. CO24 — the rule list is scanned in order and the first match wins; the longest-prefix rule is not applied automatically.
Four Layers and the Externally Exposed Surface
The model below first counts a single request’s layers, then how much surface each of two routing formats exposes to the outside at three different service counts.
// external-access.mjs — layers of an incoming external request and the exposed surface (MODEL). // The ingress rule's schema is defined here: each rule ties one (host, path) pair to a service. export const SCHEMA = { host: 'string', path: 'string', service: 'string', port: 'number' }; export const SERVICES = ['billing', 'work-order', 'measurement-report', 'meter-record', 'subscriber-portal', 'outage-notice', 'tariff-lookup', 'balance-lookup', 'collections', 'fault-report', 'contract', 'consumption-history']; // CO19: externally exposed fictional interfaces export function rules(n, format) { return SERVICES.slice(0, n).map((s) => (format === 'name' ? { host: `${s}-external`, path: '/', service: s, port: 8080 } : { host: 'measurement-external', path: `/${s}`, service: s, port: 8080 })); } // Externally exposed surface: how many hosts, how many paths, one identity item per host (CO22, CO23). export function surface(n, format) { const r = rules(n, format); const hosts = new Set(r.map((x) => x.host)).size; const paths = new Set(r.map((x) => `${x.host}${x.path}`)).size; return { hosts, rules: r.length, paths, identities: hosts, total: hosts + paths + hosts }; } // CO20-CO21: four layers, each one round; the number of rules scanned depends on the layer. export function layers(n, format) { const y = surface(n, format); return [ ['external balancer', 1, (y.hosts + 1) / 2], ['ingress rule', 1, format === 'name' ? 1 : (y.rules + 1) / 2], ['service object', 1, 1], ['pod', 1, 0], ]; } if (import.meta.url === `file://${process.argv[1]}`) { const s = (x, n) => String(x).padStart(n); const N = 6; console.log(`${N} external services, rule schema: ${Object.keys(SCHEMA).join(', ')}`); console.log(`${'layer'.padEnd(19)}${s('round', 5)}${s('path-based scanned', 20)}` + `${s('name-based scanned', 20)}`); const pathL = layers(N, 'path'), nameL = layers(N, 'name'); const sum = (k, i) => k.reduce((a, x) => a + x[i], 0); for (let i = 0; i < pathL.length; i++) { console.log(`${pathL[i][0].padEnd(19)}${s(pathL[i][1], 5)}${s(pathL[i][2], 20)}` + `${s(nameL[i][2], 20)}`); } console.log(`${'total'.padEnd(19)}${s(sum(pathL, 1), 5)}${s(sum(pathL, 2), 20)}` + `${s(sum(nameL, 2), 20)}`); console.log(`\n${'services'.padEnd(9)}${'format'.padEnd(12)}${s('hosts', 8)}${s('rules', 7)}` + `${s('exposed paths', 15)}${s('identity items', 16)}${s('surface items', 15)}`); for (const n of [3, 6, 12]) { for (const f of ['path', 'name']) { const y = surface(n, f); console.log(`${String(n).padEnd(9)}${(f === 'path' ? 'path-based' : 'name-based').padEnd(12)}` + `${s(y.hosts, 8)}${s(y.rules, 7)}${s(y.paths, 15)}${s(y.identities, 16)}${s(y.total, 15)}`); } } }
6 external services, rule schema: host, path, service, port layer round path-based scanned name-based scanned external balancer 1 1 3.5 ingress rule 1 3.5 1 service object 1 1 1 pod 1 0 0 total 4 5.5 5.5 services format hosts rules exposed paths identity items surface items 3 path-based 1 3 3 1 5 3 name-based 3 3 3 3 9 6 path-based 1 6 6 1 8 6 name-based 6 6 6 6 18 12 path-based 1 12 12 1 14 12 name-based 12 12 12 12 36
The first table says two things. The first is the layer count: an incoming external request passes four stops before reaching a pod, and all four cost one round. These four rounds are a fixed price the client pays; compared against the round count an in-cluster call pays, the difference is the price of being outside the cluster.
How much these four rounds amount to can only be understood through comparison. The in-cluster call from the previous lesson cost two rounds: one name-resolution round and the request itself. An incoming external request pays four rounds, that is, twice as much; the two rounds in between belong to the balancer and the ingress rule. If these two layers were removed and pods were exposed directly, the gap would close — but then the exposed surface would be as large as the pod count, and pod addresses would change as pods churned. The two layers are the price of carrying the stability the previous two lessons built out to the outside.
The second is more interesting: the total rules scanned is the same in both formats — five and a half. In path-based routing the balancer holds one entry and the ingress rule scans six rules; in name-based routing the balancer scans six entries and the ingress rule matches on one rule. The difference is not a saving, it is a relocation: the same work moves from one layer to the other. This shows why decisions like “name-based is faster,” made without measurement, have no ground to stand on.
The real difference is in the second table, and it is not rounds but surface. At three services the gap between the two formats is five to nine; at six services, eight to eighteen; at twelve services, fourteen to thirty-six. In path-based format the external host count is independent of the service count, always one; in name-based format it equals the service count. Because identity items are tied to hosts, they grow at the same rate. When twelve services are exposed, path-based format requires managing fourteen items, name-based thirty-six — about 2.6 times as many.
That rule count is equal in both formats is this table’s second result. Rule count equals service count and is independent of routing format. What changes is how many separate external hosts the rules are spread across. This is why “how many rules will be written” is not a criterion for choosing a format; “how many hosts will have to be maintained” is the criterion.
The Rule’s Order
Knowing the rule count does not show that routing will be correct. CO24 says the rule list is scanned in order and the first match wins; once nested paths combine with this rule, order stops being a detail.
// routing.mjs — the effect of rule order on the routing decision (MODEL). import { SCHEMA } from './external-access.mjs'; // Same schema, with nested paths: a subpath should go to its own service. const RULES = [ { host: 'measurement-external', path: '/billing', service: 'billing', port: 8080 }, { host: 'measurement-external', path: '/billing/statement', service: 'billing-statement', port: 8080 }, { host: 'measurement-external', path: '/work-order', service: 'work-order', port: 8080 }, { host: 'measurement-external', path: '/work-order/urgent', service: 'urgent-work-order', port: 8080 }, { host: 'measurement-external', path: '/report', service: 'measurement-report', port: 8080 }, { host: 'measurement-external', path: '/report/monthly', service: 'monthly-report', port: 8080 }, ]; // Expected result: the longest prefix wins. This expectation is not written in the rule. const REQUESTS = [ ['/billing', 'billing'], ['/billing/statement', 'billing-statement'], ['/billing/statement/2026', 'billing-statement'], ['/work-order', 'work-order'], ['/work-order/urgent', 'urgent-work-order'], ['/work-order/urgent/12', 'urgent-work-order'], ['/report', 'measurement-report'], ['/report/monthly', 'monthly-report'], ['/report/monthly/03', 'monthly-report'], ['/report/daily', 'measurement-report'], ['/subscriber', null], ['/', null], ]; // CO24: the rule list is scanned in order, the first match wins. function match(rules, path) { for (let i = 0; i < rules.length; i++) { const k = rules[i]; if (path === k.path || path.startsWith(`${k.path}/`)) return { service: k.service, scanned: i + 1 }; } return { service: null, scanned: rules.length }; } const ORDERS = { 'declaration order': RULES, 'specificity order': [...RULES].sort((a, b) => b.path.length - a.path.length), }; const s = (x, n) => String(x).padStart(n); console.log(`${RULES.length} rules, ${REQUESTS.length} requests, schema: ${Object.keys(SCHEMA).join(', ')}`); console.log(`${'rule order'.padEnd(18)}${s('correct', 7)}${s('wrong service', 15)}` + `${s('rule not found', 18)}${s('avg scanned', 18)}`); for (const [label, rules] of Object.entries(ORDERS)) { let correct = 0, wrong = 0, notFound = 0, scanned = 0; for (const [path, expected] of REQUESTS) { const r = match(rules, path); scanned += r.scanned; if (r.service === null) notFound++; if (r.service === expected) correct++; else if (r.service !== null) wrong++; } console.log(`${label.padEnd(18)}${s(correct, 7)}${s(wrong, 15)}${s(notFound, 18)}` + `${s((scanned / REQUESTS.length).toFixed(2), 18)}`); } console.log(`\nrequests that went wrong in declaration order:`); for (const [path, expected] of REQUESTS) { const r = match(ORDERS['declaration order'], path); if (r.service !== expected && r.service !== null) { console.log(` ${path.padEnd(20)} -> ${String(r.service).padEnd(18)} (expected ${expected},` + ` matched at rule ${r.scanned})`); } }
6 rules, 12 requests, schema: host, path, service, port rule order correct wrong service rule not found avg scanned declaration order 6 6 2 3.67 specificity order 12 0 2 3.75 requests that went wrong in declaration order: /billing/statement -> billing (expected billing-statement, matched at rule 1) /billing/statement/2026 -> billing (expected billing-statement, matched at rule 1) /work-order/urgent -> work-order (expected urgent-work-order, matched at rule 3) /work-order/urgent/12 -> work-order (expected urgent-work-order, matched at rule 3) /report/monthly -> measurement-report (expected monthly-report, matched at rule 5) /report/monthly/03 -> measurement-report (expected monthly-report, matched at rule 5)
The same six rules, the same twelve requests, two different orders: ten correct or four correct. Reading the list as written in declaration order, nothing looks wrong — every service has its own line, and every line is correct. What is wrong is the relationship between the lines, and this relationship is not written down anywhere in the file. That a subpath should go to its own service is an expectation; the rule list does not know this expectation, only the order.
The last column of the listing below shows how the error occurs: all six requests match the more general rule, and the match happens at rule one, three, and five. The more specific rule sits right underneath in the list and is never read. This is why the average rules scanned is lower in the wrong order — three point sixty-seven against three point seventy-five. The wrong routing is achieved by doing less work; the fast answer is not the correct answer.
This run also says, in hindsight, what the name-based format buys. When each service has its own external host, that host’s rule list has a single line; there is no such thing as one rule containing another, and the six misroutings here cannot happen. This is what the thirty-six surface items buy: the routing decision stops depending on rule order. The choice between the two formats is made between the number of objects to manage and the existence of a whole class of wrong decisions.
In both rows the count of unmatched requests is two, and these two requests are not an error. There is
no rule in the exposed surface for /subscriber and /, so the ingress rule cannot make a decision
and rejects the request. This is what it means for the exposed surface to be countable: a path that is
not on the surface cannot enter the cluster by any route.
The Devolved Decision and What It Does Not Know
The devolved decision in this lesson is which internal service an incoming external request goes to. This decision could also have been made at the client — a separate address for every interface, in the client’s configuration. The ingress rule carries that decision into the cluster, and in exchange asks for four information items: external name, path, target service name, target port. All four items are declarations; they need no measurement and do not age.
The cost is not in the declaration itself but in its count and its order. The second table showed how the count grows: twelve services amount to thirty-six surface items in name-based format, and twelve of them are identity items; each is an object that expires and has to be renewed. In path-based format the same work is done with fourteen items, and there is a single identity item. Here the price of devolving is not a delay, it is the number of objects to manage.
For a known set of requests, three results come out as follows. Ten of twelve requests match a rule, two do not. In specificity order there are ten correct decisions, zero wrong decisions, and two failures to decide; in declaration order there are four correct, six wrong, and again two failures to decide. That the failure-to-decide count stays the same across both orders is meaningful: those two requests are rejected because they have no counterpart on the surface, and this is a result independent of order. The only thing that changes is how many of the ten matching requests go to the right place.
What the system does not know was seen in the second run: which rule contains which. The ingress rule does not infer the containment relationship between paths, it reads the list in order. This missing piece of information sent six requests to the wrong service, and none of them produced an error record — the requests were answered, just from the wrong place. The correct order works once it is written, but when a rule is added later, the same information is needed again. Devolving a decision forces the relationship it depends on to be re-declared with every change. A declaration is not text written once and forgotten, it is an ordering that has to be revisited every time the rule set grows; a relationship visible to the eye at six rules becomes invisible at twelve.
Summary
- An incoming external request passes through four layers — external balancer, ingress rule, service object, pod — and each layer adds one round; the total is four rounds.
- Path-based and name-based routing scan the same total number of rules (five and a half); the difference is not a saving, it is work moving from one layer to the other.
- Rule count equals service count and is independent of format; what changes is how many separate external hosts the rules spread across.
- The exposed surface is five to nine at three services, eight to eighteen at six, fourteen to thirty-six at twelve; in name-based format the external host count and identity item count grow together with the service count.
- The same six rules sent six of twelve requests to the wrong service in declaration order; in specificity order all of them went to the right place. The wrong matches occurred by scanning fewer rules, that is, faster.
- The ingress rule asks for four declaration items and none of them age; the one thing it does not know is the containment relationship between rules, and this relationship has to be re-declared with every new rule.
Next Step
This lesson brought the request to the pod’s door. The process running inside, though, has needs beyond the request that arrives at the door: which data store to connect to, which thresholds to operate with, which key to authenticate with. If these values are written into the image, three environments mean three separate images; if they are kept outside, they mean a new object declared to the cluster. The next lesson measures these objects: when a change made to the same setting set passed in two different forms reaches the running pod, how many keys travel in which form, and how many subjects within the cluster can see values that need to stay secret.
To keep your progress and take notes, Log in
My notes
Log in to take notes.