Downward API

The Downward API exposes a curated subset of Pod and Container metadata from the Kubernetes API into the container as environment variables or files, so application code can read facts about itself — its own Pod name, the namespace it runs in, the node hosting it, its CPU/memory limits — without needing API-server credentials. The Kubernetes documentation defines the use case: “It is sometimes useful for a container to have information about itself, without being overly coupled to Kubernetes. The downward API allows containers to consume information about themselves or the cluster without using the Kubernetes client or API server.” (k8s.io — Downward API). The name “downward” reflects the data-flow direction: information flows down from the Pod spec into the running container, not up from the container into the API. The two delivery modes — environment variables and a special downwardAPI volume — mirror those of ConfigMap and Secret, with the same update-semantics asymmetry: env vars are frozen at container start (with one notable exception introduced for in-place resource resize), while volume-mounted files are live-updated by the kubelet. The Downward API is deliberately narrow in what it exposes: only the fields explicitly listed in the spec, not arbitrary fields of the Pod object. This is a security design choice — preventing a container from reading the Pod’s full spec (which might include other containers’ Secrets, image-pull credentials, sensitive annotations) by accident.

Mental Model

flowchart LR
    subgraph SPEC["Pod object (in API server)"]
        META["metadata:<br/>  name: my-app-0<br/>  namespace: payments<br/>  uid: 7f...<br/>  labels: { app: my-app }<br/>  annotations: { team: data }"]
        STATUS["status:<br/>  podIP: 10.244.3.15<br/>  hostIP: 10.0.5.4"]
        RES["spec.containers[0].<br/>resources:<br/>  requests: { cpu: 500m }<br/>  limits: { memory: 1Gi }"]
        NODE["spec.nodeName: ip-10-0-5-4"]
        SA["spec.serviceAccountName: payments-sa"]
    end
    subgraph PROJ["kubelet projection layer"]
        FR["fieldRef"]
        RFR["resourceFieldRef"]
    end
    subgraph CONTAINER["Container"]
        ENV["env:<br/>POD_NAME=my-app-0<br/>POD_NAMESPACE=payments<br/>NODE_NAME=ip-10-0-5-4<br/>CPU_REQUEST=500m"]
        VOL["downwardAPI volume<br/>/etc/podinfo/labels<br/>/etc/podinfo/cpu_limit"]
    end
    META --> FR
    STATUS --> FR
    NODE --> FR
    SA --> FR
    RES --> RFR
    FR --> ENV
    RFR --> ENV
    FR --> VOL
    RFR --> VOL

What this diagram shows. Two API “selectors” — fieldRef for object-level metadata and resourceFieldRef for container-level resource fields — pick fields out of the Pod object and inject them into the container as either env vars or files. The diagram makes visible the field-by-field nature of the API: you do not get the whole Pod spec, you get only the keys you explicitly named. The insight to extract: the Downward API is a curated, schema-validated projection of the Pod object, designed so application code can be “K8s-aware without being K8s-clienty” — useful for clustered apps that need to register peers by Pod name, for JVMs that need their memory limit to size the heap, and for log shippers that need to tag events with the namespace and node, all without granting the Pod any RBAC permission on the K8s API.

Mechanical Walk-through

What fields are exposable

The official list, distilled from k8s.io — Downward API:

Via fieldRef (Pod-level fields):

Available through both env vars AND downwardAPI volume:

  • metadata.name — the Pod’s name
  • metadata.namespace — the namespace
  • metadata.uid — the Pod’s UID (immutable per-Pod-object)
  • metadata.labels['<key>'] — value of a specific label
  • metadata.annotations['<key>'] — value of a specific annotation

Available through env vars only:

  • spec.serviceAccountName — Pod’s ServiceAccount
  • spec.nodeName — node the Pod is scheduled on
  • status.hostIP — primary IP of the node
  • status.hostIPs — dual-stack version of status.hostIP
  • status.podIP — Pod’s primary IP (typically IPv4)
  • status.podIPs — dual-stack version of status.podIP

Available through downwardAPI volume only:

  • metadata.labelsall labels, formatted as key="value" one per line in the projected file
  • metadata.annotationsall annotations, same format

