devenv/docker-moby-migration-guide.md
This guide documents all breaking changes encountered when migrating from the
monolithic github.com/docker/docker and github.com/docker/go-connections/nat
packages to the new split Moby modules:
github.com/moby/moby/api v1.54.xgithub.com/moby/moby/client v0.3.xBefore:
github.com/docker/docker v28.x.x+incompatible
github.com/docker/go-connections v0.x.x
After:
github.com/moby/moby/api v1.54.1-0.20260401134807-948d5691a093
github.com/moby/moby/client v0.3.1-0.20260401134807-948d5691a093
github.com/docker/docker v28.x.x+incompatible // keep as indirect
Gotcha: If both
github.com/moby/moby v28.x.x+incompatible(the old monolith) andgithub.com/moby/moby/api(the new split module) are present,go mod tidywill report "ambiguous import". Keep only the split modules; letdocker/dockerstay as an indirect dep for things that still need it transitively.
// Before
import "github.com/docker/go-connections/nat"
// After
import (
"net/netip"
"github.com/moby/moby/api/types/network"
)
// Before
h.PortBindings = nat.PortMap{
nat.Port("9000/tcp"): []nat.PortBinding{
{HostIP: "0.0.0.0", HostPort: "9000"},
},
}
// After
h.PortBindings = network.PortMap{
network.MustParsePort("9000/tcp"): []network.PortBinding{
{HostIP: netip.MustParseAddr("0.0.0.0"), HostPort: "9000"},
},
}
Key differences:
nat.PortMap = map[nat.Port][]nat.PortBinding where nat.Port is a string aliasnetwork.PortMap = map[network.Port][]network.PortBinding where network.Port is a structnetwork.PortBinding.HostIP is netip.Addr (not string)network.MustParsePort("NNN/tcp") for known-valid strings (panics on invalid)network.ParsePort("NNN/tcp") to get (Port, error) for runtime valuesp, _ := network.ParsePort("9000/tcp")
p.Port() // → "9000" (string, port number only)
p.Num() // → 9000 (uint16)
p.Proto() // → "tcp" (IPProtocol)
p.String() // → "9000/tcp"
Pre-compute keys before the closure to handle errors properly:
portKey, err := network.ParsePort(fmt.Sprintf("%s/tcp", portStr))
if err != nil {
return nil, err
}
req := testcontainers.ContainerRequest{
HostConfigModifier: func(h *container.HostConfig) {
h.PortBindings = network.PortMap{
portKey: []network.PortBinding{{
HostIP: netip.MustParseAddr("0.0.0.0"),
HostPort: portStr,
}},
}
},
}
Or use network.MustParsePort directly for compile-time known strings:
HostConfigModifier: func(h *container.HostConfig) {
h.PortBindings = network.PortMap{
network.MustParsePort(containerPort): []network.PortBinding{{
HostIP: netip.MustParseAddr("0.0.0.0"),
HostPort: in.Port,
}},
}
},
// Before
wait.ForListeningPort(nat.Port("9000/tcp"))
// After
wait.ForListeningPort("9000/tcp") // plain string
// Before
wait.ForHTTP("/health").WithPort(nat.Port("8080/tcp"))
// After
wait.ForHTTP("/health").WithPort("8080/tcp") // plain string
// Before: takes nat.Port, returns nat.Port
ep, err := c.MappedPort(ctx, nat.Port("9000/tcp"))
ep.Port() // → "9000"
// After: takes string, returns network.Port
ep, err := c.MappedPort(ctx, "9000/tcp")
ep.Port() // → "9000" (same method, still returns string)
The client API is a complete redesign: every method now takes a single *Options
struct and returns a *Result struct (or similar).
// Before
"github.com/docker/docker/client"
// After
"github.com/moby/moby/client"
// Before
cli.Ping(ctx)
// After
cli.Ping(ctx, client.PingOptions{})
// Before
import "github.com/docker/docker/api/types/container"
execID, err := cli.ContainerExecCreate(ctx, id, container.ExecOptions{...})
resp, err := cli.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{})
// After
execID, err := cli.ExecCreate(ctx, id, client.ExecCreateOptions{...})
resp, err := cli.ExecAttach(ctx, execID.ID, client.ExecAttachOptions{})
// Before
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
for _, c := range containers { ... }
// After
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true})
for _, c := range containers.Items { ... } // note: .Items
// Before
inspected, err := cli.ContainerInspect(ctx, id)
_ = inspected.Config
_ = inspected.HostConfig
_ = inspected.NetworkSettings.Networks
// After
inspected, err := cli.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
_ = inspected.Container.Config
_ = inspected.Container.HostConfig
_ = inspected.Container.NetworkSettings.Networks
// After
_, err = cli.ContainerStop(ctx, name, client.ContainerStopOptions{})
_, err = cli.ContainerRemove(ctx, name, client.ContainerRemoveOptions{RemoveVolumes: false})
createResp, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: inspected.Container.Config,
HostConfig: inspected.Container.HostConfig,
NetworkingConfig: networkingConfig,
Name: name,
})
_, err = cli.ContainerStart(ctx, createResp.ID, client.ContainerStartOptions{})
// Before
err = cli.CopyToContainer(ctx, id, targetPath, &buf, container.CopyToContainerOptions{...})
// After
_, err = cli.CopyToContainer(ctx, id, client.CopyToContainerOptions{
DestinationPath: targetPath,
Content: &buf,
AllowOverwriteDirWithFile: true,
})
// Before (4 args)
cli.NetworkConnect(ctx, networkName, containerID, &endpointSettings)
// After (single Options struct)
_, err = cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
Container: containerID,
EndpointConfig: &networkTypes.EndpointSettings{
Aliases: []string{alias},
},
})
The github.com/docker/docker/api/types/filters package does not exist in the split modules.
// Before
import dfilter "github.com/docker/docker/api/types/filters"
args := dfilter.NewArgs(dfilter.Arg("label", "framework=ctf"))
opts := container.ListOptions{Filters: args}
// After (client.Filters is map[string]map[string]bool with Add method)
filters := make(client.Filters).Add("label", "framework=ctf")
opts := client.ContainerListOptions{All: true, Filters: filters}
// Before
import "github.com/docker/docker/api/types/mount"
// After
import "github.com/moby/moby/api/types/mount"
Types and fields are identical — just the import path changes.
// Before
import "github.com/docker/docker/api/types/container"
// HostConfig is container.HostConfig
// After
import "github.com/moby/moby/api/types/container"
// HostConfig is still container.HostConfig — same package name, different module
// Migrating functions that iterate over a port map
// Before: nat.PortMap keys are nat.Port (string alias with .Int() method)
for port, bindings := range portMap {
portNum := strconv.Itoa(port.Int())
}
// After: network.PortMap keys are network.Port struct
for port, bindings := range portMap {
portNum := port.Port() // returns string like "9000"
// or: strconv.Itoa(int(port.Num())) for uint16
}
// Before
inspected, _ := cli.ContainerInspect(ctx, id)
networks := inspected.NetworkSettings.Networks // map[string]*network.EndpointSettings
// After
inspected, _ := cli.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
networks := inspected.Container.NetworkSettings.Networks
Grep patterns to find all affected code:
grep -r "docker/go-connections/nat" --include="*.go" .
grep -r "docker/docker/api/types" --include="*.go" .
grep -r "docker/docker/client" --include="*.go" .
grep -r "nat\.Port\(" --include="*.go" .
grep -r "nat\.PortMap" --include="*.go" .
grep -r "nat\.PortBinding" --include="*.go" .
grep -r "ContainerExecCreate\|ContainerExecAttach" --include="*.go" .
grep -r "ContainerList\|ContainerInspect\|NetworkConnect" --include="*.go" .
grep -r "dfilter\.\|filters\.NewArgs" --include="*.go" .
If you generate Go code in string templates, update those too:
nat.PortMap{...} → network.PortMap{network.MustParsePort(...): ...}[]nat.PortBinding{{HostIP: "0.0.0.0", ...}} → []network.PortBinding{{HostIP: netip.MustParseAddr("0.0.0.0"), ...}}