Skip to content
academia.sh

Lesson 06 / 20

Shared Layers and Dependencies

The relationship between package size and cold start is scanned: when dependencies move to a shared layer, the package shrinks but the bytes pulled do not change. Version pinning's storage cost, a layer update's radius, and an unused dependency's cost in bytes moved are counted.

Published Updated

In this lesson

  • Breaks package size down into cold start's pull, unpack, and module load line items to work out the time per megabyte
  • Compares four packaging arrangements carrying the same content: package size, stored bytes, and cold start range
  • Counts a layer update's two costs separately: the number of publishes required and the number of functions affected at once
Contents

In the version-shifting measurement, cold start behaved like a constant of eighteen hundred milliseconds. It is not. That number comes from the function’s package: the bigger the package, the more bytes there are to pull, unpack, and load. The question here is this — what are the ways to shrink the package, and what does each way bill?

The layer here shares the same word as a container image’s layer but is a different object: there, the layer was a file system snapshot; here it is a common set of dependencies added to the function’s package at publish time.

  • FC31. The fictional regional measurement network has nine functions and a dependency catalog of seven items. Sizes, module counts, and usage relationships are fictional.
  • FC32. Cold start is the sum of four items: a size-independent baseline of one hundred eighty milliseconds, pulling at twenty megabytes per second, unpacking at twelve milliseconds per megabyte, and loading at zero point thirty-five milliseconds per module. Network variability and compression are not modeled.
  • FC33. A dependency moves to the shared layer if it appears in at least four functions. The threshold itself is a design decision and is not scanned.
  • FC34. The layer is pulled together with the package on cold start. Host caching is modeled with a single hit rate; on a hit the layer’s cost is zero.
  • FC35. Every function experiences forty cold starts a day. Traffic’s distribution across the day is not modeled.
  • FC36. The distribution of live layer versions is fictional: four versions, pinned to three, two, three, and one functions respectively.

What a Cold Start Is the Sum Of

What is measured is not a real deployment but a model built with node. The catalog holds each dependency’s size and module count; each of the nine functions lists the dependencies it actually calls. One item does not appear in any list.

// package.mjs — package size, cold start, and shared layer model; not a measurement, a model
export const BANDWIDTH_MB_S = 20;   // fictional: bandwidth for pulling the package
export const UNPACK_MS_MB = 12;     // time to unpack one MB
export const MODULE_MS = 0.35;      // load time per module
export const BASE_MS = 180;         // runtime startup baseline, independent of size
export const COLD_PER_DAY = 40;     // fictional: daily cold starts per function
export const LAYER_THRESHOLD = 4;   // a dependency used in this many functions moves to the shared layer

// The fictional measurement network's dependency catalog: size (MB) and module count.
export const CATALOG = {
  mp: { name: 'measurement parser', mb: 9.4, module: 320 },
  sv: { name: 'schema validator', mb: 4.8, module: 210 },
  da: { name: 'data access layer', mb: 6.3, module: 240 },
  dt: { name: 'date-time library', mb: 2.1, module: 90 },
  eh: { name: 'encryption helpers', mb: 1.6, module: 60 },
  rf: { name: 'report formatter', mb: 14.2, module: 540 },
  ig: { name: 'image generator', mb: 21.5, module: 760 },
};
export const OWN_CODE = { mb: 1.2, module: 45 };   // each function's own code

// Nine functions and the dependencies they actually call. 'ig' does not appear in any function.
export const FUNCTIONS = {
  'reading-validation': ['mp', 'sv', 'da', 'dt', 'eh'],
  'reading-writing': ['mp', 'da', 'dt', 'eh'],
  'billing-generation': ['mp', 'sv', 'da', 'dt', 'eh', 'rf'],
  'billing-delivery': ['da', 'dt', 'eh', 'rf'],
  'field-notification': ['da', 'dt', 'eh'],
  'nightly-batch-job': ['mp', 'sv', 'da', 'dt', 'eh'],
  'meter-registration': ['sv', 'da', 'dt', 'eh'],
  'tariff-update': ['sv', 'da', 'eh'],
  'health-endpoint': ['da'],
};

export const usedBy = (k) => Object.values(FUNCTIONS).filter((d) => d.includes(k)).length;
export const size = (ks) => ks.reduce((a, k) => ({ mb: a.mb + CATALOG[k].mb, module: a.module + CATALOG[k].module }),
  { ...OWN_CODE });

// Cold start: fixed base + pull + unpack + module load. Bytes are both pulled and unpacked.
export const cold = (b) => BASE_MS + (1000 * b.mb) / BANDWIDTH_MB_S + UNPACK_MS_MB * b.mb + MODULE_MS * b.module;

