Kubernetes log collection design¶
This document defines the production design for comparing an in-cluster Fluent Bit agent with an external Grafana Alloy Kubernetes API collector. For deployment, certificate rotation, validation commands, and rollback, see the Kubernetes log forwarding runbook. Measured outcomes are recorded separately in the Kubernetes log collection comparison.
Scope and design goals¶
Both paths observe the same application stdout/stderr without changing Ledger
application code. They write independent copies to the same Loki instance and
identify their source with a bounded collection label.
The design has four goals:
- preserve a vendor-compatible path that installs no collector in K3s;
- compare that path with a node-level agent under the same workload;
- keep Loki off the unauthenticated LAN by requiring private-CA client identities; and
- make host and Kubernetes desired state reproducible without storing private keys, tokens, kubeconfigs, or passwords in Git.
The comparison is intentionally dual-write. Duplicate events across the two
collection values are expected and are part of the evaluation, not an
application-processing duplicate.
Production topology¶
flowchart LR
subgraph k3s[BrownRook K3s]
app[home-budget Pods]
cri[(containerd CRI logs)]
api[Kubernetes API]
fb[Fluent Bit DaemonSet]
saFB[fluent-bit ServiceAccount]
saAPI[external-log-reader identity]
app -->|stdout and stderr| cri
cri -->|hostPath read| fb
fb -->|Pod metadata only| api
saFB -.->|authenticates| fb
end
subgraph monitor[monitoring.idc.brownrook.net]
alloy[Grafana Alloy systemd service]
loki[(Loki 3100)]
grafana[Grafana]
alloy -->|loopback HTTPS and mTLS| loki
grafana -->|loopback HTTPS and mTLS| loki
end
alloy -->|pods and pods/log| api
saAPI -.->|restricted kubeconfig identity| alloy
fb -->|LAN HTTPS and mTLS| loki
operator[Operator browser] -->|Grafana HTTPS| grafana
Loki terminates TLS itself on port 3100; NGINX is not in the Loki data path. The monitoring-host firewall permits that port only from loopback and the approved K3s node address. Grafana remains the normal operator query surface.
Collection paths¶
Method A: node agent¶
Pod stdout/stderr
-> containerd CRI file
-> Fluent Bit tail and multiline parsing
-> Kubernetes metadata enrichment
-> bounded labels and credential redaction
-> filesystem buffer
-> Loki HTTPS push with fluent-bit-loki-client
The deploy/rabbitmq-private-logging Kustomize overlay adds one Fluent Bit Pod
per eligible Linux node. It reads only
/var/log/containers/*_home-budget_*.log, excludes its own file, and uses a
namespace Role only for Pod metadata. Its tail database and buffered chunks
persist at /var/lib/home-budget/fluent-bit on the node.
The output retries without a fixed retry limit and caps filesystem use at 256 MiB. The Pod requests 50 millicores and 64 MiB, with limits of 300 millicores and 256 MiB. Its port 2020 endpoints expose health, output metrics, and buffer state.
Method B: external Kubernetes API collector¶
Pod stdout/stderr
-> Kubernetes pods/log API
-> external Alloy discovery and stream
-> bounded relabeling and credential redaction
-> Alloy WAL
-> Loki loopback HTTPS push with alloy-loki-client
Alloy runs as a systemd service on the monitoring host. It discovers only the
home-budget namespace using a dedicated kubeconfig. The corresponding Role
allows get, list, and watch on Pods plus get on pods/log; it cannot
read Secrets or other namespaces. No Alloy workload, agent, or sidecar runs in
K3s.
This path represents the vendor boundary: BrownRook needs a supported Kubernetes API endpoint and read-only credential, but no node access or permission to install software in the application cluster.
Trust and identity design¶
flowchart TB
root[Brown Rook root CA]
intermediate[Brown Rook intermediate CA]
server[monitoring server certificate]
alloyID[alloy-loki-client]
grafanaID[grafana-loki-client]
fluentID[fluent-bit-loki-client]
root --> intermediate
intermediate --> server
intermediate --> alloyID
intermediate --> grafanaID
intermediate --> fluentID
server -->|presented by| loki[Loki]
alloyID -->|client authentication| loki
grafanaID -->|client authentication| loki
fluentID -->|client authentication| loki
Each Loki consumer has a separate leaf identity with the clientAuth extended
key usage. All clients validate the server name
monitoring.idc.brownrook.net against the Brown Rook root. In the enforced
state, Loki uses RequireAndVerifyClientCert; a TCP connection that passes the
firewall still cannot use Loki without a trusted client certificate.
The Kubernetes API trust path is separate. Alloy validates the K3s API CA and
authenticates with the external-log-reader service-account token. That token
does not grant access to the Loki endpoint, and the Loki certificates do not
grant Kubernetes access.
| Identity or secret | Installed location | Capability |
|---|---|---|
external-log-reader kubeconfig |
Monitoring host, readable by Alloy | Discover Pods and read Pod logs in home-budget only |
alloy-loki-client |
Monitoring host, readable by Alloy | Write/query Loki through loopback mTLS |
grafana-loki-client |
Grafana secure datasource fields | Query Loki through loopback mTLS |
fluent-bit-loki-client |
Kubernetes Secret mounted by Fluent Bit | Write to Loki from the approved K3s node |
| Monitoring server key | Monitoring host, monitoring-tls group |
Terminate Loki HTTPS for the declared DNS name |
CA signing keys, client private keys at rest, kubeconfigs, tokens, and the
Grafana administrator password are external inputs. Ansible uses no_log for
secret-bearing operations and does not commit or render their values into CI
artifacts.
Event and label contract¶
Both paths retain the original application message after decolorization and
credential-pattern redaction. High-cardinality values such as receipt hashes,
filenames, request IDs, run_uuid, and pass_id remain in message content.
They are never deliberately promoted to Loki labels.
| Label | Source | Cardinality rule |
|---|---|---|
cluster |
Static deployment value | One value per cluster |
environment |
Static deployment value | One value per environment |
collection |
Static collector value | fluent-bit or kubernetes-api |
namespace |
Kubernetes metadata | Restricted to home-budget in this design |
pod |
Kubernetes metadata | Workload lifecycle cardinality is accepted |
container |
Kubernetes metadata | Bounded by deployed workload definitions |
node |
Kubernetes metadata | Bounded by cluster nodes |
app, service |
Selected application labels | Present where the workload exposes a mapped label; Fluent Bit supplies a container fallback |
job |
Namespace and container | Bounded composition, not a receipt identity |
Loki or a client library may also derive bounded labels such as instance or
service_name. Arbitrary Pod labels and annotations are not copied. Queries
must use collection, not collector, to distinguish the paths.
Delivery and failure semantics¶
| Condition | Fluent Bit node agent | External Alloy collector |
|---|---|---|
| Loki unavailable | Retries and stores bounded chunks on the node filesystem | Queues writes in the enabled Alloy WAL on the monitoring host |
| Collector restart | Tail database resumes from node files that still exist | Rediscovers current Pods and reconnects to their API log streams |
| Pod replacement | Reads old and replacement CRI files while retained on the node | Discovers the replacement Pod; recovery from an already-terminated stream is API/runtime dependent |
| Kubernetes API unavailable | Continues tailing files; metadata refresh can become stale | Cannot discover or stream until API access returns |
| Node unavailable | That node's agent and local buffer are unavailable | API path can continue if the control plane and Pod log endpoint remain available |
| Monitoring host unavailable | Buffers on the K3s node up to configured limits | Alloy and Loki are both unavailable, so no independent external collector remains active |
The table states the designed mechanisms, not measured guarantees. Container log rotation, buffer exhaustion, terminated-Pod retention, and overlapping reconnects can still produce loss or duplicates. KAN-86 tests those boundaries before selecting a preferred method.
Loki currently uses a single-process filesystem deployment under /tmp/loki,
an in-memory ring, and replication factor one. That is adequate for this
controlled comparison but is not a highly available or durable long-term Loki
storage design.
GitOps ownership and reconciliation¶
flowchart TB
appRepo[home-budget-pipeline main]
infraRepo[brownrook-infra main]
pki[External PKI and kubeconfigs]
rootApp[brownrook-root Application]
ledgerApp[ledger Application]
argo[Argo CD]
k8s[K3s resources]
ansible[Monitoring Ansible workflow]
host[Monitoring host state]
tlsSecret[fluent-bit-loki-tls Secret]
infraRepo -->|ledger-app.yaml| rootApp
rootApp -->|self-heal child spec| ledgerApp
appRepo -->|deploy/rabbitmq-private-logging| ledgerApp
ledgerApp --> argo --> k8s
appRepo -->|ops/monitoring| ansible
pki -->|external secret inputs| ansible
ansible -->|Loki, Alloy, nftables, Grafana| host
ansible -->|controller-only kubeconfig| tlsSecret
tlsSecret --> k8s
The repositories own different layers:
| Desired state | Authoritative source |
|---|---|
| Ledger Argo CD Application path | brownrook-infra/kubernetes/gitops/apps/ledger-app.yaml |
| Fluent Bit workload, RBAC, configuration, and overlay | deploy/fluent-bit and deploy/rabbitmq-private-logging |
| Loki, Alloy, nftables, Grafana datasource, and external Secret reconciliation | ops/monitoring |
| Public certificates and private secret inputs | External PKI/controller files referenced by .env.monitoring |
Directly changing the child Application with argocd app set is not durable;
the brownrook-root parent restores the Git-declared path. Enabling or removing
the Fluent Bit overlay therefore starts with a reviewed brownrook-infra
change. Argo owns workload reconciliation, while the operator-invoked Ansible
workflow owns the external monitoring host and creates the externally sourced
TLS Secret without exposing its values.
The Loki rollout has two explicit states:
flowchart LR
optional[Optional checkpoint<br/>loopback listener<br/>VerifyClientCertIfGiven]
enforced[Comparison state<br/>LAN listener<br/>RequireAndVerifyClientCert]
optional -->|certificates, firewall, clients, and Fluent Bit ready| enforced
enforced -->|reviewed rollback and apply| optional
Comparison method¶
Use uniquely identifiable, non-sensitive messages emitted once by the same Pod
and query each collection independently. Record:
- first-observed delivery latency from emission until the event is queryable;
- missing and duplicate count per collection;
- common and path-specific metadata;
- multiline traceback shape;
- behavior across application Pod, collector, Loki, and API restarts;
- buffered records, retries, failed output records, and dropped records;
- steady-state and outage CPU, memory, storage, and network use; and
- operator actions and credentials needed for deployment and recovery.
The Loki event timestamp alone is not an ingest timestamp. For this comparison, poll both queries from the same controller clock and treat first observation as an upper-bound delivery-latency measurement. Use the same poll interval for both paths and record it with the results.
On 2026-09-14, the production probe KAN86_DUAL_COLLECTOR_PROBE was returned
through enforced mTLS in both collection="fluent-bit" and
collection="kubernetes-api". Argo was healthy, the Fluent Bit DaemonSet was
fully rolled out, the namespace RBAC boundary passed, Grafana's datasource
proxy returned both collections through its separately stored mTLS identity,
and the final monitoring check reported zero drift. Restart, outage,
sustained-resource, and live receipt-event measurements remain comparison
work; no final preference should be inferred from the initial delivery proof
alone.
Decision rule¶
For a BrownRook-managed cluster, select the node agent only if its measured completeness or recovery advantage justifies its node access and per-node resource cost. Select the API collector when the lower cluster footprint and weaker privilege boundary meet the measured reliability requirement.
For a vendor-managed cluster where agent installation is prohibited, Method A is unavailable. Method B is suitable only when the vendor can provide a stable Kubernetes API endpoint, the documented namespace-scoped permissions, and log retention long enough for the measured reconnect behavior.