@@ -18,7 +18,9 @@ package main
1818
1919import (
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".
667706func 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).
690755func tailString (s string , n int ) string {
691756 if len (s ) <= n {
0 commit comments