export const row = (g, ...x) => x.map((v, i) => String(v).padEnd(g[i])).join('');
export const mb = (x) => `${x.toFixed(1)} MB`;
export const extra = (b) => cold(b) - BASE_MS;   // pull + unpack + load time with the base subtracted
// run1.mjs — the relationship between package size and cold start, and four packaging arrangements
import { CATALOG, FUNCTIONS, OWN_CODE, LAYER_THRESHOLD, COLD_PER_DAY, BANDWIDTH_MB_S, UNPACK_MS_MB, MODULE_MS,
  usedBy, size, cold, row, mb } from './package.mjs';

const all = Object.keys(CATALOG), n = Object.keys(FUNCTIONS).length;
console.log(`${n} functions | pull ${BANDWIDTH_MB_S} MB/s | unpack ${UNPACK_MS_MB} ms/MB | module ${MODULE_MS} ms`);
console.log(`usage count: ${all.map((k) => `${k} ${usedBy(k)}`).join(', ')} | layer threshold ${LAYER_THRESHOLD}`);

console.log('\npackage size -> cold start (module count is assumed proportional to size, 36 modules/MB)');
console.log(row([10, 9, 12, 11, 11], 'size', 'modules', 'pull ms', 'unpack ms', 'load ms') + 'cold start');
for (const x of [2, 8, 16, 26, 40, 61]) {
  const b = { mb: x, module: Math.round(36 * x) };
  console.log(row([10, 9, 12, 11, 11], mb(x), b.module, Math.round(1000 * x / BANDWIDTH_MB_S),
    Math.round(UNPACK_MS_MB * x), Math.round(MODULE_MS * b.module)) + `${Math.round(cold(b))} ms`);
}

// Four packaging arrangements. C and D carry the same content: the only difference is where the bytes sit.
const layerCode = all.filter((k) => usedBy(k) >= LAYER_THRESHOLD), layer = size(layerCode);
layer.mb -= OWN_CODE.mb; layer.module -= OWN_CODE.module;          // the layer holds no function's own code
const arrangement = (name, packer, layered = false) => {
  const pkgs = Object.values(FUNCTIONS).map((d) => size(packer(d)));
  const pulled = pkgs.map((x) => (layered ? { mb: x.mb + layer.mb, module: x.module + layer.module } : x));
  const c = pulled.map(cold), sizes = pkgs.map((x) => x.mb);
  return { name, smallest: Math.min(...sizes), largest: Math.max(...sizes),
    stored: sizes.reduce((a, b) => a + b, 0) + (layered ? layer.mb : 0),
    coldMin: Math.min(...c), coldMax: Math.max(...c) };
};
const D = [54, 18, 17, 12];
console.log('\npackaging arrangements — nine functions');
console.log(row(D, 'arrangement', 'smallest package', 'largest package', 'stored') + 'cold start range');
for (const r of [
  arrangement('A. every function packages the whole catalog', () => all),
  arrangement('B. entirely unused items are stripped out', () => all.filter((k) => usedBy(k) > 0)),
  arrangement('C. every function packages only what it uses', (d) => d),
  arrangement('D. dependencies past the threshold move to the layer', (d) => d.filter((k) => usedBy(k) < LAYER_THRESHOLD), true)]) {
  console.log(row(D, r.name, mb(r.smallest), mb(r.largest), mb(r.stored)) +
    `${Math.round(r.coldMin)}-${Math.round(r.coldMax)} ms`);
}

const ig = CATALOG.ig, singleCold = 1000 * ig.mb / BANDWIDTH_MB_S + UNPACK_MS_MB * ig.mb + MODULE_MS * ig.module;
console.log(`\nif the entirely unused "${ig.name}" (${mb(ig.mb)}, ${ig.module} modules) stays in arrangement A:`);
console.log(`  ${Math.round(singleCold)} ms on every cold start, ${(n * COLD_PER_DAY * singleCold / 1000 / 60).toFixed(1)} min ` +
  `and ${(n * COLD_PER_DAY * ig.mb / 1024).toFixed(1)} GB of bytes moved across ${n * COLD_PER_DAY} daily cold starts`);
console.log(`  ${mb(n * ig.mb)} in storage; stripping it shrinks the package by ${mb(ig.mb)} and requires no code change`);
9 functions | pull 20 MB/s | unpack 12 ms/MB | module 0.35 ms
usage count: mp 4, sv 5, da 9, dt 7, eh 8, rf 2, ig 0 | layer threshold 4

package size -> cold start (module count is assumed proportional to size, 36 modules/MB)
size      modules  pull ms     unpack ms  load ms    cold start
2.0 MB    72       100         24         25         329 ms
8.0 MB    288      400         96         101        777 ms
16.0 MB   576      800         192        202        1374 ms
26.0 MB   936      1300        312        328        2120 ms
40.0 MB   1440     2000        480        504        3164 ms
61.0 MB   2196     3050        732        769        4731 ms

