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)