---
title: 'Block Storage'
source: 'https://academia.sh/en/courses/cloud-storage/block-storage'
course: 'Storage and Data in the Cloud'
language: en
updated: '2026-09-13T05:07:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Computes the input/output budget given by a volume's type and size, and shows the operation chunk"
    - 'Applies workload profiles to the budget and counts the saturated axis and the idle budget'
    - 'Works out the cost of size and performance being tied together as idle GiB and monthly fee'
    - "Counts the volume's inability to shrink and its binding to a single instance as unrecoverable items"
---

# Block Storage

How a volume's type and size give an input/output budget: applying workload profiles to that budget, counting the saturation point and the idle budget, the design trap opened by size and performance being tied together, and the volume's inability to shrink and its binding to a single instance.

The previous three lessons treated data as an object written and read whole. The fictional
measurement network's collector process does not work this way: it changes a few kilobytes in the
middle of a database file thousands of times a second. The 190x write amplification measured in the
first lesson makes this workload impossible on object storage. What is needed is a layer that
presents data as addressable **blocks** and on top of which a file system can be built.

What is rented in this type is a **volume**. In this model a volume attaches to a single
instance, and the operating system sees it as a disk. Some providers allow certain volume types
to attach to more than one instance, but that arrangement needs a cluster-aware file system. The
delegated side is broad: physical disks, replication, repair of a bad
sector, and disk replacement all belong to the provider. What stays comes down to two numbers —
**type** and **size**. That these two determine a third, the input/output budget, is the source of
every measurement in this lesson.

- **ST19** — The volume-type catalog and its fees are fictional; types are called by their
  capabilities.
- **ST20** — An I/O operation rounds up to the volume's **chunk**: on a type that counts 16 KiB
  chunks a 1 MiB read spends 64 chunks, on a type that counts 1 MiB chunks it spends 1.
- **ST21** — Workload profiles are a model: operation size, operation count, and data size are
  written into the model.

```js
// block/model.mjs — the block volume catalog and workload profiles are a MODEL. Fees are a fictional
// unit; no product or volume family name is used, types are called by their capabilities.
export const TYPE = {   // unitKib: the chunk size this type counts I/O operations in; iopsRate: fee per provisioned IOPS
  "general purpose": { unitKib: 16, perGib: 3, base: 100, maxIops: 16000, mibPerGib: 0.25, maxMib: 250, rate: 0.080, iopsRate: 0 },
  "performance-provisioned": { unitKib: 16, perGib: 500, base: 100, maxIops: 64000, mibPerGib: 1.00, maxMib: 1000, rate: 0.125, iopsRate: 0.006 },
  "throughput-optimized": { unitKib: 1024, perGib: 0.5, base: 12, maxIops: 500, mibPerGib: 0.40, maxMib: 1000, rate: 0.045, iopsRate: 0 },
  cold: { unitKib: 1024, perGib: 0.2, base: 12, maxIops: 250, mibPerGib: 0.12, maxMib: 250, rate: 0.015, iopsRate: 0 },
};
// Workload profiles: operation size, operations a second, the data it sits on.
export const WORKLOAD = [
  { name: "meter writes", kib: 16, ops: 4200, dataGib: 200, kind: "random small write" },
  { name: "work-order query", kib: 8, ops: 900, dataGib: 60, kind: "random small read" },
  { name: "invoice generation", kib: 1024, ops: 180, dataGib: 400, kind: "sequential large read" },
  { name: "photo upload", kib: 4096, ops: 25, dataGib: 1200, kind: "sequential large write" },
];
// An operation rounds up to the type's chunk: a 16 KiB write also spends 1 chunk on a 1024 KiB-chunk type.
export const chunks = (w, t) => Math.ceil(w.kib / t.unitKib);
export const consumedIops = (w, t) => w.ops * chunks(w, t);
export const consumedMib = (w, t) => consumedIops(w, t) * t.unitKib / 1024;
export const budget = (t, gib) => ({
  iops: Math.min(t.maxIops, Math.max(t.base, Math.round(t.perGib * gib))),
  mib: Math.min(t.maxMib, t.mibPerGib * gib),
});
export const cost = (t, gib, provisionedIops = 0) => t.rate * gib + t.iopsRate * provisionedIops;
```