packaging arrangements — nine functions
arrangement                                           smallest package  largest package  stored      cold start range
A. every function packages the whole catalog          61.1 MB           61.1 MB          549.9 MB    4761-4761 ms
B. entirely unused items are stripped out             39.6 MB           39.6 MB          356.4 MB    3162-3162 ms
C. every function packages only what it uses          7.5 MB            39.6 MB          185.0 MB    745-3162 ms
D. dependencies past the threshold move to the layer  1.2 MB            15.4 MB          63.4 MB     2093-3162 ms

if the entirely unused "image generator" (21.5 MB, 760 modules) stays in arrangement A:
  1599 ms on every cold start, 9.6 min and 7.6 GB of bytes moved across 360 daily cold starts
  193.5 MB in storage; stripping it shrinks the package by 21.5 MB and requires no code change

The first scan separates out where cold start comes from. Going from two megabytes to sixty-one, the duration climbs from three hundred twenty-nine milliseconds to four thousand seven hundred thirty-one; the entire difference in between is due to size. The load per megabyte is seventy-four point six milliseconds: fifty pull, twelve unpack, twelve point six module load. The fixed baseline stays at one hundred eighty — in a thirty-megabyte package, more than 90% of cold start is the bytes themselves.

Four Packaging Arrangements

The second table builds the same nine functions with four different packaging arrangements. In arrangement A every function carries the whole catalog; in B the entirely unused item is stripped out; in C every function packages only what it calls; in D the dependencies past the threshold move to the shared layer.

Going from A to B is a free gain: the code does not change, the package drops from sixty-one point one to thirty-nine point six, and cold start falls from four thousand seven hundred sixty-one to three thousand one hundred sixty-two. C, by giving each function its own list, brings the smallest package down to seven point five and opens the cold start range to between seven hundred forty-five and three thousand one hundred sixty-two.

Row D, though, writes the lesson’s real number. Package size falls to as low as one point two megabytes — one-sixth of C’s — but cold start’s lower end climbs from seven hundred forty-five to two thousand ninety-three. The reason is simple: the twenty-four point two megabytes in the layer did not vanish, they were only moved outside the package; they are still pulled on cold start. What is more, functions that do not use everything in the layer now pull bytes they do not use as well. Taking package size as the measure is misleading here; what should be measured is bytes pulled.

The last rows write the bill for the entirely unused item. The twenty-one point five megabyte image generator adds one thousand five hundred ninety-nine milliseconds to every cold start; across three hundred sixty daily cold starts that comes to nine point six minutes and seven point six gigabytes of bytes moved.

The Layer Does Not Destroy Bytes, It Relocates Them

// run2.mjs — layer cache hit rate, version pinning, and the radius of a layer update
import { CATALOG, FUNCTIONS, OWN_CODE, LAYER_THRESHOLD, BASE_MS, usedBy, size, cold, extra, row, mb } from './package.mjs';

const all = Object.keys(CATALOG), n = Object.keys(FUNCTIONS).length;
const layerCode = all.filter((k) => usedBy(k) >= LAYER_THRESHOLD), layer = size(layerCode);
layer.mb -= OWN_CODE.mb; layer.module -= OWN_CODE.module;
const pkgC = Object.values(FUNCTIONS).map((d) => size(d));
const pkgD = Object.values(FUNCTIONS).map((d) => size(d.filter((k) => usedBy(k) < LAYER_THRESHOLD)));
console.log(`shared layer: ${layerCode.join(', ')} = ${mb(layer.mb)}, ${layer.module} modules`);

// If the layer is cached on the host, it is not pulled again on cold start. Hit rate is scanned.
const O = [10, 21, 20, 18];
console.log(row(O, 'hit rate', 'D smallest function', 'D largest function', 'D average') + 'C average');
const avgC = pkgC.map(cold).reduce((a, b) => a + b, 0) / n;
for (const h of [0, 0.25, 0.5, 0.75, 1]) {
  const times = pkgD.map((p) => BASE_MS + extra(p) + (1 - h) * extra(layer));
  console.log(row(O, `${h * 100}%`, `${Math.round(Math.min(...times))} ms`, `${Math.round(Math.max(...times))} ms`,
    `${Math.round(times.reduce((a, b) => a + b, 0) / n)} ms`) + `${Math.round(avgC)} ms`);
}

// The layer is versioned, and each function is pinned to one version. Fictional split: how many functions on which version.
const PINNING = { v4: 3, v3: 2, v2: 3, v1: 1 };
const live = Object.keys(PINNING).length;
console.log(`\nversion pinning — ${live} layer versions kept live`);
console.log(`  ${Object.entries(PINNING).map(([s, k]) => `${s}: ${k} functions`).join(' | ')}`);
console.log(`  total layer storage ${mb(live * layer.mb)} (single-version assumption ${mb(layer.mb)}); ` +
  `dropping to one version requires republishing ${n - Math.max(...Object.values(PINNING))} functions`);

