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
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
- Consume. A durable JetStream pull consumer (
LOGSstream, durablearchiver, subject filter defaultlogs.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. - 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 hitsmax_bytes(64 MiB raw),max_events, ormax_age(5 min) — whichever first — or on shutdown. - Seal. The batch is rendered to NDJSON, compressed with zstd, and sealed into a LARC1 object (see below).
- Store. The object is
PUTto S3 atarchive/<subject>/YYYY/MM/DD/<UTCstamp>-<rand>.ndjson.zst.larc. - Index. One row per object is inserted into ClickHouse
logs.archive_index. - 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||counterand 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.