github.com/tilt-dev/tilt@v0.36.0/internal/engine/dockerprune/docker_pruner.go (about)

     1  package dockerprune
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"sort"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/docker/go-units"
    11  
    12  	"github.com/docker/docker/api/types"
    13  	typescontainer "github.com/docker/docker/api/types/container"
    14  	"github.com/docker/docker/api/types/filters"
    15  	typesimage "github.com/docker/docker/api/types/image"
    16  
    17  	"github.com/tilt-dev/tilt/internal/container"
    18  
    19  	"github.com/tilt-dev/tilt/pkg/model"
    20  
    21  	"github.com/tilt-dev/tilt/internal/engine/buildcontrol"
    22  
    23  	"github.com/tilt-dev/tilt/internal/docker"
    24  	"github.com/tilt-dev/tilt/internal/sliceutils"
    25  	"github.com/tilt-dev/tilt/internal/store"
    26  	"github.com/tilt-dev/tilt/pkg/logger"
    27  )
    28  
    29  var gcEnabledSelector = fmt.Sprintf("%s=true", docker.GCEnabledLabel)
    30  
    31  type DockerPruner struct {
    32  	dCli docker.Client
    33  
    34  	disabledForTesting bool
    35  	disabledOnSetup    bool
    36  
    37  	lastPruneBuildCount int
    38  	lastPruneTime       time.Time
    39  }
    40  
    41  var _ store.Subscriber = &DockerPruner{}
    42  var _ store.SetUpper = &DockerPruner{}
    43  
    44  func NewDockerPruner(dCli docker.Client) *DockerPruner {
    45  	return &DockerPruner{dCli: dCli}
    46  }
    47  
    48  func (dp *DockerPruner) DisabledForTesting(disabled bool) {
    49  	dp.disabledForTesting = disabled
    50  }
    51  
    52  func (dp *DockerPruner) SetUp(ctx context.Context, _ store.RStore) error {
    53  	err := dp.dCli.CheckConnected()
    54  	if err != nil {
    55  		// If Docker is not responding at all, other parts of the system will log this.
    56  		dp.disabledOnSetup = true
    57  		return nil
    58  	}
    59  
    60  	if err := dp.sufficientVersionError(); err != nil {
    61  		logger.Get(ctx).Infof(
    62  			"[Docker Prune] Docker API version too low for Tilt to run Docker Prune:\n\t%v", err,
    63  		)
    64  		dp.disabledOnSetup = true
    65  		return nil
    66  	}
    67  	return nil
    68  }
    69  
    70  // OnChange determines if any Tilt-built Docker images should be pruned based on settings and invokes the pruning
    71  // process if necessary.
    72  //
    73  // Care should be taken when modifying this method to not introduce expensive operations unless necessary, as this
    74  // is invoked for EVERY store action change batch. Because of this, the store (un)locking is done somewhat manually,
    75  // so care must be taken to avoid locking issues.
    76  func (dp *DockerPruner) OnChange(ctx context.Context, st store.RStore, summary store.ChangeSummary) error {
    77  	if dp.disabledForTesting || dp.disabledOnSetup || summary.IsLogOnly() {
    78  		return nil
    79  	}
    80  
    81  	state := st.RLockState()
    82  	settings := state.DockerPruneSettings
    83  	// Exit early if possible if any of the following is true:
    84  	// 	* Pruning is disabled entirely
    85  	// 	* Engine is currently building something
    86  	// 	* There are NO `docker_build`s in the Tiltfile
    87  	// 	* Something is queued for building
    88  	if !settings.Enabled || len(state.CurrentBuildSet) > 0 || !state.HasBuild() || buildcontrol.NextManifestNameToBuild(state) != "" {
    89  		st.RUnlockState()
    90  		return nil
    91  	}
    92  
    93  	// Prune as soon after startup as we can (waiting until we've built SOMETHING)
    94  	curBuildCount := state.CompletedBuildCount
    95  	shouldPrune := dp.lastPruneTime.IsZero() && curBuildCount > 0
    96  	// "Prune every X builds" takes precedence over "prune every Y hours"
    97  	if settings.NumBuilds != 0 {
    98  		buildsSince := curBuildCount - dp.lastPruneBuildCount
    99  		if buildsSince >= settings.NumBuilds {
   100  			shouldPrune = true
   101  		}
   102  	} else {
   103  		interval := settings.Interval
   104  		if interval == 0 {
   105  			interval = model.DockerPruneDefaultInterval
   106  		}
   107  		if time.Since(dp.lastPruneTime) >= interval {
   108  			shouldPrune = true
   109  		}
   110  	}
   111  
   112  	if shouldPrune {
   113  		// N.B. Only determine the ref selectors if we're actually going to prune - OnChange is called for every batch
   114  		// 	of store events and this is a comparatively expensive operation (lots of regex), but 99% of the time this
   115  		// 	is called, no pruning is going to happen, so avoid burning CPU cycles unnecessarily
   116  		imgSelectors := model.LocalRefSelectorsForManifests(state.Manifests(), state.Clusters)
   117  		st.RUnlockState()
   118  		dp.PruneAndRecordState(ctx, settings.MaxAge, settings.KeepRecent, imgSelectors, curBuildCount)
   119  		return nil
   120  	}
   121  
   122  	st.RUnlockState()
   123  	return nil
   124  }
   125  
   126  func (dp *DockerPruner) PruneAndRecordState(ctx context.Context, maxAge time.Duration, keepRecent int, imgSelectors []container.RefSelector, curBuildCount int) {
   127  	dp.Prune(ctx, maxAge, keepRecent, imgSelectors)
   128  	dp.lastPruneTime = time.Now()
   129  	dp.lastPruneBuildCount = curBuildCount
   130  }
   131  
   132  func (dp *DockerPruner) Prune(ctx context.Context, maxAge time.Duration, keepRecent int, imgSelectors []container.RefSelector) {
   133  	// For future: dispatch event with output/errors to be recorded
   134  	//   in engineState.TiltSystemState on store (analogous to TiltfileState)
   135  	err := dp.prune(ctx, maxAge, keepRecent, imgSelectors)
   136  	if err != nil {
   137  		logger.Get(ctx).Infof("[Docker Prune] error running docker prune: %v", err)
   138  	}
   139  }
   140  
   141  func (dp *DockerPruner) prune(ctx context.Context, maxAge time.Duration, keepRecent int, imgSelectors []container.RefSelector) error {
   142  	l := logger.Get(ctx)
   143  	if err := dp.sufficientVersionError(); err != nil {
   144  		l.Debugf("[Docker Prune] skipping Docker prune, Docker API version too low:\t%v", err)
   145  		return nil
   146  	}
   147  
   148  	f := filters.NewArgs(
   149  		filters.Arg("label", gcEnabledSelector),
   150  		filters.Arg("until", maxAge.String()),
   151  	)
   152  
   153  	// PRUNE CONTAINERS
   154  	containerReport, err := dp.dCli.ContainersPrune(ctx, f)
   155  	if err != nil {
   156  		return err
   157  	}
   158  	prettyPrintContainersPruneReport(containerReport, l)
   159  
   160  	// PRUNE IMAGES
   161  	imageReport, err := dp.deleteOldImages(ctx, maxAge, keepRecent, imgSelectors)
   162  	if err != nil {
   163  		return err
   164  	}
   165  	prettyPrintImagesPruneReport(imageReport, l)
   166  
   167  	// PRUNE BUILD CACHE
   168  	opts := types.BuildCachePruneOptions{Filters: f}
   169  	cacheReport, err := dp.dCli.BuildCachePrune(ctx, opts)
   170  	if err != nil {
   171  		if !strings.Contains(err.Error(), `"build prune" requires API version`) {
   172  			return err
   173  		}
   174  		l.Debugf("[Docker Prune] skipping build cache prune, Docker API version too low:\t%s", err)
   175  	} else {
   176  		prettyPrintCachePruneReport(cacheReport, l)
   177  	}
   178  
   179  	return nil
   180  }
   181  
   182  func (dp *DockerPruner) inspectImages(ctx context.Context, imgs []typesimage.Summary) []types.ImageInspect {
   183  	result := []types.ImageInspect{}
   184  	for _, imgSummary := range imgs {
   185  		inspect, _, err := dp.dCli.ImageInspectWithRaw(ctx, imgSummary.ID)
   186  		if err != nil {
   187  			logger.Get(ctx).Debugf("[Docker Prune] error inspecting image '%s': %v", imgSummary.ID, err)
   188  			continue
   189  		}
   190  		result = append(result, inspect)
   191  	}
   192  	return result
   193  }
   194  
   195  // Return all image objects that exceed the max age threshold.
   196  func (dp *DockerPruner) filterImageInspectsByMaxAge(ctx context.Context, inspects []types.ImageInspect, maxAge time.Duration, selectors []container.RefSelector) []types.ImageInspect {
   197  	result := []types.ImageInspect{}
   198  	for _, inspect := range inspects {
   199  		namedRefs, err := container.ParseNamedMulti(inspect.RepoTags)
   200  		if err != nil {
   201  			logger.Get(ctx).Debugf("[Docker Prune] error parsing repo tags for '%s': %v", inspect.ID, err)
   202  			continue
   203  		}
   204  
   205  		// LastTagTime indicates the last time the image was built, which is more
   206  		// meaningful to us than when the image was created.
   207  		if time.Since(inspect.Metadata.LastTagTime) >= maxAge && container.AnyMatch(namedRefs, selectors) {
   208  			if len(inspect.RepoTags) > 1 {
   209  				logger.Get(ctx).Debugf("[Docker Prune] cannot prune image %s (tags: %s); `docker image remove --force` "+
   210  					"required to remove an image with multiple tags (Docker throws error: "+
   211  					"\"image is referenced in one or more repositories\")",
   212  					inspect.ID, strings.Join(inspect.RepoTags, ", "))
   213  				continue
   214  			}
   215  			result = append(result, inspect)
   216  		}
   217  	}
   218  	return result
   219  }
   220  
   221  // Return all image objects that aren't in the N
   222  // most recently used for each tag.
   223  func (dp *DockerPruner) filterOutMostRecentInspects(ctx context.Context, inspects []types.ImageInspect, keepRecent int, selectors []container.RefSelector) []types.ImageInspect {
   224  	// First, sort the images in order from most recent to least recent.
   225  	recentFirst := append([]types.ImageInspect{}, inspects...)
   226  	sort.SliceStable(recentFirst, func(i, j int) bool {
   227  		// LastTagTime indicates the last time the image was built, which is more
   228  		// meaningful to us than when the image was created.
   229  		return recentFirst[i].Metadata.LastTagTime.After(recentFirst[j].Metadata.LastTagTime)
   230  	})
   231  
   232  	// Next, aggregate the images by which selector they match.
   233  	imgsBySelector := make(map[container.RefSelector][]types.ImageInspect)
   234  	for _, inspect := range recentFirst {
   235  		namedRefs, err := container.ParseNamedMulti(inspect.RepoTags)
   236  		if err != nil {
   237  			logger.Get(ctx).Debugf("[Docker Prune] error parsing repo tags for '%s': %v", inspect.ID, err)
   238  			continue
   239  		}
   240  
   241  		for _, sel := range selectors {
   242  			if sel.MatchesAny(namedRefs) {
   243  				imgsBySelector[sel] = append(imgsBySelector[sel], inspect)
   244  				break
   245  			}
   246  		}
   247  	}
   248  
   249  	// Finally, keep the N most recent for each tag.
   250  	idsToKeep := make(map[string]bool)
   251  	for _, list := range imgsBySelector {
   252  		for i := 0; i < keepRecent && i < len(list); i++ {
   253  			idsToKeep[list[i].ID] = true
   254  		}
   255  	}
   256  
   257  	result := []types.ImageInspect{}
   258  	for _, inspect := range inspects {
   259  		if !idsToKeep[inspect.ID] {
   260  			result = append(result, inspect)
   261  		}
   262  	}
   263  	return result
   264  }
   265  
   266  func (dp *DockerPruner) deleteOldImages(ctx context.Context, maxAge time.Duration, keepRecent int, selectors []container.RefSelector) (typesimage.PruneReport, error) {
   267  	opts := typesimage.ListOptions{
   268  		Filters: filters.NewArgs(
   269  			filters.Arg("label", gcEnabledSelector),
   270  		),
   271  	}
   272  	imgs, err := dp.dCli.ImageList(ctx, opts)
   273  	if err != nil {
   274  		return typesimage.PruneReport{}, err
   275  	}
   276  
   277  	inspects := dp.inspectImages(ctx, imgs)
   278  	inspects = dp.filterImageInspectsByMaxAge(ctx, inspects, maxAge, selectors)
   279  	toDelete := dp.filterOutMostRecentInspects(ctx, inspects, keepRecent, selectors)
   280  
   281  	rmOpts := typesimage.RemoveOptions{PruneChildren: true}
   282  	var responseItems []typesimage.DeleteResponse
   283  	var reclaimedBytes uint64
   284  
   285  	for _, inspect := range toDelete {
   286  		items, err := dp.dCli.ImageRemove(ctx, inspect.ID, rmOpts)
   287  		if err != nil {
   288  			// No good way to detect in-use images from `inspect` output, so just ignore those errors
   289  			if !strings.Contains(err.Error(), "image is being used by running container") {
   290  				logger.Get(ctx).Debugf("[Docker Prune] error removing image '%s': %v", inspect.ID, err)
   291  			}
   292  			continue
   293  		}
   294  		responseItems = append(responseItems, items...)
   295  		reclaimedBytes += uint64(inspect.Size)
   296  	}
   297  
   298  	return typesimage.PruneReport{
   299  		ImagesDeleted:  responseItems,
   300  		SpaceReclaimed: reclaimedBytes,
   301  	}, nil
   302  }
   303  
   304  func (dp *DockerPruner) sufficientVersionError() error {
   305  	return dp.dCli.NewVersionError(context.Background(),
   306  		"1.30", "image | container prune with filter: label")
   307  }
   308  
   309  func prettyPrintImagesPruneReport(report typesimage.PruneReport, l logger.Logger) {
   310  	if len(report.ImagesDeleted) == 0 && !l.Level().ShouldDisplay(logger.VerboseLvl) {
   311  		return
   312  	}
   313  
   314  	l.Infof("[Docker Prune] removed %d images, reclaimed %s",
   315  		len(report.ImagesDeleted), humanSize(report.SpaceReclaimed))
   316  	if len(report.ImagesDeleted) > 0 {
   317  		for _, img := range report.ImagesDeleted {
   318  			l.Debugf("\t- %s", prettyStringImgDeleteItem(img))
   319  		}
   320  	}
   321  }
   322  
   323  func prettyStringImgDeleteItem(img typesimage.DeleteResponse) string {
   324  	if img.Deleted != "" {
   325  		return fmt.Sprintf("deleted: %s", img.Deleted)
   326  	}
   327  	if img.Untagged != "" {
   328  		return fmt.Sprintf("untagged: %s", img.Untagged)
   329  	}
   330  	return ""
   331  }
   332  
   333  func prettyPrintCachePruneReport(report *types.BuildCachePruneReport, l logger.Logger) {
   334  	if len(report.CachesDeleted) == 0 && !l.Level().ShouldDisplay(logger.VerboseLvl) {
   335  		return
   336  	}
   337  
   338  	l.Infof("[Docker Prune] removed %d caches, reclaimed %s",
   339  		len(report.CachesDeleted), humanSize(report.SpaceReclaimed))
   340  	if len(report.CachesDeleted) > 0 {
   341  		l.Debugf("%s", sliceutils.BulletedIndentedStringList(report.CachesDeleted))
   342  	}
   343  }
   344  
   345  func prettyPrintContainersPruneReport(report typescontainer.PruneReport, l logger.Logger) {
   346  	if len(report.ContainersDeleted) == 0 && !l.Level().ShouldDisplay(logger.VerboseLvl) {
   347  		return
   348  	}
   349  
   350  	l.Infof("[Docker Prune] removed %d containers, reclaimed %s",
   351  		len(report.ContainersDeleted), humanSize(report.SpaceReclaimed))
   352  	if len(report.ContainersDeleted) > 0 {
   353  		l.Debugf("%s", sliceutils.BulletedIndentedStringList(report.ContainersDeleted))
   354  	}
   355  }
   356  
   357  func humanSize(bytes uint64) string {
   358  	return units.HumanSize(float64(bytes))
   359  }