Skip to content

Use annotations instead of labels for Compose-managed container runtime state #14050

Description

@ndeloof

Context

Spun off from #13959 (comment) (@thaJeztah):

compose is already heavily depending on labels to store state, but we should start looking if we could transition to using Annotations, especially for state that shouldn't be persisted (doing a docker commit on a container, or running a container from an image that already has labels set could probably still provide "interesting" results)

Problem

Compose stores both identity and runtime state as container labels (com.docker.compose.project, service, container-number, oneoff, config-hash, image, depends_on, replace, version, project.working_dir, project.config_files, environment_file, …). Labels have two persistence problems annotations don't have:

  • docker commit on a Compose-managed container bakes all of this state into the resulting image;
  • containers inherit image labels, so running a container from such an image (or any image shipping com.docker.compose.* labels) makes it look like Compose-managed state: wrong config-hash comparisons, orphan detection, ps/down matching, etc.

HostConfig.Annotations is per-container, non-identifying runtime metadata — exactly the right home for state that must never leak into images.

Proposal

Split the current label set by role:

  • Runtime state → annotations: config-hash, image, depends_on, replace, container-number, oneoff, version, project.working_dir, project.config_files, environment_file — none of these should survive a docker commit.
  • Identity stays as labels (for now): project and service are used in server-side filters (docker ps --filter label=…, events, networks/volumes listing); the Engine API has no annotation-based filtering, and annotations don't exist on networks/volumes/images at all. Per the original comment, containerd namespaces may eventually be the right primitive for project scoping — that's engine-side work to track separately.

Backward compatibility

Migration must be invisible to users with running stacks:

  1. Phase 1 (dual write, fallback read): new containers get both labels and annotations; all readers prefer the annotation and fall back to the label. Stacks created by older Compose keep working (up recreates, down, ps).
  2. Phase 2 (annotation-first): stop writing the state labels on new containers; keep the fallback read path for at least a major-version window.
  3. Filters keep using the identity labels throughout; nothing changes for docker ps --filter label=com.docker.compose.project=… consumers, and third-party tooling relying on identity labels is unaffected.

A container recreated by a newer Compose gets the new layout; mixed projects (old + new containers) must reconcile correctly during phase 1, which the fallback read gives us.

Open questions

  • Exact split for container-number/oneoff: they are identity-adjacent but not used in server-side filters everywhere — audit each call site.
  • Annotations are only available for containers: networks, volumes and images keep labels regardless (their divergence-detection labels like com.docker.compose.network/volume and config-hash stay as-is).
  • Whether third-party ecosystems read the state labels (not just identity) — a deprecation note in release notes should cover phase 2.

Known direct label consumers

Compose labels were never a documented API, yet some external tools are known to read com.docker.compose.* labels directly from docker ps output instead of using compose ps or another supported interface — Docker Desktop has been identified as one such consumer. These consumers will need to be updated; the phase 1 dual-write window is their migration period, and does not turn the state labels into a compatibility commitment.

Blocker: the Engine API has no annotation filter

ContainerList returns annotations since Engine v27.0.0 (moby/moby ca0529f984, HostConfig.Annotations in each summary — verified empirically against v29.6.1 / API 1.55), so list-driven reads keep working once state moves to annotations. But server-side filtering by annotation does not exist (invalid filter 'annotation'; label only). This is a blocker for moving any filtered label, and Compose filters on more than identity today:

Label Filter kind Server-side usage
com.docker.compose.project equality + presence ContainerList (all lookups, ls project discovery), NetworkList, VolumeList, ImageList (pruner, watch), Events (up monitor), stats
com.docker.compose.service equality ContainerList (getDefaultFilters), ImageList (watch), stats
com.docker.compose.oneoff equality ContainerList (getDefaultFilters, start, monitor initial state), Events (up monitor)
com.docker.compose.config-hash presence ("compose-managed" marker) ContainerList: ls, getDefaultFilters (i.e. virtually every container lookup), monitor initial state
com.docker.compose.container-number equality (--index lookup) ContainerList (getSpecifiedContainer)
com.docker.compose.network equality NetworkList (down)

Consequence: two labels from the runtime state basket are filtered on today. Migrating them requires either an annotation filter landing engine-side, or Compose first rewriting those filter usages — the config-hash presence marker can be replaced by the project label presence, and the container-number equality by client-side selection over a service-scoped list. Both rewrites are Compose-local and should land as a phase 1 prerequisite; identity labels (project, service, oneoff, network) stay labels regardless until the engine offers annotation filtering.

Activity

  1. ndeloof commented on Sep 15, 2026

    @ndeloof
    ContributorAuthor

    Follow-up after moby/moby#53538 (annotation filter for GET /containers/json) merged. That PR only touches daemon/list.go — it unblocks the ContainerList-based filters in the table above (config-hash presence, container-number equality, and project/service/oneoff if we ever wanted to move those). It does not close the whole gap:

    1. Annotations only exist on containers. HostConfig.Annotations has no equivalent on networks, volumes, or images — and won't via this PR. Concretely:

    • com.docker.compose.project/service baked onto built images (getImageBuildLabels, BuildKit/bake path only — the classic-builder path currently skips this entirely, a separate pre-existing gap) stay labels forever; they're baked into the image config and persist through docker commit/push/retag/reuse as a base image, regardless of anything done on the container side. The original motivating scenario ("a container inheriting compose state from an image") is only closed for state written by Compose at container-create time — not for identity labels an image carries because Compose built it.
    • com.docker.compose.config-hash is reused across containers, networks, and volumes (create.go:1465,1554, observed_state.go). Migration has to be per call-site/object-type, not per label name — the same constant migrates to annotation for containers but must stay a label for networks/volumes indefinitely.

    2. Event filtering isn't covered. daemon/events/filter.go (matchLabels) only knows label, not annotation. The up reconciliation monitor filters Events by project/oneoff — that path stays blocked until (if) a separate moby PR adds annotation support there. stats isn't a separate blocker: it resolves its container set via ContainerList first, so it rides along with #53538.

    3. Transition complexity is bigger than "one version window". The annotation filter is brand new server-side; Compose supports a much wider range of engine versions (and other Docker-API-compatible daemons). Phase 2 (stop writing state labels) can't be gated on a Compose version bump — it needs a per-connection capability check (probe the filter / API version), otherwise users on older engines silently lose filtering once labels are dropped. Practically, phase 1 (dual-write, label-fallback read) likely needs to stay in place for as long as those engines are supported — which also means the image-label-inheritance false-positive this issue is meant to fix keeps happening for that whole window, since the fallback read still trusts labels.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions