How to run etcd cluster as service with docker swarm mode?

Hello,

This is my first time posting here.
I recently began experimenting with docker swarm as a migration path away from kubernetes, and am trying to achieve some of the same high-availability for apps and databases which I had with kubernetes.

As such, I’m trying to deploy etcd as a distributed KV-store, for use with high-availability database solutions (Patroni, ++).

I’d like to have a virtual IP (VIP) or url where clients can reach a random etcd-replica, and also some templatable (or similar) way to express the IPs or urls of all replicas, within each replica. Basically so each etcd replica knows how to reach every other replica (without the randomness of using the shared service VIP). The etcd image only has etcd commands available, no curl/nslookup/dig.
What should be done?

  • Run HAproxy in the same namespace, with tasks.serviceName so the HAproxy can know the IPs of upstream etcd replicas. (This will introduce an extra hop for comms between etcd replicas, I think).
  • Run busybox or something else as an init container, using nslookup or similar to populate an /etc/hosts file mounted as a shared volume between the init container and etcd replicas. (This feels somewhat brittle. What happens when one replica restarts and gets a new IP + url?)
  • Something else (please advise) :slight_smile:

Here’s my current best attempt, but every node fails to reach eachother:

---
services:
  app:
    image: quay.io/coreos/etcd:v3.7.0
    hostname: '{{.Service.Name}}.{{.Task.Slot}}'
#    depends_on:
#      - init-volume-permission:
#          "condition: service_completed_successfully"
    command: |
      etcd
    environment:
      - ETCD_NAME=etcd.{{.Task.Slot}}
      - NAME=${ETCD_NAME}
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://{{.Service.Name}}.{{.Task.Slot}}:2380
#      - ETCD_INITIAL_CLUSTER=etcd.1=http://{{.Service.Name}}:2380,etcd.2=http://{{.Service.Name}}:2380,etcd.3=http://{{.Service.Name}}:2380
      - ETCD_INITIAL_CLUSTER=etcd.1=http://{{.Service.Name}}.1:2380,etcd.2=http://{{.Service.Name}}.2:2380,etcd.3=http://{{.Service.Name}}.3:2380
      - ETCD_INITIAL_CLUSTER_STATE=new
      - ETCD_INITIAL_CLUSTER_TOKEN={{.Service.Name}}
      - ETCD_ADVERTISE_CLIENT_URLS=http://{{.Service.Name}}:2379
      - ETCD_DATA_DIR=/etcd-data
    networks:
      internal:
#    ports:
#      - "2379:2379"
#      - "2380:2380"
    deploy:
      replicas: 3
      labels:
        - "traefik.enable=false"
      restart_policy:
        condition: on-failure
      placement:
        max_replicas_per_node: 1
        constraints:
          - node.role == manager
          - node.labels.etcd == true
        preferences:
          - spread: node.labels.etcd
#      endpoint_mode: dnsrr
      update_config:
        parallelism: 3
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health", "http://localhost:2379"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 300s

  init-volume-permission:
    image: busybox:latest
    command: sh -c "chmod -R 0700 /etcd-data"
    volumes:
      - etcd:/etcd-data
    deploy:
      replicas: 3
      labels:
        - "traefik.enable=false"
      restart_policy:
        condition: on-failure
        max_attempts: 2
      placement:
        max_replicas_per_node: 1
        constraints:
          - node.role == manager
          - node.labels.etcd == true
        preferences:
          - spread: node.labels.etcd

  init-replicas-hosts:
    image: busybox:latest
    command: sleep 500
    hostname: "{{.Service.Name}}.{{.Task.Slot}}"
    networks:
      internal:
    deploy:
      replicas: 3
      labels:
        - "traefik.enable=false"
      placement:
        max_replicas_per_node: 1
        constraints:
          - node.role == manager
          - node.labels.etcd == true
        preferences:
          - spread: node.labels.etcd

networks:
  internal:
    attachable: true
#    driver_opts:
#      dsr: "true"

volumes:
  etcd:

I’ve now also tried to deploy an ectd cluster as three separate stacks, on the same node, without success. It looks like each etcd replica tries to reach the other replicas at the wrong IPs… This is after removing previous etcd stacks, so a clean room situation.

.env of one deployment, the others are almost identical

## Config for etcd recipe

DOMAIN=etcd-0.sallto.cafe

ETCD_NAME=etcd-0
ETCD_INITIAL_CLUSTER=etcd-0=http://etcd-0_sallto_cafe_app:2380,etcd-1=http://etcd-1_sallto_cafe_app:2380,etcd-2=http://etcd-2_sallto_cafe_app:2380
ETCD_INITIAL_CLUSTER_STATE=new

compose.yml

---
services:
  app:
    image: quay.io/coreos/etcd:v3.7.0
    command: |
      etcd
    environment:
      - NAME=${ETCD_NAME}
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://{{.Service.Name}}:2380
      - ETCD_INITIAL_CLUSTER_TOKEN=etcd
      - ETCD_ADVERTISE_CLIENT_URLS=http://{{.Service.Name}}:2379
      - ETCD_DATA_DIR=/etcd-data
    networks:
      etcd:
        aliases:
          - etcd-cluster
    deploy:
      replicas: 1
      labels:
        - "traefik.enable=false"
      restart_policy:
        condition: on-failure
      placement:
        max_replicas_per_node: 1
        constraints:
          - node.role == manager
          - node.labels.etcd == true
          - node.labels.etcd-1 == true

    healthcheck:
      test: ["NONE"]

networks:
  etcd:
    external: true

log snippets from etcd-1, trying to reach 10.0.7.229 and 10.0.7.224:

