Skip to content

Docker

Pick a stack, put two lines in a .env file beside it, and start it.

Every combination here is written by one generator, and CI fails when a file no longer matches it.

Terminal window
docker compose up -d

The stack’s state volume keeps the state and the cache from one start to the next, and it is the collector’s to write from the first: the image carries the directory it is mounted on, owned by the uid the collector runs as, and Docker gives a new volume that owner. See what has to be writable.

The first sweep runs every family, since nothing has run yet, except that the collector starts one family of six hours or more a sweep: traffic, stats, forks and the rest of the eleven reach the store over the first two and a half hours, and only the first time. See the slow families take turns.

With Grafana it is on http://localhost:3000, user admin, password ghchronicle unless you set GRAFANA_PASSWORD. The dashboard is already there: the collector publishes it when it starts and points it at the store beside it, so there is nothing to import and no datasource to fill in.

The rest of this page is the image itself, for a reader running it another way.

Terminal window
docker run -d --name ghchronicle \
-v /etc/ghchronicle/config.yaml:/config.yaml:ro \
-v ghchronicle-state:/var/lib/ghchronicle \
-e GITHUB_TOKEN -e INFLUX_TOKEN \
-p 9605:9605 \
ghcr.io/jmrplens/ghchronicle -config /config.yaml

With state_file: /var/lib/ghchronicle/state.json in the configuration, which is where the example configuration puts it. The volume is what keeps the state and the cache across a new container, and Docker creates it on first use, owned by uid 65532 like the directory it is mounted on; what has to be writable says why each is needed.

Built FROM gcr.io/distroless/static-debian13:nonroot over a static, CGO_ENABLED=0 binary. There is no shell and no package manager in it, so code execution inside the container has nothing to pivot with, and the nonroot tag bakes in uid 65532, which keeps it off root even when the orchestrator sets no securityContext of its own.

Two consequences worth knowing before you debug it:

  • docker exec ... sh does not work. There is no sh. Read the logs instead.
  • Anything the container writes must be owned by, or writable by, uid 65532.

Every released image from 2.5.1 on, which is every image a release page names, is signed with cosign, keylessly and by the same identity as the checksum file the other installs check: this repository’s release workflow, run for a release tag, recorded in a public transparency log. With cosign 3:

Terminal window
cosign verify \
--certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/jmrplens/ghchronicle:v2.6.5 > /dev/null
Verification for ghcr.io/jmrplens/ghchronicle:v2.6.5 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates

The Docker Hub copy, docker.io/jmrplens/ghchronicle, verifies with the same command. What cosign checks is a digest, not the tag: without > /dev/null it also prints, as JSON, the digest it verified (docker-manifest-digest), and an image pulled as ghcr.io/jmrplens/ghchronicle@sha256:... is the one verified however a tag moves afterwards. latest is moved onto a release’s digest last, once the release workflow has verified that digest’s signature and run the image on both architectures, so it never names an image that was not checked; a release that fails before then leaves it on the previous one.

Use cosign 3. The signatures are in the bundle format it writes, and an older cosign answers no signatures found for these images: measured, cosign 2.6.1 finds the signature only when given --new-bundle-format, 2.5.0 does not find it even then, and 2.4.2 does not know the flag. Images up to 2.5.0 carry no signature at all, so for them that answer is the true one.

The config file is mounted read-only. Six things are not:

PathNeeded for
state_fileAlways. Without a persistent path the stargazer walk repeats on every restart
<name>-cache.binAlways. The cache, which sits beside the state file and has no setting of its own
sinks.dedupe_fileThe long-running service, with a sink that keeps it. A -once run opens none
<name>-lockThe long-running service, which holds it while it runs, and -migrate -yes
sinks.file.pathOnly with the file sink
log.fileOnly with a log file configured

The first three live in the same directory by default, and so does the lock, so one mounted directory covers them, and it has to be a directory. Each of the three is written beside itself and renamed into place, so a reader never sees half a file, and a file mounted on its own cannot be renamed over: measured with the 2.6.1 image built from its Dockerfile, a state file bind-mounted alone was never saved, and every sweep said so.

