fix(elbv2): make NLB nginx health-check pathway robust in CI

E2E NLB suites timed out at "0/2 healthy" while ALB suites on the same
target instances went healthy in ~5s. The host daemon journal shows the
NLB agent reporting an empty server list (no probe results at all), which
happens when the lb-agent never enters nginx mode — i.e. `nginx -t`/start
fails closed inside the LB microVM, so the agent never probes and reports
nothing. The data plane itself is verified working; this is purely the
active-health-check pathway failing to converge in CI.

- lbagent: pre-create nginx runtime prefix dirs (pid dir + /var/lib/nginx,
  /var/lib/nginx/tmp, /var/log/nginx) before `nginx -t`, mirroring
  reloadHAProxy's socket-dir pre-create. A stripped Alpine initramfs may
  lack these, dead-ending the whole NLB health path.
- build-microvm-image.sh: assert the nginx binary and ngx_stream_module.so
  are present in the image and fail the build loudly, instead of surfacing
  as an opaque e2e health timeout.
- e2e: capture the LB VM serial console to the artifact bundle on NLB
  health-wait failure. The agent's engine activation and any `reload nginx:`
  error land on the guest console, which the host journal never sees.
This commit is contained in:
joshsiv-mulga
2026-06-05 08:14:18 +10:00
parent 554c5d96c7
commit 8e38faaede
4 changed files with 75 additions and 0 deletions
+15
View File
@@ -173,6 +173,21 @@ else
echo "[build-microvm-image] fw_cfg: module found in lib/modules"
fi
# --- Assert nginx + stream module (NLB L4 data plane) ---
# The lb-agent runs `nginx -c` with a config that `load_module`s the stream
# module; if either the binary or the .so is missing the agent never enters
# nginx mode, never probes, and NLB targets stay at 0/N healthy. Fail the build
# loudly here rather than discover it as an opaque e2e health timeout.
if [ ! -x "$CHROOT_DIR/usr/sbin/nginx" ] && [ ! -x "$CHROOT_DIR/usr/bin/nginx" ]; then
echo "ERROR: nginx binary missing from image — NLB data plane will not start" >&2
exit 1
fi
if ! find "$CHROOT_DIR/usr/lib/nginx/modules" -name "ngx_stream_module.so" 2>/dev/null | grep -q .; then
echo "ERROR: ngx_stream_module.so missing from image — NLB stream config fails to load" >&2
exit 1
fi
echo "[build-microvm-image] nginx: binary + stream module present"
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
+9
View File
@@ -413,6 +413,15 @@ func reloadHAProxy(configPath, pidPath string) error {
// `nginx -t` fails closed before any reload, mirroring HAProxy's
// validate-before-bind guarantee (a bad config never replaces a good one).
func reloadNginx(configPath, pidPath string) error {
// On a stripped Alpine microvm initramfs the nginx runtime prefix dirs may
// be absent, so `nginx -t`/start fails closed on a missing pid dir or temp
// path — and the agent never enters nginx mode, never probes, and reports
// empty health (NLB targets stuck at 0/N). Pre-create them, mirroring
// reloadHAProxy's socket-dir pre-create.
for _, dir := range []string{filepath.Dir(pidPath), "/var/lib/nginx", "/var/lib/nginx/tmp", "/var/log/nginx"} {
_ = os.MkdirAll(dir, 0o750)
}
test := exec.Command("nginx", "-t", "-c", configPath)
if out, err := test.CombinedOutput(); err != nil {
return fmt.Errorf("nginx -t: %s: %w", strings.TrimSpace(string(out)), err)
+26
View File
@@ -92,6 +92,32 @@ func dumpConsoleOutput(t *testing.T, c *AWSClient, instanceID string) {
}
}
// DumpInstanceConsole writes an instance's full serial console (base64-decoded)
// to <dir>/<name>. The lb-agent logs its data-plane engine activation and any
// `reload nginx:` error to the guest console, which the host daemon journal
// never sees — capturing it makes an NLB health-check timeout diagnosable from
// CI artifacts alone. Best-effort: failures log, they don't fail the test.
func DumpInstanceConsole(t *testing.T, c *AWSClient, instanceID, dir, name string) {
t.Helper()
out, err := c.EC2.GetConsoleOutput(&ec2.GetConsoleOutputInput{
InstanceId: aws.String(instanceID),
})
if err != nil {
t.Logf("console capture: GetConsoleOutput(%s) failed: %v", instanceID, err)
return
}
encoded := aws.StringValue(out.Output)
if encoded == "" {
t.Logf("console capture: %s empty", instanceID)
return
}
raw, derr := base64.StdEncoding.DecodeString(encoded)
if derr != nil {
raw = []byte(encoded)
}
DumpFile(t, dir, name, raw)
}
func dumpOVNState(t *testing.T, instanceID string) {
t.Helper()
if _, err := exec.LookPath("ovn-nbctl"); err != nil {
+25
View File
@@ -506,6 +506,9 @@ func runLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture, kind lbKin
}
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
if kind == kindNLB {
captureLBConsoleOnFailure(t, c, eniDescPrefix, lb)
}
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
eni := lbENI(t, c, eniDescPrefix, lb)
@@ -554,6 +557,7 @@ func runUDPNLBSuite(t *testing.T, c *harness.AWSClient, f *sharedFixture) {
assert.Contains(t, lb.ARN, "/net/", label+" ARN must contain /net/")
harness.WaitForLBActive(t, c, lb.ARN, label, 5*time.Minute)
captureLBConsoleOnFailure(t, c, "net", lb)
// The key assertion: NLB targets reach healthy via the agent's active
// prober. Pre-feature this timed out (nginx reported empty server lists).
harness.WaitForTargetsHealthy(t, c, tgArn, 2, label, 2*time.Minute)
@@ -1300,6 +1304,27 @@ func lbENI(t *testing.T, c *harness.AWSClient, prefix string, lb lbInfo) *ec2.Ne
return eni
}
// captureLBConsoleOnFailure registers an on-failure dump of the NLB VM's serial
// console to the subtest artifact dir. The lb-agent's nginx engine activation
// (and any `reload nginx:` error) lands on the guest console, not in the host
// daemon journal — so an NLB "0/N healthy" timeout is otherwise undiagnosable
// from CI artifacts. Call after the LB is active (the VM/ENI exists by then).
func captureLBConsoleOnFailure(t *testing.T, c *harness.AWSClient, eniDescPrefix string, lb lbInfo) {
t.Helper()
eni := lbENI(t, c, eniDescPrefix, lb)
if eni.Attachment == nil {
return
}
instanceID := aws.StringValue(eni.Attachment.InstanceId)
if instanceID == "" {
return
}
dir := harness.ArtifactDir(t, harness.LoadEnv(t))
harness.OnFailure(t, func() {
harness.DumpInstanceConsole(t, c, instanceID, dir, "lb-vm-console.log")
})
}
func publicIP(eni *ec2.NetworkInterface) string {
if eni == nil || eni.Association == nil {
return ""