Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9599d0a

Browse files
Your Nameweipeng
Your Name
authored and
weipeng
committedDec 13, 2024·
Add image squash command
Signed-off-by: weipeng <[email protected]>
1 parent 6f55896 commit 9599d0a

File tree

5 files changed

+566
-0
lines changed

5 files changed

+566
-0
lines changed
 

‎cmd/nerdctl/image/image.go

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func NewImageCommand() *cobra.Command {
4242
NewLoadCommand(),
4343
NewSaveCommand(),
4444
NewTagCommand(),
45+
NewSquashCommand(),
4546
imageRmCommand(),
4647
newImageConvertCommand(),
4748
newImageInspectCommand(),

‎cmd/nerdctl/image/image_squash.go

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package image
18+
19+
import (
20+
"fmt"
21+
22+
"github.com/spf13/cobra"
23+
24+
"github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers"
25+
"github.com/containerd/nerdctl/v2/pkg/api/types"
26+
"github.com/containerd/nerdctl/v2/pkg/clientutil"
27+
"github.com/containerd/nerdctl/v2/pkg/cmd/image"
28+
)
29+
30+
func addSquashFlags(cmd *cobra.Command) {
31+
cmd.Flags().IntP("layer-count", "c", 0, "The number of layers that can be compressed")
32+
cmd.Flags().StringP("layer-digest", "d", "", "The digest of the layer to be compressed")
33+
cmd.Flags().StringP("author", "a", "", `Author (e.g., "nerdctl contributor <nerdctl-dev@example.com>")`)
34+
cmd.Flags().StringP("message", "m", "", "Commit message")
35+
}
36+
37+
func NewSquashCommand() *cobra.Command {
38+
var squashCommand = &cobra.Command{
39+
Use: "squash [flags] SOURCE_IMAGE TAG_IMAGE",
40+
Short: "Compress the number of layers of the image",
41+
Args: helpers.IsExactArgs(2),
42+
RunE: squashAction,
43+
SilenceUsage: true,
44+
SilenceErrors: true,
45+
}
46+
addSquashFlags(squashCommand)
47+
return squashCommand
48+
}
49+
50+
func processSquashCommandFlags(cmd *cobra.Command, args []string) (options types.ImageSquashOptions, err error) {
51+
globalOptions, err := helpers.ProcessRootCmdFlags(cmd)
52+
if err != nil {
53+
return options, err
54+
}
55+
layerCount, err := cmd.Flags().GetInt("layer-count")
56+
if err != nil {
57+
return options, err
58+
}
59+
layerDigest, err := cmd.Flags().GetString("layer-digest")
60+
if err != nil {
61+
return options, err
62+
}
63+
author, err := cmd.Flags().GetString("author")
64+
if err != nil {
65+
return options, err
66+
}
67+
message, err := cmd.Flags().GetString("message")
68+
if err != nil {
69+
return options, err
70+
}
71+
72+
options = types.ImageSquashOptions{
73+
GOptions: globalOptions,
74+
75+
Author: author,
76+
Message: message,
77+
78+
SourceImageRef: args[0],
79+
TargetImageName: args[1],
80+
81+
SquashLayerCount: layerCount,
82+
SquashLayerDigest: layerDigest,
83+
}
84+
return options, nil
85+
}
86+
87+
func squashAction(cmd *cobra.Command, args []string) error {
88+
options, err := processSquashCommandFlags(cmd, args)
89+
if err != nil {
90+
return err
91+
}
92+
if !options.GOptions.Experimental {
93+
return fmt.Errorf("squash is an experimental feature, please enable experimental mode")
94+
}
95+
client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address)
96+
if err != nil {
97+
return err
98+
}
99+
defer cancel()
100+
101+
return image.Squash(ctx, client, options)
102+
}

‎cmd/nerdctl/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ Config file ($NERDCTL_TOML): %s
295295
image.NewTagCommand(),
296296
image.NewRmiCommand(),
297297
image.NewHistoryCommand(),
298+
image.NewSquashCommand(),
298299
// #endregion
299300

300301
// #region System

‎pkg/api/types/image_types.go

+20
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,23 @@ type SociOptions struct {
287287
// Minimum layer size to build zTOC for. Smaller layers won't have zTOC and not lazy pulled. Default is 10 MiB.
288288
MinLayerSize int64
289289
}
290+
291+
// ImageSquashOptions specifies options for `nerdctl image squash`.
292+
type ImageSquashOptions struct {
293+
// GOptions is the global options
294+
GOptions GlobalCommandOptions
295+
296+
// Author (e.g., "nerdctl contributor <nerdctl-dev@example.com>")
297+
Author string
298+
// Commit message
299+
Message string
300+
// SourceImageRef is the image to be squashed
301+
SourceImageRef string
302+
// TargetImageName is the name of the squashed image
303+
TargetImageName string
304+
305+
// SquashLayerCount is the number of layers to squash
306+
SquashLayerCount int
307+
// SquashLayerDigest is the digest of the layer to squash
308+
SquashLayerDigest string
309+
}

