Skip to content

Commit a1e0da8

Browse files
authored
implement rate limited queue to control Certificate creation rate (#130)
* implement rate limited workqueue for Certificate apply * add metrics for counting queued applies (#132) Signed-off-by: Hiroki Hanada <hiroki-hanada@cybozu.co.jp>
1 parent 6897f9a commit a1e0da8

10 files changed

Lines changed: 887 additions & 78 deletions

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func init() {
4949
fs.StringSlice("propagated-labels", []string{}, "List of label keys to be propagated from HTTPProxy to generated resources")
5050
fs.StringSlice("allowed-dns-namespaces", []string{}, "List of namespaces where DNSEndpoint resources can be created. If empty, no namespaces are allowed")
5151
fs.StringSlice("allowed-issuer-namespaces", []string{}, "List of namespaces where Certificate resources can be created. If empty, no namespaces are allowed")
52+
fs.Float64("certificate-apply-limit", 0, "Maximum number of certificate apply operations allowed per second (0 disables rate limiting)")
5253
if err := viper.BindPFlags(fs); err != nil {
5354
panic(err)
5455
}

cmd/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ func run() error {
7878

7979
opts.AllowedDNSNamespaces = viper.GetStringSlice("allowed-dns-namespaces")
8080
opts.AllowedIssuerNamespaces = viper.GetStringSlice("allowed-issuer-namespaces")
81+
opts.CertificateApplyLimit = viper.GetFloat64("certificate-apply-limit")
82+
if opts.CertificateApplyLimit < 0 {
83+
return errors.New("certificate-apply-limit must be greater than or equal to 0")
84+
}
8185

8286
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
8387
Scheme: scheme,

controllers/certificate_worker.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math"
7+
"sync"
8+
9+
cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
10+
projectcontourv1 "github.com/projectcontour/contour/apis/projectcontour/v1"
11+
"github.com/prometheus/client_golang/prometheus"
12+
"golang.org/x/time/rate"
13+
"k8s.io/apimachinery/pkg/api/equality"
14+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
15+
"k8s.io/apimachinery/pkg/types"
16+
"k8s.io/client-go/tools/cache"
17+
"k8s.io/client-go/util/workqueue"
18+
"k8s.io/utils/ptr"
19+
"sigs.k8s.io/controller-runtime/pkg/client"
20+
"sigs.k8s.io/controller-runtime/pkg/event"
21+
crlog "sigs.k8s.io/controller-runtime/pkg/log"
22+
"sigs.k8s.io/controller-runtime/pkg/manager"
23+
"sigs.k8s.io/controller-runtime/pkg/metrics"
24+
)
25+
26+
type viaQueueValue string
27+
type applyResultValue string
28+
29+
const (
30+
certificateApplierName = "certificate-apply"
31+
labelViaQueue = "via_queue"
32+
viaQueueYes viaQueueValue = "true"
33+
viaQueueNo viaQueueValue = "false"
34+
labelApplyResult = "result"
35+
applyResultSuccess applyResultValue = "success"
36+
applyResultError applyResultValue = "error"
37+
)
38+
39+
type Applier[T client.Object] interface {
40+
Apply(ctx context.Context, obj T) error
41+
}
42+
43+
var _ Applier[*cmv1.Certificate] = &CertificateApplier{}
44+
45+
// CertificateApplier implements Applier[cmv1.Certificate] without a workqueue.
46+
// Any objects applied with Apply method will be applied without going through a queue.
47+
type CertificateApplier struct {
48+
client client.Client
49+
}
50+
51+
func (w *CertificateApplier) Apply(ctx context.Context, obj *cmv1.Certificate) error {
52+
return applyCertificate(ctx, w.client, obj)
53+
}
54+
55+
func NewCertificateApplier(client client.Client) *CertificateApplier {
56+
return &CertificateApplier{
57+
client: client,
58+
}
59+
}
60+
61+
// ApplyWorker is Applier with Start method so that it can be used directly by manager.Manager.Add
62+
// Implement ApplyWorker if the Applier requires a worker that runs in a background and start it via controller manager.
63+
type ApplyWorker[T client.Object] interface {
64+
Applier[T]
65+
// manager.Runnable defines signature for Start
66+
manager.Runnable
67+
// GetRetryChannel should return a receive only channel that can be used by the main reconciliation loop.
68+
// For e.g. by WatchesRawSource and source.Channel in SetupWithManager to add a retry path.
69+
// NOTE: could use second generics type instead of HTTPProxy if we want to use this somewhere else.
70+
GetRetryChannel() <-chan event.TypedGenericEvent[*projectcontourv1.HTTPProxy]
71+
// RegisterMetrics should register metrics that ApplyWorker records
72+
RegisterMetrics(metrics.RegistererGatherer) error
73+
}
74+
75+
var _ ApplyWorker[*cmv1.Certificate] = &CertificateApplyWorker{}
76+
77+
// CertificateApplyWorker implements Applier and ApplyWorker.
78+
type CertificateApplyWorker struct {
79+
mu sync.Mutex
80+
ReconcilerOptions
81+
client client.Client
82+
// internal workqueue for rate limiting Certificate changes
83+
workqueue workqueue.TypedRateLimitingInterface[types.NamespacedName]
84+
// manifests contains full client.Object that should be applied for the key
85+
manifests map[types.NamespacedName]*cmv1.Certificate
86+
// limiter is the underlying rate limiter used by the workqueue
87+
limiter *rate.Limiter
88+
// channel for queueing HTTPProxy back into main reconcile loop for a retry
89+
retryCh chan event.TypedGenericEvent[*projectcontourv1.HTTPProxy]
90+
// certificatesAppliedTotal keeps track of the number of certificates applied either via queue or directly.
91+
certificatesAppliedTotal *prometheus.CounterVec
92+
}
93+
94+
func NewCertificateApplyWorker(client client.Client, opt ReconcilerOptions) *CertificateApplyWorker {
95+
limit := opt.CertificateApplyLimit
96+
97+
var limiter *rate.Limiter
98+
if limit <= 0 {
99+
// Unlimited: allow everything, burst doesn’t really matter in this case
100+
limiter = rate.NewLimiter(rate.Inf, 1)
101+
} else {
102+
burst := max(int(math.Ceil(limit)), 1)
103+
limiter = rate.NewLimiter(rate.Limit(limit), burst)
104+
}
105+
106+
global := &workqueue.TypedBucketRateLimiter[types.NamespacedName]{Limiter: limiter}
107+
workqueue := workqueue.NewTypedRateLimitingQueueWithConfig(
108+
global,
109+
workqueue.TypedRateLimitingQueueConfig[types.NamespacedName]{
110+
Name: certificateApplierName,
111+
},
112+
)
113+
retryCh := make(chan event.TypedGenericEvent[*projectcontourv1.HTTPProxy], 10)
114+
return &CertificateApplyWorker{
115+
ReconcilerOptions: opt,
116+
client: client,
117+
workqueue: workqueue,
118+
manifests: make(map[types.NamespacedName]*cmv1.Certificate),
119+
limiter: limiter,
120+
retryCh: retryCh,
121+
}
122+
}
123+
124+
func (w *CertificateApplyWorker) RegisterMetrics(registry metrics.RegistererGatherer) error {
125+
certificatesAppliedTotal := prometheus.NewCounterVec(
126+
prometheus.CounterOpts{
127+
Name: "contour_plus_certificates_applied_total",
128+
Help: "Total number of Certificate resources applied.",
129+
},
130+
[]string{"controller", labelViaQueue, labelApplyResult},
131+
)
132+
133+
// cannot use MustRegister because controller-runtime uses global prometheus registry which cannot be reset during testing
134+
// we need to explicitly check for duplicate metrics error
135+
if err := registry.Register(certificatesAppliedTotal); err != nil {
136+
if _, ok := err.(prometheus.AlreadyRegisteredError); !ok {
137+
return err
138+
}
139+
}
140+
w.certificatesAppliedTotal = certificatesAppliedTotal
141+
return nil
142+
}
143+
144+
func (w *CertificateApplyWorker) Apply(ctx context.Context, obj *cmv1.Certificate) error {
145+
log := crlog.FromContext(ctx)
146+
objKey := types.NamespacedName{
147+
Namespace: obj.GetNamespace(),
148+
Name: obj.GetName(),
149+
}
150+
requiresQueue, err := w.RequiresQueue(ctx, objKey, obj)
151+
if err != nil {
152+
return err
153+
}
154+
if requiresQueue {
155+
w.enqueueCertificate(objKey, obj)
156+
log.Info("cert queued for apply", "key", objKey.String())
157+
return nil
158+
}
159+
if err := applyCertificate(ctx, w.client, obj); err != nil {
160+
log.Error(err, "cert apply failed", "key", objKey.String())
161+
w.recordApply(viaQueueNo, applyResultError)
162+
return err
163+
}
164+
log.Info("cert applied without queueing", "key", objKey.String())
165+
w.recordApply(viaQueueNo, applyResultSuccess)
166+
return nil
167+
}
168+
169+
func (w *CertificateApplyWorker) Start(ctx context.Context) error {
170+
log := crlog.FromContext(ctx)
171+
go func() {
172+
<-ctx.Done()
173+
log.Info("context.Done received. Shutting down certificate apply worker.")
174+
w.workqueue.ShutDown()
175+
}()
176+
177+
for {
178+
objKey, shutdown := w.workqueue.Get()
179+
if shutdown {
180+
return nil
181+
}
182+
log.Info("processing cert queue item", "key", objKey.String())
183+
184+
func() {
185+
// there is no need to call .Forget since we are only using BucketRateLimiter
186+
defer w.workqueue.Done(objKey)
187+
188+
w.mu.Lock()
189+
obj, ok := w.manifests[objKey]
190+
delete(w.manifests, objKey)
191+
w.mu.Unlock()
192+
193+
if !ok {
194+
log.Error(fmt.Errorf("cannot find certificate manifest for %s", objKey.String()), "cert apply failed", "key", objKey.String())
195+
return
196+
}
197+
198+
if ctx.Err() != nil {
199+
log.Info("context cancelled, skipping cert apply", "key", objKey.String())
200+
return
201+
}
202+
203+
if err := applyCertificate(ctx, w.client, obj); err != nil {
204+
log.Error(err, "cert apply from queue failed", "key", objKey.String())
205+
w.recordApply(viaQueueYes, applyResultError)
206+
w.enqueueHTTPProxy(ctx, obj)
207+
return
208+
}
209+
210+
log.Info("cert applied from queue", "key", objKey.String())
211+
w.recordApply(viaQueueYes, applyResultSuccess)
212+
}()
213+
}
214+
}
215+
216+
func (w *CertificateApplyWorker) GetRetryChannel() <-chan event.TypedGenericEvent[*projectcontourv1.HTTPProxy] {
217+
return w.retryCh
218+
}
219+
220+
// RequiresQueue indicates whether the object should be queued or not.
221+
func (w *CertificateApplyWorker) RequiresQueue(ctx context.Context, key types.NamespacedName, obj *cmv1.Certificate) (bool, error) {
222+
log := crlog.FromContext(ctx)
223+
224+
current := new(cmv1.Certificate)
225+
err := w.client.Get(ctx, key, current)
226+
if k8serrors.IsNotFound(err) {
227+
// MUST be queued with rate limit
228+
return true, nil
229+
}
230+
if err != nil {
231+
log.Error(err, "unable to get Certificate resource")
232+
return false, err
233+
}
234+
// MUST COMPARE specs of desired and current to see if there will be re-issuance of the Certificate
235+
// can safely Ignore spec.secretTemplate changes as they are only secret metadata change and does not trigger re-issuance
236+
objCopy := obj.DeepCopy()
237+
objCopy.Spec.SecretTemplate = nil
238+
current.Spec.SecretTemplate = nil
239+
if equality.Semantic.DeepEqual(objCopy.Spec, current.Spec) {
240+
// no-reissuance, safe to patch without rate limit
241+
return false, nil
242+
}
243+
return true, nil
244+
}
245+
246+
func (w *CertificateApplyWorker) enqueueCertificate(objKey types.NamespacedName, obj *cmv1.Certificate) {
247+
w.mu.Lock()
248+
defer w.mu.Unlock()
249+
w.manifests[objKey] = obj
250+
w.workqueue.AddRateLimited(objKey)
251+
}
252+
253+
// enqueueHTTPProxy enqueues HTTPProxy in to the workqueue used by the main Reconcile function.
254+
// It requires the Controller to be setup with .WatchesRawSource using the same channel as w.retryCh
255+
func (w *CertificateApplyWorker) enqueueHTTPProxy(ctx context.Context, obj *cmv1.Certificate) {
256+
log := crlog.FromContext(ctx)
257+
if w.retryCh == nil {
258+
return
259+
}
260+
annotations := obj.GetAnnotations()
261+
if annotations == nil {
262+
log.Error(fmt.Errorf("annotation does not exist"), "skipping HTTPProxy enqueue", "certificateName", obj.Name, "certificateNamespace", obj.Namespace)
263+
return
264+
}
265+
owner, ok := annotations[ownerAnnotation]
266+
if !ok {
267+
log.Error(fmt.Errorf("annotation not found for %s", ownerAnnotation), "skipping HTTPProxy enqueue", "certificateName", obj.Name, "certificateNamespace", obj.Namespace)
268+
return
269+
}
270+
271+
ns, name, err := cache.SplitMetaNamespaceKey(owner)
272+
if err != nil {
273+
log.Error(err, "skipping HTTPProxy enqueue", "certificateName", obj.Name, "certificateNamespace", obj.Namespace)
274+
return
275+
}
276+
h := projectcontourv1.HTTPProxy{}
277+
h.SetNamespace(ns)
278+
h.SetName(name)
279+
log.Info("re-queueing HTTPProxy", "namespace", ns, "name", name)
280+
w.retryCh <- event.TypedGenericEvent[*projectcontourv1.HTTPProxy]{
281+
Object: &h,
282+
}
283+
}
284+
285+
func (w *CertificateApplyWorker) recordApply(viaQueue viaQueueValue, applyResult applyResultValue) {
286+
if w.certificatesAppliedTotal == nil {
287+
return
288+
}
289+
w.certificatesAppliedTotal.WithLabelValues(certificateApplierName, string(viaQueue), string(applyResult)).Inc()
290+
}
291+
292+
// applyCertificate applies provided certificate object with provided context and apiserver client
293+
func applyCertificate(ctx context.Context, k8sClient client.Client, obj *cmv1.Certificate) error {
294+
return k8sClient.Patch(ctx, obj, client.Apply, &client.PatchOptions{
295+
Force: ptr.To(true),
296+
FieldManager: "contour-plus",
297+
})
298+
}

0 commit comments

Comments
 (0)