Watch
0
0
Fork
You've already forked hyperhive
0

swarm-victoriametrics: own network namespace, gateway over a unix socket

The metrics store's container gets privateNetwork = true with no
interface but loopback. VictoriaMetrics listens on
unix:/run/swarm-victoriametrics/victoriametrics.sock, a host directory
(root:nginx 0770) bind-mounted into the container, and every vhost
location that forwarded to 127.0.0.1:8428 now forwards to that socket.

The store runs as a static user whose primary group is nginx's gid,
with UMask 0007, so the socket it creates is 0770 group nginx. This
relies on VictoriaMetrics >= 1.153.0 honouring the umask (the locked
nixpkgs ships 1.153.0). nixpkgs' readiness loop curls a TCP URL, so
postStart is replaced with the same loop over --unix-socket.

swarm.victoriametrics.port is removed (nothing binds it any more), with
a mkRemovedOptionModule entry, and dropped from swarm-otel's host
port-collision assertion. The container's resolver unit is dropped:
there is no interface to reach the bridge resolver through, and the
store resolves no names.

The collector scrape, the collector push and Grafana's datasource
already reach the store by its published domain, so they are unchanged.

Refs #4815
Refs #3560
This commit is contained in:
atlas 2026-10-14 14:52:31 +02:00 • committed by mara
commit 5e653ab9b5
8 changed files with 139 additions and 59 deletions

View file

@ -168,8 +168,10 @@ Grafana start — see [`secrets.md`](secrets.md).
**Where the data comes from.** The swarm's OTEL collector, below. **Where the data comes from.** The swarm's OTEL collector, below.
Neither container is reachable except through the gateway: both bind Neither container is reachable except through the gateway: each listens
loopback, and VictoriaMetrics authenticates nothing itself. The on a unix socket only the gateway's nginx can open, and VictoriaMetrics
runs in a network namespace of its own with no interface but loopback.
VictoriaMetrics authenticates nothing itself. The
`metrics.<swarm-domain>` vhost forwards only the store's read API (the `metrics.<swarm-domain>` vhost forwards only the store's read API (the
Prometheus query, series, label and metadata routes, plus the vmui web UI) Prometheus query, series, label and metadata routes, plus the vmui web UI)
without a credential. It forwards the collector's OTLP push, and the without a credential. It forwards the collector's OTLP push, and the
@ -206,9 +208,8 @@ swarm.
| `swarm.victorialogs.domain` | Only to rename it. | | `swarm.victorialogs.domain` | Only to rename it. |
| `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. | | `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. |
Like the metrics store, it binds loopback and takes no credential of its It binds loopback and takes no credential of its own: the gateway vhost is
own: the gateway vhost is the only way in, and the collector is the only the only way in, and the collector is the only intended writer.
intended writer.
The collector also scrapes this store's own metrics, at The collector also scrapes this store's own metrics, at
`https://<swarm.victorialogs.domain>/metrics`, so a collector on a host `https://<swarm.victorialogs.domain>/metrics`, so a collector on a host

View file

@ -80,6 +80,11 @@ in
[ "services" "hyperhive" "swarm" "victoriametrics" "enable" ] [ "services" "hyperhive" "swarm" "victoriametrics" "enable" ]
[ "services" "hyperhive" "deploy" "victoriametrics" "enable" ] [ "services" "hyperhive" "deploy" "victoriametrics" "enable" ]
) )
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "victoriametrics" "port" ] ''
VictoriaMetrics listens on a unix socket in a network namespace of its
own, and the gateway reaches it there, so it binds no host port. Remove
this definition.
'')
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "services" "hyperhive" "swarm" "victorialogs" "enable" ] [ "services" "hyperhive" "swarm" "victorialogs" "enable" ]
[ "services" "hyperhive" "deploy" "victorialogs" "enable" ] [ "services" "hyperhive" "deploy" "victorialogs" "enable" ]

View file

@ -757,8 +757,7 @@ in
cfg.producerPort cfg.producerPort
cfg.storeProducerPort cfg.storeProducerPort
otelCfg.collector.port otelCfg.collector.port
] ];
++ lib.optional deployCfg.victoriametrics.enable vmCfg.port;
all = derived ++ others; all = derived ++ others;
in in
lib.length (lib.unique all) == lib.length all; lib.length (lib.unique all) == lib.length all;

View file

@ -455,8 +455,7 @@ in
enable = true; enable = true;
package = deployCfg.victorialogs.package; package = deployCfg.victorialogs.package;
# ⚠️ PINNED TO LOOPBACK for the same reason the metrics store is, # ⚠️ PINNED TO LOOPBACK: upstream's default listens on every
# and it matters more here: upstream's default listens on every
# interface, and this endpoint accepts writes as well as reads. # interface, and this endpoint accepts writes as well as reads.
# Nothing authenticates it either — upstream offers one basic-auth # Nothing authenticates it either — upstream offers one basic-auth
# pair (`-httpAuth.username` / `-httpAuth.password`) and this # pair (`-httpAuth.username` / `-httpAuth.password`) and this