‎pkg/cmd/image/squash.go

+442
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,442 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package image
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"crypto/rand"
23+
"encoding/base64"
24+
"encoding/json"
25+
"fmt"
26+
"runtime"
27+
"strings"
28+
"time"
29+
30+
containerd "github.com/containerd/containerd/v2/client"
31+
"github.com/containerd/containerd/v2/core/content"
32+
"github.com/containerd/containerd/v2/core/images"
33+
"github.com/containerd/containerd/v2/core/leases"
34+
"github.com/containerd/containerd/v2/core/mount"
35+
"github.com/containerd/containerd/v2/core/snapshots"
36+
"github.com/containerd/containerd/v2/pkg/namespaces"
37+
"github.com/containerd/containerd/v2/pkg/rootfs"
38+
"github.com/containerd/errdefs"
39+
"github.com/containerd/log"
40+
"github.com/opencontainers/go-digest"
41+
"github.com/opencontainers/image-spec/identity"
42+
"github.com/opencontainers/image-spec/specs-go"
43+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
44+
45+
"github.com/containerd/nerdctl/v2/pkg/api/types"
46+
"github.com/containerd/nerdctl/v2/pkg/imgutil"
47+
)
48+
49+
const (
50+
emptyDigest = digest.Digest("")
51+
)
52+
53+
type squashImage struct {
54+
ClientImage containerd.Image
55+
Config ocispec.Image
56+
Image images.Image
57+
Manifest *ocispec.Manifest
58+
}
59+
60+
type squashRuntime struct {
61+
opt types.ImageSquashOptions
62+
63+
client *containerd.Client
64+
namespace string
65+
66+
differ containerd.DiffService
67+
imageStore images.Store
68+
contentStore content.Store
69+
snapshotter snapshots.Snapshotter
70+
}
71+
72+
func (sr *squashRuntime) initImage(ctx context.Context) (*squashImage, error) {
73+
containerImage, err := sr.imageStore.Get(ctx, sr.opt.SourceImageRef)
74+
if err != nil {
75+
return &squashImage{}, err
76+
}
77+
78+
clientImage := containerd.NewImage(sr.client, containerImage)
79+
manifest, _, err := imgutil.ReadManifest(ctx, clientImage)
80+
if err != nil {
81+
return &squashImage{}, err
82+
}
83+
config, _, err := imgutil.ReadImageConfig(ctx, clientImage)
84+
if err != nil {
85+
return &squashImage{}, err
86+
}
87+
resImage := &squashImage{
88+
ClientImage: clientImage,
89+
Config: config,
90+
Image: containerImage,
91+
Manifest: manifest,
92+
}
93+
return resImage, err
94+
}
95+
96+
func (sr *squashRuntime) generateSquashLayer(image *squashImage) ([]ocispec.Descriptor, error) {
97+
// get the layer descriptors by the layer digest
98+
if sr.opt.SquashLayerDigest != "" {
99+
find := false
100+
var res []ocispec.Descriptor
101+
for _, layer := range image.Manifest.Layers {
102+
if layer.Digest.String() == sr.opt.SquashLayerDigest {
103+
find = true
104+
}
105+
if find {
106+
res = append(res, layer)
107+
}
108+
}
109+
if !find {
110+
return nil, fmt.Errorf("layer digest %s not found in the image: %w", sr.opt.SquashLayerDigest, errdefs.ErrNotFound)
111+
}
112+
return res, nil
113+
}
114+
115+
// get the layer descriptors by the layer count
116+
if sr.opt.SquashLayerCount > 1 && sr.opt.SquashLayerCount <= len(image.Manifest.Layers) {
117+
return image.Manifest.Layers[len(image.Manifest.Layers)-sr.opt.SquashLayerCount:], nil
118+
}
119+
120+
return nil, fmt.Errorf("invalid squash option: %w", errdefs.ErrInvalidArgument)
121+
}
122+
123+
func (sr *squashRuntime) applyLayersToSnapshot(ctx context.Context, mount []mount.Mount, layers []ocispec.Descriptor) error {
124+
for _, layer := range layers {
125+
if _, err := sr.differ.Apply(ctx, layer, mount); err != nil {
126+
return err
127+
}
128+
}
129+
return nil
130+
}
131+
132+
// createDiff creates a diff from the snapshot
133+
func (sr *squashRuntime) createDiff(ctx context.Context, snapshotName string) (ocispec.Descriptor, digest.Digest, error) {
134+
newDesc, err := rootfs.CreateDiff(ctx, snapshotName, sr.snapshotter, sr.differ)
135+
if err != nil {
136+
return ocispec.Descriptor{}, "", err
137+
}
138+
info, err := sr.contentStore.Info(ctx, newDesc.Digest)
139+
if err != nil {
140+
return ocispec.Descriptor{}, "", err
141+
}
142+
diffIDStr, ok := info.Labels["containerd.io/uncompressed"]
143+
if !ok {
144+
return ocispec.Descriptor{}, "", fmt.Errorf("invalid differ response with no diffID")
145+
}
146+
diffID, err := digest.Parse(diffIDStr)
147+
if err != nil {
148+
return ocispec.Descriptor{}, "", err
149+
}
150+
return ocispec.Descriptor{
151+
MediaType: images.MediaTypeDockerSchema2LayerGzip,
152+
Digest: newDesc.Digest,
153+
Size: info.Size,
154+
}, diffID, nil
155+
}
156+
157+
func (sr *squashRuntime) generateBaseImageConfig(ctx context.Context, image *squashImage, remainingLayerCount int) (ocispec.Image, error) {
158+
// generate squash squashImage config
159+
orginalConfig, _, err := imgutil.ReadImageConfig(ctx, image.ClientImage) // aware of img.platform
160+
if err != nil {
161+
return ocispec.Image{}, err
162+
}
163+
164+
var history []ocispec.History
165+
var count int
166+
for _, h := range orginalConfig.History {
167+
// if empty layer, add to history, be careful with the last layer that is empty
168+
if h.EmptyLayer {
169+
history = append(history, h)
170+
continue
171+
}
172+
// if not empty layer, add to history, check if count+1 <= remainingLayerCount to see if we need to add more
173+
if count+1 <= remainingLayerCount {
174+
history = append(history, h)
175+
count++
176+
} else {
177+
break
178+
}
179+
}
180+
cTime := time.Now()
181+
return ocispec.Image{
182+
Created: &cTime,
183+
Author: orginalConfig.Author,
184+
Platform: orginalConfig.Platform,
185+
Config: orginalConfig.Config,
186+
RootFS: ocispec.RootFS{
187+
Type: orginalConfig.RootFS.Type,
188+
DiffIDs: orginalConfig.RootFS.DiffIDs[:remainingLayerCount],
189+
},
190+
History: history,
191+
}, nil
192+
}
193+
194+
// writeContentsForImage will commit oci image config and manifest into containerd's content store.
195+
func (sr *squashRuntime) writeContentsForImage(ctx context.Context, snName string, newConfig ocispec.Image,
196+
baseImageLayers []ocispec.Descriptor, diffLayerDesc ocispec.Descriptor) (ocispec.Descriptor, digest.Digest, error) {
197+
newConfigJSON, err := json.Marshal(newConfig)
198+
if err != nil {
199+
return ocispec.Descriptor{}, emptyDigest, err
200+
}
201+
202+
configDesc := ocispec.Descriptor{
203+
MediaType: images.MediaTypeDockerSchema2Config,
204+
Digest: digest.FromBytes(newConfigJSON),
205+
Size: int64(len(newConfigJSON)),
206+
}
207+
208+
layers := append(baseImageLayers, diffLayerDesc)
209+
210+
newMfst := struct {
211+
MediaType string `json:"mediaType,omitempty"`
212+
ocispec.Manifest
213+
}{
214+
MediaType: images.MediaTypeDockerSchema2Manifest,
215+
Manifest: ocispec.Manifest{
216+
Versioned: specs.Versioned{
217+
SchemaVersion: 2,
218+
},
219+
Config: configDesc,
220+
Layers: layers,
221+
},
222+
}
223+
224+
newMfstJSON, err := json.MarshalIndent(newMfst, "", " ")
225+
if err != nil {
226+
return ocispec.Descriptor{}, emptyDigest, err
227+
}
228+
229+
newMfstDesc := ocispec.Descriptor{
230+
MediaType: images.MediaTypeDockerSchema2Manifest,
231+
Digest: digest.FromBytes(newMfstJSON),
232+
Size: int64(len(newMfstJSON)),
233+
}
234+
235+
// new manifest should reference the layers and config content
236+
labels := map[string]string{
237+
"containerd.io/gc.ref.content.0": configDesc.Digest.String(),
238+
}
239+
for i, l := range layers {
240+
labels[fmt.Sprintf("containerd.io/gc.ref.content.%d", i+1)] = l.Digest.String()
241+
}
242+
243+
err = content.WriteBlob(ctx, sr.contentStore, newMfstDesc.Digest.String(), bytes.NewReader(newMfstJSON), newMfstDesc, content.WithLabels(labels))
244+
if err != nil {
245+
return ocispec.Descriptor{}, emptyDigest, err
246+
}
247+
248+
// config should reference to snapshotter
249+
labelOpt := content.WithLabels(map[string]string{
250+
fmt.Sprintf("containerd.io/gc.ref.snapshot.%s", snName): identity.ChainID(newConfig.RootFS.DiffIDs).String(),
251+
})
252+
err = content.WriteBlob(ctx, sr.contentStore, configDesc.Digest.String(), bytes.NewReader(newConfigJSON), configDesc, labelOpt)
253+
if err != nil {
254+
return ocispec.Descriptor{}, emptyDigest, err
255+
}
256+
return newMfstDesc, configDesc.Digest, nil
257+
}
258+
259+
func (sr *squashRuntime) createSquashImage(ctx context.Context, img images.Image) (images.Image, error) {
260+
newImg, err := sr.imageStore.Update(ctx, img)
261+
log.G(ctx).Infof("updated new squashImage %s", img.Name)
262+
if err != nil {
263+
// if err is `not found` in the message then create the squashImage, otherwise return the error
264+
if !errdefs.IsNotFound(err) {
265+
return newImg, fmt.Errorf("failed to update new squashImage %s: %w", img.Name, err)
266+
}
267+
if _, err := sr.imageStore.Create(ctx, img); err != nil {
268+
return newImg, fmt.Errorf("failed to create new squashImage %s: %w", img.Name, err)
269+
}
270+
log.G(ctx).Infof("created new squashImage %s", img.Name)
271+
}
272+
return newImg, nil
273+
}
274+
275+
// generateCommitImageConfig returns commit oci image config based on the container's image.
276+
func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConfig ocispec.Image, diffID digest.Digest) (ocispec.Image, error) {
277+
createdTime := time.Now()
278+
arch := baseConfig.Architecture
279+
if arch == "" {
280+
arch = runtime.GOARCH
281+
log.G(ctx).Warnf("assuming arch=%q", arch)
282+
}
283+
os := baseConfig.OS
284+
if os == "" {
285+
os = runtime.GOOS
286+
log.G(ctx).Warnf("assuming os=%q", os)
287+
}
288+
author := strings.TrimSpace(sr.opt.Author)
289+
if author == "" {
290+
author = baseConfig.Author
291+
}
292+
comment := strings.TrimSpace(sr.opt.Message)
293+
294+
return ocispec.Image{
295+
Platform: ocispec.Platform{
296+
Architecture: arch,
297+
OS: os,
298+
},
299+
300+
Created: &createdTime,
301+
Author: author,
302+
Config: baseConfig.Config,
303+
RootFS: ocispec.RootFS{
304+
Type: "layers",
305+
DiffIDs: append(baseConfig.RootFS.DiffIDs, diffID),
306+
},
307+
History: append(baseConfig.History, ocispec.History{
308+
Created: &createdTime,
309+
CreatedBy: "",
310+
Author: author,
311+
Comment: comment,
312+
EmptyLayer: false,
313+
}),
314+
}, nil
315+
}
316+
317+
// Squash will squash the image with the given options.
318+
func Squash(ctx context.Context, client *containerd.Client, option types.ImageSquashOptions) error {
319+
sr := newSquashRuntime(client, option)
320+
ctx = namespaces.WithNamespace(ctx, sr.namespace)
321+
// init squashImage
322+
image, err := sr.initImage(ctx)
323+
if err != nil {
324+
return err
325+
}
326+
// generate squash layers
327+
sLayers, err := sr.generateSquashLayer(image)
328+
if err != nil {
329+
return err
330+
}
331+
remainingLayerCount := len(image.Manifest.Layers) - len(sLayers)
332+
// Don't gc me and clean the dirty data after 1 hour!
333+
ctx, done, err := sr.client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour))
334+
if err != nil {
335+
return fmt.Errorf("failed to create lease for squash: %w", err)
336+
}
337+
defer done(ctx)
338+
339+
// generate remaining base squashImage config
340+
baseImage, err := sr.generateBaseImageConfig(ctx, image, remainingLayerCount)
341+
if err != nil {
342+
return err
343+
}
344+
diffLayerDesc, diffID, _, err := sr.applyDiffLayer(ctx, baseImage, sr.snapshotter, sLayers)
345+
if err != nil {
346+
log.G(ctx).WithError(err).Error("failed to apply diff layer")
347+
return err
348+
}
349+
// generate commit image config
350+
imageConfig, err := sr.generateCommitImageConfig(ctx, baseImage, diffID)
351+
if err != nil {
352+
log.G(ctx).WithError(err).Error("failed to generate commit image config")
353+
return fmt.Errorf("failed to generate commit image config: %w", err)
354+
}
355+
commitManifestDesc, _, err := sr.writeContentsForImage(ctx, sr.opt.GOptions.Snapshotter, imageConfig, image.Manifest.Layers[:remainingLayerCount], diffLayerDesc)
356+
if err != nil {
357+
log.G(ctx).WithError(err).Error("failed to write contents for image")
358+
return err
359+
}
360+
nimg := images.Image{
361+
Name: sr.opt.TargetImageName,
362+
Target: commitManifestDesc,
363+
UpdatedAt: time.Now(),
364+
}
365+
_, err = sr.createSquashImage(ctx, nimg)
366+
if err != nil {
367+
log.G(ctx).WithError(err).Error("failed to create squash image")
368+
return err
369+
}
370+
cimg := containerd.NewImage(sr.client, nimg)
371+
if err := cimg.Unpack(ctx, sr.opt.GOptions.Snapshotter, containerd.WithSnapshotterPlatformCheck()); err != nil {
372+
log.G(ctx).WithError(err).Error("failed to unpack squash image")
373+
return err
374+
}
375+
return nil
376+
}
377+
378+
// applyDiffLayer will apply diff layer content created by createDiff into the snapshotter.
379+
func (sr *squashRuntime) applyDiffLayer(ctx context.Context, baseImg ocispec.Image, sn snapshots.Snapshotter, layers []ocispec.Descriptor) (
380+
diffLayerDesc ocispec.Descriptor, diffID digest.Digest, snapshotID string, retErr error) {
381+
var (
382+
key = uniquePart()
383+
parent = identity.ChainID(baseImg.RootFS.DiffIDs).String()
384+
)
385+
386+
m, err := sn.Prepare(ctx, key, parent)
387+
if err != nil {
388+
return diffLayerDesc, diffID, snapshotID, err
389+
}
390+
391+
defer func() {
392+
if retErr != nil {
393+
// NOTE: the snapshotter should be hold by lease. Even
394+
// if the cleanup fails, the containerd gc can delete it.
395+
if err := sn.Remove(ctx, key); err != nil {
396+
log.G(ctx).Warnf("failed to cleanup aborted apply %s: %s", key, err)
397+
}
398+
}
399+
}()
400+
401+
err = sr.applyLayersToSnapshot(ctx, m, layers)
402+
if err != nil {
403+
log.G(ctx).WithError(err).Errorf("failed to apply layers to snapshot %s", key)
404+
return diffLayerDesc, diffID, snapshotID, err
405+
}
406+
diffLayerDesc, diffID, err = sr.createDiff(ctx, key)
407+
if err != nil {
408+
return diffLayerDesc, diffID, snapshotID, fmt.Errorf("failed to export layer: %w", err)
409+
}
410+
411+
// commit snapshot
412+
snapshotID = identity.ChainID(append(baseImg.RootFS.DiffIDs, diffID)).String()
413+
414+
if err = sn.Commit(ctx, snapshotID, key); err != nil {
415+
if errdefs.IsAlreadyExists(err) {
416+
return diffLayerDesc, diffID, snapshotID, nil
417+
}
418+
return diffLayerDesc, diffID, snapshotID, err
419+
}
420+
return diffLayerDesc, diffID, snapshotID, nil
421+
}
422+
423+
func newSquashRuntime(client *containerd.Client, option types.ImageSquashOptions) *squashRuntime {
424+
return &squashRuntime{
425+
opt: option,
426+
client: client,
427+
namespace: option.GOptions.Namespace,
428+
differ: client.DiffService(),
429+
imageStore: client.ImageService(),
430+
contentStore: client.ContentStore(),
431+
snapshotter: client.SnapshotService(option.GOptions.Snapshotter),
432+
}
433+
}
434+
435+
// copied from github.com/containerd/containerd/rootfs/apply.go
436+
func uniquePart() string {
437+
t := time.Now()
438+
var b [3]byte
439+
// Ignore read failures, just decreases uniqueness
440+
rand.Read(b[:])
441+
return fmt.Sprintf("%d-%s", t.Nanosecond(), base64.URLEncoding.EncodeToString(b[:]))
442+
}

0 commit comments

Comments
 (0)
Please sign in to comment.