github.com/hernad/nomad@v1.6.112/drivers/docker/coordinator.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package docker
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"regexp"
    10  	"sync"
    11  	"time"
    12  
    13  	docker "github.com/fsouza/go-dockerclient"
    14  	hclog "github.com/hashicorp/go-hclog"
    15  	"github.com/hernad/nomad/nomad/structs"
    16  )
    17  
    18  var (
    19  	// imageNotFoundMatcher is a regex expression that matches the image not
    20  	// found error Docker returns.
    21  	imageNotFoundMatcher = regexp.MustCompile(`Error: image .+ not found`)
    22  )
    23  
    24  // pullFuture is a sharable future for retrieving a pulled images ID and any
    25  // error that may have occurred during the pull.
    26  type pullFuture struct {
    27  	waitCh chan struct{}
    28  
    29  	err     error
    30  	imageID string
    31  }
    32  
    33  // newPullFuture returns a new pull future
    34  func newPullFuture() *pullFuture {
    35  	return &pullFuture{
    36  		waitCh: make(chan struct{}),
    37  	}
    38  }
    39  
    40  // wait waits till the future has a result
    41  func (p *pullFuture) wait() *pullFuture {
    42  	<-p.waitCh
    43  	return p
    44  }
    45  
    46  // result returns the results of the future and should only ever be called after
    47  // wait returns.
    48  func (p *pullFuture) result() (imageID string, err error) {
    49  	return p.imageID, p.err
    50  }
    51  
    52  // set is used to set the results and unblock any waiter. This may only be
    53  // called once.
    54  func (p *pullFuture) set(imageID string, err error) {
    55  	p.imageID = imageID
    56  	p.err = err
    57  	close(p.waitCh)
    58  }
    59  
    60  // DockerImageClient provides the methods required to do CRUD operations on the
    61  // Docker images
    62  type DockerImageClient interface {
    63  	PullImage(opts docker.PullImageOptions, auth docker.AuthConfiguration) error
    64  	InspectImage(id string) (*docker.Image, error)
    65  	RemoveImageExtended(id string, opts docker.RemoveImageOptions) error
    66  }
    67  
    68  // LogEventFn is a callback which allows Drivers to emit task events.
    69  type LogEventFn func(message string, annotations map[string]string)
    70  
    71  // noopLogEventFn satisfies the LogEventFn type but noops when called
    72  func noopLogEventFn(string, map[string]string) {}
    73  
    74  // dockerCoordinatorConfig is used to configure the Docker coordinator.
    75  type dockerCoordinatorConfig struct {
    76  	ctx context.Context
    77  
    78  	// logger is the logger the coordinator should use
    79  	logger hclog.Logger
    80  
    81  	// cleanup marks whether images should be deleted when the reference count
    82  	// is zero
    83  	cleanup bool
    84  
    85  	// client is the Docker client to use for communicating with Docker
    86  	client DockerImageClient
    87  
    88  	// removeDelay is the delay between an image's reference count going to
    89  	// zero and the image actually being deleted.
    90  	removeDelay time.Duration
    91  }
    92  
    93  // dockerCoordinator is used to coordinate actions against images to prevent
    94  // racy deletions. It can be thought of as a reference counter on images.
    95  type dockerCoordinator struct {
    96  	*dockerCoordinatorConfig
    97  
    98  	// imageLock is used to lock access to all images
    99  	imageLock sync.Mutex
   100  
   101  	// pullFutures is used to allow multiple callers to pull the same image but
   102  	// only have one request be sent to Docker
   103  	pullFutures map[string]*pullFuture
   104  
   105  	// pullLoggers is used to track the LogEventFn for each alloc pulling an image.
   106  	// If multiple alloc's are attempting to pull the same image, each will need
   107  	// to register its own LogEventFn with the coordinator.
   108  	pullLoggers map[string][]LogEventFn
   109  
   110  	// pullLoggerLock is used to sync access to the pullLoggers map
   111  	pullLoggerLock sync.RWMutex
   112  
   113  	// imageRefCount is the reference count of image IDs
   114  	imageRefCount map[string]map[string]struct{}
   115  
   116  	// deleteFuture is indexed by image ID and has a cancelable delete future
   117  	deleteFuture map[string]context.CancelFunc
   118  }
   119  
   120  // newDockerCoordinator returns a new Docker coordinator
   121  func newDockerCoordinator(config *dockerCoordinatorConfig) *dockerCoordinator {
   122  	if config.client == nil {
   123  		return nil
   124  	}
   125  
   126  	return &dockerCoordinator{
   127  		dockerCoordinatorConfig: config,
   128  		pullFutures:             make(map[string]*pullFuture),
   129  		pullLoggers:             make(map[string][]LogEventFn),
   130  		imageRefCount:           make(map[string]map[string]struct{}),
   131  		deleteFuture:            make(map[string]context.CancelFunc),
   132  	}
   133  }
   134  
   135  // PullImage is used to pull an image. It returns the pulled imaged ID or an
   136  // error that occurred during the pull
   137  func (d *dockerCoordinator) PullImage(image string, authOptions *docker.AuthConfiguration, callerID string,
   138  	emitFn LogEventFn, pullTimeout, pullActivityTimeout time.Duration) (imageID string, err error) {
   139  	// Get the future
   140  	d.imageLock.Lock()
   141  	future, ok := d.pullFutures[image]
   142  	d.registerPullLogger(image, emitFn)
   143  	if !ok {
   144  		// Make the future
   145  		future = newPullFuture()
   146  		d.pullFutures[image] = future
   147  		go d.pullImageImpl(image, authOptions, pullTimeout, pullActivityTimeout, future)
   148  	}
   149  	d.imageLock.Unlock()
   150  
   151  	// We unlock while we wait since this can take a while
   152  	id, err := future.wait().result()
   153  
   154  	d.imageLock.Lock()
   155  	defer d.imageLock.Unlock()
   156  
   157  	// Delete the future since we don't need it and we don't want to cache an
   158  	// image being there if it has possibly been manually deleted (outside of
   159  	// Nomad).
   160  	delete(d.pullFutures, image)
   161  
   162  	// If we are cleaning up, we increment the reference count on the image
   163  	if err == nil && d.cleanup {
   164  		d.incrementImageReferenceImpl(id, image, callerID)
   165  	}
   166  
   167  	return id, err
   168  }
   169  
   170  // pullImageImpl is the implementation of pulling an image. The results are
   171  // returned via the passed future
   172  func (d *dockerCoordinator) pullImageImpl(image string, authOptions *docker.AuthConfiguration,
   173  	pullTimeout, pullActivityTimeout time.Duration, future *pullFuture) {
   174  
   175  	defer d.clearPullLogger(image)
   176  	// Parse the repo and tag
   177  	repo, tag := parseDockerImage(image)
   178  	ctx, cancel := context.WithTimeout(context.Background(), pullTimeout)
   179  	defer cancel()
   180  
   181  	pm := newImageProgressManager(image, cancel, pullActivityTimeout, d.handlePullInactivity,
   182  		d.handlePullProgressReport, d.handleSlowPullProgressReport)
   183  	defer pm.stop()
   184  
   185  	pullOptions := docker.PullImageOptions{
   186  		Repository:    repo,
   187  		Tag:           tag,
   188  		OutputStream:  pm,
   189  		RawJSONStream: true,
   190  		Context:       ctx,
   191  	}
   192  
   193  	// Attempt to pull the image
   194  	var auth docker.AuthConfiguration
   195  	if authOptions != nil {
   196  		auth = *authOptions
   197  	}
   198  
   199  	err := d.client.PullImage(pullOptions, auth)
   200  
   201  	if ctxErr := ctx.Err(); ctxErr == context.DeadlineExceeded {
   202  		d.logger.Error("timeout pulling container", "image_ref", dockerImageRef(repo, tag))
   203  		future.set("", recoverablePullError(ctxErr, image))
   204  		return
   205  	}
   206  
   207  	if err != nil {
   208  		d.logger.Error("failed pulling container", "image_ref", dockerImageRef(repo, tag),
   209  			"error", err)
   210  		future.set("", recoverablePullError(err, image))
   211  		return
   212  	}
   213  
   214  	d.logger.Debug("docker pull succeeded", "image_ref", dockerImageRef(repo, tag))
   215  
   216  	dockerImage, err := d.client.InspectImage(image)
   217  	if err != nil {
   218  		d.logger.Error("failed getting image id", "image_name", image, "error", err)
   219  		future.set("", recoverableErrTimeouts(err))
   220  		return
   221  	}
   222  
   223  	future.set(dockerImage.ID, nil)
   224  }
   225  
   226  // IncrementImageReference is used to increment an image reference count
   227  func (d *dockerCoordinator) IncrementImageReference(imageID, imageName, callerID string) {
   228  	d.imageLock.Lock()
   229  	defer d.imageLock.Unlock()
   230  	if d.cleanup {
   231  		d.incrementImageReferenceImpl(imageID, imageName, callerID)
   232  	}
   233  }
   234  
   235  // incrementImageReferenceImpl assumes the lock is held
   236  func (d *dockerCoordinator) incrementImageReferenceImpl(imageID, imageName, callerID string) {
   237  	// Cancel any pending delete
   238  	if cancel, ok := d.deleteFuture[imageID]; ok {
   239  		d.logger.Debug("cancelling removal of container image", "image_name", imageName)
   240  		cancel()
   241  		delete(d.deleteFuture, imageID)
   242  	}
   243  
   244  	// Increment the reference
   245  	references, ok := d.imageRefCount[imageID]
   246  	if !ok {
   247  		references = make(map[string]struct{})
   248  		d.imageRefCount[imageID] = references
   249  	}
   250  
   251  	if _, ok := references[callerID]; !ok {
   252  		references[callerID] = struct{}{}
   253  		d.logger.Debug("image reference count incremented", "image_name", imageName, "image_id", imageID, "references", len(references))
   254  	}
   255  }
   256  
   257  // RemoveImage removes the given image. If there are any errors removing the
   258  // image, the remove is retried internally.
   259  func (d *dockerCoordinator) RemoveImage(imageID, callerID string) {
   260  	d.imageLock.Lock()
   261  	defer d.imageLock.Unlock()
   262  
   263  	if !d.cleanup {
   264  		return
   265  	}
   266  
   267  	references, ok := d.imageRefCount[imageID]
   268  	if !ok {
   269  		d.logger.Warn("RemoveImage on non-referenced counted image id", "image_id", imageID)
   270  		return
   271  	}
   272  
   273  	// Decrement the reference count
   274  	delete(references, callerID)
   275  	count := len(references)
   276  	d.logger.Debug("image id reference count decremented", "image_id", imageID, "references", count)
   277  
   278  	// Nothing to do
   279  	if count != 0 {
   280  		return
   281  	}
   282  
   283  	// This should never be the case but we safety guard so we don't leak a
   284  	// cancel.
   285  	if cancel, ok := d.deleteFuture[imageID]; ok {
   286  		d.logger.Error("image id has lingering delete future", "image_id", imageID)
   287  		cancel()
   288  	}
   289  
   290  	// Setup a future to delete the image
   291  	ctx, cancel := context.WithCancel(d.ctx)
   292  	d.deleteFuture[imageID] = cancel
   293  	go d.removeImageImpl(imageID, ctx)
   294  
   295  	// Delete the key from the reference count
   296  	delete(d.imageRefCount, imageID)
   297  }
   298  
   299  // removeImageImpl is used to remove an image. It wil wait the specified remove
   300  // delay to remove the image. If the context is cancelled before that the image
   301  // removal will be cancelled.
   302  func (d *dockerCoordinator) removeImageImpl(id string, ctx context.Context) {
   303  	// Wait for the delay or a cancellation event
   304  	select {
   305  	case <-ctx.Done():
   306  		// We have been cancelled
   307  		return
   308  	case <-time.After(d.removeDelay):
   309  	}
   310  
   311  	// Ensure we are suppose to delete. Do a short check while holding the lock
   312  	// so there can't be interleaving. There is still the smallest chance that
   313  	// the delete occurs after the image has been pulled but before it has been
   314  	// incremented. For handling that we just treat it as a recoverable error in
   315  	// the docker driver.
   316  	d.imageLock.Lock()
   317  	select {
   318  	case <-ctx.Done():
   319  		d.imageLock.Unlock()
   320  		return
   321  	default:
   322  	}
   323  	d.imageLock.Unlock()
   324  
   325  	for i := 0; i < 3; i++ {
   326  		err := d.client.RemoveImageExtended(id, docker.RemoveImageOptions{
   327  			Force: true, // necessary to GC images referenced by multiple tags
   328  		})
   329  		if err == nil {
   330  			break
   331  		}
   332  
   333  		if err == docker.ErrNoSuchImage {
   334  			d.logger.Debug("unable to cleanup image, does not exist", "image_id", id)
   335  			return
   336  		}
   337  		if derr, ok := err.(*docker.Error); ok && derr.Status == 409 {
   338  			d.logger.Debug("unable to cleanup image, still in use", "image_id", id)
   339  			return
   340  		}
   341  
   342  		// Retry on unknown errors
   343  		d.logger.Debug("failed to remove image", "image_id", id, "attempt", i+1, "error", err)
   344  
   345  		select {
   346  		case <-ctx.Done():
   347  			// We have been cancelled
   348  			return
   349  		case <-time.After(3 * time.Second):
   350  		}
   351  	}
   352  
   353  	d.logger.Debug("cleanup removed downloaded image", "image_id", id)
   354  
   355  	// Cleanup the future from the map and free the context by cancelling it
   356  	d.imageLock.Lock()
   357  	if cancel, ok := d.deleteFuture[id]; ok {
   358  		delete(d.deleteFuture, id)
   359  		cancel()
   360  	}
   361  	d.imageLock.Unlock()
   362  }
   363  
   364  func (d *dockerCoordinator) registerPullLogger(image string, logger LogEventFn) {
   365  	d.pullLoggerLock.Lock()
   366  	defer d.pullLoggerLock.Unlock()
   367  	if _, ok := d.pullLoggers[image]; !ok {
   368  		d.pullLoggers[image] = []LogEventFn{}
   369  	}
   370  	d.pullLoggers[image] = append(d.pullLoggers[image], logger)
   371  }
   372  
   373  func (d *dockerCoordinator) clearPullLogger(image string) {
   374  	d.pullLoggerLock.Lock()
   375  	defer d.pullLoggerLock.Unlock()
   376  	delete(d.pullLoggers, image)
   377  }
   378  
   379  func (d *dockerCoordinator) emitEvent(image, message string, annotations map[string]string) {
   380  	d.pullLoggerLock.RLock()
   381  	defer d.pullLoggerLock.RUnlock()
   382  	for i := range d.pullLoggers[image] {
   383  		go d.pullLoggers[image][i](message, annotations)
   384  	}
   385  }
   386  
   387  func (d *dockerCoordinator) handlePullInactivity(image, msg string, timestamp time.Time) {
   388  	d.logger.Error("image pull aborted due to inactivity", "image_name", image,
   389  		"last_event_timestamp", timestamp.String(), "last_event", msg)
   390  }
   391  
   392  func (d *dockerCoordinator) handlePullProgressReport(image, msg string, _ time.Time) {
   393  	d.logger.Debug("image pull progress", "image_name", image, "message", msg)
   394  }
   395  
   396  func (d *dockerCoordinator) handleSlowPullProgressReport(image, msg string, _ time.Time) {
   397  	d.emitEvent(image, fmt.Sprintf("Docker image pull progress: %s", msg), map[string]string{
   398  		"image": image,
   399  	})
   400  }
   401  
   402  // recoverablePullError wraps the error gotten when trying to pull and image if
   403  // the error is recoverable.
   404  func recoverablePullError(err error, image string) error {
   405  	recoverable := true
   406  	if imageNotFoundMatcher.MatchString(err.Error()) {
   407  		recoverable = false
   408  	}
   409  	return structs.NewRecoverableError(fmt.Errorf("Failed to pull `%s`: %s", image, err), recoverable)
   410  }