View file

@ -1,5 +1,5 @@
# The swarm's metrics store as every hive sees it: the name it answers on and # The swarm's metrics store as every hive sees it: the name it answers on,
# its port, identical on every host. What the host running it decides, and the # identical on every host. What the host running it decides, and the
# container itself, are in ./swarm-victoriametrics.nix. # container itself, are in ./swarm-victoriametrics.nix.
{ {
lib, lib,
@ -16,8 +16,8 @@ let
domainBase = if swarmDomain == null then "invalid" else swarmDomain; domainBase = if swarmDomain == null then "invalid" else swarmDomain;
in in
{ {
# What the store IS from any hive's point of view: the name it answers on and # What the store IS from any hive's point of view: the name it answers on.
# the port. `enable`, `package` and `retentionPeriod` are decisions of the # `enable`, `package` and `retentionPeriod` are decisions of the
# host that runs it and live under `deploy.*`. # host that runs it and live under `deploy.*`.
options.services.hyperhive.swarm.victoriametrics = { options.services.hyperhive.swarm.victoriametrics = {
machine = lib.mkOption { machine = lib.mkOption {
@ -42,18 +42,5 @@ in
child decides which CA may sign it. child decides which CA may sign it.
''; '';
}; };
port = lib.mkOption {
type = lib.types.port;
default = 8428;
description = ''
Port VictoriaMetrics listens on, bound to loopback only on the
host that runs it: its write endpoint is unauthenticated, so the
gateway's vhost there is the only route to it. Upstream's own
default, kept so an operator reading VictoriaMetrics
documentation finds what they expect.
'';
};
}; };
} }

View file

@ -18,13 +18,23 @@
let let
cfg = config.services.hyperhive.swarm.victoriametrics; cfg = config.services.hyperhive.swarm.victoriametrics;
deployCfg = config.services.hyperhive.deploy; deployCfg = config.services.hyperhive.deploy;
networkCfg = config.services.hyperhive.network;
hyperhiveCfg = config.services.hyperhive; hyperhiveCfg = config.services.hyperhive;
gatewayCfg = hyperhiveCfg.gateway; gatewayCfg = hyperhiveCfg.gateway;
# Shared host netns, like every sibling swarm container: the gateway # A netns of its own with no interface but loopback: the store makes no
# reaches this at 127.0.0.1:<port>. # outbound calls, and the gateway reaches it over `socketPath` below, so
privateNetwork = false; # nothing on the host or the hive bridge can dial its listener.
privateNetwork = true;
# Fixed, not an option: the directory's mode is what keeps the socket
# private, so it holds this one socket and nothing else.
socketDir = "/run/swarm-victoriametrics";
socketPath = "${socketDir}/victoriametrics.sock";
upstream = path: "http://unix:${socketPath}:${path}";
# The gid the gateway's nginx runs as. Only the number crosses into the
# container, which knows it as `gateway-nginx`.
nginxGid = config.ids.gids.nginx;
# The store's read API: the only part of it the vhost serves without a # The store's read API: the only part of it the vhost serves without a
# credential. Each location's `proxy_pass` carries the path, so the store # credential. Each location's `proxy_pass` carries the path, so the store
@ -64,7 +74,7 @@ let
]; ];
readLocations = readLocations =
let let
to = path: { proxyPass = "http://127.0.0.1:${toString cfg.port}${path}"; }; to = path: { proxyPass = upstream path; };
in in
lib.listToAttrs ( lib.listToAttrs (
map (p: lib.nameValuePair "= ${p}" (to p)) readPaths map (p: lib.nameValuePair "= ${p}" (to p)) readPaths
@ -72,8 +82,8 @@ let
); );
in in
{ {
# What the store IS from any hive's point of view, the name it answers on and # What the store IS from any hive's point of view, the name it answers on,
# the port, is `swarm.victoriametrics` in ./swarm-victoriametrics-service.nix. # is `swarm.victoriametrics` in ./swarm-victoriametrics-service.nix.
# `enable`, `package` and `retentionPeriod` are decisions of the host that # `enable`, `package` and `retentionPeriod` are decisions of the host that
# runs it and live under `deploy.*`. # runs it and live under `deploy.*`.
@ -111,8 +121,7 @@ in
# have. # have.
services.hyperhive.gateway.localNames = [ cfg.domain ]; services.hyperhive.gateway.localNames = [ cfg.domain ];
# This host serves the vhost, and the container behind it resolves # This host serves the vhost, and the hive's dnsmasq answers its name.
# through the hive's dnsmasq.
services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.enable = lib.mkDefault true;
services.hyperhive.gateway.dns.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true;
@ -138,7 +147,7 @@ in
# follow it and POST its batch at a login page that answers 200 — # follow it and POST its batch at a login page that answers 200 —
# ingest reporting healthy while storing nothing. # ingest reporting healthy while storing nothing.
"= /opentelemetry/api/v1/push" = { "= /opentelemetry/api/v1/push" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/opentelemetry/api/v1/push"; proxyPass = upstream "/opentelemetry/api/v1/push";
extraConfig = '' extraConfig = ''
auth_request /__metrics_push_authz; auth_request /__metrics_push_authz;
''; '';
@ -153,7 +162,7 @@ in
# `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix; # `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix;
# the two must agree, since that string is also the token's audience. # the two must agree, since that string is also the token's audience.
"= /metrics" = { "= /metrics" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics"; proxyPass = upstream "/metrics";
extraConfig = '' extraConfig = ''
auth_request /__metrics_push_authz; auth_request /__metrics_push_authz;
''; '';
@ -188,6 +197,17 @@ in
}; };
}; };
# The socket directory, created host-side before the container starts:
# nixos-container refuses to start when a bind source is missing.
#
# Root-owned and group nginx, `0770`: the store creates its socket here
# through the group (its uid is allocated inside the container, so the
# host cannot name it), and the gateway traverses it the same way.
# Everything else is kept out.
systemd.tmpfiles.rules = [
"d ${socketDir} 0770 root ${toString nginxGid} - -"
];
containers.${cfg.machine} = { containers.${cfg.machine} = {
autoStart = true; autoStart = true;
ephemeral = false; ephemeral = false;
@ -196,31 +216,62 @@ in
extraFlags = [ "--link-journal=host" ]; extraFlags = [ "--link-journal=host" ];
inherit privateNetwork; inherit privateNetwork;
config = # A bind mount rather than a path both sides happen to know: a
{ ... }: # container's `/run` is an nspawn tmpfs the host cannot see into.
{ bindMounts.${socketDir} = {
imports = [ hostPath = socketDir;
../container-modules/swarm-container.nix isReadOnly = false;
(import ../container-modules/swarm-container-resolver.nix { };
inherit (networkCfg) bridgeIp;
dnsConsumers = [ "victoriametrics.service" ];
})
];
services.hyperhive.swarmContainer = { inherit privateNetwork; }; config =
{ pkgs, ... }:
{
imports = [ ../container-modules/swarm-container.nix ];
# No resolver unit: with no interface but loopback there is no
# nameserver to reach, and the store resolves no names.
services.hyperhive.swarmContainer = {
inherit privateNetwork;
writesOwnResolvConf = false;
};
# The socket's group is the store's primary group, so that group is
# nginx's gid. A static user, not nixpkgs' DynamicUser: a dynamic
# user's group is allocated per start, and nginx is never in it.
users.groups.gateway-nginx.gid = nginxGid;
users.users.victoriametrics = {
isSystemUser = true;
group = "gateway-nginx";
};
systemd.services.victoriametrics = {
serviceConfig = {
DynamicUser = lib.mkForce false;
User = "victoriametrics";
Group = "gateway-nginx";
# The store creates its socket under the process umask, so
# `0007` makes it `0770` and nginx's group can connect.
UMask = "0007";
};
# nixpkgs' readiness loop curls `http://<listenAddress>/ping`,
# which no `unix:` address answers.
postStart = lib.mkForce ''
until ${lib.getExe pkgs.curl} -s -o /dev/null --unix-socket ${socketPath} http://localhost/ping; do
sleep 1;
done
'';
};
services.victoriametrics = { services.victoriametrics = {
enable = true; enable = true;
package = deployCfg.victoriametrics.package; package = deployCfg.victoriametrics.package;
retentionPeriod = deployCfg.victoriametrics.retentionPeriod; retentionPeriod = deployCfg.victoriametrics.retentionPeriod;
# ⚠️ PINNED TO LOOPBACK, and this is a correction rather than a # ⚠️ A socket, not upstream's default `:8428` on every interface:
# preference: upstream's default is `:8428`, i.e. every # the gateway is the only intended client, and this listener
# interface. The gateway is the only intended client and it is on # carries an unauthenticated write endpoint (see the OTLP note
# this host, so binding wider would publish an unauthenticated # below).
# write endpoint (see the OTLP note below) to whatever the host listenAddress = "unix:${socketPath}";
# is reachable on.
listenAddress = "127.0.0.1:${toString cfg.port}";
}; };
}; };
}; };
@ -235,8 +286,8 @@ in
# deployment ever needs them. # deployment ever needs them.
# #
# ⚠️ That endpoint is unauthenticated, which is why `listenAddress` above is # ⚠️ That endpoint is unauthenticated, which is why `listenAddress` above is
# loopback: the only route to it from off-host is the vhost's ingest # a socket only the gateway can reach: the only route to it is the vhost's
# location, which authenticates. The collector is still the only writer, but # ingest location, which authenticates. The collector is still the only writer, but
# it now arrives by the swarm name rather than over loopback, because it need # it now arrives by the swarm name rather than over loopback, because it need
# not share a host with this store. # not share a host with this store.
} }

