Skip to content

Commit 59cd9ae

Browse files
committed
feat: add support for preserving and labeling intermediate stage images
This adds support for preserving and labeling intermediate stage images in multi-stage builds. In contrast to the --layers flag, --cache-stages preserves only the final image from each named stage (FROM ... AS name), not every instruction layer. This also keeps the final image's layer count unchanged compared to a regular build. New flags: - --cache-stages: preserve intermediate stage images instead of removing them - --stage-labels: add metadata labels to intermediate stage images (stage name, base image, build ID, parent stage name). Requires --cache-stages. - --build-id-file: write unique build ID (UUID) to file for easier identification and grouping of intermediate images from a single build. Requires --stage-labels. The implementation also includes: - Detection of transitive alias patterns (stage using another intermediate stage as base) - Validation that --stage-labels requires --cache-stages - Validation that --build-id-file requires --stage-labels - Test coverage (15 tests) and documentation updates This functionality is useful for debugging, exploring, and reusing intermediate stage images in multi-stage builds. Signed-off-by: Erik Mravec <[email protected]>
1 parent d936e65 commit 59cd9ae

File tree

14 files changed

+563
-13
lines changed

14 files changed

+563
-13
lines changed

define/build.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ type BuildOptions struct {
262262
DefaultMountsFilePath string
263263
// IIDFile tells the builder to write the image ID to the specified file
264264
IIDFile string
265+
// BuildIDFile tells the builder to write the build ID to the specified file
266+
BuildIDFile string
265267
// Squash tells the builder to produce an image with a single layer instead of with
266268
// possibly more than one layer, by only committing a new layer after processing the
267269
// final instruction.
@@ -276,6 +278,12 @@ type BuildOptions struct {
276278
OnBuild []string
277279
// Layers tells the builder to commit an image for each step in the Dockerfile.
278280
Layers bool
281+
// CacheStages tells the builder to preserve intermediate stage images instead of removing them.
282+
CacheStages bool
283+
// StageLabels tells the builder to add metadata labels to intermediate stage images for easier recognition.
284+
// These labels include stage name, index, base image, build ID, and whether it's the final stage.
285+
// This option requires CacheStages to be enabled.
286+
StageLabels bool
279287
// NoCache tells the builder to build the image from scratch without checking for a cache.
280288
// It creates a new set of cached images for the build.
281289
NoCache bool

docs/buildah-build.1.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,13 @@ Path to an alternative .containerignore (.dockerignore) file.
515515
Write the built image's ID to the file. When `--platform` is specified more
516516
than once, attempting to use this option will trigger an error.
517517

518+
**--build-id-file** *BuildIDfile*
519+
520+
Write a unique build ID (UUID) to the file. This build ID is generated once per
521+
build and is added to intermediate stage images as a label (`io.buildah.build.id`)
522+
when `--stage-labels` is enabled. This allows grouping all intermediate images
523+
from a single build together. This option requires `--stage-labels` to be enabled.
524+
518525
**--inherit-annotations** *bool-value*
519526

520527
Inherit the annotations from the base image or base stages. (default true).
@@ -586,6 +593,33 @@ Cache intermediate images during the build process (Default is `false`).
586593
Note: You can also override the default value of layers by setting the BUILDAH\_LAYERS
587594
environment variable. `export BUILDAH_LAYERS=true`
588595

596+
**--cache-stages** *bool-value*
597+
598+
Preserve intermediate stage images instead of removing them after the build completes
599+
(Default is `false`). By default, buildah removes intermediate stage images to save space.
600+
This option keeps those images, which can be useful for debugging multi-stage builds or
601+
for reusing intermediate stages in subsequent builds.
602+
603+
Note: This option only preserves stage images (FROM ... AS stage_name). It does not affect
604+
the behavior of `--layers`, which controls per-instruction caching within stages.
605+
606+
When combined with `--stage-labels`, intermediate images will include metadata labels
607+
for easier identification and management.
608+
609+
**--stage-labels** *bool-value*
610+
611+
Add metadata labels to intermediate stage images (Default is `false`). This option
612+
requires `--cache-stages` to be enabled.
613+
614+
When enabled, intermediate stage images will be labeled with:
615+
- `io.buildah.stage.name`: The stage name (from `FROM ... AS name`)
616+
- `io.buildah.stage.base`: The base image used by this stage
617+
- `io.buildah.stage.parent_name`: The parent stage name (if this stage uses another stage as base)
618+
- `io.buildah.build.id`: A unique build ID shared across all stages in a single build
619+
620+
These labels make it easier to identify, query, and manage intermediate images from
621+
multi-stage builds.
622+
589623
**--logfile** *filename*
590624

591625
Log output which would be sent to standard output and standard error to the
@@ -1385,6 +1419,10 @@ buildah build -v /var/lib/dnf:/var/lib/dnf:O -t imageName .
13851419

13861420
buildah build --layers -t imageName .
13871421

1422+
buildah build --cache-stages --stage-labels -t imageName .
1423+
1424+
buildah build --cache-stages --stage-labels --build-id-file /tmp/build-id.txt -t imageName .
1425+
13881426
buildah build --no-cache -t imageName .
13891427

13901428
buildah build -f Containerfile --layers --force-rm -t imageName .
@@ -1443,6 +1481,21 @@ buildah build --output type=tar,dest=out.tar .
14431481

14441482
buildah build -o - . > out.tar
14451483

1484+
### Preserving and querying intermediate stage images
1485+
1486+
Build a multi-stage image while preserving intermediate stages with metadata labels:
1487+
1488+
buildah build --cache-stages --stage-labels --build-id-file /tmp/build-id.txt -t myapp .
1489+
1490+
Query intermediate images from a specific build using the build ID:
1491+
1492+
BUILD_ID=$(cat /tmp/build-id.txt)
1493+
buildah images --filter "label=io.buildah.build.id=${BUILD_ID}"
1494+
1495+
Find an intermediate image for a specific stage name:
1496+
1497+
buildah images --filter "label=io.buildah.stage.name=builder"
1498+
14461499
### Building an image using a URL
14471500

14481501
This will clone the specified GitHub repository from the URL and use it as context. The Containerfile or Dockerfile at the root of the repository is used as the context of the build. This only works if the GitHub repository is a dedicated repository.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/docker/go-connections v0.6.0
1616
github.com/docker/go-units v0.5.0
1717
github.com/fsouza/go-dockerclient v1.12.2
18+
github.com/google/uuid v1.6.0
1819
github.com/hashicorp/go-multierror v1.1.1
1920
github.com/mattn/go-shellwords v1.0.12
2021
github.com/moby/buildkit v0.26.0
@@ -76,7 +77,6 @@ require (
7677
github.com/golang/protobuf v1.5.4 // indirect
7778
github.com/google/go-containerregistry v0.20.6 // indirect
7879
github.com/google/go-intervals v0.0.2 // indirect
79-
github.com/google/uuid v1.6.0 // indirect
8080
github.com/gorilla/mux v1.8.1 // indirect
8181
github.com/hashicorp/errwrap v1.1.0 // indirect
8282
github.com/inconshreveable/mousetrap v1.1.0 // indirect

imagebuildah/executor.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/containers/buildah/pkg/sshagent"
2323
"github.com/containers/buildah/util"
2424
encconfig "github.com/containers/ocicrypt/config"
25+
"github.com/google/uuid"
2526
digest "github.com/opencontainers/go-digest"
2627
v1 "github.com/opencontainers/image-spec/specs-go/v1"
2728
"github.com/openshift/imagebuilder"
@@ -111,6 +112,11 @@ type executor struct {
111112
layerLabels []string
112113
annotations []string
113114
layers bool
115+
cacheStages bool
116+
stageLabels bool
117+
buildID string
118+
buildIDFile string
119+
intermediateStageParents map[string]struct{} // Tracks which intermediate stages are used as base by other intermediate stages
114120
noHostname bool
115121
noHosts bool
116122
useCache bool
@@ -248,6 +254,19 @@ func newExecutor(logger *logrus.Logger, logPrefix string, store storage.Store, o
248254
buildOutputs = append(buildOutputs, options.BuildOutput) //nolint:staticcheck
249255
}
250256

257+
// Generate unique build ID if stage labels or build ID file are enabled
258+
var buildID string
259+
if options.StageLabels || options.BuildIDFile != "" {
260+
buildID = uuid.New().String()
261+
262+
// Write build ID to file if requested
263+
if options.BuildIDFile != "" {
264+
if err := os.WriteFile(options.BuildIDFile, []byte(buildID), 0o644); err != nil {
265+
return nil, fmt.Errorf("writing build ID to file %q: %w", options.BuildIDFile, err)
266+
}
267+
}
268+
}
269+
251270
exec := executor{
252271
args: options.Args,
253272
cacheFrom: options.CacheFrom,
@@ -293,13 +312,18 @@ func newExecutor(logger *logrus.Logger, logPrefix string, store storage.Store, o
293312
commonBuildOptions: options.CommonBuildOpts,
294313
defaultMountsFilePath: options.DefaultMountsFilePath,
295314
iidfile: options.IIDFile,
315+
buildIDFile: options.BuildIDFile,
296316
squash: options.Squash,
297317
labels: slices.Clone(options.Labels),
298318
layerLabels: slices.Clone(options.LayerLabels),
299319
processLabel: processLabel,
300320
mountLabel: mountLabel,
301321
annotations: slices.Clone(options.Annotations),
302322
layers: options.Layers,
323+
cacheStages: options.CacheStages,
324+
stageLabels: options.StageLabels,
325+
buildID: buildID,
326+
intermediateStageParents: make(map[string]struct{}),
303327
noHostname: options.CommonBuildOpts.NoHostname,
304328
noHosts: options.CommonBuildOpts.NoHosts,
305329
useCache: !options.NoCache,
@@ -840,6 +864,12 @@ func (b *executor) Build(ctx context.Context, stages imagebuilder.Stages) (image
840864
currentStageInfo.Needs = append(currentStageInfo.Needs, baseWithArg)
841865
}
842866
}
867+
// Track if this base is an intermediate stage used by another intermediate stage
868+
if stageIndex < len(stages)-1 {
869+
b.intermediateStageParents[baseWithArg] = struct{}{}
870+
logrus.Debugf("stage %d (%s) uses stage %q as base - marking %q as intermediate parent",
871+
stageIndex, stage.Name, baseWithArg, baseWithArg)
872+
}
843873
}
844874
}
845875
case "ADD", "COPY":
@@ -1038,9 +1068,10 @@ func (b *executor) Build(ctx context.Context, stages imagebuilder.Stages) (image
10381068
// We're not populating the cache with intermediate
10391069
// images, so add this one to the list of images that
10401070
// we'll remove later.
1041-
// Only remove intermediate image is `--layers` is not provided
1042-
// or following stage was not only a base image ( i.e a different image ).
1043-
if !b.layers && !r.OnlyBaseImage {
1071+
// Only remove intermediate image if `--layers` is not provided,
1072+
// `--cache-stages` is not enabled, or following stage was not
1073+
// only a base image (i.e. a different image).
1074+
if !b.layers && !b.cacheStages && !r.OnlyBaseImage {
10441075
cleanupImages = append(cleanupImages, r.ImageID)
10451076
}
10461077
}

imagebuildah/stage_executor.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ type stageExecutor struct {
8181
argsFromContainerfile []string
8282
hasLink bool
8383
isLastStep bool
84+
fromName string // original FROM value (stage name or image name) before resolution to image ID
8485
}
8586

8687
// Preserve informs the stage executor that from this point on, it needs to
@@ -978,6 +979,14 @@ func (s *stageExecutor) prepare(ctx context.Context, from string, initializeIBCo
978979
}
979980
from = base
980981
}
982+
// Store the original FROM value (stage name or image name) before resolution to image ID.
983+
// This is needed for detecting transitive aliases when setting stage labels.
984+
// Only set it on the first prepare call (when initializeIBConfig=true) to avoid
985+
// overwriting with image IDs from subsequent rebasing operations.
986+
if initializeIBConfig && rebase && s.fromName == "" {
987+
logrus.Debugf("Setting fromName for stage %q (index %d): from=%q", s.name, s.index, from)
988+
s.fromName = from
989+
}
981990
sanitizedFrom, err := s.sanitizeFrom(from, tmpdir.GetTempDir())
982991
if err != nil {
983992
return nil, fmt.Errorf("invalid base image specification %q: %w", from, err)
@@ -1221,6 +1230,11 @@ func (s *stageExecutor) execute(ctx context.Context, base string) (imgID string,
12211230
return "", nil, false, err
12221231
}
12231232
pullPolicy := s.executor.pullPolicy
1233+
// Save the original base name before it might get replaced with an image ID.
1234+
// This is needed for detecting transitive aliases when setting stage labels.
1235+
if s.fromName == "" {
1236+
s.fromName = base
1237+
}
12241238
s.executor.stagesLock.Lock()
12251239
var preserveBaseImageAnnotationsAtStageStart bool
12261240
if stageImage, isPreviousStage := s.executor.imageMap[base]; isPreviousStage {
@@ -1837,6 +1851,27 @@ func (s *stageExecutor) execute(ctx context.Context, base string) (imgID string,
18371851
s.hasLink = false
18381852
}
18391853

1854+
// If --cache-stages is enabled and this is not the last stage, commit the intermediate stage image.
1855+
// However, skip committing if this stage is a "parent" stage used as a base
1856+
// by another intermediate stage (transitive alias pattern).
1857+
// Only commit the final stage in a transitive alias chain.
1858+
if s.executor.cacheStages && !lastStage {
1859+
// Check if this stage is used as base by another intermediate stage
1860+
_, isParentStage := s.executor.intermediateStageParents[s.name]
1861+
if isParentStage {
1862+
logrus.Debugf("Skipping commit for intermediate stage %s (index %d) - used as base by another intermediate stage", s.name, s.index)
1863+
} else {
1864+
logrus.Debugf("Committing intermediate stage %s (index %d) for --cache-stages", s.name, s.index)
1865+
createdBy := fmt.Sprintf("/bin/sh -c #(nop) STAGE %s", s.name)
1866+
// Commit the stage without squashing, using empty output name (intermediate image)
1867+
imgID, commitResults, err = s.commit(ctx, createdBy, false, "", false, false)
1868+
if err != nil {
1869+
return "", nil, false, fmt.Errorf("committing intermediate stage %s: %w", s.name, err)
1870+
}
1871+
logrus.Debugf("Committed intermediate stage %s with ID %s", s.name, imgID)
1872+
}
1873+
}
1874+
18401875
return imgID, commitResults, onlyBaseImage, nil
18411876
}
18421877

@@ -2548,6 +2583,23 @@ func (s *stageExecutor) commit(ctx context.Context, createdBy string, emptyLayer
25482583
for k, v := range config.Labels {
25492584
s.builder.SetLabel(k, v)
25502585
}
2586+
// Add stage metadata labels if --cache-stages and --stage-labels are enabled.
2587+
// IMPORTANT: This must be done AFTER copying config.Labels to ensure stage labels
2588+
// are not overwritten by inherited labels from parent stages (transitive aliases).
2589+
if output == "" && s.executor.cacheStages && s.executor.stageLabels {
2590+
s.builder.SetLabel("io.buildah.stage.name", s.name)
2591+
s.builder.SetLabel("io.buildah.stage.base", s.builder.FromImage)
2592+
2593+
// Check if the base is another stage (transitive alias) using the original FROM value.
2594+
// s.fromName contains the stage name before resolution to image ID.
2595+
if s.fromName != "" && s.executor.stages[s.fromName] != nil {
2596+
s.builder.SetLabel("io.buildah.stage.parent_name", s.fromName)
2597+
}
2598+
2599+
if s.executor.buildID != "" {
2600+
s.builder.SetLabel("io.buildah.build.id", s.executor.buildID)
2601+
}
2602+
}
25512603
switch s.executor.commonBuildOptions.IdentityLabel {
25522604
case types.OptionalBoolTrue:
25532605
s.builder.SetLabel(buildah.BuilderIdentityAnnotation, define.Version)

pkg/cli/build.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ func GenBuildOptions(c *cobra.Command, inputArgs []string, iopts BuildOptions) (
222222
return options, nil, nil, errors.New("'rm' and 'force-rm' can only be set with either 'layers' or 'no-cache'")
223223
}
224224

225+
if iopts.StageLabels && !iopts.CacheStages {
226+
return options, nil, nil, errors.New("'stage-labels' requires 'cache-stages'")
227+
}
228+
229+
if iopts.BuildIdFile != "" && !iopts.StageLabels {
230+
return options, nil, nil, errors.New("'build-id-file' requires 'stage-labels'")
231+
}
232+
225233
if c.Flag("compress").Changed {
226234
logrus.Debugf("--compress option specified but is ignored")
227235
}
@@ -408,6 +416,7 @@ func GenBuildOptions(c *cobra.Command, inputArgs []string, iopts BuildOptions) (
408416
GroupAdd: iopts.GroupAdd,
409417
IDMappingOptions: idmappingOptions,
410418
IIDFile: iopts.Iidfile,
419+
BuildIDFile: iopts.BuildIdFile,
411420
IgnoreFile: iopts.IgnoreFile,
412421
In: stdin,
413422
InheritLabels: inheritLabels,
@@ -417,6 +426,8 @@ func GenBuildOptions(c *cobra.Command, inputArgs []string, iopts BuildOptions) (
417426
Labels: iopts.Label,
418427
LayerLabels: iopts.LayerLabel,
419428
Layers: layers,
429+
CacheStages: iopts.CacheStages,
430+
StageLabels: iopts.StageLabels,
420431
LogFile: iopts.Logfile,
421432
LogRusage: iopts.LogRusage,
422433
LogSplitByPlatform: iopts.LogSplitByPlatform,

pkg/cli/common.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ import (
2626

2727
// LayerResults represents the results of the layer flags
2828
type LayerResults struct {
29-
ForceRm bool
30-
Layers bool
29+
ForceRm bool
30+
Layers bool
31+
CacheStages bool
32+
StageLabels bool
3133
}
3234

3335
// UserNSResults represents the results for the UserNS flags
@@ -73,6 +75,7 @@ type BudResults struct {
7375
Format string
7476
From string
7577
Iidfile string
78+
BuildIdFile string
7679
InheritLabels bool
7780
InheritAnnotations bool
7881
Label []string
@@ -217,6 +220,8 @@ func GetLayerFlags(flags *LayerResults) pflag.FlagSet {
217220
fs := pflag.FlagSet{}
218221
fs.BoolVar(&flags.ForceRm, "force-rm", false, "always remove intermediate containers after a build, even if the build is unsuccessful.")
219222
fs.BoolVar(&flags.Layers, "layers", UseLayers(), "use intermediate layers during build. Use BUILDAH_LAYERS environment variable to override.")
223+
fs.BoolVar(&flags.CacheStages, "cache-stages", false, "preserve intermediate stage images.")
224+
fs.BoolVar(&flags.StageLabels, "stage-labels", false, "add metadata labels to intermediate stage images (requires --cache-stages).")
220225
return fs
221226
}
222227

@@ -253,6 +258,7 @@ func GetBudFlags(flags *BudResults) pflag.FlagSet {
253258
fs.StringSliceVarP(&flags.File, "file", "f", []string{}, "`pathname or URL` of a Dockerfile")
254259
fs.StringVar(&flags.Format, "format", DefaultFormat(), "`format` of the built image's manifest and metadata. Use BUILDAH_FORMAT environment variable to override.")
255260
fs.StringVar(&flags.Iidfile, "iidfile", "", "`file` to write the image ID to")
261+
fs.StringVar(&flags.BuildIdFile, "build-id-file", "", "`file` to write the build ID to")
256262
fs.IntVar(&flags.Jobs, "jobs", 1, "how many stages to run in parallel")
257263
fs.StringArrayVar(&flags.Label, "label", []string{}, "set metadata for an image (default [])")
258264
fs.StringArrayVar(&flags.LayerLabel, "layer-label", []string{}, "set metadata for an intermediate image (default [])")
@@ -357,6 +363,7 @@ func GetBudFlagsCompletions() commonComp.FlagCompletions {
357363
flagCompletion["hooks-dir"] = commonComp.AutocompleteNone
358364
flagCompletion["ignorefile"] = commonComp.AutocompleteDefault
359365
flagCompletion["iidfile"] = commonComp.AutocompleteDefault
366+
flagCompletion["build-id-file"] = commonComp.AutocompleteDefault
360367
flagCompletion["jobs"] = commonComp.AutocompleteNone
361368
flagCompletion["label"] = commonComp.AutocompleteNone
362369
flagCompletion["layer-label"] = commonComp.AutocompleteNone

0 commit comments

Comments
 (0)