fix(guest): wait for the Docker API before starting the executor
Init waits for GET /_ping on the Docker socket (at most 60 s) before starting the executor, reaping every child meanwhile. A shutdown during the wait stops dockerd (TERM, KILL after 5 s, reaped) and the job is reported cancelled; a dockerd that never answers or exits only logs a warning.
- commit
- 819edf3a50ab9812b7a1c9a087683cceb893a494
- author
- brice · · 2026-10-14 09:59 UTC
- committer
- bnema · · 2026-10-14 09:59 UTC
- parent
- 298aaf7
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
2 files · +508 -24
M internal/guest/guest_test.go +266 -0
| @@ -4,8 +4,10 @@ package guest | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "net" | |
| 10 | + | "net/http" | |
| 9 | 11 | "os" | |
| 10 | 12 | "path/filepath" | |
| 11 | 13 | "reflect" | |
| @@ -17,6 +19,8 @@ import ( | |||
| 17 | 19 | "testing" | |
| 18 | 20 | "time" | |
| 19 | 21 | ||
| 22 | + | "github.com/bnema/zerowrap" | |
| 23 | + | ||
| 20 | 24 | "bnema.dev/ermite-runner/internal/execstream" | |
| 21 | 25 | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 22 | 26 | ) | |
| @@ -409,3 +413,265 @@ func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) { | |||
| 409 | 413 | t.Fatal("the executor never received SIGTERM") | |
| 410 | 414 | } | |
| 411 | 415 | } | |
| 416 | + | ||
| 417 | + | // fakeDocker serves the Docker API on a temp unix socket from the moment it is | |
| 418 | + | // called; ping decides the /_ping answer. | |
| 419 | + | func fakeDocker(t *testing.T, ping http.HandlerFunc) string { | |
| 420 | + | t.Helper() | |
| 421 | + | sock := filepath.Join(t.TempDir(), "docker.sock") | |
| 422 | + | l, err := net.Listen("unix", sock) | |
| 423 | + | if err != nil { | |
| 424 | + | t.Fatal(err) | |
| 425 | + | } | |
| 426 | + | mux := http.NewServeMux() | |
| 427 | + | mux.HandleFunc("/_ping", ping) | |
| 428 | + | srv := &http.Server{Handler: mux} | |
| 429 | + | go func() { _ = srv.Serve(l) }() | |
| 430 | + | t.Cleanup(func() { _ = srv.Close() }) | |
| 431 | + | return sock | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func sleeper(t *testing.T) int { | |
| 435 | + | t.Helper() | |
| 436 | + | pid, err := spawn([]string{"/bin/sh", "-c", "sleep 60"}, nil) | |
| 437 | + | if err != nil { | |
| 438 | + | t.Fatal(err) | |
| 439 | + | } | |
| 440 | + | t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL); _, _ = wait4NoHang(pid) }) | |
| 441 | + | return pid | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | var testLog = zerowrap.FromCtx(context.Background()) | |
| 445 | + | ||
| 446 | + | func TestWaitDockerReadyWaitsForPing(t *testing.T) { | |
| 447 | + | var calls atomic.Int32 | |
| 448 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { | |
| 449 | + | if calls.Add(1) < 4 { // dockerd is up but still starting | |
| 450 | + | http.Error(w, "starting", http.StatusServiceUnavailable) | |
| 451 | + | return | |
| 452 | + | } | |
| 453 | + | _, _ = w.Write([]byte("OK")) | |
| 454 | + | }) | |
| 455 | + | start := time.Now() | |
| 456 | + | if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil { | |
| 457 | + | t.Fatal(err) | |
| 458 | + | } | |
| 459 | + | if calls.Load() < 4 || time.Since(start) < 200*time.Millisecond { | |
| 460 | + | t.Fatalf("returned before the API answered OK (calls=%d)", calls.Load()) | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestWaitDockerReadyLateSocket(t *testing.T) { | |
| 465 | + | sock := filepath.Join(t.TempDir(), "docker.sock") | |
| 466 | + | go func() { | |
| 467 | + | time.Sleep(400 * time.Millisecond) | |
| 468 | + | l, err := net.Listen("unix", sock) | |
| 469 | + | if err != nil { | |
| 470 | + | return | |
| 471 | + | } | |
| 472 | + | srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("OK")) })} | |
| 473 | + | go func() { _ = srv.Serve(l) }() | |
| 474 | + | time.AfterFunc(5*time.Second, func() { _ = srv.Close() }) | |
| 475 | + | }() | |
| 476 | + | start := time.Now() | |
| 477 | + | if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil { | |
| 478 | + | t.Fatal(err) | |
| 479 | + | } | |
| 480 | + | if time.Since(start) < 400*time.Millisecond { | |
| 481 | + | t.Fatal("returned before the socket existed") | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func TestWaitDockerReadyTimesOut(t *testing.T) { | |
| 486 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusServiceUnavailable) }) | |
| 487 | + | start := time.Now() | |
| 488 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil) | |
| 489 | + | if err == nil || time.Since(start) > 5*time.Second { | |
| 490 | + | t.Fatalf("want a timeout error, got %v after %s", err, time.Since(start)) | |
| 491 | + | } | |
| 492 | + | var exited *dockerdExitError | |
| 493 | + | var abort *abortBootError | |
| 494 | + | if errors.As(err, &exited) || errors.As(err, &abort) { | |
| 495 | + | t.Fatalf("a timeout is neither a dockerd exit nor an abort: %v", err) | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | // A daemon that accepts the connection and never answers must not delay the | |
| 500 | + | // deadline, nor a signal. | |
| 501 | + | func TestWaitDockerReadyStalledServer(t *testing.T) { | |
| 502 | + | release := make(chan struct{}) | |
| 503 | + | t.Cleanup(func() { close(release) }) | |
| 504 | + | sock := fakeDocker(t, func(http.ResponseWriter, *http.Request) { <-release }) | |
| 505 | + | ||
| 506 | + | start := time.Now() | |
| 507 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil) | |
| 508 | + | var abort *abortBootError | |
| 509 | + | if err == nil || errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond { | |
| 510 | + | t.Fatalf("want the deadline error within ~0.5 s, got %v after %s", err, time.Since(start)) | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | sigs := make(chan os.Signal, 1) | |
| 514 | + | time.AfterFunc(200*time.Millisecond, func() { sigs <- syscall.SIGTERM }) | |
| 515 | + | start = time.Now() | |
| 516 | + | err = waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs) | |
| 517 | + | if !errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond { | |
| 518 | + | t.Fatalf("want an abort right after the signal, got %v after %s", err, time.Since(start)) | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func TestWaitDockerReadyDockerdExits(t *testing.T) { | |
| 523 | + | pid, err := spawn([]string{"/bin/sh", "-c", "exit 3"}, nil) | |
| 524 | + | if err != nil { | |
| 525 | + | t.Fatal(err) | |
| 526 | + | } | |
| 527 | + | start := time.Now() | |
| 528 | + | err = waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), pid, 30*time.Second, nil) | |
| 529 | + | var exited *dockerdExitError | |
| 530 | + | if !errors.As(err, &exited) || exited.status.ExitStatus() != 3 { | |
| 531 | + | t.Fatalf("want dockerd exit 3, got %v", err) | |
| 532 | + | } | |
| 533 | + | if time.Since(start) > 5*time.Second { | |
| 534 | + | t.Fatal("did not notice the exit promptly") | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | // Orphans that init adopts while it waits must be reaped at once, not left as | |
| 539 | + | // zombies until the wait ends. | |
| 540 | + | func TestWaitDockerReadyReapsAdoptedOrphans(t *testing.T) { | |
| 541 | + | // A child that exits immediately stands for an adopted orphan. | |
| 542 | + | orphan, err := spawn([]string{"/bin/sh", "-c", "exit 0"}, nil) | |
| 543 | + | if err != nil { | |
| 544 | + | t.Fatal(err) | |
| 545 | + | } | |
| 546 | + | zombie := func() bool { | |
| 547 | + | b, err := os.ReadFile("/proc/" + strconv.Itoa(orphan) + "/stat") | |
| 548 | + | return err == nil && strings.Contains(string(b), ") Z ") | |
| 549 | + | } | |
| 550 | + | deadline := time.Now().Add(5 * time.Second) | |
| 551 | + | for !zombie() && time.Now().Before(deadline) { | |
| 552 | + | time.Sleep(10 * time.Millisecond) | |
| 553 | + | } | |
| 554 | + | if !zombie() { | |
| 555 | + | t.Fatal("setup: the orphan did not become a zombie") | |
| 556 | + | } | |
| 557 | + | sigs := make(chan os.Signal, 1) | |
| 558 | + | time.AfterFunc(600*time.Millisecond, func() { sigs <- syscall.SIGTERM }) | |
| 559 | + | done := make(chan error, 1) | |
| 560 | + | go func() { | |
| 561 | + | done <- waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), sleeper(t), 30*time.Second, sigs) | |
| 562 | + | }() | |
| 563 | + | // The zombie must vanish while the wait is still running. | |
| 564 | + | deadline = time.Now().Add(400 * time.Millisecond) | |
| 565 | + | for zombie() && time.Now().Before(deadline) { | |
| 566 | + | time.Sleep(10 * time.Millisecond) | |
| 567 | + | } | |
| 568 | + | if zombie() { | |
| 569 | + | t.Fatal("the adopted orphan stayed a zombie during the wait") | |
| 570 | + | } | |
| 571 | + | <-done | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // A shutdown signal during the wait aborts the boot (exit 128+signal) instead | |
| 575 | + | // of starting the executor; so does a cancelled context. | |
| 576 | + | func TestWaitDockerReadyAbortsOnSignalAndContext(t *testing.T) { | |
| 577 | + | sock := filepath.Join(t.TempDir(), "none.sock") | |
| 578 | + | sigs := make(chan os.Signal, 1) | |
| 579 | + | sigs <- syscall.SIGINT | |
| 580 | + | var abort *abortBootError | |
| 581 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs) | |
| 582 | + | if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGINT) { | |
| 583 | + | t.Fatalf("err=%v", err) | |
| 584 | + | } | |
| 585 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 586 | + | cancel() | |
| 587 | + | err = waitDockerReady(ctx, testLog, sock, sleeper(t), 30*time.Second, nil) | |
| 588 | + | if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGTERM) { | |
| 589 | + | t.Fatalf("err=%v", err) | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | func TestStopDockerdReapsAndZeroIsNoop(t *testing.T) { | |
| 594 | + | stopDockerd(0) | |
| 595 | + | pid := sleeper(t) | |
| 596 | + | stopDockerd(pid) | |
| 597 | + | if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil { | |
| 598 | + | t.Fatal("dockerd is still there") | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func TestDockerPingRejectsNonOK(t *testing.T) { | |
| 603 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("nope")) }) | |
| 604 | + | client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { | |
| 605 | + | var d net.Dialer | |
| 606 | + | return d.DialContext(ctx, "unix", sock) | |
| 607 | + | }}} | |
| 608 | + | if dockerPing(context.Background(), client) { | |
| 609 | + | t.Fatal("a 200 without OK must not count as ready") | |
| 610 | + | } | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func initWithFakeDockerd(t *testing.T, sigs <-chan os.Signal, timeout time.Duration, dockerd string) (code int, marker string) { | |
| 614 | + | t.Helper() | |
| 615 | + | marker = filepath.Join(t.TempDir(), "executor-ran") | |
| 616 | + | code = Init(context.Background(), InitConfig{ | |
| 617 | + | Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent", | |
| 618 | + | Getpid: func() int { return 4242 }, | |
| 619 | + | Signals: sigs, | |
| 620 | + | DockerdArgs: []string{"/bin/sh", "-c", dockerd}, | |
| 621 | + | DockerSocket: filepath.Join(t.TempDir(), "none.sock"), | |
| 622 | + | DockerReadyTimeout: timeout, | |
| 623 | + | ExecArgs: []string{"/bin/sh", "-c", "touch " + marker + "; exit 7"}, | |
| 624 | + | }) | |
| 625 | + | return code, marker | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | // A shutdown signal while waiting for dockerd: no executor, dockerd stopped, | |
| 629 | + | // exit 128+signal. | |
| 630 | + | func TestInitSignalDuringDockerWaitDoesNotStartTheExecutor(t *testing.T) { | |
| 631 | + | sigs := make(chan os.Signal, 1) | |
| 632 | + | sigs <- syscall.SIGTERM | |
| 633 | + | code, marker := initWithFakeDockerd(t, sigs, 30*time.Second, "sleep 60") | |
| 634 | + | if code != 128+int(syscall.SIGTERM) { | |
| 635 | + | t.Fatalf("code=%d", code) | |
| 636 | + | } | |
| 637 | + | if _, err := os.Stat(marker); err == nil { | |
| 638 | + | t.Fatal("the executor was started") | |
| 639 | + | } | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | // The deadline and a dying dockerd still start the executor. | |
| 643 | + | func TestInitStartsTheExecutorWhenDockerNeverGetsReady(t *testing.T) { | |
| 644 | + | for name, tc := range map[string]struct { | |
| 645 | + | dockerd string | |
| 646 | + | timeout time.Duration | |
| 647 | + | }{"timeout": {"sleep 60", 400 * time.Millisecond}, "dockerd exits": {"exit 1", 30 * time.Second}} { | |
| 648 | + | t.Run(name, func(t *testing.T) { | |
| 649 | + | code, marker := initWithFakeDockerd(t, nil, tc.timeout, tc.dockerd) | |
| 650 | + | if code != 7 { | |
| 651 | + | t.Fatalf("want the executor's code 7, got %d", code) | |
| 652 | + | } | |
| 653 | + | if _, err := os.Stat(marker); err != nil { | |
| 654 | + | t.Fatal("the executor did not run") | |
| 655 | + | } | |
| 656 | + | }) | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | // A dockerd that ignores SIGTERM goes through the SIGKILL path and is reaped | |
| 661 | + | // (no zombie left). | |
| 662 | + | func TestStopDockerdKillsAndReapsATermIgnoringDaemon(t *testing.T) { | |
| 663 | + | pid, err := spawn([]string{"/bin/sh", "-c", "trap '' TERM; while :; do sleep 0.1; done"}, nil) | |
| 664 | + | if err != nil { | |
| 665 | + | t.Fatal(err) | |
| 666 | + | } | |
| 667 | + | t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) | |
| 668 | + | time.Sleep(200 * time.Millisecond) // let the shell install its trap | |
| 669 | + | start := time.Now() | |
| 670 | + | stopDockerd(pid) | |
| 671 | + | if d := time.Since(start); d < 5*time.Second || d > 8*time.Second { | |
| 672 | + | t.Fatalf("expected the SIGKILL path after the 5 s grace, took %s", d) | |
| 673 | + | } | |
| 674 | + | if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil { | |
| 675 | + | t.Fatal("dockerd was not reaped (zombie or alive)") | |
| 676 | + | } | |
| 677 | + | } | |
| @@ -4,8 +4,10 @@ package guest | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "net" | 9 | "net" | ||
| 10 | + | "net/http" | |||
| 9 | "os" | 11 | "os" | ||
| 10 | "path/filepath" | 12 | "path/filepath" | ||
| 11 | "reflect" | 13 | "reflect" | ||
| @@ -17,6 +19,8 @@ import ( | |||||
| 17 | "testing" | 19 | "testing" | ||
| 18 | "time" | 20 | "time" | ||
| 19 | 21 | ||||
| 22 | + | "github.com/bnema/zerowrap" | |||
| 23 | + | ||||
| 20 | "bnema.dev/ermite-runner/internal/execstream" | 24 | "bnema.dev/ermite-runner/internal/execstream" | ||
| 21 | "bnema.dev/ermite-runner/internal/execstream/execpb" | 25 | "bnema.dev/ermite-runner/internal/execstream/execpb" | ||
| 22 | ) | 26 | ) | ||
| @@ -409,3 +413,265 @@ func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) { | |||||
| 409 | t.Fatal("the executor never received SIGTERM") | 413 | t.Fatal("the executor never received SIGTERM") | ||
| 410 | } | 414 | } | ||
| 411 | } | 415 | } | ||
| 416 | + | ||||
| 417 | + | // fakeDocker serves the Docker API on a temp unix socket from the moment it is | |||
| 418 | + | // called; ping decides the /_ping answer. | |||
| 419 | + | func fakeDocker(t *testing.T, ping http.HandlerFunc) string { | |||
| 420 | + | t.Helper() | |||
| 421 | + | sock := filepath.Join(t.TempDir(), "docker.sock") | |||
| 422 | + | l, err := net.Listen("unix", sock) | |||
| 423 | + | if err != nil { | |||
| 424 | + | t.Fatal(err) | |||
| 425 | + | } | |||
| 426 | + | mux := http.NewServeMux() | |||
| 427 | + | mux.HandleFunc("/_ping", ping) | |||
| 428 | + | srv := &http.Server{Handler: mux} | |||
| 429 | + | go func() { _ = srv.Serve(l) }() | |||
| 430 | + | t.Cleanup(func() { _ = srv.Close() }) | |||
| 431 | + | return sock | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func sleeper(t *testing.T) int { | |||
| 435 | + | t.Helper() | |||
| 436 | + | pid, err := spawn([]string{"/bin/sh", "-c", "sleep 60"}, nil) | |||
| 437 | + | if err != nil { | |||
| 438 | + | t.Fatal(err) | |||
| 439 | + | } | |||
| 440 | + | t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL); _, _ = wait4NoHang(pid) }) | |||
| 441 | + | return pid | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | var testLog = zerowrap.FromCtx(context.Background()) | |||
| 445 | + | ||||
| 446 | + | func TestWaitDockerReadyWaitsForPing(t *testing.T) { | |||
| 447 | + | var calls atomic.Int32 | |||
| 448 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { | |||
| 449 | + | if calls.Add(1) < 4 { // dockerd is up but still starting | |||
| 450 | + | http.Error(w, "starting", http.StatusServiceUnavailable) | |||
| 451 | + | return | |||
| 452 | + | } | |||
| 453 | + | _, _ = w.Write([]byte("OK")) | |||
| 454 | + | }) | |||
| 455 | + | start := time.Now() | |||
| 456 | + | if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil { | |||
| 457 | + | t.Fatal(err) | |||
| 458 | + | } | |||
| 459 | + | if calls.Load() < 4 || time.Since(start) < 200*time.Millisecond { | |||
| 460 | + | t.Fatalf("returned before the API answered OK (calls=%d)", calls.Load()) | |||
| 461 | + | } | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func TestWaitDockerReadyLateSocket(t *testing.T) { | |||
| 465 | + | sock := filepath.Join(t.TempDir(), "docker.sock") | |||
| 466 | + | go func() { | |||
| 467 | + | time.Sleep(400 * time.Millisecond) | |||
| 468 | + | l, err := net.Listen("unix", sock) | |||
| 469 | + | if err != nil { | |||
| 470 | + | return | |||
| 471 | + | } | |||
| 472 | + | srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("OK")) })} | |||
| 473 | + | go func() { _ = srv.Serve(l) }() | |||
| 474 | + | time.AfterFunc(5*time.Second, func() { _ = srv.Close() }) | |||
| 475 | + | }() | |||
| 476 | + | start := time.Now() | |||
| 477 | + | if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil { | |||
| 478 | + | t.Fatal(err) | |||
| 479 | + | } | |||
| 480 | + | if time.Since(start) < 400*time.Millisecond { | |||
| 481 | + | t.Fatal("returned before the socket existed") | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func TestWaitDockerReadyTimesOut(t *testing.T) { | |||
| 486 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusServiceUnavailable) }) | |||
| 487 | + | start := time.Now() | |||
| 488 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil) | |||
| 489 | + | if err == nil || time.Since(start) > 5*time.Second { | |||
| 490 | + | t.Fatalf("want a timeout error, got %v after %s", err, time.Since(start)) | |||
| 491 | + | } | |||
| 492 | + | var exited *dockerdExitError | |||
| 493 | + | var abort *abortBootError | |||
| 494 | + | if errors.As(err, &exited) || errors.As(err, &abort) { | |||
| 495 | + | t.Fatalf("a timeout is neither a dockerd exit nor an abort: %v", err) | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | // A daemon that accepts the connection and never answers must not delay the | |||
| 500 | + | // deadline, nor a signal. | |||
| 501 | + | func TestWaitDockerReadyStalledServer(t *testing.T) { | |||
| 502 | + | release := make(chan struct{}) | |||
| 503 | + | t.Cleanup(func() { close(release) }) | |||
| 504 | + | sock := fakeDocker(t, func(http.ResponseWriter, *http.Request) { <-release }) | |||
| 505 | + | ||||
| 506 | + | start := time.Now() | |||
| 507 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil) | |||
| 508 | + | var abort *abortBootError | |||
| 509 | + | if err == nil || errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond { | |||
| 510 | + | t.Fatalf("want the deadline error within ~0.5 s, got %v after %s", err, time.Since(start)) | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | sigs := make(chan os.Signal, 1) | |||
| 514 | + | time.AfterFunc(200*time.Millisecond, func() { sigs <- syscall.SIGTERM }) | |||
| 515 | + | start = time.Now() | |||
| 516 | + | err = waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs) | |||
| 517 | + | if !errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond { | |||
| 518 | + | t.Fatalf("want an abort right after the signal, got %v after %s", err, time.Since(start)) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | func TestWaitDockerReadyDockerdExits(t *testing.T) { | |||
| 523 | + | pid, err := spawn([]string{"/bin/sh", "-c", "exit 3"}, nil) | |||
| 524 | + | if err != nil { | |||
| 525 | + | t.Fatal(err) | |||
| 526 | + | } | |||
| 527 | + | start := time.Now() | |||
| 528 | + | err = waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), pid, 30*time.Second, nil) | |||
| 529 | + | var exited *dockerdExitError | |||
| 530 | + | if !errors.As(err, &exited) || exited.status.ExitStatus() != 3 { | |||
| 531 | + | t.Fatalf("want dockerd exit 3, got %v", err) | |||
| 532 | + | } | |||
| 533 | + | if time.Since(start) > 5*time.Second { | |||
| 534 | + | t.Fatal("did not notice the exit promptly") | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | // Orphans that init adopts while it waits must be reaped at once, not left as | |||
| 539 | + | // zombies until the wait ends. | |||
| 540 | + | func TestWaitDockerReadyReapsAdoptedOrphans(t *testing.T) { | |||
| 541 | + | // A child that exits immediately stands for an adopted orphan. | |||
| 542 | + | orphan, err := spawn([]string{"/bin/sh", "-c", "exit 0"}, nil) | |||
| 543 | + | if err != nil { | |||
| 544 | + | t.Fatal(err) | |||
| 545 | + | } | |||
| 546 | + | zombie := func() bool { | |||
| 547 | + | b, err := os.ReadFile("/proc/" + strconv.Itoa(orphan) + "/stat") | |||
| 548 | + | return err == nil && strings.Contains(string(b), ") Z ") | |||
| 549 | + | } | |||
| 550 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 551 | + | for !zombie() && time.Now().Before(deadline) { | |||
| 552 | + | time.Sleep(10 * time.Millisecond) | |||
| 553 | + | } | |||
| 554 | + | if !zombie() { | |||
| 555 | + | t.Fatal("setup: the orphan did not become a zombie") | |||
| 556 | + | } | |||
| 557 | + | sigs := make(chan os.Signal, 1) | |||
| 558 | + | time.AfterFunc(600*time.Millisecond, func() { sigs <- syscall.SIGTERM }) | |||
| 559 | + | done := make(chan error, 1) | |||
| 560 | + | go func() { | |||
| 561 | + | done <- waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), sleeper(t), 30*time.Second, sigs) | |||
| 562 | + | }() | |||
| 563 | + | // The zombie must vanish while the wait is still running. | |||
| 564 | + | deadline = time.Now().Add(400 * time.Millisecond) | |||
| 565 | + | for zombie() && time.Now().Before(deadline) { | |||
| 566 | + | time.Sleep(10 * time.Millisecond) | |||
| 567 | + | } | |||
| 568 | + | if zombie() { | |||
| 569 | + | t.Fatal("the adopted orphan stayed a zombie during the wait") | |||
| 570 | + | } | |||
| 571 | + | <-done | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | // A shutdown signal during the wait aborts the boot (exit 128+signal) instead | |||
| 575 | + | // of starting the executor; so does a cancelled context. | |||
| 576 | + | func TestWaitDockerReadyAbortsOnSignalAndContext(t *testing.T) { | |||
| 577 | + | sock := filepath.Join(t.TempDir(), "none.sock") | |||
| 578 | + | sigs := make(chan os.Signal, 1) | |||
| 579 | + | sigs <- syscall.SIGINT | |||
| 580 | + | var abort *abortBootError | |||
| 581 | + | err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs) | |||
| 582 | + | if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGINT) { | |||
| 583 | + | t.Fatalf("err=%v", err) | |||
| 584 | + | } | |||
| 585 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 586 | + | cancel() | |||
| 587 | + | err = waitDockerReady(ctx, testLog, sock, sleeper(t), 30*time.Second, nil) | |||
| 588 | + | if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGTERM) { | |||
| 589 | + | t.Fatalf("err=%v", err) | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | func TestStopDockerdReapsAndZeroIsNoop(t *testing.T) { | |||
| 594 | + | stopDockerd(0) | |||
| 595 | + | pid := sleeper(t) | |||
| 596 | + | stopDockerd(pid) | |||
| 597 | + | if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil { | |||
| 598 | + | t.Fatal("dockerd is still there") | |||
| 599 | + | } | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | func TestDockerPingRejectsNonOK(t *testing.T) { | |||
| 603 | + | sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("nope")) }) | |||
| 604 | + | client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { | |||
| 605 | + | var d net.Dialer | |||
| 606 | + | return d.DialContext(ctx, "unix", sock) | |||
| 607 | + | }}} | |||
| 608 | + | if dockerPing(context.Background(), client) { | |||
| 609 | + | t.Fatal("a 200 without OK must not count as ready") | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func initWithFakeDockerd(t *testing.T, sigs <-chan os.Signal, timeout time.Duration, dockerd string) (code int, marker string) { | |||
| 614 | + | t.Helper() | |||
| 615 | + | marker = filepath.Join(t.TempDir(), "executor-ran") | |||
| 616 | + | code = Init(context.Background(), InitConfig{ | |||
| 617 | + | Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent", | |||
| 618 | + | Getpid: func() int { return 4242 }, | |||
| 619 | + | Signals: sigs, | |||
| 620 | + | DockerdArgs: []string{"/bin/sh", "-c", dockerd}, | |||
| 621 | + | DockerSocket: filepath.Join(t.TempDir(), "none.sock"), | |||
| 622 | + | DockerReadyTimeout: timeout, | |||
| 623 | + | ExecArgs: []string{"/bin/sh", "-c", "touch " + marker + "; exit 7"}, | |||
| 624 | + | }) | |||
| 625 | + | return code, marker | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // A shutdown signal while waiting for dockerd: no executor, dockerd stopped, | |||
| 629 | + | // exit 128+signal. | |||
| 630 | + | func TestInitSignalDuringDockerWaitDoesNotStartTheExecutor(t *testing.T) { | |||
| 631 | + | sigs := make(chan os.Signal, 1) | |||
| 632 | + | sigs <- syscall.SIGTERM | |||
| 633 | + | code, marker := initWithFakeDockerd(t, sigs, 30*time.Second, "sleep 60") | |||
| 634 | + | if code != 128+int(syscall.SIGTERM) { | |||
| 635 | + | t.Fatalf("code=%d", code) | |||
| 636 | + | } | |||
| 637 | + | if _, err := os.Stat(marker); err == nil { | |||
| 638 | + | t.Fatal("the executor was started") | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | // The deadline and a dying dockerd still start the executor. | |||
| 643 | + | func TestInitStartsTheExecutorWhenDockerNeverGetsReady(t *testing.T) { | |||
| 644 | + | for name, tc := range map[string]struct { | |||
| 645 | + | dockerd string | |||
| 646 | + | timeout time.Duration | |||
| 647 | + | }{"timeout": {"sleep 60", 400 * time.Millisecond}, "dockerd exits": {"exit 1", 30 * time.Second}} { | |||
| 648 | + | t.Run(name, func(t *testing.T) { | |||
| 649 | + | code, marker := initWithFakeDockerd(t, nil, tc.timeout, tc.dockerd) | |||
| 650 | + | if code != 7 { | |||
| 651 | + | t.Fatalf("want the executor's code 7, got %d", code) | |||
| 652 | + | } | |||
| 653 | + | if _, err := os.Stat(marker); err != nil { | |||
| 654 | + | t.Fatal("the executor did not run") | |||
| 655 | + | } | |||
| 656 | + | }) | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | // A dockerd that ignores SIGTERM goes through the SIGKILL path and is reaped | |||
| 661 | + | // (no zombie left). | |||
| 662 | + | func TestStopDockerdKillsAndReapsATermIgnoringDaemon(t *testing.T) { | |||
| 663 | + | pid, err := spawn([]string{"/bin/sh", "-c", "trap '' TERM; while :; do sleep 0.1; done"}, nil) | |||
| 664 | + | if err != nil { | |||
| 665 | + | t.Fatal(err) | |||
| 666 | + | } | |||
| 667 | + | t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) | |||
| 668 | + | time.Sleep(200 * time.Millisecond) // let the shell install its trap | |||
| 669 | + | start := time.Now() | |||
| 670 | + | stopDockerd(pid) | |||
| 671 | + | if d := time.Since(start); d < 5*time.Second || d > 8*time.Second { | |||
| 672 | + | t.Fatalf("expected the SIGKILL path after the 5 s grace, took %s", d) | |||
| 673 | + | } | |||
| 674 | + | if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil { | |||
| 675 | + | t.Fatal("dockerd was not reaped (zombie or alive)") | |||
| 676 | + | } | |||
| 677 | + | } | |||
M internal/guest/init.go +242 -24
| @@ -4,7 +4,11 @@ package guest | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net" | |
| 11 | + | "net/http" | |
| 8 | 12 | "os" | |
| 9 | 13 | "os/signal" | |
| 10 | 14 | "strings" | |
| @@ -33,6 +37,15 @@ type InitConfig struct { | |||
| 33 | 37 | // ExecArgs is the executor command line; default | |
| 34 | 38 | // [GuestExecDir/GuestExecName exec]. | |
| 35 | 39 | ExecArgs []string | |
| 40 | + | // DockerdArgs replaces the dockerd command line, and the check that | |
| 41 | + | // /usr/bin/dockerd exists (tests); SkipDocker still wins. | |
| 42 | + | DockerdArgs []string | |
| 43 | + | // DockerSocket is the Docker API socket dockerd listens on and init waits | |
| 44 | + | // for; default dockerSocket. | |
| 45 | + | DockerSocket string | |
| 46 | + | // DockerReadyTimeout bounds the wait for the Docker API before the executor | |
| 47 | + | // is started anyway; default dockerReadyTimeout. | |
| 48 | + | DockerReadyTimeout time.Duration | |
| 36 | 49 | } | |
| 37 | 50 | ||
| 38 | 51 | // Init is `ermite-runner init`: the entrypoint of every sandbox. | |
| @@ -45,7 +58,7 @@ type InitConfig struct { | |||
| 45 | 58 | // | |
| 46 | 59 | // In a container the root and the pseudo filesystems are ready: it skips all of | |
| 47 | 60 | // that and only prepares cgroups. Then, in both: start dockerd with its data | |
| 48 | - | // root on the scratch space, wait for its socket, run the executor | |
| 61 | + | // root on the scratch space, wait (bounded) for its API to answer, run the executor | |
| 49 | 62 | // (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by | |
| 50 | 63 | // powering the VM off, or exiting with the executor's code in a container. | |
| 51 | 64 | func Init(ctx context.Context, cfg InitConfig) int { | |
| @@ -116,10 +129,15 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||
| 116 | 129 | } | |
| 117 | 130 | ||
| 118 | 131 | var dockerPid int | |
| 119 | - | if _, err := os.Stat("/usr/bin/dockerd"); err == nil && !cfg.SkipDocker { | |
| 132 | + | _, statErr := os.Stat("/usr/bin/dockerd") | |
| 133 | + | if !cfg.SkipDocker && (statErr == nil || cfg.DockerdArgs != nil) { | |
| 120 | 134 | _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711) | |
| 121 | - | pid, err := spawn([]string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | |
| 122 | - | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"}, nil) | |
| 135 | + | argv := cfg.DockerdArgs | |
| 136 | + | if argv == nil { | |
| 137 | + | argv = []string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | |
| 138 | + | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"} | |
| 139 | + | } | |
| 140 | + | pid, err := spawn(argv, nil) | |
| 123 | 141 | if err != nil { | |
| 124 | 142 | log.Error().Err(err).Msg("init: start dockerd") | |
| 125 | 143 | } else { | |
| @@ -127,12 +145,6 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||
| 127 | 145 | } | |
| 128 | 146 | } | |
| 129 | 147 | ||
| 130 | - | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |
| 131 | - | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |
| 132 | - | if err != nil { | |
| 133 | - | log.Error().Err(err).Msg("init: start executor") | |
| 134 | - | return 1 | |
| 135 | - | } | |
| 136 | 148 | // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the | |
| 137 | 149 | // executor, which cancels its script with the usual escalation and reports | |
| 138 | 150 | // its Exit; init then finishes normally instead of dying with children. | |
| @@ -143,35 +155,241 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||
| 143 | 155 | defer signal.Stop(ch) | |
| 144 | 156 | sigs = ch | |
| 145 | 157 | } | |
| 158 | + | ||
| 159 | + | // The first job-container step needs the Docker API, so the executor must | |
| 160 | + | // not start before dockerd answers. If it never does (or dies), start the | |
| 161 | + | // executor anyway: jobs that do not use Docker still run. A shutdown signal | |
| 162 | + | // during the wait aborts the boot instead: see abortBootError. | |
| 163 | + | if dockerPid != 0 { | |
| 164 | + | sock := cfg.DockerSocket | |
| 165 | + | if sock == "" { | |
| 166 | + | sock = dockerSocket | |
| 167 | + | } | |
| 168 | + | timeout := cfg.DockerReadyTimeout | |
| 169 | + | if timeout <= 0 { | |
| 170 | + | timeout = dockerReadyTimeout | |
| 171 | + | } | |
| 172 | + | err := waitDockerReady(ctx, log, sock, dockerPid, timeout, sigs) | |
| 173 | + | var exited *dockerdExitError | |
| 174 | + | var abort *abortBootError | |
| 175 | + | switch { | |
| 176 | + | case errors.As(err, &abort): | |
| 177 | + | stopDockerd(dockerPid) | |
| 178 | + | return abort.code | |
| 179 | + | case errors.As(err, &exited): | |
| 180 | + | dockerPid = 0 // already reaped | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |
| 185 | + | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |
| 186 | + | if err != nil { | |
| 187 | + | log.Error().Err(err).Msg("init: start executor") | |
| 188 | + | stopDockerd(dockerPid) | |
| 189 | + | return 1 | |
| 190 | + | } | |
| 191 | + | forward := func(sig os.Signal) { | |
| 192 | + | if s, ok := sig.(syscall.Signal); ok { | |
| 193 | + | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |
| 194 | + | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |
| 195 | + | _ = syscall.Kill(execPid, s) | |
| 196 | + | } | |
| 197 | + | } | |
| 146 | 198 | stopFwd := make(chan struct{}) | |
| 147 | 199 | defer close(stopFwd) | |
| 148 | 200 | go func() { | |
| 149 | 201 | for { | |
| 150 | 202 | select { | |
| 151 | 203 | case sig := <-sigs: | |
| 152 | - | if s, ok := sig.(syscall.Signal); ok { | |
| 153 | - | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |
| 154 | - | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |
| 155 | - | _ = syscall.Kill(execPid, s) | |
| 156 | - | } | |
| 204 | + | forward(sig) | |
| 157 | 205 | case <-stopFwd: | |
| 158 | 206 | return | |
| 159 | 207 | } | |
| 160 | 208 | } | |
| 161 | 209 | }() | |
| 162 | 210 | code := reapUntil(execPid) | |
| 163 | - | if dockerPid != 0 { | |
| 164 | - | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | |
| 165 | - | deadline := time.Now().Add(5 * time.Second) | |
| 166 | - | for time.Now().Before(deadline) { | |
| 167 | - | if pid, _ := wait4NoHang(dockerPid); pid == dockerPid { | |
| 168 | - | break | |
| 211 | + | stopDockerd(dockerPid) | |
| 212 | + | return code | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // stopDockerd stops dockerd (SIGTERM, then SIGKILL after 5 s) and reaps it (at | |
| 216 | + | // most 5 s more after the SIGKILL); a zero pid is a no-op. | |
| 217 | + | func stopDockerd(pid int) { | |
| 218 | + | if pid == 0 { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | _ = syscall.Kill(pid, syscall.SIGTERM) | |
| 222 | + | deadline := time.Now().Add(5 * time.Second) | |
| 223 | + | for time.Now().Before(deadline) { | |
| 224 | + | if got, _ := wait4NoHang(pid); got == pid { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | time.Sleep(100 * time.Millisecond) | |
| 228 | + | } | |
| 229 | + | _ = syscall.Kill(pid, syscall.SIGKILL) | |
| 230 | + | // SIGKILL cannot be ignored, so the exit is prompt; poll rather than block | |
| 231 | + | // for ever on a process stuck in the kernel (uninterruptible sleep). | |
| 232 | + | for end := time.Now().Add(5 * time.Second); time.Now().Before(end); time.Sleep(10 * time.Millisecond) { | |
| 233 | + | got, err := wait4NoHang(pid) | |
| 234 | + | if got == pid || (err != nil && err != syscall.EINTR) { // ECHILD: already reaped | |
| 235 | + | return | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | const ( | |
| 241 | + | // dockerSocket is the Docker API socket of the nested dockerd. | |
| 242 | + | dockerSocket = "/var/run/docker.sock" | |
| 243 | + | // dockerReadyTimeout bounds the wait for the Docker API at boot. | |
| 244 | + | dockerReadyTimeout = 60 * time.Second | |
| 245 | + | dockerPollInterval = 100 * time.Millisecond | |
| 246 | + | dockerPingTimeout = 2 * time.Second | |
| 247 | + | ) | |
| 248 | + | ||
| 249 | + | // dockerdExitError reports that dockerd exited (and was reaped) before its API | |
| 250 | + | // answered. | |
| 251 | + | type dockerdExitError struct{ status syscall.WaitStatus } | |
| 252 | + | ||
| 253 | + | func (e *dockerdExitError) Error() string { | |
| 254 | + | if e.status.Signaled() { | |
| 255 | + | return "dockerd killed by signal " + e.status.Signal().String() | |
| 256 | + | } | |
| 257 | + | return fmt.Sprintf("dockerd exited with code %d", e.status.ExitStatus()) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // abortBootError means a shutdown signal (or the cancelled context) arrived | |
| 261 | + | // while waiting for the Docker API: init must not start the executor, stop | |
| 262 | + | // dockerd and exit with code (128+signal, as for a killed executor). | |
| 263 | + | // | |
| 264 | + | // Not starting the executor is safe for the host: the sandbox ends before the | |
| 265 | + | // executor connected ("container exited ... before the executor connected" / | |
| 266 | + | // "firecracker exited before the executor connected"), and the host reports the | |
| 267 | + | // job cancelled (not failed) when a cancel or shutdown was requested | |
| 268 | + | // (jobRun.execute: startSandbox error with cancelRequested). A stop that the | |
| 269 | + | // host did not ask for is reported as "the sandbox did not start". | |
| 270 | + | type abortBootError struct{ code int } | |
| 271 | + | ||
| 272 | + | func (e *abortBootError) Error() string { | |
| 273 | + | return fmt.Sprintf("boot aborted by a shutdown signal (exit %d)", e.code) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // reapChildren reaps every child that has exited (orphans adopted by init | |
| 277 | + | // included) without blocking, and reports whether pid was among them, with its | |
| 278 | + | // wait status. | |
| 279 | + | func reapChildren(pid int) (bool, syscall.WaitStatus) { | |
| 280 | + | var found bool | |
| 281 | + | var status syscall.WaitStatus | |
| 282 | + | for { | |
| 283 | + | var ws syscall.WaitStatus | |
| 284 | + | got, err := syscall.Wait4(-1, &ws, syscall.WNOHANG, nil) | |
| 285 | + | if err == syscall.EINTR { | |
| 286 | + | continue | |
| 287 | + | } | |
| 288 | + | if err != nil || got <= 0 { | |
| 289 | + | return found, status | |
| 290 | + | } | |
| 291 | + | if got == pid { | |
| 292 | + | found, status = true, ws | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // waitDockerReady blocks until the Docker API on sock answers GET /_ping with | |
| 298 | + | // "OK", for at most timeout. It returns nil when ready, and otherwise: | |
| 299 | + | // - *dockerdExitError: dockerd (pid, a child of init) exited and was reaped; | |
| 300 | + | // - a plain error: the deadline passed; | |
| 301 | + | // - *abortBootError: a signal arrived on sigs or ctx was cancelled. | |
| 302 | + | // | |
| 303 | + | // The orphans that init adopts are reaped on every tick, and a ping in flight | |
| 304 | + | // never delays the deadline, the signals or the exit check. Every outcome is | |
| 305 | + | // logged; for the first two the caller starts the executor anyway. | |
| 306 | + | func waitDockerReady(ctx context.Context, log zerowrap.Logger, sock string, pid int, timeout time.Duration, sigs <-chan os.Signal) error { | |
| 307 | + | start := time.Now() | |
| 308 | + | log.Info().Str("socket", sock).Dur("timeout", timeout).Msg("init: waiting for the Docker API") | |
| 309 | + | wctx, cancel := context.WithTimeout(ctx, timeout) | |
| 310 | + | defer cancel() // also aborts a ping in flight | |
| 311 | + | client := &http.Client{Transport: &http.Transport{ | |
| 312 | + | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { | |
| 313 | + | var d net.Dialer | |
| 314 | + | return d.DialContext(ctx, "unix", sock) | |
| 315 | + | }, | |
| 316 | + | DisableKeepAlives: true, | |
| 317 | + | }} | |
| 318 | + | defer client.CloseIdleConnections() | |
| 319 | + | ticker := time.NewTicker(dockerPollInterval) | |
| 320 | + | defer ticker.Stop() | |
| 321 | + | ||
| 322 | + | var pinging <-chan bool // result of the ping in flight, nil if none | |
| 323 | + | startPing := func() { | |
| 324 | + | ch := make(chan bool, 1) | |
| 325 | + | pinging = ch | |
| 326 | + | go func() { ch <- dockerPing(wctx, client) }() | |
| 327 | + | } | |
| 328 | + | checkExit := func() error { | |
| 329 | + | if ok, ws := reapChildren(pid); ok { | |
| 330 | + | err := &dockerdExitError{status: ws} | |
| 331 | + | log.Warn().Err(err).Msg("init: dockerd died before its API was ready; starting the executor anyway (jobs using Docker will fail)") | |
| 332 | + | return err | |
| 333 | + | } | |
| 334 | + | return nil | |
| 335 | + | } | |
| 336 | + | if err := checkExit(); err != nil { | |
| 337 | + | return err | |
| 338 | + | } | |
| 339 | + | startPing() | |
| 340 | + | for { | |
| 341 | + | select { | |
| 342 | + | case ok := <-pinging: | |
| 343 | + | pinging = nil | |
| 344 | + | if ok { | |
| 345 | + | log.Info().Dur("after", time.Since(start).Round(time.Millisecond)).Msg("init: the Docker API is ready") | |
| 346 | + | return nil | |
| 347 | + | } | |
| 348 | + | case <-ticker.C: | |
| 349 | + | if err := checkExit(); err != nil { | |
| 350 | + | return err | |
| 351 | + | } | |
| 352 | + | if pinging == nil { | |
| 353 | + | startPing() | |
| 169 | 354 | } | |
| 170 | - | time.Sleep(100 * time.Millisecond) | |
| 355 | + | case sig := <-sigs: | |
| 356 | + | return abortBoot(log, sig) | |
| 357 | + | case <-wctx.Done(): | |
| 358 | + | if ctx.Err() != nil { | |
| 359 | + | return abortBoot(log, syscall.SIGTERM) | |
| 360 | + | } | |
| 361 | + | log.Warn().Dur("timeout", timeout).Msg("init: the Docker API did not answer in time; starting the executor anyway (jobs using Docker will fail)") | |
| 362 | + | return fmt.Errorf("docker API not ready after %s", timeout) | |
| 171 | 363 | } | |
| 172 | - | _ = syscall.Kill(dockerPid, syscall.SIGKILL) | |
| 173 | 364 | } | |
| 174 | - | return code | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func abortBoot(log zerowrap.Logger, sig os.Signal) error { | |
| 368 | + | code := 128 + int(syscall.SIGTERM) | |
| 369 | + | if s, ok := sig.(syscall.Signal); ok { | |
| 370 | + | code = 128 + int(s) | |
| 371 | + | } | |
| 372 | + | log.Warn().Str("signal", fmt.Sprint(sig)).Msg("init: shutdown signal while waiting for the Docker API; not starting the executor, stopping dockerd") | |
| 373 | + | return &abortBootError{code: code} | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // dockerPing reports whether GET /_ping answers 200 "OK". | |
| 377 | + | func dockerPing(ctx context.Context, client *http.Client) bool { | |
| 378 | + | // One ping is bounded on its own so that a daemon that accepts and stalls | |
| 379 | + | // does not hold the others back; ctx (the readiness deadline) bounds it too. | |
| 380 | + | ctx, cancel := context.WithTimeout(ctx, dockerPingTimeout) | |
| 381 | + | defer cancel() | |
| 382 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/_ping", nil) | |
| 383 | + | if err != nil { | |
| 384 | + | return false | |
| 385 | + | } | |
| 386 | + | resp, err := client.Do(req) | |
| 387 | + | if err != nil { | |
| 388 | + | return false | |
| 389 | + | } | |
| 390 | + | defer func() { _ = resp.Body.Close() }() | |
| 391 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 16)) | |
| 392 | + | return resp.StatusCode == http.StatusOK && strings.TrimSpace(string(body)) == "OK" | |
| 175 | 393 | } | |
| 176 | 394 | ||
| 177 | 395 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | |
| @@ -4,7 +4,11 @@ package guest | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 9 | + | "io" | |||
| 10 | + | "net" | |||
| 11 | + | "net/http" | |||
| 8 | "os" | 12 | "os" | ||
| 9 | "os/signal" | 13 | "os/signal" | ||
| 10 | "strings" | 14 | "strings" | ||
| @@ -33,6 +37,15 @@ type InitConfig struct { | |||||
| 33 | // ExecArgs is the executor command line; default | 37 | // ExecArgs is the executor command line; default | ||
| 34 | // [GuestExecDir/GuestExecName exec]. | 38 | // [GuestExecDir/GuestExecName exec]. | ||
| 35 | ExecArgs []string | 39 | ExecArgs []string | ||
| 40 | + | // DockerdArgs replaces the dockerd command line, and the check that | |||
| 41 | + | // /usr/bin/dockerd exists (tests); SkipDocker still wins. | |||
| 42 | + | DockerdArgs []string | |||
| 43 | + | // DockerSocket is the Docker API socket dockerd listens on and init waits | |||
| 44 | + | // for; default dockerSocket. | |||
| 45 | + | DockerSocket string | |||
| 46 | + | // DockerReadyTimeout bounds the wait for the Docker API before the executor | |||
| 47 | + | // is started anyway; default dockerReadyTimeout. | |||
| 48 | + | DockerReadyTimeout time.Duration | |||
| 36 | } | 49 | } | ||
| 37 | 50 | ||||
| 38 | // Init is `ermite-runner init`: the entrypoint of every sandbox. | 51 | // Init is `ermite-runner init`: the entrypoint of every sandbox. | ||
| @@ -45,7 +58,7 @@ type InitConfig struct { | |||||
| 45 | // | 58 | // | ||
| 46 | // In a container the root and the pseudo filesystems are ready: it skips all of | 59 | // In a container the root and the pseudo filesystems are ready: it skips all of | ||
| 47 | // that and only prepares cgroups. Then, in both: start dockerd with its data | 60 | // that and only prepares cgroups. Then, in both: start dockerd with its data | ||
| 48 | - | // root on the scratch space, wait for its socket, run the executor | 61 | + | // root on the scratch space, wait (bounded) for its API to answer, run the executor |
| 49 | // (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by | 62 | // (`ermite-runner exec`) as a child, reap orphans, stop dockerd, and finish by | ||
| 50 | // powering the VM off, or exiting with the executor's code in a container. | 63 | // powering the VM off, or exiting with the executor's code in a container. | ||
| 51 | func Init(ctx context.Context, cfg InitConfig) int { | 64 | func Init(ctx context.Context, cfg InitConfig) int { | ||
| @@ -116,10 +129,15 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||||
| 116 | } | 129 | } | ||
| 117 | 130 | ||||
| 118 | var dockerPid int | 131 | var dockerPid int | ||
| 119 | - | if _, err := os.Stat("/usr/bin/dockerd"); err == nil && !cfg.SkipDocker { | 132 | + | _, statErr := os.Stat("/usr/bin/dockerd") |
| 133 | + | if !cfg.SkipDocker && (statErr == nil || cfg.DockerdArgs != nil) { | |||
| 120 | _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711) | 134 | _ = os.MkdirAll(sandbox.GuestScratch+"/docker", 0o711) | ||
| 121 | - | pid, err := spawn([]string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | 135 | + | argv := cfg.DockerdArgs |
| 122 | - | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"}, nil) | 136 | + | if argv == nil { |
| 137 | + | argv = []string{"/usr/bin/dockerd", "--data-root", sandbox.GuestScratch + "/docker", | |||
| 138 | + | "--storage-driver", "overlay2", "--host", "unix:///var/run/docker.sock"} | |||
| 139 | + | } | |||
| 140 | + | pid, err := spawn(argv, nil) | |||
| 123 | if err != nil { | 141 | if err != nil { | ||
| 124 | log.Error().Err(err).Msg("init: start dockerd") | 142 | log.Error().Err(err).Msg("init: start dockerd") | ||
| 125 | } else { | 143 | } else { | ||
| @@ -127,12 +145,6 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||||
| 127 | } | 145 | } | ||
| 128 | } | 146 | } | ||
| 129 | 147 | ||||
| 130 | - | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |||
| 131 | - | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |||
| 132 | - | if err != nil { | |||
| 133 | - | log.Error().Err(err).Msg("init: start executor") | |||
| 134 | - | return 1 | |||
| 135 | - | } | |||
| 136 | // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the | 148 | // SIGTERM/SIGINT (docker stop, or the host stopping the VM) go to the | ||
| 137 | // executor, which cancels its script with the usual escalation and reports | 149 | // executor, which cancels its script with the usual escalation and reports | ||
| 138 | // its Exit; init then finishes normally instead of dying with children. | 150 | // its Exit; init then finishes normally instead of dying with children. | ||
| @@ -143,35 +155,241 @@ func runPayload(ctx context.Context, cfg InitConfig, mode string, args map[strin | |||||
| 143 | defer signal.Stop(ch) | 155 | defer signal.Stop(ch) | ||
| 144 | sigs = ch | 156 | sigs = ch | ||
| 145 | } | 157 | } | ||
| 158 | + | ||||
| 159 | + | // The first job-container step needs the Docker API, so the executor must | |||
| 160 | + | // not start before dockerd answers. If it never does (or dies), start the | |||
| 161 | + | // executor anyway: jobs that do not use Docker still run. A shutdown signal | |||
| 162 | + | // during the wait aborts the boot instead: see abortBootError. | |||
| 163 | + | if dockerPid != 0 { | |||
| 164 | + | sock := cfg.DockerSocket | |||
| 165 | + | if sock == "" { | |||
| 166 | + | sock = dockerSocket | |||
| 167 | + | } | |||
| 168 | + | timeout := cfg.DockerReadyTimeout | |||
| 169 | + | if timeout <= 0 { | |||
| 170 | + | timeout = dockerReadyTimeout | |||
| 171 | + | } | |||
| 172 | + | err := waitDockerReady(ctx, log, sock, dockerPid, timeout, sigs) | |||
| 173 | + | var exited *dockerdExitError | |||
| 174 | + | var abort *abortBootError | |||
| 175 | + | switch { | |||
| 176 | + | case errors.As(err, &abort): | |||
| 177 | + | stopDockerd(dockerPid) | |||
| 178 | + | return abort.code | |||
| 179 | + | case errors.As(err, &exited): | |||
| 180 | + | dockerPid = 0 // already reaped | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | execPid, err := spawn(cfg.ExecArgs, append(os.Environ(), | |||
| 185 | + | "ERMITE_CONNECT="+target, "ERMITE_SANDBOX="+mode, "ERMITE_VERSION="+cfg.Version)) | |||
| 186 | + | if err != nil { | |||
| 187 | + | log.Error().Err(err).Msg("init: start executor") | |||
| 188 | + | stopDockerd(dockerPid) | |||
| 189 | + | return 1 | |||
| 190 | + | } | |||
| 191 | + | forward := func(sig os.Signal) { | |||
| 192 | + | if s, ok := sig.(syscall.Signal); ok { | |||
| 193 | + | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |||
| 194 | + | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |||
| 195 | + | _ = syscall.Kill(execPid, s) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 146 | stopFwd := make(chan struct{}) | 198 | stopFwd := make(chan struct{}) | ||
| 147 | defer close(stopFwd) | 199 | defer close(stopFwd) | ||
| 148 | go func() { | 200 | go func() { | ||
| 149 | for { | 201 | for { | ||
| 150 | select { | 202 | select { | ||
| 151 | case sig := <-sigs: | 203 | case sig := <-sigs: | ||
| 152 | - | if s, ok := sig.(syscall.Signal); ok { | 204 | + | forward(sig) |
| 153 | - | log.Info().Str("signal", s.String()).Msg("init: forwarding the signal to the executor") | |||
| 154 | - | _ = syscall.Kill(-execPid, s) // the executor leads its own process group | |||
| 155 | - | _ = syscall.Kill(execPid, s) | |||
| 156 | - | } | |||
| 157 | case <-stopFwd: | 205 | case <-stopFwd: | ||
| 158 | return | 206 | return | ||
| 159 | } | 207 | } | ||
| 160 | } | 208 | } | ||
| 161 | }() | 209 | }() | ||
| 162 | code := reapUntil(execPid) | 210 | code := reapUntil(execPid) | ||
| 163 | - | if dockerPid != 0 { | 211 | + | stopDockerd(dockerPid) |
| 164 | - | _ = syscall.Kill(dockerPid, syscall.SIGTERM) | 212 | + | return code |
| 165 | - | deadline := time.Now().Add(5 * time.Second) | 213 | + | } |
| 166 | - | for time.Now().Before(deadline) { | 214 | + | |
| 167 | - | if pid, _ := wait4NoHang(dockerPid); pid == dockerPid { | 215 | + | // stopDockerd stops dockerd (SIGTERM, then SIGKILL after 5 s) and reaps it (at |
| 168 | - | break | 216 | + | // most 5 s more after the SIGKILL); a zero pid is a no-op. |
| 217 | + | func stopDockerd(pid int) { | |||
| 218 | + | if pid == 0 { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | _ = syscall.Kill(pid, syscall.SIGTERM) | |||
| 222 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 223 | + | for time.Now().Before(deadline) { | |||
| 224 | + | if got, _ := wait4NoHang(pid); got == pid { | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 227 | + | time.Sleep(100 * time.Millisecond) | |||
| 228 | + | } | |||
| 229 | + | _ = syscall.Kill(pid, syscall.SIGKILL) | |||
| 230 | + | // SIGKILL cannot be ignored, so the exit is prompt; poll rather than block | |||
| 231 | + | // for ever on a process stuck in the kernel (uninterruptible sleep). | |||
| 232 | + | for end := time.Now().Add(5 * time.Second); time.Now().Before(end); time.Sleep(10 * time.Millisecond) { | |||
| 233 | + | got, err := wait4NoHang(pid) | |||
| 234 | + | if got == pid || (err != nil && err != syscall.EINTR) { // ECHILD: already reaped | |||
| 235 | + | return | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | const ( | |||
| 241 | + | // dockerSocket is the Docker API socket of the nested dockerd. | |||
| 242 | + | dockerSocket = "/var/run/docker.sock" | |||
| 243 | + | // dockerReadyTimeout bounds the wait for the Docker API at boot. | |||
| 244 | + | dockerReadyTimeout = 60 * time.Second | |||
| 245 | + | dockerPollInterval = 100 * time.Millisecond | |||
| 246 | + | dockerPingTimeout = 2 * time.Second | |||
| 247 | + | ) | |||
| 248 | + | ||||
| 249 | + | // dockerdExitError reports that dockerd exited (and was reaped) before its API | |||
| 250 | + | // answered. | |||
| 251 | + | type dockerdExitError struct{ status syscall.WaitStatus } | |||
| 252 | + | ||||
| 253 | + | func (e *dockerdExitError) Error() string { | |||
| 254 | + | if e.status.Signaled() { | |||
| 255 | + | return "dockerd killed by signal " + e.status.Signal().String() | |||
| 256 | + | } | |||
| 257 | + | return fmt.Sprintf("dockerd exited with code %d", e.status.ExitStatus()) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // abortBootError means a shutdown signal (or the cancelled context) arrived | |||
| 261 | + | // while waiting for the Docker API: init must not start the executor, stop | |||
| 262 | + | // dockerd and exit with code (128+signal, as for a killed executor). | |||
| 263 | + | // | |||
| 264 | + | // Not starting the executor is safe for the host: the sandbox ends before the | |||
| 265 | + | // executor connected ("container exited ... before the executor connected" / | |||
| 266 | + | // "firecracker exited before the executor connected"), and the host reports the | |||
| 267 | + | // job cancelled (not failed) when a cancel or shutdown was requested | |||
| 268 | + | // (jobRun.execute: startSandbox error with cancelRequested). A stop that the | |||
| 269 | + | // host did not ask for is reported as "the sandbox did not start". | |||
| 270 | + | type abortBootError struct{ code int } | |||
| 271 | + | ||||
| 272 | + | func (e *abortBootError) Error() string { | |||
| 273 | + | return fmt.Sprintf("boot aborted by a shutdown signal (exit %d)", e.code) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // reapChildren reaps every child that has exited (orphans adopted by init | |||
| 277 | + | // included) without blocking, and reports whether pid was among them, with its | |||
| 278 | + | // wait status. | |||
| 279 | + | func reapChildren(pid int) (bool, syscall.WaitStatus) { | |||
| 280 | + | var found bool | |||
| 281 | + | var status syscall.WaitStatus | |||
| 282 | + | for { | |||
| 283 | + | var ws syscall.WaitStatus | |||
| 284 | + | got, err := syscall.Wait4(-1, &ws, syscall.WNOHANG, nil) | |||
| 285 | + | if err == syscall.EINTR { | |||
| 286 | + | continue | |||
| 287 | + | } | |||
| 288 | + | if err != nil || got <= 0 { | |||
| 289 | + | return found, status | |||
| 290 | + | } | |||
| 291 | + | if got == pid { | |||
| 292 | + | found, status = true, ws | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // waitDockerReady blocks until the Docker API on sock answers GET /_ping with | |||
| 298 | + | // "OK", for at most timeout. It returns nil when ready, and otherwise: | |||
| 299 | + | // - *dockerdExitError: dockerd (pid, a child of init) exited and was reaped; | |||
| 300 | + | // - a plain error: the deadline passed; | |||
| 301 | + | // - *abortBootError: a signal arrived on sigs or ctx was cancelled. | |||
| 302 | + | // | |||
| 303 | + | // The orphans that init adopts are reaped on every tick, and a ping in flight | |||
| 304 | + | // never delays the deadline, the signals or the exit check. Every outcome is | |||
| 305 | + | // logged; for the first two the caller starts the executor anyway. | |||
| 306 | + | func waitDockerReady(ctx context.Context, log zerowrap.Logger, sock string, pid int, timeout time.Duration, sigs <-chan os.Signal) error { | |||
| 307 | + | start := time.Now() | |||
| 308 | + | log.Info().Str("socket", sock).Dur("timeout", timeout).Msg("init: waiting for the Docker API") | |||
| 309 | + | wctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 310 | + | defer cancel() // also aborts a ping in flight | |||
| 311 | + | client := &http.Client{Transport: &http.Transport{ | |||
| 312 | + | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { | |||
| 313 | + | var d net.Dialer | |||
| 314 | + | return d.DialContext(ctx, "unix", sock) | |||
| 315 | + | }, | |||
| 316 | + | DisableKeepAlives: true, | |||
| 317 | + | }} | |||
| 318 | + | defer client.CloseIdleConnections() | |||
| 319 | + | ticker := time.NewTicker(dockerPollInterval) | |||
| 320 | + | defer ticker.Stop() | |||
| 321 | + | ||||
| 322 | + | var pinging <-chan bool // result of the ping in flight, nil if none | |||
| 323 | + | startPing := func() { | |||
| 324 | + | ch := make(chan bool, 1) | |||
| 325 | + | pinging = ch | |||
| 326 | + | go func() { ch <- dockerPing(wctx, client) }() | |||
| 327 | + | } | |||
| 328 | + | checkExit := func() error { | |||
| 329 | + | if ok, ws := reapChildren(pid); ok { | |||
| 330 | + | err := &dockerdExitError{status: ws} | |||
| 331 | + | log.Warn().Err(err).Msg("init: dockerd died before its API was ready; starting the executor anyway (jobs using Docker will fail)") | |||
| 332 | + | return err | |||
| 333 | + | } | |||
| 334 | + | return nil | |||
| 335 | + | } | |||
| 336 | + | if err := checkExit(); err != nil { | |||
| 337 | + | return err | |||
| 338 | + | } | |||
| 339 | + | startPing() | |||
| 340 | + | for { | |||
| 341 | + | select { | |||
| 342 | + | case ok := <-pinging: | |||
| 343 | + | pinging = nil | |||
| 344 | + | if ok { | |||
| 345 | + | log.Info().Dur("after", time.Since(start).Round(time.Millisecond)).Msg("init: the Docker API is ready") | |||
| 346 | + | return nil | |||
| 347 | + | } | |||
| 348 | + | case <-ticker.C: | |||
| 349 | + | if err := checkExit(); err != nil { | |||
| 350 | + | return err | |||
| 351 | + | } | |||
| 352 | + | if pinging == nil { | |||
| 353 | + | startPing() | |||
| 169 | } | 354 | } | ||
| 170 | - | time.Sleep(100 * time.Millisecond) | 355 | + | case sig := <-sigs: |
| 356 | + | return abortBoot(log, sig) | |||
| 357 | + | case <-wctx.Done(): | |||
| 358 | + | if ctx.Err() != nil { | |||
| 359 | + | return abortBoot(log, syscall.SIGTERM) | |||
| 360 | + | } | |||
| 361 | + | log.Warn().Dur("timeout", timeout).Msg("init: the Docker API did not answer in time; starting the executor anyway (jobs using Docker will fail)") | |||
| 362 | + | return fmt.Errorf("docker API not ready after %s", timeout) | |||
| 171 | } | 363 | } | ||
| 172 | - | _ = syscall.Kill(dockerPid, syscall.SIGKILL) | |||
| 173 | } | 364 | } | ||
| 174 | - | return code | 365 | + | } |
| 366 | + | ||||
| 367 | + | func abortBoot(log zerowrap.Logger, sig os.Signal) error { | |||
| 368 | + | code := 128 + int(syscall.SIGTERM) | |||
| 369 | + | if s, ok := sig.(syscall.Signal); ok { | |||
| 370 | + | code = 128 + int(s) | |||
| 371 | + | } | |||
| 372 | + | log.Warn().Str("signal", fmt.Sprint(sig)).Msg("init: shutdown signal while waiting for the Docker API; not starting the executor, stopping dockerd") | |||
| 373 | + | return &abortBootError{code: code} | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // dockerPing reports whether GET /_ping answers 200 "OK". | |||
| 377 | + | func dockerPing(ctx context.Context, client *http.Client) bool { | |||
| 378 | + | // One ping is bounded on its own so that a daemon that accepts and stalls | |||
| 379 | + | // does not hold the others back; ctx (the readiness deadline) bounds it too. | |||
| 380 | + | ctx, cancel := context.WithTimeout(ctx, dockerPingTimeout) | |||
| 381 | + | defer cancel() | |||
| 382 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/_ping", nil) | |||
| 383 | + | if err != nil { | |||
| 384 | + | return false | |||
| 385 | + | } | |||
| 386 | + | resp, err := client.Do(req) | |||
| 387 | + | if err != nil { | |||
| 388 | + | return false | |||
| 389 | + | } | |||
| 390 | + | defer func() { _ = resp.Body.Close() }() | |||
| 391 | + | body, _ := io.ReadAll(io.LimitReader(resp.Body, 16)) | |||
| 392 | + | return resp.StatusCode == http.StatusOK && strings.TrimSpace(string(body)) == "OK" | |||
| 175 | } | 393 | } | ||
| 176 | 394 | ||||
| 177 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | 395 | // spawn starts argv[0] as a child (own process group) without os/exec, so that | ||