## Type and Size Give a Budget

```js
// block/budget.mjs — volume type and size give a budget; workloads are applied to that budget
import { TYPE, WORKLOAD, chunks, consumedIops, consumedMib, budget, cost } from "./model.mjs";

const g = (x, n, d = 2) => x.toFixed(d).padStart(n);
const s = (x, n) => String(x).padStart(n);
console.log("workload            | kind                    |  op size | ops/s | raw MiB/s | data GiB");
console.log("--------------------|-------------------------|----------|-------|-----------|---------");
for (const w of WORKLOAD)
  console.log(`${w.name.padEnd(19)} | ${w.kind.padEnd(23)} | ${s(w.kib + " KiB", 8)} | ${s(w.ops, 5)} | ` +
    `${g(w.ops * w.kib / 1024, 9, 1)} | ${s(w.dataGib, 8)}`);

const general = TYPE["general purpose"];
console.log("\nbuying a general-purpose volume sized to the data (the natural choice), 16 KiB chunk:");
console.log("workload            | budget iops | consumed | utilization | budget MiB/s | consumed | utilization | saturated axis");
console.log("--------------------|-------------|----------|-------------|--------------|----------|-------------|---------------");
for (const w of WORKLOAD) {
  const b = budget(general, w.dataGib), dIops = consumedIops(w, general) / b.iops, dMib = consumedMib(w, general) / b.mib;
  console.log(`${w.name.padEnd(19)} | ${s(b.iops, 11)} | ${s(consumedIops(w, general), 8)} | ${g(100 * dIops, 10, 1)}% | ` +
    `${g(b.mib, 12, 1)} | ${g(consumedMib(w, general), 8, 1)} | ${g(100 * dMib, 10, 1)}% | ` +
    `${(dIops >= dMib ? "iops" : "MiB/s").padStart(14)}`);
}
const photo = WORKLOAD[3], pb = budget(general, photo.dataGib);
console.log(`idle budget: while ${photo.name} saturates on the iops axis, ` +
  `${(100 * (1 - consumedMib(photo, general) / pb.mib)).toFixed(1)}% of the MiB/s budget goes unused. Also, an 8 KiB read`);
console.log(`consumes a 16 KiB chunk: the work-order query's raw ${(WORKLOAD[1].ops * WORKLOAD[1].kib / 1024).toFixed(1)} MiB/s is ` +
  `written into the budget as ${consumedMib(WORKLOAD[1], general).toFixed(1)} MiB/s.`);

console.log("\nsmallest size needed to satisfy the workload, and monthly fee (fictional unit):");
console.log("workload            | type                     | chunks/op | required GiB | idle GiB | monthly fee");
console.log("--------------------|--------------------------|-----------|--------------|----------|------------");
const cheapest = {};
for (const w of WORKLOAD)
  for (const [name, t] of Object.entries(TYPE)) {
    let size = null;
    for (let gib = w.dataGib; gib <= 65536; gib += 4) {          // size is grown until the budget catches up
      const b = budget(t, gib);
      if (b.iops >= consumedIops(w, t) && b.mib >= consumedMib(w, t)) { size = gib; break; }
    }
    if (name === "performance-provisioned" && consumedMib(w, t) <= t.maxMib && consumedIops(w, t) <= t.maxIops) size = w.dataGib;
    if (size === null) {
      console.log(`${w.name.padEnd(19)} | ${name.padEnd(24)} | ${s(chunks(w, t), 9)} | ${"infeasible".padStart(12)} | ` +
        `${"-".padStart(8)} | ${"-".padStart(11)}`);
      continue;
    }
    const c = cost(t, size, name === "performance-provisioned" ? consumedIops(w, t) : 0);
    if (!cheapest[w.name] || c < cheapest[w.name].c) cheapest[w.name] = { name, c, size };
    console.log(`${w.name.padEnd(19)} | ${name.padEnd(24)} | ${s(chunks(w, t), 9)} | ${s(size, 12)} | ` +
      `${s(size - w.dataGib, 8)} | ${g(c, 11)}`);
  }