Via resourceFieldRef (Container-level resource fields):

  • requests.cpu, requests.memory
  • limits.cpu, limits.memory
  • requests.ephemeral-storage, limits.ephemeral-storage
  • requests.hugepages-<pagesize>, limits.hugepages-<pagesize>

The crucial absences:

  • No spec.containers[*] — a container cannot read the spec of its sibling containers.
  • No spec.volumes, no imagePullSecrets — credentials and storage references are deliberately hidden.
  • No status beyond IPs — no status.conditions, no status.containerStatuses self-introspection.
  • No metadata.ownerReferences — you can’t trivially discover your parent Deployment/StatefulSet.

These omissions are intentional: the field list is the security boundary. To get anything more, the Pod’s ServiceAccount needs real RBAC permission on the K8s API and the application has to use a Kubernetes client.

The two delivery modes

Mode 1: env vars

containers:
- name: app
  image: my-app:v1
  env:
    - name: POD_NAME
      valueFrom:
        fieldRef:
          fieldPath: metadata.name
    - name: POD_NAMESPACE
      valueFrom:
        fieldRef:
          fieldPath: metadata.namespace
    - name: NODE_IP
      valueFrom:
        fieldRef:
          fieldPath: status.hostIP
    - name: MEMORY_LIMIT_BYTES
      valueFrom:
        resourceFieldRef:
          containerName: app           # required: which container's resources to read
          resource: limits.memory
          divisor: 1                   # bytes; could also be 1Mi for mebibytes

The kubelet evaluates these expressions when constructing the container’s environment at start. They are frozen for the container’s lifetime under the classical semantics — once the process is running, the env vars don’t change.

Exception (K8s 1.35+): in-place Pod resource resize (the ability to change a running container’s CPU/memory requests without restarting) was promoted in 1.35. The K8s docs note that env-var resourceFieldRef values do not update when resources are resized — only the volume-mounted equivalents update dynamically. This is the same general principle (env vars are frozen) but now visibly limiting in a feature that is designed to mutate at runtime.

Mode 2: downwardAPI volume

containers:
- name: app
  volumeMounts:
    - name: podinfo
      mountPath: /etc/podinfo
 
volumes:
  - name: podinfo
    downwardAPI:
      items:
        - path: labels                  # file at /etc/podinfo/labels
          fieldRef:
            fieldPath: metadata.labels  # all labels, one per line
        - path: annotations
          fieldRef:
            fieldPath: metadata.annotations
        - path: cpu_limit
          resourceFieldRef:
            containerName: app
            resource: limits.cpu
            divisor: 1m                 # millicores
        - path: pod_name
          fieldRef:
            fieldPath: metadata.name

Each item creates a file at <mountPath>/<path> containing the projected value. Key behaviors:

  • The whole metadata.labels and metadata.annotations are only exposable via the volume, formatted as <key>="<escaped-value>" lines.
  • The mount is tmpfs, like Secrets — the data never touches the node’s disk.
  • Live updates: when the Pod’s labels or annotations are edited (e.g., kubectl label pod ...), or when its resources are resized in-place, the kubelet re-projects the affected files within the kubelet sync period.
  • Atomic projection: updates use the same rename-into-place pattern as ConfigMap volume mounts; the application never reads a torn write.

The divisor field for resource subdivision

When exposing CPU and memory, the raw representation might not be what your application wants. The divisor field controls the unit:

- name: CPU_LIMIT_MILLICORES
  valueFrom:
    resourceFieldRef:
      containerName: app
      resource: limits.cpu
      divisor: 1m              # value reported as N millicores (e.g., "2000")
- name: CPU_LIMIT_CORES
  valueFrom:
    resourceFieldRef:
      containerName: app
      resource: limits.cpu
      divisor: 1                # value reported as N cores (e.g., "2", rounded UP if fractional)
- name: MEM_LIMIT_MIB
  valueFrom:
    resourceFieldRef:
      containerName: app
      resource: limits.memory
      divisor: 1Mi              # value reported as N mebibytes

