CoursesPolicy-as-code at scaleGatekeeper ConstraintTemplates at scale

Gatekeeper ConstraintTemplates at scale

Templates, constraints, and syncing data.

Advanced30 min · lesson 6 of 13

A cookie cutter and a tray of cookies. The cutter holds the shape; the tray holds however many cookies you press out of it, each one finished with different sprinkles. Gatekeeper splits Kubernetes policy the same way. A ConstraintTemplate is the cutter: the rule's logic, written once in Rego (the small policy language used by OPA, the Open Policy Agent engine that Gatekeeper embeds), plus a schema listing the knobs the rule accepts. A Constraint is one pressing of that cutter: which resources it applies to, and what those knobs are set to. Fifteen teams can share one template and still get fifteen different answers out of it.

The failure this design prevents is copy-paste policy. Hand-write fresh Rego for every new requirement and you finish the quarter with eighteen near-identical rules, four of which forgot to check initContainers (helper containers that run to completion before the main container starts). An attacker never has to beat your policy engine when one of your eighteen copies has a hole. They put the unapproved image in an init container and walk in. One tested template, parameterized eighteen ways, has one hole to find and one place to fix it.

What Applying a Template Actually Does

Applying a ConstraintTemplate is a code deploy wearing a config file's clothes. Gatekeeper reads it, compiles the Rego, and creates a brand-new CRD (CustomResourceDefinition, the mechanism that teaches the Kubernetes API server about an object type it did not ship with). From that moment the API server accepts objects of kind K8sAllowedRepos, checks their parameters against the schema you wrote, and Gatekeeper watches for them. The template is the program. The constraint is configuration that the API server type-checks on your behalf before Gatekeeper ever sees it.

template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sallowedrepos # MUST equal lowercase(spec.crd.spec.names.kind)
spec:
crd:
spec:
names:
kind: K8sAllowedRepos
validation:
openAPIV3Schema:
type: object
properties:
repos:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedrepos
import data.lib.containers
violation[{"msg": msg}] {
cs := containers.list(input.review.object)
c := cs[_]
matches := [r | r := input.parameters.repos[_]; startswith(c.image, r)]
count(matches) == 0
msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v",
[c.name, c.image, input.parameters.repos])
}
libs:
- |
package lib.containers
# every place a container image can hide in a Pod spec
list(obj) := cs {
cs := array.concat(
array.concat(
object.get(obj, ["spec", "containers"], []),
object.get(obj, ["spec", "initContainers"], [])),
object.get(obj, ["spec", "ephemeralContainers"], []))
}

Three details in there carry most of the scaling value. input.review.object is the resource the API server is asking about. input.parameters is whatever that particular constraint set. And libs holds shared Rego that the template's main rule imports, where library packages have to live under package lib.<name> and get imported as data.lib.<name>; the constraint framework enforces that naming, it is not a style preference. The list helper is why the init-container hole from a moment ago cannot come back, because every template that reasons about images pulls containers from the same three fields.

Two footnotes on that Rego. The violation[{"msg": msg}] head is the v0 dialect that the upstream Gatekeeper policy library still ships; if your build compiles template Rego as v1, the same rule is written violation contains {"msg": msg} if { ... }. And listing spec.ephemeralContainers covers the Pod at creation time only. A debug container attached to a running Pod later arrives through the pods/ephemeralcontainers subresource, and a webhook rule that says resources: ["*"] does not match subresources. Read your own ValidatingWebhookConfiguration before you count that path as covered.

terminal
kubectl apply -f template.yaml
sleep 3 # give the controller a moment to mint the CRD
kubectl get crd k8sallowedrepos.constraints.gatekeeper.sh
kubectl get constrainttemplate k8sallowedrepos -o jsonpath='{.status.created}{"\n"}'
output
constrainttemplate.templates.gatekeeper.sh/k8sallowedrepos created
NAME CREATED AT
k8sallowedrepos.constraints.gatekeeper.sh 2026-07-27T09:14:22Z
true