console.log("\ncheapest choice: " + WORKLOAD.map((w) => `${w.name} -> ${cheapest[w.name].name} ${cheapest[w.name].size} GiB ` +
  `(${cheapest[w.name].c.toFixed(2)})`).join("; "));
```

```
workload            | kind                    |  op size | ops/s | raw MiB/s | data GiB
--------------------|-------------------------|----------|-------|-----------|---------
meter writes        | random small write      |   16 KiB |  4200 |      65.6 |      200
work-order query    | random small read       |    8 KiB |   900 |       7.0 |       60
invoice generation  | sequential large read   | 1024 KiB |   180 |     180.0 |      400
photo upload        | sequential large write  | 4096 KiB |    25 |     100.0 |     1200

buying a general-purpose volume sized to the data (the natural choice), 16 KiB chunk:
workload            | budget iops | consumed | utilization | budget MiB/s | consumed | utilization | saturated axis
--------------------|-------------|----------|-------------|--------------|----------|-------------|---------------
meter writes        |         600 |     4200 |      700.0% |         50.0 |     65.6 |      131.3% |           iops
work-order query    |         180 |      900 |      500.0% |         15.0 |     14.1 |       93.8% |           iops
invoice generation  |        1200 |    11520 |      960.0% |        100.0 |    180.0 |      180.0% |           iops
photo upload        |        3600 |     6400 |      177.8% |        250.0 |    100.0 |       40.0% |           iops
idle budget: while photo upload saturates on the iops axis, 60.0% of the MiB/s budget goes unused. Also, an 8 KiB read
consumes a 16 KiB chunk: the work-order query's raw 7.0 MiB/s is written into the budget as 14.1 MiB/s.

smallest size needed to satisfy the workload, and monthly fee (fictional unit):
workload            | type                     | chunks/op | required GiB | idle GiB | monthly fee
--------------------|--------------------------|-----------|--------------|----------|------------
meter writes        | general purpose          |         1 |         1400 |     1200 |      112.00
meter writes        | performance-provisioned  |         1 |          200 |        0 |       50.20
meter writes        | throughput-optimized     |         1 |   infeasible |        - |           -
meter writes        | cold                     |         1 |   infeasible |        - |           -
work-order query    | general purpose          |         1 |          300 |      240 |       24.00
work-order query    | performance-provisioned  |         1 |           60 |        0 |       12.90
work-order query    | throughput-optimized     |         1 |   infeasible |        - |           -
work-order query    | cold                     |         1 |   infeasible |        - |           -
invoice generation  | general purpose          |        64 |         3840 |     3440 |      307.20
invoice generation  | performance-provisioned  |        64 |          400 |        0 |      119.12
invoice generation  | throughput-optimized     |         1 |          452 |       52 |       20.34
invoice generation  | cold                     |         1 |         1500 |     1100 |       22.50
photo upload        | general purpose          |       256 |         2136 |      936 |      170.88
photo upload        | performance-provisioned  |       256 |         1200 |        0 |      188.40
photo upload        | throughput-optimized     |         4 |         1200 |        0 |       54.00
photo upload        | cold                     |         4 |         1200 |        0 |       18.00

