Skip to content

Commit 678c8b8

Browse files
committed
serialize SecurityGroup ingress reconciles per SecurityGroup
Concurrent reconciles of the same SecurityGroup each perform a non-atomic read-modify-write sequence: fetch the current rules, diff them against the desired permissions, then authorize/revoke. When two reconciles interleave, one can diff against a snapshot taken while the other was mid-mutation and revoke rules without granting replacements, leaving the SecurityGroup without any of the controller-managed rules. With the shared backend SecurityGroup feature this removes every ALB-to-target ingress rule on the cluster SecurityGroup at once, failing health checks and client traffic for all load balancers until the deferred TargetGroupBinding reconciler restores the rules (~30 minutes after a controller restart). Observed in production during Fargate maintenance: endpoint churn on the TargetGroupBinding defining the aggregated port-range boundary produced two concurrent reconciles; CloudTrail shows the controller authorizing tcp/3000-3008, revoking tcp/3000-8025, then 45 seconds later revoking tcp/3000-3008 again with no replacement grant. Serialize ReconcileIngress per SecurityGroup ID with a keyed mutex held across the fetch and the mutations, so every reconcile diffs against post-mutation state. Reconciles of different SecurityGroups are not serialized against each other. Fixes #4889
1 parent 52ebc1c commit 678c8b8

2 files changed

Lines changed: 180 additions & 1 deletion

File tree

pkg/networking/security_group_reconciler.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package networking
22

