diff --git a/commands/diskusage.go b/commands/diskusage.go
index a1f8224d7..62c621785 100644
--- a/commands/diskusage.go
+++ b/commands/diskusage.go
@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"io"
- "os"
- "strings"
"text/tabwriter"
"time"
@@ -13,20 +11,77 @@ import (
"github.com/docker/buildx/util/cobrautil/completion"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
+ "github.com/docker/cli/cli/command/formatter"
"github.com/docker/cli/opts"
"github.com/docker/go-units"
"github.com/moby/buildkit/client"
+ "github.com/pkg/errors"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)
+const (
+ duIDHeader = "ID"
+ duParentsHeader = "PARENTS"
+ duCreatedAtHeader = "CREATED AT"
+ duMutableHeader = "MUTABLE"
+ duReclaimHeader = "RECLAIMABLE"
+ duSharedHeader = "SHARED"
+ duSizeHeader = "SIZE"
+ duDescriptionHeader = "DESCRIPTION"
+ duUsageHeader = "USAGE COUNT"
+ duLastUsedAtHeader = "LAST ACCESSED"
+ duTypeHeader = "TYPE"
+
+ duDefaultTableFormat = "table {{.ID}}\t{{.Reclaimable}}\t{{.Size}}\t{{.LastUsedAt}}"
+
+ duDefaultPrettyTemplate = `ID: {{.ID}}
+{{- if .Parents }}
+Parents:
+{{- range .Parents }}
+ - {{.}}
+{{- end }}
+{{- end }}
+Created at: {{.CreatedAt}}
+Mutable: {{.Mutable}}
+Reclaimable: {{.Reclaimable}}
+Shared: {{.Shared}}
+Size: {{.Size}}
+{{- if .Description}}
+Description: {{ .Description }}
+{{- end }}
+Usage count: {{.UsageCount}}
+{{- if .LastUsedAt}}
+Last used: {{ .LastUsedAt }}
+{{- end }}
+{{- if .Type}}
+Type: {{ .Type }}
+{{- end }}
+`
+)
+
type duOptions struct {
builder string
filter opts.FilterOpt
verbose bool
+ format string
}
func runDiskUsage(ctx context.Context, dockerCli command.Cli, opts duOptions) error {
+ if opts.format != "" && opts.verbose {
+ return errors.New("--format and --verbose cannot be used together")
+ } else if opts.format == "" {
+ if opts.verbose {
+ opts.format = duDefaultPrettyTemplate
+ } else {
+ opts.format = duDefaultTableFormat
+ }
+ } else if opts.format == formatter.PrettyFormatKey {
+ opts.format = duDefaultPrettyTemplate
+ } else if opts.format == formatter.TableFormatKey {
+ opts.format = duDefaultTableFormat
+ }
+
pi, err := toBuildkitPruneInfo(opts.filter.Value())
if err != nil {
return err
@@ -74,33 +129,53 @@ func runDiskUsage(ctx context.Context, dockerCli command.Cli, opts duOptions) er
return err
}
- tw := tabwriter.NewWriter(os.Stdout, 1, 8, 1, '\t', 0)
- first := true
+ fctx := formatter.Context{
+ Output: dockerCli.Out(),
+ Format: formatter.Format(opts.format),
+ }
+
+ var dus []*client.UsageInfo
for _, du := range out {
- if du == nil {
- continue
- }
- if opts.verbose {
- printVerbose(tw, du)
- } else {
- if first {
- printTableHeader(tw)
- first = false
- }
- for _, di := range du {
- printTableRow(tw, di)
- }
-
- tw.Flush()
+ if du != nil {
+ dus = append(dus, du...)
}
}
- if opts.filter.Value().Len() == 0 {
- printSummary(tw, out)
+ render := func(format func(subContext formatter.SubContext) error) error {
+ for _, du := range dus {
+ if err := format(&diskusageContext{
+ format: fctx.Format,
+ du: du,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
}
- tw.Flush()
- return nil
+ duCtx := diskusageContext{}
+ duCtx.Header = formatter.SubHeaderContext{
+ "ID": duIDHeader,
+ "Parents": duParentsHeader,
+ "CreatedAt": duCreatedAtHeader,
+ "Mutable": duMutableHeader,
+ "Reclaimable": duReclaimHeader,
+ "Shared": duSharedHeader,
+ "Size": duSizeHeader,
+ "Description": duDescriptionHeader,
+ "UsageCount": duUsageHeader,
+ "LastUsedAt": duLastUsedAtHeader,
+ "Type": duTypeHeader,
+ }
+
+ defer func() {
+ if (fctx.Format != duDefaultTableFormat && fctx.Format != duDefaultPrettyTemplate) || fctx.Format.IsJSON() || opts.filter.Value().Len() > 0 {
+ return
+ }
+ printSummary(dockerCli.Out(), out)
+ }()
+
+ return fctx.Write(&duCtx, render)
}
func duCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
@@ -119,64 +194,78 @@ func duCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
flags := cmd.Flags()
flags.Var(&options.filter, "filter", "Provide filter values")
- flags.BoolVar(&options.verbose, "verbose", false, "Provide a more verbose output")
+ flags.BoolVar(&options.verbose, "verbose", false, `Shorthand for "--format=pretty"`)
+ flags.StringVar(&options.format, "format", "", "Format the output")
return cmd
}
-func printKV(w io.Writer, k string, v any) {
- fmt.Fprintf(w, "%s:\t%v\n", k, v)
+type diskusageContext struct {
+ formatter.HeaderContext
+ format formatter.Format
+ du *client.UsageInfo
}
-func printVerbose(tw *tabwriter.Writer, du []*client.UsageInfo) {
- for _, di := range du {
- printKV(tw, "ID", di.ID)
- if len(di.Parents) != 0 {
- printKV(tw, "Parent", strings.Join(di.Parents, ","))
- }
- printKV(tw, "Created at", di.CreatedAt)
- printKV(tw, "Mutable", di.Mutable)
- printKV(tw, "Reclaimable", !di.InUse)
- printKV(tw, "Shared", di.Shared)
- printKV(tw, "Size", units.HumanSize(float64(di.Size)))
- if di.Description != "" {
- printKV(tw, "Description", di.Description)
- }
- printKV(tw, "Usage count", di.UsageCount)
- if di.LastUsedAt != nil {
- printKV(tw, "Last used", units.HumanDuration(time.Since(*di.LastUsedAt))+" ago")
- }
- if di.RecordType != "" {
- printKV(tw, "Type", di.RecordType)
- }
-
- fmt.Fprintf(tw, "\n")
- }
-
- tw.Flush()
+func (d *diskusageContext) MarshalJSON() ([]byte, error) {
+ return formatter.MarshalJSON(d)
}
-func printTableHeader(tw *tabwriter.Writer) {
- fmt.Fprintln(tw, "ID\tRECLAIMABLE\tSIZE\tLAST ACCESSED")
-}
-
-func printTableRow(tw *tabwriter.Writer, di *client.UsageInfo) {
- id := di.ID
- if di.Mutable {
+func (d *diskusageContext) ID() string {
+ id := d.du.ID
+ if d.format.IsTable() && d.du.Mutable {
id += "*"
}
- size := units.HumanSize(float64(di.Size))
- if di.Shared {
- size += "*"
- }
- lastAccessed := ""
- if di.LastUsedAt != nil {
- lastAccessed = units.HumanDuration(time.Since(*di.LastUsedAt)) + " ago"
- }
- fmt.Fprintf(tw, "%-40s\t%-5v\t%-10s\t%s\n", id, !di.InUse, size, lastAccessed)
+ return id
}
-func printSummary(tw *tabwriter.Writer, dus [][]*client.UsageInfo) {
+func (d *diskusageContext) Parents() []string {
+ return d.du.Parents
+}
+
+func (d *diskusageContext) CreatedAt() string {
+ return d.du.CreatedAt.String()
+}
+
+func (d *diskusageContext) Mutable() bool {
+ return d.du.Mutable
+}
+
+func (d *diskusageContext) Reclaimable() bool {
+ return !d.du.InUse
+}
+
+func (d *diskusageContext) Shared() bool {
+ return d.du.Shared
+}
+
+func (d *diskusageContext) Size() string {
+ size := units.HumanSize(float64(d.du.Size))
+ if d.format.IsTable() && d.du.Shared {
+ size += "*"
+ }
+ return size
+}
+
+func (d *diskusageContext) Description() string {
+ return d.du.Description
+}
+
+func (d *diskusageContext) UsageCount() int {
+ return d.du.UsageCount
+}
+
+func (d *diskusageContext) LastUsedAt() string {
+ if d.du.LastUsedAt != nil {
+ return units.HumanDuration(time.Since(*d.du.LastUsedAt)) + " ago"
+ }
+ return ""
+}
+
+func (d *diskusageContext) Type() string {
+ return string(d.du.RecordType)
+}
+
+func printSummary(w io.Writer, dus [][]*client.UsageInfo) {
total := int64(0)
reclaimable := int64(0)
shared := int64(0)
@@ -195,11 +284,11 @@ func printSummary(tw *tabwriter.Writer, dus [][]*client.UsageInfo) {
}
}
+ tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0)
if shared > 0 {
fmt.Fprintf(tw, "Shared:\t%s\n", units.HumanSize(float64(shared)))
fmt.Fprintf(tw, "Private:\t%s\n", units.HumanSize(float64(total-shared)))
}
-
fmt.Fprintf(tw, "Reclaimable:\t%s\n", units.HumanSize(float64(reclaimable)))
fmt.Fprintf(tw, "Total:\t%s\n", units.HumanSize(float64(total)))
tw.Flush()
diff --git a/commands/prune.go b/commands/prune.go
index d739fac5b..24574f92a 100644
--- a/commands/prune.go
+++ b/commands/prune.go
@@ -3,6 +3,7 @@ package commands
import (
"context"
"fmt"
+ "io"
"os"
"strings"
"text/tabwriter"
@@ -241,3 +242,55 @@ func toBuildkitPruneInfo(f filters.Args) (*client.PruneInfo, error) {
Filter: []string{strings.Join(filters, ",")},
}, nil
}
+
+func printKV(w io.Writer, k string, v any) {
+ fmt.Fprintf(w, "%s:\t%v\n", k, v)
+}
+
+func printVerbose(tw *tabwriter.Writer, du []*client.UsageInfo) {
+ for _, di := range du {
+ printKV(tw, "ID", di.ID)
+ if len(di.Parents) != 0 {
+ printKV(tw, "Parent", strings.Join(di.Parents, ","))
+ }
+ printKV(tw, "Created at", di.CreatedAt)
+ printKV(tw, "Mutable", di.Mutable)
+ printKV(tw, "Reclaimable", !di.InUse)
+ printKV(tw, "Shared", di.Shared)
+ printKV(tw, "Size", units.HumanSize(float64(di.Size)))
+ if di.Description != "" {
+ printKV(tw, "Description", di.Description)
+ }
+ printKV(tw, "Usage count", di.UsageCount)
+ if di.LastUsedAt != nil {
+ printKV(tw, "Last used", units.HumanDuration(time.Since(*di.LastUsedAt))+" ago")
+ }
+ if di.RecordType != "" {
+ printKV(tw, "Type", di.RecordType)
+ }
+
+ fmt.Fprintf(tw, "\n")
+ }
+
+ tw.Flush()
+}
+
+func printTableHeader(tw *tabwriter.Writer) {
+ fmt.Fprintln(tw, "ID\tRECLAIMABLE\tSIZE\tLAST ACCESSED")
+}
+
+func printTableRow(tw *tabwriter.Writer, di *client.UsageInfo) {
+ id := di.ID
+ if di.Mutable {
+ id += "*"
+ }
+ size := units.HumanSize(float64(di.Size))
+ if di.Shared {
+ size += "*"
+ }
+ lastAccessed := ""
+ if di.LastUsedAt != nil {
+ lastAccessed = units.HumanDuration(time.Since(*di.LastUsedAt)) + " ago"
+ }
+ fmt.Fprintf(tw, "%-40s\t%-5v\t%-10s\t%s\n", id, !di.InUse, size, lastAccessed)
+}
diff --git a/docs/reference/buildx_du.md b/docs/reference/buildx_du.md
index 298782726..d80498c10 100644
--- a/docs/reference/buildx_du.md
+++ b/docs/reference/buildx_du.md
@@ -14,7 +14,8 @@ Disk usage
| [`--builder`](#builder) | `string` | | Override the configured builder instance |
| `-D`, `--debug` | `bool` | | Enable debug logging |
| `--filter` | `filter` | | Provide filter values |
-| [`--verbose`](#verbose) | `bool` | | Provide a more verbose output |
+| [`--format`](#format) | `string` | | Format the output |
+| [`--verbose`](#verbose) | `bool` | | Shorthand for `--format=pretty` |
@@ -50,7 +51,7 @@ If `RECLAIMABLE` is false, the `docker buildx du prune` command won't delete
the record, even if you use `--all`. That's because the record is actively in
use by some component of the builder.
-The asterisks (\*) in the default output indicate the following:
+The asterisks (\*) in the default output format indicate the following:
- An asterisk next to an ID (`zu7m6evdpebh5h8kfkpw9dlf2*`) indicates that the record
is mutable. The size of the record may change, or another build can take ownership of
@@ -61,33 +62,146 @@ The asterisks (\*) in the default output indicate the following:
If you prune such a record then you will lose build cache but only metadata
will be deleted as the image still needs to actual storage layers.
+### Format the output (--format)
+
+The formatting options (`--format`) pretty-prints usage information output
+using a Go template.
+
+Valid placeholders for the Go template are:
+
+* `.ID`
+* `.Parents`
+* `.CreatedAt`
+* `.Mutable`
+* `.Reclaimable`
+* `.Shared`
+* `.Size`
+* `.Description`
+* `.UsageCount`
+* `.LastUsedAt`
+* `.Type`
+
+When using the `--format` option, the `du` command will either output the data
+exactly as the template declares.
+
+The `pretty` format is useful for inspecting the disk usage records in more
+detail. It shows the mutable and shared states more clearly, as well as
+additional information about the corresponding layer:
+
+```console
+$ docker buildx du --format=pretty
+...
+ID: 6wqu0v6hjdwvhh8yjozrepaof
+Parents:
+ - bqx15bcewecz4wcg14b7iodvp
+Created at: 2025-06-12 15:44:02.715795569 +0000 UTC
+Mutable: false
+Reclaimable: true
+Shared: true
+Size: 1.653GB
+Description: [build-base 4/4] COPY . .
+Usage count: 1
+Last used: 2 months ago
+Type: regular
+
+Shared: 35.57GB
+Private: 97.94GB
+Reclaimable: 131.5GB
+Total: 133.5GB
+```
+
+The following example uses a template without headers and outputs the
+`ID` and `Size` entries separated by a colon (`:`):
+
+```console
+$ docker buildx du --format "{{.ID}}: {{.Size}}"
+6wqu0v6hjdwvhh8yjozrepaof: 1.653GB
+4m8061kctvjyh9qleus8rgpgx: 1.723GB
+fcm9mlz2641u8r5eicjqdhy1l: 1.841GB
+z2qu1swvo3afzd9mhihi3l5k0: 1.873GB
+nmi6asc00aa3ja6xnt6o7wbrr: 2.027GB
+0qlam41jxqsq6i27yqllgxed3: 2.495GB
+3w9qhzzskq5jc262snfu90bfz: 2.617GB
+```
+
+The following example uses a `table` template and outputs the `ID` and
+`Description`:
+
+```console
+$ docker buildx du --format "table {{.ID}} {{.Descirption}}"
+lu76wm07lk5u7fe9nul93o95o [integration-tests 1/1] COPY . .
+v6zmkcmgujv34vnys9eszttnv [dev 1/1] COPY --link . .
+nj4fwb6qxznswmij3fg30sns2 mount / from exec /bin/sh -c rpm-init $DISTRO_NAME
+```
+
+JSON output is also supported and will print as newline delimited JSON:
+
+```console
+$ docker buildx du --format=json
+{"CreatedAt":"2025-07-29T12:36:01Z","Description":"pulled from docker.io/library/rust:1.85.1-bookworm@sha256:e51d0265072d2d9d5d320f6a44dde6b9ef13653b035098febd68cce8fa7c0bc4","ID":"ic1gfidvev5nciupzz53alel4","LastUsedAt":"2025-07-29T12:36:01Z","Mutable":false,"Parents":["hmpdhm4sjrfpmae4xm2y3m0ra"],"Reclaimable":true,"Shared":false,"Size":"829889526","Type":"regular","UsageCount":1}
+{"CreatedAt":"2025-08-05T09:24:09Z","Description":"pulled from docker.io/library/node:22@sha256:3218f0d1b9e4b63def322e9ae362d581fbeac1ef21b51fc502ef91386667ce92","ID":"jsw7fx09l5zsda3bri1z4mwk5","LastUsedAt":"2025-08-05T09:24:09Z","Mutable":false,"Parents":["098jsj5ebbv1w47ikqigeuurs"],"Reclaimable":true,"Shared":true,"Size":"829898832","Type":"regular","UsageCount":1}
+```
+
+You can use `jq` to pretty-print the JSON output:
+
+```console
+$ docker buildx du --format=json | jq .
+{
+ "CreatedAt": "2025-07-29T12:36:01Z",
+ "Description": "pulled from docker.io/library/rust:1.85.1-bookworm@sha256:e51d0265072d2d9d5d320f6a44dde6b9ef13653b035098febd68cce8fa7c0bc4",
+ "ID": "ic1gfidvev5nciupzz53alel4",
+ "LastUsedAt": "2025-07-29T12:36:01Z",
+ "Mutable": false,
+ "Parents": [
+ "hmpdhm4sjrfpmae4xm2y3m0ra"
+ ],
+ "Reclaimable": true,
+ "Shared": false,
+ "Size": "829889526",
+ "Type": "regular",
+ "UsageCount": 1
+}
+{
+ "CreatedAt": "2025-08-05T09:24:09Z",
+ "Description": "pulled from docker.io/library/node:22@sha256:3218f0d1b9e4b63def322e9ae362d581fbeac1ef21b51fc502ef91386667ce92",
+ "ID": "jsw7fx09l5zsda3bri1z4mwk5",
+ "LastUsedAt": "2025-08-05T09:24:09Z",
+ "Mutable": false,
+ "Parents": [
+ "098jsj5ebbv1w47ikqigeuurs"
+ ],
+ "Reclaimable": true,
+ "Shared": true,
+ "Size": "829898832",
+ "Type": "regular",
+ "UsageCount": 1
+}
+```
+
### Use verbose output (--verbose)
-The verbose output of the `docker buildx du` command is useful for inspecting
-the disk usage records in more detail. The verbose output shows the mutable and
-shared states more clearly, as well as additional information about the
-corresponding layer.
+Shorthand for [`--format=pretty`](#format):
```console
$ docker buildx du --verbose
...
-Last used: 2 days ago
-Type: regular
+ID: 6wqu0v6hjdwvhh8yjozrepaof
+Parents:
+ - bqx15bcewecz4wcg14b7iodvp
+Created at: 2025-06-12 15:44:02.715795569 +0000 UTC
+Mutable: false
+Reclaimable: true
+Shared: true
+Size: 1.653GB
+Description: [build-base 4/4] COPY . .
+Usage count: 1
+Last used: 2 months ago
+Type: regular
-ID: 05d0elirb4mmvpmnzbrp3ssrg
-Parent: e8sfdn4mygrg7msi9ak1dy6op
-Created at: 2023-11-20 09:53:30.881558721 +0000 UTC
-Mutable: false
-Reclaimable: true
-Shared: false
-Size: 0B
-Description: [gobase 3/3] WORKDIR /src
-Usage count: 3
-Last used: 24 hours ago
-Type: regular
-
-Reclaimable: 4.453GB
-Total: 4.453GB
+Shared: 35.57GB
+Private: 97.94GB
+Reclaimable: 131.5GB
+Total: 133.5GB
```
### Override the configured builder instance (--builder)
diff --git a/tests/diskusage.go b/tests/diskusage.go
new file mode 100644
index 000000000..8cfb4caa9
--- /dev/null
+++ b/tests/diskusage.go
@@ -0,0 +1,51 @@
+package tests
+
+import (
+ "testing"
+
+ "github.com/moby/buildkit/util/testutil/integration"
+ "github.com/stretchr/testify/require"
+)
+
+var diskusageTests = []func(t *testing.T, sb integration.Sandbox){
+ testDiskusage,
+ testDiskusageVerbose,
+ testDiskusageVerboseFormatError,
+ testDiskusageFormatJSON,
+ testDiskusageFormatGoTemplate,
+}
+
+func testDiskusage(t *testing.T, sb integration.Sandbox) {
+ buildTestProject(t, sb)
+ cmd := buildxCmd(sb, withArgs("du"))
+ out, err := cmd.Output()
+ require.NoError(t, err, string(out))
+}
+
+func testDiskusageVerbose(t *testing.T, sb integration.Sandbox) {
+ buildTestProject(t, sb)
+ cmd := buildxCmd(sb, withArgs("du", "--verbose"))
+ out, err := cmd.Output()
+ require.NoError(t, err, string(out))
+}
+
+func testDiskusageVerboseFormatError(t *testing.T, sb integration.Sandbox) {
+ buildTestProject(t, sb)
+ cmd := buildxCmd(sb, withArgs("du", "--verbose", "--format=json"))
+ out, err := cmd.Output()
+ require.Error(t, err, string(out))
+}
+
+func testDiskusageFormatJSON(t *testing.T, sb integration.Sandbox) {
+ buildTestProject(t, sb)
+ cmd := buildxCmd(sb, withArgs("du", "--format=json"))
+ out, err := cmd.Output()
+ require.NoError(t, err, string(out))
+}
+
+func testDiskusageFormatGoTemplate(t *testing.T, sb integration.Sandbox) {
+ buildTestProject(t, sb)
+ cmd := buildxCmd(sb, withArgs("du", "--format={{.ID}}: {{.Size}}"))
+ out, err := cmd.Output()
+ require.NoError(t, err, string(out))
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
index 44375b45a..e77a1a434 100644
--- a/tests/integration_test.go
+++ b/tests/integration_test.go
@@ -33,6 +33,7 @@ func TestIntegration(t *testing.T) {
tests = append(tests, rmTests...)
tests = append(tests, dialstdioTests...)
tests = append(tests, composeTests...)
+ tests = append(tests, diskusageTests...)
testIntegration(t, tests...)
}