Skip to content

Commit f0658d3

Browse files
bdehamerCopilot
andauthored
Cache registry keychain with periodic background refresh (#212)
* Cache registry keychain with periodic background refresh Build the registry keychain once at startup and refresh it on a fixed interval in the background instead of reconstructing it on every request. Serving a keychain to a request now performs no I/O, removing the per-request keychain setup cost and its control-plane dependency from the request path. Add an aaop_keychain_build_timer histogram so the build latency is observable off the request path, plus an aaop_keychain_refresh_fail counter for background refreshes that yield no keychain (the last-good keychain is retained). The refresh interval is configurable via -keychain-refresh-interval (default 5m). The KeyChainProvider.KeyChain interface is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c1e5506-4a29-40f8-ba04-356c576403ad * Bound initial keychain build and detect failed refreshes Address review feedback: - Bound the synchronous initial build with the same timeout used for background refreshes, so a stalled dependency at startup cannot keep the process from listening. The result is still cached even if incomplete, so the default-keychain fallback lets startup proceed. - Have buildKeychain report whether the in-cluster authenticators built successfully, and only swap the cached keychain on a successful refresh. A degraded rebuild (e.g. the authenticators erroring during a transient outage) now keeps the last-good keychain and increments aaop_keychain_refresh_fail instead of silently downgrading to a default-only keychain. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c1e5506-4a29-40f8-ba04-356c576403ad * Document keychain metrics and clarify refresh-fail semantics Address review feedback: - List aaop_keychain_build_timer and aaop_keychain_refresh_fail in the README metrics section so operators can discover the build-latency and refresh-failure signals. - Correct the aaop_keychain_refresh_fail help text: the counter also increments for a degraded rebuild (a non-nil keychain whose in-cluster authenticators did not all build), not only when no keychain is produced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c1e5506-4a29-40f8-ba04-356c576403ad * Serve the cached keychain via atomic.Pointer Replace the RWMutex-guarded cache with an atomic.Pointer to a small snapshot struct (authn.Keychain is an interface, so it cannot be stored in an atomic.Pointer directly). Reads are now lock-free. KeyChain still falls back to the default keychain when the cache is empty or holds no keychain. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c1e5506-4a29-40f8-ba04-356c576403ad --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c1e5506-4a29-40f8-ba04-356c576403ad
1 parent f8743ce commit f0658d3

5 files changed

Lines changed: 342 additions & 17 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ The metrics exposed beyond the default Prometheus metrics are:
263263
time it takes to download the attestations from the OCI registry.
264264
* `aaop_attestations_verification_timer`: the duration in seconds for
265265
the time it takes to verify the retrieved attestations.
266+
* `aaop_keychain_build_timer`: the duration in seconds to build the
267+
registry keychain. The keychain is built once at startup and refreshed
268+
in the background, so this latency is off the request path.
269+
* `aaop_keychain_refresh_fail`: the total number of background keychain
270+
refreshes that did not fully rebuild the keychain (a failed or degraded
271+
rebuild), so the previous keychain was retained.
266272

267273
Each request is also logged with a `request_id`, `image_count`, and, for
268274
per-image log lines, an `image_index` (1-based position within the

cmd/aaop/aaop.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var (
4040
tufTargets = flag.String("tuf-targets", "", "Comma separated list of targets to load as trust roots")
4141
ns = flag.String("namespace", "", "namespace the pod runs in")
4242
ips = flag.String("image-pull-secret", "", "the imagePullSecret to use for private registries")
43+
keychainRefresh = flag.Duration("keychain-refresh-interval", 5*time.Minute, "how often the registry keychain is rebuilt in the background")
4344
port = flag.String("port", "8080", "port to listen to")
4445
metricsPort = flag.String("metrics-port", "9090", "port to listen to for metrics")
4546
bundleMaxAttempts = flag.Int("bundle-max-attempts", 3, "max attempts to fetch a bundle")
@@ -158,7 +159,13 @@ func main() {
158159
}
159160
}
160161

161-
kc = authn.NewKeyChainProvider(*ns, []string{*ips})
162+
// Handle signals gracefully to avoid dropping requests during Pod shutdown
163+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
164+
165+
kc = authn.NewKeyChainProvider(*ns, []string{*ips}, *keychainRefresh)
166+
// Build the keychain once and refresh it periodically in the background so
167+
// requests never pay the keychain construction cost on their critical path.
168+
kc.Start(ctx)
162169
var p = provider.New(v, kc, &fetcher.DefaultBundleFetcher{})
163170
var t = transport{
164171
p: p,
@@ -169,9 +176,6 @@ func main() {
169176
w.WriteHeader(http.StatusOK)
170177
})
171178

172-
// Handle signals gracefully to avoid dropping requests during Pod shutdown
173-
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
174-
175179
var srv = &http.Server{
176180
Addr: fmt.Sprintf(":%s", *port),
177181
ReadTimeout: 10 * time.Second,

pkg/authn/provider.go

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,183 @@ package authn
33
import (
44
"context"
55
"log/slog"
6+
"sync/atomic"
7+
"time"
68

79
"github.com/google/go-containerregistry/pkg/authn"
810
"github.com/google/go-containerregistry/pkg/authn/k8schain"
911
"github.com/google/go-containerregistry/pkg/authn/kubernetes"
12+
13+
"github.com/github/artifact-attestations-opa-provider/pkg/metrics"
1014
)
1115

16+
// defaultKeychainRefreshInterval is how often the cached keychain is rebuilt in
17+
// the background when no explicit interval is configured.
18+
const defaultKeychainRefreshInterval = 5 * time.Minute
19+
20+
// keychainBuildTimeout bounds a single background keychain build so a stalled
21+
// rebuild can never wedge the refresher.
22+
const keychainBuildTimeout = 30 * time.Second
23+
24+
// keychainSnapshot wraps the cached keychain so it can be held in an
25+
// atomic.Pointer; authn.Keychain is an interface and cannot be stored in one
26+
// directly.
27+
type keychainSnapshot struct {
28+
kc authn.Keychain
29+
}
30+
1231
// KeyChainProvider is used to provide k8s keychains, which can be used
1332
// to authenticate certain requests like fetching resources from an OCI
1433
// registry.
34+
//
35+
// The keychain is built once at startup and refreshed periodically in the
36+
// background, so serving it to a request performs no I/O.
1537
type KeyChainProvider struct {
1638
namespace string
1739
imagePullSecrets []string
40+
refreshInterval time.Duration
41+
42+
// build constructs a keychain and reports whether the configured in-cluster
43+
// authenticators were built successfully. It is a field so tests can inject
44+
// a stub without a live cluster; production uses buildKeychain.
45+
build func(ctx context.Context) (authn.Keychain, bool)
46+
47+
// cached holds the current keychain snapshot. Reads are lock-free; the
48+
// background refresher swaps in a new snapshot on each successful build.
49+
cached atomic.Pointer[keychainSnapshot]
1850
}
1951

2052
// NewKeyChainProvider returns a new instance for a namespace and a set of
2153
// image pull secrets. If namesapce is not set, or no image pull secret
2254
// references are provided, the default keychain is will be used for further
2355
// requests to get a key chain.
24-
func NewKeyChainProvider(ns string, ips []string) *KeyChainProvider {
56+
//
57+
// refresh sets how often the cached keychain is rebuilt in the background; a
58+
// non-positive value selects defaultKeychainRefreshInterval. Call Start to warm
59+
// the cache and begin refreshing.
60+
func NewKeyChainProvider(ns string, ips []string, refresh time.Duration) *KeyChainProvider {
2561
slog.Info("configure authn with image pull secrets",
2662
"secrets_refs", ips,
2763
"namespace", ns)
2864

29-
return &KeyChainProvider{
65+
if refresh <= 0 {
66+
refresh = defaultKeychainRefreshInterval
67+
}
68+
69+
k := &KeyChainProvider{
3070
namespace: ns,
3171
imagePullSecrets: ips,
72+
refreshInterval: refresh,
3273
}
74+
// Default to the real constructor; tests may override this field.
75+
k.build = k.buildKeychain
76+
77+
return k
3378
}
3479

35-
// KeyChain returns the configured keychain from this provider.
36-
func (k *KeyChainProvider) KeyChain(ctx context.Context) (authn.Keychain, error) {
37-
var kc authn.Keychain
80+
// buildKeychain assembles the keychain from the in-cluster authenticators,
81+
// timing the build so its latency is observable off the request path. The
82+
// returned bool reports whether both in-cluster authenticators were built
83+
// successfully; if either fails it is false. The keychain itself is always
84+
// non-nil: on failure it falls back to whatever could be assembled (at minimum
85+
// the default keychain), which serves the initial cold-start build. A
86+
// background refresh only adopts a build whose bool is true, so a degraded
87+
// rebuild never replaces a good keychain with a default-only one.
88+
func (k *KeyChainProvider) buildKeychain(ctx context.Context) (authn.Keychain, bool) {
89+
start := time.Now()
90+
defer func() {
91+
metrics.KeychainBuildTimer.Observe(time.Since(start).Seconds())
92+
}()
93+
3894
var kcs = []authn.Keychain{
3995
authn.DefaultKeychain,
4096
}
41-
var err error
97+
ok := true
4298

4399
// Add the kubernetes authenticator
44-
kc, err = kubernetes.NewInCluster(ctx, kubernetes.Options{
100+
if kc, err := kubernetes.NewInCluster(ctx, kubernetes.Options{
45101
Namespace: k.namespace,
46102
ImagePullSecrets: k.imagePullSecrets,
47-
})
48-
if err != nil {
103+
}); err != nil {
49104
slog.Error("failed to add kubernetes key chain",
50105
"error", err)
106+
ok = false
51107
} else {
52108
kcs = append(kcs, kc)
53109
}
54110

55111
// Add a "cloud k8s" authenticator
56-
kc, err = k8schain.NewInCluster(ctx, k8schain.Options{
112+
if kc, err := k8schain.NewInCluster(ctx, k8schain.Options{
57113
Namespace: k.namespace,
58-
})
59-
if err != nil {
114+
}); err != nil {
60115
slog.Error("failed to add k8schain key chain",
61116
"error", err)
117+
ok = false
62118
} else {
63119
kcs = append(kcs, kc)
64120
}
65121

66-
return authn.NewMultiKeychain(kcs...), nil
122+
return authn.NewMultiKeychain(kcs...), ok
123+
}
124+
125+
// Start performs one bounded synchronous initial build so the first requests
126+
// are warm, then refreshes the cached keychain on refreshInterval in the
127+
// background until ctx is done. The initial build is best-effort: it is bounded
128+
// by keychainBuildTimeout so a stalled dependency cannot keep the process from
129+
// listening, and its result is cached even if incomplete (KeyChain then serves
130+
// the default keychain until the first successful refresh).
131+
func (k *KeyChainProvider) Start(ctx context.Context) {
132+
bctx, cancel := context.WithTimeout(ctx, keychainBuildTimeout)
133+
kc, _ := k.build(bctx)
134+
cancel()
135+
k.set(kc)
136+
137+
t := time.NewTicker(k.refreshInterval)
138+
go func() {
139+
defer t.Stop()
140+
k.refreshLoop(ctx, t.C)
141+
}()
142+
}
143+
144+
// refreshLoop rebuilds the cached keychain each time tick fires until ctx is
145+
// done. A build is bounded by keychainBuildTimeout. The cache is swapped only
146+
// when the build reports success; a failed or degraded build (e.g. the
147+
// in-cluster authenticators erroring during a transient outage) leaves the
148+
// last-good keychain in place and increments KeychainRefreshFail.
149+
func (k *KeyChainProvider) refreshLoop(ctx context.Context, tick <-chan time.Time) {
150+
for {
151+
select {
152+
case <-ctx.Done():
153+
return
154+
case <-tick:
155+
bctx, cancel := context.WithTimeout(ctx, keychainBuildTimeout)
156+
kc, ok := k.build(bctx)
157+
cancel()
158+
if ok && kc != nil {
159+
k.set(kc)
160+
continue
161+
}
162+
// Keep the last-good keychain when a refresh fails or is degraded.
163+
metrics.KeychainRefreshFail.Inc()
164+
slog.Error("failed to refresh key chain, keeping last-good")
165+
}
166+
}
167+
}
168+
169+
func (k *KeyChainProvider) set(kc authn.Keychain) {
170+
k.cached.Store(&keychainSnapshot{kc: kc})
171+
}
172+
173+
// KeyChain returns the cached keychain from this provider. It performs no
174+
// per-request I/O; the ctx argument is retained for interface compatibility and
175+
// is ignored. Before Start has populated the cache (or if the initial build
176+
// produced nothing), it returns the default keychain, which works for public
177+
// registries.
178+
func (k *KeyChainProvider) KeyChain(_ context.Context) (authn.Keychain, error) {
179+
s := k.cached.Load()
180+
if s == nil || s.kc == nil {
181+
return authn.DefaultKeychain, nil
182+
}
183+
184+
return s.kc, nil
67185
}

0 commit comments

Comments
 (0)