Two failures greet almost everybody on day one. Name the template anything other than the lowercase form of its kind and Gatekeeper's own webhook rejects it on the spot, because Gatekeeper validates its own resources through the same admission path it uses for yours.

terminal
kubectl apply -f bad-name.yaml
output
Error from server (Forbidden): error when creating "bad-name.yaml": admission webhook
"validation.gatekeeper.sh" denied the request: ConstraintTemplate's name
"allowedrepos" is not equal to the lowercase of CRD's Kind: "k8sallowedrepos"

The second failure is Rego that does not compile. Normally you get rejected for that too, since the webhook compiles the template before the API server stores it. Normally is the load-bearing word. Gatekeeper installs its validating webhook with failurePolicy: Ignore so that a sick policy controller cannot take the whole cluster offline, which means that if the Gatekeeper pods are restarting or wedged at the moment you apply, the API server shrugs, stores the broken template, and the compile error surfaces only in the object's status.

terminal
kubectl apply -f broken-rego.yaml
kubectl get constrainttemplate k8sbrokenrule \
-o jsonpath='{.status.byPod[0].errors}' | jq .
output
constrainttemplate.templates.gatekeeper.sh/k8sbrokenrule created
[
{
"code": "ingest_error",
"location": "",
"message": "Could not ingest Rego: rego_parse_error: unexpected } token"
}
]

Wire that second command into whatever watches your cluster. A template carrying a non-empty status.byPod[].errors is a control you believe you have and do not, which is worse than having no control at all, because nobody goes looking for the gap. The same logic argues for flipping the webhook's failure policy to Fail once you trust your Gatekeeper deployment's availability, so a dead policy engine blocks writes instead of waving them through.

One Template, Many Constraints

Now press the cutter twice. Production gets a short allowlist and a hard no. The sandbox clusters get a longer list and record-only mode, so you find out what would break before anything does.

constraints.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
name: allowed-repos-prod
spec:
enforcementAction: deny # the default if you omit it
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaceSelector:
matchLabels:
acme.example/tier: prod
excludedNamespaces: ["kube-system", "gatekeeper-system"]
parameters:
repos:
- "registry.internal.acme.example/"
- "ghcr.io/acme/"
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
name: allowed-repos-sandbox
spec:
enforcementAction: dryrun
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaceSelector:
matchLabels:
acme.example/tier: sandbox
parameters:
repos:
- "registry.internal.acme.example/"
- "docker.io/library/"

The match block is your blast-radius dial, and it takes more than kinds. namespaces and excludedNamespaces accept names with a trailing wildcard such as team-*. labelSelector filters on labels of the object itself. namespaceSelector filters on labels of the namespace the object lands in. scope narrows to Cluster or Namespaced resources. Prefer namespaceSelector for anything that grants leniency, because a label on the Pod can be set by anyone who can create a Pod, and a self-labelled exemption is an opt-out button for attackers. A label on the namespace usually needs cluster-admin. Gatekeeper also honours the admission.gatekeeper.sh/ignore namespace label, but only for namespaces you listed at install time with --exempt-namespace, which is the mechanism for bootstrap namespaces that must come up before Gatekeeper does.

terminal
kubectl apply -f constraints.yaml
kubectl get constraints
output
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod created
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox created
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod deny <none>
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox dryrun <none>

Both counts read <none>, which trips people up. Enforcement started the instant the constraint existed, but the numbers come from a separate background job, the audit loop, which re-checks everything already running every 60 seconds by default (--audit-interval). Wait one sweep and look again.

terminal
sleep 90
kubectl get constraints
output
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod deny 3
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox dryrun 41

Forty-one sandbox violations on day one is the number that tells you a same-day flip to deny in production would have paged you at dinner. That is the whole argument for dry-run, expressed as an integer you can paste into a rollout ticket. The three in production are workloads that predate the constraint, still running, still violating, and now visible.

