We closed the previous lesson pointing at a loose thread that has run through the whole module. The selector of a ReplicaSet adopting pods, the selector of a Service finding its destinations, the environment label network policies will use to select namespaces, the kubernetes.io/change-cause annotation documenting every revision, the pod-template-hash the Deployment adds by itself, and all the -l queries we have been using since the first lesson: all of that is labels and annotations. We have been using them out of necessity, without any system. This lesson puts them in order, and it is no minor matter: in a cluster with six components across three environments, the labelling scheme is what separates a navigable platform from a chaos of anonymous objects. You will see the exact difference between a label and an annotation, the syntax and constraints of keys and values, the labels Kubernetes recommends and how they take shape in Rutas Norte's official scheme, equality- and set-based selectors in all their forms, who consumes them, why a Deployment's selector is immutable, the real use of annotations and the everyday queries that will make you productive.
Contents
- Labels versus annotations
- Syntax and constraints of keys and values
- The labels Kubernetes recommends
- The Rutas Norte labelling scheme
- Equality-based selectors
- Set-based selectors
- Selectors in manifests:
matchLabelsandmatchExpressions - Who consumes selectors
- The golden rule: a Deployment's selector is immutable
- Annotations in practice
kubectl labelandkubectl annotate- Useful everyday queries
- Labels versus annotations
Both live in metadata and both are key-value pairs. That is where the resemblance ends.
metadata:
name: bookings-api
labels: # to IDENTIFY and SELECT
app.kubernetes.io/name: bookings-api
environment: pro
annotations: # to ATTACH INFORMATION
kubernetes.io/change-cause: "v2.5.0 - availability cache (RN-482)"
rutasnorte.example/owner: [email protected]| Aspect | Labels (labels) |
Annotations (annotations) |
|---|---|---|
| Purpose | Identify and select objects | Attach non-identifying metadata |
| Can they be queried? | Yes: kubectl get -l, selectors |
No: there is no --annotation-selector |
| Are they indexed? | Yes, etcd indexes them | No |
| Value size | Maximum 63 characters | Up to 256 KiB in total |
| Value characters | Alphanumerics, -, _, . |
Anything: JSON, long text, URLs, line breaks |
| Who uses them | Controllers, Services, the scheduler, you | Tools, kubectl, Ingress controllers, humans |
| Cost of having many | An impact on query performance | Practically nil |
| Example | app: bookings-api |
kubernetes.io/change-cause: "..." |
The decision rule, which resolves 100 % of doubts:
Will you want to search for objects by this data, or will a selector use it? It is a label. Is it information that gets read but not searched? It is an annotation.
Examples applied to Rutas Norte:
| Data | Type | Why |
|---|---|---|
Component (bookings-api) |
Label | Services and ReplicaSets select by it |
Environment (pro) |
Label | It is queried constantly and NetworkPolicies will use it |
Version (2.5.0) |
Label | Useful for querying which version runs where |
| The owning team's email | Annotation | It is read during an incident, not searched by |
| The reason for the last rollout | Annotation | Free-form, long text |
| The monitoring dashboard URL | Annotation | It contains : and /, forbidden in label values |
| The deployed commit hash | Annotation (or a short label) | It is usually informative |
| The database password | Neither of them | Never. It goes in a Secret (03-02) |
- Syntax and constraints of keys and values
Kubernetes is strict about the format, and knowing the rules avoids baffling rejections.
The structure of a key
A key has a name and, optionally, a prefix separated by /:
Name rules (the mandatory part):
- Maximum 63 characters.
- It must begin and end with an alphanumeric character.
- It can contain
-,_,.and alphanumerics in between.
Prefix rules (optional):
- It must be a valid DNS subdomain:
rutasnorte.example,app.kubernetes.io. - Maximum 253 characters.
- It is separated from the name by
/. - Reserved: the prefixes
kubernetes.io/andk8s.io/are reserved for Kubernetes itself and its core components. Do not use them for your own labels.
Rules for a label's value
- Maximum 63 characters (or empty, which is valid).
- If it is not empty, it must begin and end with an alphanumeric.
- Only alphanumerics,
-,_and.in between. - Forbidden: spaces,
/,:,@, accents,ñ, emojis.
Valid and invalid examples:
| Label | Valid? | Reason |
|---|---|---|
app: bookings-api |
Yes | The canonical format |
app.kubernetes.io/version: 2.5.0 |
Yes | A standard prefix, a value with dots |
rutasnorte.example/line: BIL-SAN |
Yes | The company's own prefix |
environment: pro |
Yes | No prefix, perfectly valid |
team: backend_bookings |
Yes | The underscore is allowed |
owner: [email protected] |
No | @ is forbidden in values |
dashboard: https://grafana.example/d/abc |
No | : and / forbidden. Use an annotation |
description: bookings API |
No | Spaces are forbidden |
región: north |
No | Accents are forbidden |
kubernetes.io/my-label: x |
No (discouraged) | A reserved prefix |
Trigger the rejection yourself:
kubectl label pod --all [email protected] -n rutas-norte-deverror: '[email protected]' is not a valid label value:
a valid label must be an empty string or consist of alphanumeric characters,
'-', '_' or '.', and must start and end with an alphanumeric characterThe correct version uses an annotation:
kubectl annotate deployment bookings-api \
rutasnorte.example/[email protected] -n rutas-norte-devRules for annotations
Keys follow the same rules as label keys. Values, on the other hand, are almost free: any string, including JSON, line breaks and special characters, with a combined limit of 256 KiB for all of an object's annotations.
That generous limit is what lets kubectl apply store the whole manifest in kubectl.kubernetes.io/last-applied-configuration, as we saw in Objects and Manifests.
- The labels Kubernetes recommends
Kubernetes defines a set of common labels under the app.kubernetes.io/ prefix. They are not mandatory and nothing enforces them, but they are the shared vocabulary understood by Helm, Kustomize, Argo CD, Prometheus and practically every tool in the ecosystem.
| Label | Meaning | Example at Rutas Norte |
|---|---|---|
app.kubernetes.io/name |
The application's name | bookings-api |
app.kubernetes.io/instance |
Identifies a specific installation of that application | bookings-api-pro |
app.kubernetes.io/version |
The current version | 2.5.0 |
app.kubernetes.io/component |
The role it plays within the architecture | api, frontend, cache, database, worker |
app.kubernetes.io/part-of |
The higher-level application it belongs to | rutas-norte |
app.kubernetes.io/managed-by |
The tool that manages the object | kubectl, helm, argocd |
The difference between name and instance is the hardest one:
nameis what it is:postgres. The same across every installation.instanceis which one it is:bookings-postgres-pro. It tells two installations of the same thing apart.
If Rutas Norte had two PostgreSQL databases —one for bookings and another for billing—, both would carry name: postgres but instance: bookings-postgres and instance: billing-postgres.
A complete example with all six:
metadata:
name: bookings-api
labels:
app.kubernetes.io/name: bookings-api
app.kubernetes.io/instance: bookings-api-pro
app.kubernetes.io/version: "2.5.0"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kubectlA practical note: version: "2.5.0" goes in quotes. Without them, YAML could read values such as 1.0 as a number and cause a type error, since label values must be strings.
An important warning that ties in with section 9: app.kubernetes.io/version must not go in the selector. If it did, changing version would stop the selector matching its own pods, and since the selector is immutable, you would have to recreate the Deployment on every rollout. The version is informative; identity comes from name and instance.
- The Rutas Norte labelling scheme
The time has come to settle the project's definitive scheme. So far we have used three minimum labels (app, app.kubernetes.io/part-of and environment); we consolidate and extend them.
The project's labels
| Label | Mandatory | Possible values | What for |
|---|---|---|---|
app |
Yes | web-store, bookings-api, bookings-postgres, redis-cache, notifications-worker, occupancy-reports |
The component's short identifier. It goes in the selectors |
environment |
Yes | dev, pre, pro |
The environment. It goes in the selectors and in the NetworkPolicies |
app.kubernetes.io/name |
Yes | The same as app |
Compatibility with the ecosystem |
app.kubernetes.io/part-of |
Yes | rutas-norte |
Groups the whole platform. Never in a selector |
app.kubernetes.io/component |
Yes | frontend, api, database, cache, worker, reports |
The architectural role |
app.kubernetes.io/version |
Yes | 1.8.0, 2.5.0... |
The deployed version. Never in a selector |
app.kubernetes.io/managed-by |
Recommended | kubectl, argocd |
Who manages the object |
rutasnorte.example/criticality |
Recommended | critical, high, medium, low |
Prioritises the response to incidents |
The scheme's golden rule, which follows from everything learned in the module:
Only
appandenvironmentgo into the selectors. They are the only two labels that identify the component unambiguously and that never change.
Everything else —version, component, criticality, manager— is informative and can change without recreating anything.
The master table: six components
| Component | app |
component |
version |
criticality |
Has a Service? |
|---|---|---|---|---|---|
web-store |
web-store |
frontend |
1.8.0 |
high |
Yes |
bookings-api |
bookings-api |
api |
2.4.0 |
critical |
Yes |
bookings-postgres |
bookings-postgres |
database |
16.2 |
critical |
Yes (headless in module 6) |
redis-cache |
redis-cache |
cache |
7.2 |
medium |
Yes |
notifications-worker |
notifications-worker |
worker |
1.2.0 |
high |
No |
occupancy-reports |
occupancy-reports |
reports |
1.0.3 |
low |
No |
The resulting manifest
This is how bookings-api looks with the full scheme applied:
# k8s/base/bookings-api-deployment.yaml (complete metadata)
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
namespace: rutas-norte-dev
labels:
app: bookings-api
environment: dev
app.kubernetes.io/name: bookings-api
app.kubernetes.io/instance: bookings-api-dev
app.kubernetes.io/version: "2.4.0"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kubectl
rutasnorte.example/criticality: critical
annotations:
kubernetes.io/change-cause: "v2.4.0 - initial rollout"
rutasnorte.example/owner: [email protected]
rutasnorte.example/dashboard: "https://grafana.rutasnorte.example/d/bookings-api"
rutasnorte.example/runbook: "https://wiki.rutasnorte.example/runbooks/bookings-api"
spec:
replicas: 2
selector:
matchLabels:
app: bookings-api # ONLY these two.
environment: dev # Immutable.
template:
metadata:
labels:
app: bookings-api
environment: dev
app.kubernetes.io/name: bookings-api
app.kubernetes.io/instance: bookings-api-dev
app.kubernetes.io/version: "2.4.0"
app.kubernetes.io/component: api
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kubectl
rutasnorte.example/criticality: critical
spec:
containers:
- name: api
image: node:20-alpine
# ...Note the asymmetry, exactly the one we learned about with ReplicaSets: the template carries nine labels and the selector demands only two. It is allowed and it is correct: the selector must be a minimal, stable subset; the other labels travel with the pods so they can be queried.
Apply it and observe the immediate power:
kubectl apply -f k8s/base/bookings-api-deployment.yaml
kubectl get pods -l app.kubernetes.io/component=api --show-labelsNAME READY STATUS LABELS
bookings-api-9c4d7e831-j2wsk 1/1 Running app=bookings-api,app.kubernetes.io/component=api,app.kubernetes.io/instance=bookings-api-dev,app.kubernetes.io/managed-by=kubectl,app.kubernetes.io/name=bookings-api,app.kubernetes.io/part-of=rutas-norte,app.kubernetes.io/version=2.4.0,environment=dev,pod-template-hash=9c4d7e831,rutasnorte.example/criticality=critical
- Equality-based selectors
A selector is a query over labels. The simplest kind compares values.
| Operator | Meaning | Example |
|---|---|---|
= |
Equal | app=bookings-api |
== |
Equal (an exact synonym of =) |
app==bookings-api |
!= |
Not equal | environment!=pro |
They are used with -l (or --selector), and several conditions separated by commas are combined with a logical AND:
# The bookings-api pods
kubectl get pods -l app=bookings-api
# The bookings-api pods IN development (logical AND)
kubectl get pods -l app=bookings-api,environment=dev
# Every platform pod that is NOT in production
kubectl get pods -A -l app.kubernetes.io/part-of=rutas-norte,environment!=proNAMESPACE NAME READY STATUS AGE
rutas-norte-dev bookings-api-9c4d7e831-j2wsk 1/1 Running 5m
rutas-norte-dev redis-cache-6c8f9d745-w2mtb 1/1 Running 1h
rutas-norte-dev web-store-6f9c4b8d7-42kxr 1/1 Running 1h
rutas-norte-pre bookings-api-7f1d3e942-c9plk 1/1 Running 45m
rutas-norte-pre web-store-6f9c4b8d7-d3jbx 1/1 Running 45mA detail about != worth knowing: it also selects objects that do not have that label at all. environment!=pro returns those in dev and pre as well as those lacking the environment label.
There is no logical OR in equality-based selectors. That is what set-based ones are for.
- Set-based selectors
More expressive. They operate on sets of values and on the existence of the key.
| Operator | Meaning | Example |
|---|---|---|
in |
The value is in the list | environment in (pre,pro) |
notin |
The value is not in the list | environment notin (dev) |
<key> |
The label exists, with any value | rutasnorte.example/criticality |
!<key> |
The label does not exist | !rutasnorte.example/criticality |
Applied examples:
# Components in the "serious" environments (logical OR)
kubectl get pods -A -l 'environment in (pre,pro)'
# Everything that is NOT development
kubectl get deploy -A -l 'environment notin (dev)'
# Objects whose criticality was forgotten: an audit of the scheme
kubectl get deploy -A -l 'app.kubernetes.io/part-of=rutas-norte,!rutasnorte.example/criticality'
# Critical or high-criticality components, in any environment
kubectl get pods -A -l 'rutasnorte.example/criticality in (critical,high)'NAMESPACE NAME READY STATUS AGE
rutas-norte-dev bookings-api-9c4d7e831-j2wsk 1/1 Running 12m
rutas-norte-dev bookings-api-9c4d7e831-p7xtr 1/1 Running 12m
rutas-norte-dev web-store-6f9c4b8d7-42kxr 1/1 Running 1h
rutas-norte-dev notifications-worker-8c7b5d94f-k2xrt 1/1 Running 1hA note on quoting: set-based selectors carry parentheses and spaces, which the shell would interpret. Always wrap them in single quotes.
And they can be combined with equality-based ones, separated by commas:
kubectl get pods -A -l 'app.kubernetes.io/part-of=rutas-norte,environment in (pre,pro),app.kubernetes.io/component!=cache'
- Selectors in manifests:
matchLabels and matchExpressions
matchLabels and matchExpressionsInside a YAML, selectors have two forms equivalent to the two families you have just seen.
matchLabels: equality
It is a map: every key must match (logical AND). It is what we have used throughout the module.
matchExpressions: sets
selector:
matchExpressions:
- key: app
operator: In
values: [bookings-api]
- key: environment
operator: In
values: [pre, pro]
- key: rutasnorte.example/deprecated
operator: DoesNotExistoperator |
Equivalent to | Does it need values? |
|---|---|---|
In |
in (…) |
Yes |
NotIn |
notin (…) |
Yes |
Exists |
<key> |
No |
DoesNotExist |
!<key> |
No |
Combining them
Both can be used at once; every condition is combined with a logical AND:
selector:
matchLabels:
app: bookings-api
matchExpressions:
- key: environment
operator: In
values: [pre, pro]Translation: "pods with app=bookings-api and with environment equal to pre or pro".
Which one to use
| Case | Recommendation |
|---|---|
| The selector of a Deployment, ReplicaSet or StatefulSet | matchLabels: simple, readable, and the selector should be minimal |
| The selector of a Service | Necessarily a flat map: it supports neither form, only a direct selector: |
| A NetworkPolicy spanning several environments or excluding something | matchExpressions |
| Pod affinity | matchExpressions |
Mind the Service exception, which we already saw in its lesson and is worth repeating here because it is a real asymmetry in the API:
# Service: a flat map, WITHOUT matchLabels
kind: Service
spec:
selector:
app: bookings-api
environment: dev# Deployment: WITH matchLabels
kind: Deployment
spec:
selector:
matchLabels:
app: bookings-api
environment: devThe reason is historical: Services belong to the core v1 group, which predates the enriched selector, and they only support equality.
- Who consumes selectors
Selectors are not only for manual queries. They are the coupling mechanism of the whole of Kubernetes: the way some objects find others without knowing their names.
| Who | What it selects | Lesson |
|---|---|---|
| ReplicaSet | The pods it must maintain and count | 02-02 |
| Deployment | The pods of its ReplicaSets (with pod-template-hash added) |
02-03 |
| Service | The pods it routes traffic to | 02-05 |
| StatefulSet and DaemonSet | Their pods | 06-01, 06-02 |
| Job and CronJob | The pods of their runs | 06-03 |
| NetworkPolicy | Which pods it protects and whose traffic it accepts | 04-06 |
| Pod affinity and anti-affinity | Which pods to sit next to, or not | 06-05 |
| PodDisruptionBudget | Which pods it protects from voluntary evictions | 09-05 |
| HorizontalPodAutoscaler | Indirectly, through the object it scales | 09-01 |
| Prometheus (ServiceMonitor) | Which pods to collect metrics from | 07-03 |
kubectl -l |
Whatever you like | 01-05 |
flowchart TB
POD["Pods with labels<br/>app=bookings-api<br/>environment=dev"]
RS["ReplicaSet<br/>maintains them"]
SVC["Service<br/>routes traffic to them"]
NP["NetworkPolicy<br/>protects them"]
PDB["PodDisruptionBudget<br/>stops them all being evicted"]
MON["ServiceMonitor<br/>collects their metrics"]
RS -->|selector| POD
SVC -->|selector| POD
NP -->|podSelector| POD
PDB -->|selector| POD
MON -->|selector| POD
The underlying lesson: a pod's labels are not decoration, they are its interface with the rest of the system. Changing a pod's label can take it out of a Service, leave it outside a network policy or make it invisible to its ReplicaSet. We will see this in exercise 3.
- The golden rule: a Deployment's selector is immutable
Check it:
kubectl patch deployment bookings-api --type=merge \
-p '{"spec":{"selector":{"matchLabels":{"app":"bookings-api","environment":"dev","new":"yes"}}}}'The same goes for ReplicaSets, StatefulSets and DaemonSets. Services are the exception: their selector can be modified, and that makes them the tool behind module 11's blue-green strategies.
Why it is immutable
The reason is the safety of the data... of the pods. Imagine changing it were allowed:
- The Deployment has
selector: app=bookings-apiand governs 4 pods with that label. - You change the selector to
app=bookings-api-v2. - Instantly, the 4 pods stop matching. They become orphans, exactly like those in the
--cascade=orphanexperiment of the ReplicaSets lesson. - The Deployment counts 0 pods of its own and creates 4 new ones.
- Result: 8 pods running, 4 of them with no controller, invisible to the Deployment, consuming resources and with nobody to update them or bring them back if they crash.
Worse still: if the Service was still pointing at app=bookings-api, those 4 orphans would keep receiving production traffic with no controller watching them.
Kubernetes prefers to fail on apply rather than let you reach that state.
How to change a selector when you really must
There are two routes, and the first is the good one:
Route A: replace the controller without interrupting the service.
# 1. Delete the Deployment leaving the pods alive
kubectl delete deployment bookings-api --cascade=orphan
# 2. Apply the new manifest, with the corrected selector
kubectl apply -f k8s/base/bookings-api-deployment.yaml
# 3. Check. If the new selector matches the old pods, it adopts them;
# if not, it creates its own and then the orphans have to be cleaned up
kubectl get pods -l app.kubernetes.io/part-of=rutas-norte -n rutas-norte-devRoute B: a new name and a transition through the Service. You create a Deployment under another name with the correct selector, wait for it to be ready and change the Service's selector to point at the new pods. It is, in essence, a blue-green rollout (11-04).
The practical consequence
Think the selector through before the first
apply. It is the least reversible decision in a manifest.
Hence the project rule you already know: in the selector, only app and environment. Never the version, never the manager, never the criticality, never part-of. Anything that can change over time stays out.
- Annotations in practice
Annotations look less important than labels until you discover how many things work thanks to them.
Annotations you have already used
{
"deployment.kubernetes.io/revision": "3",
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",...}",
"kubernetes.io/change-cause": "v2.4.0 - initial rollout",
"rutasnorte.example/owner": "[email protected]"
}| Annotation | Who sets it | What for |
|---|---|---|
kubernetes.io/change-cause |
You or your CI/CD | It shows up in kubectl rollout history (02-04) |
deployment.kubernetes.io/revision |
The Deployment controller | Each ReplicaSet's revision number |
kubectl.kubernetes.io/last-applied-configuration |
kubectl apply |
The basis of the three-way merge (01-06) |
kubectl.kubernetes.io/restartedAt |
kubectl rollout restart |
Forces a change in the template to recreate the pods |
Annotations that configure behaviour
This is the most powerful use: many controllers are configured through annotations, not through spec fields. The textbook case is the Ingress controller:
# A preview of module 4: do NOT apply this yet
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rutas-norte
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "8m"
nginx.ingress.kubernetes.io/rate-limit-rps: "50"
cert-manager.io/cluster-issuer: "letsencrypt-production"None of those lines is in the Ingress schema: they are instructions the NGINX controller and cert-manager read and apply. The reason for this design is that each Ingress implementation has different capabilities, and the standard API cannot cover them all. They are covered in Ingress Controllers and TLS and Certificates.
Annotations for humans
And the simplest, most profitable use: information that saves a night on call.
metadata:
annotations:
rutasnorte.example/owner: "[email protected]"
rutasnorte.example/on-call: "+34 600 000 000"
rutasnorte.example/dashboard: "https://grafana.rutasnorte.example/d/bookings-api"
rutasnorte.example/runbook: "https://wiki.rutasnorte.example/runbooks/bookings-api"
rutasnorte.example/repository: "https://git.rutasnorte.example/rutas-norte/bookings-api"
rutasnorte.example/commit: "a3f9c1b4e7d2"
rutasnorte.example/description: |
Bookings REST API. It reads availability from redis-cache and
persists to bookings-postgres. Critical dependencies: both.
On sustained 5xx, check the connection to bookings-postgres first.Note two things impossible with labels: values with @, : and /, and a multi-line value. That is why annotations exist.
At three in the morning, this is worth its weight in gold:
kubectl get deploy bookings-api -o jsonpath='{.metadata.annotations.rutasnorte\.example/runbook}{"\n"}'The non-negotiable rule: never secrets in annotations
Never store passwords, tokens, API keys or private certificates in annotations.
The reasons:
- They are read in plain text with
kubectl get -o yamlorkubectl describe, permissions that are usually granted widely. - They show up in logs: any dump of an object includes them.
- They are copied into
last-applied-configuration, ending up duplicated. - They travel to backups and GitOps tooling, spreading everywhere.
- They are not encrypted at rest inside etcd unless the cluster is configured for it, and even then the mechanism is designed for Secrets.
The same applies to labels, with the aggravating factor that they are also indexed and queryable.
That is what Secrets are for, in lesson 03-02, and their secure management in module 8. That plain-text POSTGRES_PASSWORD we left in the provisional bookings-postgres Deployment is exactly the debt we are going to pay in the next module.
kubectl label and kubectl annotate
kubectl label and kubectl annotateBoth commands share a syntax.
Adding
kubectl label deployment bookings-api rutasnorte.example/criticality=critical
kubectl annotate deployment bookings-api rutasnorte.example/on-call="+34 600 000 000"Overwriting: --overwrite
If the key already exists, the command fails unless you say otherwise:
It is a deliberate protection against accidentally trampling a label some selector is using.
Deleting: the - suffix
kubectl label deployment bookings-api rutasnorte.example/criticality-
kubectl annotate deployment bookings-api rutasnorte.example/on-call-That trailing hyphen is easy to miss when reading a script. Remember it: a key with a trailing hyphen means delete.
Bulk operations
# To several objects by name
kubectl label pods pod-a pod-b test=yes
# To ALL objects of a type in the namespace
kubectl label pods --all reviewed=2026-08-05
# Filtering by another selector
kubectl label deploy -l app.kubernetes.io/part-of=rutas-norte \
app.kubernetes.io/managed-by=kubectl --overwrite
# Across all namespaces
kubectl label ns -l app.kubernetes.io/part-of=rutas-norte audited=yesA safety warning: --all combined with --overwrite on a label that appears in some selector can pull pods out of their Service or their ReplicaSet on the spot. Always try --dry-run=server first:
A warning about labels set by hand
Changing a label with kubectl label on a pod managed by a Deployment does not survive: on the next rollout, the pods are recreated from the template and the change disappears. To make it last, edit spec.template.metadata.labels in the manifest.
And there is a far more serious side effect, which we will explore in exercise 3: if you change a label that is in the ReplicaSet's selector, the pod is orphaned instantly and the ReplicaSet creates another to replace it.
- Useful everyday queries
This is where the labelling scheme stops being bureaucracy and becomes productivity. The most profitable queries combine -l with the output formats we saw in The Kubernetes CLI.
Platform inventory
kubectl get pods -A -l app.kubernetes.io/part-of=rutas-norte \
-o custom-columns='ENVIRONMENT:.metadata.labels.environment,COMPONENT:.metadata.labels.app,VERSION:.metadata\.labels.app\.kubernetes\.io/version,POD:.metadata.name,STATUS:.status.phase'ENVIRONMENT COMPONENT VERSION POD STATUS
dev bookings-api 2.4.0 bookings-api-9c4d7e831-j2wsk Running
dev bookings-api 2.4.0 bookings-api-9c4d7e831-p7xtr Running
dev redis-cache 7.2 redis-cache-6c8f9d745-w2mtb Running
dev web-store 1.8.0 web-store-6f9c4b8d7-42kxr Running
pre bookings-api 2.4.0 bookings-api-7f1d3e942-c9plk Running
pro web-store 1.8.0 web-store-6f9c4b8d7-x4nbc RunningAn important syntax trick: in custom-columns, dots that are part of the label key must be escaped with \.. Otherwise, kubectl reads them as JSONPath path separators and finds nothing.
Which version runs in each environment
The query you will be asked for most often:
kubectl get deploy -A -l app=bookings-api \
-o custom-columns='NS:.metadata.namespace,DEPLOY:.metadata.name,IMAGE:.spec.template.spec.containers[0].image,REPLICAS:.spec.replicas'NS DEPLOY IMAGE REPLICAS
rutas-norte-dev bookings-api node:20-alpine 2
rutas-norte-pre bookings-api node:20-alpine 2Auditing the labelling scheme
Finding what breaks the project convention:
# Deployments without the part-of label: they escaped the scheme
kubectl get deploy -A -l '!app.kubernetes.io/part-of'
# Platform objects with no criticality assigned
kubectl get deploy,sts,ds -A -l 'app.kubernetes.io/part-of=rutas-norte,!rutasnorte.example/criticality'
# Pods without the environment label: no NetworkPolicy would reach them
kubectl get pods -A -l 'app.kubernetes.io/part-of=rutas-norte,!environment'Incident queries
# Everything critical that is NOT running
kubectl get pods -A -l 'rutasnorte.example/criticality=critical' \
--field-selector status.phase!=Running
# Aggregated logs from every replica of a component, with the pod prefix
kubectl logs -l app=bookings-api -n rutas-norte-pro --tail=50 --prefix
# Sort by restarts: who is suffering
kubectl get pods -A -l app.kubernetes.io/part-of=rutas-norte \
--sort-by=.status.containerStatuses[0].restartCount
# The namespace's recent events, the first thing to look at
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp | tail -20Bulk operations
# Restart every stateless component in an environment
kubectl rollout restart deploy -l 'app.kubernetes.io/part-of=rutas-norte' -n rutas-norte-dev
# Delete a WHOLE test environment (carefully)
kubectl delete all -l 'app.kubernetes.io/part-of=rutas-norte' -n rutas-norte-dev --dry-run=clientThat --dry-run=client before a bulk delete is a habit worth acquiring.
Combinations with -o wide and --show-labels
# See where everything is
kubectl get pods -A -l environment=pro -o wide
# See every label on an object
kubectl get deploy bookings-api --show-labels
# Group visually by a label
kubectl get pods -A -L environment,app.kubernetes.io/componentThe -L flag (capital L) is a small treasure: it adds columns with the value of the given labels, without filtering anything.
NAMESPACE NAME READY STATUS AGE ENVIRONMENT COMPONENT
rutas-norte-dev bookings-api-9c4d7e831-j2wsk 1/1 Running 30m dev api
rutas-norte-dev redis-cache-6c8f9d745-w2mtb 1/1 Running 2h dev cache
rutas-norte-dev web-store-6f9c4b8d7-42kxr 1/1 Running 2h dev frontend
rutas-norte-pre bookings-api-7f1d3e942-c9plk 1/1 Running 1h pre apiCommon Mistakes and Tips
- Putting the version in the selector. On rolling out a new version the selector stops matching, and since it is immutable, it forces you to recreate the Deployment. The selector carries only
appandenvironment. - Overly broad selectors. An
app.kubernetes.io/part-of: rutas-norteas a selector adopts and can delete other components' pods, as we saw in ReplicaSets. - Using forbidden characters in label values. No
@,:,/, spaces or accents. If the data needs them, it is an annotation. - Using the
kubernetes.io/prefix for your own labels. It is reserved. Use your organisation's domain:rutasnorte.example/. - Storing secrets in annotations or labels. They are read in plain text with
describe, they travel to backups and they end up in Git. They go in Secrets. - Putting the labels only in
metadataand not inspec.template.metadata. Pods inherit the template's, not the Deployment's. It is the mistake that breaks Services. - Forgetting
--overwriteand assuming the command worked.kubectl labelfails if the key exists. Always read the output. - Changing a label that is in a selector with
kubectl label. The pod is orphaned and the controller creates another. It can double capacity without you noticing. - Expecting a
kubectl labelchange on a pod to last. It is lost on the next rollout. Edit thetemplate. - Forgetting the single quotes in set-based selectors. The shell interprets the parentheses and spaces.
- Not escaping the dots in keys within
custom-columns.app.kubernetes.io/versionmust be writtenapp\.kubernetes\.io/version. - Tip: define the labelling scheme before deploying anything. Adding it later means editing every manifest and, if it touches selectors, recreating objects.
- Tip:
kubectl get pods -A -L environment,appgives you a tabulated view of the whole platform without filtering. It is the query to start any review with. - Tip: document the scheme in the repository's
k8s/README.md. A scheme only its designer knows is not a scheme.
Exercises
Exercise 1: Apply the full scheme to web-store
- Update
k8s/base/web-store-deployment.yamlso that the Deployment and itstemplatecarry the eight labels of the Rutas Norte scheme (app,environment, the fiveapp.kubernetes.io/ones andcriticality), plus four annotations: owner, dashboard, runbook andchange-cause. - Make sure the selector still has only
appandenvironment, and explain in two lines why. - Apply it and prove with one command that the pods carry the eight labels.
- Retrieve the runbook URL with a single command.
Exercise 2: Queries over the platform
With the three environments deployed, write the exact command for each question:
- Every platform pod in
preorpro, in any namespace. - Every platform Deployment that is not development.
- The pods of components with
criticalorhighcriticality that are not inRunningstate. - A table with the environment, component, version and node of every platform pod.
- The Deployments missing the
rutasnorte.example/criticalitylabel (an audit of the scheme). - Every pod, unfiltered, showing
environmentandapp.kubernetes.io/componentas extra columns.
Exercise 3: The orphaned pod experiment
This exercise shows why labels are a pod's interface with the rest of the system. Work in rutas-norte-dev with the web-store Deployment (3 replicas) and its Service.
- Note down how many pods there are and how many endpoints the Service has.
- Pick a pod and change its
applabel toweb-store-isolatedwithkubectl label --overwrite. - Observe immediately: how many pods are there now? What has the ReplicaSet done? Is the modified pod still alive? Who owns it?
- Check how many endpoints the Service has now and explain why.
- Explain what this technique is for in the real world and what its danger is.
- Leave the cluster as it was.
Solutions
Solution 1
# k8s/base/web-store-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-store
namespace: rutas-norte-dev
labels:
app: web-store
environment: dev
app.kubernetes.io/name: web-store
app.kubernetes.io/instance: web-store-dev
app.kubernetes.io/version: "1.8.0"
app.kubernetes.io/component: frontend
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kubectl
rutasnorte.example/criticality: high
annotations:
kubernetes.io/change-cause: "v1.8.0 - complete labelling scheme (RN-495)"
rutasnorte.example/owner: "[email protected]"
rutasnorte.example/dashboard: "https://grafana.rutasnorte.example/d/web-store"
rutasnorte.example/runbook: "https://wiki.rutasnorte.example/runbooks/web-store"
spec:
replicas: 3
revisionHistoryLimit: 5
minReadySeconds: 10
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: web-store
environment: dev
template:
metadata:
labels:
app: web-store
environment: dev
app.kubernetes.io/name: web-store
app.kubernetes.io/instance: web-store-dev
app.kubernetes.io/version: "1.8.0"
app.kubernetes.io/component: frontend
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kubectl
rutasnorte.example/criticality: high
annotations:
rutasnorte.example/owner: "[email protected]"
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"- The selector carries only
appandenvironmentbecause it is immutable and must identify the component stably. If it includedapp.kubernetes.io/version, rolling out 1.9.0 would stop the selector matching the new pods, and since it cannot be modified, the Deployment would have to be deleted and recreated on every rollout. Besides, a minimal selector avoids the risk of improper adoptions if another object shares informative labels.
kubectl apply -f k8s/base/web-store-deployment.yaml
kubectl get pods -l app=web-store -o jsonpath='{.items[0].metadata.labels}' | python3 -m json.tool{
"app": "web-store",
"app.kubernetes.io/component": "frontend",
"app.kubernetes.io/instance": "web-store-dev",
"app.kubernetes.io/managed-by": "kubectl",
"app.kubernetes.io/name": "web-store",
"app.kubernetes.io/part-of": "rutas-norte",
"app.kubernetes.io/version": "1.8.0",
"environment": "dev",
"pod-template-hash": "5b7c9f4d8",
"rutasnorte.example/criticality": "high"
}The eight from the scheme, plus the pod-template-hash the Deployment adds.
kubectl get deploy web-store -o jsonpath='{.metadata.annotations.rutasnorte\.example/runbook}{"\n"}'Solution 2
# 1. Platform pods in pre or pro
kubectl get pods -A -l 'app.kubernetes.io/part-of=rutas-norte,environment in (pre,pro)'
# 2. Deployments that are not development
kubectl get deploy -A -l 'app.kubernetes.io/part-of=rutas-norte,environment notin (dev)'
# 3. Critical or high that are not Running
kubectl get pods -A -l 'rutasnorte.example/criticality in (critical,high)' \
--field-selector status.phase!=Running
# 4. Inventory table
kubectl get pods -A -l app.kubernetes.io/part-of=rutas-norte \
-o custom-columns='ENVIRONMENT:.metadata.labels.environment,COMPONENT:.metadata.labels.app,VERSION:.metadata.labels.app\.kubernetes\.io/version,NODE:.spec.nodeName'
# 5. Audit: no criticality
kubectl get deploy -A -l 'app.kubernetes.io/part-of=rutas-norte,!rutasnorte.example/criticality'
# 6. Extra columns without filtering
kubectl get pods -A -L environment,app.kubernetes.io/component# Output of number 4
ENVIRONMENT COMPONENT VERSION NODE
dev bookings-api 2.4.0 rutas-norte
dev bookings-api 2.4.0 rutas-norte
dev redis-cache 7.2 rutas-norte
dev web-store 1.8.0 rutas-norte
pre bookings-api 2.4.0 rutas-norte
pro web-store 1.8.0 rutas-norteA note on question 3: -l filters by labels and --field-selector by object fields. They are different, complementary mechanisms; the fields available to --field-selector are limited (status.phase, spec.nodeName, metadata.name, metadata.namespace and little else).
Solution 3
# 1. Starting situation
kubectl get pods -l app=web-store -n rutas-norte-dev
kubectl get endpoints web-store -n rutas-norte-devNAME READY STATUS RESTARTS AGE
web-store-5b7c9f4d8-6kdnp 1/1 Running 0 10m
web-store-5b7c9f4d8-q3xwt 1/1 Running 0 10m
web-store-5b7c9f4d8-z8mrv 1/1 Running 0 10m
NAME ENDPOINTS AGE
web-store 10.244.0.81:80,10.244.0.82:80,10.244.0.83:80 2h3 pods, 3 endpoints.
# 2. Change the selector label on one pod
kubectl label pod web-store-5b7c9f4d8-6kdnp app=web-store-isolated --overwrite -n rutas-norte-dev
# 3. Observe
kubectl get pods -n rutas-norte-dev -l app.kubernetes.io/part-of=rutas-norte,app.kubernetes.io/component=frontendpod/web-store-5b7c9f4d8-6kdnp labeled
NAME READY STATUS RESTARTS AGE
web-store-5b7c9f4d8-6kdnp 1/1 Running 0 11m
web-store-5b7c9f4d8-h7pnk 1/1 Running 0 4s
web-store-5b7c9f4d8-q3xwt 1/1 Running 0 11m
web-store-5b7c9f4d8-z8mrv 1/1 Running 0 11mThere are now 4 pods. What happened, step by step:
- On changing
appfromweb-storetoweb-store-isolated, the pod stopped matching the selector of the ReplicaSet. - On the next turn of its reconciliation loop, the ReplicaSet counted 2 pods of its own where there should have been 3 and created a new one (
h7pnk, 4 seconds old). - The modified pod is still alive, but it has become an orphan: no controller is watching it.
kubectl get pod web-store-5b7c9f4d8-6kdnp -n rutas-norte-dev \
-o jsonpath='{.metadata.ownerReferences}{"\n"}'Empty output: it has no owner. Kubernetes stripped its ownerReferences when it stopped matching the selector.
There are still 3, but one has changed. The isolated pod (10.244.0.81) has dropped out of the Service because its selector also demands app=web-store; the new pod (10.244.0.87) has taken its place. The orphaned pod no longer receives traffic even though it is perfectly alive and healthy.
-
What it is for in the real world: it is the classic technique of isolation for debugging in production. If one of five pods shows anomalous behaviour —a memory leak, erratic responses, a suspicious log—, changing one of its selector labels pulls it out of the Service and the ReplicaSet in an instant. It stops receiving real customer traffic, the ReplicaSet creates a replacement so no capacity is lost, and you keep the pod intact, with its memory and its state, to inspect at leisure:
kubectl exec,kubectl logs, dumps, profiling. There is no need to reproduce the problem in a test environment: you have the patient alive on the table.The danger is twofold. First, that pod is no longer managed by anybody: if the node goes down or somebody evicts it, it disappears with no replacement, and above all it is easy to forget about it, consuming CPU and memory indefinitely. Second, if the label change is made by mistake or the other way round —giving a bare pod the labels of a governed component—, you trigger the adoption and possible immediate deletion we saw in the ReplicaSets lesson. The mandatory discipline is to label the isolated pod with the date and the reason, and delete it when you are done:
kubectl label pod web-store-5b7c9f4d8-6kdnp \
rutasnorte.example/isolated-on=2026-08-05 \
rutasnorte.example/incident=RN-501 -n rutas-norte-dev# 6. Cleanup
kubectl delete pod web-store-5b7c9f4d8-6kdnp -n rutas-norte-dev
kubectl get pods -l app=web-store -n rutas-norte-dev
kubectl get endpoints web-store -n rutas-norte-devpod "web-store-5b7c9f4d8-6kdnp" deleted
NAME READY STATUS RESTARTS AGE
web-store-5b7c9f4d8-h7pnk 1/1 Running 0 6m
web-store-5b7c9f4d8-q3xwt 1/1 Running 0 17m
web-store-5b7c9f4d8-z8mrv 1/1 Running 0 17m
NAME ENDPOINTS AGE
web-store 10.244.0.82:80,10.244.0.83:80,10.244.0.87:80 2hThree pods, three endpoints, all in order. And notice that deleting the orphan produced no replacement: the ReplicaSet already had its three pods and that one was not its own.
Conclusion
You close module 2 with the thread that ran through it all turned into a system. You can tell a label from an annotation without hesitation: the former identify and are queried, they are indexed, they have 63 characters and a restricted character set; the latter attach information that is read but not searched, they take up to 256 KiB and any content, including JSON and multi-line text. You know the full syntax of keys and values, you know kubernetes.io/ is a reserved prefix and that your organisation's domain is the right place for your own, and you have a command of the six labels Kubernetes recommends, including the distinction between name (what it is) and instance (which one it is).
You have settled Rutas Norte's definitive labelling scheme for its six components and three environments, with its golden rule: only app and environment go into the selectors, because a Deployment's selector is immutable and putting the version in it would condemn you to recreating the object on every rollout. You handle both families of selectors —equality with =, == and !=; sets with in, notin, exists and its negation— both on the command line with -l and in manifests with matchLabels and matchExpressions, without forgetting the Services' asymmetry, which only accepts a flat map. You know who consumes selectors, which is almost everybody: ReplicaSets, Deployments, Services, NetworkPolicies, PodDisruptionBudgets, affinity and the monitoring tooling. You use annotations for what they are good for —documenting the change, configuring Ingress controllers, keeping the runbook and the on-call number to hand— without ever making the mistake of storing a secret in them. And you have kubectl label and kubectl annotate on your belt with --overwrite and deletion with a trailing hyphen, plus the repertoire of queries that turns a large cluster into something navigable.
With this you close module 2 and the Rutas Norte platform is alive. web-store went from being that fragile pod in module 1 to a three-replica Deployment that recovers on its own; bookings-api, redis-cache and notifications-worker have theirs; the four components that receive connections have a stable address with balancing across replicas; you know how to deploy without downtime and to undo a broken rollout in under a minute; you have three environments separated into namespaces with the same manifest deployed to several of them; and everything is labelled with a coherent scheme the coming lessons are going to exploit.
But there are very visible outstanding debts. The PostgreSQL password is still written in plain text inside a manifest that lives in Git, and that database holds the name, ID number, phone and email of every Rutas Norte customer. The API URL is baked into the image instead of being injected per environment. No namespace has a quota, so a mistake in development can starve production of resources. And all our pods declare requests and limits by eye, with no criteria. Module 3, Configuration and Secret Management, tackles exactly that: we will separate configuration from code with ConfigMaps, we will get the credentials out of the manifests with Secrets, we will inject both as environment variables, we will put quotas and limits on each environment, we will understand the quality of service classes that decide which pod dies first when memory runs short, and we will give each component its own identity before the API with ServiceAccounts.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
