A comprehensive Nix-driven setup that boots xtcp2 inside QEMU microvms alongside the rest of its production data path (redpanda → clickhouse → grafana) for end-to-end testing, soak runs, and ad-hoc inspection — all from a single nix run invocation.
- Introduction
- Test surfaces at a glance
- Quick start
- Architecture
- Microvm flavors
- Components
- xtcp2 daemon
- nsTest — namespace churn driver
- tcp_server / tcp_client — socket population
- oci-xtcp2-tcp-stress — containerized stress image
- discovery-bench — namespace-discovery A/B
- xtcp2ctl — runtime control client
- clickhouse-pipeline-rate — runtime-control rate test
- Redpanda
- ClickHouse
- Prometheus
- Grafana
- Lifecycle phases
- Host port forwards
- Tunables
- Typical workflows
- Troubleshooting
This is the heavy integration-testing side of xtcp2. Unit tests live in pkg/*/_test.go and are run by go test ./...; the work documented here exercises code paths that only fire under a real Linux kernel, real namespaces, real network sockets, a real Kafka broker, and so on.
Everything is packaged through the flake's microvm targets, so a single nix run .#microvm-x86_64-<flavor> invocation boots a fresh QEMU/KVM guest with the requested test scenario fully wired and ready to inspect.
The environment is structured as a small set of flavors built from one shared mkVm.nix. Each flavor is gated by a sink = "..." predicate and assembles a different mix of services on top of the base xtcp2 daemon.
xtcp2's automated testing spans four layers; this document covers layers 2-3.
-
Unit / race / fuzz — pure Go, no VM (
go test ./...). See testing-and-quality.md. -
microVM integration flavors (the bulk of this doc) — boot xtcp2 under a real kernel, real namespaces, real sockets, and (for the pipeline flavors) real Redpanda / ClickHouse / MinIO. Grouped by intent:
Group Flavors What it exercises Lifecycle / self-test -lifecycle(+-coverage,-coverage-iouring),-lifecycle-s3parquetFast (~1 min) assertion-scraped boot; -lifecycleruns innix flake checkSoak & long-running -soak,-s3parquet-runner(s3parquet-long)Hours-long stability (churn / sustained upload). Methodology + measured results: stability-testing.md Stress (under socket load) -tcp-stress,-clickhouse-pipeline-stress,-s3parquet-stress,-s3parquet-lowfreq20 containers × 250 sockets; the -stresssoaks run 24hS3 / MinIO (parquet) -s3parquet-pipeline,-s3parquet-runner,-s3parquet-stress,-s3parquet-lowfreq,-clickhouse-pipeline-parquetxtcp2 → Parquet → in-VM MinIO, read back with duckdb / ClickHouse s3()ClickHouse pipeline -clickhouse-pipeline(+-parquet,-stress,-rate)xtcp2 → Redpanda (Kafka) → ClickHouse ProtobufList ingest Runtime control -clickhouse-pipeline-rate+ self-test Checks 17-19Drives xtcp2ctland asserts the daemon's live behaviour changesSpecialized -discovery-bench,-capcheck-failNamespace-discovery A/B grid; startup capability-check refusal -
Non-VM flake checks (
nix flake check) — lint / audit / cli-help / capability-check + per-build-taggo test. See Non-VM checks. -
docker-compose local stack — a non-hermetic, hands-on version of the ClickHouse/Redpanda pipeline for local development (
make deploy). Documented in operations.md; the microVMclickhouse-pipeline*flavors are the hermetic, CI-friendly equivalent.
# Lifecycle self-test smoke (minimal flavor, ~1 min wall-clock)
nix run .#microvm-x86_64-lifecycle
# Same self-test but with coverage instrumentation; merged into quality-report
nix run .#microvm-x86_64-lifecycle-coverage
nix run .#microvm-x86_64-lifecycle-coverage-iouring
# Long-running stability soak (nsTest churn + tcp population) — 1h default
nix run .#microvm-x86_64-soak
nix run .#microvm-x86_64-soak -- --duration 12h
# Per-container netns stress: 20 docker containers × 250 sockets each
nix run .#microvm-x86_64-tcp-stress -- --duration 180s
# Namespace-discovery A/B benchmark: dir-scan vs /proc-scan, on a real kernel
nix run .#microvm-x86_64-discovery-bench -- --timeout 900
# Full production pipeline: xtcp2 → redpanda → clickhouse + grafana
nix run .#microvm-x86_64-clickhouse-pipeline
# Then in browser:
open http://127.0.0.1:13000 # Grafana
open http://127.0.0.1:18123 # ClickHouse HTTP (curl -u default:xtcp)
# Runtime-control rate test: drive xtcp2ctl and assert the ClickHouse ingest
# RATE matches a socket-count prediction (baseline→fast→revert + poll-burst).
nix run .#microvm-x86_64-clickhouse-pipeline-rate-runner
# Parquet→S3 soak (hourly file-count sentinels)
nix run .#microvm-x86_64-s3parquet-runner -- --duration 12h
# Combined stress soaks (pipeline/sink under the tcp-stress load) — 1h default,
# pass --duration 24h for the production soak. See the disk-wipe caveat below.
nix run .#microvm-x86_64-clickhouse-pipeline-stress -- --duration 24h # Kafka→ClickHouse
nix run .#microvm-x86_64-s3parquet-stress -- --duration 24h # Parquet→S3 (MinIO)
# Low-activity Parquet→S3: 1h poll + 2 sockets/container — confirms files still
# land via the staleness timer (not the byte cap). Defaults to a 2h run.
nix run .#microvm-x86_64-s3parquet-lowfreqThe clickhouse-pipeline flavor is the most complete; the others are subsets. ASCII view of the full data flow:
host
├── nix run .#microvm-x86_64-clickhouse-pipeline
│ │
│ ▼
└── QEMU microvm (x86_64-linux)
│
├── systemd
│ ├── xtcp2.service ────────────── inet_diag netlink readout
│ │ │ ─→ kafka producer (franz-go)
│ │ │ ─→ kafkaDest "kafka:localhost:19092"
│ │ │
│ │ └── /metrics :9088 ─────────────────┐
│ │ │
│ ├── xtcp2-clickpipe-up.service │
│ │ ├── docker network create xtcp │
│ │ ├── docker volume create redpanda-0 │
│ │ ├── docker volume create clickhouse_db │
│ │ ├── docker pull redpanda + clickhouse │
│ │ ├── docker run -d redpanda-0 … │
│ │ ├── rpk topic create xtcp … │
│ │ └── docker run -d clickhouse … with │
│ │ initdb mounted from /nix/store │
│ │ │
│ ├── xtcp2-clickpipe-monitor.service │
│ │ └── XTCP2_CLICKPIPE_ROWS heartbeat │
│ │ │
│ ├── prometheus.service ────────────────┐ │
│ │ └── scrapes xtcp2:9088 every 15s ──┘────┘
│ │ :9090
│ │
│ └── grafana.service
│ └── :3000 pre-provisioned datasources:
│ ├── xtcp2-clickhouse (native :19001 default db xtcp)
│ └── xtcp2-prometheus (http :9090)
│
└── docker
├── redpanda-0 container (Kafka broker)
│ └── topic "xtcp" ← xtcp2 producer
│ └── consumer group "xtcp"
│ └── ClickHouse kafka_engine
│
└── clickhouse container
├── xtcp_flat_records_kafka (Kafka engine)
│ │
│ ▼ MATERIALIZED VIEW
│
├── xtcp_flat_records_mv
│ │
│ ▼
│
└── xtcp_flat_records (MergeTree, queryable)
Two exposure shapes:
- raw boot (
nix run .#<attr>) — boots the VM via/bin/microvm-runand leaves it up for interactive inspection (serial console + forwarded ports); no host-side assertions. - host runner (
nix run .#<attr> [-- --duration/--timeout …]) — a duration- or timeout-bounded host process that boots the VM, taps its serial + virtio consoles, asserts on scraped sentinels, then powers off. These hold a/dev/kvmslot for the whole run and are not innix flake check(except the lifecycle one).
Attribute (nix run .#…) |
Sink | Type | Mem | Purpose |
|---|---|---|---|---|
microvm-x86_64 |
minimal |
raw boot | 1024 | xtcp2 alone (-dest null, 2s poll) + the self-test oneshot. Cheapest smoke. |
microvm-x86_64-lifecycle |
minimal |
runner | 1024 | Host launcher for the minimal VM; scrapes all self-test sentinels. In nix flake check (needs /dev/kvm). |
microvm-x86_64-lifecycle-coverage |
coverage |
runner | 1024 | xtcp2 built -cover; scrapes the coverage dump into $XTCP2_COVERDIR. |
microvm-x86_64-lifecycle-coverage-iouring |
coverage-iouring |
runner | 1024 | Coverage + -ioUring so the netlinkerIoUring path runs. |
microvm-x86_64-lifecycle-s3parquet |
s3parquet |
runner | 6144 | s3parquet lifecycle; adds the S3PARQUET_FILES/S3PARQUET_ROWS checks. |
microvm-x86_64-s3parquet-pipeline |
s3parquet |
raw boot | 6144 | The s3parquet lifecycle VM (in-VM MinIO, xtcp2 writes Parquet), booted directly. |
microvm-x86_64-soak |
soak |
runner | 3072 | xtcp2 (-dest null) + nsTest churn + tcp_server/client + /metrics scraper. Long stability (1h default, --duration 24h); asserts no panic/restart, ≥10 churn events, bounded RSS/threads. |
microvm-x86_64-tcp-stress |
tcp-stress |
runner | 3072 | dockerd + 20 containers × 250 sockets, each its own netns. Asserts Method B discovered ≥ container-count namespaces, 0 panics. |
microvm-x86_64-discovery-bench |
discovery-bench |
runner | 4096 | Root VM runs the discovery-bench grid (dir-scan vs /proc-scan) vs a real kernel; PASS iff no cell errors. No xtcp2/docker. |
microvm-x86_64-clickhouse-pipeline |
clickhouse-pipeline |
raw boot | 6144 | Full xtcp2 → Redpanda → ClickHouse + Prometheus + Grafana. Self-test Checks 11-12 run; inspect via Grafana/curl. |
microvm-x86_64-clickhouse-pipeline-parquet |
clickhouse-pipeline-parquet |
raw boot | 16384 | clickpipe stack + in-VM MinIO + a 2nd xtcp2 writing Parquet; ClickHouse s3() reads it back (Check 15). |
microvm-x86_64-clickhouse-pipeline-rate-runner |
clickhouse-pipeline-rate |
runner | 6144 | Runtime-control rate test — drives xtcp2ctl and asserts the ClickHouse ingest rate matches a socket-count prediction. See below. |
microvm-x86_64-clickhouse-pipeline-stress |
clickhouse-pipeline-stress |
runner | 8192 | Full pipeline under TCP-stress load (20 × 250) with a 1h records-table TTL — ProtobufList ingest at rate, disk/RSS guards; 24h soak. |
microvm-x86_64-s3parquet-runner |
s3parquet-long |
runner | 6144 | Parquet→S3 soak (63 MiB flush, 10s poll, Pyroscope); scrapes XTCP2_S3PARQUET_HOURLY, asserts files advanced + no leak. --duration 12h. |
microvm-x86_64-s3parquet-stress |
s3parquet-stress |
runner | 6144 | Parquet→S3 under TCP-stress load + ~1h object retention — the S3 analog of clickhouse-pipeline-stress; 24h soak. |
microvm-x86_64-s3parquet-lowfreq |
s3parquet-lowfreq |
runner | 6144 | Parquet→S3 with 1h poll + 2 sockets/container — the byte cap is never hit, so files land purely via the staleness timer. ~2h. |
microvm-x86_64-capcheck-fail |
capcheck-fail |
raw boot | 6144 | An s3parquet-long VM with CAP_SYS_ADMIN dropped — xtcp2's startup capability check must refuse to start (inspect the serial console). Sub-second non-VM analog: the capability-check-* flake checks. |
Coverage flavors exist only when the coverage build is enabled; the tcp-stress / *-stress / -lowfreq flavors require the OCI stress image (containers.oci-xtcp2-tcp-stress). Each flavor inherits the shared base from nix/microvms/mkVm.nix and adds only what it needs.
The thing under test. Discovers every network namespace that has a live process by scanning /proc/<pid>/ns/net inodes (Method B — see below), spawns a per-namespace netlinker that reads inet_diag via netlink, deserializes the wire format into XtcpFlatRecord protobuf, then ships the records to a configurable destination.
In the microvms, xtcp2 runs as a NixOS systemd service (xtcp2.service, defined in nix/modules/xtcp2-service.nix). Per-flavor argument sets:
| Flavor | -dest |
Notes |
|---|---|---|
| minimal / soak / tcp-stress / coverage | null |
No downstream; the point is the netlink readout |
| clickhouse-pipeline / -stress / -rate | kafka:localhost:19092 |
Real Kafka → ClickHouse destination (rate disables poll jitter via -pollJitterPct 0) |
| s3parquet / -long / -stress / -lowfreq | s3parquet:http://127.0.0.1:9000 |
In-VM MinIO; parquet upload path |
Grants: CAP_NET_ADMIN, CAP_NET_RAW, CAP_SYS_RESOURCE. Limits: TasksMax = 8192 (raised from systemd's default ~1100 after the 1h soak hit the cgroup ceiling). Go-runtime cap: -maxThreads 2000 (via runtime/debug.SetMaxThreads).
cmd/nsTest/nsTest.go. A tiny load generator that does ip netns add nsN / ip netns del nsN on a tight loop. Exercises xtcp2's /proc-scan discovery + pre-poll reconcile + nsAdd / nsDelete lifecycle.
Tunables (CLI flags):
-initial— initial namespace fill (default 1000)-sleep— pause between churn iterations (default 100ms)
Used by the soak flavor with reduced parameters (-initial 50 -sleep 250ms) so a 12h soak doesn't generate gigabytes of churn-log noise.
tools/tcp_server/, tools/tcp_client/. Generate a known population of ESTABLISHED loopback sockets so xtcp2's inet_diag readout has real TCP state to parse.
tcp_server -count N -bind 0.0.0.0— N echo listeners on ports 4000..4000+N-1tcp_client -count N -connect <host> -sleep 5s -pads 2048— N goroutines dialing the matching ports, writing 2 KiB messages every 5 s
Used standalone in the soak flavor (default 100+100 on the VM host) and via the OCI image in the tcp-stress + clickhouse-pipeline flavors (one tcp_server+client pair per docker container).
Built via pkgs.dockerTools.streamLayeredImage. Bundles just the two tools from tools/tcp_{server,client}/ plus a tiny shell entrypoint that dispatches on TCP_MODE:
| Env | Default | Effect |
|---|---|---|
TCP_MODE |
both |
server, client, or both (server in bg, client in fg) |
TCP_COUNT |
100 | Number of listeners / dialers |
TCP_SLEEP |
5s | Pause between client writes |
TCP_PADS |
2048 | Bytes of zero-pad per message |
TCP_CONNECT |
127.0.0.1 | Client target host |
TCP_BIND |
0.0.0.0 | Server listen address |
In the tcp-stress and clickhouse-pipeline flavors, dockerd pre-loads this image at boot, then spawns N containers with TCP_MODE=both. Each container gets its own netns courtesy of docker's bridge network — xtcp2 discovers those by scanning /proc/<pid>/ns/net (Method B), so it sees each container's namespace whether or not docker bind-mounts it under /run/docker/netns/.
tools/discovery-bench/. A re-runnable benchmark comparing the two ways xtcp2 can discover the set of network namespaces to poll:
- Method A — directory scan (the legacy mechanism, since replaced by Method B):
os.ReadDir(/run/netns, /run/docker/netns). Cost is O(named-namespaces); it only sees bind-mounted namespaces, so anonymous container/unshare -nnetns are invisible to it — the audit gap that motivated the switch. - Method B —
/proc/<pid>/ns/netinode scan (what xtcp2 does today): walk/proc, dedup namespaces by inode. Cost is O(processes); it sees every namespace with a live process, including anonymous ones. Two sub-variants:readlink+parse vsstat→Stat_t.Ino.
Modes: measure (time each method against the live system + a coverage diff + per-method skip counts), grid (root-only N namespaces × P processes sweep, one JSON line per cell). The discovery-bench microVM flavor runs grid on boot as root — so Method B's /proc scan sees every namespace with no ptrace-gated skips, and ip netns builds the controlled grid — emitting DISCOBENCH_START / DISCOBENCH_GRID (per cell) / DISCOBENCH_DONE sentinels to the serial console. The grid populates each cell with cheap sleep infinity processes (ip netns exec <ns> sleep infinity for the in-namespace ones), so P can reach thousands without the RSS a fleet of real binaries would cost. Grid sizes are overridable via the DISCO_NS_GRID / DISCO_PID_GRID / DISCO_ITERS service env.
A hermetic Go microbenchmark (the algorithmic O(namespaces) vs O(processes) shape) and a root-gated coverage proof (an anonymous unshare -n netns is found only by the /proc scan) ship in discovery_bench_test.go and run under ordinary go test. Background and motivation live in docs/design-namespace-discovery-and-reconcile.md.
cmd/xtcp2ctl. The gRPC control CLI that changes a running daemon over
ConfigService (see grpc-api.md): set-poll-frequency,
trigger-poll, poll-burst, set-s3, and reconfigure (a full-config soft
restart). The integration tests drive it in two places:
- Self-test Checks 17-19 (
CTL_HOT/CTL_TRIGGER/CTL_RESTART, every lifecycle boot) — assert the daemon's behaviour changes: a liveset-poll-frequencyis reflected by a laterget+ the Pollerticker.Resetcounter;trigger-poll+poll-burstadvancepollRequestCh; andreconfigurere-execs in place (same systemd MainPID) with the newtagshowing up ingetand in streamed records. - The
clickhouse-pipeline-rateflavor (below) — drives it against the full Kafka→ClickHouse pipeline and measures the ingest-rate response quantitatively.
sink = "clickhouse-pipeline-rate", host runner
microvm-x86_64-clickhouse-pipeline-rate-runner. The clickhouse-pipeline stack
plus a steady 100-connection tcp load (a stable socket denominator) and poll
jitter disabled (-pollJitterPct 0) so the per-window poll count is exactly
window/frequency. An in-VM monitor drives xtcp2ctl through a schedule —
BASELINE (10s) → FAST (1s) → REVERT (10s) → poll-burst — and, for each phase,
estimates sockets-per-poll from the daemon's own counters
(N = Δ(Poller/envelopeRows) / Δ(Poller/ticker)) and asserts the ClickHouse row
delta matches the prediction N × (window/frequency). Verdict sentinels
(XTCP2_RATE_*, ±10% default):
| Verdict | Asserts |
|---|---|
CADENCE |
actual polls ≈ window/frequency each phase (the frequency change took effect) |
PREDICT |
ClickHouse rows ≈ N × window/frequency (fast + revert) |
DELIVERY |
ClickHouse Δ ≈ produced (no loss through Kafka→ClickHouse) |
SOCKETS |
N stable across phases (rate change is from frequency, not socket drift) |
BURST |
poll-burst -count 6 -interval 10s produced + delivered 6×N rows |
Env-tunable (RATE_WINDOW_SEC / RATE_SETTLE_SEC / RATE_TOL_PCT) for a shorter
smoke; default windows are 180s (~15 min schedule, --timeout 1800s).
Kafka-compatible event broker. Runs as a single docker container in the clickhouse-pipeline flavor.
- Image:
docker.redpanda.com/redpandadata/redpanda:v25.1.7 - Internal Kafka API:
redpanda-0:9092(insidextcpdocker network) - External Kafka API:
localhost:19092(xtcp2 dials this) - Admin API:
localhost:19644 - Schema registry:
localhost:18081 - Data volume: named
redpanda-0 - Mode:
dev-container(single-node, no auth)
xtcp2-clickpipe-up.service creates the xtcp topic via rpk after the broker comes up.
Columnar OLAP DB consuming records from the Kafka topic.
- Image:
clickhouse/clickhouse-server:26.7-alpine(clickPipeClickhouseImageinnix/microvms/mkVm.nix) - HTTP:
localhost:18123(auth:default/xtcp) - Native:
localhost:19001 - Data volume: named
clickhouse_db format_schemas+initdb.dmounted from nix-built tmpfs copies
Schema (all under database xtcp):
xtcp_flat_records_kafka ENGINE = Kafka ← consumes redpanda topic
xtcp_flat_records_mv ENGINE = MaterializedView ← bridge
xtcp_flat_records ENGINE = MergeTree ← queryable storage
xtcp_flat_records_errors_mv ENGINE = MaterializedView ← parse-failure capture
xtcp_flat_records_errors ENGINE = MergeTree ← _error rows (1d TTL)
SQL DDL lives in build/containers/clickhouse/initdb.d/sql/ — shared between this microvm and the production docker-compose stack.
The xtcp daemon writes batched records as a length-delimited Envelope per Kafka message (see proto/xtcp_flat_record/v1/xtcp_flat_record.proto):
Kafka message body = varint(envelope_size) || serialized_Envelope
where Envelope { repeated XtcpFlatRecord row = 10 } carries all records from one poll cycle (or a chunk if the size-cap safety valve flushes mid-cycle — see EnvelopeFlushThresholdBytesCst in pkg/xtcp/marshallers.go, default 768 KiB).
No Confluent schema-registry header is prepended on the wire. xtcp's schema-registry registration (registerProtobufSchema in pkg/xtcp/destinations_kafka.go) is informational only — ClickHouse does not consult the registry to decode messages; it loads the xtcp_flat_record.proto schema from /var/lib/clickhouse/format_schemas/ via its kafka_schema setting.
ClickHouse decodes the wire format via:
ENGINE = Kafka SETTINGS
kafka_format = 'ProtobufList',
kafka_schema = 'xtcp_flat_record.proto:XtcpFlatRecord',
...Two things about that kafka_schema value are load-bearing and easy to get wrong (both have bitten us — see Troubleshooting):
- Point at the ROW type
XtcpFlatRecord, not theEnvelopewrapper.ProtobufListhandles the envelope framing itself; namingEnvelopeyieldsNO_COLUMNS_SERIALIZED_TO_PROTOBUF_FIELDS. - Use the SIMPLE, unqualified name — do NOT prepend the proto package. ClickHouse's resolver (
src/Formats/ProtobufSchemas.cpp,FileDescriptor::FindMessageTypeByName) wants the name relative to the file's package. A package-qualified name likextcp_flat_record.v1.XtcpFlatRecorddeterministically fails withCould not find a message named '…'(BAD_ARGUMENTS), the consumer detaches, and ingestion stalls.
Reference encoders (one Kafka, one HTTP) live at:
cmd/clickhouse_http_insert_protobuflist/— produces the wire format and POSTs to ClickHouse's HTTP?format=ProtobufListendpoint. The minimal byte-by-byte reproduction of what the daemon's marshaller emits.cmd/kafka_to_clickhouse/— same bytes, but sent via Kafka to exercise the engine table path end-to-end.
The cmd/xtcp2_kafka_client/ tool decodes records from the topic via protodelim.UnmarshalFrom and logs each Envelope.row; useful for debugging the producer end without ClickHouse in the loop.
Time-series scraper for xtcp2's /metrics endpoint. Enabled in tcp-stress and clickhouse-pipeline flavors.
- Listens on
0.0.0.0:9090inside the VM - Scrape interval: 15s
- Retention: 48h
- Scrape jobs:
xtcp2@127.0.0.1:9088prometheus-self@127.0.0.1:9090
Companion xtcp2-prom-snapshot.service (tcp-stress only) writes a JSON line per 30s to stdout so the host runner can grep counters out of the transcript.
Web UI for both Prometheus metrics and ClickHouse SQL queries. Enabled in clickhouse-pipeline.
- Listens on
0.0.0.0:3000inside the VM, host-forwarded to :13000 - Plugin:
grafana-clickhouse-datasourcev4.16.0 (from nixpkgs) - Anonymous access enabled as
Viewer;admin/adminto edit - Datasources pre-provisioned via
services.grafana.provision:xtcp2-clickhouse(default) — native127.0.0.1:19001, dbxtcpxtcp2-prometheus—http://127.0.0.1:9090
Example queries to try in Explore:
-- Row rate per minute
SELECT toStartOfMinute(timestamp_ns) AS t, count() FROM xtcp.xtcp_flat_records
GROUP BY t ORDER BY t
-- Top destination ports
SELECT topK(10)(inet_diag_msg_socket_destination_port) FROM xtcp.xtcp_flat_records
-- Average TCP RTT in the last 5 min
SELECT avg(tcp_info_rtt) FROM xtcp.xtcp_flat_records
WHERE timestamp_ns > now() - INTERVAL 5 MINUTEThe shared self-test (nix/microvms/self-test.nix) runs a sequence of checks on every lifecycle boot. Each emits a sentinel line on the serial console that the host harness greps: XTCP2_SELF_TEST_<NAME>_(PASS|FAIL). Checks are independent — a failure in one doesn't skip later ones, so an OVERALL_FAIL pinpoints exactly what broke. Baseline checks (1-10, 16) and the runtime-control checks (17-19) run on every boot; the ClickHouse/S3 checks (11-15) run only on the flavor that provides that backend.
| # | Sentinel | Checks | Runs on |
|---|---|---|---|
| 1 | SYSTEMD |
systemctl is-active xtcp2 within 30s |
all |
| 2 | METRICS |
curl /metrics returns xtcp_* rows |
all |
| 3 | NETLINK |
xtcp_counts{variable="p"} advances → daemon parsed ≥1 inet_diag socket end-to-end |
all |
| 4 | BINARIES_HELP |
All 11 cmd binaries respond to -help (xtcp2, xtcp2client, xtcp2ctl, xtcp2_kafka_client, clickhouse_protobuflist[_db], clickhouse_http_insert_protobuflist, kafka_to_clickhouse, ns, nsTest, register_schema) |
all |
| 5 | GRPC_ROUNDTRIP |
xtcp2client -target 127.0.0.1 -port 8889 connects and produces output |
all |
| 6 | NS_INSPECT |
ns namespace inspector binary runs |
all |
| 7 | NSTEST |
nsTest -help works |
all |
| 8 | NS_LIFECYCLE |
a netns holding a live process is discovered by the Method B /proc scan (pre-poll reconcile → netNamespaceInstance start); process exit + ns removal → reconcile delete. Both counters bump |
all |
| 9 | NS_TRAFFIC |
TCP listener (the live process Method B keys on) + client inside a fresh netns produces measurable Netlinker packets |
all |
| 10 | NS_DOCKER |
docker-style netns: bind-mount under /run/docker/netns/ + a live process is discovered via /proc and named from the bind mount; netNamespaceInstance start + delete bump |
all |
| 11 | CLICKHOUSE_RECORDS |
xtcp.xtcp_flat_records > 0 and xtcp_flat_records_errors == 0 |
clickhouse-pipeline* |
| 12 | CLICKHOUSE_RECONCILE |
Prom envelopeRows reconciles with the ClickHouse row count (within tolerance) |
clickhouse-pipeline* |
| 13 | S3PARQUET_FILES |
≥1 .parquet object lands in MinIO within 90s |
s3parquet |
| 14 | S3PARQUET_ROWS |
duckdb decodes the parquet file and returns ≥1 row | s3parquet |
| 15 | CLICKHOUSE_PARQUET |
ClickHouse s3() table function reads the MinIO parquet, count() > 0 |
clickhouse-pipeline-parquet |
| 16 | NS_ANONYMOUS |
the Method B audit-gap proof: an anonymous unshare -n netns (no /run/netns bind mount) held by a live process is discovered purely via /proc — the exact case dir-scan/inotify was blind to |
all |
| 17 | CTL_HOT |
xtcp2ctl set-poll-frequency is reflected by a later get + bumps the Poller ticker.Reset counter |
all |
| 18 | CTL_TRIGGER |
with the ticker parked, xtcp2ctl trigger-poll + poll-burst advance the Poller pollRequestCh counter |
all |
| 19 | CTL_RESTART |
xtcp2ctl reconfigure = in-place soft restart (syscall.Exec): same systemd MainPID, and the new tag shows up in get and streamed records (skipped under coverage — a re-exec drops -cover counters) |
all (non-coverage) |
| — | OVERALL |
All checks passed | all |
The soak flavor doesn't run the self-test; instead its runner sleeps for --duration, then prints:
panics, restarts, ns-churn events
The tcp-stress runner sleeps --duration then asserts:
xtcp2.service started, docker.service started, oci image loaded,
N containers spawned, ≥N per-container ns discovered, 0 panics
The discovery-bench runner boots the VM, waits for the in-VM
discovery-bench-run service to emit DISCOBENCH_DONE (bounded by
--timeout, default 1200 s), prints the collected per-cell JSON, powers off,
and passes iff no cell reported DISCOBENCH_ERR.
The clickhouse-pipeline-rate runner boots the VM, drives xtcp2ctl through the baseline→fast→revert→burst schedule, waits for XTCP2_RATE_DONE (bounded by --timeout, default 1800 s), and passes iff all five verdicts (CADENCE, PREDICT, DELIVERY, SOCKETS, BURST) are PASS — see the flavor description above.
The soak / s3parquet / stress runners sleep for --duration, scraping a periodic sentinel (XTCP2_S3PARQUET_HOURLY, XTCP2_CLICKPIPE_ROWS, XTCP2_RES_SNAPSHOT, …) into the transcript, then assert no panics/restarts, forward progress (rows/files grew), a disk-usage ceiling, and a bounded RSS/thread trend.
The plain clickhouse-pipeline flavor has no assertion runner — boot it and inspect via Grafana / curl; the companion xtcp2-clickpipe-monitor emits XTCP2_CLICKPIPE_ROWS … rows=N lines every 30s to the journal.
Not every guard needs a VM. nix flake check runs the fast, hermetic checks (nix/checks/): go-vet, gofmt, nix-fmt, golangci-lint, go-sec; the audit tools (netlink-audit, iouring-audit, metrics-audit, proto-field-audit); the whole-repo race test (test-go-race) and per-build-tag test-go-flavor-{kafka,nats,nsq,valkey,s3parquet,all}; a cli-help-smoke-* matrix (one per cmd binary, including xtcp2ctl); the capability-check-* matrix (sub-second non-VM analog of capcheck-fail — asserts xtcp2 exits non-zero and names the missing cap); and the KVM microvm-lifecycle-x86_64 check. Per-package go test targets (test-pkg-*, test-cmd-* incl. test-cmd-xtcp2ctl) are buildable packages but kept out of the default flake check set.
microvm.forwardPorts plumbs each port through QEMU's SLiRP hostfwd, and networking.firewall.allowedTCPPorts opens the matching guest ports. All bindings are gated on the flavor predicate.
| Host | Guest | Service | Flavors |
|---|---|---|---|
127.0.0.1:9088 |
:9088 |
xtcp2 /metrics |
tcp-stress, clickhouse-pipeline |
127.0.0.1:8889 |
:8889 |
xtcp2 gRPC | tcp-stress, clickhouse-pipeline |
127.0.0.1:19090 |
:9090 |
Prometheus UI | tcp-stress (host port shifted off :9090 to avoid clashing with a Prometheus on the dev box; clickhouse-pipeline reaches it internally) |
127.0.0.1:18123 |
:18123 |
ClickHouse HTTP (-u default:xtcp) |
clickhouse-pipeline |
127.0.0.1:19001 |
:19001 |
ClickHouse native | clickhouse-pipeline |
127.0.0.1:19092 |
:19092 |
Redpanda Kafka external | clickhouse-pipeline |
127.0.0.1:19644 |
:19644 |
Redpanda admin | clickhouse-pipeline |
127.0.0.1:18081 |
:18081 |
Schema registry | clickhouse-pipeline |
127.0.0.1:13000 |
:3000 |
Grafana UI | clickhouse-pipeline |
If any host port collides with something already bound on your dev box, either kill that process or edit the forwardPorts entry in nix/microvms/mkVm.nix to use a different host port.
Top-of-file let bindings in nix/microvms/mkVm.nix — change a number, rebuild, run.
soakInitialNs = 50; # initial ip netns add count
soakChurnSleep = "250ms"; # gap between churn cycles
soakScrapePeriodSec = 60; # /metrics scrape cadence
soakTcpServerCount = 100;
soakTcpClientCount = 100;
soakTcpClientSleep = "5s";
soakTcpPads = 2048;
soakTcpConnect = "127.0.0.1";tcpStressNumContainers = 20; # docker containers to spawn
tcpStressSocketsPerContainer = 250; # TCP_COUNT inside each
tcpStressClientSleep = "5s";
tcpStressPads = 1024;clickPipeRedpandaImage = "docker.redpanda.com/redpandadata/redpanda:v25.1.7";
clickPipeClickhouseImage = "clickhouse/clickhouse-server:26.7-alpine"; # must be >= 26.3 (ProtobufList Kafka fix)
clickPipeKafkaTopic = "xtcp";
clickPipeChPassword = "xtcp"; # default user passwordmem = 1024; # minimal + coverage flavors
memSoak = 3072; # nsTest churn working set
memTcpStress = 3072; # dockerd + 20 containers + Prometheus + Grafana
memClickPipe = 6144; # redpanda + clickhouse + xtcp2 (also -rate, s3parquet*)
memClickPipeStress = 8192; # pipeline + tcp-stress load containers
memClickPipeParquet = 16384; # + MinIO + a 2nd xtcp2 writing parquet
memDiscoveryBench = 4096; # the N×P discovery gridnix run .#microvm-x86_64-lifecycle # ~45s, hits 10 sentinelsnix run .#microvm-x86_64-soak -- --duration 1h
# prints heartbeats every 300s; final summary lists panics + restartsnix run .#microvm-x86_64-soak -- --duration 12h
# the 12h run flushed out the systemd TasksMax=1100 ceiling that
# crashed xtcp2 at ~3320s in earlier validationsnix run .#microvm-x86_64-tcp-stress -- --duration 300s
# xtcp2 discovers each container's netns via the /proc/<pid>/ns/net scan
# (Method B — no inotify), spawns a per-container netNamespaceInstance,
# and reads inet_diag in each. The runner asserts the running instance
# count (XTCP2_NS_INSTANCES start=N) is >= the container count.nix run .#microvm-x86_64-discovery-bench -- --timeout 900
# boots a root VM, sweeps an N-namespaces × P-processes grid, and prints a
# DISCOBENCH_GRID JSON line per cell with each method's ns/op + coverage.
# Re-run any time to re-confirm the numbers on a new kernel/host.nix run .#microvm-x86_64-clickhouse-pipeline # leave running
# in another terminal:
curl -u default:xtcp 'http://127.0.0.1:18123/?query=SELECT count() FROM xtcp.xtcp_flat_records'
open http://127.0.0.1:13000 # Grafananix run .#microvm-x86_64-clickhouse-pipeline-rate-runner
# boots the kafka pipeline, drives xtcp2ctl set-poll-frequency 10s→1s→10s
# + a poll-burst, estimates sockets/poll from the daemon counters, and asserts
# the ClickHouse row rate matches N×(window/freq) within ±10% (CADENCE / PREDICT
# / DELIVERY / SOCKETS / BURST). ~15 min; shorten with RATE_WINDOW_SEC via the
# systemd env. The hot xtcp2ctl paths are also smoke-checked on every
# `nix run .#microvm-x86_64-lifecycle` (self-test Checks 17-19).The microvm exposes a serial getty on 127.0.0.1:12055:
nc 127.0.0.1 12055 # then ENTER for the login prompt
# inside the VM:
docker ps
docker logs clickhouse | tail -50
journalctl -u xtcp2 -n 100Could not set up host forwarding rule 'tcp::XXX-:YYY' Something on the host already binds port XXX. Check ss -tnlp 'sport = XXX', kill it, or edit nix/microvms/mkVm.nix forwardPorts to use a different host port.
ClickHouse query returns REQUIRED_PASSWORD (code 194) Pass -u default:xtcp on curl or --password xtcp on clickhouse-client. Password comes from clickPipeChPassword in mkVm.nix.
xtcp2 panic with failed to create new OS thread (have 1121 already) The systemd TasksMax ceiling. Already raised to 8192 in nix/modules/xtcp2-service.nix — if you see this in a fresh deployment, check the unit file cat /etc/systemd/system/xtcp2.service | grep -i task.
docker pull fails on first boot The microvm uses qemu user-mode networking; outbound NAT is on by default but needs DNS to resolve docker.io. Check the VM serial console for network errors; usually a transient issue, the unit's Restart=on-failure will retry.
Grafana datasource health-check fails The clickhouse container needs ~30s to become query-ready after its docker run. Wait, then refresh the datasource page. If it persists, exec into the VM and check docker logs clickhouse.
microvm-run: Address already in use A previous run's qemu didn't clean up. fuser -k 12055/tcp 12056/tcp (serial + virtio-console ports), then re-run.
StorageKafka: Could not find a message named 'xtcp_flat_record.v1.XtcpFlatRecord' in the schema file (recurring at runtime) This is a real, fatal bug — not cosmetic. (An earlier version of this doc wrongly called it a harmless startup artifact; that misdiagnosis cost a lot of debugging time.) If this error recurs at runtime (every consumer poll, in the clickpipe-monitor's XTCP2_CH_KAFKALOG dump), the kafka_schema message name is package-qualified (xtcp_flat_record.v1.XtcpFlatRecord). ClickHouse's ProtobufList resolver cannot resolve a qualified name — it needs the simple name. Ingestion stalls at a fixed row count while kafka_exc may still read 0 (a detached consumer stops throwing). Fix: kafka_schema = 'xtcp_flat_record.proto:XtcpFlatRecord' in build/containers/clickhouse/initdb.d/sql/xtcp_xtcp_flat_records_kafka.sql (verify with SHOW CREATE TABLE xtcp.xtcp_flat_records_kafka). Reproduce/verify the resolver behavior in seconds without a VM:
# FAILS (qualified) vs RESOLVES (simple), against the real proto:
docker run --rm -w /s -v "$PWD/proto/xtcp_flat_record/v1":/s clickhouse/clickhouse-server:26.7-alpine \
clickhouse-local -q "SELECT toFloat64(1) AS timestamp_ns FORMAT ProtobufList" \
--format_schema='xtcp_flat_record.proto:XtcpFlatRecord' # exit 0 = resolvesNote: a single Could not find a message line right at container init (during the entrypoint's temp-server → real-server handover) can be a benign one-shot. The tell is recurrence: benign = once at boot; bug = every poll forever. When in doubt, check whether rows is growing.
A schema/DDL change doesn't take effect on re-run (or the row count is frozen at a suspiciously round, identical number across runs) Every clickhouse-pipeline* flavor mounts a persistent 16 GiB ext4 disk at /tmp/xtcp2-microvm-clickhouse-pipeline-docker.img → /var/lib/docker (nix/microvms/mkVm.nix, microvm.volumes under isAnyClickPipe). That disk backs the clickhouse_db named volume. ClickHouse's docker entrypoint runs /docker-entrypoint-initdb.d/* only when its data dir is empty, so once clickhouse_db exists from a prior boot, initdb is skipped and your edited DDL (kafka_schema, columns, TTL, …) never runs — the old tables and old data persist across rebuilds and even ClickHouse version bumps. This once masked an entire debugging session: a plateau at exactly 64824 rows was frozen leftover data, not a live ceiling. Fix while iterating on schema: rm -f /tmp/xtcp2-microvm-clickhouse-pipeline-docker.img before re-running (it is autoCreated fresh). The persistence is intentional for multi-hour soaks (survives in-VM reboots); it is only a footgun when the DDL changes. The s3parquet-stress flavor has the same model with its own disks — rm -f /tmp/xtcp2-microvm-s3parquet-stress-*.img for a clean docker image store + empty MinIO bucket before a fresh run.
Stress containers all report FAILED to start, and/or dockerd: failed to validate image signature … expected image index descriptor, got manifest.v2+json Almost always a stale/corrupt docker image store on the reused persistent disk (previous point) — a prior build's /var/lib/docker left half-migrated image metadata. Wipe the same disk image (rm -f /tmp/xtcp2-microvm-clickhouse-pipeline-docker.img) and re-run; on a fresh disk all containers start and the signature line, if it still appears, is a harmless warning (containers run regardless).
Row count plateaus with disk near 100% in the heartbeat /var/lib/docker (the 16 GiB disk above) filled up — MergeTree parts + redpanda segment logs grow over a soak. When it saturates, the kafka_engine can't commit offsets, xtcp2's producer back-pressures, and ingestion silently plateaus (this exact failure froze a 12h run at ~18k rows and looked like a decode/consumer bug). The clickpipe* runner now surfaces this: each heartbeat prints disk=N%, the summary prints docker disk % (last / peak), and it FAILs at ≥95% / WARNs at ≥85% (asserted on the peak, not just the final sample). Fix: grow the volume size in nix/microvms/mkVm.nix, or shorten the MergeTree TTL / redpanda retention. The clickhouse-pipeline-stress flavor already drops the records-table TTL to 1 hour for exactly this reason.
Pushing N rows … took 37152 ms in the ClickHouse log The kafka_engine → MV → MergeTree path is slow per-batch (tens of seconds for a few k rows under the mixed clickhouse-pipeline-parquet flavor's load). That's why ch_rows appears to "halt" between 30-min probe intervals — it's not a halt, it's a long-running flush. Confirm with SELECT num_messages_read, assignments.current_offset[1], last_poll_time FROM system.kafka_consumers — if last_poll_time is recent the consumer is alive; the slowness is downstream of the consumer. Profiling the 122-column ZSTD MergeTree insert path is a known open follow-up.
MEMORY_LIMIT_EXCEEDED while bumping container memory keeps the rate the same (historical — kept for reference; the actual fix is below) Earlier hypotheses chased ClickHouse's per-server memory cap. Bumping the container from 12000m → 14000m → 20000m → 28000m moved the cap but ClickHouse's MemoryTracking grew to fill it (10 GiB → 12 GiB → 17 GiB → 24 GiB respectively). The OOM rate (~2.3/min) stayed flat because the OOMs are workload-allocation events, not free-memory exhaustion. Past ~20000m, MV-insert times blew up (8 rows / 197 s) and the consumer started getting kicked by max.poll.interval.ms. The real cause turned out to be something else entirely — see below.
The actual root cause: kafka_engine Block accumulation is redundant with ProtobufList batching The 10 GiB MemoryTracking was empty over-allocated buffer space, not data. Each xtcp2 → kafka message is a ProtobufList envelope already containing 100-1000 rows; on top of that, the kafka_engine's default kafka_max_block_size = 65,505 rows accumulates rows from many envelopes before flushing to the MV. ClickHouse pre-allocates per-column buffers sized for the FULL block at flush time, regardless of how few rows actually arrived. With 122 columns × 65K rows of pre-allocated buffer + ZSTD/LZ4 compression contexts + MV pipeline state, the per-flush peak hit ~10 GiB even though the actual data rate is only ~215 KB/sec.
The fix is kafka_max_block_size = 1024 (~1 envelope per flush) and kafka_flush_interval_ms = 2000. Each ProtobufList message effectively passes through to the MV directly without redundant row-level batching on top. Per-flush column buffers shrink ~64×.
Measured before/after on a fresh 31-min smoke:
| Metric | block=65,536 / flush=5s | block=1024 / flush=2s |
|---|---|---|
| MemoryTracking (peak) | ~12 GiB | 246 MiB |
| ClickHouse container RSS | 6-9 GiB | 311 MiB |
| MEMORY_LIMIT_EXCEEDED | 67 / 31 min | 0 |
| errors_mv rows | 68 | 0 |
| Throughput | ~393 rows/min | ~27,700 rows/min |
| Consumer commits / messages | 2 / 426 (rebalance loop) | 367 / 367 |
The throughput now matches xtcp2's actual production rate (~430 rows/sec) with the MV running in real-time and zero backlog. ClickHouse runs on ~300 MiB instead of needing 14 GiB.
If you see new MEMORY_LIMIT_EXCEEDED entries with a different kafka_* setup, check SHOW CREATE TABLE xtcp.xtcp_flat_records_kafka and verify kafka_max_block_size is still at ~1024 — if it's reverted to the default 65,505 you'll see the OOM rate jump back to ~2/min.
- Stability & soak testing — soak methodology, the OS-thread scaling model, and measured 24h results for the
-soak/clickhouse-pipeline-stress/s3parquet-stressruns. - Operations notes — the non-hermetic docker-compose local pipeline (
make deploy) and how to query the data. - Testing & quality — the unit / deserialization / benchmark / fuzz layer and the audit tools that back the non-VM flake checks.
- gRPC API — the
ConfigService/xtcp2ctlruntime-control surface exercised by the rate flavor and self-test Checks 17-19. - Namespace discovery & reconcile — Method B (
/proc-scan), the mechanism the NS_* checks anddiscovery-benchvalidate.