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.