The kubelet rounds the resource value UP to the nearest multiple of the divisor. Pick the divisor that matches your application’s tuning needs — JVMs typically want 1Mi for memory and 1 for CPU; a thread-pool sizing routine might want 1m for fractional-CPU precision.

The “default to node allocatable” rule

If you reference limits.cpu or limits.memory for a container that doesn’t actually set those limits, the kubelet defaults to the node’s allocatable capacity rather than zero or an error. The K8s docs note: “If CPU and memory limits are not specified for a container, and you use the downward API to try to expose that information, then the kubelet defaults to exposing the maximum allocatable value for CPU and memory based on the node allocatable calculation.” This makes the field still useful for sizing decisions, but watch out for the inflation — a JVM that reads MEM_LIMIT from the Downward API and sizes its heap to 80% of that will use 80% of the node’s memory if no limit was set, OOM-killing every other Pod.

Configuration / API Surface

Canonical example: a clustered app self-registering by Pod name

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: kafka
spec:
  serviceName: kafka
  replicas: 3
  selector:
    matchLabels: { app: kafka }
  template:
    metadata:
      labels: { app: kafka }
    spec:
      containers:
      - name: kafka
        image: kafka:3.7
        env:
          - name: POD_NAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name        # kafka-0, kafka-1, kafka-2
          - name: POD_NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace
          - name: NODE_NAME
            valueFrom:
              fieldRef:
                fieldPath: spec.nodeName
          - name: POD_IP
            valueFrom:
              fieldRef:
                fieldPath: status.podIP
          - name: KAFKA_BROKER_ID
            value: $(POD_NAME)                  # uses K8s env-var-in-env-var substitution
          - name: KAFKA_ADVERTISED_LISTENERS
            value: PLAINTEXT://$(POD_NAME).kafka.$(POD_NAMESPACE).svc.cluster.local:9092

This is the canonical StatefulSet + Downward API + Headless Service pattern: Kafka brokers know their own stable identity (kafka-N), and the headless service plus <pod-name>.<svc>.<ns>.svc.cluster.local DNS gives them a stable resolvable hostname.

Canonical example: JVM heap sizing from container limits

containers:
- name: jvm-app
  image: openjdk:17
  resources:
    requests: { memory: 1Gi, cpu: 500m }
    limits:   { memory: 1Gi, cpu: 1 }
  env:
    - name: MEM_LIMIT_MIB
      valueFrom:
        resourceFieldRef:
          containerName: jvm-app
          resource: limits.memory
          divisor: 1Mi
  command: ["sh", "-c"]
  args:
    - |
      java -Xmx$(( MEM_LIMIT_MIB * 75 / 100 ))m \
           -XX:MaxRAMPercentage=75 \
           -jar /app.jar

Modern JVMs (17+) can do -XX:MaxRAMPercentage natively, reading the cgroup memory limit themselves — but pre-cgroup-v2 environments or non-JVM languages without that auto-detection still benefit from the explicit Downward API plumbing.

Canonical example: log shipper tagging events with Pod metadata

- name: fluent-bit
  image: fluent/fluent-bit
  env:
    - name: POD_NAME
      valueFrom: { fieldRef: { fieldPath: metadata.name } }
    - name: POD_NAMESPACE
      valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
    - name: NODE_NAME
      valueFrom: { fieldRef: { fieldPath: spec.nodeName } }
  # Fluent Bit's output configuration interpolates these into structured log fields

downwardAPI volume with all labels

volumes:
- name: podinfo
  downwardAPI:
    items:
      - path: labels
        fieldRef:
          fieldPath: metadata.labels      # ALL labels, only available via volume
      - path: annotations
        fieldRef:
          fieldPath: metadata.annotations

Read by an application like:

cat /etc/podinfo/labels
# app="payments"
# version="v1.4.2"
# env="prod"

This is the right pattern when the application needs to react to label changes at runtime (e.g., a service mesh sidecar that re-routes based on a label edit).

