From 333407b7a48b6b9894c5383d313761d4dfc632a3 Mon Sep 17 00:00:00 2001 From: jim-junior Date: Sun, 2 Aug 2026 20:47:13 +0300 Subject: [PATCH] feat: add HTTP readiness benchmark using nerdctl and nginx Signed-off-by: jim-junior --- README.md | 25 +- docs/methodology.md | 74 ++-- experiment.yml | 14 + internal/cli/run.go | 11 +- internal/manifest/validate.go | 11 + internal/runtime/httpreadiness/adapter.go | 316 ++++++++++++++++++ .../runtime/httpreadiness/adapter_test.go | 94 ++++++ internal/runtime/lifecycle/adapter.go | 6 +- 8 files changed, 498 insertions(+), 53 deletions(-) create mode 100644 internal/runtime/httpreadiness/adapter.go create mode 100644 internal/runtime/httpreadiness/adapter_test.go diff --git a/README.md b/README.md index aee0205..f1823f0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ container runtimes. It currently supports experiments using: The harness runs lifecycle and storage benchmarks through containerd, records the host environment, and writes machine-readable results for the included -analysis notebooks. +analysis notebooks. It also includes a CLI-based HTTP readiness benchmark that +uses `nerdctl` and records the time from `nerdctl start` to nginx's first HTTP +200 response. ## Requirements @@ -148,8 +150,25 @@ experiments: snapshotter: devmapper ``` -Only `lifecycle` and `storage` currently have benchmark adapters, so use those -names as the experiment keys. +The available experiment adapters are `lifecycle`, `storage`, and +`http-readiness`. + +For HTTP readiness, specify the nginx image and an available host port. The +adapter pulls and creates the container before measurement, then starts a GET +request loop while invoking `nerdctl start`. Connection failures, timeouts, and +all non-200 responses are ignored. The `wait_ready` stage in `run.json` records +the first HTTP 200 time and latency: + +```yaml +experiments: + http-readiness: + workloads: + default: + image: docker.io/library/nginx:latest + ports: + containerPort: 80 + hostPort: 8080 +``` ### 1. Declare runtimes diff --git a/docs/methodology.md b/docs/methodology.md index 25f448e..f8a13d6 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -112,18 +112,12 @@ All elapsed times will use Go's monotonic clock or `clock_gettime(CLOCK_MONOTONI ### 5.1 Proposed result layout ```text -results/// -|-- manifest.yaml -|-- environment.json -|-- trials.jsonl -|-- raw/ -| |-- -workload.json -| |-- -collector.jsonl -| `-- -tool-output.json -`-- logs/ - |-- -stdout.log - |-- -stderr.log - `-- -runtime.log +results/ +└── run-20260721T120000Z/ + ├── environment.json + ├── manifest.yaml + ├── plan.json + └── run.json ``` The harness will preserve original tool output. Summaries will be generated from raw data and will never replace it. @@ -147,28 +141,28 @@ The final lifecycle adapter will use the containerd API instead of spawning this ### 6.1 Workload -The lifecycle experiment will use `nginx:alpine` as the primary workload. This image is small, widely available, starts a long-running server process by default, and provides a simple HTTP readiness target without requiring a custom benchmark program. +The lifecycle latency experiment will use `nginx:alpine` as the primary workload for runtimes `runc`, `kata` and `runsc`, then fir urunc it will use a container image built from the `minimalc` program that is located in [https://github.com/urunc-dev/evaluation_suite/blob/main/workloads/minimal-c/main.c](https://github.com/urunc-dev/evaluation_suite/blob/main/workloads/minimal-c/main.c). The image will be pulled, unpacked, and pinned by digest before timed trials begin. The container will use the image's default command so that the benchmark does not add shell startup overhead through `/bin/sh -c ...`. -HTTP readiness will be measured by probing the nginx HTTP endpoint after the task-start request is issued. If the default `nginx:alpine` image is used unchanged, the probe target will be `/`. +HTTP readiness will be measured by probing the nginx HTTP endpoint after the task-start request is issued. If the default `nginx:alpine` image is used unchanged, the probe target will be `/`. -For `urunc`, the nginx image will be packagesd to be compatible with `urunc` using [bunny](https://github.com/nubificus/bunny) as explained in this tutorial [https://urunc.io/tutorials/existing-container-linux/](https://urunc.io/tutorials/existing-container-linux/) +For `urunc`, the nginx image will be packaged to be compatible with `urunc` using [bunny](https://github.com/nubificus/bunny) as explained in this tutorial [https://urunc.io/tutorials/existing-container-linux/](https://urunc.io/tutorials/existing-container-linux/) ### 6.2 Measurement boundaries -containerd distinguishes a container metadata object from a live task. Creating metadata with `ctr containers create` or the containerd Go client's `NewContainer` is therefore not the OCI runtime create measurement. Container metadata creation, snapshot preparation, image pull, and image unpack will happen before the measured lifecycle interval. +containerd distinguishes a container metadata object from a live task. Creating metadata with `ctr containers create` or the containerd Go client's `NewContainer` is therefore not the OCI runtime create measurement. Container metadata creation, snapshot preparation, image pull, and image unpack will happen before the measured lifecycle interval. This will be done in the `prepare` stage of the harness lifecyle. The primary lifecycle measurements will be event-based. The harness will issue lifecycle operations through the containerd Go client, but the recorded OCI lifecycle boundaries will be based on the corresponding containerd task events. -| Metric | Start | End | -| ------------------------- | ----------------------------------------------------------------- | ----------------------------------------- | -| Task create event latency | Immediately before containerd `NewTask` / task-create request | Matching task-create event is observed | -| Task start event latency | Immediately before task-start request | Matching task-start event is observed | -| Task delete event latency | Immediately before task-delete request after the task has stopped | Matching task-delete event is observed | -| HTTP ready latency | Immediately before task-start request | First successful HTTP response from nginx | +| Metric | Start | End | +| ------------------------- | ------------------------------------------------------------------- | ----------------------------------------- | +| Task create event latency | Immediately before containerd `NewTask` / task-create request | Matching `task/create` event is observed | +| Task start event latency | Immediately before `task/start` request | Matching `task/start` event is observed | +| Task delete event latency | Immediately before `task/delete` request after the task has stopped | Matching `task/delete` event is observed | +| HTTP ready latency | Immediately before `task/start` request | First successful HTTP response from nginx | -Only these four metrics will be reported as the primary lifecycle results. The containerd Go client will be used so that CLI process startup is not included. Client RPC return times may still be logged as diagnostic data during development, but they will not be part of the primary reported lifecycle metrics. +Only these four metrics will be reported as the primary lifecycle results. The containerd Go client will be used so that CLI process startup is not included. However for gVisor, we shall use the CLI by invoking the `runsc` command and passing parameters required to start the container such as name, bundle etc. Tis is because gvisor doesnot abide by the Containerd Runtime V2 Spec. During harness development, event-based measurements may be validated against equivalent `ctr` operations. These checks will only be used for implementation validation because `ctr` includes CLI overhead and reports client-observed behavior rather than event-observed lifecycle latency. @@ -176,31 +170,19 @@ A runtime adapter layer will be used where necessary. For example, runc and Kata ### 6.3 Lifecycle experiment procedure -1. Verify that the `nginx:alpine` image digest is present locally. -2. Pre-create a unique container and snapshot outside the timed interval. +1. Verify that the required image digest is present locally. +2. Pre-create a unique container and snapshot outside the timed interval. For `runsc` this involves creating the container bundle from the image. 3. Start the host collector. 4. Subscribe to containerd task events for the trial container ID. -5. Start the task-create timer immediately before issuing the containerd task-create request. -6. Stop the task-create timer when the matching task-create event is observed. -7. Start the task-start and HTTP-ready timers immediately before issuing the task-start request. -8. Stop the task-start timer when the matching task-start event is observed. -9. Probe the nginx HTTP endpoint until the first valid response and record HTTP ready latency. -10. Hold the workload for five seconds to confirm that it remains healthy. -11. Signal termination and wait for the task to exit. -12. Start the task-delete timer immediately before issuing the task-delete request. -13. Stop the task-delete timer when the matching task-delete event is observed. -14. Delete container metadata and snapshot outside the OCI delete measurement. -15. Audit cleanup and retain all errors, including leftover tasks, containers, cgroups, mounts, network interfaces, ports, or runtime helper processes. - -The HTTP-readiness test includes application initialization and network availability, so it will be reported separately from OCI task start. - -### 6.4 Cold and warm lifecycle runs - -- **Runtime-cold:** no live sandbox, no pre-created VM/unikernel pool, and a freshly restarted runtime/containerd block. Images remain locally available. This does not claim that the host page cache is cold. -- **Warm:** image and runtime code paths have been exercised by three untimed trials, with no live sandbox carried into the measured trial. -- **Provisioning-cold:** image absent and pulled during the trial. This is optional and reported separately from runtime latency. - -Lifecycle tests will use 10 runtime-cold repetitions and 30 warm repetitions per runtime. Runtime order will be randomized by block. Serial and concurrent launch will be tested separately at concurrency `1`, `2`, `4`, and `8`. +5. Start the task-create timer immediately before issuing the containerd `/tasks/create` request. +6. Stop the task-create timer when the matching `/tasks/create` event is observed. +7. Stop the task-start timer when the matching `/tasks/start` event is observed. +8. Start the task-delete timer immediately before issuing the task-delete request. +9. Stop the task-delete timer when the matching `/tasks/delete` event is observed. +10. Delete container metadata and snapshot outside the OCI delete measurement. +11. Audit cleanup and retain all errors, including leftover tasks, containers, cgroups, mounts, network interfaces, ports, or runtime helper processes. + +The HTTP-readiness test includes application initialization and network availability, so it will be reported separately from OCI task start. It will involve a separate process, attempting to connect to the container port in very short intervals, and then record the timestamp once a valid connection is made If a runtime cannot reliably produce the required task events through the selected containerd handler, its lifecycle results will not be silently mixed into the main comparison. The failure mode will be reported separately, and the runtime may be measured through a runtime-specific adapter only if that adapter's boundaries are clearly documented. diff --git a/experiment.yml b/experiment.yml index 5e70703..5cadbf5 100644 --- a/experiment.yml +++ b/experiment.yml @@ -15,6 +15,20 @@ runtimes: experiments: + http-readiness: + workloads: + default: + image: docker.io/library/nginx:latest + ports: + containerPort: 80 + hostPort: 8082 + other: + - image: harbor.nbfc.io/nubificus/urunc/nginx-qemu-unikraft-initrd:latest + runtime: urunc + ports: + containerPort: 80 + hostPort: 8081 + lifecycle: workloads: default: diff --git a/internal/cli/run.go b/internal/cli/run.go index 7c03353..2ec9174 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -16,6 +16,7 @@ import ( "github.com/urunc-dev/evaluation_suite/internal/orchestrator" "github.com/urunc-dev/evaluation_suite/internal/plan" harnessruntime "github.com/urunc-dev/evaluation_suite/internal/runtime" + runtimeHTTPReadiness "github.com/urunc-dev/evaluation_suite/internal/runtime/httpreadiness" runtimeLifecycle "github.com/urunc-dev/evaluation_suite/internal/runtime/lifecycle" runtimeStorage "github.com/urunc-dev/evaluation_suite/internal/runtime/storage" ) @@ -77,7 +78,15 @@ func NewRunCommand() *cobra.Command { return runtimeStorage.NewAdapter(containerdClient, &containerdNamespace), nil } - orch := orchestrator.New(lifecycleAdapterFactory, storageAdapterFactory) + httpReadinessAdapterFactory := func(trial plan.Trial) (harnessruntime.Adapter, error) { + return runtimeHTTPReadiness.NewAdapter(), nil + } + + orch := orchestrator.New( + lifecycleAdapterFactory, + storageAdapterFactory, + httpReadinessAdapterFactory, + ) result, err := orch.Run(cmd.Context(), p, orchestrator.Options{ RunID: runID, diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 1477d28..f1f052d 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -74,6 +74,17 @@ func Validate(m *Manifest) error { for i, w := range exp.Workloads.Other { validateWorkload(fmt.Sprintf("experiments.%s.workloads.other[%d]", name, i), w) } + + if name == "http-readiness" { + if exp.Workloads.Default.Ports == nil { + errs = append(errs, "experiments.http-readiness.workloads.default.ports is required") + } + for i, w := range exp.Workloads.Other { + if w.Ports == nil { + errs = append(errs, fmt.Sprintf("experiments.http-readiness.workloads.other[%d].ports is required", i)) + } + } + } } if len(errs) > 0 { diff --git a/internal/runtime/httpreadiness/adapter.go b/internal/runtime/httpreadiness/adapter.go new file mode 100644 index 0000000..36544fc --- /dev/null +++ b/internal/runtime/httpreadiness/adapter.go @@ -0,0 +1,316 @@ +package httpreadiness + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "syscall" + "time" + + harnessruntime "github.com/urunc-dev/evaluation_suite/internal/runtime" +) + +const ( + retryInterval = time.Millisecond + requestTimeout = 100 * time.Millisecond +) + +type probeResult struct { + readyAt time.Time + attempts int +} + +// Adapter measures the time between invoking `nerdctl start` and the first +// successful HTTP response from the container. +type Adapter struct { + readyCh chan probeResult + probeCancel context.CancelFunc + startedAt time.Time + url string +} + +func NewAdapter() *Adapter { + return &Adapter{} +} + +func (a *Adapter) ExperimentName() string { + return "http-readiness" +} + +func (a *Adapter) Prepare(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + return runCommandStage( + ctx, + harnessruntime.StagePrepare, + "Pull HTTP readiness image", + tc, + "pull", tc.Trial.Image, + ) +} + +func (a *Adapter) CreateTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + if tc.Trial.Ports == nil { + return harnessruntime.StageResult{}, fmt.Errorf("http-readiness trial %q requires ports", tc.Trial.ID) + } + + portMapping := fmt.Sprintf( + "127.0.0.1:%d:%d", + tc.Trial.Ports.HostPort, + tc.Trial.Ports.ContainerPort, + ) + + return runCommandStage( + ctx, + harnessruntime.StageCreate, + "Create HTTP readiness container", + tc, + "create", + "-it", + "--name", tc.Trial.ID, + "--runtime", tc.Trial.RuntimeHandler, + "--publish", portMapping, + tc.Trial.Image, + ) +} + +func (a *Adapter) StartTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + if tc.Trial.Ports == nil { + return harnessruntime.StageResult{}, fmt.Errorf("http-readiness trial %q requires ports", tc.Trial.ID) + } + + a.url = fmt.Sprintf("http://127.0.0.1:%d/", tc.Trial.Ports.HostPort) + a.readyCh = make(chan probeResult, 1) + + probeCtx, cancel := context.WithCancel(ctx) + a.probeCancel = cancel + startProbe := make(chan time.Time, 1) + client := &http.Client{Timeout: requestTimeout} + go probeUntilOK(probeCtx, client, a.url, startProbe, a.readyCh) + + cmd := exec.CommandContext(ctx, "nerdctl", "start", "-a", tc.Trial.ID) + log.Printf("Running command: %s", cmd.String()) + a.startedAt = time.Now() + startProbe <- a.startedAt + + // temporary log file to capture the server's output + logFile, err := os.CreateTemp("", "http-readiness-*.log") + if err != nil { + return stageResult( + harnessruntime.StageStart, + "Start HTTP readiness container", + tc, + a.startedAt, + time.Now(), + map[string]interface{}{"url": a.url}, + ), + fmt.Errorf("failed to create log file for iperf3 server: %w", err) + } + defer logFile.Close() + + cmd.Stdin = os.Stdin + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Detach from the parent's terminal/session. + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + return stageResult( + harnessruntime.StageStart, + "Start HTTP readiness container", + tc, + a.startedAt, + time.Now(), + map[string]interface{}{"url": a.url}, + ), + fmt.Errorf("failed to start HTTP readiness container: %w", err) + } + + finishedAt := time.Now() + return stageResult( + harnessruntime.StageStart, + "Start HTTP readiness container", + tc, + a.startedAt, + finishedAt, + map[string]interface{}{"url": a.url}, + ), nil +} + +func (a *Adapter) WaitReady(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + if a.readyCh == nil || a.startedAt.IsZero() { + return harnessruntime.StageResult{}, fmt.Errorf("http readiness probe was not started") + } + + select { + case result := <-a.readyCh: + if a.probeCancel != nil { + a.probeCancel() + } + return stageResult( + harnessruntime.StageWaitReady, + "First HTTP 200 received", + tc, + a.startedAt, + result.readyAt, + map[string]interface{}{ + "url": a.url, + "status_code": http.StatusOK, + "attempts": result.attempts, + "ready_at": result.readyAt, + "readiness_latency": result.readyAt.Sub(a.startedAt), + "readiness_latency_ms": float64(result.readyAt.Sub(a.startedAt).Microseconds()) / 1000, + }, + ), nil + case <-ctx.Done(): + if a.probeCancel != nil { + a.probeCancel() + } + return harnessruntime.StageResult{}, ctx.Err() + } +} + +func (a *Adapter) Stop(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + if a.probeCancel != nil { + a.probeCancel() + } + return runCommandStage( + ctx, + harnessruntime.StageStop, + "Stop HTTP readiness container", + tc, + "stop", tc.Trial.ID, + ) +} + +func (a *Adapter) DeleteTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + return runCommandStage( + ctx, + harnessruntime.StageDelete, + "Delete HTTP readiness container", + tc, + "rm", "--force", tc.Trial.ID, + ) +} + +func (a *Adapter) Cleanup(_ context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + now := time.Now() + return stageResult( + harnessruntime.StageCleanup, + "HTTP readiness cleanup complete", + tc, + now, + now, + nil, + ), nil +} + +func probeUntilOK( + ctx context.Context, + client *http.Client, + url string, + start <-chan time.Time, + ready chan<- probeResult, +) { + startedAt, ok := <-start + if !ok { + return + } + + attempts := 0 + + for { + attempts++ + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err == nil { + response, requestErr := client.Do(request) + if requestErr == nil { + // client.Do returns after the response headers arrive, so take the + // timestamp before consuming the body. + readyAt := time.Now() + statusCode := response.StatusCode + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if statusCode == http.StatusOK { + if readyAt.Before(startedAt) { + readyAt = startedAt + } + select { + case ready <- probeResult{readyAt: readyAt, attempts: attempts}: + case <-ctx.Done(): + } + return + } + } + } + + timer := time.NewTimer(retryInterval) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +func runCommandStage( + ctx context.Context, + stage harnessruntime.Stage, + description string, + tc harnessruntime.TrialContext, + args ...string, +) (harnessruntime.StageResult, error) { + startedAt := time.Now() + cmd := exec.CommandContext(ctx, "nerdctl", args...) + + cmd.Stdin = os.Stdin + + log.Printf("Running command: %s", cmd.String()) + output, err := cmd.CombinedOutput() + finishedAt := time.Now() + if err != nil { + return harnessruntime.StageResult{}, fmt.Errorf( + "run %q: %w: %s", + cmd.String(), + err, + output, + ) + } + + return stageResult(stage, description, tc, startedAt, finishedAt, nil), nil +} + +func stageResult( + stage harnessruntime.Stage, + description string, + tc harnessruntime.TrialContext, + startedAt time.Time, + finishedAt time.Time, + extra map[string]interface{}, +) harnessruntime.StageResult { + latency := finishedAt.Sub(startedAt) + data := map[string]interface{}{ + "start": startedAt, + "end": finishedAt, + "latency": latency, + "latency_ms": float64(latency.Microseconds()) / 1000, + } + for key, value := range extra { + data[key] = value + } + + return harnessruntime.StageResult{ + Stage: stage, + StartedAt: startedAt, + FinishedAt: finishedAt, + Duration: latency, + Description: fmt.Sprintf("%s: trial=%s runtime=%s handler=%s image=%s", description, tc.Trial.ID, tc.Trial.RuntimeName, tc.Trial.RuntimeHandler, tc.Trial.Image), + Data: data, + } +} diff --git a/internal/runtime/httpreadiness/adapter_test.go b/internal/runtime/httpreadiness/adapter_test.go new file mode 100644 index 0000000..a5dd22d --- /dev/null +++ b/internal/runtime/httpreadiness/adapter_test.go @@ -0,0 +1,94 @@ +package httpreadiness + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestProbeUntilOKIgnoresNon200Responses(t *testing.T) { + var requests atomic.Int32 + client := &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + switch requests.Add(1) { + case 1: + return nil, errors.New("connection refused") + case 2: + return response(http.StatusInternalServerError), nil + case 3: + return response(http.StatusNotFound), nil + case 4: + return nil, errors.New("connection reset") + default: + return response(http.StatusOK), nil + } + })} + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + start := make(chan time.Time, 1) + ready := make(chan probeResult, 1) + startedAt := time.Now() + start <- startedAt + go probeUntilOK(ctx, client, "http://127.0.0.1:8080/", start, ready) + + select { + case result := <-ready: + if result.attempts != 5 { + t.Fatalf("attempts = %d, want 5", result.attempts) + } + if result.readyAt.Before(startedAt) { + t.Fatalf("ready time %s is before start time %s", result.readyAt, startedAt) + } + case <-ctx.Done(): + t.Fatal("probe did not report HTTP 200") + } +} + +func TestProbeUntilOKStopsWhenContextIsCancelled(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return response(http.StatusBadRequest), nil + })} + + ctx, cancel := context.WithCancel(context.Background()) + start := make(chan time.Time, 1) + ready := make(chan probeResult, 1) + done := make(chan struct{}) + start <- time.Now() + go func() { + probeUntilOK(ctx, client, "http://127.0.0.1:8080/", start, ready) + close(done) + }() + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("probe did not stop after context cancellation") + } + + select { + case result := <-ready: + t.Fatalf("unexpected readiness result: %+v", result) + default: + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func response(status int) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader("response")), + Header: make(http.Header), + } +} diff --git a/internal/runtime/lifecycle/adapter.go b/internal/runtime/lifecycle/adapter.go index d0d23c8..cd4ca7b 100644 --- a/internal/runtime/lifecycle/adapter.go +++ b/internal/runtime/lifecycle/adapter.go @@ -297,7 +297,7 @@ func (a *Adapter) Stop( status, err := a.Task.Status(*a.ContainerdNamespace) if err != nil { log.Fatalf( - "get task status: %w", + "get task status: %v", err, ) } @@ -308,7 +308,7 @@ func (a *Adapter) Stop( syscall.SIGKILL, ); err != nil { log.Fatalf( - "kill task: %w", + "kill task: %v", err, ) } @@ -325,7 +325,7 @@ func (a *Adapter) Stop( exitCode, exitTime, err := exitStatus.Result() if err != nil { log.Fatalf( - "read task exit status: %w", + "read task exit status: %v", err, ) }