{"level":"warn","ts":"2026-09-26T12:52:32.753717Z","caller":"rafthttp/probing_status.go:68","msg":"prober detected unhealthy status","round-tripper-name":"ROUND_TRIPPER_SNAPSHOT","remote-peer-id":"bd09536d5a49d929","rtt":"0s","error":"dial tcp 10.0.7.229:2380: connect: connection refused"}

{"level":"warn","ts":"2026-09-26T12:52:32.753798Z","caller":"rafthttp/probing_status.go:68","msg":"prober detected unhealthy status","round-tripper-name":"ROUND_TRIPPER_RAFT_MESSAGE","remote-peer-id":"6fea75802200393a","rtt":"0s","error":"dial tcp 10.0.7.224:2380: connect: connection refused"}

{"level":"warn","ts":"2026-09-26T12:52:35.748952Z","caller":"etcdserver/server.go:1838","msg":"failed to publish local member to cluster through raft","local-member-id":"c834bbaae2de7816","local-member-attributes":"{Name:etcd-1 ClientURLs:[http://etcd-1_sallto_cafe_app:2379]}","publish-timeout":"7s","error":"context deadline exceeded"}

As shown in docker network inspect etcd, no containers have those IPs:

[
    {
        "Name": "etcd",
        "Id": "y2x7qd1sbexcg4p4h86l7fvdp",
        "Created": "2026-09-26T14:49:52.06887543+02:00",
        "Scope": "swarm",
        "Driver": "overlay",
        "EnableIPv4": true,
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "10.0.7.0/24",
                    "Gateway": "10.0.7.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Options": {
            "com.docker.network.driver.overlay.vxlanid_list": "4100"
        },
        "Labels": {},
        "Peers": [
            {
                "Name": "26f0bb428759",
                "IP": "100.69.125.55"
            },
            {
                "Name": "9ad7b90e9f95",
                "IP": "100.72.15.23"
            }
        ],
        "Containers": {
            "aec54f3fc0808282c88b413ee9fc5f569168a0a3087a2bca99bfa691647b9a3a": {
                "Name": "etcd-2_sallto_cafe_app.1.oerdl6y45vv1ese6bg7u036yj",
                "EndpointID": "ff7e26ec2009be1b49ca7826feb7afc310ab31934f3509320d89660e718fc99d",
                "MacAddress": "02:42:0a:00:07:e6",
                "IPv4Address": "10.0.7.230/24",
                "IPv6Address": ""
            },
            "b19ee03522fe69e409aa98883696552086fc9819a8fe23e76465621a8d538912": {
                "Name": "etcd-0_sallto_cafe_app.1.ytjcshufq3fawhvlciljb63qe",
                "EndpointID": "8058e83d50c9ae65c281b82495df8eb19f3e2fd90fa86c86b4f643356faed56a",
                "MacAddress": "02:42:0a:00:07:e1",
                "IPv4Address": "10.0.7.225/24",
                "IPv6Address": ""
            },
            "cb4d12c0f9488c609008a18e13f015dd511aa332bbecf3d47df5120bb8fbc63e": {
                "Name": "etcd-1_sallto_cafe_app.1.6rykdw6weoxatvr7bi27v6d1z",
                "EndpointID": "052427c9982459c96fa52753c734835ae21faa45b7fdedd1167dc42d16e31e4a",
                "MacAddress": "02:42:0a:00:07:e4",
                "IPv4Address": "10.0.7.228/24",
                "IPv6Address": ""
            },
            "lb-etcd": {
                "Name": "etcd-endpoint",
                "EndpointID": "2ea2c1d38cc3f8c813006a2ce058065532bb4fe92893989e846b30be62192179",
                "MacAddress": "02:42:0a:00:07:e2",
                "IPv4Address": "10.0.7.226/24",
                "IPv6Address": ""
            }
        },
        "Status": {
            "IPAM": {
                "Subnets": {
                    "10.0.7.0/24": {
                        "IPsInUse": 10,
                        "DynamicIPsAvailable": 246
                    }
                }
            }
        }
    }
]

Your second post was blocked by the spam filter and we were not around to fix it. Sorry about that. Now I accepted the post.

I don’t use Docker Swarm, but could it be caused by endpoint mode?

https://docs.docker.com/engine/swarm/networking/#configure-service-discovery

Your container IP ends with 230, but the error log mentions 229. The other container ends with 225 but the error message mentions 224.

Have you tried the dnsrr mode instead of vip?

It has been ages since I tried to ran etcd on Swarm.

Back then my setup was:

  • global service on nodes with static ips (can be pinned to a specific set of nodes using node labels and placement constraints)
  • The client and server port of the service was published in host mode
  • everything points to the node’s host names and published ports

It might not be an elegant solution, but it as a reliable one, back when I used it.

Unfortuanately, Docker Swarm does not allow specifiying static ip addresses, so that every task will get a new ip.

Thank you very much for your replies, I managed to get it working with endpoint-mode: dnsrr.

Here is a link to the full working Co-op Cloud recipe (fyi, AGPL-v3): papiris/etcd-recipe: Co-op Cloud recipe for etcd - Codeberg.org

Great you could make it work.

Though, the only difference between the endpoint-modes vip and dnsrr is whether the service name resolves to a virtual ip, which load balances to the ips of each service task (=per replica), or the service name resolves to a multi-value entry with the ips of each service task (=pre replica).

Note: accessing other containers with tasks.<service-nam> provides the same behaviour as endpoint-mode: dnsrr using just the service name.

You might want to check if an etcd3 replica caches dns resolution indefinitely. If this is the case it could cause problems when one of the other replicas is restarted, as the replacing swarm task will have a different ip.