Failure Modes

  • Reference to a field that isn’t in the exposable list. Pod creation fails admission with field not implemented or similar. There is no “expose this random field” escape valve — the list is the list.
  • metadata.labels referenced in an env var. Not allowed (env var requires a single value; labels is a map). Use volume mode instead.
  • resourceFieldRef for a container with no limits.memory set. Silently returns the node’s allocatable memory, often much larger than intended. Symptom: JVM heap auto-sized to the whole node, OOM-killing siblings. Mitigation: always set limits explicitly, or document the dependence.
  • In-place resource resize, app reads env var. The env var stays at the old value; the volume file updates. Application that relies on MEM_LIMIT_MIB env var won’t see the new size. Mitigation: use volume mode if your application participates in in-place resize.
  • downwardAPI volume mounted with subPath. Same trap as ConfigMap volumes: subPath disables live updates. Use the directory mount pattern.
  • Trying to read another container’s resources. resourceFieldRef requires containerName, and the kubelet validates that the named container exists in the Pod, but you can read across siblings — useful for sidecars sizing themselves relative to the main container. This is not a failure mode but worth knowing.

Alternatives and When to Choose Them

  • Direct K8s API access via the Pod’s ServiceAccount token. When you need more than the Downward API exposes (e.g., owner reference walking, sibling Pod discovery, cross-namespace reads), use the in-cluster K8s client with explicit RBAC. The cost is a real K8s client dependency and a real RBAC posture.
  • ConfigMap for static cluster-wide info. Cluster name, region, AZ-to-rack mapping — these don’t change per Pod and belong in a ConfigMap, not the Downward API.
  • Projected Volume combining Downward API + ConfigMap + Secret + ServiceAccount token. When you want one mount path holding everything the container needs (e.g., /etc/app-info containing both Pod metadata and a TLS cert), use a projected volume. This is the modern pattern that supersedes single-source downwardAPI volumes for complex setups.
  • JVM/runtime cgroup-aware sizing. Modern JVMs (11+ with -XX:+UseContainerSupport, on by default in 17+), Node.js, .NET 7+, and Go (1.19+) read their container limits directly from cgroups. For these, the Downward API is redundant for resource limits — but still useful for non-resource fields like Pod name and namespace.
  • Init container that calls the K8s API to write to a shared volume. A heavier alternative when you need richer info than Downward API exposes; the init container uses a service-account token to query the API and writes results to an emptyDir for the main container to read. Use sparingly — adds complexity and RBAC surface.

Production Notes

  • The “no client, no RBAC” property is the real win. Production K8s shops universally prefer Downward API over direct K8s API access for simple introspection because: (a) it adds no client-library dependency to the app, (b) it requires no RBAC on the Pod’s ServiceAccount, (c) it’s stable across K8s minor versions (the field list rarely changes), (d) it’s available to dead-simple languages like shell scripts.
  • The Spotify “every Pod knows its node” pattern. Most production observability stacks tag every log line and metric with pod_name, pod_namespace, node_name, pod_ip — all from the Downward API. Spotify’s, Shopify’s, and Airbnb’s published infrastructure blogs all show variations of this; the Downward API plumbing is part of every shared “base container” or “platform sidecar” template.
  • Cluster-wide identity: pair with ServiceAccount projected tokens. For Pods that need cloud-IAM identity (e.g., S3 access), use workload identity via projected ServiceAccount tokens, not the Downward API. The Downward API only knows what’s in the Pod spec; cloud IAM identity is delivered via the OIDC-federated token path, which is a different (Projected Volume-based) mechanism.
  • Annotations as a coordination channel. A pattern: a controller annotates the Pod with state-of-the-world info (infra.example.com/leader: "true"), the Pod’s Downward API volume re-projects the annotation, and a sidecar in the Pod reads it from /etc/podinfo/annotations to update its behavior. This avoids requiring the sidecar to have K8s API access. Limit: kubelet sync period (~1 minute), so reaction time is slow.
  • In-place resize gotcha (1.35+). As in-place Pod resource resize becomes more common, expect a wave of “my app didn’t notice the new CPU limit” reports because the env-var path doesn’t propagate. The fix is volume-mount the resource limit if your application reacts to changes; otherwise document that env-var resource-limit consumers see the initial values only.

See Also