Files
visor/runsc/boot/fscheckpoint.go
2026-08-19 15:37:46 -07:00

606 lines
20 KiB
Go

// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package boot
import (
"bytes"
"fmt"
"io"
"strings"
"time"
specs "github.com/opencontainers/runtime-spec/specs-go"
"golang.org/x/sys/unix"
"google.golang.org/protobuf/proto"
"gvisor.dev/gvisor/pkg/cleanup"
"gvisor.dev/gvisor/pkg/context"
"gvisor.dev/gvisor/pkg/errors/linuxerr"
"gvisor.dev/gvisor/pkg/fd"
"gvisor.dev/gvisor/pkg/hostarch"
"gvisor.dev/gvisor/pkg/log"
"gvisor.dev/gvisor/pkg/sentry/checkpoint"
"gvisor.dev/gvisor/pkg/sentry/fscheckpoint"
fspb "gvisor.dev/gvisor/pkg/sentry/fscheckpoint/fscheckpoint_proto_go_proto"
"gvisor.dev/gvisor/pkg/sentry/kernel"
"gvisor.dev/gvisor/pkg/sentry/pgalloc"
"gvisor.dev/gvisor/pkg/sentry/state/checkpointfiles"
"gvisor.dev/gvisor/pkg/sentry/state/stateio"
"gvisor.dev/gvisor/pkg/sentry/state/stateipc"
"gvisor.dev/gvisor/pkg/sync"
"gvisor.dev/gvisor/pkg/unet"
"gvisor.dev/gvisor/pkg/urpc"
"gvisor.dev/gvisor/runsc/specutils"
"gvisor.dev/gvisor/runsc/version"
)
const (
annotationFSCheckpointPrefix = "dev.gvisor.internal.fscheckpoint."
// annotationFSCheckpointEnable indicates whether files under /proc/gvisor
// should be present in the container to allow the workload to trigger a
// filesystem checkpoint.
annotationFSCheckpointEnable = annotationFSCheckpointPrefix + "enable"
// annotationFSCheckpointPath is the path to the directory where the
// filesystem checkpoint files will be created. When present, it allows for
// the workload running inside to trigger a filesystem checkpoint without
// having to use the runsc CLI.
annotationFSCheckpointPath = annotationFSCheckpointPrefix + "path"
// annotationFSCheckpointResume indicates whether the sandbox should
// continue running after filesystem checkpoint saving triggered via
// /proc/gvisor. Optional, defaults to false.
annotationFSCheckpointResume = annotationFSCheckpointPrefix + "resume"
// annotationFSCheckpointDirect indicates whether filesystem checkpoint
// I/Os triggered via /proc/gvisor should use O_DIRECT. Optional, defaults
// to false.
annotationFSCheckpointDirect = annotationFSCheckpointPrefix + "direct"
// annotationFSCheckpointPaths is a comma-separated list of paths inside the
// containers to save. Optional.
annotationFSCheckpointPaths = annotationFSCheckpointPrefix + "paths"
)
// GetAnnotationFSCheckpointPath returns the filesystem checkpoint path
// specified in the container annotation. Return empty string if no annotation
// is specified.
func GetAnnotationFSCheckpointPath(spec *specs.Spec) string {
return spec.Annotations[annotationFSCheckpointPath]
}
// GetAnnotationFSCheckpointDirect returns true if filesystem checkpoint I/O
// controlled by the containing annotation should use O_DIRECT.
func GetAnnotationFSCheckpointDirect(spec *specs.Spec) bool {
return specutils.AnnotationToBool(spec, annotationFSCheckpointDirect)
}
// FSSave implements kernel.Saver.FSSave.
func (l *Loader) FSSave() error {
l.mu.Lock()
fsSaveFDs := l.fsSaveFDs
l.fsSaveFDs = nil
useCheckpointGofer := l.fsSaveCheckpointGofer
l.mu.Unlock()
if len(fsSaveFDs) == 0 {
return linuxerr.ENXIO
}
paths, err := parseFSCheckpointPaths(l.root.spec.Annotations[annotationFSCheckpointPaths])
if err != nil {
return err
}
args := FSSaveArgs{
ExitAfterSaving: !specutils.AnnotationToBool(l.root.spec, annotationFSCheckpointResume),
Paths: paths,
}
args.FilePayload.Files = fd.ReleaseToFiles(fsSaveFDs, "fs-checkpoint")
args.UseCheckpointGofer = useCheckpointGofer
opts := kernel.FSSaveOpts{
RunscVersion: version.Version(),
Paths: paths,
}
if err := setKernelFSSaveOptsFiles(&args, &opts); err != nil {
return err
}
return l.k.FSSave(context.Background(), &opts)
}
func parseFSCheckpointPaths(val string) ([]checkpoint.ResourceID, error) {
if val == "" {
return nil, nil
}
var paths []checkpoint.ResourceID
for _, part := range strings.Split(val, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
subparts := strings.SplitN(part, ":", 2)
if len(subparts) == 1 {
p := strings.TrimSpace(subparts[0])
if p == "" {
return nil, fmt.Errorf("empty path in fscheckpoint paths: %q", val)
}
paths = append(paths, checkpoint.ResourceID{Path: p})
} else {
c := strings.TrimSpace(subparts[0])
p := strings.TrimSpace(subparts[1])
if p == "" {
return nil, fmt.Errorf("empty path in fscheckpoint paths: %q", val)
}
paths = append(paths, checkpoint.ResourceID{ContainerName: c, Path: p})
}
}
return paths, nil
}
func convertToKernelFSSaveOpts(args *FSSaveArgs) (kernel.FSSaveOpts, error) {
opts := kernel.FSSaveOpts{
RunscVersion: version.Version(),
ExitAfterSaving: args.ExitAfterSaving,
Paths: args.Paths,
}
if err := setKernelFSSaveOptsFiles(args, &opts); err != nil {
return kernel.FSSaveOpts{}, err
}
return opts, nil
}
func setKernelFSSaveOptsFiles(args *FSSaveArgs, opts *kernel.FSSaveOpts) error {
if args.UseCheckpointGofer {
return setKernelFSSaveOptsFilesForCheckpointGofer(args, opts)
}
return setKernelFSSaveOptsFilesForLocalCheckpoint(args, opts)
}
func setKernelFSSaveOptsFilesForLocalCheckpoint(args *FSSaveArgs, opts *kernel.FSSaveOpts) error {
if len(args.FilePayload.Files) != 4 {
return fmt.Errorf("got %d files, want 4", len(args.FilePayload.Files))
}
manifestFile, err := args.ReleaseFD(0)
if err != nil {
return err
}
multiTarFile, err := args.ReleaseFD(1)
if err != nil {
return err
}
pagesMetadataFile, err := args.ReleaseFD(2)
if err != nil {
return err
}
pagesFile, err := args.ReleaseFD(3)
if err != nil {
return err
}
opts.ManifestFile = stateio.NewBufioWriteCloser(manifestFile)
opts.MultiTarFile = stateio.NewBufioWriteCloser(multiTarFile)
opts.PagesMetadataFile = stateio.NewBufioWriteCloser(pagesMetadataFile)
opts.PagesFile = stateio.NewPagesFileFDWriterDefault(int32(pagesFile.Release()))
return nil
}
func setKernelFSSaveOptsFilesForCheckpointGofer(args *FSSaveArgs, opts *kernel.FSSaveOpts) error {
clientFD, err := unix.Dup(int(args.Files[0].Fd()))
if err != nil {
return fmt.Errorf("failed to dup checkpoint gofer client FD: %w", err)
}
clientSock, err := unet.NewSocket(clientFD)
if err != nil {
unix.Close(clientFD)
return fmt.Errorf("failed to create unet.Socket for checkpoint gofer client FD: %w", err)
}
afc, err := stateipc.NewAsyncFileClient(urpc.NewClient(clientSock) /* transfers ownership */)
if err != nil {
return fmt.Errorf("failed to create stateipc client: %w", err)
}
defer afc.DecRef()
manifestFileAsync, err := afc.OpenWrite(checkpointfiles.FSCheckpointManifestFileName)
if err != nil {
return fmt.Errorf("failed to open manifest file: %w", err)
}
manifestFile, err := stateio.NewBufWriter(manifestFileAsync /* transfers ownership */, 2<<20 /* size = 2 MiB */)
if err != nil {
return fmt.Errorf("failed to buffer manifest file: %w", err)
}
closeCleanup := cleanup.Make(func() { manifestFile.Close() })
defer closeCleanup.Clean()
multiTarFileAsync, err := afc.OpenWrite(checkpointfiles.FSCheckpointMultiTarFileName)
if err != nil {
return fmt.Errorf("failed to open multi-tar file: %w", err)
}
multiTarFile, err := stateio.NewBufWriter(multiTarFileAsync /* transfers ownership */, 8<<20 /* size = 8 MiB */)
if err != nil {
return fmt.Errorf("failed to buffer multi-tar file: %w", err)
}
closeCleanup.Add(func() { multiTarFile.Close() })
pagesMetadataFileAsync, err := afc.OpenWrite(checkpointfiles.PagesMetadataFileName)
if err != nil {
return fmt.Errorf("failed to open pages metadata file: %w", err)
}
pagesMetadataFile, err := stateio.NewBufWriter(pagesMetadataFileAsync /* transfers ownership */, 8<<20 /* size = 8 MiB */)
if err != nil {
return fmt.Errorf("failed to buffer pages metadata file: %w", err)
}
closeCleanup.Add(func() { pagesMetadataFile.Close() })
pagesFile, err := afc.OpenWrite(checkpointfiles.PagesFileName)
if err != nil {
return fmt.Errorf("failed to open pages file: %w", err)
}
closeCleanup.Release()
opts.ManifestFile = manifestFile
opts.MultiTarFile = multiTarFile
opts.PagesMetadataFile = pagesMetadataFile
opts.PagesFile = pagesFile
return nil
}
// fsRestore holds the state of a filesystem restore.
type fsRestore struct {
wg sync.WaitGroup
// immutable
getPagesMetadata func() ([]byte, error)
getMultiTar func() ([]byte, error)
// immutable after wg.Wait()
manifestErr error
apfl *pgalloc.AsyncPagesFileLoad
mfs map[checkpoint.ResourceID]*fscheckpoint.MemoryFile
tmpfs map[checkpoint.ResourceID]*fscheckpoint.Tmpfs
waitMu sync.Mutex
waitMap map[string]*fsRestoreContainer // key is container ID
}
type fsRestoreContainer struct {
// These fields are protected by fsRestore.waitMu.
err error
asyncLoads int // number of MemoryFiles currently in async page loading
cond sync.Cond
}
// fsRestoreOpts holds options to startFSRestore.
type fsRestoreOpts struct {
// These correspond to files specified by the fscheckpoint package, and are
// all required.
ManifestFile io.ReadCloser
MultiTarFile io.ReadCloser
PagesMetadataFile io.ReadCloser
PagesFile stateio.AsyncReader
}
func makeFSRestoreOpts(args *Args) (fsRestoreOpts, error) {
if args.FSRestoreCheckpointGofer {
return makeFSRestoreOptsForCheckpointGofer(args)
}
return makeFSRestoreOptsForLocalCheckpoint(args)
}
func makeFSRestoreOptsForLocalCheckpoint(args *Args) (fsRestoreOpts, error) {
if len(args.FSRestoreFDs) != 4 {
return fsRestoreOpts{}, fmt.Errorf("got %d files in -fs-restore-fds, want 4", len(args.FSRestoreFDs))
}
return fsRestoreOpts{
ManifestFile: stateio.NewBufioReadCloser(args.FSRestoreFDs[0].ReleaseToFile(checkpointfiles.FSCheckpointManifestFileName)),
MultiTarFile: stateio.NewBufioReadCloser(args.FSRestoreFDs[1].ReleaseToFile(checkpointfiles.FSCheckpointMultiTarFileName)),
PagesMetadataFile: stateio.NewBufioReadCloser(args.FSRestoreFDs[2].ReleaseToFile(checkpointfiles.PagesMetadataFileName)),
PagesFile: stateio.NewPagesFileFDReaderDefault(int32(args.FSRestoreFDs[3].Release())),
}, nil
}
func makeFSRestoreOptsForCheckpointGofer(args *Args) (fsRestoreOpts, error) {
if len(args.FSRestoreFDs) != 1 {
return fsRestoreOpts{}, fmt.Errorf("got %d files in -fs-restore-fds, want 1", len(args.FSRestoreFDs))
}
clientFD := args.FSRestoreFDs[0].Release()
clientSock, err := unet.NewSocket(clientFD)
if err != nil {
unix.Close(clientFD)
return fsRestoreOpts{}, fmt.Errorf("failed to create unet.Socket for checkpoint gofer client FD: %w", err)
}
afc, err := stateipc.NewAsyncFileClient(urpc.NewClient(clientSock) /* transfers ownership */)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to create stateipc client: %w", err)
}
defer afc.DecRef()
manifestFileAsync, err := afc.OpenRead(checkpointfiles.FSCheckpointManifestFileName)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to open manifest file: %w", err)
}
manifestFile, err := stateio.NewBufReader(manifestFileAsync /* transfers ownership */, 2<<20 /* size = 2 MiB */)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to buffer manifest file: %w", err)
}
closeCleanup := cleanup.Make(func() { manifestFile.Close() })
defer closeCleanup.Clean()
multiTarFileAsync, err := afc.OpenRead(checkpointfiles.FSCheckpointMultiTarFileName)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to open multi-tar file: %w", err)
}
multiTarFile, err := stateio.NewBufReader(multiTarFileAsync /* transfers ownership */, 8<<20 /* size = 8 MiB */)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to buffer multi-tar file: %w", err)
}
closeCleanup.Add(func() { multiTarFile.Close() })
pagesMetadataFileAsync, err := afc.OpenRead(checkpointfiles.PagesMetadataFileName)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to open pages metadata file: %w", err)
}
pagesMetadataFile, err := stateio.NewBufReader(pagesMetadataFileAsync /* transfers ownership */, 8<<20 /* size = 8 MiB */)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to buffer pages metadata file: %w", err)
}
closeCleanup.Add(func() { pagesMetadataFile.Close() })
pagesFile, err := afc.OpenRead(checkpointfiles.PagesFileName)
if err != nil {
return fsRestoreOpts{}, fmt.Errorf("failed to open pages file: %w", err)
}
closeCleanup.Release()
return fsRestoreOpts{
ManifestFile: manifestFile,
MultiTarFile: multiTarFile,
PagesMetadataFile: pagesMetadataFile,
PagesFile: pagesFile,
}, nil
}
// startFSRestore takes ownership of resources in opts.
func startFSRestore(opts *fsRestoreOpts) (*fsRestore, error) {
fsr := &fsRestore{
mfs: make(map[checkpoint.ResourceID]*fscheckpoint.MemoryFile),
tmpfs: make(map[checkpoint.ResourceID]*fscheckpoint.Tmpfs),
waitMap: make(map[string]*fsRestoreContainer),
}
// TODO: NOLINT - Currently we read the whole pages metadata file into a
// []byte, then pass pieces of that []byte to MemoryFile construction. This
// is necessary because opts.PagesMetadataFile is io.Reader (read
// sequentially), and tmpfs filesystems and their private MemoryFiles may
// be restored in a different order than checkpoint order (disk-backed
// filestore files are not available until container creation).
//
// We could make opts.PagesMetadataFile io.ReaderAt to avoid this copy.
// However, when the multi-tar file is accessed via stateio.AsyncReader,
// this requires an implementation of io.ReaderAt that wraps
// stateio.AsyncReader, akin to stateio.BufReader. AsyncReader already
// supports random reads, but has a fixed maximum parallelism per
// AsyncReader that would need to be shared between readers. Furthermore,
// BufReader asynchronously fills its buffer with reads to minimize
// latency; our io.ReaderAt implementation would need to do something
// comparable to avoid regressions.
//
// Alternatively, we could implement io.ReaderAt by asynchronously
// buffering the whole file in memory, which is probably better overall
// (equivalent to what we are doing now, but permits reading parts of the
// file that have been read before the whole file is read) but requires
// adding a stateio.AsyncReader method to get file size.
//
// All of the above also applies to the multi-tar file.
readOnce := func(desc string, optsR *io.ReadCloser) func() ([]byte, error) {
r := *optsR
*optsR = nil
f := sync.OnceValues(func() ([]byte, error) {
timeStart := time.Now()
data, err := io.ReadAll(r)
dur := time.Since(timeStart)
// Close r immediately to release any memory used for buffering.
r.Close()
r = nil
if err == nil {
log.Infof("Read filesystem checkpoint %s (%d bytes) in %s", desc, len(data), dur)
}
return data, err
})
// Start reading immediately.
go f()
return f
}
fsr.getPagesMetadata = readOnce("pages metadata file", &opts.PagesMetadataFile)
fsr.getMultiTar = readOnce("multi-tar file", &opts.MultiTarFile)
// Read and handle the manifest in parallel.
fsr.manifestErr = fmt.Errorf("loading manifest panicked")
fsr.wg.Go(func() {
defer func() {
if opts.ManifestFile != nil {
opts.ManifestFile.Close()
opts.ManifestFile = nil
}
if opts.PagesFile != nil {
opts.PagesFile.Close()
opts.PagesFile = nil
}
}()
fsr.manifestErr = func() error {
var manifest fscheckpoint.Manifest
timeStart := time.Now()
manifestData, err := io.ReadAll(opts.ManifestFile)
if err != nil {
return fmt.Errorf("failed to read manifest: %w", err)
}
var pb fspb.Manifest
if err := proto.Unmarshal(manifestData, &pb); err != nil {
return fmt.Errorf("failed to decode proto manifest: %w", err)
}
manifest = fscheckpoint.FromProto(&pb)
log.Infof("Read filesystem checkpoint manifest in %s", time.Since(timeStart))
if manifest.Version > 1 {
return fmt.Errorf("unsupported filesystem checkpoint version: %d", manifest.Version)
}
log.Infof("Restoring from proto manifest (version %d) created by runsc version %q", manifest.Version, manifest.RunscVersion)
if manifest.PageSize != hostarch.PageSize {
return fmt.Errorf("filesystem checkpoint page size %d does not match current page size %d", manifest.PageSize, hostarch.PageSize)
}
if endian := hostarch.EndianString(); manifest.Endian != endian {
return fmt.Errorf("filesystem checkpoint endianness %q does not match current endianness %q", manifest.Endian, endian)
}
apfl, err := pgalloc.StartAsyncPagesFileLoad(opts.PagesFile /* transfers ownership */, func(err error) {
if err != nil {
log.Warningf("Failed to load filesystem checkpoint pages: %v", err)
} else if log.IsLogging(log.Debug) {
log.Debugf("Finished loading filesystem checkpoint pages")
}
}, nil)
opts.PagesFile = nil
if err != nil {
return fmt.Errorf("failed to start async page loading: %w", err)
}
fsr.apfl = apfl
for i := range manifest.MemoryFiles {
mmf := &manifest.MemoryFiles[i]
fsr.mfs[mmf.ResourceID] = mmf
}
for i := range manifest.Tmpfs {
mt := &manifest.Tmpfs[i]
fsr.tmpfs[mt.ResourceID] = mt
}
// Note that apfl.MemoryFilesDone() will never be called. This is
// because if a container fails and is restarted, we also want to
// restore the filesystem of the restarted container, so we need to
// keep filesystem restore operational indefinitely.
return nil
}()
})
return fsr, nil
}
// +checklocks:fsr.waitMu
func (fsr *fsRestore) ensureContainer(cid string) *fsRestoreContainer {
c := fsr.waitMap[cid]
if c == nil {
c = &fsRestoreContainer{}
c.cond.L = &fsr.waitMu
fsr.waitMap[cid] = c
}
return c
}
func (c *fsRestoreContainer) setError(err error) error {
if c.err == nil && err != nil {
c.err = err
c.cond.Broadcast()
}
return err
}
func (fsr *fsRestore) memoryFileLoadArgs(id checkpoint.ResourceID, cid string) (io.Reader, uint64, func(error), error) {
if fsr == nil {
return nil, 0, func(error) {}, nil
}
fsr.wg.Wait()
if fsr.manifestErr != nil {
return nil, 0, nil, fsr.manifestErr
}
mmf := fsr.mfs[id]
if mmf == nil {
return nil, 0, func(error) {}, nil
}
pagesMetadata, err := fsr.getPagesMetadata()
fsr.waitMu.Lock()
defer fsr.waitMu.Unlock()
c := fsr.ensureContainer(cid)
if mmf.PagesMetadataEnd > uint64(len(pagesMetadata)) {
if err != nil {
return nil, 0, nil, c.setError(fmt.Errorf("failed to read pages metadata: %w", err))
}
return nil, 0, nil, c.setError(fmt.Errorf("MemoryFile %q has pages metadata range [%d, %d) beyond pages metadata file size %d", mmf.ResourceID, mmf.PagesMetadataStart, mmf.PagesMetadataEnd, len(pagesMetadata)))
}
c.asyncLoads++
return bytes.NewReader(pagesMetadata[mmf.PagesMetadataStart:mmf.PagesMetadataEnd]), mmf.PagesStart, func(err error) {
fsr.waitMu.Lock()
defer fsr.waitMu.Unlock()
c.asyncLoads--
switch {
case err != nil:
if c.err == nil {
c.err = err
}
fallthrough
case c.asyncLoads == 0:
c.cond.Broadcast()
}
}, nil
}
func (fsr *fsRestore) tmpfsSourceTar(id checkpoint.ResourceID, cid string) (io.ReadCloser, error) {
if fsr == nil {
return nil, nil
}
fsr.wg.Wait()
if fsr.manifestErr != nil {
return nil, fsr.manifestErr
}
mt := fsr.tmpfs[id]
if mt == nil {
return nil, nil
}
multiTar, err := fsr.getMultiTar()
fsr.waitMu.Lock()
defer fsr.waitMu.Unlock()
if mt.TarEnd <= uint64(len(multiTar)) {
return io.NopCloser(bytes.NewReader(multiTar[mt.TarStart:mt.TarEnd])), nil
}
c := fsr.ensureContainer(cid)
if err != nil {
return nil, c.setError(fmt.Errorf("failed to read tar archive: %w", err))
}
return nil, c.setError(fmt.Errorf("tmpfs %q has tar range [%d, %d) beyond multi-tar file size %d", mt.ResourceID, mt.TarStart, mt.TarEnd, len(multiTar)))
}
// wait blocks until either all filesystems have been restored for the
// container with the given ID, or an error occurs while restoring filesystems
// for that container.
func (fsr *fsRestore) wait(cid string) error {
if fsr == nil {
return fmt.Errorf("filesystem restore is not enabled")
}
fsr.wg.Wait()
if fsr.manifestErr != nil {
return fsr.manifestErr
}
fsr.waitMu.Lock()
defer fsr.waitMu.Unlock()
c := fsr.waitMap[cid]
if c == nil {
return fmt.Errorf("no filesystems restored for container %s", cid)
}
for {
if c.err != nil {
return c.err
}
if c.asyncLoads == 0 {
return nil
}
c.cond.Wait()
}
}