Files
logarchiver/docs/architecture.md
T
benvin c05ccfcb5d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Initial implementation: NATS->S3 archiver + search/retrieve CLI
logarchiver replaces the plain Vector archiver leg of the centralized
logging stack (argocd-apps #296) with a Go service that archives raw logs
from NATS JetStream to S3 as zstd-compressed, OpenPGP-encrypted, indexed
objects, plus an operator CLI to search the index and retrieve/decrypt
archived logs. It adds the things that outgrew Vector: zstd compression,
encryption keyed from Ben's Vault GPG secrets engine, a searchable
ClickHouse index, and sink-conditional acks (a batch is acknowledged to
JetStream only after the object is durably in S3 AND indexed).

Service (`logarchiver run`):
- Durable JetStream pull consumer (stream LOGS, durable archiver, subject
  filter default logs.k8s.vault.>), explicit acks, independent offsets.
- Batch per subject by size/count/time -> NDJSON -> zstd -> encrypt -> S3
  PUT -> ClickHouse index row -> ack. On any failure the batch is Nak'd and
  redelivered, so nothing is lost on a sink outage.
- Encryption is a wrapped-DEK envelope (container LARC1): the bulk is
  AES-256-GCM framed under a random data key, and only that 32-byte key is
  OpenPGP-encrypted to the engine's public key. This is because the Vault
  GPG engine does whole-payload decrypt only; retrieval round-trips just the
  tiny wrapped key regardless of object size. Public key fetched from the
  engine or a mounted file (configurable); key fingerprint recorded per
  object; periodic pubkey refresh for rotation.
- Prometheus metrics, structured slog, graceful drain on shutdown.

CLI:
- `search` queries the index (subject/host/time) and lists matching objects.
- `fetch` downloads, decrypts via the Vault GPG engine, unzstds and emits
  NDJSON (optionally re-filtered by host/time).
- `init-schema` creates/prints the ClickHouse archive_index DDL.
- cobra `completion` subcommands.

Config via file+env (k8s-friendly, secrets from env), boundaries (NATS/S3/
ClickHouse/Vault) behind interfaces with unit tests (config, batching,
host/subject extraction, crypto roundtrip with a test key, ack-after-persist
with fakes, search query building). go build/vet/test -race clean;
golangci-lint v2 clean. Woodpecker CI: build/test/pre-commit on PR; on v*
tag a container image plus a Gitea binary release + rpm-internal RPM. Docs
per subcommand + architecture + retrieval runbook + deployment drop-in.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 23:22:40 +10:00

6.7 KiB

Architecture

logarchiver is one Go binary with a service mode (run) and an operator CLI (search/fetch/init-schema). Boundaries to external systems (NATS, S3, ClickHouse, Vault) sit behind interfaces so they can be faked in tests.

Packages

Package Responsibility
internal/config Config struct, YAML+env loading, validation, defaults tuned for the logging stack.
internal/event Extract host + timestamp from a raw JSON event (k8s and VM shapes), subject sanitization.
internal/batcher Group events per subject into batches; decide flush by size/count/age; carry ack tokens.
internal/crypto The LARC1 container: zstd + framed AES-256-GCM + OpenPGP-wrapped data key. Seal/Open.
internal/vaultgpg Thin client for vault-plugin-secrets-gpg: fetch public key, decrypt wrapped data key.
internal/s3store S3/Ceph RGW object storage behind an ObjectStore interface.
internal/index ClickHouse archive index (Index interface), DDL, pure search-SQL builder.
internal/archiver Orchestrates seal → S3 PUT → index write; object-key builder; public-key provider/refresh.
internal/consumer JetStream binding + the fetch→batch→persist→ack loop (sink-conditional acks).
internal/metrics Prometheus collectors and /metrics.
internal/cli cobra command tree; wires everything for each subcommand.

Service data flow

  1. Consume. A durable JetStream pull consumer (LOGS stream, durable archiver, subject filter default logs.k8s.vault.>) is created/updated at startup with explicit acks and independent offsets, so logarchiver's replay position is decoupled from the ClickHouse transform tier. Messages are pulled in batches.
  2. Route + batch. Each message's payload is parsed just enough to extract the source host (.host.hostname.kubernetes.pod_node_name) and timestamp (.timestamp.ts). Events are grouped by NATS subject. A batch is flushed when it hits max_bytes (64 MiB raw), max_events, or max_age (5 min) — whichever first — or on shutdown.
  3. Seal. The batch is rendered to NDJSON, compressed with zstd, and sealed into a LARC1 object (see below).
  4. Store. The object is PUT to S3 at archive/<subject>/YYYY/MM/DD/<UTCstamp>-<rand>.ndjson.zst.larc.
  5. Index. One row per object is inserted into ClickHouse logs.archive_index.
  6. Ack. Only now are the batch's JetStream messages acknowledged. If seal, S3, or index fails, the messages are Nak'd (with a backoff) and JetStream redelivers them — nothing is lost on a sink outage.

The LARC1 container format

The Vault GPG engine can only decrypt a whole OpenPGP message inline (no session-key extraction, no streaming — see the retrieval runbook). To keep objects any size while making retrieval cheap, logarchiver does its own hybrid encryption instead of PGP-encrypting the whole object:

+-----------------------------------------------------------+
| magic         "LARC1\n"  (6 bytes)                        |
| header_len    uint32 big-endian                           |
| header        JSON  { v, key_name, key_fingerprint,       |
|                       wrapped_dek_len, nonce_prefix,       |
|                       frame_size, compression, cipher }    |
| wrapped_dek   OpenPGP message encrypting the 32-byte DEK   |  <- only this goes to Vault
|               to the engine's public key (~hundreds of B)  |
| frames        repeated:  uint32 ct_len | ciphertext        |  <- zstd(NDJSON) in AES-256-GCM
|               terminated by a zero-length frame            |     frames; nonce = prefix||counter
+-----------------------------------------------------------+
  • A fresh random 256-bit DEK and 4-byte nonce prefix are generated per object. Each frame is AES-256-GCM sealed with nonce prefix||counter and AAD = the frame counter (binds frame order; tampering fails the GCM tag).
  • The DEK is OpenPGP-encrypted to the engine's public key. This wrapped blob is a small, standard OpenPGP message — the only thing ever sent to Vault's decrypt endpoint, regardless of object size.
  • Compression order is NDJSON → zstd → encrypt, so decryption streams frames, GCM-decrypts, and feeds a streaming zstd decoder to emit NDJSON.

Because objects are a logarchiver-specific container (not a bare gpg file), retrieval must go through logarchiver fetch. This is the deliberate cost of supporting arbitrary object sizes against an engine that only whole-payload decrypts.

Index schema

logs.archive_index (one row per object), MergeTree, PARTITION BY toYYYYMM(min_ts), ORDER BY (subject, min_ts, object_key), with a bloom_filter skip index on hosts:

column type notes
object_key String S3 key
bucket LowCardinality(String) e.g. logs-archive
subject LowCardinality(String) NATS subject
hosts Array(LowCardinality(String)) distinct source hosts
min_ts / max_ts DateTime64(3) event time range
event_count UInt64
raw_bytes / stored_bytes UInt64 pre/post compression+encryption
compression / cipher / container_format LowCardinality(String) zstd / AES-256-GCM / LARC1
key_name / key_fingerprint LowCardinality(String) / String Vault GPG key + 40-hex fingerprint
created_at DateTime64(3) DEFAULT now64(3)

Search overlaps the [from,to] window (max_ts >= from AND min_ts <= to), matches the subject glob with an anchored regex (* = one token, > = rest), and filters hosts with has(hosts, …) (exact) or arrayExists(… match …) (glob).

The DDL is owned in-cluster by the argocd bootstrap Job; logarchiver init-schema applies the same statements and --print emits them. Keep schema/archive_index.sql and internal/index/ddl.go in sync.

Delivery guarantees

  • At-least-once, sink-conditional. Acks happen only after S3 + index success. A failure after S3 but before index leaves an orphan object (still retrievable by prefix; reaped by bucket lifecycle) and triggers redelivery, which produces a fresh object — so an event may appear in two objects, never zero. Downstream consumers of the archive should treat events as at-least-once.
  • Graceful shutdown drains open batches (bounded timeout) so in-flight events are persisted and acked before exit.

Configuration & secrets

Config loads defaults < YAML file < env. Secrets are never in the file: NATS password from NATS_CONSUMER_PASSWORD, S3 creds from the standard AWS_* env (cephrgw logs-archive-s3 secret), ClickHouse password from CLICKHOUSE_PASSWORD, Vault via VAULT_ADDR + k8s auth (service) or ambient token (CLI). See deployment.md.