cheapest choice: meter writes -> performance-provisioned 200 GiB (50.20); work-order query -> performance-provisioned 60 GiB (12.90); invoice generation -> throughput-optimized 452 GiB (20.34); photo upload -> cold 1200 GiB (18.00)
```

The second table gives the saturation points. Buying a volume sized to the data — the natural
choice — falls short on all four workloads: utilization runs from 177.8% to 960%. The saturated axis
is iops in all four, but the reasons differ. Meter writes truly perform a great many operations;
invoice generation performs only 180 operations a second, but since each operation spends 64 chunks,
the budget records 11,520 iops. **Chunk size changes how a workload looks in the budget.**

Idle budget is the other face of the same table. While photo upload saturates on the iops axis, 60%
of the MiB/s budget is never spent. There is a loss in the reverse direction too: an 8 KiB work-order
read consumes a full 16 KiB chunk, so the raw 7.0 MiB/s is recorded in the budget as 14.1 MiB/s — a
direct consequence of the operation being smaller than the chunk.

The third table reduces type choice to a calculation. Sequential large reads cost 15x less on types
that count 1 MiB chunks: invoice generation costs 307.20 on the general-purpose volume, 20.34 on the
throughput-optimized volume. The same types, though, **cannot satisfy** random small writes — a
16 KiB write spends a full 1 MiB chunk, so 4,200 operations a second records 4,200 MiB/s of
consumption. The wrong type here is not expensive, it is infeasible.

## Size and Performance Being Tied Together

- **ST22** — On the performance-provisioned type, iops is billed separately; on the other types the
  budget depends only on size.
- **ST23** — A volume can grow but cannot shrink; the copy speed in the model is 250 MiB/s.
- **ST24** — A volume is bound to a single instance and a single region; the snapshot compression
  ratio is 0.55.

```js
// block/trap.mjs — size and performance being tied together, no shrinking, and single-instance binding are counted
import { TYPE, WORKLOAD, consumedIops, budget, cost } from "./model.mjs";

const general = TYPE["general purpose"], meter = WORKLOAD[0];
const EGRESS = { crossRegion: 0.020, internet: 0.085 };
const COMPRESSION = 0.55, COPY_MIB = 250, ATTACH_SEC = 40, COLLECTORS = 12, WRITERS = 1;
const g = (x, d = 2) => x.toFixed(d);
const requiredSize = (iops) => {                       // size is grown until the budget catches up
  for (let gib = 4; gib <= 65536; gib += 4) if (budget(general, gib).iops >= iops) return gib;
};
const INITIAL = requiredSize(consumedIops(meter, general)), DROPPED = 900, NEW_SIZE = requiredSize(DROPPED);
console.log(`${meter.name}: data ${meter.dataGib} GiB, required ${consumedIops(meter, general)} iops.`);
console.log(`  on a general-purpose volume, iops is tied to size, so ${INITIAL} GiB must be bought: ` +
  `${INITIAL - meter.dataGib} GiB stands there not for data but for budget (${g(cost(general, INITIAL))} units a month).`);
console.log(`  the same budget is bought on the performance-provisioned type with ${meter.dataGib} GiB: ` +
  `${g(cost(TYPE["performance-provisioned"], meter.dataGib, consumedIops(meter, general)))} units a month.`);

console.log(`\nthe workload dropped from ${consumedIops(meter, general)} iops to ${DROPPED} iops; now ${NEW_SIZE} GiB is enough.`);
console.log(`  growing is free: the ${INITIAL} GiB volume can be grown without moving data.`);
console.log(`  no shrinking: a new ${NEW_SIZE} GiB volume is opened, ${meter.dataGib} GiB is copied, the connection is swapped.`);
const copySec = meter.dataGib * 1024 / COPY_MIB;
console.log(`  copying takes ${g(copySec / 60, 1)} min (${COPY_MIB} MiB/s), and both volumes are billed at once during the move: ` +
  `${g(cost(general, INITIAL) + cost(general, NEW_SIZE))} units/month.`);
console.log(`  the extra paid for not being able to shrink: ${g(cost(general, INITIAL) - cost(general, NEW_SIZE))} a month, ` +
  `${g(12 * (cost(general, INITIAL) - cost(general, NEW_SIZE)))} units a year.`);

const snapshot = meter.dataGib * COMPRESSION;
console.log(`\na volume is bound to a single instance and a single region: only ${WRITERS} of ${COLLECTORS} collector instances can write at once.`);
console.log(`  handing it to another instance means detaching and attaching; in the model, attaching takes ${ATTACH_SEC} s with no writer during that time.`);
console.log(`  moving it to another region needs a snapshot: ${g(snapshot, 1)} GiB (compression ${COMPRESSION}); ` +
  `cross-region egress ${g(snapshot * EGRESS.crossRegion, 2)}, to the internet ${g(snapshot * EGRESS.internet, 2)} units.`);
