From 186397511b62120b4b99157005268339be84e796 Mon Sep 17 00:00:00 2001 From: Samuel Karp Date: Thu, 4 Jun 2026 04:39:15 +0000 Subject: [PATCH 1/3] cri: validate CRIU availability and version early Perform an early validation check on both container checkpoint and restore paths to fail-fast if the CRIU binary is missing or is older than the minimum required version (3.16.0). To support runtime-configured environments, the validation respects the custom PATH from the shim manager environment if configured, skipping any relative paths to avoid incorrect daemon-relative resolution. If not configured, it falls back to a standard system PATH lookup. The check result is cached using sync.Once to prevent redundant process spawning. Assisted-by: Antigravity Signed-off-by: Samuel Karp --- core/runtime/v2/shim_manager.go | 10 +++ .../cri/server/container_checkpoint_linux.go | 70 +++++++++++++--- .../server/container_checkpoint_linux_test.go | 81 +++++++++++++++++++ internal/cri/server/service.go | 9 +++ plugins/cri/cri.go | 19 +++++ 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/core/runtime/v2/shim_manager.go b/core/runtime/v2/shim_manager.go index 7cf9efff950af..09e15c3368c4d 100644 --- a/core/runtime/v2/shim_manager.go +++ b/core/runtime/v2/shim_manager.go @@ -194,6 +194,16 @@ func (m *ShimManager) ID() string { return plugins.ShimPlugin.String() + ".manager" } +// Env returns the environment configured for the shim manager. +func (m *ShimManager) Env() []string { + if m.env == nil { + return nil + } + cp := make([]string, len(m.env)) + copy(cp, m.env) + return cp +} + // Start launches a new shim instance func (m *ShimManager) Start(ctx context.Context, id string, bundle *Bundle, opts runtime.CreateOpts) (_ ShimInstance, retErr error) { shouldInvokeShimBinary := false diff --git a/internal/cri/server/container_checkpoint_linux.go b/internal/cri/server/container_checkpoint_linux.go index e0b15331aed2f..42bc7dd13aa38 100644 --- a/internal/cri/server/container_checkpoint_linux.go +++ b/internal/cri/server/container_checkpoint_linux.go @@ -26,11 +26,13 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "strings" "time" crmetadata "github.com/checkpoint-restore/checkpointctl/lib" + criu "github.com/checkpoint-restore/go-criu/v7" "github.com/checkpoint-restore/go-criu/v7/utils" "github.com/containerd/containerd/api/types/runc/options" "github.com/containerd/containerd/v2/client" @@ -135,6 +137,55 @@ func assertCheckpointDirSafe(root string) error { }) } +func (c *criService) checkCriu() error { + c.checkCriuOnce.Do(func() { + c.checkCriuErr = c.doCheckCriu() + }) + return c.checkCriuErr +} + +func (c *criService) doCheckCriu() error { + path := resolveCriuPath(c.shimPath) + if path == "" { + return errors.New("criu binary not found in shim path or system PATH") + } + client := criu.MakeCriu() + client.SetCriuPath(path) + version, err := client.GetCriuVersion() + if err != nil { + return fmt.Errorf("failed to retrieve criu version: %w", err) + } + if version < utils.PodCriuVersion { + return fmt.Errorf("checkpoint/restore requires at least CRIU %d, current version is %d", utils.PodCriuVersion, version) + } + return nil +} + +func resolveCriuPath(customPath string) string { + if customPath != "" { + // This logic is Linux-specific. If CRIU is ever supported on other + // operating systems, path lookup will need to respect that OS's + // conventions. + for _, dir := range filepath.SplitList(customPath) { + if !filepath.IsAbs(dir) { + continue + } + criuPath := filepath.Join(dir, "criu") + if fi, err := os.Stat(criuPath); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 { + return criuPath + } + } + return "" + } + if criuPath, err := exec.LookPath("criu"); err == nil { + if absPath, err := filepath.Abs(criuPath); err == nil { + return absPath + } + return criuPath + } + return "" +} + // checkIfCheckpointOCIImage returns checks if the input refers to a checkpoint image. // It returns the StorageImageID of the image the input resolves to, nil otherwise. func (c *criService) checkIfCheckpointOCIImage(ctx context.Context, input string) (string, error) { @@ -189,6 +240,10 @@ func (c *criService) CRImportCheckpoint( sandbox *sandbox.Sandbox, sandboxConfig *runtime.PodSandboxConfig, ) (ctrID string, retErr error) { + if err := c.checkCriu(); err != nil { + return "", fmt.Errorf("checkpoint restore is not enabled: %w", err) + } + var mountPoint string start := time.Now() // Ensure that the image to restore the checkpoint from has been provided. @@ -539,18 +594,9 @@ func (c *criService) CRImportCheckpoint( func (c *criService) CheckpointContainer(ctx context.Context, r *runtime.CheckpointContainerRequest) (*runtime.CheckpointContainerResponse, error) { start := time.Now() - if err := utils.CheckForCriu(utils.PodCriuVersion); err != nil { - errorMessage := fmt.Sprintf( - "CRIU binary not found or too old (<%d). Failed to checkpoint container %q", - utils.PodCriuVersion, - r.GetContainerId(), - ) - log.G(ctx).WithError(err).Error(errorMessage) - return nil, fmt.Errorf( - "%s: %w", - errorMessage, - err, - ) + if err := c.checkCriu(); err != nil { + log.G(ctx).WithError(err).Errorf("Failed to checkpoint container %q", r.GetContainerId()) + return nil, fmt.Errorf("failed to checkpoint container %q: %w", r.GetContainerId(), err) } criContainerStatus, err := c.ContainerStatus(ctx, &runtime.ContainerStatusRequest{ diff --git a/internal/cri/server/container_checkpoint_linux_test.go b/internal/cri/server/container_checkpoint_linux_test.go index 65c887f81a5a9..32cc466aeffac 100644 --- a/internal/cri/server/container_checkpoint_linux_test.go +++ b/internal/cri/server/container_checkpoint_linux_test.go @@ -235,3 +235,84 @@ func TestFilterAndMergeAnnotations(t *testing.T) { }) } } + +func TestResolveCriuPath(t *testing.T) { + tempDir, err := os.MkdirTemp("", "criu-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Create a dummy executable criu + execDir := filepath.Join(tempDir, "bin-exec") + if err := os.MkdirAll(execDir, 0755); err != nil { + t.Fatal(err) + } + execPath := filepath.Join(execDir, "criu") + if err := os.WriteFile(execPath, []byte("dummy"), 0755); err != nil { + t.Fatal(err) + } + + // Create a dummy non-executable criu + nonExecDir := filepath.Join(tempDir, "bin-nonexec") + if err := os.MkdirAll(nonExecDir, 0755); err != nil { + t.Fatal(err) + } + nonExecPath := filepath.Join(nonExecDir, "criu") + if err := os.WriteFile(nonExecPath, []byte("dummy"), 0644); err != nil { + t.Fatal(err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relDir, err := filepath.Rel(wd, execDir) + if err != nil { + t.Fatal(err) + } + + // Mock the system PATH to point to our executable directory + t.Setenv("PATH", execDir) + + tests := []struct { + name string + customPath string + expectedPath string + }{ + { + name: "custom PATH with executable criu", + customPath: execDir, + expectedPath: execPath, + }, + { + name: "custom PATH with non-executable criu", + customPath: nonExecDir, + expectedPath: "", + }, + { + name: "multiple directories in PATH, executable in second", + customPath: nonExecDir + string(filepath.ListSeparator) + execDir, + expectedPath: execPath, + }, + { + name: "custom PATH with relative directory is skipped", + customPath: relDir, + expectedPath: "", + }, + { + name: "empty customPath falls back to system PATH", + customPath: "", + expectedPath: execPath, // Falls back to system PATH (mocked to execDir) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := resolveCriuPath(tt.customPath) + if path != tt.expectedPath { + t.Errorf("expected %s, got %s", tt.expectedPath, path) + } + }) + } +} diff --git a/internal/cri/server/service.go b/internal/cri/server/service.go index c578aaf3f38a0..5211f21f90b90 100644 --- a/internal/cri/server/service.go +++ b/internal/cri/server/service.go @@ -169,6 +169,11 @@ type criService struct { runtimeFeatures *runtime.RuntimeFeatures // statsCollector collects CPU stats in background for UsageNanoCores calculation statsCollector *StatsCollector + // shimPath is the custom PATH environment variable value from the shim manager + shimPath string + + checkCriuOnce sync.Once //nolint:nolintlint,unused // Ignore on non-Linux + checkCriuErr error //nolint:nolintlint,unused // Ignore on non-Linux } type CRIServiceOptions struct { @@ -187,6 +192,9 @@ type CRIServiceOptions struct { // // TODO: Replace this gradually with directly configured instances Client *containerd.Client + + // ShimPath is the custom PATH environment variable value from the shim manager + ShimPath string } // NewCRIService returns a new instance of CRIService @@ -214,6 +222,7 @@ func NewCRIService(options *CRIServiceOptions) (CRIService, runtime.RuntimeServi sandboxService: newCriSandboxService(&config, options.SandboxControllers), runtimeHandlers: make(map[string]*runtime.RuntimeHandler), statsCollector: statsCollector, + shimPath: options.ShimPath, } // TODO: Make discard time configurable diff --git a/plugins/cri/cri.go b/plugins/cri/cri.go index 8531935dca50c..8a8fce4965f63 100644 --- a/plugins/cri/cri.go +++ b/plugins/cri/cri.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/containerd/log" "github.com/containerd/plugin" @@ -58,6 +59,7 @@ func init() { plugins.SandboxStorePlugin, plugins.TransferPlugin, plugins.WarningPlugin, + plugins.ShimPlugin, }, Config: &defaultConfig, ConfigMigration: configMigration, @@ -131,6 +133,22 @@ func initCRIService(ic *plugin.InitContext) (any, error) { return nil, fmt.Errorf("failed to get streaming config: %w", err) } + var shimPath string + shimPlugin, err := ic.GetSingle(plugins.ShimPlugin) + if err != nil { + return nil, fmt.Errorf("failed to get shim plugin: %w", err) + } + if hasEnv, ok := shimPlugin.(interface{ Env() []string }); ok { + env := hasEnv.Env() + for i := len(env) - 1; i >= 0; i-- { + // iterate backwards to grab the last PATH= + if path, ok := strings.CutPrefix(env[i], "PATH="); ok { + shimPath = path + break + } + } + } + options := &server.CRIServiceOptions{ RuntimeService: runtimeSvc, ImageService: imageSvc, @@ -138,6 +156,7 @@ func initCRIService(ic *plugin.InitContext) (any, error) { NRI: getNRIAPI(ic), Client: client, SandboxControllers: sbControllers, + ShimPath: shimPath, } is := criImagePlugin.(imageService).GRPCService() From 06495733b2c3b21433ec6e5d6e2e329726e3a181 Mon Sep 17 00:00:00 2001 From: Samuel Karp Date: Thu, 4 Jun 2026 20:40:00 +0000 Subject: [PATCH 2/3] cri: add enable_criu configuration option Add a new `enable_criu` configuration option under CRI plugin runtime settings. When set to false, any checkpoint or restore request will fail fast with an error indicating that CRIU support is disabled by configuration. `enable_criu` currently defaults to true. Add an integration test script to verify that setting `enable_criu` to false in containerd configuration successfully disables checkpoint and restore operations and fails fast. Assisted-by: Antigravity Signed-off-by: Samuel Karp --- .github/workflows/ci.yml | 9 + .../checkpoint-restore-cri-disable-test.sh | 303 ++++++++++++++++++ docs/cri/config.md | 1 + internal/cri/config/config.go | 4 + internal/cri/config/config_test.go | 9 + internal/cri/config/config_unix.go | 1 + internal/cri/config/config_windows.go | 1 + internal/cri/config/enable_criu_linux_test.go | 84 +++++ .../cri/server/container_checkpoint_linux.go | 3 + .../server/container_checkpoint_linux_test.go | 53 +++ 10 files changed, 468 insertions(+) create mode 100755 contrib/checkpoint/checkpoint-restore-cri-disable-test.sh create mode 100644 internal/cri/config/enable_criu_linux_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 663747fa90fb8..f66c79dc0372b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,6 +545,15 @@ jobs: env sudo -E PATH=$PATH ./contrib/checkpoint/checkpoint-restore-cri-test.sh + - if: matrix.os != 'ubuntu-24.04-arm' + name: Checkpoint/Restore Disable via CRI + env: + TEST_RUNTIME: ${{ matrix.runtime }} + CGROUP_DRIVER: ${{ matrix.cgroup_driver }} + run: | + env + sudo -E PATH=$PATH ./contrib/checkpoint/checkpoint-restore-cri-disable-test.sh + # Log the status of this VM to investigate issues like # https://github.com/containerd/containerd/issues/4969 - name: Host Status diff --git a/contrib/checkpoint/checkpoint-restore-cri-disable-test.sh b/contrib/checkpoint/checkpoint-restore-cri-disable-test.sh new file mode 100755 index 0000000000000..1fd84f2cfe0ec --- /dev/null +++ b/contrib/checkpoint/checkpoint-restore-cri-disable-test.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash + +# Copyright The containerd 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. + +set -eu -o pipefail + +DIR="$(dirname "${0}")" +cd "${DIR}" +if ! command -v crictl >/dev/null 2>&1; then + echo >&2 "ERROR: crictl binary not found" + exit 1 +fi + +crictl() { + command crictl --timeout 60s "$@" +} +TESTDIR=$(mktemp -d -p "${PWD}") +SUCCESS=0 +CRIU_PATH=$(command -v criu || true) +CRIU_DIR="" +if [ -n "${CRIU_PATH}" ]; then + CRIU_DIR=$(dirname "${CRIU_PATH}") +fi + +function get_cleaned_path() { + if [ -z "${CRIU_DIR}" ]; then + echo "$PATH" + return + fi + local real_criu_dir + real_criu_dir=$(realpath "${CRIU_DIR}") + local shadow_dir="${TESTDIR}/shadow_path" + mkdir -p "${shadow_dir}" + for file in "${real_criu_dir}"/*; do + if [ -x "$file" ] && [ "$(basename "$file")" != "criu" ]; then + ln -s "$file" "${shadow_dir}/" || true + fi + done + local cleaned="" + local IFS=':' + for dir in $PATH; do + if [ -d "$dir" ]; then + local real_dir + real_dir=$(realpath "$dir") + if [ "${real_dir}" == "${real_criu_dir}" ]; then + cleaned="${cleaned:+$cleaned:}${shadow_dir}" + else + cleaned="${cleaned:+$cleaned:}$dir" + fi + fi + done + echo "${cleaned}" +} + +function cleanup() { + crictl ps -a || true + stop_containerd + if [ "${SUCCESS}" == "1" ]; then + echo PASS + else + echo "--> containerd logs" + if [ -f "${TESTDIR}/containerd.log" ]; then + sed 's/^/----> \t/' <"${TESTDIR}/containerd.log" + fi + echo FAIL + fi + find "${TESTDIR}" -name shm -type d -exec umount {} + >/dev/null 2>&1 || true + find "${TESTDIR}" -name rootfs -type d -exec umount {} + >/dev/null 2>&1 || true + rm -rf "${TESTDIR}" || true +} +trap cleanup EXIT +export CONTAINERD_ADDRESS="$TESTDIR/c.sock" +export CONTAINER_RUNTIME_ENDPOINT="unix://${CONTAINERD_ADDRESS}" +TEST_IMAGE=ghcr.io/containerd/alpine +TESTDATA=testdata +function start_containerd() { + local enable_criu="$1" + local use_path="$2" + echo "--> Starting containerd with enable_criu=${enable_criu}" + cat >"${TESTDIR}/config.toml" <>"${TESTDIR}/config.toml" <>"${TESTDIR}/config.toml" <> "${TESTDIR}/containerd.log" + if [ -n "${use_path}" ]; then + env PATH="${use_path}" ../../bin/containerd \ + --address "${TESTDIR}/c.sock" \ + --config "${TESTDIR}/config.toml" \ + --root "${TESTDIR}/root" \ + --state "${TESTDIR}/state" \ + --log-level trace >>"${TESTDIR}/containerd.log" 2>&1 & + else + ../../bin/containerd \ + --address "${TESTDIR}/c.sock" \ + --config "${TESTDIR}/config.toml" \ + --root "${TESTDIR}/root" \ + --state "${TESTDIR}/state" \ + --log-level trace >>"${TESTDIR}/containerd.log" 2>&1 & + fi + echo $! > "${TESTDIR}/containerd.pid" + retry_counter=0 + max_retries=10 + while true; do + ((retry_counter += 1)) + if crictl info >/dev/null 2>&1; then + break + fi + sleep 1 + if [ "${retry_counter}" -gt "${max_retries}" ]; then + echo "--> Failed to start containerd" + exit 1 + fi + done +} +function stop_containerd() { + echo "--> Stopping containerd..." + if [ -f "${TESTDIR}/containerd.pid" ]; then + local pid + pid=$(cat "${TESTDIR}/containerd.pid") + echo "--> Killing containerd PID ${pid}..." + kill -15 "${pid}" || true + sleep 2 + if kill -0 "${pid}" 2>/dev/null; then + echo "--> containerd PID ${pid} still running, force killing..." + kill -9 "${pid}" || true + fi + rm -f "${TESTDIR}/containerd.pid" + else + echo "--> No containerd.pid found, using pkill..." + pkill -x containerd || true + sleep 2 + fi +} +function setup_container() { + crictl pull "${TEST_IMAGE}" >/dev/null + POD_JSON=$(mktemp) + jq ".log_directory=\"${TESTDIR}\"" "$TESTDATA"/sandbox_config.json >"$POD_JSON" + pod_id=$(crictl runp "$POD_JSON") + ctr_id=$(crictl create "$pod_id" "$TESTDATA"/container_sleep.json "$POD_JSON") + crictl start "$ctr_id" >/dev/null + rm -f "$POD_JSON" + echo "${pod_id}:${ctr_id}" +} +function cleanup_container() { + local pod_id="$1" + local ctr_id="$2" + crictl rm -f "${ctr_id}" >/dev/null 2>&1 || true + crictl rmp -f "${pod_id}" >/dev/null 2>&1 || true + crictl rmi "${TEST_IMAGE}" >/dev/null 2>&1 || true +} + +# --- Test Executions --- +rm -f "${TESTDIR}/containerd.log" + +# ============================================================================== +# Group 1 (Configuration: enable_criu = false, normal PATH) +# ============================================================================== +start_containerd "false" "" + +# Test 1: Checkpoint fails fast when enable_criu = false, and the source container remains running. +ids=$(setup_container) +pod_id=$(echo "$ids" | cut -d: -f1) +ctr_id=$(echo "$ids" | cut -d: -f2) +set +e +output=$(crictl checkpoint --export="$TESTDIR"/cp.tar "${ctr_id}" 2>&1) +exit_code=$? +set -e +if [ $exit_code -eq 0 ] || [[ ! "$output" =~ "criu support is disabled by configuration" ]]; then + echo "ERROR: Test 1 failed (checkpoint did not fail fast). Output: $output" + exit 1 +fi +state=$(crictl inspect "${ctr_id}" | jq -r '.status.state') +if [ "$state" != "CONTAINER_RUNNING" ]; then + echo "ERROR: Test 1 failed (source container state is not RUNNING). State: $state" + exit 1 +fi +echo "PASS: Test 1: Checkpoint fails fast and source container remains running" +cleanup_container "$pod_id" "$ctr_id" + +# Test 2: Restore fails fast when enable_criu = false. +POD_JSON=$(mktemp) +jq ".log_directory=\"${TESTDIR}\"" "$TESTDATA"/sandbox_config.json >"$POD_JSON" +pod_id=$(crictl runp "$POD_JSON") +touch "$TESTDIR/dummy-checkpoint.tar" +RESTORE_JSON=$(mktemp) +jq ".image.image=\"$TESTDIR/dummy-checkpoint.tar\"" "$TESTDATA"/container_sleep.json >"$RESTORE_JSON" +set +e +output=$(crictl create "$pod_id" "$RESTORE_JSON" "$POD_JSON" 2>&1) +exit_code=$? +set -e +rm -f "$RESTORE_JSON" "$POD_JSON" +if [ $exit_code -eq 0 ] || [[ ! "$output" =~ "criu support is disabled by configuration" ]]; then + echo "ERROR: Test 2 failed (restore did not fail fast). Output: $output" + exit 1 +fi +echo "PASS: Test 2: Restore fails fast when enable_criu = false" +crictl rmp -f "$pod_id" >/dev/null 2>&1 || true + +stop_containerd + +# ============================================================================== +# Group 2 (Configuration: enable_criu = true, normal PATH) +# ============================================================================== +start_containerd "true" "" + +# Test 3: Normal checkpoint and restore preserves container state. +ids=$(setup_container) +pod_id=$(echo "$ids" | cut -d: -f1) +ctr_id=$(echo "$ids" | cut -d: -f2) +crictl exec "$ctr_id" touch /root/state_file +rm -f "$TESTDIR"/state_checkpoint.tar +crictl checkpoint --export="$TESTDIR"/state_checkpoint.tar "${ctr_id}" +cleanup_container "$pod_id" "$ctr_id" + +POD_JSON=$(mktemp) +jq ".log_directory=\"${TESTDIR}\"" "$TESTDATA"/sandbox_config.json >"$POD_JSON" +pod_id=$(crictl runp "$POD_JSON") +RESTORE_JSON=$(mktemp) +jq ".image.image=\"$TESTDIR/state_checkpoint.tar\"" "$TESTDATA"/container_sleep.json >"$RESTORE_JSON" +restored_ctr_id=$(crictl create "$pod_id" "$RESTORE_JSON" "$POD_JSON") +rm -f "$RESTORE_JSON" "$POD_JSON" +crictl start "$restored_ctr_id" +set +e +crictl exec "$restored_ctr_id" ls /root/state_file >/dev/null 2>&1 +exit_code=$? +set -e +if [ $exit_code -ne 0 ]; then + echo "ERROR: Test 3 failed (state_file not found in restored container)." + exit 1 +fi +echo "PASS: Test 3: Normal checkpoint and restore preserves container state" +cleanup_container "$pod_id" "$restored_ctr_id" + +stop_containerd + +# ============================================================================== +# Group 3 (Configuration: enable_criu omitted/defaults, normal PATH) +# ============================================================================== +start_containerd "omit" "" + +# Test 4: enable_criu omitted from configuration defaults to true (allowing checkpoint/restore). +ids=$(setup_container) +pod_id=$(echo "$ids" | cut -d: -f1) +ctr_id=$(echo "$ids" | cut -d: -f2) +rm -f "$TESTDIR"/omitted_checkpoint.tar +crictl checkpoint --export="$TESTDIR"/omitted_checkpoint.tar "${ctr_id}" +echo "PASS: Test 4: enable_criu omitted from configuration defaults to true" +cleanup_container "$pod_id" "$ctr_id" + +stop_containerd + +# ============================================================================== +# Group 4 (Configuration: enable_criu = true, cleaned PATH without CRIU) +# ============================================================================== +if [ -n "${CRIU_DIR}" ]; then + CLEANED_PATH=$(get_cleaned_path) + + start_containerd "true" "${CLEANED_PATH}" + + # Test 5: CRIU missing from PATH, enable_criu = true. + # Verifies that when CRIU is enabled but missing, it fails with the binary missing error. + ids=$(setup_container) + pod_id=$(echo "$ids" | cut -d: -f1) + ctr_id=$(echo "$ids" | cut -d: -f2) + set +e + output=$(crictl checkpoint --export="$TESTDIR"/cp.tar "${ctr_id}" 2>&1) + exit_code=$? + set -e + if [ $exit_code -eq 0 ] || [[ ! "$output" =~ "criu binary not found" ]]; then + echo "ERROR: Test 5 failed. Output: $output" + exit 1 + fi + echo "PASS: Test 5: Fails with binary not found as expected when enable_criu = true" + cleanup_container "$pod_id" "$ctr_id" + + stop_containerd +fi + +SUCCESS=1 diff --git a/docs/cri/config.md b/docs/cri/config.md index 1a3dc61871f0a..66e35154afd42 100644 --- a/docs/cri/config.md +++ b/docs/cri/config.md @@ -274,6 +274,7 @@ version = 3 ignore_deprecation_warnings = [] stats_collect_period = '1s' stats_retention_period = '2m' + enable_criu = true [plugins.'io.containerd.cri.v1.runtime'.containerd] default_runtime_name = 'runc' diff --git a/internal/cri/config/config.go b/internal/cri/config/config.go index ecfbcab9b0d49..623e4567a5f3b 100644 --- a/internal/cri/config/config.go +++ b/internal/cri/config/config.go @@ -447,6 +447,10 @@ type RuntimeConfig struct { // https://golang.org/pkg/time/#ParseDuration // Default: "2m" StatsRetentionPeriod string `toml:"stats_retention_period" json:"statsRetentionPeriod"` + + // EnableCRIU enables CRIU (Checkpoint/Restore In Userspace) support. + // When set to false, checkpoint/restore operations will be disabled. + EnableCRIU *bool `toml:"enable_criu" json:"enableCRIU"` } // X509KeyPairStreaming contains the x509 configuration for streaming diff --git a/internal/cri/config/config_test.go b/internal/cri/config/config_test.go index e0054c1cb16bc..9b8edb6682751 100644 --- a/internal/cri/config/config_test.go +++ b/internal/cri/config/config_test.go @@ -531,3 +531,12 @@ func TestCheckLocalImagePullConfigs(t *testing.T) { }) } } + +func TestDefaultConfigEnableCRIU(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("only supported on Linux") + } + cfg := DefaultRuntimeConfig() + assert.NotNil(t, cfg.EnableCRIU) + assert.True(t, *cfg.EnableCRIU) +} diff --git a/internal/cri/config/config_unix.go b/internal/cri/config/config_unix.go index 1c94ff53546d9..891fa08439740 100644 --- a/internal/cri/config/config_unix.go +++ b/internal/cri/config/config_unix.go @@ -109,5 +109,6 @@ func DefaultRuntimeConfig() RuntimeConfig { DrainExecSyncIOTimeout: "0s", EnableUnprivilegedPorts: true, EnableUnprivilegedICMP: true, + EnableCRIU: func() *bool { v := true; return &v }(), } } diff --git a/internal/cri/config/config_windows.go b/internal/cri/config/config_windows.go index baa045a3360fa..aa5df447421c2 100644 --- a/internal/cri/config/config_windows.go +++ b/internal/cri/config/config_windows.go @@ -87,5 +87,6 @@ func DefaultRuntimeConfig() RuntimeConfig { // TODO(windows): Add platform specific config, so that most common defaults can be shared. DrainExecSyncIOTimeout: "0s", + EnableCRIU: func() *bool { v := false; return &v }(), } } diff --git a/internal/cri/config/enable_criu_linux_test.go b/internal/cri/config/enable_criu_linux_test.go new file mode 100644 index 0000000000000..c6e80c9786e1f --- /dev/null +++ b/internal/cri/config/enable_criu_linux_test.go @@ -0,0 +1,84 @@ +/* + Copyright The containerd 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 config + +import ( + "encoding/json" + "testing" + + "github.com/pelletier/go-toml/v2" + "github.com/stretchr/testify/assert" +) + +func TestParseEnableCRIU(t *testing.T) { + testCases := []struct { + name string + tomlStr string + expectedCRIU *bool + }{ + { + name: "enable_criu set to true", + tomlStr: ` +enable_criu = true +`, + expectedCRIU: func() *bool { v := true; return &v }(), + }, + { + name: "enable_criu set to false", + tomlStr: ` +enable_criu = false +`, + expectedCRIU: func() *bool { v := false; return &v }(), + }, + { + name: "enable_criu absent", + tomlStr: ` +# empty or other fields +`, + expectedCRIU: func() *bool { v := true; return &v }(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := DefaultRuntimeConfig() + err := toml.Unmarshal([]byte(tc.tomlStr), &cfg) + assert.NoError(t, err) + if tc.expectedCRIU == nil { + assert.Nil(t, cfg.EnableCRIU) + } else { + if assert.NotNil(t, cfg.EnableCRIU) { + assert.Equal(t, *tc.expectedCRIU, *cfg.EnableCRIU) + } + } + }) + } +} + +func TestJSONEnableCRIU(t *testing.T) { + jsonStr := `{"enableCRIU": false}` + cfg := DefaultRuntimeConfig() + err := json.Unmarshal([]byte(jsonStr), &cfg) + assert.NoError(t, err) + if assert.NotNil(t, cfg.EnableCRIU) { + assert.False(t, *cfg.EnableCRIU) + } + + b, err := json.Marshal(cfg) + assert.NoError(t, err) + assert.Contains(t, string(b), `"enableCRIU":false`) +} diff --git a/internal/cri/server/container_checkpoint_linux.go b/internal/cri/server/container_checkpoint_linux.go index 42bc7dd13aa38..0f787975f1c98 100644 --- a/internal/cri/server/container_checkpoint_linux.go +++ b/internal/cri/server/container_checkpoint_linux.go @@ -145,6 +145,9 @@ func (c *criService) checkCriu() error { } func (c *criService) doCheckCriu() error { + if c.config.EnableCRIU != nil && !*c.config.EnableCRIU { + return errors.New("criu support is disabled by configuration") + } path := resolveCriuPath(c.shimPath) if path == "" { return errors.New("criu binary not found in shim path or system PATH") diff --git a/internal/cri/server/container_checkpoint_linux_test.go b/internal/cri/server/container_checkpoint_linux_test.go index 32cc466aeffac..defcc3f440ad3 100644 --- a/internal/cri/server/container_checkpoint_linux_test.go +++ b/internal/cri/server/container_checkpoint_linux_test.go @@ -24,6 +24,7 @@ import ( "errors" "os" "path/filepath" + "strings" "sync" "testing" @@ -32,6 +33,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" + runtime "k8s.io/cri-api/pkg/apis/runtime/v1" ) func TestCopyNoFollowRegularFile(t *testing.T) { @@ -316,3 +318,54 @@ func TestResolveCriuPath(t *testing.T) { }) } } + +func TestCheckCriuDisabled(t *testing.T) { + c := newTestCRIService() + c.config.EnableCRIU = func() *bool { v := false; return &v }() + err := c.checkCriu() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "criu support is disabled by configuration") { + t.Errorf("expected error containing 'criu support is disabled by configuration', got: %v", err) + } +} + +func TestCheckCriuEnabled(t *testing.T) { + t.Setenv("PATH", "") + c := newTestCRIService() + c.config.EnableCRIU = func() *bool { v := true; return &v }() + err := c.checkCriu() + if err == nil { + t.Fatal("expected error, got nil") + } + if strings.Contains(err.Error(), "criu support is disabled by configuration") { + t.Errorf("did not expect error containing 'criu support is disabled by configuration', got: %v", err) + } +} + +func TestCheckpointContainerDisabled(t *testing.T) { + c := newTestCRIService() + c.config.EnableCRIU = func() *bool { v := false; return &v }() + _, err := c.CheckpointContainer(context.Background(), &runtime.CheckpointContainerRequest{ + ContainerId: "test-container", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "criu support is disabled by configuration") { + t.Errorf("expected error containing 'criu support is disabled by configuration', got: %v", err) + } +} + +func TestCRImportCheckpointDisabled(t *testing.T) { + c := newTestCRIService() + c.config.EnableCRIU = func() *bool { v := false; return &v }() + _, err := c.CRImportCheckpoint(context.Background(), nil, nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "criu support is disabled by configuration") { + t.Errorf("expected error containing 'criu support is disabled by configuration', got: %v", err) + } +} From 81350a5d9a7a4299ed88ce188062badc986b9e6c Mon Sep 17 00:00:00 2001 From: Samuel Karp Date: Mon, 29 Jun 2026 05:43:54 +0000 Subject: [PATCH 3/3] github/workflows: install criu in node-e2e The Kubernetes E2E CI workflow was failing on tests requiring container checkpointing because criu was not installed on the GitHub Actions runner. Add a step to install criu via ppa:criu/ppa before building and installing containerd, matching the existing setup in ci.yml. Assisted-by: Antigravity Signed-off-by: Samuel Karp --- .github/workflows/node-e2e.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/node-e2e.yml b/.github/workflows/node-e2e.yml index c2540674081cb..4fe96bfe79aab 100644 --- a/.github/workflows/node-e2e.yml +++ b/.github/workflows/node-e2e.yml @@ -62,6 +62,12 @@ jobs: echo "GOPATH=${{ github.workspace }}" >> $GITHUB_ENV echo "${{ github.workspace }}/bin" >> $GITHUB_PATH + - name: Install criu + run: | + sudo add-apt-repository -y ppa:criu/ppa + sudo apt-get update + sudo apt-get install -y criu + - name: Build and Install containerd working-directory: ./src/github.com/containerd/containerd run: |