One more thing about that schema. The generated CRD is structural, so the API server prunes any parameter you did not declare. Typo repos as repo and the apply succeeds with no complaint at all.

terminal
kubectl apply -f constraint-typo.yaml
kubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.spec.parameters}{"\n"}'
sleep 90
kubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.status.totalViolations}{"\n"}'
output
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod configured
{}
0

Follow what an empty parameters does to the rule, because the answer is the opposite of what most people guess. input.parameters.repos is undefined, so the comprehension produces an empty list, so count(matches) == 0 holds for every container. You would expect production to start rejecting everything. Instead the next line builds the message with sprintf(..., [c.name, c.image, input.parameters.repos]), that reference is undefined too, so msg never gets a value, so the rule body fails and no violation is produced at all. A one-character typo turned a deny into an allow, quietly, with a green apply and a status that reads zero.

The policy that never fires
There is a second way to build the same silence. In Rego, not X succeeds only when X is undefined or false, and the number 0 is neither of those. So not count(matches) never succeeds, and a violation rule written that way is dead code that reports zero forever. It applies cleanly, shows 0 under TOTAL-VIOLATIONS, and stops nothing. Both bugs land in the same place: a control that looks healthy and blocks nothing. Zero violations is not evidence. The only evidence is a manifest that must be denied, run against the policy on every change, plus one that must pass.

What a Denied Admission Actually Returns

Underneath, Gatekeeper's webhook always answers the API server with HTTP 200, meaning the request reached the webhook and it had an opinion, plus an AdmissionReview body carrying allowed: true or allowed: false and a message. The API server is what turns a false into the rejection your client sees. Three enforcementAction values decide which of those the caller gets, and two of the three are completely invisible to the person who wrote the bad manifest.

terminal
kubectl apply -n payments -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: web
image: docker.io/library/nginx:1.29
EOF
echo "exit=$?"
output
Error from server (Forbidden): error when creating "STDIN": admission webhook
"validation.gatekeeper.sh" denied the request: [allowed-repos-prod] container
<web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos
are ["registry.internal.acme.example/", "ghcr.io/acme/"]
exit=1

Read that reply closely, because it is the contract the rest of your tooling depends on. HTTP 403 Forbidden, the code that means authenticated and refused, so the object was never persisted. The webhook's name, so you know which of your admission controllers spoke. The constraint name in square brackets, which is why deny-privileged-prod beats policy-7 as a name. Then your msg string verbatim, which is the only place a developer learns what to change. A message that reads "violation found" costs your platform team a support ticket every single time it fires.

Switch the same constraint to warn and the object is created; the message arrives on standard error as a warning line and the exit code is 0. Switch it to dryrun and the caller sees nothing whatsoever. The violation exists only in the constraint's status and in the gatekeeper_violations metric.

terminal
kubectl patch k8sallowedrepos allowed-repos-prod --type=merge \
-p '{"spec":{"enforcementAction":"warn"}}'
kubectl apply -n payments -f bad-pod.yaml
echo "exit=$?"
output
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod patched
Warning: [allowed-repos-prod] container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are ["registry.internal.acme.example/", "ghcr.io/acme/"]
pod/web created
exit=0

Note the created Pod and the warning that changed nothing. warn is a fine two-week teaching mode and a terrible permanent state, because people learn to scroll past yellow text, and after a month your control is decoration with good intentions. Put the flip-to-deny date in the constraint's annotations and hold it. Newer Gatekeeper releases add a fourth value, enforcementAction: scoped, paired with a scopedEnforcementActions list. It exists because Gatekeeper now evaluates constraints at several enforcement points (the admission webhook, the background audit, the gator command-line tool, and any Kubernetes ValidatingAdmissionPolicy objects it generates), and scoped lets one constraint deny at one of those while only warning at another. Check what your installed version serves before you write it into a manifest.

You Matched Pods, They Shipped a Deployment