level=WARN msg="state not saved" err="rename /var/lib/ghchronicle/state.json.tmp /var/lib/ghchronicle/state.json: device or resource busy"

A directory the collector cannot write gives the same warning, with the error that fits, and one for the cache beside it:

level=WARN msg="state not saved" err="open /var/lib/ghchronicle/state.json.tmp: permission denied"
level=WARN msg="cache file not saved" file=/var/lib/ghchronicle/state-cache.bin err="open /var/lib/ghchronicle/state-cache.bin.tmp: permission denied"

From 2.6.1 the image carries /var/lib/ghchronicle owned by uid 65532, and Docker fills a new named volume from the directory it is mounted over, owner included, so the directory the example configuration names needs nothing done to it: measured with a fresh volume, and with an empty one the 2.6.0 image had left to root, the collector wrote its state and its cache into both with no warning. With nothing mounted there it writes too, and the state goes with the container. Up to 2.6.0 the image had no such directory: nothing mounted there left one uid 65532 could not create, and a new named volume there was root’s, which is where the two lines above came from.

Two ways are left to end up with a directory the collector cannot write. A named volume mounted where the image has no directory of its own is created owned by root: hand it to uid 65532 once, with any image that has a chown, as in docker run --rm -v <volume>:/v alpine chown 65532:65532 /v. A host directory is mounted as it is, whoever owns it: sudo chown 65532:65532 it on the host. A configuration that names no state_file at all writes to the container’s working directory, which is writable and goes with the container.

Without the state, every new container starts from nothing: the stargazer walk, the whole star history and the co-authored walk again, and every family at once. Without the cache, a whole sweep of asking GitHub again what it already knew: the cache beside it. Without the ledger, a whole sweep of rewriting, which is the one thing it exists to prevent: only what changed is written.

EXPOSE 9605 is the Prometheus exporter, and it is the only listener the process ever opens. Publish it only if you enabled the prometheus sink; every other sink is outbound.

compose.yaml
services:
ghchronicle:
image: ghcr.io/jmrplens/ghchronicle
command: ["-config", "/config.yaml"]
restart: unless-stopped
environment:
GITHUB_TOKEN: ${GITHUB_TOKEN}
INFLUX_TOKEN: ${INFLUX_TOKEN}
volumes:
- ./config.yaml:/config.yaml:ro
- state:/var/lib/ghchronicle
volumes:
state:

Pull the new image and recreate the container. Under the default migrate: auto, its start applies on its own, before its first sweep, every change the new release carries that loses nothing, and warns about the rest at every start with the two commands that apply them: see Migrations. The container takes the same flags as the binary, so those run in a one-off container of the same service, with its volume, its environment and its user, while the service is stopped:

Terminal window
docker compose pull ghchronicle
docker compose up -d ghchronicle
docker compose logs ghchronicle | grep 'migration pending'
docker compose stop ghchronicle
docker compose run --rm ghchronicle -config /config.yaml -migrate
docker compose run --rm ghchronicle -config /config.yaml -migrate -yes
docker compose start ghchronicle

The first run prints the plan and changes nothing; the second applies it and reads back what it cleared. Both mount the volume the service mounts, so they find the lock it holds beside the state file: with the service still running, -migrate -yes refuses, naming the process by the number the service’s own container gives it, and changes nothing. Measured with two containers on one named volume: a flock one of them held was refused to the other, and granted once the first had gone. A one-shot container a scheduler runs, below, holds no lock while it sweeps, so pause the scheduler for the length of the two runs.

The container takes the same flags as the binary, so a scheduler can run it without a long-lived service.

Terminal window
docker run --rm \
-v /etc/ghchronicle/config.yaml:/config.yaml:ro \
-v ghchronicle-state:/var/lib/ghchronicle \
-e GITHUB_TOKEN \
ghcr.io/jmrplens/ghchronicle -config /config.yaml -once

Mount the state volume in this mode too. It is what makes the second run cheap, and what keeps each family to its own cadence: without it every run collects every family.

Written and maintained by
MIT licenceRelease history