feat(#3125): a swarm-tier OTEL collector, in its own container
The swarm tier is the only holder of the upstream credential, the only writer to the swarm's metrics store, and (once #3283 lands) the place that stamps hive= from the authenticated connection rather than from anything a sender can choose. Today one collector does both tiers' jobs, which works only because they land on one box. A container rather than a second host unit, for two reasons that agree: every sibling swarm service is one, and `services.opentelemetry-collector` is a singleton NixOS option already spoken for on the host by the hive tier. A container gets its own evaluation and therefore its own collector. Port defaults to 4319, deliberately not the OTLP default 4318 the hive tier uses: swarm containers share the host netns, and two listeners claiming one port is not a build failure but a runtime coin toss with nothing in any log saying so -- the same collision grafana and the forge hit on 3000. `url` is an option with a co-located default rather than a loopback literal in the exporter, so a split-host deployment is a config change instead of a code change. Wires nothing yet: the hive tier still exports directly, and switching it over is the next commit.
This commit is contained in:
parent
5fdbf0b452
commit
f61c927310
2 changed files with 214 additions and 0 deletions
|
|
@ -27,6 +27,7 @@
|
||||||
./swarm-nats.nix
|
./swarm-nats.nix
|
||||||
./swarm-controller.nix
|
./swarm-controller.nix
|
||||||
./swarm-grafana.nix
|
./swarm-grafana.nix
|
||||||
|
./swarm-otel.nix
|
||||||
./swarm-snapshot-store.nix
|
./swarm-snapshot-store.nix
|
||||||
./swarm-ui.nix
|
./swarm-ui.nix
|
||||||
./swarm-victoriametrics.nix
|
./swarm-victoriametrics.nix
|
||||||
|
|
|
||||||
213
nix/host-modules/swarm-otel.nix
Normal file
213
nix/host-modules/swarm-otel.nix
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
# The swarm's telemetry collector: one per swarm, in a `swarm-otel`
|
||||||
|
# nixos-container beside the swarm's other shared services.
|
||||||
|
#
|
||||||
|
# Two tiers, and they are separate on purpose:
|
||||||
|
#
|
||||||
|
# - `otel.nix` is the **hive** tier. It receives from this hive's agents
|
||||||
|
# on the bridge and forwards, and it holds no upstream credential.
|
||||||
|
# - this is the **swarm** tier. It is the only holder of the upstream
|
||||||
|
# credential, the only writer to the swarm's metrics store, and (once
|
||||||
|
# #3283 lands) the place that stamps `hive=` from the authenticated
|
||||||
|
# connection rather than from anything a sender can choose.
|
||||||
|
#
|
||||||
|
# On a host that runs both, both processes run. They are not collapsed:
|
||||||
|
# all-local is a statement about *where* processes run, not about what
|
||||||
|
# shape the deployment has, and a local tier boundary that disappears is
|
||||||
|
# one the local deployment stops testing. `hive=` attribution is the
|
||||||
|
# property that would differ, and it is the one #3283 depends on.
|
||||||
|
#
|
||||||
|
# A container rather than a second host unit, for the same reason every
|
||||||
|
# sibling swarm service is one — and because `services.opentelemetry-collector`
|
||||||
|
# is a singleton NixOS option, already spoken for on the host by the hive
|
||||||
|
# tier. A container gets its own evaluation and therefore its own collector.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.services.hyperhive.swarm.otel;
|
||||||
|
swarmCfg = config.services.hyperhive.swarm;
|
||||||
|
otelCfg = config.services.hyperhive.otel;
|
||||||
|
vmCfg = config.services.hyperhive.swarm.victoriametrics;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.hyperhive.swarm.otel = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = swarmCfg.enableRequiredServices;
|
||||||
|
defaultText = lib.literalExpression "services.hyperhive.swarm.enableRequiredServices";
|
||||||
|
description = ''
|
||||||
|
Run the swarm's telemetry collector on this host.
|
||||||
|
|
||||||
|
Derived from `swarm.enableRequiredServices` like the swarm's other
|
||||||
|
shared services: a swarm has one of these, and it belongs wherever
|
||||||
|
the shared services live rather than on every hive.
|
||||||
|
|
||||||
|
A hive that does not run it still runs its own hive-tier collector
|
||||||
|
(`services.hyperhive.otel.enable`) and points it here with
|
||||||
|
{option}`services.hyperhive.swarm.otel.url`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
machine = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "swarm-otel";
|
||||||
|
description = ''
|
||||||
|
Name of the nixos-container this collector runs in — also the
|
||||||
|
`machinectl` name, so other modules may read it rather than
|
||||||
|
repeating the literal.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4319;
|
||||||
|
description = ''
|
||||||
|
Port this collector's OTLP/HTTP receiver listens on.
|
||||||
|
|
||||||
|
⚠️ **Deliberately not 4318**, the OTLP/HTTP default, because the
|
||||||
|
hive tier already uses it (`services.hyperhive.otel.collector.port`)
|
||||||
|
and every swarm container shares the host's network namespace. Two
|
||||||
|
listeners claiming one port on one host is not a build failure —
|
||||||
|
it is a runtime coin toss over which one gets it, with nothing in
|
||||||
|
any log saying so. The same collision cost a release when grafana
|
||||||
|
and the forge both defaulted to 3000.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
url = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "http://127.0.0.1:${toString cfg.port}";
|
||||||
|
defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString config.services.hyperhive.swarm.otel.port}"'';
|
||||||
|
description = ''
|
||||||
|
Where the **hive** tier sends what it receives — this collector's
|
||||||
|
OTLP/HTTP base URL.
|
||||||
|
|
||||||
|
The default addresses it on loopback, which is correct while the
|
||||||
|
two tiers share a host: every swarm container runs in the host's
|
||||||
|
network namespace, so a swarm service is reachable there exactly
|
||||||
|
as the metrics store already is.
|
||||||
|
|
||||||
|
⚠️ That default is a *default*, not an assumption baked into the
|
||||||
|
exporter. A hive whose swarm collector runs elsewhere sets this to
|
||||||
|
that host's address, and nothing else changes — a loopback literal
|
||||||
|
written directly into the exporter would have made the split-host
|
||||||
|
case a code change instead of a config one.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
# The tier exists to hold the upstream credential and to write the
|
||||||
|
# swarm's store. With neither, it is a process that receives
|
||||||
|
# samples and drops them — which looks healthy and loses data.
|
||||||
|
assertion = otelCfg.endpoint != "" || vmCfg.enable;
|
||||||
|
message = ''
|
||||||
|
services.hyperhive.swarm.otel.enable is true but this collector
|
||||||
|
has nowhere to send what it receives:
|
||||||
|
services.hyperhive.otel.endpoint is empty and
|
||||||
|
services.hyperhive.swarm.victoriametrics.enable is false.
|
||||||
|
|
||||||
|
Set the endpoint to export upstream, or enable the swarm's
|
||||||
|
metrics store.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
containers.${cfg.machine} = {
|
||||||
|
autoStart = true;
|
||||||
|
ephemeral = false;
|
||||||
|
# Shared host netns, like every sibling swarm service: the hive tier
|
||||||
|
# reaches this collector, and this collector reaches the metrics
|
||||||
|
# store, without either crossing a network boundary that would need
|
||||||
|
# its own trust material.
|
||||||
|
privateNetwork = false;
|
||||||
|
|
||||||
|
# The upstream credential is operator-provided and lives on the host.
|
||||||
|
# Read-only, and only when one is configured — binding a path that
|
||||||
|
# does not exist makes nixos-container refuse to start the container,
|
||||||
|
# which is a stall several layers from its cause.
|
||||||
|
bindMounts = lib.optionalAttrs (otelCfg.headersCredential != null) {
|
||||||
|
${otelCfg.headersCredential} = {
|
||||||
|
hostPath = otelCfg.headersCredential;
|
||||||
|
isReadOnly = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config =
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
system.stateVersion = config.system.stateVersion;
|
||||||
|
networking.firewall.enable = false;
|
||||||
|
# Keep the host-copied /etc/resolv.conf intact — same reasoning
|
||||||
|
# as the sibling swarm containers.
|
||||||
|
networking.resolvconf.enable = lib.mkForce false;
|
||||||
|
|
||||||
|
services.opentelemetry-collector = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.opentelemetry-collector-contrib;
|
||||||
|
# Runs `otelcol validate` at build time. ⚠️ A parser, not a
|
||||||
|
# wiring check: it accepts a receiver naming an absent
|
||||||
|
# extension, and the collector then dies at startup. A green
|
||||||
|
# build does not prove this config starts, never mind that a
|
||||||
|
# sample arrives — which is why this module's gate pushes a
|
||||||
|
# real sample through both tiers into the store.
|
||||||
|
validateConfigFile = true;
|
||||||
|
settings = {
|
||||||
|
receivers.otlp.protocols.http.endpoint = "127.0.0.1:${toString cfg.port}";
|
||||||
|
|
||||||
|
exporters =
|
||||||
|
lib.optionalAttrs vmCfg.enable {
|
||||||
|
# `metrics_endpoint`, NOT `endpoint`: the latter is a
|
||||||
|
# base that otlphttp appends `/v1/metrics` to, while
|
||||||
|
# VictoriaMetrics serves OTLP at
|
||||||
|
# `/opentelemetry/api/v1/push`. With `endpoint` the
|
||||||
|
# collector answers 200 to its own clients and posts the
|
||||||
|
# samples to a path that does not exist. Measured
|
||||||
|
# end-to-end, not read — `state/probe-3265-collector-to-vm.sh`.
|
||||||
|
"otlphttp/victoriametrics".metrics_endpoint =
|
||||||
|
"http://127.0.0.1:${toString vmCfg.port}/opentelemetry/api/v1/push";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (otelCfg.endpoint != "") {
|
||||||
|
${if otelCfg.protocol == "grpc" then "otlp" else "otlphttp"} = {
|
||||||
|
endpoint = otelCfg.endpoint;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (otelCfg.headersCredential != null) {
|
||||||
|
# Interpolated by the collector from its environment at
|
||||||
|
# runtime, never by nix: `EnvironmentFile` below is what
|
||||||
|
# puts it there, so the value is not read into the store.
|
||||||
|
headers.${otelCfg.collector.upstreamHeaderName} = "\${env:${otelCfg.collector.upstreamHeaderName}}";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (otelCfg.protocol == "http/json") { encoding = "json"; };
|
||||||
|
};
|
||||||
|
|
||||||
|
service.pipelines.metrics = {
|
||||||
|
receivers = [ "otlp" ];
|
||||||
|
# Fan-out, not a choice: with both configured the same
|
||||||
|
# samples go upstream AND into the swarm's store. The store
|
||||||
|
# is for looking at this swarm; the upstream is for whoever
|
||||||
|
# aggregates across swarms, and neither replaces the other.
|
||||||
|
exporters =
|
||||||
|
lib.optional (otelCfg.endpoint != "") (if otelCfg.protocol == "grpc" then "otlp" else "otlphttp")
|
||||||
|
++ lib.optional vmCfg.enable "otlphttp/victoriametrics";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# The credential file is already `NAME=value`, systemd's
|
||||||
|
# EnvironmentFile format — so the secret reaches the process as an
|
||||||
|
# environment variable without being read by nix, written to the
|
||||||
|
# store, or passed in argv.
|
||||||
|
systemd.services.opentelemetry-collector.serviceConfig =
|
||||||
|
lib.optionalAttrs (otelCfg.headersCredential != null)
|
||||||
|
{
|
||||||
|
EnvironmentFile = otelCfg.headersCredential;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue