---
title: 'External Access'
source: 'https://academia.sh/en/courses/orchestration/external-access'
course: 'Container Orchestration'
language: en
updated: '2026-09-13T05:08:26+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```js
// 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.

```js
// 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.