// A dependency update's two costs: how many publishes it takes and how many functions it hits at once.
const Y = [40, 11, 22];
console.log('\na dependency update');
console.log(row(Y, 'where the dependency sits', 'publishes', 'affected at once') + 'stored');
for (const [label, publishes, radius, stored] of [
  ['C — separately inside each package', n, 1, pkgC.reduce((a, b) => a + b.mb, 0)],
  ['D — in the layer, version pinned', n, 1, pkgD.reduce((a, b) => a + b.mb, 0) + live * layer.mb],
  ['D — in the layer, following an alias', 1, n, pkgD.reduce((a, b) => a + b.mb, 0) + layer.mb]]) {
  console.log(row(Y, label, publishes, `${radius} functions`) + mb(stored));
}
shared layer: mp, sv, da, dt, eh = 24.2 MB, 920 modules
hit rate  D smallest function  D largest function  D average         C average
0%        2093 ms              3162 ms             2330 ms           1730 ms
25%       1637 ms              2706 ms             1875 ms           1730 ms
50%       1181 ms              2251 ms             1419 ms           1730 ms
75%       726 ms               1795 ms             963 ms            1730 ms
100%      270 ms               1340 ms             508 ms            1730 ms

version pinning — 4 layer versions kept live
  v4: 3 functions | v3: 2 functions | v2: 3 functions | v1: 1 functions
  total layer storage 96.8 MB (single-version assumption 24.2 MB); dropping to one version requires republishing 6 functions

a dependency update
where the dependency sits               publishes  affected at once      stored
C — separately inside each package      9          1 functions           185.0 MB
D — in the layer, version pinned        9          1 functions           136.0 MB
D — in the layer, following an alias    1          9 functions           63.4 MB

The first table shows when the layer pays off. At zero cache hit rate, arrangement D’s average is two thousand three hundred thirty, C’s average is one thousand seven hundred thirty milliseconds — the layer makes the average six hundred milliseconds worse. At 50% hit rate D drops to one thousand four hundred nineteen and passes C; the break-even point sits around 33%, and at a full hit D falls to five hundred eight. The layer’s value is not in the layer itself but in how often the host holds onto it — and that is not a decision the function’s author makes.

Version Pinning and the Radius of an Update

The version pinning table writes the second cost. Nine functions are pinned to four separate layer versions, and none can be dropped, because each has users. Stored layer bytes climb from twenty-four point two to ninety-six point eight; pulling all of them onto a single version requires republishing six functions.

The last table cuts the bill for the handed-off decision. While the dependency sits separately inside each package, an update needs nine publishes, but each publish affects a single function: the radius is one, and it can roll out gradually. Once it moves to the layer and the version is pinned, the number of publishes is still nine — pinning takes back the layer’s “update from one place” promise — the gain is only in storage, from one hundred eighty-five to one hundred thirty-six megabytes. Once pinning is dropped and the layer follows an alias, publishes fall to one; in return the radius climbs to nine. A single write changes nine functions at once, and one flawed layer version breaks all nine together.

Summary

  • Cold start’s load per megabyte is seventy-four point six milliseconds: fifty pull, twelve unpack, twelve point six module load.
  • Stripping the entirely unused item drops the package to thirty-nine point six megabytes and cold start to three thousand one hundred sixty-two milliseconds, with no code change required; bytes moved shrink by seven point six gigabytes a day.
  • Moving dependencies to the layer pulls the package down to one point two megabytes but pushes cold start’s lower end from seven hundred forty-five to two thousand ninety-three milliseconds: the bytes did not vanish, they changed location.
  • The layer only pays off once the host cache hits above 33%; at zero hit rate it makes the average six hundred milliseconds worse.
  • With four layer versions kept live, stored layer bytes quadruple; dropping to one version demands republishing six functions.
  • An update means nine publishes and a one-function radius inside the package, still nine publishes in a pinned layer, and a single publish with a nine-function radius in a layer that follows an alias.

Next Step

The package was measured, the layer was measured, and which item of cold start comes from where was written down. All of this shared a single assumption: the job fits the function. It fits inside the duration limit, its state can be kept outside, it starts when a request arrives and ends with a response. Not every job is like this. The next lesson places five job types side by side on the axes of duration, state, and cost; for each type it writes the item that exceeds the threshold by name, and counts the cost of splitting an unfit job to make it fit — step count, intermediate state storage, failure surface. In the end a single table remains: which job fits serverless, which does not, and why.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close