This one costs teams an afternoon roughly once per cluster. Your constraint matches Pod. Almost nobody applies Pods; they apply Deployments, and the Deployment sails through admission untouched, because a Deployment is not a Pod.

terminal
kubectl apply -n payments -f deployment.yaml
kubectl get deploy -n payments web
kubectl describe rs -n payments web-6f8c9d5b7 | tail -4
output
deployment.apps/web created
NAME READY UP-TO-DATE AVAILABLE AGE
web 0/3 0 0 25s
Events:
Type Reason Age From Message
---- ------ ---- ------------------- -------
Warning FailedCreate 14s replicaset-controller Error creating: admission webhook "validation.gatekeeper.sh" denied the request: [allowed-repos-prod] container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are ["registry.internal.acme.example/", "ghcr.io/acme/"]

The developer sees a green kubectl apply and a Deployment stuck at 0 of 3, and the actual reason sits in a ReplicaSet's events, two objects away. Their continuous integration job (the automation that runs on every merge) reports success. The pipeline moves on. You have three ways out: match the workload kinds directly (Deployment, StatefulSet, DaemonSet, Job, CronJob) and reach into spec.template.spec in the Rego, keep matching Pods only and accept the late feedback, or have Gatekeeper generate the implied Pod for you and check that.

expansion.yaml
apiVersion: expansion.gatekeeper.sh/v1alpha1
kind: ExpansionTemplate
metadata:
name: expand-deployments
spec:
applyTo:
- groups: ["apps"]
kinds: ["Deployment", "StatefulSet", "DaemonSet"]
versions: ["v1"]
templateSource: "spec.template"
generatedGVK:
group: ""
version: "v1"
kind: "Pod"

With that applied, creating the Deployment runs your Pod-scoped constraints against the Pod it *would* produce, and the denial arrives at kubectl apply time, with the message flagged as implied by expand-deployments so you can tell it came from a generated object rather than a real one. Resource expansion is a newer feature gated by a controller flag (--enable-generator-resource-expansion) and served under an alpha or beta API version depending on your release, so run kubectl api-resources | grep expansion and confirm rather than assuming. Whichever route you take, the test that proves it works is the boring one: apply a violating Deployment, not a violating Pod, because Deployments are what your users actually write.

From kubectl apply to a Gatekeeper answer
1API server receives the request
Authenticates the caller, authorizes it, runs mutating webhooks, validates against the schema, then works out which validating webhooks match this kind
2AdmissionReview posted to validation.gatekeeper.sh
Over TLS, on the hot path of the request, 3 second timeout, and failurePolicy Ignore by default, so an unhealthy Gatekeeper means the request passes unchecked
3Gatekeeper selects the matching constraints
kinds, namespaces, excludedNamespaces, labelSelector, namespaceSelector, scope
4Rego runs once per matching constraint
input.review holds the object, input.parameters holds that constraint's settings, data.inventory holds whatever cluster state you chose to sync
5The violation set comes back empty or not
Empty means allowed:true; non-empty carries your msg string and nothing else
6enforcementAction shapes the reply
deny returns 403 Forbidden and exit code 1; warn creates the object plus a Warning line; dryrun creates it silently and records to status and metrics only

Teaching a Policy About the Rest of the Cluster

A doorman who can only see the person standing in front of him can check that person's ID. He cannot notice that the same name has already walked in three times tonight. Admission works the same way. input.review describes one object, so any rule that needs to compare against what already exists in the cluster is blind without help. That help is the Config resource, which tells Gatekeeper to replicate chosen resource types into OPA's in-memory cache.

config.yaml
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config # the name and namespace are both fixed
namespace: gatekeeper-system
spec:
sync:
syncOnly:
- group: "networking.k8s.io"
version: "v1"
kind: "Ingress"
- group: ""
version: "v1"
kind: "Namespace"

