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 }