View file

@ -282,7 +282,7 @@ let
m = locs."= /metrics" or null; m = locs."= /metrics" or null;
in in
m != null m != null
&& m.proxyPass == "http://127.0.0.1:${toString h.swarm.victoriametrics.port}/metrics" && m.proxyPass == "http://unix:/run/swarm-victoriametrics/victoriametrics.sock:/metrics"
&& lib.hasInfix "auth_request /__metrics_push_authz;" m.extraConfig && lib.hasInfix "auth_request /__metrics_push_authz;" m.extraConfig
&& !(lib.hasInfix "error_page" m.extraConfig) && !(lib.hasInfix "error_page" m.extraConfig)
&& lib.hasInfix "internal;" locs."= /__metrics_push_authz".extraConfig && lib.hasInfix "internal;" locs."= /__metrics_push_authz".extraConfig

View file

@ -161,8 +161,8 @@ let
lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links; lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links;
# Every service container at once: the services-only host plus the CI # Every service container at once: the services-only host plus the CI
# runner. The CI runner and the review classifier are the two containers on # runner. The CI runner, the review classifier and the metrics store are the
# a netns of their own. # three containers on a netns of their own.
serviceContainersWithCi = hive { serviceContainersWithCi = hive {
deploy.hive-controller.enable = false; deploy.hive-controller.enable = false;
deploy.allSwarmServices = true; deploy.allSwarmServices = true;
@ -484,7 +484,7 @@ let
let let
shared = lib.filter (c: !c.privateNetwork) (lib.attrValues serviceContainers); shared = lib.filter (c: !c.privateNetwork) (lib.attrValues serviceContainers);
in in
lib.length shared == 9 lib.length shared == 8
&& lib.all ( && lib.all (
c: !c.config.networking.firewall.enable && !c.config.networking.resolvconf.enable c: !c.config.networking.firewall.enable && !c.config.networking.resolvconf.enable
) shared; ) shared;
@ -499,6 +499,44 @@ let
in in
c.networking.firewall.enable && c.networking.resolvconf.enable; c.networking.firewall.enable && c.networking.resolvconf.enable;
} }
{
# No interface but loopback, so the socket is the store's only listener
# the host can reach. Every location that forwards to the store goes
# through it; the authelia subrequest is the one `https://` upstream.
# The read, push and scrape locations are named so an empty filter
# can't pass.
name = "the metrics store runs on a netns of its own and the gateway reaches it over its socket";
ok =
let
host = serviceContainersWithCi;
c = host.containers.swarm-victoriametrics;
dir = "/run/swarm-victoriametrics";
sock = "${dir}/victoriametrics.sock";
viaSock = path: "http://unix:${sock}:${path}";
locs =
host.services.nginx.virtualHosts.${host.services.hyperhive.swarm.victoriametrics.domain}.locations;
toStore = lib.filter (p: p != null && !(lib.hasPrefix "https://" p)) (
map (l: l.proxyPass) (lib.attrValues locs)
);
unit = c.config.systemd.services.victoriametrics;
nginxGid = host.ids.gids.nginx;
in
c.privateNetwork
&& c.hostBridge == null
&& c.hostAddress == null
&& c.config.services.victoriametrics.listenAddress == "unix:${sock}"
&& locs."= /api/v1/query".proxyPass == viaSock "/api/v1/query"
&& locs."= /opentelemetry/api/v1/push".proxyPass == viaSock "/opentelemetry/api/v1/push"
&& locs."= /metrics".proxyPass == viaSock "/metrics"
&& lib.all (lib.hasPrefix (viaSock "")) toStore
&& c.bindMounts.${dir}.hostPath == dir
&& builtins.elem "d ${dir} 0770 root ${toString nginxGid} - -" host.systemd.tmpfiles.rules
&& c.config.users.groups.gateway-nginx.gid == nginxGid
&& unit.serviceConfig.Group == "gateway-nginx"
&& !unit.serviceConfig.DynamicUser
&& unit.serviceConfig.UMask == "0007"
&& lib.hasInfix "--unix-socket ${sock}" unit.postStart;
}
{ {
name = "a service container that sets no stateVersion of its own is on 26.05"; name = "a service container that sets no stateVersion of its own is on 26.05";
ok = serviceContainers.swarm-victoriametrics.config.system.stateVersion == "26.05"; ok = serviceContainers.swarm-victoriametrics.config.system.stateVersion == "26.05";