33
import (
44
"context"
5+
"sync"
6+
57
"github.com/aws/smithy-go"
68
"github.com/go-logr/logr"
79
"github.com/pkg/errors"
@@ -64,6 +66,21 @@ var _ SecurityGroupReconciler = &defaultSecurityGroupReconciler{}
6466
type defaultSecurityGroupReconciler struct {
6567
sgManager SecurityGroupManager
6668
logger logr.Logger
69+
70+
// sgLocks serializes reconciles per SecurityGroup.
71+
// A reconcile is a read-modify-write sequence (fetch rules, diff against desired, authorize/revoke):
72+
// if two reconciles for the same SecurityGroup interleave, one can diff against a snapshot taken
73+
// while the other was mid-mutation and revoke rules without granting replacements,
74+
// leaving the SecurityGroup without any of the managed rules.
75+
sgLocks sync.Map // sgID -> *sync.Mutex
76+
}
77+
78+
// lockSG locks the mutex for sgID and returns the function that unlocks it.
79+
func (r *defaultSecurityGroupReconciler) lockSG(sgID string) func() {
80+
lock, _ := r.sgLocks.LoadOrStore(sgID, &sync.Mutex{})
81+
mu := lock.(*sync.Mutex)
82+
mu.Lock()
83+
return mu.Unlock
6784
}
6885

6986
func (r *defaultSecurityGroupReconciler) ReconcileIngress(ctx context.Context, sgID string, desiredPermissions []IPPermissionInfo, opts ...SecurityGroupReconcileOption) error {
@@ -72,6 +89,11 @@ func (r *defaultSecurityGroupReconciler) ReconcileIngress(ctx context.Context, s
7289
}
7390
reconcileOpts.ApplyOptions(opts...)
7491

92+
// the fetch below must happen under the same lock as the mutations:
93+
// a snapshot fetched while another reconcile holds the lock would be stale by the time the lock is acquired.
94+
unlockSG := r.lockSG(sgID)
95+
defer unlockSG()
96+
7597
sgInfoByID, err := r.sgManager.FetchSGInfosByID(ctx, []string{sgID})
7698
if err != nil {
7799
return err

pkg/networking/security_group_reconciler_test.go

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@ package networking
33
import (
44
"context"
55
"errors"
6+
"sync"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
611
awssdk "github.com/aws/aws-sdk-go-v2/aws"
12+
ec2sdk "github.com/aws/aws-sdk-go-v2/service/ec2"
713
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
814
"github.com/aws/smithy-go"
915
"github.com/go-logr/logr"
1016
"github.com/golang/mock/gomock"
1117
"github.com/stretchr/testify/assert"
1218
"sigs.k8s.io/controller-runtime/pkg/log"
13-
"testing"
1419
)
1520

1621
func Test_defaultSecurityGroupReconciler_shouldRetryWithoutCache(t *testing.T) {
@@ -593,3 +598,155 @@ func TestReconcileSGIngress_RehydrateCache(t *testing.T) {
593598
})
594599
}
595600
}
601+
602+
// fakeStatefulSGManager simulates the EC2-side SecurityGroup rule state for concurrency tests.
603+
// It tracks how many manager calls run concurrently and whether a revoke ever left the
604+
// SecurityGroup without any rules.
605+
type fakeStatefulSGManager struct {
606+
sgID string
607+
608+
mu sync.Mutex
609+
perms map[string]IPPermissionInfo
610+
611+
inFlight int32
612+
maxInFlight int32
613+
wentEmpty int32
614+
}
615+
616+
// enter tracks call concurrency and sleeps briefly to widen any interleaving window.
617+
func (f *fakeStatefulSGManager) enter() {
618+
cur := atomic.AddInt32(&f.inFlight, 1)
619+
for {
620+
max := atomic.LoadInt32(&f.maxInFlight)
621+
if cur <= max || atomic.CompareAndSwapInt32(&f.maxInFlight, max, cur) {
622+
break
623+
}
624+
}
625+
time.Sleep(time.Millisecond)
626+
}
627+
628+
func (f *fakeStatefulSGManager) exit() {
629+
atomic.AddInt32(&f.inFlight, -1)
630+
}
631+
632+
func (f *fakeStatefulSGManager) FetchSGInfosByID(ctx context.Context, sgIDs []string, opts ...FetchSGInfoOption) (map[string]SecurityGroupInfo, error) {
633+
f.enter()
634+
defer f.exit()
635+
f.mu.Lock()
636+
defer f.mu.Unlock()
637+
ingress := make([]IPPermissionInfo, 0, len(f.perms))
638+
for _, perm := range f.perms {
639+
ingress = append(ingress, perm)
640+
}
641+
return map[string]SecurityGroupInfo{
642+
f.sgID: {
643+
SecurityGroupID: f.sgID,
644+
Ingress: ingress,
645+
},
646+
}, nil
647+
}
648+
649+
func (f *fakeStatefulSGManager) FetchSGInfosByRequest(ctx context.Context, req *ec2sdk.DescribeSecurityGroupsInput) (map[string]SecurityGroupInfo, error) {
650+
return nil, errors.New("not implemented")
651+
}
652+
653+
func (f *fakeStatefulSGManager) AuthorizeSGIngress(ctx context.Context, sgID string, permissions []IPPermissionInfo) error {
654+
f.enter()
655+
defer f.exit()
656+
f.mu.Lock()
657+
defer f.mu.Unlock()
658+
for _, perm := range permissions {
659+
f.perms[perm.HashCode()] = perm
660+
}
661+
return nil
662+
}
663+
664+
func (f *fakeStatefulSGManager) RevokeSGIngress(ctx context.Context, sgID string, permissions []IPPermissionInfo) error {
665+
f.enter()
666+
defer f.exit()
667+
f.mu.Lock()
668+
defer f.mu.Unlock()
669+
for _, perm := range permissions {
670+
delete(f.perms, perm.HashCode())
671+
}
672+
if len(f.perms) == 0 {
673+
atomic.StoreInt32(&f.wentEmpty, 1)
674+
}
675+
return nil
676+
}
677+
678+
// TestReconcileSGIngress_ConcurrentReconcilesAreSerializedPerSG is a regression test for
679+
// concurrent reconciles of the same SecurityGroup revoking rules without granting replacements.
680+
// Endpoint churn on the TargetGroupBinding that defines the boundary of the aggregated
681+
// port-range produces alternating desired permission sets reconciled concurrently; a reconcile
682+
// diffing against a snapshot taken mid-way through another reconcile's authorize/revoke
683+
// sequence can revoke the last remaining rule and leave the SecurityGroup empty.
684+
func TestReconcileSGIngress_ConcurrentReconcilesAreSerializedPerSG(t *testing.T) {
685+
sgID := "sg-cluster"
686+
wideRule := NewGroupIDIPPermission("tcp", awssdk.Int32(3000), awssdk.Int32(8025), "sg-backend", nil)
687+
narrowRule := NewGroupIDIPPermission("tcp", awssdk.Int32(3000), awssdk.Int32(3008), "sg-backend", nil)
688+
689+
f := &fakeStatefulSGManager{
690+
sgID: sgID,
691+
perms: map[string]IPPermissionInfo{wideRule.HashCode(): wideRule},
692+
}
693+
reconciler := &defaultSecurityGroupReconciler{
694+
sgManager: f,
695+
logger: logr.New(&log.NullLogSink{}),
696+
}
697+
698+
const reconcileCount = 10
699+
var wg sync.WaitGroup
700+
errs := make([]error, reconcileCount)
701+
for i := 0; i < reconcileCount; i++ {
702+
desired := []IPPermissionInfo{wideRule}
703+
if i%2 == 1 {
704+
desired = []IPPermissionInfo{narrowRule}
705+
}
706+
wg.Add(1)
707+
go func(i int, desired []IPPermissionInfo) {
708+
defer wg.Done()
709+
errs[i] = reconciler.ReconcileIngress(context.Background(), sgID, desired)
710+
}(i, desired)
711+
}
712+
wg.Wait()
713+
714+
for i, err := range errs {
715+
assert.NoError(t, err, "reconcile %d", i)
716+
}
717+
assert.EqualValues(t, 1, atomic.LoadInt32(&f.maxInFlight),
718+
"concurrent reconciles of the same SecurityGroup must not interleave fetch/authorize/revoke calls")
719+
assert.EqualValues(t, 0, atomic.LoadInt32(&f.wentEmpty),
720+
"the managed permissions must never be fully revoked while a non-empty permission set is desired")
721+
assert.Len(t, f.perms, 1, "the final state must converge to exactly one of the desired permission sets")
722+
}
723+
724+
// TestReconcileSGIngress_LocksAreScopedPerSG verifies reconciles of different SecurityGroups
725+
// do not serialize against each other.
726+
func TestReconcileSGIngress_LocksAreScopedPerSG(t *testing.T) {
727+
reconciler := &defaultSecurityGroupReconciler{}
728+
729+
unlockA := reconciler.lockSG("sg-a")
730+
// must not block while sg-a is held; a shared lock would deadlock the test here.
731+
unlockB := reconciler.lockSG("sg-b")
732+
unlockB()
733+
734+
// re-acquiring sg-a must block until it is released.
735+
acquired := make(chan struct{})
736+
go func() {
737+
unlock := reconciler.lockSG("sg-a")
738+
unlock()
739+
close(acquired)
740+
}()
741+
select {
742+
case <-acquired:
743+
t.Fatal("acquiring the lock for a SecurityGroup that is already locked must block")
744+
case <-time.After(50 * time.Millisecond):
745+
}
746+
unlockA()
747+
select {
748+
case <-acquired:
749+
case <-time.After(5 * time.Second):
750+
t.Fatal("the lock for a SecurityGroup must be acquirable after it is released")
751+
}
752+
}

0 commit comments

Comments
 (0)