Skip to content

Commit 03eb099

Browse files
BenTheElderdberkov
authored andcommitted
ateom-microvm: recover when the guest dies before the kata-agent answers
A resume can fail with a bare `dial unix /run/vc/vm/<uid>/clh.sock: connect: no such file or directory`, one minute after it started (agent-substrate#619). The socket was there — the caller waits for it before dialing — so it went away while we polled, and cloud-hypervisor unlinks it in the vsock device's shutdown: the guest died during boot. Under a contended host the guest's boot stretches badly (measured: systemd reaching its default target 25s in, versus about a second when idle), and a boot that stalls long enough is torn down guest-side. So treat ENOENT as what it is. Instead of polling a socket that cannot come back, and reporting the last dial error as if it were the problem, give up at once with an error that names the cause, and retry the cold boot: a guest that never reached its agent ran none of the actor's containers, and the failure path tears the whole attempt down, so starting over is safe — and it is the only recovery, since the dead VM is not coming back. Each retry is logged with the guest's boot diagnostics (the console tail, plus each virtiofsd's log, because cloud-hypervisor also stops the VM when a vhost-user backend dies and that leaves the console silent).
1 parent e4844d0 commit 03eb099

4 files changed

Lines changed: 174 additions & 7 deletions

File tree

cmd/ateom-microvm/durable.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ func workloadSpec(c actorContainer) *specs.Spec {
9999
return &spec
100100
}
101101

102+
// durableVirtiofsdLogPath is where the durable-dir share's virtiofsd logs,
103+
// beside the overlay lower's (see virtiofsdLogPath) under the actor's VM dir.
104+
func durableVirtiofsdLogPath(id string) string {
105+
return filepath.Join(kata.VMDir(id), "virtiofsd-durable.log")
106+
}
107+
102108
// stageDurableShare starts the virtiofsd serving the actor's durable-dir volumes.
103109
//
104110
// It serves ateompath.DurableDirVolumeMountsDir directly — no bind into the
@@ -113,7 +119,7 @@ func (s *AteomService) stageDurableShare(ctx context.Context, rr resolvedRuntime
113119
if _, err := os.Stat(shared); err != nil {
114120
return nil, fmt.Errorf("while checking durable-dir volumes dir %q: %w", shared, err)
115121
}
116-
log, _ := os.OpenFile(filepath.Join(kata.VMDir(actorUID), "virtiofsd-durable.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
122+
log, _ := os.OpenFile(durableVirtiofsdLogPath(actorUID), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
117123
cmd, err := kata.StartVirtiofsd(ctx, kata.VirtiofsdOptions{
118124
Binary: rr.virtiofsd,
119125
SocketPath: kata.DurableVirtiofsdSocketPath(actorUID),

cmd/ateom-microvm/restore.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore
8787
// A Data snapshot holds no guest state, so this is a cold boot that
8888
// happens to start with the volumes already populated. readyz gating comes
8989
// with the cold-boot path, so the actor is serving when we return.
90-
if err := s.coldBootActor(ctx, p); err != nil {
90+
if err := s.coldBootActorRetrying(ctx, p); err != nil {
9191
return nil, err
9292
}
9393
slog.InfoContext(ctx, "Actor restored (durable-dir volumes, cold boot)",

cmd/ateom-microvm/run.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ package main
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
23+
"io/fs"
2224
"log/slog"
2325
"os"
2426
"os/exec"
@@ -209,7 +211,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload
209211
}
210212

211213
s.actorLogger.EmitLifecycleLog("Actor starting", p.actorRef, p.actorUID, p.templateNS, p.templateName)
212-
if err := s.coldBootActor(ctx, p); err != nil {
214+
if err := s.coldBootActorRetrying(ctx, p); err != nil {
213215
return nil, err
214216
}
215217
s.actorLogger.EmitLifecycleLog("Actor started", p.actorRef, p.actorUID, p.templateNS, p.templateName)
@@ -229,6 +231,34 @@ type actorBootParams struct {
229231
assetPaths map[string]string
230232
}
231233

234+
// coldBootAttempts is how many times a cold boot is tried when the micro-VM
235+
// stops before the kata-agent answers. Two: one retry covers a transient guest
236+
// death (a contended host makes the guest's boot pathologically slow, and a
237+
// boot that stalls long enough is torn down guest-side), and beyond that the
238+
// fault is not transient and the caller should hear about it.
239+
const coldBootAttempts = 2
240+
241+
// coldBootActorRetrying cold-boots the actor, retrying if the micro-VM stopped
242+
// before the kata-agent answered.
243+
//
244+
// Retrying is safe there and nowhere else: a guest that never reached its agent
245+
// ran none of the actor's containers, so the attempt has no observable effect,
246+
// and coldBootActor's failure path tears the whole thing down (VMM, virtiofsds,
247+
// network, bundle mounts) before returning. It is also the only recovery — the
248+
// dead VM does not come back, so the alternative is failing the actor's resume.
249+
// Every retry is logged alongside the guest's boot diagnostics, so a guest that
250+
// dies at boot is never silent.
251+
func (s *AteomService) coldBootActorRetrying(ctx context.Context, p actorBootParams) error {
252+
for attempt := 1; ; attempt++ {
253+
err := s.coldBootActor(ctx, p)
254+
if err == nil || attempt >= coldBootAttempts || !errors.Is(err, errGuestStopped) {
255+
return err
256+
}
257+
slog.WarnContext(ctx, "Micro-VM stopped before the kata-agent answered; retrying cold boot",
258+
slog.String("id", p.actorUID), slog.Int("attempt", attempt), slog.Any("err", err))
259+
}
260+
}
261+
232262
// coldBootActor boots the actor's micro-VM from scratch and starts its
233263
// containers, registering the result in s.running. The caller holds s.lock and
234264
// owns the lifecycle logging.
@@ -392,9 +422,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re
392422
}
393423
ac, err := dialAgentRetry(ctx, vsockPath, 60*time.Second)
394424
if err != nil {
395-
if b, rerr := os.ReadFile(serialLog); rerr == nil {
396-
slog.ErrorContext(ctx, "agent dial failed; guest serial tail", slog.String("serial", tailString(string(b), 3000)))
397-
}
425+
logGuestBootDiagnostics(ctx, actorUID, serialLog)
398426
return fmt.Errorf("while dialing kata-agent: %w", err)
399427
}
400428
// The agent client must stay open past this RPC: the stdout/stderr forwarding
@@ -486,7 +514,7 @@ func (s *AteomService) stageOverlayLowers(ctx context.Context, rr resolvedRuntim
486514
return nil, fmt.Errorf("while staging overlay lower for %q: %w", c.name, err)
487515
}
488516
}
489-
vfsdLog, _ := os.OpenFile(filepath.Join(kata.VMDir(id), "virtiofsd.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
517+
vfsdLog, _ := os.OpenFile(virtiofsdLogPath(id), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
490518
vfsdCmd, err := kata.StartVirtiofsd(ctx, kata.VirtiofsdOptions{
491519
Binary: rr.virtiofsd,
492520
SocketPath: kata.VirtiofsdSocketPath(id),
@@ -658,12 +686,23 @@ func (s *AteomService) startActorLogForwarding(ac *kata.AgentClient, actorRef re
658686
go s.actorLogger.WrapContainerLogs(kata.NewStdioReader(context.Background(), ac, streamID, streamID, true), actorRef, actorUID, actorTemplateNamespace, actorTemplateName, containerName)
659687
}
660688

689+
// errGuestStopped reports that the micro-VM stopped before the kata-agent
690+
// answered. Callers that can start over (a cold boot has no observable side
691+
// effects until the agent runs the containers) retry on it.
692+
var errGuestStopped = errors.New("micro-VM stopped before the kata-agent answered")
693+
661694
// dialAgentRetry polls DialAgent until the kata-agent answers the hybrid-vsock
662695
// CONNECT (the socket file exists at boot, but the agent only listens once the
663696
// guest reaches kata-containers.target) or the overall timeout elapses. Each
664697
// attempt is capped at 5s (usually it fails fast with connection-refused while
665698
// the agent isn't listening yet; the cap only bounds a rare hung dial), then
666699
// waits 500ms before retrying — so steady-state polling is ~every 500ms, not 5s.
700+
//
701+
// A dial that fails with ENOENT ends the poll immediately as errGuestStopped:
702+
// callers wait for the socket to appear before dialing, and cloud-hypervisor
703+
// unlinks it when the VM stops (virtio-vsock device shutdown), so a socket that
704+
// has gone missing means the guest died. Polling on would only spend the rest
705+
// of the timeout to report a bare "no such file or directory".
667706
func dialAgentRetry(ctx context.Context, vsockPath string, timeout time.Duration) (*kata.AgentClient, error) {
668707
deadline := time.Now().Add(timeout)
669708
var lastErr error
@@ -674,6 +713,9 @@ func dialAgentRetry(ctx context.Context, vsockPath string, timeout time.Duration
674713
if err == nil {
675714
return ac, nil
676715
}
716+
if errors.Is(err, fs.ErrNotExist) {
717+
return nil, fmt.Errorf("%w (cloud-hypervisor removed %q): %w", errGuestStopped, vsockPath, err)
718+
}
677719
lastErr = err
678720
if time.Now().After(deadline) {
679721
return nil, lastErr
@@ -686,6 +728,29 @@ func dialAgentRetry(ctx context.Context, vsockPath string, timeout time.Duration
686728
}
687729
}
688730

731+
// logGuestBootDiagnostics dumps what the host recorded about a guest that never
732+
// reached the kata-agent: the console tail, where a guest-side panic or an early
733+
// power-off shows up, and each virtiofsd's log — cloud-hypervisor stops the VM
734+
// when a vhost-user backend dies, and that leaves the console silent.
735+
func logGuestBootDiagnostics(ctx context.Context, actorUID, serialLog string) {
736+
for _, l := range []struct{ name, path string }{
737+
{"serial", serialLog},
738+
{"virtiofsd", virtiofsdLogPath(actorUID)},
739+
{"virtiofsd-durable", durableVirtiofsdLogPath(actorUID)},
740+
} {
741+
b, err := os.ReadFile(l.path)
742+
if err != nil || len(b) == 0 {
743+
continue
744+
}
745+
slog.ErrorContext(ctx, "agent dial failed; guest boot diagnostics",
746+
slog.String("log", l.name), slog.String("tail", tailString(string(b), 3000)))
747+
}
748+
}
749+
750+
// virtiofsdLogPath is where the overlay RO lower's virtiofsd logs, under the
751+
// actor's VM dir alongside the sockets and the guest console.
752+
func virtiofsdLogPath(id string) string { return filepath.Join(kata.VMDir(id), "virtiofsd.log") }
753+
689754
// tailString returns the last n bytes of s (for logging a serial-console tail).
690755
func tailString(s string, n int) string {
691756
if len(s) <= n {

cmd/ateom-microvm/run_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//go:build linux
2+
3+
// Copyright 2026 Google LLC
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"errors"
22+
"net"
23+
"path/filepath"
24+
"testing"
25+
"time"
26+
)
27+
28+
// A vsock socket that has gone missing means cloud-hypervisor stopped the VM
29+
// (it unlinks the socket in the vsock device's shutdown), so the poll must give
30+
// up at once instead of spending the whole timeout on a guest that is gone.
31+
func TestDialAgentRetryGuestStopped(t *testing.T) {
32+
missing := filepath.Join(t.TempDir(), "clh.sock")
33+
34+
start := time.Now()
35+
_, err := dialAgentRetry(t.Context(), missing, 30*time.Second)
36+
elapsed := time.Since(start)
37+
38+
if !errors.Is(err, errGuestStopped) {
39+
t.Errorf("dialAgentRetry(%q) error = %v, want it to wrap errGuestStopped", missing, err)
40+
}
41+
if elapsed > 5*time.Second {
42+
t.Errorf("dialAgentRetry(%q) took %v; want it to give up immediately, not poll for the full timeout", missing, elapsed)
43+
}
44+
}
45+
46+
// The socket exists but nothing answers yet: that is every dial until the guest
47+
// reaches kata-containers.target, so it must keep polling to the timeout.
48+
func TestDialAgentRetryNotListeningYet(t *testing.T) {
49+
path := filepath.Join(t.TempDir(), "clh.sock")
50+
l, err := net.Listen("unix", path)
51+
if err != nil {
52+
t.Fatalf("failed to create test socket: %v", err)
53+
}
54+
// Leave the socket file behind, as cloud-hypervisor does while the VM runs:
55+
// connecting is then refused, which is what an unanswered CONNECT looks like.
56+
l.(*net.UnixListener).SetUnlinkOnClose(false)
57+
if err := l.Close(); err != nil {
58+
t.Fatalf("failed to close test listener: %v", err)
59+
}
60+
61+
const timeout = 700 * time.Millisecond
62+
start := time.Now()
63+
_, err = dialAgentRetry(t.Context(), path, timeout)
64+
elapsed := time.Since(start)
65+
66+
if err == nil {
67+
t.Fatalf("dialAgentRetry(%q) succeeded, want an error", path)
68+
}
69+
if errors.Is(err, errGuestStopped) {
70+
t.Errorf("dialAgentRetry(%q) error = %v, want a plain dial error (the guest is still running)", path, err)
71+
}
72+
if elapsed < timeout {
73+
t.Errorf("dialAgentRetry(%q) gave up after %v, want it to keep polling for at least %v", path, elapsed, timeout)
74+
}
75+
}
76+
77+
// A canceled context ends the poll with the context's error, so a caller that
78+
// gives up on the restore is not left waiting out the timeout.
79+
func TestDialAgentRetryContextCanceled(t *testing.T) {
80+
path := filepath.Join(t.TempDir(), "clh.sock")
81+
l, err := net.Listen("unix", path)
82+
if err != nil {
83+
t.Fatalf("failed to create test socket: %v", err)
84+
}
85+
l.(*net.UnixListener).SetUnlinkOnClose(false)
86+
if err := l.Close(); err != nil {
87+
t.Fatalf("failed to close test listener: %v", err)
88+
}
89+
90+
ctx, cancel := context.WithCancel(t.Context())
91+
cancel()
92+
93+
if _, err := dialAgentRetry(ctx, path, time.Minute); !errors.Is(err, context.Canceled) {
94+
t.Errorf("dialAgentRetry(%q) error = %v, want context.Canceled", path, err)
95+
}
96+
}

0 commit comments

Comments
 (0)