Compose file version 3, and what swarm changed

Bumping the version at the top of a working compose file from '2' to '3' looks like a formality. It is not — v3 exists so the same file can be handed to a scheduler, and a scheduler cannot promise several things a single host could. Three keys stopped working, and the reasons are worth understanding before deciding whether to move at all.

The symptom

$ docker-compose up -d
WARNING: Some services (php, db) use the 'volumes_from' key, which will be
ignored. Compose does not support 'volumes_from' in version 3 files.
WARNING: Some services (php) use the 'depends_on' condition form, which will
be ignored.

$ docker-compose ps
     Name          State
shop_php_1         Exit 1
shop_db_1          Up

$ docker-compose logs php | tail -2
SQLSTATE[HY000] [2002] Connection refused

Ignored rather than rejected is the unpleasant part. The file parses, the stack comes up, and the failure is a container that started before the database was ready — which had been solved for two years by a depends_on condition that is now silently doing nothing.

Why it happens

v2 describes containers on one machine, so it can express relationships between them: share this container’s volumes, start after that one reports healthy. Both are answerable because Compose is the thing starting them and it knows the order.

v3 describes services that a scheduler places, possibly on different hosts, possibly restarting one of them at three in the morning without asking. volumes_from cannot work because the other container may not be on this machine. Startup ordering cannot be promised because a service that is rescheduled will start whenever it starts, and a dependency that was satisfied at boot says nothing about ten minutes later.

Note

This is the useful reframing: v3 did not remove features, it removed guarantees it could no longer keep. An application that needed them still needs them — it just has to get them from somewhere honest.

The fix

Named volumes instead of volumes_from

# v2 — one container lends its filesystem to another
services:
  php:
    volumes_from: [app-data]
  nginx:
    volumes_from: [app-data]
  app-data:
    image: busybox
    volumes: ['/var/www']

# v3 — a named volume both mount, no data container
version: '3.4'

services:
  php:
    volumes: ['app:/var/www']
  nginx:
    volumes: ['app:/var/www:ro']

volumes:
  app:

The read-only flag on the nginx side is the small win: with volumes_from both sides had identical access, and stating that the web server never writes is documentation the daemon enforces. Data-only containers were always a workaround for named volumes not existing, and they stopped being necessary in 1.9.

Healthchecks instead of depends_on

The replacement for start ordering is not another ordering mechanism. It is accepting that a dependency can be unavailable at any moment and making the application say so.

services:
  db:
    image: mariadb:10.2
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s          # 3.4 — grace before failures count

  php:
    image: shop/php:7.1
    restart: unless-stopped      # the actual dependency mechanism
    depends_on: [db]             # still allowed; only affects start order

The restart policy is doing the work that depends_on used to. A PHP container that cannot reach the database exits, is restarted, and succeeds on the third attempt fifteen seconds later. That is uglier than waiting and is the behaviour you want in production, because the database will also go away at some point when nothing is starting.

start_period, added in file format 3.4, is worth knowing about. Without it a database that takes forty seconds to initialise is marked unhealthy during startup, and anything reacting to health status treats a normal boot as an incident.

The deploy key, which does nothing locally

services:
  php:
    deploy:
      replicas: 4
      resources:
        limits: { cpus: '0.50', memory: 512M }
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first     # 3.4 — new task before the old one stops

# docker stack deploy  → all of this applies
# docker-compose up    → all of this is ignored, silently

That last comment is the whole problem with one file for two audiences. deploy is ignored by docker-compose up and mem_limit is ignored by docker stack deploy, so resource limits have to be written twice or the file only works properly in one of the two places.

order: start-first is the setting to check before the first rolling update. The default stops a task before starting its replacement, which means capacity dips during every deploy — fine at four replicas, visible at two.

Two files rather than one, which is the honest answer

Trying to make a single file serve local development and a swarm deploy produces something that is wrong in both. Compose merges files, so the base can hold what is genuinely shared and the differences can live where they belong.

$ ls
docker-compose.yml           # services, networks, images — shared
docker-compose.override.yml  # bind mounts, xdebug, exposed ports (local)
docker-compose.prod.yml      # deploy:, secrets, no bind mounts

# local — override is picked up automatically
$ docker-compose up -d

# swarm — explicit, and the override is not merged
$ docker stack deploy -c docker-compose.yml -c docker-compose.prod.yml shop

# and the check that the merge is what you think it is
$ docker-compose -f docker-compose.yml -f docker-compose.prod.yml config

The config subcommand prints the fully merged, variable-substituted result and is the only reliable way to know what will actually be deployed. Running it in CI and diffing against the previous release catches the class of mistake where an override quietly replaces a list instead of extending it.

Verifying it worked

# one host
$ docker-compose up -d && docker-compose ps
shop_php_1    Up
shop_nginx_1  Up
shop_db_1     Up (healthy)

# three hosts, same base file
$ docker stack deploy -c docker-compose.yml -c docker-compose.prod.yml shop
$ docker service ls
NAME       MODE         REPLICAS  IMAGE
shop_php   replicated   4/4       registry.internal/shop/php:7.1
shop_nginx replicated   2/2       registry.internal/shop/nginx:1.13

# the one that matters: kill a node and watch it reschedule
$ docker node update --availability drain node-02
$ watch docker service ps shop_php

Draining a node is the test the whole exercise is for. It surfaces the assumptions a single-host file was allowed to make — a container that expected a sibling on localhost, a volume with data in it that only exists on one machine — and it surfaces them deliberately rather than during an outage.

What this costs

One file format serving two audiences satisfies neither perfectly, and the keys each side ignores are ignored silently. That is a permanent source of confusion, and the mitigation is to run config and read the output rather than trusting that a key took effect.

The larger question is whether to move at all. A stack that runs on one host and will keep running on one host gets nothing from v3 and loses two conveniences, and staying on v2 is a defensible decision for as long as that remains true. The reason to move early is that the constraints v3 imposes — no shared filesystems between containers, no assumed start order, no local state — are the constraints any scheduler will impose, and adopting them while there is still one host is far cheaper than discovering them all on the day the second one arrives.