NAME
API::Docker::API::Containers - Docker Engine Containers API
VERSION
version 0.004
SYNOPSIS
my $docker = API::Docker->new;
# List containers
my $containers = $docker->containers->list(all => 1);
for my $container (@$containers) {
say $container->id;
say $container->status;
}
# Create and start a container
my $result = $docker->containers->create(
Image => 'nginx:latest',
name => 'my-nginx',
ExposedPorts => { '80/tcp' => {} },
);
$docker->containers->start($result->{Id});
# Inspect container details
my $container = $docker->containers->inspect($result->{Id});
say $container->name;
# Stop and remove
$docker->containers->stop($result->{Id}, timeout => 10);
$docker->containers->remove($result->{Id});
# View logs (ArrayRef of { stream => 'stdout'|'stderr'|'raw', data => ... })
my $frames = $docker->containers->logs($result->{Id}, tail => 100);
my $text = join '', map { $_->{data} } @$frames;
# Attach one-way: replays the same frames and returns (stream => 0 by
# default -- stream => 1 on a stopped container never returns). On Podman,
# attaching to a container that has ALREADY EXITED destroys its exit
# status; use logs() for that case, see attach()
my $attached = $docker->containers->attach($result->{Id});
# Copy a file out, and a tar archive in (what docker cp is built on)
my $tar = $docker->containers->get_archive($result->{Id},
path => '/etc/hostname');
$docker->containers->put_archive($result->{Id}, $tar, path => '/tmp');
DESCRIPTION
This module provides methods for managing Docker containers including creation, lifecycle operations (start, stop, restart), inspection, logs, and more.
list and inspect return generated API::Docker::Type objects carrying the convenience methods of API::Docker::Role::Entity::Container, so $container->start and $container->logs work on either. Which class each returns, and where the two disagree, is below.
Accessed via $docker->containers, or through "using" in API::Docker::Role::Using for a run of calls that needs its own transport bound: $docker->containers->using(read_timeout => 5).
The two container shapes
The daemon describes a container two ways and the swagger has two definitions for it, so this class returns two classes:
"list" returns API::Docker::Type::ContainerSummary objects -- one per entry of
GET /containers/json."inspect" returns an API::Docker::Type::ContainerInspectResponse -- the body of
GET /containers/{id}/json.
They overlap but do not line up, and the field names are the swagger's own spelling in snake_case (Id is ->id, SizeRootFs is ->size_root_fs). The differences worth knowing before reading a value off the wrong one:
->imageis the name the container was created from on a summary (nginx:latest) and the resolvedsha256:digest on an inspect. A summary reports that digest separately as->image_id; an inspect has no such field.->createdis an integer Unix epoch on a summary and an RFC 3339 string on an inspect. Same field name, two types --IntandStrin the model, which is the swagger's own answer, not a normalisation this client applies.->stateis the status string (running,exited) on a summary and an API::Docker::Type::ContainerState object on an inspect, where that string is->state->statusand the flags are->state->running,->state->paused,->state->exit_code. "is_running" in API::Docker::Role::Entity::Container reads whichever it is given.->status-- the human sentence,"Up 2 hours"-- is on the summary only.->commandis the whole command as one string, on a summary only. An inspect splits it into->pathand->argsand keeps the originalCmdArrayRef under->config->cmd.->labelsand->portsare top-level on a summary only. An inspect carries the labels under->config->labelsand the port bindings under->network_settings->ports, which is a map of container port to host bindings rather than the summary's ArrayRef of API::Docker::Type::Port.->names(an ArrayRef, each with a leading/) is the summary's;->name(one string, also with the/) is the inspect's.->config,->restart_count,->driver,->platform,->graph_driver,->exec_idsand the*_pathfields come from an inspect only.->host_configand->network_settingsexist on both and are different classes: the summary's are API::Docker::Type::ContainerSummary::HostConfig (NetworkModeandAnnotations, nothing else) and API::Docker::Type::ContainerSummary::NetworkSettings (Networksalone), against the full API::Docker::Type::HostConfig and API::Docker::Type::NetworkSettings on an inspect.->size_rwand->size_root_fsare on both, but a summary only carries them whensize => 1was asked for.
A field neither class knows -- a newer engine than the spec/v1.51.yaml this model was generated from -- is not dropped: it stays under the name it arrived with in "unknown_fields" in API::Docker::Role::Type and goes back out unchanged.
A field whose value disagrees with the swagger is kept the same way and costs only itself. An engine answering State with the bare status string rather than the object the spec declares leaves ->state undef while every other field of the inspect reads normally; the raw value is in unknown_fields under State, and "rejected_fields" in API::Docker::Role::Type names it, so "not sent" and "sent and not usable" are two different answers.
client
Reference to API::Docker client. Weak reference to avoid circular dependencies.
list
my $containers = $containers->list(%opts);
List containers. Returns an ArrayRef of API::Docker::Type::ContainerSummary objects -- see "The two container shapes" for what a summary carries and "inspect" does not.
Options:
all- Show all containers (default shows just running)limit- Limit results to N most recently created containerssize- Include size informationfilters- HashRef of filter name to ArrayRef of string values, e.g.{ status => ['running'], label => ['stage=build'] }. Shape-checked and normalised by API::Docker::Role::Filters
create
my $result = $containers->create(
Image => 'nginx:latest',
name => 'my-nginx',
Cmd => ['/bin/sh'],
Env => ['FOO=bar'],
);
Create a new container. Returns hashref with Id and Warnings.
The name parameter is extracted and passed as query parameter. All other parameters are Docker container configuration (see Docker API documentation).
Common config keys: Image, Cmd, Env, ExposedPorts, HostConfig.
Boolean flags may be given as a Perl 1/0 or as a JSON boolean; either goes out as a real JSON true/false, which the engine's body type-check requires. This applies to the top-level flags (Tty, OpenStdin, AttachStdin, AttachStdout, AttachStderr, StdinOnce, NetworkDisabled, ArgsEscaped) and to the HostConfig flags (Privileged, PublishAllPorts, ReadonlyRootfs, AutoRemove, Init, OomKillDisable).
inspect
my $container = $containers->inspect($id);
Get detailed information about a container. Returns an API::Docker::Type::ContainerInspectResponse -- see "The two container shapes".
start
$containers->start($id);
say 'was already running' unless $containers->start($id);
Start a container. Returns 1 when the container was started and 0 when it was already running: the engine answers a state change with 204 and a no-op with 304 Not Modified, and both carry an empty body, so until now both came back as undef.
The no-op keeps the falsy value this method always returned -- 0 where it used to be undef -- so a caller that ignores the return or tests it for falseness is unaffected; only a caller testing defined sees a difference. A failure is still a croak, never a 0.
stop
$containers->stop($id, timeout => 10);
say 'was already stopped' unless $containers->stop($id);
Stop a container. Returns 1 when the container was stopped and 0 when it was already stopped -- the engine answers the no-op with 304 Not Modified. See "start" for what that 0 replaces.
Options:
timeout- Seconds to wait before killing (default 10)signal- Signal to send (default SIGTERM)
restart
$containers->restart($id, timeout => 10);
Restart a container. Optionally specify timeout in seconds.
Reports 1/0 like "start", but a restart has no no-op state to report: the engine restarts a stopped container as readily as a running one. Measured against Podman 5.4.2 (API 1.41) it answers 204 in both cases, and the Docker Engine API documents no 304 for this endpoint either, so 0 is not expected here. The value is reported the same way rather than specially, so an engine that does answer 304 is not silently read as a change.
kill
$containers->kill($id, signal => 'SIGKILL');
$containers->kill($id, signal => 'SIGUSR1'); # not necessarily a stop
Send a signal to a container. Default signal is SIGKILL.
Returns nothing -- unlike "start", "stop", "restart", "pause" and "unpause", which report 1/0 through their shared _state_change path. Those methods have two outcomes worth telling apart: a change (204) and a no-op (304, where the engine sends one). kill has only one, because _request croaks on any status >= 400, so the 409 a non-running container gets back never reaches this method's return. A boolean with a single possible value is not worth adding.
More importantly, 204 does not mean the container stopped. Measured on both engines -- Docker 29.7.2 (API 1.55) and rootless Podman 5.4.2 (API 1.41), same machine, identical behavior: sending a signal the container traps or ignores -- signal => 'SIGUSR1' against a process with a handler installed for it -- is delivered, the container keeps running, the handler's output turns up in "logs", and the engine still answers 204 exactly as it does for a signal that does end the process. A caller that needs to know whether the container is still running after a kill has to ask "inspect"; that is also why this returns nothing rather than a plain 1 -- a 1 here would claim a state change that a trapped signal never made.
A paused container is where the two engines part. Both answer 204 to signal => 'SIGUSR1' against a paused container, and then:
Docker unpauses it as a side effect. "inspect" reports
runningstraight afterwards and the handler has already run. A caller that paused the container and means to unpause it later gets a croak instead: the following "unpause" answers 500Container <id> is not pausedPodman leaves it paused and queues the signal. The state stays
paused, the handler produces nothing until an explicit "unpause", and that "unpause" succeeds
So kill is a state change for a paused container on Docker and is not one on Podman -- with the same 204 on both.
Killing a container that is not running -- stopped, exited or just created -- croaks 409; it does not return a falsy value. An unknown container ID croaks 404. Neither is reachable through a return value, and no case answers 304: moby's swagger for this endpoint (operationId: ContainerKill) documents only 204, 404, 409 and 500.
The text of either is engine prose, so branch on "status" in API::Docker::Error::HTTP and not on the message. For one and the same stopped container:
Docker --
cannot kill container: <name>: container <id> is not runningPodman --
can only kill running containers. <id> is in state exited: container state improper.container state improperis Podman's separatecausefield, reachable as$err->data->{cause}, not a phrase Docker uses anywhere
The 404 differs too, and on Docker it differs per endpoint: kill against a missing ID answers cannot kill container: <name>: No such container: <name> where "inspect" answers the bare No such container: <name>. Podman sends one sentence for both.
Options:
signal- Signal to send (defaultSIGKILL)
remove
$containers->remove($id, force => 1, volumes => 1);
Remove a container.
Options:
force- Force removal (kill if running)volumes- Remove associated volumeslink- Remove specified link
logs
my $frames = $containers->logs($id, tail => 100, timestamps => 1);
# stdout and stderr, in the order the engine emitted them
my $text = join '', map { $_->{data} } @$frames;
# stderr only
my @errors = grep { $_->{stream} eq 'stderr' } @$frames;
Get container logs. Returns an ArrayRef of frames, each a HashRef with stream and data:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
A container created without a TTY multiplexes stdout and stderr into a single framed stream, and this method demultiplexes it -- without that, the 8-byte frame headers end up in the caller's log text. A container created with a TTY writes to one pty and the engine sends no frame headers, so its whole output arrives as a single frame with stream => 'raw': with a TTY there is no stdout/stderr distinction left to report. stream is always a plain string, so $_->{stream} eq 'stderr' is safe on any frame.
Framing is detected from the response bytes, because the engine's Content-Type cannot be trusted for it -- see "Detecting a framed stream" in API::Docker::Role::HTTP for the rule and its one failure mode.
Options:
follow- Keep the connection open and send new output as the container writes it. Only usable withon_frame; see belowstdout- Include stdout (default 1)stderr- Include stderr (default 1)since- Show logs since timestampuntil- Show logs before timestamptimestamps- Include timestampstail- Number of lines from end (e.g.,100orall)tty- Set to 1 when the container was created with a TTY and its output is binary, to skip demultiplexing. Not needed for text output. The container's own setting isConfig.Ttyfrom$containers->inspect($id). Withon_frameit is a declaration rather than a hint; see belowon_frame- CodeRef called with each frame as it arrives, instead of the ArrayRef being collected and returned; see below
Following the log
follow => 1 asks the daemon to keep sending as the container writes. Pass on_frame with it and the frames are handed over as they arrive:
my $summary = $containers->logs($id,
follow => 1,
tail => 0,
on_frame => sub {
my ($frame, $stop) = @_;
print $frame->{data};
$stop->() if $frame->{data} =~ /listening on/;
},
);
$summary; # { delivered => 4, stopped => 1 }
With a callback the return value is that summary HashRef, not the frames: delivered is how many went to the callback, stopped is 1 when the callback ended the stream and 0 when the daemon did. Nothing is accumulated -- a followed log is unbounded by construction, and the callback has been handed every frame already. See "Streaming a response as it arrives" in API::Docker::Role::HTTP.
Without a callback, follow => 1 blocks until the container exits or the daemon closes the connection, because the whole response is read before anything is parsed. Use it with on_frame or not at all.
tty means something stronger on this path. The buffered path decides framing by walking the whole body (see "Detecting a framed stream" in API::Docker::Role::HTTP), which is exactly what a streamed one does not have; so with on_frame the flag is a promise about the container rather than a hint, and an undeclared stream that turns out not to be framed croaks instead of being handed back raw. Read Config.Tty from $containers->inspect($id) and pass it. The frame shape is the same either way -- a TTY stream arrives as a series of stream => 'raw' frames rather than the single one the buffered path builds.
attach
my $frames = $containers->attach($id);
my $text = join '', map { $_->{data} } @$frames;
Attach to a container's streams and return everything they produced, as an ArrayRef of frames in the same shape "logs" returns:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
A container created without a TTY multiplexes its output into one framed stream, which this method demultiplexes; one created with a TTY arrives as a single stream => 'raw' frame. See "logs" and "Detecting a framed stream" in API::Docker::Role::HTTP.
The container must be running. Attaching to one that has already exited destroys its exit status on Podman, so this method checks first and croaks rather than attaching -- read the output of a finished container with "logs". Both halves of that are worth knowing before the call: see "On Podman this destroys a stopped container's exit status" and "This method refuses a container that is not running".
On Podman this destroys a stopped container's exit status
Attaching to a container that has already exited loses its exit code on Podman, and nothing reports it. Measured before and after a single attach against one container that exited with 4, on Podman 5.4.2 (API 1.41) and Docker 29.7.2 (API 1.55), same machine:
PODMAN before inspect: exited 4 wait: { StatusCode => 4 }
PODMAN after inspect: created 0 wait: { StatusCode => -1 }
DOCKER before inspect: exited 4 wait: { StatusCode => 4 }
DOCKER after inspect: exited 4 wait: { StatusCode => 4 }
Podman reverts the container to created, resets ExitCode to 0, and answers a later "wait" with the sentinel -1 inside a 200. The real value is gone from the engine; there is nothing to read it back from. All three variants do it -- stream => 1 with and without logs, and the stream => 0, logs => 1 this method now sends by default, which is the one that returns cleanly in milliseconds. It is the call that does it, not the hang.
"logs" does not do it, and Docker does not do it at all. So on Podman the sequence "attach to collect the output, then "wait" for the exit code" cannot work: read the output with "logs" instead, or take the exit code before attaching.
This method refuses a container that is not running
Because of the above, attach asks "inspect" whether the container is running and croaks instead of attaching when it is not:
API::Docker::API::Containers->attach refused: container x is exited. ...
require_running => 0 turns that off and attaches anyway; the check is then not performed at all, so opting out costs no round trip either.
With the guard off, the hang this section exists to explain becomes reachable: attaching to a container that has already exited never returns on rootless Podman (measured 5.4.2, still true on 5.8.4, API 1.44). A bound on the resource class is how to survive that call instead of blocking on it forever:
$docker->containers->using(read_timeout => 2)
->attach($id, require_running => 0);
See API::Docker::Role::Using and "Bounding a request that never ends" in API::Docker::Role::HTTP.
What the check does not do is close the race. It is a pre-flight question, and the container can stop between the answer and the attach arriving -- in which case the exit status is destroyed exactly as it would have been without the check. The engine offers no attach-if-running, so this cannot be fixed from a client. What makes the check worth its round trip is that the window is one round trip wide rather than unbounded, and that the condition it tests is precisely the condition that does the damage. Measured, one container per row, each exiting with status 4:
attach to an ALREADY-EXITED container Podman: status destroyed
attach while RUNNING, exits under the call Podman: status intact (4)
either of those Docker: status intact (4)
A container that is still running when the attach is sent therefore stays safe even when it exits a millisecond later. The damage needs the container to be stopped already, which is the common case and the one the check catches: a caller reaching for a container it knows has finished.
Refusing costs a caller nothing that attach could have given them, on either engine. Against a container that is not running there is no combination that is both safe and useful: on Podman every variant destroys the exit status, stream => 1 hangs forever on both engines (measured: Docker was still open when a 10 s probe gave up), and Docker's stream => 0 replay returns the same frames "logs" returns, with none of the hazard and with tail and since on top.
The check is engine-independent although the data loss is Podman's alone. Telling the engines apart would cost a round trip of its own, it would leave the both-engine stream => 1 hang in place, and it would make the safe call on Docker a call this distribution recommends against anywhere else.
This is a behavior change. Up to and including the previous release the call went straight to the engine.
This is the one-way attach
The engine has two attach protocols behind one path. Sent with Upgrade: tcp and Connection: Upgrade, POST /containers/{id}/attach answers 101 Switching Protocols and hands over a bidirectional connection: that is what docker attach uses, and it is what lets a caller type into the container's stdin. Sent without those headers -- which is what this method does -- the engine answers 200 and streams the container's output one way, in exactly the frames "logs" returns.
This method implements the second one only, because the transport here buffers a whole response before returning it (see "What the transport does not do" in API::Docker::Role::HTTP). Two consequences a caller has to plan around:
You cannot write to the container.
stdin => 1is passed to the engine, but this client sends no bytes after the request headers and then reads until the daemon closes, so there is no moment at which input could be supplied. Use API::Docker::API::Exec to run something interactive-shaped, or wait for the upgraded variantWithout a callback it returns when the stream ends, not before. With
stream => 1, attaching to a container that keeps running blocks until it exits or the daemon closes the connection -- and on a container that is not running it never returns at all, see "The defaults follow the engine" below. Passon_frameto read the stream as it arrives and stop where you like, exactly as "logs" does under "Following the log"; the return value is then the summary HashRef{ delivered => N, stopped => 0|1 }rather than the frames, andttybecomes a declaration the transport takes at its word -- an undeclared unframed stream croaks. For a running container that need not be attached to, "logs" withtailreads the same output and returns immediately
/containers/{id}/attach/ws, the WebSocket variant, is not implemented either.
The defaults follow the engine
stream defaults to 0 -- the engine's own default -- and logs to 1, which is the one flag that keeps the call useful without it. So $containers->attach($id) replays what the container has written and returns.
This is a change. Up to and including the previous release stream defaulted to 1, so the same call opened an open-ended subscription; a caller who wants the live stream now has to ask for it with stream => 1.
The reason is that the subscription has exactly one terminator: the container ending. stream means stream attached streams from the time the request was made onwards, so on a container that has already exited that terminator is in the past and will not happen again. attach also hijacks the connection -- the response carries no Content-Length and no chunked terminator -- so HTTP framing cannot signal the end either. The transport reads until EOF, there is no EOF, and the call hangs. on_frame does not help: nothing will ever call $stop->().
Measured on Podman 5.4.2 (API 1.41), all four against one and the same container:
?logs=1&stdout=1&stderr=1&stream=0, exited container -- 200, the frames, connection closed after 13 ms?logs=1&stdout=1&stderr=1&stream=1, exited container -- 200, the same frames, then hangsthe same with
Upgrade: tcp-- 101 UPGRADED, the same frames, still hangs?stream=1while the container is still running and exits three seconds later -- closes cleanly after 3 s
Docker does exactly the same, and that is measured now too. Against Docker 29.7.2 (API 1.55): ?logs=1&stdout=1&stderr=1&stream=1 on an exited container was still open when a 10 s probe gave up, and ?logs=1&stdout=1&stderr=1&stream=0 answered 200 with byte-identical frames and closed in half a millisecond. So the hang is not a Podman quirk to be worked around -- it is what both engines do with a subscription whose only terminator is already in the past, on an endpoint whose reference promises a close in neither direction. It is unspecified behavior on both, which is the case for the stream => 0 default rather than an argument against it.
One more measured difference: Podman refuses stream => 0 together with logs => 0 outright, with 400 at least one of Logs or Stream must be set, rather than answering an empty 200.
Options:
stream- Subscribe to what the container writes from the time of the request onwards. Default 0, which is the engine's own default.stream => 1on a container that is not running never returns; see "The defaults follow the engine"logs- Replay what the container has already written. Default 1, so the call returns something without subscribing; combined withstream => 1the replay comes first and then transitions seamlessly into the live output.logs => 0withoutstream => 1is the combination the engine refuses (400 on Podman)stdout- Attach stdout. Default 1 (engine default: false)stderr- Attach stderr. Default 1 (engine default: false)stdin- Attach stdin. Sent as asked, but nothing can be written to it here; see abovetty- Set to 1 when the container was created with a TTY and its output is binary, to skip demultiplexing. Same meaning as in "logs", and withon_framethe same promiseon_frame- CodeRef called with each frame as it arrives, instead of the ArrayRef being collected and returned. Same contract as in "logs"require_running- Ask "inspect" whether the container is running first, and croak rather than attach when it is not. Default 1. Set to 0 to attach to a stopped container anyway, which also skips the round trip; see "This method refuses a container that is not running" for what the check does and does not guarantee
top
my $processes = $containers->top($id, ps_args => 'aux');
List running processes in a container. Returns hashref with Titles and Processes arrays.
Options:
ps_args- Arguments passed topsinside the container, e.g.'aux'. Omitted, the engine uses its own default
stats
my $stats = $containers->stats($id);
Get container resource usage statistics (CPU, memory, network, I/O). With no options this is the one-shot call it always was: a single reading, returned as a HashRef.
For a container that is not running the two engines answer differently and neither says so in the status line -- on Podman this method croaks, on Docker it returns zeros that look like a reading. See "A container that is not running: a croak on Podman, zeros on Docker" before calling it on a container that may have stopped.
Following the stats
stream => 1 asks the engine for a reading per sampling cycle for as long as the container runs. Pass on_event with it and the readings are handed over as they arrive:
my $summary = $containers->stats($id,
stream => 1,
on_event => sub {
my ($stats, $stop) = @_;
printf "%.1f MB\n", $stats->{memory_stats}{usage} / 1024 ** 2;
$stop->() if ++$seen >= 5;
},
);
$summary; # { delivered => 5, stopped => 1 }
With a callback the return value is that summary HashRef, not the readings: delivered is how many went to the callback, stopped is 1 when the callback ended the stream and 0 when the daemon did. Nothing is accumulated. See "Streaming a response as it arrives" in API::Docker::Role::HTTP.
Without a callback, stream => 1 blocks until the container stops or the daemon closes the connection: the whole response is read before anything is parsed. It then returns an ArrayRef of readings rather than the single HashRef the one-shot call returns, which is the other reason to pass a callback instead.
Unlike "events" in API::Docker::API::System, this does not turn the stream's error check off. /events is a feed of engine records, where an errorDetail object would still be data; a stats stream is one container's readings, and the transport's default is to croak on a failure reported inside a 200 body ("Failure inside a 200 response" in API::Docker::Role::HTTP).
The default is kept on a measurement rather than on that analogy. Against Podman 5.4.2 (API 1.41) every object a running container's stream carries is a complete reading -- read, cpu_stats, memory_stats, networks and the rest -- and killing the container and then removing it while the stream was open ended the stream on a whole reading, with nothing appended after it. No errorDetail was sent in either case, and the Engine API reference names that key for /build, /images/create and /images/{name}/push alone. So the check has no legitimate reading here it could turn into a croak, and that -- not an unexamined default -- is why it stays on.
Options:
stream- Ask for a reading per sampling cycle instead of one. Defaults off and is always sent, so a call with no options is the single reading it has always beenon_event- CodeRef called with each reading as it arrives, instead of them being collected and returned; see above
A container that is not running: a croak on Podman, zeros on Docker
Neither engine answers this with an HTTP error, and they agree on nothing else. Measured on Podman 5.4.2 (API 1.41) and Docker 29.7.2 (API 1.55), same machine, one container that had exited.
Podman sends an error object inside the 200 -- chunked, for the one-shot call and for stream => 1 alike:
{ cause => 'container is stopped',
message => 'container is stopped',
response => 500 }
This method now croaks on it, with an API::Docker::Error::HTTP carrying ->status == 500 -- the code Podman named in response -- and the object itself as ->data, so $err->data->{cause} stays readable. That is a change: up to and including the previous release the one-shot call returned this HashRef where a reading was expected, and an on_event callback was handed it as though it were one. The check runs on the buffered return value and in front of the callback alike, and only on that exact shape -- a HashRef inside a 2xx carrying all three of cause, message and response lower-cased, with response reading as an integer >= 400. Nothing else in this distribution, in its fixtures, or in a sweep of fifteen read endpoints per engine carries even one of those keys lower-cased at the top level. The rule is deliberately not case-insensitive: a successful "wait" answers with a top-level Error key, and a looser rule would report every one of those as a failure.
Docker sends a structurally valid reading with everything zeroed -- 200, about 825 bytes, no error anywhere in it:
{ id => '...', name => '/...', os_type => 'linux',
read => '0001-01-01T00:00:00Z',
cpu_stats => { cpu_usage => { total_usage => 0, ... }, ... },
memory_stats => {}, pids_stats => {}, num_procs => 0, ... }
So the advice this section used to give -- test for read or cpu_stats before using what comes back -- is wrong on Docker: both keys are present, both look plausible, the test passes, and the caller uses zeros as though they were a measurement. The markers in that body are Go's zero time in read (0001-01-01T00:00:00Z) and an empty memory_stats, and those are Docker's shape rather than anybody's contract.
num_procs is not one of them, contrary to what this section said before it was measured: a one-shot reading taken from a container that was genuinely running on Docker 29.7.2 carries num_procs => 0 as well. It is zero on that engine either way and separates nothing.
The engine-independent question is not about the reading at all: ask "inspect" whether the container is running. Treat the zero timestamp as a cheap Docker-specific second opinion, not as the test.
stream => 1 on a container that is not running
The same call in follow mode fails in opposite directions on the two engines, and the standing advice for a streaming endpoint -- bound the window -- does not help, because there is no window to bound:
Podman sends the one error object above and closes at once. Without a callback that arrives as a one-element ArrayRef; either way this method croaks it
Docker streams one zero-filled reading per second, forever. Measured: 5 objects in a 5 s probe, 12 in a 12 s probe, the connection never closed by the engine. No container exit will ever end it -- the container had already exited when the call was made
stream => 1 without a callback therefore never returns on Docker for a container that is not running. on_event plus a $stop->() is the only way out, and there the callback has to decide for itself that a reading is not one: that stream carries nothing to croak on.
On Docker the stream does not end when the container does
Worse, and measured since: the container does not have to be stopped when the call is made. A stream => 1 opened on a container that was genuinely running on Docker 29.7.2 does not end when that container exits. It degrades. One 20 s probe against a container that exited after 3 s:
3 real readings, then 13 zero-filled ones, connection still open at 20 s
Podman ends the same stream on the container's exit -- 5.0 s for the same probe, the last object a whole reading.
So on Docker stream => 1 has no terminator tied to the container at all, and the readings turn to zeros without anything in the stream saying so. A caller that follows a container's stats until it stops is asking for something this endpoint does not offer on that engine: give on_event its own stopping condition -- a reading count, a deadline, or the Go zero time in read -- and do not wait for the stream to end on its own.
read_timeout does not bound this. It is an idle timeout -- silence since the last byte -- and this stream is never silent: it keeps producing a zero-filled reading once a second, indefinitely, so the clock that read_timeout measures never runs out. read_timeout bounds a daemon that goes quiet; a Docker stats stream after container exit does the opposite -- it keeps talking, just not truthfully. on_event still has to notice the Go zero time in read and call $stop->() itself; do not rely on read_timeout to end this case.
Why this is documented and not guarded
"attach" refuses a container that is not running (see "This method refuses a container that is not running"). This method does not, and the difference is deliberate rather than an inconsistency.
A pre-flight "inspect" can only answer is it running now. For "attach" that is the whole question: the damage needs the container to be stopped already, so the check leaves a window one round trip wide. For this method it is the wrong question -- the hazard is the container stopping at any point in a stream that may run for hours, which the measurement above is exactly a case of. A guard here would have returned "running, go ahead" and the caller would have hung anyway. Its blind spot is not a round trip, it is the entire stream.
The second difference is that nothing here is destroyed. This is a read: after a hang or a pocketful of zeros, "inspect" still reports the truth and the exit status is still there. That is precisely what "attach" takes away -- a caller cannot check afterwards, because checking afterwards is the thing that stops working. A guard is worth an unclosable race when the alternative is unrecoverable, and is not worth it when the caller can simply ask again.
What can be caught for free already is: Podman reports its refusal in the body and this method croaks on it, with no extra request and no race.
changes
for my $change (@{ $containers->changes($id) }) {
say $KIND[ $change->{Kind} ], ' ', $change->{Path};
}
Report which paths in the container's filesystem differ from the image it was created from -- the endpoint behind docker diff. Returns an ArrayRef of HashRefs, each with Path and Kind:
[ { Path => '/etc/hostname', Kind => 0 },
{ Path => '/tmp/new', Kind => 1 },
{ Path => '/etc/gone', Kind => 2 } ]
Kind is an integer, not a word, and the engine documents no names for the three values:
0- modified. The path exists in both and its contents or metadata changed1- added. The path exists only in the container2- deleted. The path existed in the image and is gone
A container with nothing changed comes back as an empty ArrayRef; the engine answers that case with a JSON null rather than an empty list.
Measured against Podman 5.4.2 (API 1.41): the endpoint is served, but an unknown container is answered with 500 and {"cause":"layer not known","message":"<id> not found: layer not known"} rather than the 404 every other container endpoint gives -- so a caller distinguishing "no such container" from a real failure cannot do it on the status code alone on that engine.
export
use Path::Tiny;
path('container.tar')->spew_raw($containers->export($id));
Export the container's whole filesystem as a tar archive -- the endpoint behind docker export. Returns the raw archive bytes, never decoded and never modified.
The archive is buffered whole in memory, so this costs the size of the container's filesystem in RAM. There is no streaming variant here.
Unlike "get" in API::Docker::API::Images, the result is a plain filesystem tar: no manifest.json, no layers, no image metadata. "load" in API::Docker::API::Images will not take it back -- importing a flat filesystem is POST /images/create?fromSrc=-, which this distribution does not expose.
resize
$containers->resize($id, h => 40, w => 120);
Resize the TTY of a container, so a program inside it sees the new terminal size. Form-identical to "resize" in API::Docker::API::Exec, which resizes the TTY of an exec instance instead.
Only meaningful for a container created with Tty => 1; the engine rejects the call otherwise.
Options:
h- New height in character rowsw- New width in character columns
wait
my $result = $containers->wait($id);
my $code = $result->{StatusCode};
$containers->wait($id, condition => 'not-running');
Block until the container reaches a condition, then return a HashRef -- not the exit code. StatusCode is the exit status of the container's main process, and it is the one key both engines always send:
{ StatusCode => 4 } # Docker 29.7.2 (API 1.55)
{ StatusCode => 4, Error => undef } # Podman 5.4.2 (API 1.41)
The Error key diverges, and exists is the wrong test for it. Measured on successful waits on both engines: Docker omits the key entirely, Podman sends "Error": null on every wait, which decodes to undef. So exists $result->{Error} is false on Docker and true on Podman for one and the same outcome, while defined $result->{Error} is false on both. Ask defined, never exists, and take StatusCode as the answer.
A non-null Error was not produced on either engine by any probe behind this documentation. The Engine API reference documents it as an object carrying Message; that shape is documented but not measured here, which is not the same as unreachable -- do not write code that assumes it cannot appear, and do not trust its shape without checking.
The call blocks in the client for as long as the engine takes to answer: this endpoint answers only once the condition is met, and the whole response is read before anything is parsed. There is no timeout, on this method or in the transport.
Measured on both engines:
A container that has already exited answers immediately with its real exit status -- with no condition and with
not-runningalikeA container that was created and never started answers immediately with an invented one: Docker
StatusCode => 0, PodmanStatusCode => -1. There is no exit status to report and the two engines make up different ones, so a0from this call is not proof that anything rancondition => 'next-exit'andcondition => 'removed'against an exited container block on both engines: the awaited event is in the future and may never happenAn unrecognised condition croaks 400 on both (Docker
invalid condition: "...", Podmanfailed to parse query parameter 'condition' ...), and an unknown container ID croaks 404
Podman also answers StatusCode => -1 for a container whose exit status "attach" has destroyed -- see "On Podman this destroys a stopped container's exit status". The value is that engine's sentinel for "no status", not an exit code.
This endpoint is also the reason the error check on "stats" matches cause, message and response case-sensitively: a successful wait is a 2xx body with a top-level Error key in it, and a rule matching error case-insensitively would turn every one of them into a failure.
Options:
condition- What to wait for:not-running(the engine's own default),next-exitorremoved. Sent only when given
pause
$containers->pause($id);
Pause all processes in a container.
Reports 1/0 like "start", but pausing an already-paused container is an error rather than a 304: measured against Podman 5.4.2 (API 1.41) it answers 500 with "..." is already paused: container state improper, which croaks. The Docker Engine API documents no 304 for this endpoint either. So this method returns 1 or croaks in practice.
unpause
$containers->unpause($id);
Unpause all processes in a container. Reports 1/0 like "start"; as with "pause", the no-op is an error and not a 304 -- Podman 5.4.2 answers unpausing a running container with 500.
rename
$containers->rename($id, 'new-name');
Rename a container.
update
$containers->update($id, Memory => 314572800);
Update container resource limits and configuration.
The boolean flags (Init, OomKillDisable) may be given as a Perl 1/0 or as a JSON boolean; either goes out as a real JSON true/false, which the engine's body type-check requires.
get_archive
use Path::Tiny;
my $tar = $containers->get_archive($id, path => '/etc/hostname');
path('hostname.tar')->spew_raw($tar);
# and what the path was, without a second request
my %stat;
my $tar = $containers->get_archive($id, path => '/var/log', stat => \%stat);
say $stat{name};
Read a path out of a container as a tar archive -- the outbound half of docker cp. Returns the raw archive bytes, never decoded and never modified.
A file comes back as a one-member archive named after its basename; a directory comes back as the directory and everything under it, with paths relative to its parent. The whole archive is buffered in memory.
Options:
path- Path inside the container to read. Requiredstat- HashRef theX-Docker-Container-Path-Statheader is decoded into. The engine sends it on this response as well as on the HEAD one, so asking for it here saves the extra round trip "stat_archive" would cost. Emptied when the engine sent no such header. See "stat_archive" for the keys
put_archive
use Path::Tiny;
$containers->put_archive($id, path('payload.tar')->slurp_raw,
path => '/opt/app');
Write a tar archive into a path inside the container -- the inbound half of docker cp. The archive is the request body; pass it as raw bytes or as a scalar reference to them, the way "load" in API::Docker::API::Images takes its archive. Returns nothing: the engine answers a success with an empty body.
path must name a directory that already exists in the container; the archive's members are unpacked into it. Writing a single file means putting that file in a one-member archive and naming its parent directory as path -- there is no "write these bytes to this filename" form of this endpoint.
The archive is sent as one buffered request body, so this costs its full size in RAM.
Options:
path- Directory inside the container to unpack into. RequirednoOverwriteDirNonDir- Refuse the request rather than replace an existing directory with a non-directory, or the other way round. Without it the engine replaces either with the othercopyUIDGID- Keep the UID and GID recorded in the archive instead of mapping the members to the container user
stat_archive
my $stat = $containers->stat_archive($id, path => '/etc/hostname');
say $stat->{name}; # hostname
say $stat->{size}; # 13
printf "%04o\n", $stat->{mode} & 0777; # 0644
Stat a path inside a container without transferring it -- HEAD on the same endpoint "get_archive" uses. Returns a HashRef, or undef when the engine answered without the header. A path that does not exist is a croak from the transport's status handling, not an undef.
The response has no body at all: the answer is the X-Docker-Container-Path-Stat header, base64-encoded JSON, which this method decodes. Its keys are the engine's, passed through as they arrive:
name- The path's basename. For a symlink the two engines disagree: Docker reports the requested path's own basename, Podman the resolved target'ssize- Size in bytesmode- Go'sos.FileModebits, not a POSIX mode word, on both engines. The permission bits are the low nine ($stat->{mode} & 0777); the type bits above them are Go's own numbering, so a directory'smodeisos.ModeDir(1<<31) plus the permission bits --2147484141for a0755directory -- rather than POSIX'sS_IFDIR, which for the same directory would give16877mtime- Modification time, RFC 3339linkTarget- The symlink target. Docker sends the literal, unresolved link content, and leaves this empty for anything that is not a symlink exactly as the Engine API reference documents; Podman sends the fully resolved path instead, and was measured populating it even for a plain regular file, where Docker leaves it emptyisDir- Boolean, true when the path is a directory. Podman only -- Docker was measured never sending this key, not even for a directory, so it is not part of the Docker Engine API's own answer
This shape is confirmed against Podman, not assumed: measured against the rootless socket, stat_archive on Podman returns exactly these six keys. For a symlink such as hnlink pointing at /etc/hostname, Docker reports name as hnlink (the link's own basename) and linkTarget as /etc/hostname (the raw, unresolved content); Podman reports name as hostname (the resolved target's basename) and the fully resolved path in linkTarget. The route itself was measured the same way on Podman: an unknown container answers 404, and that 404 announces a Content-Length while sending no body -- which is why "head" in API::Docker::Role::HTTP never reads one.
Options:
path- Path inside the container to stat. Required
prune
my $result = $containers->prune(filters => { until => ['24h'] });
Delete stopped containers. Returns hashref with ContainersDeleted and SpaceReclaimed.
Options:
filters- HashRef of filter name to ArrayRef of string values; the engine acceptsuntilandlabelhere. Shape-checked and normalised by API::Docker::Role::Filters
SEE ALSO
API::Docker - Main Docker client
API::Docker::Role::Entity::Container - the convenience methods the returned objects carry
API::Docker::Type::ContainerSummary - what "list" returns
API::Docker::Type::ContainerInspectResponse - what "inspect" returns
API::Docker::API::Exec - Execute commands in containers
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-api-docker/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.