console.log(`  an in-region snapshot incurs no egress fee; taking the volume out of the region does.`);
```

```
meter writes: data 200 GiB, required 4200 iops.
  on a general-purpose volume, iops is tied to size, so 1400 GiB must be bought: 1200 GiB stands there not for data but for budget (112.00 units a month).
  the same budget is bought on the performance-provisioned type with 200 GiB: 50.20 units a month.

the workload dropped from 4200 iops to 900 iops; now 300 GiB is enough.
  growing is free: the 1400 GiB volume can be grown without moving data.
  no shrinking: a new 300 GiB volume is opened, 200 GiB is copied, the connection is swapped.
  copying takes 13.7 min (250 MiB/s), and both volumes are billed at once during the move: 136.00 units/month.
  the extra paid for not being able to shrink: 88.00 a month, 1056.00 units a year.

a volume is bound to a single instance and a single region: only 1 of 12 collector instances can write at once.
  handing it to another instance means detaching and attaching; in the model, attaching takes 40 s with no writer during that time.
  moving it to another region needs a snapshot: 110.0 GiB (compression 0.55); cross-region egress 2.20, to the internet 9.35 units.
  an in-region snapshot incurs no egress fee; taking the volume out of the region does.
```

The trap stands in the first lines. Because iops is tied to size on the general-purpose type, 200
GiB of data demands a 1,400 GiB volume: 1,200 GiB stands there not for data but for **budget**. The
same budget is bought at 200 GiB for 50.20 units on the type that sells iops separately — buying
capacity for performance means paying for empty bytes again every month.

The second block gives the unrecoverable item. When the workload drops from 4,200 iops to 900, 300
GiB is enough — but **the volume cannot shrink**. Growing happens without moving data; shrinking
means opening a new volume, copying 200 GiB, and swapping the connection — 13.7 minutes in the model,
double billing during the move, and 1,056 units overpaid a year. Size is a one-way dial.

The third block writes the type's own limit. A volume is bound to a single instance: only one of
twelve collector instances can write at once; handing it to another instance is a matter of
detaching and attaching, and in the model there is no writer at all for forty seconds. A volume is
also bound to a single region, and moving it requires a snapshot — 110 GiB, 2.20 units cross-region,
9.35 units to the internet. A snapshot that stays inside the region incurs no egress fee.

## Summary

- Type and size together give an input/output budget; the budget is spent on the iops and MiB/s
  axes, and the workload saturates on whichever it fills first.
- An operation rounds up to the volume's chunk: a 1 MiB read spends 64 chunks on a 16 KiB chunk; an
  8 KiB read's raw 7.0 MiB/s shows up in the budget as 14.1 MiB/s.
- Idle budget is measured: while photo upload saturates on the iops axis, 60% of the MiB/s budget
  goes unused. The wrong type is sometimes not expensive but infeasible.
- Size and performance being tied together is a trap: a 1,400 GiB volume is bought for 200 GiB of
  data, and 1,200 GiB stands there for budget. The same budget is 50.20 units instead of 112.00 on
  the type that sells iops separately.
- The unrecoverable item is size: a volume can grow but cannot shrink. Shrinking means a new volume,
  a 13.7-minute copy, and double billing.

## Next Step

The last block left a door open: only one of twelve collector instances can write at once. In the
fictional measurement network this is a direct obstacle — field teams drop work-order attachments
into the same directory, invoice generation reads from the same directory, and all of it runs on
separate instances. Block storage cannot satisfy this, and object storage offers no directory or
lock. The next lesson measures a third type: a shared file system that multiple instances mount at
the same time on the same data. Three things will be measured — how multi-writer access changes
across the three types, how long the lock makes things wait, and the workload where a shared file
system gains nothing at all.
