feat(otelcollector): render OTel Collector - #5106
Conversation
| DefaultMemoryLimit = "512Mi" | ||
| DefaultMemoryRequest = "128Mi" | ||
| DefaultMemoryLimitMiB = 409 // 80% of 512Mi | ||
| DefaultMemorySpikeLimitMiB = 100 // ~25% of limit_mib |
There was a problem hiding this comment.
How are these memory limits decided? Are they from the vendor recommendations?
There was a problem hiding this comment.
The memory_limiter processor follows the OTel Collector best practices: limit_mib should be ~80% of the container memory limit (409 ≈ 80% of 512Mi), and spike_limit_mib at ~25% of limit_mib (100 ≈ 25% of 409). This headroom lets the GC reclaim memory before the container hits OOMKill. See: https://github.com/open-telemetry/opentelemetry-collector/blob/main/processor/memorylimiterprocessor/README.md
The 512Mi container limit itself is a conservative starting point — the collector is mostly I/O bound (receiving and forwarding telemetry), so memory usage is dominated by in-flight batches rather than computation.
8124957 to
222f3f4
Compare
6dd36c8 to
a8c2d9e
Compare
…lCollector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Renames the API surface from OTel to OpenTelemetry, and reworks exporter TLS, certificate rotation, validation and network policy. API: - OTel* types become OpenTelemetry*; LogCollector.spec.otelCollector becomes spec.openTelemetry. The file moves to api/v1/opentelemetry_types.go. - Replace tlsInsecure with mutualTLS. tlsInsecure rendered tls.insecure, which disables TLS entirely rather than skipping verification, so it could never do what it documented. A private CA is now supplied out of band via the otel-collector-ca ConfigMap and a client keypair via the otel-collector-client-certs Secret, following the syslog/Splunk and external Elasticsearch conventions. Plaintext is selected by an http:// endpoint, as with Splunk. No input disables verification. - Add listType/listMapKey, required and default markers on exporters. Certificate rotation: - Roll the pod on certificate change via HashAnnotations. The config hash alone never changes on rotation because the config holds paths, not PEM, so a rotated CA would silently break fluent-bit ingest until something else restarted the collector. - Watch tigera-ca-private, and reload the receiver's CA and keypair in place so rotation usually costs no restart. Validation: - Reject specs the collector cannot start from (no exporters, no data sources, duplicate exporter names) instead of rendering a config it rejects at boot and leaving status on Progressing with no reason. - Degrade when the exporter CA or client keypair exists but is empty. - Degrade rather than return silently when the Installation is unreadable. Network policy: - Pin egress to each exporter's destination and drop the blanket rule that allowed any host on the OTLP ports. In-cluster destinations match by Service, since Calico resolves Domains rules from observed DNS answers and those never match a ClusterIP reached via the cluster domain. - Set Source on both ingress rules. The internal metrics port serves without TLS or authentication and was reachable by any pod. Lifecycle: - Remove the collector's resources when the feature is disabled, the license loses the feature, or the license expires. Nothing owns them, so they previously lingered, and the expiry message claimed forwarding had stopped while the collector kept exporting. - Pin to a single replica. Each replica federates the same Prometheus targets under its own service.instance.id, duplicating every series. Also register the receiver keypair's key usages, give the probes a boot grace, derive the memory limiter from the container's effective memory limit so overrides take effect, and use the current otlp_grpc/otlp_http exporter type names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a ServiceMonitor for the collector so a silently failing export pipeline is visible: queue depth, export failures and dropped records are otherwise only in the pod's logs. Gated on the collector actually being configured, and removed when it is not. The ServiceMonitor selects a Service that only exists while the collector does, so rendering it unconditionally leaves one behind matching nothing once OpenTelemetry export is switched off. The monitor controller reads LogCollector to decide, mirroring how LicenseExpired already gates the other ServiceMonitors. The collector's identity constants move to pkg/render so both packages can share them: pkg/render/otelcollector imports pkg/render/monitor for the Prometheus federation target, so monitor cannot import it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e Collector The field name was changed to OpenTelemetry but its comment still opened with "configures the OpenTelemetry Collector", which is the framing the review pushed back on: it implies the struct mirrors upstream Collector configuration, which it does not. Describe it as configuring OpenTelemetry export, which the operator happens to implement with a Collector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c612900 to
fca9047
Compare
pasanw
left a comment
There was a problem hiding this comment.
Thanks for addressing my comments! I let Claude lead the re-review and reviewed everything it said was addressed along with what it flagged - it helped flag some things that weren't fully addressed but it also flagged some new issues (sorry about that!)
I'll let you and @rene-dekker work through them together and decide the best way to proceed.
| // ParseExternalDestination extracts the host and port from an endpoint, which may | ||
| // be a bare "host:port" or a URL. When a URL carries no explicit port the scheme's | ||
| // default is used. It reports false when no port can be determined. | ||
| func ParseExternalDestination(endpoint string) (ExternalDestination, bool) { |
There was a problem hiding this comment.
Claude: Glad this got pulled into a shared helper. Right now though only the OTel component uses it — Dex (dex.go, parseHostPortFromURL + resolveEgressRuleForDestination) and Guardian (inline in guardianCalicoSystemPolicy) still have their own copies of the same URL→host:port→egress-rule logic. The ask was one shared piece across all three, so it'd be good to move those two onto ParseExternalDestination too — otherwise the duplication's only half gone.
There was a problem hiding this comment.
Fair — the duplication is only half gone. I have dex and guardian converted onto ParseExternalDestination locally, but both have a much wider blast radius than otel and I'd rather not fold that into this PR's test surface. Splitting it into a follow-up so it can be reviewed on its own; this PR keeps the helper plus the one caller.
There was a problem hiding this comment.
The shared piece exists and OTel is on it:
ParseExternalDestination handles bare host:port and URLs with scheme defaults; ExternalDestinationEntityRule picks the tightest rule available — a /32 or /128 for a literal IP, a Services match for an in-cluster <svc>.<ns>.svc name, a Domains rule for a hostname when the license carries egress-access-control, and port-only otherwise.
Moving Dex and Guardian across is going into its own PR rather than this one. Their conversion is already written (26 lines replacing 83), but they sit on the authentication and management-cluster tunnel paths, where a regression costs more than it does on log export, and each has behaviour the OTel path doesn't — Dex's 0.0.0.0/0 any-destination case, Guardian's HTTP proxy handling. Folding that in here would also widen an already large diff.
Tian is picking that up as a follow-up, so the duplication does get finished off, just not on this PR.
| } | ||
| } | ||
| if c.cfg.ReceiverTLSSecret != nil { | ||
| annotations[c.cfg.ReceiverTLSSecret.HashAnnotationKey()] = c.cfg.ReceiverTLSSecret.HashAnnotationValue() |
There was a problem hiding this comment.
Claude: This map hashes the config, the trusted bundle, and the receiver keypair (this line) — but not the exporter-side certs: the user-supplied ExporterCA (otel-collector-ca) and the client keypair (otel-collector-client-certs). And the exporter TLS block has no reload_interval. So it's the same rotation bug we just fixed on the receiver, reopened on the export side: if a user rotates the exporter CA or client cert, the controller re-copies the new PEM but the pod template is byte-identical, so the StatefulSet doesn't roll. otelcol only reads these files at startup, so it keeps presenting/trusting the stale cert and mTLS to the customer backend breaks until an unrelated restart. Can we fold the ExporterCA + client keypair hashes into this map too, same as the receiver keypair?
There was a problem hiding this comment.
Good catch, fixed. Both are folded into the same map now:
https://github.com/tigera/operator/blob/322d8b439/pkg/render/otelcollector/component.go#L591-L597
On reload_interval for the exporter block: I left it off deliberately. Unlike the receiver's client_ca_file_reload, these are user-managed Secrets/ConfigMaps with no rotation schedule we control, so the hash annotation is the mechanism rather than the backstop — a rotation changes the pod template and rolls the StatefulSet. Happy to add the poll too if you'd rather have both.
| } | ||
|
|
||
| // Tracks the collector's own lifecycle rather than the license's. | ||
| if mc.cfg.OpenTelemetryEnabled && !mc.cfg.LicenseExpired { |
There was a problem hiding this comment.
Claude: This gate keeps the ServiceMonitor on OpenTelemetryEnabled && !LicenseExpired, but the collector is also torn down when the license just lacks the otel-collector feature (valid, not expired) — that case isn't mirrored here, so the ServiceMonitor sticks around selecting a Service that's gone, which is the dangling-monitor situation this gating was meant to avoid. Related: in monitor_controller.go:286, a transient LogCollector read error leaves openTelemetryEnabled=false, which drops into the delete branch and recreates on the next reconcile — so a brief apiserver blip flaps the monitor. Both come down to "enabled" needing to track the same conditions the otel controller uses to actually deploy the collector.
There was a problem hiding this comment.
Both fixed, and chasing the second half turned up a third bug.
Feature gate mirrored, so "enabled" means licensed, not just configured:
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/monitor/monitor_controller.go#L282-L295
The transient read now requeues instead of reading as "disabled":
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/monitor/monitor_controller.go#L286-L292
The third one: the ServiceMonitor was rendering a target Prometheus was never allowed to reach. Nothing opened egress to the collector's metrics port, so the calico-system default-deny dropped it and the scrape just reported up=0 — no error naming the cause. Found it on a cluster; the ServiceMonitor looked correct and no otelcol_* metrics ever arrived. Now paired under the same gate:
https://github.com/tigera/operator/blob/322d8b439/pkg/render/monitor/monitor.go#L1509-L1517
Verified on a cluster after the fix: up{job="otel-collector"} went 0 -> 1 and the collector's own queue/throughput metrics started landing. There's a test asserting the ServiceMonitor and the egress rule appear and disappear together.
| // Default: false | ||
| // +optional | ||
| // +kubebuilder:default=false | ||
| MutualTLS *bool `json:"mutualTLS,omitempty"` |
There was a problem hiding this comment.
Claude: Coming back to this after digging into the TLS side properly. The custom-CA handling is fine and matches the syslog convention, and the rendered config is correct/secure at the OTel level (verified against v0.157.0 — verification can't be turned off, and https verifies on both transports). But the mTLS client cert has a real limitation worth raising.
Right now there's one shared client cert (otel-collector-client-certs) for every exporter that turns mutualTLS on. So every backend we send to is shown the same client cert. That's fine for one backend, or if they all accept the same one — but this feature fans out to multiple external backends (Datadog, a corporate collector, etc.), and different backends usually want different client certs. With one shared cert you can't do that: one identity for all, take it or leave it.
tlsroute already solves this the flexible way — each destination points at its own cert (per-target SecretKeySelector). So this is the one spot where we diverge from the closest existing pattern.
The fix is small if we want it: leave the CA as-is (a shared bundle already trusts many backends, so nothing to change there), and swap the mutualTLS boolean for a per-exporter client-cert Secret name — "here's the Secret with this exporter's cert." The render already loops over the exporters one at a time and validates each, so it's extending that loop rather than new structure; the only new bit is mounting each exporter's Secret instead of one shared one. Bonus: it also lands us on the tlsroute convention.
Also (minor, same field): mutualTLS: true on an http:// endpoint is silently ignored — the cert block gets skipped because the endpoint is plaintext, with no warning, and you're still forced to supply the Secret. Worth at least a validation error so it isn't a silent no-op.
There was a problem hiding this comment.
Agreed on the limitation — one shared cert means one identity for every backend, and fanning out to several backends is exactly when that breaks down.
Deferring rather than doing it here: it changes the API shape (bool -> per-exporter Secret ref) and I'd rather land that with the tlsroute-style plumbing and its own tests.
One thing I did fix from this comment: mutualTLS: true on an http:// endpoint is no longer a silent no-op, it's a validation error:
https://github.com/tigera/operator/blob/322d8b439/pkg/controller/otelcollector/controller.go#L499-L501
Filing a follow-up for the per-exporter cert. Worth flagging it should land before GA — swapping the field afterwards is a breaking change.
There was a problem hiding this comment.
Correcting my earlier reply on this thread: I said I was deferring the per-exporter client cert to a follow-up. That's no longer accurate — @rene-dekker asked for the same thing independently, so I've implemented it rather than deferring.
mutualTLS is gone. Each exporter now carries its own TLS and auth:
https://github.com/tigera/operator/blob/ba5aeb9d4/api/v1/opentelemetry_types.go#L92-L127
exporters:
- name: datadog
endpoint: https://otlp.datadoghq.com:4318
protocol: http
auth:
headers:
- name: DD-API-KEY
valueFrom:
secretKeyRef: { name: otlp-datadog-auth, key: api-key }
tls:
caConfigMapName: corp-ca
clientCertSecretName: corp-client-mtlsTwo notes on the implementation:
Header values are read from Secrets into environment variables and referenced as ${env:...}, so credentials never appear in the rendered ConfigMap. There's a test asserting the secret value is absent from it.
I used caConfigMapName rather than the caSecretName in Rene's sketch — a CA isn't secret, and syslog/splunk already carry user CAs as ConfigMaps. The client cert stays a Secret since it holds a private key. Happy to switch if you'd both rather it were a Secret for symmetry.
The silent no-op you flagged is now a validation error, covering the CA as well as the client cert:
https://github.com/tigera/operator/blob/ba5aeb9d4/api/v1/logcollector_types.go#L340-L350
Deployed and verified on a cluster — logs and metrics both still flowing. The per-exporter TLS and auth paths themselves are unit-tested but not yet exercised against a real TLS backend.
- Hash exporter CA and client TLS in pod annotations so cert rotation triggers a StatefulSet roll - Gate OTel fluent-bit output on osType==Linux, matching other non-Linseed outputs - Mirror IsFeatureActive check in monitor controller so ServiceMonitor is deleted when the license lacks the otel-collector feature - Reject mutualTLS on http:// endpoints instead of silently ignoring the client cert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The ServiceMonitor rendered a target the tigera-prometheus default-deny blocked, so the scrape reported up=0 with nothing naming the cause. Add the matching egress rule under the same gate. Also track the collector's real deploy conditions: requeue on an unreadable LogCollector rather than treating it as disabled, which flapped both resources on an apiserver blip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Default: false | ||
| // +optional | ||
| // +kubebuilder:default=false | ||
| MutualTLS *bool `json:"mutualTLS,omitempty"` |
There was a problem hiding this comment.
I agree with Pasan's feedback in that I think that the TLS and Authn for exporters need more flexibility in their configuration. I think both authentication and transport may be set differently per sink and so each may need their own sub struct for configuration.
From what I understand there are a few ways you may want to configure them:
- mTLS
- TLS with headers (can be bearer token, or other headers like X-SF-Token, DD-API-KEY)
- no TLS (leverage it with a gateway for example)
Perhaps something of this shape would work better:
otelCollector:
exporters:
- name: datadog
endpoint: https://otlp.datadoghq.com:4318
protocol: http
auth:
headers:
- name: DD-API-KEY
valueFrom:
secretKeyRef: { name: otlp-datadog-auth, key: api-key }
tls:
caSecretName: corp-ca
clientCertSecretName: corp-client-mtls # when set, implies tls.
Alternatively, make a transport sub struct and include both tls settings and protocol there.
There was a problem hiding this comment.
Another note is that Boolean fields in the API spec are not idiomatic; we should refrain from using them.
| // Default: Disabled | ||
| // +optional | ||
| // +kubebuilder:default=Disabled | ||
| Enabled *OpenTelemetryMetricsEnabled `json:"enabled,omitempty"` |
There was a problem hiding this comment.
We could end up with Enabled: Disabled, I think that reads a bit weird, maybe look at other instances in our repo for these kinds of flags, I think we use other field names, like state | status | mode.
Trusted bundle: the collector rendered the shared calico-system/tigera-ca-bundle with a CA-only set, which the Installation controller owns and fills with a different certificate set. The component handler replaces ConfigMap data wholesale, so the two overwrote each other every reconcile. Carry our own named bundle, as fluent-bit already does, and add the Prometheus serving certificate so the federation scrape verifies a BYO cert. System roots stay out: this bundle backs the receiver's client_ca_file. Gating: fluent-bit shipped to the collector whenever spec.openTelemetry was set, even when the license or an invalid spec meant it was never deployed. All three controllers now share OpenTelemetrySpec.Deployable, and the monitor controller watches LogCollector so enabling export actually wakes it. Egress: an endpoint with no parseable port emitted no rule at all, so the default-deny silently dropped every export. Fall back to the protocol's default OTLP port. Non-cluster-host flow and DNS records carry their own tags and were never matched, so they reached Linseed but never the OTLP backend. Teardown probed only the StatefulSet, stranding the RBAC, ConfigMap, Service and policy if it had already gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One shared client certificate meant one identity for every backend, and a single CA for all of them. This feature fans out to several external backends that generally want different client certs and different trust anchors, so both move onto the exporter. Each exporter now takes its own tls.caConfigMapName and tls.clientCertSecretName, plus auth.headers for backends that authenticate with a token or a vendor-specific header instead. Header values are read from Secrets and reach the collector as environment variables, so credentials never land in the rendered ConfigMap. The operator gathers the user's per-exporter material into one object per kind, keyed by exporter. The teardown cannot enumerate the spec once the feature is off, so the names it has to delete stay fixed. Drops the mutualTLS boolean, which is not the repo idiom for a toggle, and renames metrics.enabled to metrics.state so the value no longer reads "enabled: Disabled". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Render the OTel Collector as a StatefulSet in
calico-system, configured viaLogCollector.spec.otelCollector.The collector receives logs from fluent-bit via OTLP and optionally federates Prometheus metrics, forwarding both to user-configured OTLP endpoints. It is added to the
LogCollectorCR rather thanAdditionalStoresbecause it is operator-managed infrastructure (StatefulSet, ConfigMap, RBAC, certs) with its own lifecycle, not a pointer to an external system.Release Note
Test plan
EV-6862