A World in a Container
Code history and identity
go run ./cmd/worldd makes the valley exist for exactly as long as that
terminal stays open and your hand stays off Ctrl-C. The hosted rule is a three-way split:
a world you can host keeps its code, its history and its identity in three
separate places, and none of the three is a property of the machine it happens to be
running on.
Close the laptop and time stops. Whatever else this is, it is not yet something you can host.
The running world borrows a Go toolchain, of a version nobody checked at startup. It
borrows the source tree, because go run compiles from source every single
time.
It borrows your current directory, since -log world.jsonl means whatever
directory you were standing in. It borrows your attention, because the program dies with
the shell that launched it.
Code goes into a container image, built once and identical everywhere it lands. History goes into a volume, which outlives every process that writes to it. Identity, which here means the seed, arrives from the environment at startup.
Hold those three apart and you can start the world with one command, walk away, and read
what it did through podman logs.
Environment defaults
Identity first, because it changes the program least. Last chapter gave worldd flags:
-seed, -ticks, -log, -fast. Flags
are the right interface for a person at a keyboard, and they keep working here, because
a container run command can pass arguments straight through to the program inside.
They are the wrong interface for everything else that starts this world,
though. Compose files, unit files and orchestrators all configure a container the same
way: by handing it environment variables. The fix is not to choose. Let the environment
set the flag defaults, and let a flag on the command line beat the environment
whenever somebody bothers to type one.
// cmd/worldd/main.go — above main
// defaults are the flag defaults, which the environment is allowed to
// change before the command line gets its turn.
type defaults struct {
seed uint64
ticks uint64
report uint64
log string
}
// envNum reads an unsigned number from the environment, or returns
// def if the variable is unset or empty.
func envNum(name string, def uint64) (uint64, error) {
raw, ok := os.LookupEnv(name)
if !ok || raw == "" {
return def, nil
}
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s=%q: not a whole number", name, raw)
}
return n, nil
}
// envStr reads a string from the environment, or returns def.
func envStr(name, def string) string {
if raw, ok := os.LookupEnv(name); ok && raw != "" {
return raw
}
return def
}
// fromEnv collects the defaults worldd will start with, refusing to
// start at all on a value it cannot read.
func fromEnv() (defaults, error) {
d := defaults{log: envStr("WORLD_LOG", "world.jsonl")}
var err error
if d.seed, err = envNum("WORLD_SEED", 5); err != nil {
return d, err
}
if d.ticks, err = envNum("WORLD_TICKS", 600); err != nil {
return d, err
}
d.report, err = envNum("WORLD_REPORT", 0)
return d, err
}
os.LookupEnv returns the value and a second result saying whether the
variable was set at all, and that second result is what makes defaults possible: an
unset WORLD_SEED is a different situation from
WORLD_SEED=0, and seed zero is a perfectly good world somebody might
want. strconv.ParseUint turns text into a number and, more usefully,
reports when it cannot. fromEnv passes that failure up instead of
falling back to 5, because a world that ran seed 5 after you fat-fingered the seed
would be the worst available outcome: you would stare at a valley and draw
conclusions about a number that never ran.
The other new value in that struct is report, and it exists because of
where this program runs. Detached inside a container, worldd has no terminal and
nobody sitting in front of it. Its standard output becomes the only sign of life
anybody outside can see, and a program that prints two lines in ten minutes is
indistinguishable from a program that hung. So the world gets a heartbeat: every N
ticks, one line saying what it looks like now.
// cmd/worldd/main.go — the top of main, replacing last chapter's flag block
d, err := fromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "worldd:", err)
os.Exit(1)
}
seed := flag.Uint64("seed", d.seed, "the number that names this world")
ticks := flag.Uint64("ticks", d.ticks, "how many ticks to take")
logPath := flag.String("log", d.log, "where to write the event log")
report := flag.Uint64("report", d.report, "print a status line every N ticks; 0 for silence")
fast := flag.Bool("fast", false, "deliver ticks as fast as they compute")
flag.Parse()
cfg := sim.Config{Seed: *seed, Ticks: *ticks, Log: *logPath, Paced: !*fast}
if *report > 0 {
cfg.Watch = func(w *sim.World) {
if w.Tick()%*report == 0 {
fmt.Printf("tick %d: water %d cells, %d entities\n",
w.Tick(), w.Ground.Count(sim.Water), w.Population())
}
}
}
$ WORLD_SEED=5 WORLD_TICKS=200 WORLD_REPORT=50 WORLD_LOG=/tmp/h.jsonl go run ./cmd/worldd -fast
worldd 0.0.1 seed 5 ticks 200 -> /tmp/h.jsonl
tick 50: water 17 cells, 3 entities
tick 100: water 18 cells, 3 entities
tick 150: water 19 cells, 3 entities
tick 200: water 21 cells, 3 entities
worldd: the world stopped cleanly
$ WORLD_SEED=5 go run ./cmd/worldd -fast -seed 9 -ticks 20 -log /tmp/i.jsonl
worldd 0.0.1 seed 9 ticks 20 -> /tmp/i.jsonl
worldd: the world stopped cleanly
$ WORLD_TICKS=soon go run ./cmd/worldd
worldd: WORLD_TICKS="soon": not a whole number
exit status 1
Three runs show three behaviours. The first is configured entirely from the
environment and it reads like any other invocation, because a flag default is still
a flag default no matter where the number came from. The second sets
WORLD_SEED=5 and then asks for seed 9 on the command line, and 9 wins:
flag.Parse overwrites the default with whatever the argument list says,
so precedence falls out of the ordering rather than out of any code you had to
write. The third refuses before the log is opened, before the grid is generated,
before a single tick runs. A server that is going to be wrong should be wrong
immediately.
cfg.Watch is the first function value in this book:
Config.Watch is a field of type func(*sim.World), which
sim.Run calls after every successful tick when it is not nil. The
closure assigned here captures report, a variable belonging to
main, and keeps reading it long after main has moved on to
waiting for a signal. So the simulation reports on itself while the
sim package still contains no printing at all, exactly as chapter 2
promised. It does not print. It calls back to whoever asked to be told.
The container image
A container image is a filesystem plus a note saying which program to start inside it.
You describe one in a Containerfile: a list of instructions, each taking
the filesystem built so far and producing a new one. FROM picks the
filesystem to begin with, COPY brings files in from your project,
RUN executes a command inside the half-built image and keeps whatever it
changed, ENV writes a default environment variable into the image, and
ENTRYPOINT records the program to start when somebody runs a container
from the finished result.
The obvious version begins FROM the official Go image, copies the source
in, and builds. It works first time, and it ships a 900 MB image holding a complete Go
compiler, the standard library source, a package cache and a Debian userland, in order
to run a binary of about three megabytes. Everything in there is a thing that can have
a security flaw, and none of it is needed once the build finishes. So the
Containerfile describes two images and keeps only the second.
# Containerfile — build worldd, then ship only worldd.
# Stage 1: the whole Go toolchain, used once and then discarded.
FROM docker.io/library/golang:1.26 AS builder
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 go build -o /out/worldd ./cmd/worldd
# Stage 2: the image that actually ships.
FROM docker.io/library/alpine:3.22
COPY --from=builder /out/worldd /usr/local/bin/worldd
ENV WORLD_LOG=/data/world.jsonl
ENTRYPOINT ["worldd"]
The second FROM is what makes this a multi-stage build: it discards
everything above it and starts again from a bare Alpine, the same eight-megabyte
image you ran back in chapter 1. The only thing that survives the discard is what
COPY --from=builder explicitly carries across, which here is one file.
CGO_ENABLED=0 tells the toolchain to build with no C dependency at all,
producing a binary that carries everything it needs and asks the host for no shared
libraries; that is what lets a binary compiled against Debian's userland run
unmodified on Alpine's very different one. ENV WORLD_LOG puts a default
into the image itself, so the image knows where a world's history belongs and the
person starting a container does not have to. And ENTRYPOINT in bracket
form starts worldd as the container's first process with no shell in
between, which matters a great deal when a signal arrives.
These FROM lines retain the original teaching tags. Both can move:
golang:1.26 can gain a compiler patch and alpine:3.22 can
gain updated packages. For an exact reference build, resolve both images with
podman image inspect, record their RepoDigests, replace
the tags with those digest references and select the reference platform. The
original volume did not record those digests, so the build log below documents its
capture rather than a permanent identity for either tag.
podman build -t worldd:0.0.1 .
$ podman build -t worldd:0.0.1 . (blob-copying lines cut; the ids below are content hashes and yours will read differently)
[1/2] STEP 1/6: FROM docker.io/library/golang:1.26 AS builder
[1/2] STEP 2/6: WORKDIR /src
--> c2ad00eb14b0
[1/2] STEP 3/6: COPY go.mod ./
--> fb066eb79825
[1/2] STEP 4/6: COPY cmd ./cmd
--> daade6316d7c
[1/2] STEP 5/6: COPY internal ./internal
--> fa12bc59a867
[1/2] STEP 6/6: RUN CGO_ENABLED=0 go build -o /out/worldd ./cmd/worldd
--> c15e3e443d21
[2/2] STEP 1/4: FROM docker.io/library/alpine:3.22
[2/2] STEP 2/4: COPY --from=builder /out/worldd /usr/local/bin/worldd
--> fdec6d3d6206
[2/2] STEP 3/4: ENV WORLD_LOG=/data/world.jsonl
--> 84826267cd3a
[2/2] STEP 4/4: ENTRYPOINT ["worldd"]
[2/2] COMMIT worldd:0.0.1
--> adab9d6f6eae
Successfully tagged localhost/worldd:0.0.1
$ podman images --format "{{.Repository}}:{{.Tag}} {{.Size}}" localhost/worldd
localhost/worldd:0.0.1 11.7 MB
The step numbering shows the whole design: [1/2] for the builder's six
instructions, [2/2] for the four that make the image you keep. Every
line ending in a hexadecimal id is a layer, a saved snapshot of the filesystem after
that instruction, and those ids are content hashes, so yours will read differently
while meaning the same thing. The shipped image weighs 11.7 MB against the builder's
900: Alpine's 8.6 plus three megabytes of worldd, and no compiler at all. Layers are
also a cache, which is why the two COPY lines are separate instead of
one COPY . .. Change a file under internal/ and podman
re-runs from COPY internal downward; the steps above it, holding source
you almost never edit, stay cached.
The podman volume
Start a container from that image and it gets a writable layer of its own: a scratch filesystem stacked on the image's read-only one, holding anything the process writes. It is genuinely writable, the log appends into it exactly as it appends to your disk, and it is deleted the moment the container is removed. For a world whose entire memory is one file, that arrangement is a trap.
A volume is podman's answer: storage that belongs to the machine instead of to any
container, mounted into a container at a path you pick. Create one, mount it at
/data, and the log the world writes lands somewhere no
podman rm can reach.
podman volume create valley
podman run -d --name worldd \
-e WORLD_SEED=5 -e WORLD_TICKS=200 -e WORLD_REPORT=50 \
-v valley:/data \
worldd:0.0.1
$ podman volume create valley
valley
$ podman run -d --name worldd -e WORLD_SEED=5 -e WORLD_TICKS=200 -e WORLD_REPORT=50 -v valley:/data worldd:0.0.1
c4b835fe418c85c85670a7ca51f7858ac9d538e1730cdd7748c65dc75846940c
$ podman ps --format "{{.Names}} {{.Image}} {{.Status}}"
worldd localhost/worldd:0.0.1 Up Less than a second
-d is the chapter's whole argument in two characters: run detached.
podman prints the container's id, hands your terminal straight back, and the world
goes on ticking with no terminal attached to it. --name gives it
something you can type instead of that id, each -e sets one environment
variable inside the container, which is precisely where fromEnv looks,
and -v valley:/data mounts the volume at the directory the image's own
ENV already named.
Nothing is printing to your screen any more. The window into a detached container is
podman logs, which replays everything the container's first process has
written to standard output since it started. Run it twice, seconds apart, and watch the
world accumulate.
podman logs worldd
podman ps -a --format "{{.Names}} {{.Status}}"
$ podman logs worldd (six seconds in)
worldd 0.0.1 seed 5 ticks 200 -> /data/world.jsonl
tick 50: water 17 cells, 3 entities
$ podman logs worldd (twenty-three seconds in)
worldd 0.0.1 seed 5 ticks 200 -> /data/world.jsonl
tick 50: water 17 cells, 3 entities
tick 100: water 18 cells, 3 entities
tick 150: water 19 cells, 3 entities
tick 200: water 21 cells, 3 entities
worldd: the world stopped cleanly
$ podman ps -a --format "{{.Names}} {{.Status}}"
worldd Exited (0) 3 seconds ago
Two hundred paced ticks is twenty seconds of world, and the second reading catches
it after the loop finished. The heartbeat lines are already earning their place: the
pond went from 17 cells to 21 over those twenty seconds, and you know it without
having watched a single tick. The last reading gains two squares instead of one
because the spring seeps every forty ticks and tick 200 is itself a seeping tick, so
that line reports the world one instant after its fifth seep. The exit status is podman
reporting what the process returned, and 0 is the difference between a world that finished and
a world that fell over. podman logs -f worldd follows the stream live,
the way tail -f follows a file.
The container has exited, so remove it and see what is left behind. Your host cannot read the volume's files directly without help, but that is a small obstacle: mount the same volume into a throwaway Alpine container and use ordinary tools on it.
podman rm worldd
podman run --rm -v valley:/data docker.io/library/alpine:3.22 \
sh -c 'wc -l /data/world.jsonl; tail -1 /data/world.jsonl'
$ podman rm worldd
worldd
$ podman run --rm -v valley:/data docker.io/library/alpine:3.22 sh -c 'wc -l /data/world.jsonl; tail -1 /data/world.jsonl'
322 /data/world.jsonl
{"t":200,"ev":"stop","what":"ticks complete"}
The process that wrote those lines is gone, and so is the container it ran inside.
The 322 lines are not. The container was the world's body for twenty seconds; the
volume is where the world's memory lives regardless of which body wrote it. Any
container mounting valley can read that file, including one running a
program with no connection to worldd, which is exactly what the Alpine container
just was.
Forget the -v flag with the image as written and worldd refuses to
start: open /data/world.jsonl: no such file or directory, because
/data only exists when something is mounted there. The obvious tidy-up
is to stop the image from being fussy. Add one line to the Containerfile so the
directory is always present:
RUN mkdir /data
ENV WORLD_LOG=/data/world.jsonl
Now it starts anywhere, mounted or not, and the error is gone. Run it twice without a volume, copying the log out of each container afterward, and look at what the fix bought:
$ podman run --name ghost -e WORLD_TICKS=200 worldd:mkdir -fast; podman cp ghost:/data/world.jsonl /tmp/g1.jsonl; wc -l < /tmp/g1.jsonl
worldd 0.0.1 seed 5 ticks 200 -> /data/world.jsonl
worldd: the world stopped cleanly
322
$ podman rm ghost; podman run --name ghost -e WORLD_TICKS=200 worldd:mkdir -fast; podman cp ghost:/data/world.jsonl /tmp/g2.jsonl; wc -l < /tmp/g2.jsonl
ghost
worldd 0.0.1 seed 5 ticks 200 -> /data/world.jsonl
worldd: the world stopped cleanly
322
322, then 322. Against the volume those same two runs leave a file of 644 lines,
because the second finds the first one's history waiting and appends to it. Reason
from the symptom: the log is opened with O_APPEND and never truncates,
so a count that fails to grow can only mean the second run opened a different file
from the one the first wrote. Same path, different filesystem. Nothing in the Go code
is wrong; twenty seconds of history went faithfully into storage that was scheduled
for deletion, and podman rm carried out the sentence. The lesson is
about which failure you would rather have. The unfixed image refuses to start when
storage is missing; the mkdir makes it start happily and lose everything
quietly. When state has nowhere durable to go, refusing to run is the useful
behaviour.
One thing has to work before you can leave a world running for real: stopping it
without hurting it. podman stop sends the container's first process a
SIGTERM, the polite request to shut down, and waits before resorting to force. Last
chapter's signal handler is already listening for exactly that. Give the container a
tick count large enough that it will never reach it, and stop it by hand.
podman run -d --name forever -e WORLD_TICKS=100000 -e WORLD_REPORT=50 \
-e WORLD_LOG=/data/forever.jsonl -v valley:/data worldd:0.0.1
podman stop forever
podman logs forever
$ podman stop forever; podman logs forever (stopped by hand after nine seconds; your run will get further or less far)
forever
worldd 0.0.1 seed 5 ticks 100000 -> /data/forever.jsonl
tick 50: water 17 cells, 3 entities
worldd: the world stopped cleanly
$ podman run --rm -v valley:/data docker.io/library/alpine:3.22 tail -1 /data/forever.jsonl
{"t":90,"ev":"stop","what":"stopped"}
The world stopped between two ticks, wrote its final line, and closed its log,
because a signal from outside the container reached the goroutine holding the stop
channel. Tick 90 is where the clock happened to be when the request landed, so your
number will differ; that is the one figure on this page allowed to vary, and it
varies honestly, since how long you waited before typing stop is no part
of the simulation. The last line reads "stopped" instead of
"ticks complete", so a reader months from now can tell an operator's
decision from a run that finished on its own. The ENTRYPOINT bracket
form is what makes any of it work: written as a plain string, the container's first
process would be a shell, the shell would swallow the SIGTERM, and podman would wait
ten seconds before killing the world mid-tick with a line half written.
Four separate lifetimes
The split this chapter enforces is not a container convention that containers invented. It is a division by how often each part changes, and containers only make the division impossible to ignore.
podman rm cannot touch.
Code changes when you write code, which often. Identity changes when you decide to run a different world, rarely and deliberately. History changes several times a second and must never be lost. Keep all three in one place and the fastest-moving part sets the terms for everything else: edit a file and you put the history at risk. Split them and each can be handled on its own schedule. Images get rebuilt and thrown away without a thought, since they hold nothing that cannot be rebuilt from source, and containers get destroyed and recreated just as freely. The volume you back up, because it is the one part of the running system nobody could reconstruct.
That last claim wants a test, not a promise, and you built the test in chapter 10: one seed produces one history, byte for byte. What is new is where the two runs happen. Run the world twice more from the same image, into two files of its own, and then let a third container be the judge.
podman run --rm -e WORLD_SEED=5 -e WORLD_TICKS=200 -v valley:/data \
worldd:0.0.1 -fast -log /data/again.jsonl
podman run --rm -e WORLD_SEED=5 -e WORLD_TICKS=200 -v valley:/data \
worldd:0.0.1 -fast -log /data/again2.jsonl
podman run --rm -v valley:/data docker.io/library/alpine:3.22 \
sh -c 'diff /data/again.jsonl /data/again2.jsonl; echo "diff exit: $?"'
$ podman run --rm -e WORLD_SEED=5 -e WORLD_TICKS=200 -v valley:/data worldd:0.0.1 -fast -log /data/again.jsonl
worldd 0.0.1 seed 5 ticks 200 -> /data/again.jsonl
worldd: the world stopped cleanly
$ podman run --rm -v valley:/data docker.io/library/alpine:3.22 sh -c 'diff /data/again.jsonl /data/again2.jsonl; echo "diff exit: $?"; wc -l /data/again.jsonl /data/again2.jsonl'
diff exit: 0
322 /data/again.jsonl
322 /data/again2.jsonl
644 total
$ podman run --rm -v valley:/data docker.io/library/alpine:3.22 sh -c 'diff /data/again.jsonl /data/nine.jsonl | head -5'
--- /data/again.jsonl
+++ /data/nine.jsonl
@@ -1,322 +1,340 @@
-{"t":0,"ev":"start","what":"seed 5"}
+{"t":0,"ev":"start","what":"seed 9"}
A silent diff and an exit status of 0 mean the two files agree to the
byte: two containers, started minutes apart, each generating its own terrain,
spawning its own entities and taking its own two hundred ticks, wrote the same 322
lines in the same order. The flags arrive through the image command. Everything after the image
name in a podman run command is appended to the image's
ENTRYPOINT, so -fast -log /data/again.jsonl reaches
flag.Parse as if you had typed it at a shell. A third run with
WORLD_SEED=9 settles the other half: same image, same command, one
number different, and the histories part company on line 1 and stay parted for 340
lines. The seed is the whole of a world's identity, and it now travels in an
environment variable instead of a source file.
Checkpoint
What is in the box is small. Rock and water, three entities, a pond that swallows a square of soil now and then. What is true about it is not small. It advances on its own clock. It writes down everything that happens to it. It can be stopped and restarted without losing a line. Its entire past reproduces from one number. The box is small, but those four properties are already true of it.
- Make the environment supply flag defaults, explain why a flag typed on the command
line still wins, and hand
simafunc(*sim.World)so the simulation reports on itself without printing anything. - Write a two-stage
Containerfile, say whatCOPY --fromcarries across the discard, and explain whatCGO_ENABLED=0buys when the builder is Debian and the runtime is Alpine. - Read
podman buildoutput as a list of layers, predict which steps a given edit invalidates, and orderCOPYlines to keep the cache working. - Start a detached container with a volume and an environment, then interrogate a
world you are not attached to with
podman logs,podman ps -aand an exit status. - Explain the 322-then-322 result that exposes a missing volume, and argue why an image that refuses to start beats one that starts and loses the run.
- Say what
ENTRYPOINT ["worldd"]makes possible whenpodman stoparrives, and what a shell in that position would have broken.
Exercise 1: two valleys at once. Create a second volume and start a second container from the same image with a different seed and a different name, both running at the same time. Read both logs. What does one image serving two live worlds tell you about what an image is?
podman volume create valley2, then podman run -d --name
worldd2 -e WORLD_SEED=9 -e WORLD_TICKS=100000 -e WORLD_REPORT=50 -v valley2:/data
worldd:0.0.1, and podman ps shows both up with different
heartbeats. Two worlds, two histories, one image and one copy of the binary on
disk. An image is read-only and shared, so twenty worlds cost twenty writable
layers and twenty processes, never twenty copies of worldd. That is the point of sharing an image: one binary can serve more than one live world.
Exercise 2: take Alpine out. Copy the Containerfile, change
the second FROM to FROM scratch, build it as
worldd:scratch, and run it against a volume. Measure the image. Then try
podman exec into it and explain what comes back.
It builds, runs and ticks identically: 3.08 MB against 11.7, because
scratch is the absence of an image and a statically linked Go binary
needs nothing else. The cost arrives the first time you want to look inside.
podman exec worldd ls /data fails with executable file 'ls'
not found in $PATH: no shell, nothing but your binary. That trade, the
smallest possible attack surface against no way to poke around, is a real choice,
and this book keeps Alpine because a world under construction is one you will
want to open up.
Exercise 3: where the cache breaks. Rebuild with no changes
and count the Using cache lines. Then add a comment to a file under
internal/sim/ and rebuild again. Which steps re-run, and which one
surprises you?
An unchanged rebuild caches every step, and so does a rebuild after merely
touching a file, because COPY hashes contents and ignores
timestamps. Adding a comment invalidates COPY internal ./internal
and forces the RUN go build under it, exactly as you would predict.
The surprise is the next line: COPY --from=builder still reports
Using cache, because a comment changes no compiled instruction, the
binary comes out byte-identical, and podman hashes the file being copied rather
than the step that produced it. An identical binary makes an identical layer, so
the image you ship is the image you already had.