Synced objects land at predictable paths. Cluster-scoped things sit at data.inventory.cluster[<groupVersion>][<kind>][<name>], namespaced things at data.inventory.namespace[<ns>][<groupVersion>][<kind>][<name>], where <groupVersion> is "v1" for core types and "networking.k8s.io/v1" for the rest. Recent versions also let you spread the same list across separate SyncSet objects instead of the single cluster-wide Config, which helps when different teams own different slices of what gets cached. Either way, a rule can now ask a question that no single object can answer: is this hostname already claimed by somebody else?

uniquehost.rego
package k8suniqueingresshost
violation[{"msg": msg}] {
input.review.kind.kind == "Ingress"
input.review.kind.group == "networking.k8s.io"
host := input.review.object.spec.rules[_].host
other := data.inventory.namespace[ns]["networking.k8s.io/v1"]["Ingress"][name]
other.spec.rules[_].host == host
not same_object(other, input.review)
msg := sprintf("host %v is already claimed by ingress %v/%v", [host, ns, name])
}
same_object(obj, review) {
obj.metadata.namespace == review.object.metadata.namespace
obj.metadata.name == review.object.metadata.name
}

The attack this stops is worth spelling out. Without it, anyone who can create an Ingress in any namespace can claim payments.acme.example, and depending on how your ingress controller breaks the tie, some or all of that traffic starts arriving at their Pods instead of the payments team's. Session cookies, bearer tokens, card numbers, whatever the client was about to send. It is a takeover that needs no exploit and leaves nothing to patch. The control is one policy that can see the other Ingresses.

terminal
kubectl apply -n scratch -f copycat-ingress.yaml
output
Error from server (Forbidden): error when creating "copycat-ingress.yaml": admission
webhook "validation.gatekeeper.sh" denied the request: [unique-ingress-host] host
payments.acme.example is already claimed by ingress payments/checkout
Synced inventory is a cache, and it is a copy
Two things follow from that. First, the cache is eventually consistent, so two conflicting Ingresses created in the same instant can both be admitted before either one shows up in inventory. Uniqueness policies are a strong deterrent and a weak guarantee, so keep the audit loop running to catch the race after the fact. Second, everything you sync is copied into the policy controller's memory, and into any violation message a rule can build from it. Never add Secret to syncOnly. Sync ConfigMap only when you know what is inside them, budget memory for the object count you actually have, and watch the gatekeeper_sync metric so that a fat new resource type does not get the controller killed for using too much memory while it is holding your guardrails.

Keep the Cluster Out of Your Edit Loop

Rego inside a CRD is still code, and a cluster is a miserable compiler: slow, shared, and it reports parse errors into a status field. Gatekeeper ships gator, a command-line tool that evaluates templates and constraints against local files with no cluster involved, which makes a policy change reviewable like any other pull request.

terminal
gator test -f template.yaml -f constraints.yaml -f bad-pod.yaml
echo "exit=$?"
output
[allowed-repos-prod] Message: "container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are [\"registry.internal.acme.example/\", \"ghcr.io/acme/\"]"
exit=1

Run that on every commit that touches policy, with fixtures for both answers: one manifest that must be denied and one that must pass. The passing fixture is the one people skip, and it is the one that catches the template that started denying everything after a bad refactor. gator verify goes further, using a Suite resource that holds named cases with the message each one expects, which is where a shared platform repository should end up.

Rolling Out Without Breaking Friday

Ship every new constraint as dryrun first, let the audit loop run for a week, read the violation count per namespace, then flip to deny in waves by namespace label. Publish the date of each wave and meet it. A dry-run that renews itself quietly every quarter has taught your organization that policy is negotiable, and that lesson is far harder to reverse than any YAML.

Two numbers will surprise you at scale. Constraint status keeps a capped sample of violations, 20 by default, set by --constraint-violations-limit, so a constraint reporting 20 findings may really have 2,000. Read totals from the gatekeeper_violations metric and treat status as something to eyeball. The second number is latency. Every webhook call sits on the API server's hot path, so watch apiserver_admission_webhook_admission_duration_seconds as your template count grows. Rego that walks a large synced inventory on every Pod creation is how a policy engine becomes an availability incident, and slow admission looks exactly like a broken cluster to everyone downstream of it.

