fix(elbv2): build lb-agent data-plane config on LB-ready heartbeat (#347)

CreateLoadBalancer is async: InstanceID is only set when the LB VM's QEMU
process starts, ~600ms after the create burst. The reactive
updateStoredConfig calls from CreateListener/RegisterTargets all no-op
during that window (InstanceID empty), and nothing rebuilds config once
the VM launches. The lb-agent then heartbeats, gets an empty ConfigHash,
never sees it change, and probes zero backends -> e2e 0/2 healthy.

Build the data-plane config on the provisioning->active heartbeat
transition instead: that is the one point where InstanceID is guaranteed
set and the agent is alive to consume config. The InstanceID guard in
updateStoredConfig stays as a cheap no-data-plane skip, no longer the
primary gate.
This commit is contained in:
Josh Siviour
2026-06-11 11:56:42 +10:00
committed by GitHub
parent 3a9a1564eb
commit db8d5a7265
2 changed files with 84 additions and 5 deletions
+19 -5
View File
@@ -895,12 +895,26 @@ func (s *ELBv2ServiceImpl) LBAgentHeartbeat(input *LBAgentHeartbeatInput, accoun
}
now := time.Now().UTC()
heartbeatStale := now.Sub(lb.LastHeartbeat) >= heartbeatPersistInterval
lb.LastHeartbeat = now
// Only persist to KV on state transitions or when the stored heartbeat
// timestamp is stale. This avoids writing the full record (including
// ConfigText) every 5 seconds when nothing changed.
if stateChanged || now.Sub(lb.LastHeartbeat) >= heartbeatPersistInterval {
lb.LastHeartbeat = now
switch {
case stateChanged:
// Activation is the LB's readiness signal: InstanceID is set and the
// agent is alive to consume config. The reactive updateStoredConfig
// calls during the create burst no-op while the VM is still launching
// (InstanceID empty), so build the data-plane config now from the
// listeners/targets created during provisioning. updateStoredConfig
// persists the record (state, config, hash, health targets) in one
// write, so the ConfigHash returned below is fresh.
if err := s.updateStoredConfig(lb); err != nil {
slog.Error("LBAgentHeartbeat: failed to build config on activation", "lbId", lbID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
case heartbeatStale:
// Steady state: refresh the heartbeat timestamp only. Avoids writing
// the full record (including ConfigText) every 5 seconds when nothing
// changed.
if err := s.store.PutLoadBalancer(lb); err != nil {
slog.Error("LBAgentHeartbeat: failed to persist LB", "lbId", lbID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
@@ -2008,6 +2008,71 @@ func TestLBAgentHeartbeat_ProcessesHealthReport(t *testing.T) {
assert.Equal(t, TargetHealthHealthy, stored.Targets[0].HealthState)
}
// TestLBAgentHeartbeat_BuildsConfigOnActivation covers the create-burst race:
// the reactive updateStoredConfig calls during CreateListener/RegisterTargets
// no-op while the LB VM is still launching (InstanceID empty). The agent's
// first heartbeat (provisioning→active) is the readiness signal that must build
// the data-plane config from the listeners/targets created during provisioning,
// so the agent receives a non-empty ConfigHash + backend list on its first poll.
func TestLBAgentHeartbeat_BuildsConfigOnActivation(t *testing.T) {
svc := setupTestService(t)
lb := &LoadBalancerRecord{
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/act-lb/lb-act1",
LoadBalancerID: "lb-act1",
Name: "act-lb",
Type: LoadBalancerTypeNetwork,
Scheme: SchemeInternal,
State: StateProvisioning,
InstanceID: "i-sys-act1",
VPCIP: "10.0.1.100",
AccountID: testAccountID,
}
require.NoError(t, svc.store.PutLoadBalancer(lb))
tg := &TargetGroupRecord{
TargetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/act-tg/tg-act1",
TargetGroupID: "tg-act1",
Protocol: ProtocolTCP,
Port: 80,
HealthCheck: DefaultHealthCheck(),
Targets: []Target{
{Id: "i-target1", Port: 80, HealthState: TargetHealthInitial, PrivateIP: "10.0.1.20"},
},
AccountID: testAccountID,
}
require.NoError(t, svc.store.PutTargetGroup(tg))
require.NoError(t, svc.store.PutListener(&ListenerRecord{
ListenerArn: lb.LoadBalancerArn + "/listener-1",
ListenerID: "lst-act1",
LoadBalancerArn: lb.LoadBalancerArn,
Protocol: ProtocolTCP,
Port: 80,
DefaultActions: []ListenerAction{{Type: ActionTypeForward, TargetGroupArn: tg.TargetGroupArn}},
AccountID: testAccountID,
}))
// Pre-condition: config was never built during provisioning.
pre, err := svc.store.GetLoadBalancer("lb-act1")
require.NoError(t, err)
require.Empty(t, pre.ConfigHash, "config must be empty before activation")
out, err := svc.LBAgentHeartbeat(&LBAgentHeartbeatInput{
LBID: aws.String("lb-act1"),
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, StateActive, *out.Status)
assert.NotEmpty(t, aws.StringValue(out.ConfigHash), "first heartbeat must return a built ConfigHash")
stored, err := svc.store.GetLoadBalancer("lb-act1")
require.NoError(t, err)
assert.NotEmpty(t, stored.ConfigText, "data-plane config must be built on activation")
assert.NotEmpty(t, stored.ConfigHash)
require.Len(t, stored.HealthTargets, 1, "NLB health target must be populated for the registered backend")
assert.Equal(t, "10.0.1.20:80", stored.HealthTargets[0].Address)
}
func TestLBAgentHeartbeat_MissingLBID(t *testing.T) {
svc := setupTestService(t)