internal/resource/resource_linux.go (229 lines of code) (raw):
// Copyright 2024 Google LLC
//
// 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
//
// https://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.
//go:build linux
package resource
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/GoogleCloudPlatform/galog"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/retry"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file"
)
// cgroupv1Client is the default client for cgroupv1.
type cgroupv1Client struct {
oomV1Watcher
cgroupsDir string
memoryLimitFile string
cpuController string
memoryController string
}
// cgroupv2Client is the default client for cgroupv2.
type cgroupv2Client struct {
oomV2Watcher
cgroupsDir string
memoryLimitFile string
}
// cgroupVersion is the cgroup version. Because there are differences in file
// directory setups between the two versions, different implementations for each
// of the versions are required.
type cgroupVersion int
const (
// cgroupv1 is cgroup version.
cgroupv1 cgroupVersion = iota + 1
cgroupv2
// defaultCgroupsDir is the default directory for cgroups.
defaultCgroupsDir = "/sys/fs/cgroup"
// guestAgentCgroupDir is the default directory for the guest agent's cgroup.
guestAgentCgroupDir = "guest_agent"
// maxCycles is the maximum number of cycles for CPU periods. This is the
// default used by cgroups.
maxCycles = 100000
)
var (
// remove is the function to remove a cgroup directory. This is stubbed out for testing.
remove = syscall.Rmdir
)
func init() {
// Check if the cgroup is v1 or v2. This is most easily done by checking the
// existence of the cgroup.controllers file in the base cgroups directory.
if file.Exists(filepath.Join(defaultCgroupsDir, "cgroup.controllers"), file.TypeFile) {
Client = &cgroupv2Client{
cgroupsDir: defaultCgroupsDir,
memoryLimitFile: "memory.max",
}
} else {
c, err := initCgroupv1(defaultCgroupsDir)
if err != nil {
galog.Errorf("Error initializing cgroupv1: %v", err)
}
Client = c
}
}
func initCgroupv1(cgroupsDir string) (*cgroupv1Client, error) {
var memoryController, cpuController string
files, err := os.ReadDir(cgroupsDir)
if err != nil {
return nil, fmt.Errorf("failed to read cgroup directory: %w", err)
}
for _, file := range files {
if !file.IsDir() {
continue
}
// Some controllers are mounted together, with their names separated by commas.
nameSplit := strings.Split(file.Name(), ",")
for _, ctrl := range nameSplit {
if ctrl == "cpu" {
// Found the cpu controller.
cpuController = file.Name()
}
if ctrl == "memory" {
// Found the memory controller.
memoryController = file.Name()
}
}
}
return &cgroupv1Client{
cgroupsDir: cgroupsDir,
memoryLimitFile: "memory.limit_in_bytes",
cpuController: cpuController,
memoryController: memoryController,
}, nil
}
func writeCPUControllerConfig(controllerDir string, constraint Constraint, version cgroupVersion) error {
if constraint.MaxCPUUsage > 100 {
return fmt.Errorf("MaxCPUUsage must be <= 100, got %d", constraint.MaxCPUUsage)
}
// Write a pids file containing the plugin's PID. This moves this process to
// this subgroup.
if err := os.WriteFile(filepath.Join(controllerDir, "cgroup.procs"), []byte(strconv.Itoa(constraint.PID)), 0755); err != nil {
return fmt.Errorf("failed to write cpu cgroup.procs file: %w", err)
}
// Write file to constrain CPU usage.
// The values in the file are the number of cycles allowed per maximum number
// of cycles, both configurable by the user.
cpuCycles := int(constraint.MaxCPUUsage) * maxCycles / 100
switch version {
case cgroupv1:
if err := os.WriteFile(filepath.Join(controllerDir, "cpu.cfs_quota_us"), []byte(strconv.Itoa(cpuCycles)), 0755); err != nil {
return fmt.Errorf("failed to write cpu.limit_in_cycles file: %v", err)
}
if err := os.WriteFile(filepath.Join(controllerDir, "cpu.cfs_period_us"), []byte(strconv.Itoa(maxCycles)), 0755); err != nil {
return fmt.Errorf("failed to write cpu.limit_in_cycles file: %v", err)
}
case cgroupv2:
fileContents := fmt.Sprintf("%d %d", cpuCycles, maxCycles)
if err := os.WriteFile(filepath.Join(controllerDir, "cpu.max"), []byte(fileContents), 0755); err != nil {
return fmt.Errorf("failed to write cpu.max file: %v", err)
}
}
return nil
}
func writeMemoryControllerConfig(controllerDir string, constraint Constraint, memoryLimitFile string) error {
// Write a pids file containing the plugin's PID. This moves this process to
// this subgroup.
procsFile := filepath.Join(controllerDir, "cgroup.procs")
if err := os.WriteFile(procsFile, []byte(strconv.Itoa(constraint.PID)), 0755); err != nil {
return fmt.Errorf("failed to write memory cgroup.procs file: %w", err)
}
// Write a file to limit memory usage.
memoryFile := filepath.Join(controllerDir, memoryLimitFile)
if err := os.WriteFile(memoryFile, []byte(strconv.Itoa(int(constraint.MaxMemoryUsage))), 0755); err != nil {
return fmt.Errorf("failed to write to %s: %w", memoryLimitFile, err)
}
return nil
}
// Apply applies resource constraints to the process with the given PID using
// cgroupv1.
func (c cgroupv1Client) Apply(constraint Constraint) error {
if constraint.MaxCPUUsage != 0 && constraint.MaxMemoryUsage != 0 {
// If we want to set both limits, but one of the controllers do not exist,
// use fallback option for consistency.
if c.cpuController == "" && c.memoryController == "" {
return fmt.Errorf("failed to find both cpu and memory controllers")
}
}
// Write CPU controller configs if MaxCPUUsage is set.
if constraint.MaxCPUUsage != 0 {
if c.cpuController == "" {
return fmt.Errorf("failed to find cpu controller")
}
cpuControllerDir := filepath.Join(c.cgroupsDir, c.cpuController, constraint.Name)
if err := os.MkdirAll(cpuControllerDir, 0755); err != nil {
return fmt.Errorf("failed to create cgroup cpu controller directory: %w", err)
}
if err := writeCPUControllerConfig(cpuControllerDir, constraint, cgroupv1); err != nil {
return err
}
}
// Write memory controller configs if MaxMemoryUsage is set.
if constraint.MaxMemoryUsage != 0 {
if c.memoryController == "" {
return fmt.Errorf("failed to find memory controller")
}
memoryControllerDir := filepath.Join(c.cgroupsDir, c.memoryController, constraint.Name)
if err := os.MkdirAll(memoryControllerDir, 0755); err != nil {
return fmt.Errorf("failed to create cgroup memory controller directory: %w", err)
}
if err := writeMemoryControllerConfig(memoryControllerDir, constraint, c.memoryLimitFile); err != nil {
return err
}
}
return nil
}
// RemoveConstraint removes resource constraints from the process with the given
// name using cgroupv1.
func (c cgroupv1Client) RemoveConstraint(ctx context.Context, name string) error {
if err := removeCgroup(ctx, filepath.Join(c.cgroupsDir, c.cpuController, name)); err != nil {
return err
}
return removeCgroup(ctx, filepath.Join(c.cgroupsDir, c.memoryController, name))
}
// Apply applies resource constraints to the process with the given PID using
// cgroupv2.
func (c cgroupv2Client) Apply(constraint Constraint) error {
// Check that the controller we need are actually enabled.
// The controllers file should contain both the cpu and memory controllers.
controlContents, err := os.ReadFile(filepath.Join(c.cgroupsDir, "cgroup.controllers"))
if err != nil {
return fmt.Errorf("failed to read cgroup.controllers file: %w", err)
}
controlContentsSplit := strings.Fields(string(controlContents))
cpuEnabled := slices.Contains(controlContentsSplit, "cpu")
memoryEnabled := slices.Contains(controlContentsSplit, "memory")
// Now check that the subtree_control file enables cpu and memory controllers.
subtreeControlContents, err := os.ReadFile(filepath.Join(c.cgroupsDir, "cgroup.subtree_control"))
if err != nil {
return fmt.Errorf("failed to read cgroup.subtree_control file: %w", err)
}
subtreeControlContentsSplit := strings.Fields(string(subtreeControlContents))
cpuEnabled = cpuEnabled && !slices.Contains(subtreeControlContentsSplit, "-cpu") && slices.Contains(subtreeControlContentsSplit, "cpu")
memoryEnabled = memoryEnabled && !slices.Contains(subtreeControlContentsSplit, "-memory") && slices.Contains(subtreeControlContentsSplit, "memory")
// Make the guest_agent cgroup dir.
// Only make and setup the directory if one of the controllers are enabled.
if cpuEnabled || memoryEnabled {
guestAgentDir := filepath.Join(c.cgroupsDir, guestAgentCgroupDir)
// Only try to create the guest-agent directory if it doesn't exist.
if !file.Exists(guestAgentDir, file.TypeDir) {
if err := os.MkdirAll(filepath.Join(c.cgroupsDir, guestAgentCgroupDir), 0755); err != nil {
return fmt.Errorf("failed to create guest_agent cgroup: %w", err)
}
subtreeControl := make([]string, 0)
if cpuEnabled {
subtreeControl = append(subtreeControl, "+cpu")
}
if memoryEnabled {
subtreeControl = append(subtreeControl, "+memory")
}
// Ensure the proper controllers are enabled.
guestAgentControlFile := filepath.Join(c.cgroupsDir, guestAgentCgroupDir, "cgroup.subtree_control")
if err := os.WriteFile(guestAgentControlFile, []byte(strings.Join(subtreeControl, " ")), 0755); err != nil {
return fmt.Errorf("failed to write cgroup.subtree_control file: %w", err)
}
}
}
if constraint.MaxCPUUsage != 0 && constraint.MaxMemoryUsage != 0 {
if !cpuEnabled && !memoryEnabled {
return fmt.Errorf("both cpu and memory controllers disabled")
}
}
if constraint.MaxCPUUsage != 0 || constraint.MaxMemoryUsage != 0 {
// Create a cgroup directory for all things guest-agent related.
pluginPath := filepath.Join(c.cgroupsDir, guestAgentCgroupDir, constraint.Name)
if err := os.MkdirAll(pluginPath, 0755); err != nil {
return fmt.Errorf("failed to create cgroup directory for %s: %v", constraint.Name, err)
}
if constraint.MaxCPUUsage != 0 {
if !cpuEnabled {
return fmt.Errorf("cpu controller disabled")
}
if err := writeCPUControllerConfig(pluginPath, constraint, cgroupv2); err != nil {
return err
}
}
if constraint.MaxMemoryUsage != 0 {
if !memoryEnabled {
return fmt.Errorf("memory controller disabled")
}
if err := writeMemoryControllerConfig(pluginPath, constraint, c.memoryLimitFile); err != nil {
return err
}
}
}
return nil
}
// RemoveConstraint removes a resource constraint from the process with the
// given name using cgroupv2.
func (c cgroupv2Client) RemoveConstraint(ctx context.Context, name string) error {
return removeCgroup(ctx, filepath.Join(c.cgroupsDir, guestAgentCgroupDir, name))
}
// removeCgroup function is responsible for managing the deletion of cgroup
// directories. It tries to handle EBUSY errors encountered during the removal
// process by implementing a retry mechanism with exponential backoff. These
// errors usually occur when the cgroup is still in use. For example, it may
// happen when trying to delete a cgroup directory associated with a recently
// terminated or stopped process.
func removeCgroup(ctx context.Context, dir string) error {
galog.Debugf("Removing cgroup directory %q", dir)
policy := retry.Policy{MaxAttempts: 5, Jitter: time.Millisecond * 100, BackoffFactor: 2}
return retry.Run(ctx, policy, func() error {
// NOENT is ignored in case the directory was already removed or never existed.
if err := remove(dir); err != nil && !errors.Is(err, syscall.ENOENT) {
return fmt.Errorf("unable to remove cgroup directory %q: %w", dir, err)
}
return nil
})
}