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

  1. Labels versus annotations
  2. Syntax and constraints of keys and values
  3. The labels Kubernetes recommends
  4. The Rutas Norte labelling scheme
  5. Equality-based selectors
  6. Set-based selectors
  7. Selectors in manifests: matchLabels and matchExpressions
  8. Who consumes selectors
  9. The golden rule: a Deployment's selector is immutable
  10. Annotations in practice
  11. kubectl label and kubectl annotate
  12. Useful everyday queries

  1. 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)

  1. 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 /:

app.kubernetes.io/component
└──────┬─────────┘ └───┬───┘
     prefix         name

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/ and k8s.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-dev
error: '[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 character

The correct version uses an annotation:

kubectl annotate deployment bookings-api \
  rutasnorte.example/[email protected] -n rutas-norte-dev
deployment.apps/bookings-api annotated

Rules 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.

  1. 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:

  • name is what it is: postgres. The same across every installation.
  • instance is 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: kubectl

A 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.

  1. 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 app and environment go 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-labels
NAME                           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

  1. 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!=pro
NAMESPACE         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   45m

A 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.

  1. 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   1h

A 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'

  1. Selectors in manifests: matchLabels and matchExpressions

Inside a YAML, selectors have two forms equivalent to the two families you have just seen.

matchLabels: equality

selector:
  matchLabels:
    app: bookings-api
    environment: dev

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: DoesNotExist
operator 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: dev

The reason is historical: Services belong to the core v1 group, which predates the enriched selector, and they only support equality.

  1. 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.

  1. 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 Deployment "bookings-api" is invalid: spec.selector: Invalid value:
field is immutable

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:

  1. The Deployment has selector: app=bookings-api and governs 4 pods with that label.
  2. You change the selector to app=bookings-api-v2.
  3. Instantly, the 4 pods stop matching. They become orphans, exactly like those in the --cascade=orphan experiment of the ReplicaSets lesson.
  4. The Deployment counts 0 pods of its own and creates 4 new ones.
  5. 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-dev

Route 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.

  1. Annotations in practice

Annotations look less important than labels until you discover how many things work thanks to them.

Annotations you have already used

kubectl get deployment bookings-api -o jsonpath='{.metadata.annotations}' | python3 -m json.tool
{
    "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 yaml or kubectl 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.

  1. kubectl label and kubectl annotate

Both 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"
deployment.apps/bookings-api labeled
deployment.apps/bookings-api annotated

Overwriting: --overwrite

If the key already exists, the command fails unless you say otherwise:

kubectl label deployment bookings-api rutasnorte.example/criticality=high
error: 'rutasnorte.example/criticality' already has a value (critical),
and --overwrite is false
kubectl label deployment bookings-api rutasnorte.example/criticality=high --overwrite
deployment.apps/bookings-api labeled

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-
deployment.apps/bookings-api unlabeled
deployment.apps/bookings-api annotated

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=yes

A 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:

kubectl label pods --all environment=pre --dry-run=server

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.

  1. 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              Running

An 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   2

Auditing 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 -20

Bulk 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=client

That --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/component

The -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           api

Common 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 app and environment.
  • Overly broad selectors. An app.kubernetes.io/part-of: rutas-norte as 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 metadata and not in spec.template.metadata. Pods inherit the template's, not the Deployment's. It is the mistake that breaks Services.
  • Forgetting --overwrite and assuming the command worked. kubectl label fails 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 label change on a pod to last. It is lost on the next rollout. Edit the template.
  • 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/version must be written app\.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,app gives 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

  1. Update k8s/base/web-store-deployment.yaml so that the Deployment and its template carry the eight labels of the Rutas Norte scheme (app, environment, the five app.kubernetes.io/ ones and criticality), plus four annotations: owner, dashboard, runbook and change-cause.
  2. Make sure the selector still has only app and environment, and explain in two lines why.
  3. Apply it and prove with one command that the pods carry the eight labels.
  4. 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:

  1. Every platform pod in pre or pro, in any namespace.
  2. Every platform Deployment that is not development.
  3. The pods of components with critical or high criticality that are not in Running state.
  4. A table with the environment, component, version and node of every platform pod.
  5. The Deployments missing the rutasnorte.example/criticality label (an audit of the scheme).
  6. Every pod, unfiltered, showing environment and app.kubernetes.io/component as 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.

  1. Note down how many pods there are and how many endpoints the Service has.
  2. Pick a pod and change its app label to web-store-isolated with kubectl label --overwrite.
  3. Observe immediately: how many pods are there now? What has the ReplicaSet done? Is the modified pod still alive? Who owns it?
  4. Check how many endpoints the Service has now and explain why.
  5. Explain what this technique is for in the real world and what its danger is.
  6. 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"
  1. The selector carries only app and environment because it is immutable and must identify the component stably. If it included app.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"}'
https://wiki.rutasnorte.example/runbooks/web-store

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-norte

A 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-dev
NAME                        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   2h

3 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=frontend
pod/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          11m

There are now 4 pods. What happened, step by step:

  • On changing app from web-store to web-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.

# 4. The Service's endpoints
kubectl get endpoints web-store -n rutas-norte-dev
NAME        ENDPOINTS                                      AGE
web-store   10.244.0.82:80,10.244.0.83:80,10.244.0.87:80   2h

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.

  1. 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-dev
pod "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   2h

Three 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved