Docker managed volume plugin that backs Docker volumes with Google Compute Engine zonal Persistent Disks. On a GCE VM it creates the PD, attaches it, formats it if blank, mounts it, and exposes it as a Docker volume — then unmounts, detaches, and deletes it as volumes go away.
docker volume create --driver gcepd \
--opt size=50 --opt type=pd-balanced --opt fs=ext4 \
my-volume
docker run --rm -v my-volume:/data alpine sh -c 'echo hi > /data/hi.txt'Written in Go on github.com/docker/go-plugins-helpers/volume and the
cloud.google.com/go/compute/apiv1 SDK.
| Docker action | What the plugin does |
|---|---|
volume create |
Creates a zonal PD in the VM's zone, tagged managed-by=docker-gcepd. Does not attach yet. |
docker run -v (mount) |
Attaches the PD to the VM, waits for /dev/disk/by-id/google-<name>, formats it only if blank, mounts it under /var/lib/docker-gcepd/mounts/<name>. |
| second container, same volume | Reuses the mount, bumps a reference count. No re-attach. |
| container stop (unmount) | Decrements the ref count; when it hits zero, unmounts and detaches the PD. |
volume rm |
Refuses if still attached/in use; otherwise deletes the PD (optionally snapshotting first). |
State (volume name, options, status, ref count) is persisted to
/var/lib/docker-gcepd/state.json and reconciled against GCE at startup.
The plugin only runs on a GCE VM. It discovers its project, zone, and instance name from the metadata server and fails fast with a clear message elsewhere. Only zonal Persistent Disks are supported (see Limitations).
The plugin authenticates with Application Default Credentials — by default
the VM's attached service account. Grant it at minimum these permissions
(a custom role is cleaner than roles/compute.instanceAdmin):
compute.disks.create
compute.disks.delete
compute.disks.get
compute.disks.list
compute.disks.createSnapshot # only if you use snapshotOnRemove
compute.instances.attachDisk
compute.instances.detachDisk
compute.instances.get
compute.zoneOperations.get # to poll async operations
Create the role and bind it:
gcloud iam roles create gcepdPlugin --project "$PROJECT" \
--title "docker-volume-gcepd" \
--permissions=compute.disks.create,compute.disks.delete,compute.disks.get,compute.disks.list,compute.disks.createSnapshot,compute.instances.attachDisk,compute.instances.detachDisk,compute.instances.get,compute.zoneOperations.get
gcloud projects add-iam-policy-binding "$PROJECT" \
--member="serviceAccount:$SA_EMAIL" \
--role="projects/$PROJECT/roles/gcepdPlugin"If your VM uses legacy access scopes (instead of full IAM), it needs the
https://www.googleapis.com/auth/compute scope. Modern VMs using
"Allow full access to all Cloud APIs" or IAM-only access are fine.
To use a specific service-account key instead of the VM's identity, set
GCEPD_KEYFILE to a JSON key path inside the plugin rootfs and bind it in:
docker plugin set gcepd GCEPD_KEYFILE=/run/secrets/gcepd-sa.jsonManaged plugins are single-architecture, so images are published per arch and
per release under a <version>-<arch> tag. Pick the one matching your VM
(amd64 for most GCE VMs, arm64 for Tau T2A) and a released version:
docker plugin install ghcr.io/aflachat/docker-plugin-gce-pd:1.0.0-amd64 --alias gcepd
docker plugin enable gcepdSee the repository's released tags for available versions.
--alias gcepd lets you use the short driver name --driver gcepd. Verify:
docker plugin lsThe plugin requests CAP_SYS_ADMIN, access to all devices, and a propagated
mount — Docker will show these for confirmation on install (see
Security).
docker volume create --driver gcepd \
--opt size=100 \
--opt type=pd-ssd \
--opt fs=xfs \
--opt labels=team=data,env=prod \
data-volumedocker run --rm -v data-volume:/data alpine df -h /datadocker volume rm data-volumeWhat happens to the PD depends on the volume's reclaimPolicy (see options):
with the default retain the disk is kept in GCE and only forgotten
locally; with delete the disk is deleted.
The plugin does not resize volumes itself: docker volume create is idempotent
on the name, so Docker ignores a new size given for an existing volume and the
request never reaches the plugin. Resize the underlying PD directly instead.
Persistent Disks can only grow (never shrink), online, while attached:
ZONE=$(curl -s -H 'Metadata-Flavor: Google' \
http://metadata.google.internal/computeMetadata/v1/instance/zone | awk -F/ '{print $NF}')
# 1. Grow the PD in GCE (e.g. 10 -> 20 GiB)
gcloud compute disks resize <volume-name> --zone "$ZONE" --size 20
# 2. Grow the filesystem on the VM (the disk must be attached/mounted).
# Device is /dev/disk/by-id/google-<volume-name>; mountpoint is
# /var/lib/docker-gcepd/mounts/<volume-name>.
sudo resize2fs $(readlink -f /dev/disk/by-id/google-<volume-name>) # ext4
sudo xfs_growfs /var/lib/docker-gcepd/mounts/<volume-name> # xfsdisks resize needs compute.disks.resize on the VM's service account. After
resizing, docker volume inspect may still show the old size until the next
plugin restart (it is re-read from GCE at startup); the filesystem is already
the new size.
All options are passed via --opt key=value to docker volume create.
| Option | Default | Description |
|---|---|---|
size |
10 |
Disk size in GiB. Positive integer. |
type |
pd-balanced |
Disk type: pd-standard, pd-balanced, pd-ssd, pd-extreme. |
fs |
ext4 |
Filesystem for a blank disk: ext4 or xfs. Existing filesystems are never reformatted. |
reclaimPolicy |
retain |
What docker volume rm does to the PD: retain (keep it in GCE, just forget it locally) or delete (delete the PD). |
labels |
— | Comma-separated k=v labels applied to the PD, e.g. team=data,env=prod. The managed-by key is reserved. |
sourceSnapshot |
— | Create the disk from a snapshot (mutually exclusive with sourceImage). |
sourceImage |
— | Create the disk from an image (mutually exclusive with sourceSnapshot). |
snapshotOnRemove |
false |
With reclaimPolicy=delete, take a snapshot of the PD before deleting it. |
Unknown options are rejected so typos fail loudly rather than silently using a default.
reclaimPolicy defaults to retain so docker volume rm never destroys
data by accident. A retained PD keeps its managed-by=docker-gcepd label, so the
plugin re-imports it at startup and a docker volume create of the same name
reuses it (and its data). To actually delete the disk, create the volume with
--opt reclaimPolicy=delete (optionally with --opt snapshotOnRemove=true to
snapshot first).
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
driver: gcepd
driver_opts:
size: "50"
type: pd-ssd
fs: ext4
snapshotOnRemove: "true"Note:
driver_optsvalues must be quoted strings in compose.
By default the plugin is local-scoped: a volume is bound to one VM for life,
and the plugin refuses to attach a disk that GCE reports as attached elsewhere.
For Swarm, enable global scope so a volume can follow a rescheduled task to
another VM in the same zone. When a task moves from VM-A to VM-B, B's plugin
detaches the disk from A and attaches it to B.
Enable it per node (set on the plugin, then it sticks):
docker plugin install ghcr.io/aflachat/docker-plugin-gce-pd:1.0.0-amd64 --alias gcepd --disable
docker plugin set gcepd GCEPD_SCOPE=global
docker plugin enable gcepdKeep a service's tasks in a single zone, since the disk is zonal. Label your nodes by zone and constrain placement:
docker node update --label-add zone=europe-west1-b <node>services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
deploy:
replicas: 1
placement:
constraints:
- node.labels.zone == europe-west1-b
volumes:
pgdata:
driver: gcepd
driver_opts:
size: "50"
type: pd-ssdWhen B wants a disk still held by A, the plugin reads the holder's state from GCE:
- Holder down (
TERMINATED/STOPPED/STOPPING/SUSPENDED/ no longer exists) → it cannot be writing, so the disk is detached immediately and attached to B. - Holder still
RUNNING→ the plugin requests a clean detach and waits up toGCEPD_FORCE_DETACH_AFTER(default30s) for the disk to be released. If it releases, B attaches it. If not, the plugin forces the detach and proceeds.
⚠️ Data-safety warning. A zonal PD is single-writer. The fencing above is best-effort: if a holder isRUNNINGbut wedged and still writing when the grace window expires, forcing the detach and mounting on B can corrupt the filesystem. Useglobalscope for workloads where a task is reliably stopped (or its VM is gone) before being rescheduled, and sizeGCEPD_FORCE_DETACH_AFTERto your environment. This is inherent to block storage without a real fencing agent — there is no distributed lock.
The plugin's config.json requests:
CAP_SYS_ADMIN— required tomount/umountfilesystems.allowAllDevices: true— the attached PD appears as a block device under/dev/disk/by-id/google-<name>whose major/minor numbers aren't known ahead of time, so a static device allowlist is insufficient.- Bind mount of host
/devwithrsharedpropagation, so newly attached devices become visible inside the plugin. - Propagated mount of
/var/lib/docker-gcepd/mounts, so volumes mounted by the plugin propagate back to the host and into target containers. - Host networking, to reach the metadata server and the Compute API.
These are inherent to a volume plugin that formats and mounts real block devices. Review them on install.
View plugin logs. Managed-plugin output goes to the Docker daemon journal:
journalctl -u docker | grep gcepd
# or, for the plugin process specifically:
journalctl -u docker -f"metadata server unreachable: this plugin must run on a GCE VM". You're not on GCE, or the metadata server is blocked. The plugin refuses to enable off-GCE by design.
Plugin won't enable / "permission denied" in logs. The VM's service account is missing IAM permissions or the compute scope. See Prerequisites. Check with:
gcloud compute instances describe "$(hostname)" --zone "$ZONE" \
--format='value(serviceAccounts[].scopes)'Plugin shows disabled after a reboot.
Managed plugins don't auto-enable on boot unless configured. Re-enable it (and
consider a systemd drop-in or startup script):
docker plugin enable gcepdOn enable, the plugin reconciles: it re-imports any managed-by=docker-gcepd
disks it finds, drops phantom entries whose disk is gone, and restores ref
counts for volumes still mounted on disk.
volume rm fails with "still attached" / "in use".
A container still references it, or a detach hasn't completed. Stop the
container(s); the plugin detaches when the last mount goes away.
A disk is left attached after a crash. Reconciliation handles state, but a hard crash mid-attach can leave a disk attached. Detach manually:
gcloud compute instances detach-disk "$(hostname)" --disk <name> --zone "$ZONE"Device never appears after attach.
The plugin polls /dev/disk/by-id/google-<name> for up to 60s. If it times out,
check that the /dev bind mount and rshared propagation survived (some host
configurations reset propagation); docker plugin disable/enable gcepd usually
restores it.
- Zonal PDs only. Regional (multi-zone replicated) Persistent Disks are not supported. A volume lives in one zone.
- One writer at a time. A zonal PD attaches read-write to a single VM.
Multi-attach / read-only fan-out is not supported. In
globalscope the disk moves between VMs on failover, but never has two writers (subject to the fencing caveat above). - Same-zone failover only.
globalscope moves a volume between VMs in the same zone. There is no cross-zone migration; keep a service's tasks pinned to one zone with placement constraints. - GCE naming rules. Volume names must be valid disk names:
^[a-z]([-a-z0-9]*[a-z0-9])?$, max 63 characters (lowercase letter first, then lowercase letters/digits/hyphens, no trailing hyphen). - Filesystems. Only
ext4andxfsare formatted by the plugin. An imported disk with another filesystem will still mount (the plugin probes rather than reformats), but the plugin won't create other types.
make build # static binary into ./bin
make test # unit tests (race + coverage); no GCE needed
make lint # go vet + gofmt check
make plugin-create REGISTRY=ghcr.io/aflachat TAG=dev # package the managed plugin
make plugin-push REGISTRY=ghcr.io/aflachat TAG=dev # push to the registryPackaging (build / rootfs / plugin-create / plugin-push) runs anywhere
with a Docker daemon and never touches GCE. Only plugin-enable and actually
running the plugin require a GCE VM.
A full create / mount / write / unmount / remove cycle against real GCE lives
behind the integration build tag and is skipped everywhere else. On a GCE VM
with the IAM permissions above:
sudo -E go test -tags=integration -run TestIntegrationFullCycle \
-v -timeout 10m ./internal/driver/It creates and deletes a disk named gcepd-itest-<pid>. If interrupted, delete
it manually:
gcloud compute disks delete gcepd-itest-<pid> --zone "$ZONE"cmd/docker-volume-gcepd/ entry point: detect GCE, wire up, reconcile, serve
internal/metadata/ GCE metadata-server client (project/zone/instance)
internal/gce/ Compute API wrapper (create/get/delete/attach/detach)
internal/mount/ blkid / mkfs / mount / umount wrappers
internal/state/ JSON state persistence + ref counting + reconciliation
internal/driver/ volume.Driver implementation orchestrating the above
GCE API calls retry with exponential backoff and jitter; async operations are awaited with a configurable timeout.
MIT — see LICENSE.