Treat template changes as schema migrations, because that is what they are. Adding a required parameter breaks every existing constraint that lacks it, and constraints are usually owned by teams who did not read your release notes. Keep templates and constraints in Git as the source of truth, rehearse Gatekeeper upgrades on a lab cluster one version ahead, and remember that an empty gatekeeper-system after a cluster rebuild is an open door, not a clean slate.

Try this

Run this on a kind cluster or any lab you can throw away, so you see the real shape of the output rather than a screenshot from somebody's blog. Install Gatekeeper, apply the template, apply the prod constraint on its own with enforcementAction: warn, then create one Deployment whose image comes from Docker Hub. Wait out an audit sweep and inspect all three layers.

terminal
kubectl get constrainttemplate -o custom-columns=NAME:.metadata.name,CREATED:.status.created
kubectl get constraints
kubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.status.totalViolations}{"\n"}'
output
NAME CREATED
k8sallowedrepos true
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod warn 1
1

Then break it on purpose twice: rename repos to repo in the constraint and watch the count fall to 0 while nothing is being blocked, and change count(matches) == 0 to not count(matches) and watch the same 0 appear for a different reason. Feeling both of those fail open is worth more than reading about them.

Takeaway

Make the template a tested library, make the constraint nothing but parameters and a match block, put a published date on every dry-run before it becomes a deny, and never accept a zero from a policy you have not watched deny something.

Next: Kyverno's mutate, generate and verifyImages rules, where policy repairs and proves rather than only refusing.

Quick check
01You apply a ConstraintTemplate whose spec.crd.spec.names.kind is K8sAllowedRepos. What does Gatekeeper do with it?
Incorrect — a template carries logic but binds to nothing. Until you create a constraint, no resource is matched and no rule is evaluated.
Incorrect — Gatekeeper does not stage policy through ConfigMaps. The template is itself the API object, and its Rego is compiled when the template is admitted.
Correct — the template generates a CustomResourceDefinition, which is why constraints are ordinary typed Kubernetes objects and why the API server validates (and prunes) parameters before Gatekeeper ever sees them.
Incorrect — Gatekeeper serves every constraint from the one webhook, validation.gatekeeper.sh. Templates add CRDs, never webhook registrations.
02A constraint is set to enforcementAction: dryrun. A developer applies a Pod that violates it. What does that developer see?
Correct — dryrun is silent to the caller by design, which is what makes it safe for measuring blast radius and useless for teaching anyone anything.
Incorrect — that is warn. The two get confused constantly, and the difference is precisely whether the caller is told anything at all.
Incorrect — a 403 and a created object cannot both happen. A denial means the API server never persisted the object.
Incorrect — the audit loop only writes violations into constraint status and metrics. Gatekeeper never deletes existing resources.
03kubectl apply -f deployment.yaml prints deployment.apps/web created, but kubectl get deploy web shows 0/3 and kubectl describe rs web-6f8c9d5b7 shows a FailedCreate event quoting validation.gatekeeper.sh and your allowed-repos constraint. What is happening, and what fixes it?
Incorrect — a timeout produces an error about the webhook failing to respond, and what happens next is decided by failurePolicy (Gatekeeper ships Ignore, so the request would have been allowed). These events quote your own constraint's message, so the webhook answered fine.
Correct — a Deployment is not a Pod, so Pod-scoped constraints only bite when the controller tries to create Pods, which is why the failure surfaces late and inside another object's events.
Incorrect — dryrun blocks nothing anywhere. These events show real denials, so this constraint is enforcing.
Incorrect — audit is asynchronous reporting only. It never gates admission and cannot hold a ReplicaSet at zero.

Related