github.com/filecoin-project/bacalhau@v0.3.23-0.20230228154132-45c989550ace/pkg/executor/docker/executor.go (about)

     1  package docker
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  	"time"
    10  
    11  	dockertypes "github.com/docker/docker/api/types"
    12  	"github.com/docker/docker/api/types/container"
    13  	"github.com/docker/docker/api/types/mount"
    14  	"github.com/filecoin-project/bacalhau/pkg/compute/capacity"
    15  	"github.com/filecoin-project/bacalhau/pkg/config"
    16  	"github.com/filecoin-project/bacalhau/pkg/docker"
    17  	"github.com/filecoin-project/bacalhau/pkg/executor"
    18  	jobutils "github.com/filecoin-project/bacalhau/pkg/job"
    19  	"github.com/filecoin-project/bacalhau/pkg/model"
    20  	"github.com/filecoin-project/bacalhau/pkg/storage"
    21  	"github.com/filecoin-project/bacalhau/pkg/storage/util"
    22  	"github.com/filecoin-project/bacalhau/pkg/system"
    23  	"github.com/filecoin-project/bacalhau/pkg/telemetry"
    24  	"github.com/pkg/errors"
    25  	"github.com/rs/zerolog"
    26  	"github.com/rs/zerolog/log"
    27  	"go.uber.org/multierr"
    28  )
    29  
    30  const NanoCPUCoefficient = 1000000000
    31  
    32  const (
    33  	labelExecutorName = "bacalhau-executor"
    34  	labelJobName      = "bacalhau-jobID"
    35  )
    36  
    37  type Executor struct {
    38  	// used to allow multiple docker executors to run against the same docker server
    39  	ID string
    40  
    41  	// the storage providers we can implement for a job
    42  	StorageProvider storage.StorageProvider
    43  
    44  	client *docker.Client
    45  }
    46  
    47  func NewExecutor(
    48  	_ context.Context,
    49  	cm *system.CleanupManager,
    50  	id string,
    51  	storageProvider storage.StorageProvider,
    52  ) (*Executor, error) {
    53  	dockerClient, err := docker.NewDockerClient()
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	de := &Executor{
    59  		ID:              id,
    60  		StorageProvider: storageProvider,
    61  		client:          dockerClient,
    62  	}
    63  
    64  	cm.RegisterCallbackWithContext(de.cleanupAll)
    65  
    66  	return de, nil
    67  }
    68  
    69  func (e *Executor) getStorage(ctx context.Context, engine model.StorageSourceType) (storage.Storage, error) {
    70  	return e.StorageProvider.Get(ctx, engine)
    71  }
    72  
    73  // IsInstalled checks if docker itself is installed.
    74  func (e *Executor) IsInstalled(ctx context.Context) (bool, error) {
    75  	return e.client.IsInstalled(ctx), nil
    76  }
    77  
    78  func (e *Executor) HasStorageLocally(ctx context.Context, volume model.StorageSpec) (bool, error) {
    79  	//nolint:ineffassign,staticcheck
    80  	ctx, span := system.NewSpan(ctx, system.GetTracer(), "pkg/executor/docker.Executor.HasStorageLocally")
    81  	defer span.End()
    82  
    83  	s, err := e.getStorage(ctx, volume.StorageSource)
    84  	if err != nil {
    85  		return false, err
    86  	}
    87  
    88  	return s.HasStorageLocally(ctx, volume)
    89  }
    90  
    91  func (e *Executor) GetVolumeSize(ctx context.Context, volume model.StorageSpec) (uint64, error) {
    92  	storageProvider, err := e.getStorage(ctx, volume.StorageSource)
    93  	if err != nil {
    94  		return 0, err
    95  	}
    96  	return storageProvider.GetVolumeSize(ctx, volume)
    97  }
    98  
    99  //nolint:funlen,gocyclo // will clean up
   100  func (e *Executor) RunShard(
   101  	ctx context.Context,
   102  	shard model.JobShard,
   103  	jobResultsDir string,
   104  ) (*model.RunCommandResult, error) {
   105  	//nolint:ineffassign,staticcheck
   106  	ctx, span := system.NewSpan(ctx, system.GetTracer(), "pkg/executor/docker.Executor.RunShard")
   107  	defer span.End()
   108  	defer e.cleanupJob(ctx, shard)
   109  
   110  	shardStorageSpec, err := jobutils.GetShardStorageSpec(ctx, shard, e.StorageProvider)
   111  	if err != nil {
   112  		return executor.FailResult(err)
   113  	}
   114  
   115  	var inputStorageSpecs []model.StorageSpec
   116  	inputStorageSpecs = append(inputStorageSpecs, shard.Job.Spec.Contexts...)
   117  	inputStorageSpecs = append(inputStorageSpecs, shardStorageSpec...)
   118  
   119  	inputVolumes, err := storage.ParallelPrepareStorage(ctx, e.StorageProvider, inputStorageSpecs)
   120  	if err != nil {
   121  		return executor.FailResult(err)
   122  	}
   123  
   124  	// the actual mounts we will give to the container
   125  	// these are paths for both input and output data
   126  	var mounts []mount.Mount
   127  	for spec, volumeMount := range inputVolumes {
   128  		if volumeMount.Type == storage.StorageVolumeConnectorBind {
   129  			log.Ctx(ctx).Trace().Msgf("Input Volume: %+v %+v", spec, volumeMount)
   130  			mounts = append(mounts, mount.Mount{
   131  				Type: mount.TypeBind,
   132  				// this is an input volume so is read only
   133  				ReadOnly: true,
   134  				Source:   volumeMount.Source,
   135  				Target:   volumeMount.Target,
   136  			})
   137  		} else {
   138  			return executor.FailResult(fmt.Errorf("unknown storage volume type: %s", volumeMount.Type))
   139  		}
   140  	}
   141  
   142  	// for this phase of the outputs we ignore the engine because it's just about collecting the
   143  	// data from the job and keeping it locally
   144  	// the engine property of the output storage spec is how we will "publish" the output volume
   145  	// if and when the deal is settled
   146  	for _, output := range shard.Job.Spec.Outputs {
   147  		if output.Name == "" {
   148  			err = fmt.Errorf("output volume has no name: %+v", output)
   149  			return executor.FailResult(err)
   150  		}
   151  
   152  		if output.Path == "" {
   153  			err = fmt.Errorf("output volume has no path: %+v", output)
   154  			return executor.FailResult(err)
   155  		}
   156  
   157  		srcd := filepath.Join(jobResultsDir, output.Name)
   158  		err = os.Mkdir(srcd, util.OS_ALL_R|util.OS_ALL_X|util.OS_USER_W)
   159  		if err != nil {
   160  			return executor.FailResult(err)
   161  		}
   162  
   163  		log.Ctx(ctx).Trace().Msgf("Output Volume: %+v", output)
   164  
   165  		// create a mount so the output data does not need to be copied back to the host
   166  		mounts = append(mounts, mount.Mount{
   167  
   168  			Type: mount.TypeBind,
   169  			// this is an output volume so can be written to
   170  			ReadOnly: false,
   171  
   172  			// we create a named folder in the job results folder for this output
   173  			Source: srcd,
   174  
   175  			// the path of the output volume is from the perspective of inside the container
   176  			Target: output.Path,
   177  		})
   178  	}
   179  
   180  	if os.Getenv("SKIP_IMAGE_PULL") == "" {
   181  		if err := e.client.PullImage(ctx, shard.Job.Spec.Docker.Image); err != nil { //nolint:govet // ignore err shadowing
   182  			err = errors.Wrapf(err, `Could not pull image %q - could be due to repo/image not existing,
   183   or registry needing authorization`, shard.Job.Spec.Docker.Image)
   184  			return executor.FailResult(err)
   185  		}
   186  	}
   187  
   188  	// json the job spec and pass it into all containers
   189  	// TODO: check if this will overwrite a user supplied version of this value
   190  	// (which is what we actually want to happen)
   191  	log.Ctx(ctx).Debug().Msgf("Job Spec: %+v", shard.Job.Spec)
   192  	jsonJobSpec, err := model.JSONMarshalWithMax(shard.Job.Spec)
   193  	if err != nil {
   194  		return executor.FailResult(err)
   195  	}
   196  	log.Ctx(ctx).Debug().Msgf("Job Spec JSON: %s", jsonJobSpec)
   197  
   198  	useEnv := append(shard.Job.Spec.Docker.EnvironmentVariables,
   199  		fmt.Sprintf("BACALHAU_JOB_SPEC=%s", string(jsonJobSpec)),
   200  	)
   201  
   202  	containerConfig := &container.Config{
   203  		Image:      shard.Job.Spec.Docker.Image,
   204  		Tty:        false,
   205  		Env:        useEnv,
   206  		Entrypoint: shard.Job.Spec.Docker.Entrypoint,
   207  		Labels:     e.jobContainerLabels(shard),
   208  		WorkingDir: shard.Job.Spec.Docker.WorkingDirectory,
   209  	}
   210  
   211  	log.Ctx(ctx).Trace().Msgf("Container: %+v %+v", containerConfig, mounts)
   212  
   213  	resourceRequirements := capacity.ParseResourceUsageConfig(shard.Job.Spec.Resources)
   214  
   215  	// Create GPU request if the job requests it
   216  	var deviceRequests []container.DeviceRequest
   217  	if resourceRequirements.GPU > 0 {
   218  		deviceRequests = append(deviceRequests,
   219  			container.DeviceRequest{
   220  				DeviceIDs:    []string{"0"}, // TODO: how do we know which device ID to use?
   221  				Capabilities: [][]string{{"gpu"}},
   222  			},
   223  		)
   224  		log.Ctx(ctx).Trace().Msgf("Adding %d GPUs to request", resourceRequirements.GPU)
   225  	}
   226  
   227  	hostConfig := &container.HostConfig{
   228  		Mounts: mounts,
   229  		Resources: container.Resources{
   230  			Memory:         int64(resourceRequirements.Memory),
   231  			NanoCPUs:       int64(resourceRequirements.CPU * NanoCPUCoefficient),
   232  			DeviceRequests: deviceRequests,
   233  		},
   234  	}
   235  
   236  	// Create a network if the job requests it
   237  	err = e.setupNetworkForJob(ctx, shard, containerConfig, hostConfig)
   238  	if err != nil {
   239  		return executor.FailResult(err)
   240  	}
   241  
   242  	jobContainer, err := e.client.ContainerCreate(
   243  		ctx,
   244  		containerConfig,
   245  		hostConfig,
   246  		nil,
   247  		nil,
   248  		e.jobContainerName(shard),
   249  	)
   250  	if err != nil {
   251  		return executor.FailResult(errors.Wrap(err, "failed to create container"))
   252  	}
   253  
   254  	ctx = log.Ctx(ctx).With().Str("Container", jobContainer.ID).Logger().WithContext(ctx)
   255  
   256  	containerStartError := e.client.ContainerStart(
   257  		ctx,
   258  		jobContainer.ID,
   259  		dockertypes.ContainerStartOptions{},
   260  	)
   261  	if containerStartError != nil {
   262  		// Special error to alert people about bad executable
   263  		internalContainerStartErrorMsg := "failed to start container"
   264  		if strings.Contains(containerStartError.Error(), "executable file not found") {
   265  			internalContainerStartErrorMsg = "Executable file not found"
   266  		}
   267  		internalContainerStartError := errors.Wrap(containerStartError, internalContainerStartErrorMsg)
   268  		return executor.FailResult(internalContainerStartError)
   269  	}
   270  
   271  	// the idea here is even if the container errors
   272  	// we want to capture stdout, stderr and feed it back to the user
   273  	var containerError error
   274  	var containerExitStatusCode int64
   275  	statusCh, errCh := e.client.ContainerWait(
   276  		ctx,
   277  		jobContainer.ID,
   278  		container.WaitConditionNotRunning,
   279  	)
   280  	select {
   281  	case err = <-errCh:
   282  		containerError = err
   283  	case exitStatus := <-statusCh:
   284  		containerExitStatusCode = exitStatus.StatusCode
   285  		if exitStatus.Error != nil {
   286  			containerError = errors.New(exitStatus.Error.Message)
   287  		}
   288  	}
   289  
   290  	// Can't use the original context as it may have already been timed out
   291  	detachedContext, cancel := context.WithTimeout(telemetry.NewDetachedContext(ctx), 3*time.Second)
   292  	defer cancel()
   293  	stdoutPipe, stderrPipe, logsErr := e.client.FollowLogs(detachedContext, jobContainer.ID)
   294  	log.Ctx(detachedContext).Debug().Err(logsErr).Msg("Captured stdout/stderr for container")
   295  
   296  	return executor.WriteJobResults(
   297  		jobResultsDir,
   298  		stdoutPipe,
   299  		stderrPipe,
   300  		int(containerExitStatusCode),
   301  		multierr.Combine(containerError, logsErr),
   302  	)
   303  }
   304  
   305  func (e *Executor) cleanupJob(ctx context.Context, shard model.JobShard) {
   306  	// Use a detached context in case the current one has already been canceled
   307  	separateCtx, cancel := context.WithTimeout(telemetry.NewDetachedContext(ctx), 1*time.Minute)
   308  	defer cancel()
   309  	if config.ShouldKeepStack() || !e.client.IsInstalled(separateCtx) {
   310  		return
   311  	}
   312  
   313  	err := e.client.RemoveObjectsWithLabel(separateCtx, labelJobName, e.labelJobValue(shard))
   314  	logLevel := map[bool]zerolog.Level{true: zerolog.DebugLevel, false: zerolog.ErrorLevel}[err == nil]
   315  	log.Ctx(ctx).WithLevel(logLevel).Err(err).Msg("Cleaned up job Docker resources")
   316  }
   317  
   318  func (e *Executor) cleanupAll(ctx context.Context) error {
   319  	// We have to use a detached context, rather than the one passed in to `NewExecutor`, as it may have already been
   320  	// canceled and so would prevent us from performing any cleanup work.
   321  	safeCtx := telemetry.NewDetachedContext(ctx)
   322  	if config.ShouldKeepStack() || !e.client.IsInstalled(safeCtx) {
   323  		return nil
   324  	}
   325  
   326  	err := e.client.RemoveObjectsWithLabel(safeCtx, labelExecutorName, e.ID)
   327  	logLevel := map[bool]zerolog.Level{true: zerolog.DebugLevel, false: zerolog.ErrorLevel}[err == nil]
   328  	log.Ctx(ctx).WithLevel(logLevel).Err(err).Msg("Cleaned up all Docker resources")
   329  
   330  	return nil
   331  }
   332  
   333  func (e *Executor) dockerObjectName(shard model.JobShard, parts ...string) string {
   334  	strs := []string{"bacalhau", e.ID, shard.Job.Metadata.ID, fmt.Sprint(shard.Index)}
   335  	strs = append(strs, parts...)
   336  	return strings.Join(strs, "-")
   337  }
   338  
   339  func (e *Executor) jobContainerName(shard model.JobShard) string {
   340  	return e.dockerObjectName(shard, "executor")
   341  }
   342  
   343  func (e *Executor) jobContainerLabels(shard model.JobShard) map[string]string {
   344  	return map[string]string{
   345  		labelExecutorName: e.ID,
   346  		labelJobName:      e.labelJobValue(shard),
   347  	}
   348  }
   349  
   350  func (e *Executor) labelJobValue(shard model.JobShard) string {
   351  	return e.ID + shard.ID()
   352  }
   353  
   354  // Compile-time interface check:
   355  var _ executor.Executor = (*Executor)(nil)