Gate systemd consumers on actual service readiness¶
Modules
After= orders unit startup; it does not prove that a dependency is ready.
systemd considers a Type=simple service started as soon as its process forks,
often before migrations, warmup, socket binding, or an HTTP health check finish.
This module turns a command, TCP connection, or HTTP request into a oneshot
<name>-ready.service that dependent units can safely require.
Use¶
{
imports = [ inputs.recipes.nixosModules.service-readiness-gate ];
modules.services.readiness-gates = {
garage = {
http = "http://127.0.0.1:3903/health";
after = [ "garage.service" ];
requiredBy = [ "garage-init.service" ];
timeoutSeconds = 120;
};
};
}
Each gate must set exactly one probe:
command: a shell command that exits zero when ready;tcp: a{ host, port }connection check;http: a URL that must return a successful HTTP status.
Setting zero or several probe types fails evaluation with the gate's name.
Contract¶
For a gate named foo, the module:
- creates
foo-ready.serviceas aType=oneshotunit; - orders and requires its own dependencies through
afterandrequires; - adds
Requires=foo-ready.serviceandAfter=foo-ready.serviceto every unit named byrequiredBy; - keeps the successful gate active with
RemainAfterExit=true.
requiredBy accepts names with or without further unit configuration; the
module removes a .service suffix when extending systemd.services.
Several gates may guard the same consumer. Their dependency lists accumulate:
the implementation uses lib.mkMerge, not recursive-update-by-accident.
Failure behavior¶
When timeoutSeconds expires, the gate fails and its required consumers remain
stopped. This is intentional: one failed readiness unit is more diagnosable than
a consumer restarting forever against a dependency that never became usable.
Probe attempts repeat every intervalSeconds; the default is one second.
Traps this avoids¶
After= is not a health check¶
Ordering against the raw service only waits for systemd's start transition. It does not wait for the daemon to accept connections.
Exactly one probe keeps failures legible¶
Implicit precedence between command, tcp, and http would make a typo look
like a successful configuration. Ambiguous gates fail during evaluation.
TCP probing needs Bash¶
The TCP probe uses Bash's /dev/tcp support under a short timeout. The generated
waiter carries Bash, curl, and coreutils explicitly and accepts extraPackages
for custom command probes.
Testing¶
test.nix is a NixOS VM test. Run it directly:
```console
$ nix-build test.nix --arg pkgs 'import
Source¶
modules/service-readiness-gate/default.nix
```nix { config, lib, pkgs, ... }: let cfg = config.modules.services.readiness-gates;
inherit (lib) mkOption mkIf types mapAttrs' nameValuePair ;
gateUnit = name: "${name}-ready.service";
probeCommand = name: gate: let set = lib.filter (p: p != null) [ (if gate.command != null then "command" else null) (if gate.tcp != null then "tcp" else null) (if gate.http != null then "http" else null) ]; in if lib.length set != 1 then throw "readiness gate '${name}': set exactly one of command, tcp or http (got ${toString (lib.length set)})" else if gate.command != null then gate.command else if gate.tcp != null then "timeout 2 bash -c 'exec 3<>/dev/tcp/${gate.tcp.host}/${toString gate.tcp.port}'" else "curl -sf -o /dev/null --max-time 5 ${lib.escapeShellArg gate.http}";
waitScript = name: gate: pkgs.writeShellApplication { name = "wait-for-${name}"; runtimeInputs = [ pkgs.coreutils pkgs.bash pkgs.curl ] ++ gate.extraPackages; text = '' set -euo pipefail
deadline=$(( SECONDS + ${toString gate.timeoutSeconds} ))
until ${probeCommand name gate}; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "readiness gate '${name}': not ready after ${toString gate.timeoutSeconds}s" >&2
exit 1
fi
sleep ${toString gate.intervalSeconds}
done
echo "readiness gate '${name}': ready"
'';
};
gateOptions = types.submodule ( { name, ... }: { options = { command = mkOption { type = types.nullOr types.str; default = null; example = "pg_isready -q"; description = "Shell command that exits 0 once the dependency is ready."; };
tcp = mkOption {
type = types.nullOr (
types.submodule {
options = {
host = mkOption {
type = types.str;
default = "127.0.0.1";
};
port = mkOption { type = types.port; };
};
}
);
default = null;
description = "Wait until this TCP port accepts a connection.";
};
http = mkOption {
type = types.nullOr types.str;
default = null;
example = "http://127.0.0.1:3903/health";
description = "Wait until this URL answers with a success status.";
};
timeoutSeconds = mkOption {
type = types.ints.positive;
default = 120;
description = ''
Give up after this long. The gate then fails, which is the point:
a dependent held back by a failed gate is far easier to diagnose
than one restarting forever against something that never arrived.
'';
};
intervalSeconds = mkOption {
type = types.ints.positive;
default = 1;
description = "Seconds between probe attempts.";
};
after = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "postgresql.service" ];
description = "Units the gate itself is ordered after.";
};
requires = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Units the gate pulls in and depends on.";
};
requiredBy = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "garage-init.service" ];
description = ''
Units that must not start until this gate has passed. Each gets
`Requires=` and `After=` the gate, so a failed probe blocks them
rather than letting them start against an unready dependency.
'';
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [ ];
description = "Extra packages on the probe's PATH.";
};
wantedBy = mkOption {
type = types.listOf types.str;
default = [ "multi-user.target" ];
description = "Targets that pull the gate in.";
};
};
}
); in { options.modules.services.readiness-gates = mkOption { type = types.attrsOf gateOptions; default = { }; description = '' Readiness gates for units whose dependencies are ready some time after systemd considers them started.
`After=` orders starts, not readiness: a Type=simple service counts as
started the moment it forks, which is typically well before it accepts
connections. Anything ordered only with `After=` therefore races it, and
the usual symptom is a dependent that restarts forever, or worse, a setup
step that silently does nothing and still reports success.
A gate is a oneshot that polls until the dependency actually answers, so
dependents can take a hard `Requires=` on something meaningful.
'';
example = lib.literalExpression ''
{
garage = {
http = "http://127.0.0.1:3903/health";
after = [ "garage.service" ];
requiredBy = [ "garage-init.service" ];
};
}
'';
};
config = mkIf (cfg != { }) { # mkMerge, not //: several gates may guard the same consumer, and their # Requires=/After= lists have to accumulate rather than overwrite. systemd.services = lib.mkMerge ( [ (mapAttrs' ( name: gate: nameValuePair "${name}-ready" { description = "Wait until ${name} is ready"; inherit (gate) after requires wantedBy; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = lib.getExe (waitScript name gate); }; } ) cfg) ] ++ lib.flatten ( lib.mapAttrsToList ( name: gate: map (consumer: { ${lib.removeSuffix ".service" consumer} = { requires = [ (gateUnit name) ]; after = [ (gateUnit name) ]; }; }) gate.requiredBy ) cfg ) ); }; } ```