wenmo8 发布的文章

use-gpu-in-k8s-pod.png

本文主要分析了在 K8s 中创建一个 Pod 并申请 GPU 资源,最终该 Pod 时怎么能够使用 GPU 的,具体的实现原理,以及 device plugin、nvidia-container-toolkit 相关源码分析。

1.概述

在 两篇文章中分别分享了在不同环境如何使用 GPU,以及在 k8s 中使用 GPU Operator 来加速部署。

在 中则是分析了 Device Plugin 的实现原理。

这篇文章则是将整个流程连起来做一个简单分析,即:
宿主机上的 GPU 是怎么能够被 K8s 中的 Pod 使用的

可以分为以下两部分:

  • 1)k8s 是如何感知到 GPU 的
  • 2)GPU 是如何分配给 Pod 的

2. 大致工作流程

这部分主要分享一下 NVIDIA 的 device-plugin 以及 nvidia-container-toolkit 的工作流程,以及二者是怎么配合的。

k8s 是如何感知到 GPU 的

这部分主要使用到了 k8s 提供的 device plugin 机制。

感兴趣的同学可以阅读一下这篇文章 -->

NVIDIA 实现了
NVIDIA/k8s-device-plugin
来使得节点上的 GPU 能够被 k8s 感知到。

这个 device plugin 主要做两件事:

  • 1)检测节点上的 GPU 设备并上报给 Kubelet,再由 Kubelet 更新节点信息时提交到 kube-apiserver。
    • 这样 k8s 就知道每个节点上有多少 GPU 了,后续 Pod 申请 GPU 时就会往有 GPU 资源的节点上调度。
  • 2)Pod 申请 GPU 时,为对应容器添加一个
    NVIDIA_VISIBLE_DEVICES
    环境变量,后续底层 Runtime 在真正创建容器时就能根据这些信息把 GPU 挂载到容器中
    • 例如添加环境变量:
      NVIDIA_VISIBLE_DEVICES=GPU-03f69c50-207a-2038-9b45-23cac89cb67d

NVIDIA 这个 device plugin 比较复杂,支持多种策略,device plugin 提供的 env、mounts、device 以及 annotations 等方式它都做了支持,在部署时可以通过
DEVICE_LIST_STRATEGY
环境变量进行指定,不过默认还是用的 env。

另外
DEVICE_ID_STRATEGY
默认也是 uuid,因此在 Pod 中看到的
NVIDIA_VISIBLE_DEVICES
就不是 Docker 环境中常见的 0,1,2 这种编号了,而是 GPU 设备对应的 UUID。

GPU 是如何分配给 Pod 的

NVIDIA 提供了 nvidia-container-toolkit 来处理如何将 GPU 分配给容器的问题。

核心组件有以下三个:

  • nvidia-container-runtime

  • nvidia-container-runtime-hook

  • nvidia-container-cli

首先需要将 docker/containerd 的 runtime 设置为
nvidia-container-runtime
,此后整个调用链就变成这样了:

nv-container-runtime-call-flow

接下来就具体分析每个组件的作用。

nvidia-container-runtime

nvidia-container-runtime 的作用就是负责在容器启动之前,将 nvidia-container-runtime-hook 注入到 prestart hook。

小知识:docker/containerd 都是高级 Runtime,runC 则是低级 Runtime。不同层级 Runtime 通过 OCI Spec 进行交互。

也就是说 docker 调用 runC 创建容器时,会把 docker 收到的信息解析,组装成 OCI Spec,然后在往下传递。

而 nvidia-container-runtime 的作用就是修改容器 Spec,往里面添加一个 prestart hook,这个 hook 就是 nvidia-container-runtime-hook

这样 runC 根据 Spec 启动容器时就会执行该 hook,即执行 nvidia-container-runtime-hook。

也就是说 nvidia-container-runtime 其实没有任何逻辑,真正的逻辑都在 nvidia-container-runtime-hook 中。

nvidia-container-runtime-hook

nvidia-container-runtime-hook 包含了给容器分配 GPU 的核心逻辑,主要分为两部分:

  • 1)从容器 Spec 的 mounts 和 env 中解析 GPU 信息
    • mounts 对应前面 device plugin 中设置的 Mount 和 Device,env 则对应 Env
  • 2)调用
    nvidia-container-cli configure
    命令,保证容器内可以使用被指定的 GPU 以及对应能力

也就是说
nvidia-container-runtime-hook
最终还是调用
nvidia-container-cli
来实现的给容器分配 GPU 能力的。

nvidia-container-cli

nvidia-container-cli 是一个命令行工具,用于配置 Linux 容器对 GPU 硬件的使用。

提供了三个命令

  • list: 打印 nvidia 驱动库及路径
  • info: 打印所有Nvidia GPU设备
  • configure: 进入给定进程的命名空间,执行必要操作保证容器内可以使用被指定的 GPU 以及对应能力(指定 NVIDIA 驱动库)

一般主要使用 configure 命令,它将 NVIDIA GPU Driver、CUDA Driver 等 驱动库的 so 文件 和 GPU 设备信息, 通过文件挂载的方式映射到容器中。

小结

整个流程如下:

  • 1)device plugin 上报节点上的 GPU 信息
  • 2)用户创建 Pod,在 resources.rquest 中申请 GPU,Scheduler 根据各节点 GPU 资源情况,将 Pod 调度到一个有足够 GPU 的节点
  • 3)DevicePlugin 根据 Pod 中申请的 GPU 资源,为容器添加 Env 和 Devices 配置
    • 例如添加环境变量:
      NVIDIA_VISIBLE_DEVICES=GPU-03f69c50-207a-2038-9b45-23cac89cb67d
  • 4)docker / containerd 启动容器
    • 由于配置了 nvidia-container-runtime,因此会使用 nvidia-container-runtime 来创建容器
    • nvidia-container-runtime 额外做了一件事:将
      nvidia-container-runtime-hook
      作为 prestart hook 添加到容器 spec 中,然后就将容器 spec 信息往后传给 runC 了。
    • runC 创建容器前会调用 prestart hook,其中就包括了上一步添加的 nvidia-container-runtime-hook,该 hook 主要做两件事:
      • 从容器 Spec 的 mounts 或者 env 中解析 GPU 信息
      • 调用
        nvidia-container-cli configure
        命令,将 NVIDIA 的 GPU Driver、CUDA Driver 等库文件挂载进容器,保证容器内可以使用被指定的 GPU以及对应能力

以上就是在 k8s 中使用 NVIDIA GPU 的流程,简单来说就是:

  • 1)device plugin 中根据 pod 申请的 GPU 资源分配 GPU,并以 ENV 环境变量方式添加到容器上。
  • 2)nvidia-container-toolkit 则根据该 Env 拿到要分配给该容器的 GPU 最终把相关文件挂载到容器里

当然并不是只有这一种实现方法,比如天数的
ix-device-plugin
实现中就没有提供自己的 container-toolkit,只在 device plugin 中通过 Device 指定要挂载哪些设备,这样容器启动时也会把这些设备挂载到容器中:

func (p *iluvatarDevicePlugin) allocateDevicesByDeviceID(hostminor uint, num int) *pluginapi.DeviceSpec {
	var device pluginapi.DeviceSpec

	hostPathPrefix := "/dev/"
	containerPathPrefix := "/dev/"

	// Expose the device node for iluvatar pod.
	device.HostPath = hostPathPrefix + deviceName + strconv.Itoa(int(hostminor))
	device.ContainerPath = containerPathPrefix + deviceName + strconv.Itoa(num)
	device.Permissions = "rw"

	return &device
}

不过由于没有挂载驱动进去,因此需要容器内自带驱动才行。

至此,已经分析了 k8s 创建 Pod 使用 GPU 的整个流程及大致原理,接下来简单分析下相关组件源码。

3. device plugin 源码分析

NVIDIA GPU 对应的 device plugin 叫做:
NVIDIA/k8s-device-plugin

Allocate 方法

主要看为容器分配资源的 Allocate 方法

// https://github.com/NVIDIA/k8s-device-plugin/blob/main/internal/plugin/server.go#L319-L332

// Allocate which return list of devices.
func (plugin *NvidiaDevicePlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) {
        responses := pluginapi.AllocateResponse{}
        for _, req := range reqs.ContainerRequests {
                if err := plugin.rm.ValidateRequest(req.DevicesIDs); err != nil {
                        return nil, fmt.Errorf("invalid allocation request for %q: %w", plugin.rm.Resource(), err)
                }
                response, err := plugin.getAllocateResponse(req.DevicesIDs)
                if err != nil {
                        return nil, fmt.Errorf("failed to get allocate response: %v", err)
                }
                responses.ContainerResponses = append(responses.ContainerResponses, response)
        }

        return &responses, nil
}

核心逻辑在
getAllocateResponse
中:

func (plugin *NvidiaDevicePlugin) getAllocateResponse(requestIds []string) (*pluginapi.ContainerAllocateResponse, error) {
	deviceIDs := plugin.deviceIDsFromAnnotatedDeviceIDs(requestIds)

	// Create an empty response that will be updated as required below.
	response := &pluginapi.ContainerAllocateResponse{
		Envs: make(map[string]string),
	}
	if plugin.deviceListStrategies.AnyCDIEnabled() {
		responseID := uuid.New().String()
		if err := plugin.updateResponseForCDI(response, responseID, deviceIDs...); err != nil {
			return nil, fmt.Errorf("failed to get allocate response for CDI: %v", err)
		}
	}
	if plugin.config.Sharing.SharingStrategy() == spec.SharingStrategyMPS {
		plugin.updateResponseForMPS(response)
	}

	// The following modifications are only made if at least one non-CDI device
	// list strategy is selected.
	if plugin.deviceListStrategies.AllCDIEnabled() {
		return response, nil
	}

	if plugin.deviceListStrategies.Includes(spec.DeviceListStrategyEnvvar) {
		plugin.updateResponseForDeviceListEnvvar(response, deviceIDs...)
	}
	if plugin.deviceListStrategies.Includes(spec.DeviceListStrategyVolumeMounts) {
		plugin.updateResponseForDeviceMounts(response, deviceIDs...)
	}
	if *plugin.config.Flags.Plugin.PassDeviceSpecs {
		response.Devices = append(response.Devices, plugin.apiDeviceSpecs(*plugin.config.Flags.NvidiaDevRoot, requestIds)...)
	}
	if *plugin.config.Flags.GDSEnabled {
		response.Envs["NVIDIA_GDS"] = "enabled"
	}
	if *plugin.config.Flags.MOFEDEnabled {
		response.Envs["NVIDIA_MOFED"] = "enabled"
	}
	return response, nil
}

可以看到,根据不同 flag 以及策略分为不同的设置方式

// Constants to represent the various device list strategies
const (
	DeviceListStrategyEnvvar         = "envvar"
	DeviceListStrategyVolumeMounts   = "volume-mounts"
	DeviceListStrategyCDIAnnotations = "cdi-annotations"
	DeviceListStrategyCDICRI         = "cdi-cri"
)

东西比较多,我们主要看设置 Env 的策略

if plugin.deviceListStrategies.Includes(spec.DeviceListStrategyEnvvar) {
    plugin.updateResponseForDeviceListEnvvar(response, deviceIDs...)
}

核心如下:

// updateResponseForDeviceListEnvvar sets the environment variable for the requested devices.
func (plugin *NvidiaDevicePlugin) updateResponseForDeviceListEnvvar(response *pluginapi.ContainerAllocateResponse, deviceIDs ...string) {
        response.Envs[plugin.deviceListEnvvar] = strings.Join(deviceIDs, ",")
}

可以看到,逻辑很简单,就是给容器添加了一个环境变量,value 为设备 id,具体 deviceID 提供了两种策略,可以是编号或者 uuid

const (
        DeviceIDStrategyUUID  = "uuid"
        DeviceIDStrategyIndex = "index"
)

key 是一个变量 plugin.deviceListEnvvar,初始化如下:

        plugin := NvidiaDevicePlugin{
                deviceListEnvvar:     "NVIDIA_VISIBLE_DEVICES",
                socket:               pluginPath + ".sock",
          // ...
        }

也就是说 NVIDIA 这个 device plugin 实现 Allocate 主要就是给容器增加了环境变量,例如:

NVIDIA_VISIBLE_DEVICES=GPU-03f69c50-207a-2038-9b45-23cac89cb67d

或者

NVIDIA_VISIBLE_DEVICES=1,2

小结

NVIDIA device plugin 核心逻辑就是给容器添加
NVIDIA_VISIBLE_DEVICES
环境变量,告知后续组件,需要给该组件分配 GPU。

比如当我们仅使用 Docker 时就可以在启动容器时指定 GPU,
--gpus
flag 和
NVIDIA_VISIBLE_DEVICES
环境变量效果一致。

# --gpus
docker run --gpus device=0 -it tensorflow/tensorflow:latest-gpu bash
# 或者环境变量 NVIDIA_VISIBLE_DEVICES
docker run -e NVIDIA_VISIBLE_DEVICES=0 -it tensorflow/tensorflow:latest-gpu bash

至于为什么添加了
NVIDIA_VISIBLE_DEVICES
环境变量就会给该容器分配 GPU,就是接下来的
nvidi-container-toolkit
组件实现的。

nvidia 在 device plugin 中也使用
NVIDIA_VISIBLE_DEVICES
环境变量正好能够兼容 nvidia-container-toolkit。

4. nvidia-container-toolkit 源码分析

这部分我们主要分析,为什么添加了
NVIDIA_VISIBLE_DEVICES
环境变量就会给该容器分配 GPU,nvidia-container-toolkit 中做了哪些处理。

nvidia-container-toolkit 包含以下 3 个部分:

nvidia-container-runtime

nvidia-container-runtime 可以看做是一个 docker/containerd 的底层 runtime(类似 runC),在模块在创建容器的整个调用链中处在如下位置:

nv-container-runtime-call-flow

它只做一件事,就是在容器启动之前,将 nvidia-container-runtime-hook 注入到 prestart hook。

以修改容器 Spec 的方式添加一个 prestart hook 进去

这样,后续 runC 使用容器 Spec 创建容器时就会执行该 prestart hook。

简单分析下源码,首先是启动命令:
nvidia-container-runtime/main.go

就是 New 了一个 nvidia runtime 对象,并执行其 Run 方法。

// https://github.com/NVIDIA/nvidia-container-toolkit/blob/main/cmd/nvidia-container-runtime/main.go#L9-L15

import (
    "os"

    "github.com/NVIDIA/nvidia-container-toolkit/internal/runtime"
)

func main() {
    r := runtime.New()
    err := r.Run(os.Args)
    if err != nil {
       os.Exit(1)
    }
}

具体的 New 方法也很简单,返回的是一个名为 Interface 的 Interface,包含一个 Run 方法

// https://github.com/NVIDIA/nvidia-containertoolkit/blob/main/internal/runtime/api.go#L17-L26

type rt struct {
    logger       *Logger
    modeOverride string
}

// Interface is the interface for the runtime library.
type Interface interface {
    Run([]string) error
}
func New(opts ...Option) Interface {
    r := rt{}
    for _, opt := range opts {
       opt(&r)
    }
    if r.logger == nil {
       r.logger = NewLogger()
    }
    return &r
}

Run 方法具体实现如下:

// https://github.com/NVIDIA/nvidia-container-toolkit/blob/main/internal/runtime/runtime.go#L34-L91

// Run is an entry point that allows for idiomatic handling of errors
// when calling from the main function.
func (r rt) Run(argv []string) (rerr error) {
    defer func() {
       if rerr != nil {
          r.logger.Errorf("%v", rerr)
       }
    }()

    printVersion := hasVersionFlag(argv)
    if printVersion {
       fmt.Printf("%v version %v\n", "NVIDIA Container Runtime", info.GetVersionString(fmt.Sprintf("spec: %v", specs.Version)))
    }

    cfg, err := config.GetConfig()
    if err != nil {
       return fmt.Errorf("error loading config: %v", err)
    }
    r.logger.Update(
       cfg.NVIDIAContainerRuntimeConfig.DebugFilePath,
       cfg.NVIDIAContainerRuntimeConfig.LogLevel,
       argv,
    )
    defer func() {
       if rerr != nil {
          r.logger.Errorf("%v", rerr)
       }
       if err := r.logger.Reset(); err != nil {
          rerr = errors.Join(rerr, fmt.Errorf("failed to reset logger: %v", err))
       }
    }()

    // We apply some config updates here to ensure that the config is valid in
    // all cases.
    if r.modeOverride != "" {
       cfg.NVIDIAContainerRuntimeConfig.Mode = r.modeOverride
    }
    //nolint:staticcheck  // TODO(elezar): We should swith the nvidia-container-runtime from using nvidia-ctk to using nvidia-cdi-hook.
    cfg.NVIDIACTKConfig.Path = config.ResolveNVIDIACTKPath(&logger.NullLogger{}, cfg.NVIDIACTKConfig.Path)
    cfg.NVIDIAContainerRuntimeHookConfig.Path = config.ResolveNVIDIAContainerRuntimeHookPath(&logger.NullLogger{}, cfg.NVIDIAContainerRuntimeHookConfig.Path)

    // Log the config at Trace to allow for debugging if required.
    r.logger.Tracef("Running with config: %+v", cfg)

    driver := root.New(
       root.WithLogger(r.logger),
       root.WithDriverRoot(cfg.NVIDIAContainerCLIConfig.Root),
    )

    r.logger.Tracef("Command line arguments: %v", argv)
    runtime, err := newNVIDIAContainerRuntime(r.logger, cfg, argv, driver)
    if err != nil {
       return fmt.Errorf("failed to create NVIDIA Container Runtime: %v", err)
    }

    if printVersion {
       fmt.Print("\n")
    }
    return runtime.Exec(argv)
}

核心部分:

runtime, err := newNVIDIAContainerRuntime(r.logger, cfg, argv, driver)
if err != nil {
   return fmt.Errorf("failed to create NVIDIA Container Runtime: %v", err)
}

if printVersion {
   fmt.Print("\n")
}
return runtime.Exec(argv)

继续查看 newNVIDIAContainerRuntime 实现

// https://github.com/NVIDIA/nvidia-container-toolkit/blob/main/internal/runtime/runtime_factory.go#L32-L62

// newNVIDIAContainerRuntime is a factory method that constructs a runtime based on the selected configuration and specified logger
func newNVIDIAContainerRuntime(logger logger.Interface, cfg *config.Config, argv []string, driver *root.Driver) (oci.Runtime, error) {
    lowLevelRuntime, err := oci.NewLowLevelRuntime(logger, cfg.NVIDIAContainerRuntimeConfig.Runtimes)
    if err != nil {
       return nil, fmt.Errorf("error constructing low-level runtime: %v", err)
    }

    logger.Tracef("Using low-level runtime %v", lowLevelRuntime.String())
    if !oci.HasCreateSubcommand(argv) {
       logger.Tracef("Skipping modifier for non-create subcommand")
       return lowLevelRuntime, nil
    }

    ociSpec, err := oci.NewSpec(logger, argv)
    if err != nil {
       return nil, fmt.Errorf("error constructing OCI specification: %v", err)
    }

    specModifier, err := newSpecModifier(logger, cfg, ociSpec, driver)
    if err != nil {
       return nil, fmt.Errorf("failed to construct OCI spec modifier: %v", err)
    }

    // Create the wrapping runtime with the specified modifier.
    r := oci.NewModifyingRuntimeWrapper(
       logger,
       lowLevelRuntime,
       ociSpec,
       specModifier,
    )

    return r, nil
}

暂时只需要关注 specModifier 这个对象,就是它在修改容器的 spec 以添加 hook

// newSpecModifier is a factory method that creates constructs an OCI spec modifer based on the provided config.
func newSpecModifier(logger logger.Interface, cfg *config.Config, ociSpec oci.Spec, driver *root.Driver) (oci.SpecModifier, error) {
    rawSpec, err := ociSpec.Load()
    if err != nil {
       return nil, fmt.Errorf("failed to load OCI spec: %v", err)
    }

    image, err := image.NewCUDAImageFromSpec(rawSpec)
    if err != nil {
       return nil, err
    }

    mode := info.ResolveAutoMode(logger, cfg.NVIDIAContainerRuntimeConfig.Mode, image)
    modeModifier, err := newModeModifier(logger, mode, cfg, ociSpec, image)
    if err != nil {
       return nil, err
    }
    // For CDI mode we make no additional modifications.
    if mode == "cdi" {
       return modeModifier, nil
    }

    graphicsModifier, err := modifier.NewGraphicsModifier(logger, cfg, image, driver)
    if err != nil {
       return nil, err
    }

    featureModifier, err := modifier.NewFeatureGatedModifier(logger, cfg, image)
    if err != nil {
       return nil, err
    }

    modifiers := modifier.Merge(
       modeModifier,
       graphicsModifier,
       featureModifier,
    )
    return modifiers, nil
}

修改 hook 的 modifier 在 newModeModifier 里面

func newModeModifier(logger logger.Interface, mode string, cfg *config.Config, ociSpec oci.Spec, image image.CUDA) (oci.SpecModifier, error) {
    switch mode {
    case "legacy":
       return modifier.NewStableRuntimeModifier(logger, cfg.NVIDIAContainerRuntimeHookConfig.Path), nil
    case "csv":
       return modifier.NewCSVModifier(logger, cfg, image)
    case "cdi":
       return modifier.NewCDIModifier(logger, cfg, ociSpec)
    }

    return nil, fmt.Errorf("invalid runtime mode: %v", cfg.NVIDIAContainerRuntimeConfig.Mode)
}

具体为 stableRuntimeModifier:

func (m stableRuntimeModifier) Modify(spec *specs.Spec) error {
    // If an NVIDIA Container Runtime Hook already exists, we don't make any modifications to the spec.
    if spec.Hooks != nil {
       for _, hook := range spec.Hooks.Prestart {
          hook := hook
          if isNVIDIAContainerRuntimeHook(&hook) {
             m.logger.Infof("Existing nvidia prestart hook (%v) found in OCI spec", hook.Path)
             return nil
          }
       }
    }

    path := m.nvidiaContainerRuntimeHookPath
    m.logger.Infof("Using prestart hook path: %v", path)
    args := []string{filepath.Base(path)}
    if spec.Hooks == nil {
       spec.Hooks = &specs.Hooks{}
    }
    spec.Hooks.Prestart = append(spec.Hooks.Prestart, specs.Hook{
       Path: path,
       Args: append(args, "prestart"),
    })

    return nil
}

核心部分:

path := m.nvidiaContainerRuntimeHookPath
spec.Hooks.Prestart = append(spec.Hooks.Prestart, specs.Hook{
   Path: path,
   Args: append(args, "prestart"),
})

可以看到,最终就是添加了一个 prestart hook,hook 的 path 就是 nvidia-container-runtime-hook 这个二进制文件的位置。

至此,nvidia-container-runtime 的工作就完成了,容器真正启动时,底层 runtime(比如 runC)检测到容器的 Spec 中有这个 hook 就会去执行了,最终 nvidia-container-runtime-hook 就会被运行了。

nvidia-container-runtime-hook

该组件则是 nvidia-container-toolkit 中的核心,所有的逻辑都在这里面实现。

主要做两件事:

  • 1)从容器的 env 中解析 GPU 信息
  • 2)调用
    nvidia-container-cli configure
    命令,挂载相关文件,保证容器内可以使用被指定的GPU以及对应能力

也是先从启动命令看起:
nvidia-container-runtime-hook/main.go

switch args[0] {
case "prestart":
    doPrestart()
    os.Exit(0)
case "poststart":
    fallthrough
case "poststop":
    os.Exit(0)
default:
    flag.Usage()
    os.Exit(2)
}

我们是添加的 prestart hook,因此会走 prestart 分支 执行doPrestart()方法。

func doPrestart() {
    var err error

    defer exit()
    log.SetFlags(0)

    hook, err := getHookConfig()
    if err != nil || hook == nil {
       log.Panicln("error getting hook config:", err)
    }
    cli := hook.NVIDIAContainerCLIConfig

    container := getContainerConfig(*hook)
    nvidia := container.Nvidia
    if nvidia == nil {
       // Not a GPU container, nothing to do.
       return
    }

    if !hook.NVIDIAContainerRuntimeHookConfig.SkipModeDetection && info.ResolveAutoMode(&logInterceptor{}, hook.NVIDIAContainerRuntimeConfig.Mode, container.Image) != "legacy" {
       log.Panicln("invoking the NVIDIA Container Runtime Hook directly (e.g. specifying the docker --gpus flag) is not supported. Please use the NVIDIA Container Runtime (e.g. specify the --runtime=nvidia flag) instead.")
    }

    rootfs := getRootfsPath(container)

    args := []string{getCLIPath(cli)}
    if cli.Root != "" {
       args = append(args, fmt.Sprintf("--root=%s", cli.Root))
    }
    if cli.LoadKmods {
       args = append(args, "--load-kmods")
    }
    if cli.NoPivot {
       args = append(args, "--no-pivot")
    }
    if *debugflag {
       args = append(args, "--debug=/dev/stderr")
    } else if cli.Debug != "" {
       args = append(args, fmt.Sprintf("--debug=%s", cli.Debug))
    }
    if cli.Ldcache != "" {
       args = append(args, fmt.Sprintf("--ldcache=%s", cli.Ldcache))
    }
    if cli.User != "" {
       args = append(args, fmt.Sprintf("--user=%s", cli.User))
    }
    args = append(args, "configure")

    if ldconfigPath := cli.NormalizeLDConfigPath(); ldconfigPath != "" {
       args = append(args, fmt.Sprintf("--ldconfig=%s", ldconfigPath))
    }
    if cli.NoCgroups {
       args = append(args, "--no-cgroups")
    }
    if len(nvidia.Devices) > 0 {
       args = append(args, fmt.Sprintf("--device=%s", nvidia.Devices))
    }
    if len(nvidia.MigConfigDevices) > 0 {
       args = append(args, fmt.Sprintf("--mig-config=%s", nvidia.MigConfigDevices))
    }
    if len(nvidia.MigMonitorDevices) > 0 {
       args = append(args, fmt.Sprintf("--mig-monitor=%s", nvidia.MigMonitorDevices))
    }
    if len(nvidia.ImexChannels) > 0 {
       args = append(args, fmt.Sprintf("--imex-channel=%s", nvidia.ImexChannels))
    }

    for _, cap := range strings.Split(nvidia.DriverCapabilities, ",") {
       if len(cap) == 0 {
          break
       }
       args = append(args, capabilityToCLI(cap))
    }

    for _, req := range nvidia.Requirements {
       args = append(args, fmt.Sprintf("--require=%s", req))
    }

    args = append(args, fmt.Sprintf("--pid=%s", strconv.FormatUint(uint64(container.Pid), 10)))
    args = append(args, rootfs)

    env := append(os.Environ(), cli.Environment...)
    //nolint:gosec // TODO: Can we harden this so that there is less risk of command injection?
    err = syscall.Exec(args[0], args, env)
    log.Panicln("exec failed:", err)
}

我们只需要关注下面这个就行

args := []string{getCLIPath(cli)}
container := getContainerConfig(*hook)
err = syscall.Exec(args[0], args, env)

一个是 getContainerConfig 解析容器配置 ,另一个就是 exec 真正开始执行命令。

这里执行的命令其实就是 nvidia-container-cli

getContainerConfig

这部分就是解析 Env 拿到要分配给该容器的 GPU,如果没有
NVIDIA_VISIBLE_DEVICES
环境变量就不会做任何事情。

func getContainerConfig(hook HookConfig) (config containerConfig) {
    var h HookState
    d := json.NewDecoder(os.Stdin)
    if err := d.Decode(&h); err != nil {
       log.Panicln("could not decode container state:", err)
    }

    b := h.Bundle
    if len(b) == 0 {
       b = h.BundlePath
    }

    s := loadSpec(path.Join(b, "config.json"))

    image, err := image.New(
       image.WithEnv(s.Process.Env),
       image.WithDisableRequire(hook.DisableRequire),
    )
    if err != nil {
       log.Panicln(err)
    }

    privileged := isPrivileged(s)
    return containerConfig{
       Pid:    h.Pid,
       Rootfs: s.Root.Path,
       Image:  image,
       Nvidia: getNvidiaConfig(&hook, image, s.Mounts, privileged),
    }
}

构建了一个 image 对象,注意这里把 ENV 也传进去了

之前说了需要给容器分配什么 GPU 是通过 NVIDIA_VISIBLE_DEVICES 环境变量指定的

image, err := image.New(
    image.WithEnv(s.Process.Env),
    image.WithDisableRequire(hook.DisableRequire),
)

然后解析配置

func getNvidiaConfig(hookConfig *HookConfig, image image.CUDA, mounts []Mount, privileged bool) *nvidiaConfig {
    legacyImage := image.IsLegacy()

    var devices string
    if d := getDevices(hookConfig, image, mounts, privileged); d != nil {
       devices = *d
    } else {
       // 'nil' devices means this is not a GPU container.
       return nil
    }

    var migConfigDevices string
    if d := getMigConfigDevices(image); d != nil {
       migConfigDevices = *d
    }
    if !privileged && migConfigDevices != "" {
       log.Panicln("cannot set MIG_CONFIG_DEVICES in non privileged container")
    }

    var migMonitorDevices string
    if d := getMigMonitorDevices(image); d != nil {
       migMonitorDevices = *d
    }
    if !privileged && migMonitorDevices != "" {
       log.Panicln("cannot set MIG_MONITOR_DEVICES in non privileged container")
    }

    var imexChannels string
    if c := getImexChannels(image); c != nil {
       imexChannels = *c
    }

    driverCapabilities := hookConfig.getDriverCapabilities(image, legacyImage).String()

    requirements, err := image.GetRequirements()
    if err != nil {
       log.Panicln("failed to get requirements", err)
    }

    return &nvidiaConfig{
       Devices:            devices,
       MigConfigDevices:   migConfigDevices,
       MigMonitorDevices:  migMonitorDevices,
       ImexChannels:       imexChannels,
       DriverCapabilities: driverCapabilities,
       Requirements:       requirements,
    }
}

核心是 getDevice,就是根据 Mounts 信息或者 Env 解析要分配给该容器的 GPU

func getDevices(hookConfig *HookConfig, image image.CUDA, mounts []Mount, privileged bool) *string {
    // If enabled, try and get the device list from volume mounts first
    if hookConfig.AcceptDeviceListAsVolumeMounts {
       devices := getDevicesFromMounts(mounts)
       if devices != nil {
          return devices
       }
    }

    // Fallback to reading from the environment variable if privileges are correct
    devices := getDevicesFromEnvvar(image, hookConfig.getSwarmResourceEnvvars())
    if devices == nil {
       return nil
    }
    if privileged || hookConfig.AcceptEnvvarUnprivileged {
       return devices
    }

    configName := hookConfig.getConfigOption("AcceptEnvvarUnprivileged")
    log.Printf("Ignoring devices specified in NVIDIA_VISIBLE_DEVICES (privileged=%v, %v=%v) ", privileged, configName, hookConfig.AcceptEnvvarUnprivileged)

    return nil
}

可以看到这里根据配置不同,提供了两种解析 devices 的方法:

  • getDevicesFromMounts
  • getDevicesFromEnvvar

这也就是为什么 nvidia device plugin 除了实现 Env 之外还实现了另外的方式,二者配置应该要对应才行。

这里我们只关注
getDevicesFromEnvvar
,从环境变量里解析 Device:

envNVVisibleDevices     = "NVIDIA_VISIBLE_DEVICES"

func getDevicesFromEnvvar(image image.CUDA, swarmResourceEnvvars []string) *string {
	// We check if the image has at least one of the Swarm resource envvars defined and use this
	// if specified.
	var hasSwarmEnvvar bool
	for _, envvar := range swarmResourceEnvvars {
		if image.HasEnvvar(envvar) {
			hasSwarmEnvvar = true
			break
		}
	}

	var devices []string
	if hasSwarmEnvvar {
		devices = image.DevicesFromEnvvars(swarmResourceEnvvars...).List()
	} else {
		devices = image.DevicesFromEnvvars(envNVVisibleDevices).List()
	}

	if len(devices) == 0 {
		return nil
	}

	devicesString := strings.Join(devices, ",")

	return &devicesString
}

核心如下:

devices = image.DevicesFromEnvvars(envNVVisibleDevices).List()

从 image 里面提取
NVIDIA_VISIBLE_DEVICES
环境变量,至于这个 Env 是哪里来的,也是容器 Spec 中定义的,之前 image 是这样初始化的:

	s := loadSpec(path.Join(b, "config.json"))

	image, err := image.New(
		image.WithEnv(s.Process.Env), // 这里把容器 env 传给了 image 对象
		image.WithDisableRequire(hook.DisableRequire),
	)

实际这里还有一个特殊逻辑:
如果没有设置
NVIDIA_VISIBLE_DEVICES
环境变量,也没通过其他方式解析到 device 并且还是是一个
legacy image
,那么默认使用全部 GPU

// Environment variable unset with legacy image: default to "all".
if !isSet && len(devices) == 0 && i.IsLegacy() {
  return NewVisibleDevices("all")
}

那么什么算是
legacy image
呢:

// IsLegacy returns whether the associated CUDA image is a "legacy" image. An
// image is considered legacy if it has a CUDA_VERSION environment variable defined
// and no NVIDIA_REQUIRE_CUDA environment variable defined.
func (i CUDA) IsLegacy() bool {
	legacyCudaVersion := i.env[envCUDAVersion]
	cudaRequire := i.env[envNVRequireCUDA]
	return len(legacyCudaVersion) > 0 && len(cudaRequire) == 0
}

这也就是为什么,有时候启动 Pod 并没有申请 GPU,但是 Pod 里面依旧可以看到所有 GPU,就是走了这个
legacy image
的分支逻辑。

至此,我们知道了这边 runtime 是怎么指定要把哪些 GPU 分配给容器了,接下来进入 Exec 逻辑。

Exec

Exec 部分比较短,就是这两行代码:

args := []string{getCLIPath(cli)}
err = syscall.Exec(args[0], args, env)

首先是 getCLIPath,用于寻找
nvidia-container-cli
工具的位置并作为第一个参数。

func getCLIPath(config config.ContainerCLIConfig) string {
    if config.Path != "" {
       return config.Path
    }

    if err := os.Setenv("PATH", lookup.GetPath(config.Root)); err != nil {
       log.Panicln("couldn't set PATH variable:", err)
    }

    path, err := exec.LookPath("nvidia-container-cli")
    if err != nil {
       log.Panicln("couldn't find binary nvidia-container-cli in", os.Getenv("PATH"), ":", err)
    }
    return path
}

可以看到,如果单独配置了 cli 的位置参数就使用配置的位置,否则使用 LookPath 根据名字寻找。

然后是相关的参数

    args := []string{getCLIPath(cli)}
    if cli.Root != "" {
       args = append(args, fmt.Sprintf("--root=%s", cli.Root))
    }
    if cli.LoadKmods {
       args = append(args, "--load-kmods")
    }
    if cli.NoPivot {
       args = append(args, "--no-pivot")
    }
    if *debugflag {
       args = append(args, "--debug=/dev/stderr")
    } else if cli.Debug != "" {
       args = append(args, fmt.Sprintf("--debug=%s", cli.Debug))
    }
    if cli.Ldcache != "" {
       args = append(args, fmt.Sprintf("--ldcache=%s", cli.Ldcache))
    }
    if cli.User != "" {
       args = append(args, fmt.Sprintf("--user=%s", cli.User))
    }
    args = append(args, "configure")

    if ldconfigPath := cli.NormalizeLDConfigPath(); ldconfigPath != "" {
       args = append(args, fmt.Sprintf("--ldconfig=%s", ldconfigPath))
    }
    if cli.NoCgroups {
       args = append(args, "--no-cgroups")
    }
    if len(nvidia.Devices) > 0 {
       args = append(args, fmt.Sprintf("--device=%s", nvidia.Devices))
    }
    if len(nvidia.MigConfigDevices) > 0 {
       args = append(args, fmt.Sprintf("--mig-config=%s", nvidia.MigConfigDevices))
    }
    if len(nvidia.MigMonitorDevices) > 0 {
       args = append(args, fmt.Sprintf("--mig-monitor=%s", nvidia.MigMonitorDevices))
    }
    if len(nvidia.ImexChannels) > 0 {
       args = append(args, fmt.Sprintf("--imex-channel=%s", nvidia.ImexChannels))
    }

    for _, cap := range strings.Split(nvidia.DriverCapabilities, ",") {
       if len(cap) == 0 {
          break
       }
       args = append(args, capabilityToCLI(cap))
    }

    for _, req := range nvidia.Requirements {
       args = append(args, fmt.Sprintf("--require=%s", req))
    }

    args = append(args, fmt.Sprintf("--pid=%s", strconv.FormatUint(uint64(container.Pid), 10)))
    args = append(args, rootfs)

    env := append(os.Environ(), cli.Environment...)

其中

args = append(args, "configure")

表示执行的是
nvidia-container-cli configure
命令。

最后则是调用 syscall.Exec 真正开始执行命令

err = syscall.Exec(args[0], args, env)

该命令具体在做什么呢,接着分析
nvidia-container-cli
实现。

nvidia-container-cli

nvidia-container-cli
是一个 C 写的小工具,主要作用就是根据上执行命令时传递的参数,把GPU 设备及其相关依赖库挂载到容器中,使得容器能够正常使用 GPU 能力。

简单看下部分代码。

首先是驱动信息:

// https://github.com/NVIDIA/libnvidia-container/blob/master/src/cli/configure.c#L279-L288

/* Query the driver and device information. */
if (perm_set_capabilities(&err, CAP_EFFECTIVE, ecaps[NVC_INFO], ecaps_size(NVC_INFO)) < 0) {
        warnx("permission error: %s", err.msg);
        goto fail;
}
if ((drv = libnvc.driver_info_new(nvc, NULL)) == NULL ||
    (dev = libnvc.device_info_new(nvc, NULL)) == NULL) {
        warnx("detection error: %s", libnvc.error(nvc));
        goto fail;
}
  • nvc_driver_info_new()
    :获取 CUDA Driver 信息
  • nvc_device_info_new()
    :获取 GPU Drvier 信息

然后获取容器中可见的 GPU 列表

// https://github.com/NVIDIA/libnvidia-container/blob/master/src/cli/configure.c#L308-L314

        /* Select the visible GPU devices. */
        if (dev->ngpus > 0) {
                if (select_devices(&err, ctx->devices, dev, &devices) < 0) {
                        warnx("device error: %s", err.msg);
                        goto fail;
                }
        }

最后则是将相关驱动挂载到容器里去:

// https://github.com/NVIDIA/libnvidia-container/blob/master/src/cli/configure.c#L362-L408

/* Mount the driver, visible devices, mig-configs and mig-monitors. */
if (perm_set_capabilities(&err, CAP_EFFECTIVE, ecaps[NVC_MOUNT], ecaps_size(NVC_MOUNT)) < 0) {
        warnx("permission error: %s", err.msg);
        goto fail;
}
if (libnvc.driver_mount(nvc, cnt, drv) < 0) {
        warnx("mount error: %s", libnvc.error(nvc));
        goto fail;
}
for (size_t i = 0; i < devices.ngpus; ++i) {
        if (libnvc.device_mount(nvc, cnt, devices.gpus[i]) < 0) {
                warnx("mount error: %s", libnvc.error(nvc));
                goto fail;
        }
}

libnvidia-container
是采用 linux c
mount --bind
功能将 CUDA Driver Libraries/Binaries一个个挂载到容器里,而不是将整个目录挂载到容器中。

可通过
NVIDIA_DRIVER_CAPABILITIES
环境变量指定要挂载的 driver libraries/binaries。

例如:

docker run -e NVIDIA_VISIBLE_DEVICES=0,1 -e NVIDIA_DRIVER_CAPABILITIES=compute,utility -it tensorflow/tensorflow:latest-gpu bash

指定
NVIDIA_DRIVER_CAPABILITIES=compute,utility
就会把 compute 和 utility 相关的库挂载进去。

这样容器里就可以使用 GPU 了。

至此,相关源码就分析完成了。

5. 小结

整个流程如下:

  • 1)device plugin 上报节点上的 GPU 信息
  • 2)用户创建 Pod,在 resources.rquest 中申请 GPU,Scheduler 根据各节点 GPU 资源情况,将 Pod 调度到一个有足够 GPU 的节点
  • 3)DevicePlugin 根据 Pod 中申请的 GPU 资源,为容器添加
    NVIDIA_VISIBLE_DEVICES
    环境变量
    • 例如:
      NVIDIA_VISIBLE_DEVICES=GPU-03f69c50-207a-2038-9b45-23cac89cb67d
  • 4)docker / containerd 启动容器
    • 由于配置了 nvidia-container-runtime,因此会使用 nvidia-container-runtime 来创建容器
    • nvidia-container-runtime 额外做了一件事:将
      nvidia-container-runtime-hook
      作为 prestart hook 添加到容器 spec 中,然后就将容器 spec 信息往后传给 runC 了。
    • runC 创建容器前会调用 prestart hook,其中就包括了上一步添加的 nvidia-container-runtime-hook,该 hook 主要做两件事:
      • 从容器 Spec 的 mounts 或者 env 中解析 GPU 信息
      • 调用
        nvidia-container-cli
        命令,将 NVIDIA 的 GPU Driver、CUDA Driver 等库文件挂载进容器,保证容器内可以使用被指定的 GPU以及对应能力

核心就是两个部分:

  • device plugin 根据 GPU 资源申请为容器添加
    NVIDIA_VISIBLE_DEVICES
    环境变量
  • nvidia-container-toolkit 则是根据
    NVIDIA_VISIBLE_DEVICES
    环境变量将 GPU、驱动等相关文件挂载到容器里。

看源码同时顺带解决了一个,之前遇到过的问题:
为什么 Pod 明明没有申请 GPU,启动后也能看到所有 GPU?

这是因为 nvidia-container-toolkit 中存在特殊逻辑,没有设置
NVIDIA_VISIBLE_DEVICES
环境变量,也没通过其他方式解析到 device 并且还是一个
legacy image
,那么默认会返回
all
,即:
NVIDIA_VISIBLE_DEVICES=all
,因此该 Pod 能看到全部 GPU。

6. 参考

NVIDIA/k8s-device-plugin

NVIDIA/nvidia-container-toolkit

https://github.com/NVIDIA/libnvidia-container

Nvidia GPU如何在Kubernetes 里工作-阿里云开发者社区

NVIDIA Docker CUDA容器化原理分析

之前讲过的《
JHM
》是一个java的基准测试框架,一般用于测试jdk里的API。如果要测试http接口,可以使用Gatling。

你可能用过JMeter,也是可以的

原生的Gatling是基于代码测试的,没有UI。你需要创建一个maven项目,或者下载官方的例子:
https://github.com/gatling/gatling-js-demo/archive/refs/heads/main.zip

官方文档参考:
https://docs.gatling.io/reference/install/oss/

配置

打开这个例子项目,找到类
ComputerDatabaseSimulation
进行修改。

比如我想测试
http://localhost:8080/unified-list-service/dpa/landing
这个地址。配置如下:

import io.gatling.javaapi.core.ScenarioBuilder;
import io.gatling.javaapi.core.Simulation;
import io.gatling.javaapi.http.HttpProtocolBuilder;

import static io.gatling.javaapi.core.CoreDsl.atOnceUsers;
import static io.gatling.javaapi.core.CoreDsl.constantUsersPerSec;
import static io.gatling.javaapi.core.CoreDsl.scenario;
import static io.gatling.javaapi.http.HttpDsl.http;

public class ComputerDatabaseSimulation extends Simulation {
    HttpProtocolBuilder httpProtocol =
            http.baseUrl("http://localhost:8080/unified-list-service/dpa")
                    .acceptHeader("*/*");
    ScenarioBuilder myScenario = scenario("My Scenario")
            .exec(http("Request pure").get("/landing"));

    {
        setUp(myScenario.injectOpen(constantUsersPerSec(300).during(10)))
                .protocols(httpProtocol);
    }
}

这个配置的意思是每秒生成300个请求,连续生成10秒,共计3000个请求。

如果想直接生成这3000个请求,可以使用

setUp(myScenario.injectOpen(atOnceUsers(3000))).protocols(httpProtocol);

setUp可以接受多个场景,比如

        setUp(
                myScenario1.injectOpen(constantUsersPerSec(300).during(10)), 
                myScenario2.injectOpen(atOnceUsers(300))
        ).protocols(httpProtocol);

每个场景也可以执行多种请求,比如:

    ScenarioBuilder myScenario1 = scenario("My Scenario")
            .exec(
                    http("Request pure").get("/landing"), 
                    http("Request swid").get("/landing?swid={d80a2714}")
            ).exec(http("Request 1").get("/landing?date={today}"));

运行

执行命令
mvn gatling:test
image

在现代应用开发中,配置管理是一个非常重要的部分。随着微服务、容器化和云原生架构的流行,使用简单、易读的配置格式变得尤为重要。在 .NET 开发中,虽然 JSON 是默认的配置文件格式,但 YAML("YAML Ain't Markup Language")正越来越受到开发者的青睐。

YAML 是什么?

YAML 是一种人类可读的数据序列化标准,常用于配置文件。它以其简洁的语法和对层次结构的友好支持,成为管理复杂配置的热门选择。


使用 YAML 的优势

1. 可读性强,适合复杂配置

YAML 以缩进表示层次结构,减少了括号和逗号等符号的使用,使配置文件更加简洁直观。

  • JSON 示例

    {
        "Logging": {
            "LogLevel": {
                "Default": "Information",
                "Microsoft": "Warning",
                "Microsoft.Hosting.Lifetime": "Information"
            }
        }
    }
    
  • YAML 示例

    Logging:
      LogLevel:
        Default: Information
        Microsoft: Warning
        Microsoft.Hosting.Lifetime: Information
    

    YAML 更加贴近人类语言,尤其在嵌套结构较多时,可读性远高于 JSON。
2. 支持多种数据类型

YAML 支持字符串、数字、布尔值、数组和字典等多种数据类型,且语法简洁。例如:

AppSettings:
  Enabled: true
  MaxRetries: 5
  Endpoints:
    - https://api.example.com
    - https://backup.example.com
3. 适合 DevOps 和云原生场景

YAML 是 Kubernetes 和 CI/CD 工具(如 GitHub Actions、Azure Pipelines)的标准配置语言。使用 YAML 统一配置语言可以减少工具之间的学习成本和切换成本。

4. 灵活的注释支持

YAML 支持注释功能(使用
#
),开发者可以在配置文件中添加详细的注释,方便团队协作和配置维护。

# 应用程序设置
AppSettings:
  Enabled: true  # 是否启用功能
  MaxRetries: 5  # 最大重试次数
5. 更好的合并和覆盖能力

YAML 文件的层次结构和键信息可以轻松支持配置的合并与覆盖。这对于微服务架构中的多环境(开发、测试、生产)配置管理非常方便。


在 .NET 中使用 YAML 配置文件

虽然 .NET 默认支持 JSON 配置文件,但通过引入一些库,可以轻松实现 YAML 配置的支持。

1. 使用
YamlDotNet
解析 YAML

YamlDotNet
是一个流行的 .NET 库,用于解析和生成 YAML 文件。

安装 NuGet 包:

dotnet add package YamlDotNet 

YamlDotNet读取 YAML 文件示例:

using System;
using System.IO;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

var yaml = File.ReadAllText("appsettings.yaml");
var deserializer = new DeserializerBuilder()
    .WithNamingConvention(CamelCaseNamingConvention.Instance)  // 使用驼峰命名约定
    .Build();
var config = deserializer.Deserialize<Dictionary<string, object>>(yaml);

Console.WriteLine(config["AppSettings"]);
2. 集成 YAML 与 ASP.NET Core 配置系统

通过第三方包,如
Microsoft.Extensions.Configuration.Yaml
,可以直接将 YAML 文件集成到 ASP.NET Core 的配置管道。

安装 NuGet 包:

dotnet add package Microsoft.Extensions.Configuration.Yaml


Program.cs
中添加 YAML 配置支持:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddYamlFile("appsettings.yaml", optional: true, reloadOnChange: true);

var app = builder.Build();
app.Run();


多环境配置管理与 Patch 技术

在实际开发中,应用需要针对不同环境(开发、测试、生产)设置不同的配置。通过 YAML 和配置覆盖技术,可以简化多环境配置管理。

1. 多环境配置文件

可以为不同环境创建多个 YAML 文件,例如:

  • appsettings.yaml
    : 默认配置
  • appsettings.Development.yaml
    : 开发环境配置
  • appsettings.Production.yaml
    : 生产环境配置
2. 配置文件的加载顺序

在 .NET 中,可以通过以下代码按顺序加载配置文件:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
    .AddYamlFile("appsettings.yaml", optional: true, reloadOnChange: true)
    .AddYamlFile($"appsettings.{builder.Environment.EnvironmentName}.yaml", optional: true, reloadOnChange: true);

var app = builder.Build();
app.Run();

加载时,后面的文件会覆盖前面的配置。例如,
appsettings.Production.yaml
的设置会覆盖
appsettings.yaml
中的默认值。

3. 使用 Patch 技术动态调整配置

YAML 支持通过层次化的结构灵活地合并和覆盖配置。例如,通过工具或代码动态应用补丁:

  • YAML Patch 示例

    Logging:
      LogLevel:
        Default: Debug  # 修改默认日志级别
    
  • 在代码中合并补丁:
    var patchYaml = File.ReadAllText("patch.yaml");
    var patchConfig = deserializer.Deserialize<Dictionary<string, object>>(patchYaml);
    
    foreach (var key in patchConfig.Keys)
    {
        originalConfig[key] = patchConfig[key];
    }
    

这种动态补丁机制非常适合热更新配置或应对突发的环境需求。


YAML 配置的适用场景

1. 微服务架构

微服务需要管理复杂的配置文件,如服务发现、负载均衡、日志记录等。YAML 简洁的格式非常适合这类场景。

2. DevOps 工具链

在 Kubernetes、Docker Compose 和 CI/CD 工具中,YAML 是事实上的标准。如果 .NET 应用也使用 YAML 配置,能够无缝对接这些工具。

3. 多环境配置管理

对于开发、测试、生产等多环境配置,YAML 的层次化结构和易读性更便于团队协作。


YAML 配置的注意事项

尽管 YAML 有诸多优势,但在使用时仍需注意:

  1. 严格的缩进要求
    :缩进错误可能导致解析失败,应统一缩进风格(如使用 2 个或 4 个空格)。
  2. 文件合并冲突
    :多人协作时,复杂的层次结构可能增加文件合并的难度。
  3. 性能问题
    :YAML 文件解析速度稍逊于 JSON,对于极端性能敏感的场景需评估解析性能。


总结

YAML 在 .NET 中作为配置文件格式的优势显而易见:

  • 它简洁直观,可读性强,特别适合复杂配置。
  • 支持多种数据类型和注释功能,方便团队协作。
  • 与 Kubernetes 等现代云原生工具链的兼容性极高。

通过引入合适的库,.NET 开发者可以轻松使用 YAML 管理应用配置,提升开发效率并优化配置管理流程。如果你的项目需要处理复杂的配置文件或者与云原生生态紧密集成,YAML 无疑是一个值得尝试的选择。

背景

最近因为工作需要,一堆内网的虚拟机之间需要频繁cp一些文件、视频等,因为都是麒麟系统,有桌面版有服务器版,用scp这种工具也是比较繁琐,索性就搭建一套内网用的共享网盘和在线文档,既方便了自己,也方便了团队。
因为多年前使用过NextCloud,还比较好用,所以就继续用它吧,但这次添加了在线文档(OnlyOffice),没想到还是有一点坑的,搞一篇文档记录,方便以后有此需求的朋友们能快速搭建使用起来。

开始部署

如何选择部署方案?

单机

我这里因为使用人并不会特别多,也不会保存特别多的重要数据,并没有做服务和数据方面的高可用考虑,所以直接就采用docker部署,快捷,高效。
但需要注意,NextCloud官网给出了两种docker单机部署的方式,需要擦亮眼睛:
第一种:
All-In-One(AIO),顾名思义,就是将所有NextCloud所需的所有组件(或者逻辑)全部封装到了这一个镜像中,只需要自己手动的启动这一个服务,就OK了,但是这里有一个很坑的点,就是部署AIO的这台机器,必须可以访问外网,因为当AIO启动后,并不是说只会有这么一个容器运行,而是它服务内部会再有逻辑去拉取新的镜像,启动新的服务容器,或下载某些文件等,如果你的机器不可以连外网,那AIO将不能使用,所以如果是纯内网的环境,AIO这种方式是不可取的。
第二种:
就是正常的docker run或者docker-compose来运行nextcloud的镜像,这个不会再次依赖外网,只需你将使用到的镜像全部提前下载下来就可以,本次也是采用这种方式。

高可用集群

如果你有比较多的重要数据,且保存的时间也不会短,或者使用的人会很多,那建议将高可用考虑进去:
1.可以使用k8s或其他方式部署多个nextcloud和onlyoffice,用来保证服务的高可用性,需要注意,它们得用同一套数据存储
2.数据层面,mysql得搞个集群,如果用Redis了也搞个主从,另外存储再引进来对象存储(oss、ceph、minio任你挑),来保证不会出现数据丢失

Docker方面

Docker和docker-compose的部署就不再赘述了,大家都轻车熟路。
但是由于现在镜像拉取被禁了,所以很多人拉不到镜像,这个我认为要简单过一下,我这里用过的有两种方式:

第一种:
配置镜像加速器(中转站)
把加速器配到docker engine中,也别一个两个了,全加:

sudo tee /etc/docker/daemon.json <<-'EOF'
{
    "registry-mirrors": [
        "https://do.nark.eu.org",
        "https://dc.j8.work",
        "https://docker.m.daocloud.io",
        "https://dockerproxy.com",
        "https://docker.mirrors.ustc.edu.cn",
        "https://docker.nju.edu.cn",
        "https://registry.docker-cn.com",
        "https://hub-mirror.c.163.com",
        "https://hub.uuuadc.top",
        "https://docker.anyhub.us.kg",
        "https://dockerhub.jobcher.com",
        "https://dockerhub.icu",
        "https://docker.ckyl.me",
        "https://docker.awsl9527.cn",
        "https://mirror.baidubce.com",
        "https://docker.1panel.live",
        "https://2epe3hl0.mirror.aliyuncs.com"
    ]
}
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker

第二种:
如果你可以FQ的话
给docker设置http/https代理,然后打开梯子,就可以直接pull了。(地址、端口有变的话,记得改)

配置好重启一下,就可以了。

第三种:
如果前两种方式你都不行,恰巧你还是x86架构的机器,那本次使用到的镜像可以直接来网盘这里取

链接: https://pan.baidu.com/s/1WKCywb4cKTFj9uDHSHe4Wg?pwd=24fc 提取码: 24fc

部署

先把最终用到的yaml文件提供出来:
vim docker-compose.yaml

version: '3'

volumes:
  nextcloud_volume:
  db_volume:
  redis_volume:
  office_volume:

services:
  db:
    image: mariadb:10.11
    restart: always
    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
    networks:
      - nextcloud_netbridge
    volumes:
      - nextcloud_volume:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=nextcloud@1234
      - MYSQL_PASSWORD=nextcloud@1234
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud

  redis:
    image: redis:alpine
    command: redis-server /etc/redis/redis.conf
    restart: always
    volumes:
      - redis_volume:/data
      - ./redis.conf:/etc/redis/redis.conf
    environment:
      - REDIS_PASSWORD=nextcloud@1234
    networks:
      - nextcloud_netbridge

  app:
    image: nextcloud:latest
    depends_on:
      - db
      - redis
    restart: always
    ports:
      - 9090:80
    networks:
      - nextcloud_netbridge
    links:
      - db
      - redis
      - onlyoffice
    volumes:
      - db_volume:/var/www/html
    environment:
      - MYSQL_PASSWORD=nextcloud@1234
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_HOST=db
      - REDIS_HOST=redis
      - REDIS_HOST_PASSWORD=nextcloud@1234
      # 以下配置是nextcloud配置https需要设定的,如果不配可注释,但是nextcloud推荐配,不配有些限制打不开。
      # https代理信任的ip地址,配置为nginx服务器ip
      - TRUSTED_PROXIES=10.44.61.172
      # https重写地址,配置为nginx服务ip+https的端口
      - OVERWRITEHOST=10.44.61.172:9091
      # 重写协议,https
      - OVERWRITEPROTOCOL=https
      # 客户端url,配置nginx https完整的访问地址
      - OVERWRITECLIURL=https://10.44.61.172:9091
      # 连接的地址,我这注释掉了,貌似没影响
        #- OVERWRITECONDADDR=^10\\.44\\.61\\.172$

  onlyoffice:
    image: onlyoffice/documentserver:latest
    restart: always
    depends_on:
      - db
      - redis
    volumes:
      - office_volume:/var/www/onlyoffice/Data
    ports:
      # nextcloud如果使用的https,onlyoffice也要是https,80端口其实不用映射出来
      - 9080:80
      - 9443:443 
    environment:
      # 打开认证
      JWT_ENABLED: 'true'
      JWT_SECRET: 'onlyoffice@1234'
    networks:
      - nextcloud_netbridge

networks:
  nextcloud_netbridge:
    driver: bridge

vim redis.coinf

port 6379
bind 0.0.0.0
maxmemory 8192mb
maxmemory-policy allkeys-lru
requirepass nextcloud@1234
timeout 0
databases 16

准备完毕后,直接启动即可:

docker-copmse up -d

配置https

NextCloud配置https

想配置https,必须要有证书,可以购买、可以阿里云申请个临时免费的、也可以搞个自签名的,长期内网的话,自签名的就够了:

mkdir -p /data/nextcloud/ssl/ && cd /data/nextcloud/ssl/
openssl genrsa -out nextcloud.key 2048
openssl req -new -key nextcloud.key -out csr.csr
openssl x509 -req -days 3650 -in csr.csr -signkey nextcloud.key -out nextcloud.crt

随后直接使用nginx来配置个反向代理即可,配置如下:
vim /etc/nginx/conf.d/nextcloud.conf

# nextcloud
server {
    listen 9091 ssl http2;
    listen [::]:9091 ssl http2;
    #server_name yourdomain.com;
    ssl_certificate /data/nextcloud/ssl/nextcloud.crt;
    ssl_certificate_key /data/nextcloud/ssl/nextcloud.key;
    ssl_session_timeout 5m;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!DHE:!RC4;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    client_max_body_size 0;
    client_body_timeout 3600s;
    fastcgi_buffers 64 4K;
    client_body_buffer_size 512k;

    add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;
    location / {
        proxy_pass http://10.44.61.172:9090/;
        proxy_set_header HOST $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 1800;
        proxy_send_timeout 1800;
        proxy_read_timeout 1800;
        proxy_buffering off;
        send_timeout 1800;
    }

    location ^~ /.well-known {
        # The rules in this block are an adaptation of the rules
        # in `.htaccess` that concern `/.well-known`.
        location = /.well-known/carddav { return 301 /remote.php/dav/; }
        location = /.well-known/caldav  { return 301 /remote.php/dav/; }
        location /.well-known/acme-challenge    { try_files $uri $uri/ =404; }
        location /.well-known/pki-validation    { try_files $uri $uri/ =404; }

        # Let Nextcloud's API for `/.well-known` URIs handle all other
        # requests by passing them to the front-end controller.
        return 301 /index.php$request_uri;
    }
}

以上的配置都是必要的,最好不要进行删减,其中涉及了上传大小限制的修改、timeout的修改以及一些官方推荐要加的配置。

nginx -t 
systemctl reload nginx 

重新加载nginx配置后,理论上就可以通过https://ip:9091(没改端口的话)来访问nextcloud,并配置账号密码了:

OnlyOffice配置https

OnlyOffice配置https较为简单,只需docker中创建的office_volume卷下创建一个certs目录,然后将证书放置这个目录下,再重启服务即可。
例如,我的office_volume目录是:

# docker volume inspect nextcloud_office_volume                                    
[
    {     
        "Labels": { 
            "com.docker.compose.project": "nextcloud",
            "com.docker.compose.version": "1.22.0",
            "com.docker.compose.volume": "office_volume" 
        },                                                                                              
        "Mountpoint": "/data/docker-home/volumes/nextcloud_office_volume/_data", 
        "Name": "nextcloud_office_volume",                    
    }
]

那我仅需要:(证书和私钥必须是onlyoffice.crt和onlyoffice.key,注意是必须,不然没法识别,或者修改onlyoffice镜像中的nginx https的配置,修改证书和私钥的名称)

cd /data/docker-home/volumes/nextcloud_office_volume/_data
mkdir certs && cd certs

# 生成证书
openssl genrsa -out onlyoffice.key 2048
openssl req -new -key onlyoffice.key -out csr.csr
openssl x509 -req -days 3650 -in csr.csr -signkey onlyoffice.key -out onlyoffice.crt

创建完毕后,重启onlyoffice服务,访问https://ip:9443(如果你没改的话),出现以下页面,表示配置成功:

NextCloud配置OnlyOffice

nextcloud和onlyoffice都部署完毕后,需要在nextcloud下配置一下onlyoffice才可以识别。
首先要下载onlyoffice插件,并放置到nextcloud的指定位置,插件下载地址:
https://apps.nextcloud.com/apps/onlyoffice

下载对应的版本,如果你是用的我提供的镜像,那就是30版本,下载9.5.0就可以,需要梯子,当然,包我也已经放置到上边的网盘中,可以直接拿。

加载onlyoffice插件

需要将下载后的插件解压到docker的中创建的db_volume卷下的apps目录下就可以了:

# docker volume inspect nextcloud_db_volume                                         
[                                                                                                       
    {                                                                                                                                                                            
        "Labels": {                                                                                     
            "com.docker.compose.project": "nextcloud",                                                  
            "com.docker.compose.version": "1.22.0",                                                     
            "com.docker.compose.volume": "db_volume"                                                    
        },                                                                                              
        "Mountpoint": "/data/docker-home/volumes/nextcloud_db_volume/_data",                            
        "Name": "nextcloud_db_volume",                                                                                                                                             
    }                                                                                                   
]

解压:(记得改为自己的目录路径)

tar xf onlyoffice.tar.gz -C /data/docker-home/volumes/nextcloud_db_volume/_data/apps/
# 验证
ls -d /data/docker-home/volumes/nextcloud_db_volume/_data/apps/onlyoffice

配置onlyoffice地址

以上操作完成后,进入nextcloud界面,点击右上角头像-->应用-->你的应用-->搜索onlyoffice:

能搜索到应用就表示插件加载成功,没有的话重启一下nextcloud服务,再来看一下。
加载好插件后,点击右上角头像-->管理设置-->ONLYOFFICE-->配置onlyoffice的https地址和密码(密码是上边compose文件中设定的):

点击保存,出现onlyoffice的设置界面,就表示配置成功了。
正常情况下,到此就算配置完成了,但是因为我们使用的是自签名证书,所以程序会出现不信任的情况,还要做几点修改,详看FAQ。

一定要看的FAQ

NextCloud打开在线文档时报错下载失败

onlyoffice服务报错:Error: self signed certificate
这个问题是因为我们的https证书是自签名的,有两种解决方案,第一种是跳过证书校验,第二种是买CA认证的证书,相信你会选择第一种:

# 进入onlyoffice容器内
docker exec -it nextcloud_onlyoffice_1 bash

# 修改默认配置文件将"rejectUnauthorized": true 修改为 "rejectUnauthorized": false
nano /etc/onlyoffice/documentserver/default.json
...
                        "requestDefaults": {                                                            
                                "headers": {                                                            
                                        "User-Agent": "Node.js/6.13",                                   
                                        "Connection": "Keep-Alive"                                      
                                },                                                                      
                                "gzip": true,                                                           
                                "rejectUnauthorized": false                                             
                        },
...

# 保存,退出,重启 onlyoffice 和 nextcloud 服务
docker-compose restart app onlyoffice

你也可以将文件映射到宿主机,方便保存和修改。
到此网盘功能和在线文档功能就可以正常使用了,开始吧:

分享的文档,不能复制共享链接?

我分享了一个文件或者目录,但是点击复制共享链接没反应?
这说明你NextCloud配置的https并没有生效,或者说你是通过http访问的,所以不能复制,想复制的话,必须https!

上传大文件时报错文件过大或超时?

想要上传大文件的话需要做一些参数调整优化:
1.一个是nginx反向代理中的客户端包大小和超时时间,这个如果直接复制的我上边的nginx配置,那就已经添加好了
2.第二个要配置PHP的上传大小限制,要在nextcloud服务的volume中,有个.user.ini文件,我的在/data/docker-home/volumes/nextcloud_db_volume/_data/.user.ini,添加以下配置:

php_value upload_max_filesize 16G
php_value post_max_size 16G
php_value max_input_time 3600
php_value max_execution_time 3600

保存后,重启nextcloud服务。

其他的一些奇奇怪怪的报错?

其他的一些报错,例如nextcloud配置https后还是访问不到啊,或者是nextcloud链接onlyoffice时一直报错地址连接错误等等。
这些问题首先要查看对应容器的日志,根据具体报错再进一步排查。
我在部署过程中也有遇到过,是因为我对参数的理解不太正确,或者其他误操作,但是在做上文总结时已经规避,如果你是按照这篇文档来一步步操作的话,理应是没有什么问题。
当然,如果真的遇到了其他问题,欢迎评论区留言一起解决。

前言

在C#编程中字符串拼接是一种常见且基础的操作,广泛应用于各种场景,如动态生成SQL查询、构建日志信息、格式化用户显示内容等。然而,不同的字符串拼接方式在性能和内存使用上可能存在显著差异。今天咱们一起来看看在C#中字符串拼接的常见6种方式及其使用
BenchmarkDotNet
进行性能分析对比。

BenchmarkDotNet

BenchmarkDotNet是一个基于.NET开源、功能全面、易于使用的性能基准测试框架,它为.NET开发者提供了强大的性能评估和优化能力。通过自动化测试、多平台支持、高级统计分析和自定义配置等特性,BenchmarkDotNet帮助开发者更好地理解和优化软件系统的性能表现。

拼接基础数据

private const int IterationCount = 1000;
private const string StringPart1 = "追逐时光者";
private const string StringPart2 = "DotNetGuide";
private const string StringPart3 = "DotNetGuide技术社区";
private readonly string[] _stringPartsArray = { "追逐时光者", "DotNetGuide", "DotNetGuide技术社区" };

+
操作符

        /// <summary>
        /// 使用 + 操作符拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string PlusOperator()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += StringPart1 + " " + StringPart2 + " " + StringPart3;
            }
            return result;
        }

$
内插字符串

        /// <summary>
        /// 使用 $ 内插字符串拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string InterpolatedString()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += $"{StringPart1} {StringPart2} {StringPart3}";
            }
            return result;
        }

String.Format

        /// <summary>
        /// 使用string.Format()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringFormat()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Format("{0} {1} {2}", StringPart1, StringPart2, StringPart3);
            }
            return result;
        }

String.Concat

        /// <summary>
        /// 使用string.Concat()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringConcat()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Concat(StringPart1, " ", StringPart2, " ", StringPart3);
            }
            return result;
        }

String.Join

        /// <summary>
        /// 使用string.Join()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringJoin()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Join(" ", _stringPartsArray);
            }
            return result;
        }

StringBuilder

        /// <summary>
        /// 使用StringBuilder拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringBuilder()
        {
            StringBuilder stringBuilder = new StringBuilder();
            for (int i = 0; i < IterationCount; i++)
            {
                stringBuilder.Append(StringPart1);
                stringBuilder.Append(" ");
                stringBuilder.Append(StringPart2);
                stringBuilder.Append(" ");
                stringBuilder.Append(StringPart3);
            }
            return stringBuilder.ToString();
        }

性能基准对比测试完整代码

    [MemoryDiagnoser]//记录内存分配情况
    public class StringConcatenationBenchmark
    {
        private const int IterationCount = 1000;
        private const string StringPart1 = "追逐时光者";
        private const string StringPart2 = "DotNetGuide";
        private const string StringPart3 = "DotNetGuide技术社区";
        private readonly string[] _stringPartsArray = { "追逐时光者", "DotNetGuide", "DotNetGuide技术社区" };

        /// <summary>
        /// 使用 + 操作符拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string PlusOperator()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += StringPart1 + " " + StringPart2 + " " + StringPart3;
            }
            return result;
        }

        /// <summary>
        /// 使用 $ 内插字符串拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string InterpolatedString()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += $"{StringPart1} {StringPart2} {StringPart3}";
            }
            return result;
        }

        /// <summary>
        /// 使用string.Format()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringFormat()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Format("{0} {1} {2}", StringPart1, StringPart2, StringPart3);
            }
            return result;
        }

        /// <summary>
        /// 使用string.Concat()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringConcat()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Concat(StringPart1, " ", StringPart2, " ", StringPart3);
            }
            return result;
        }

        /// <summary>
        /// 使用string.Join()拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringJoin()
        {
            string result = string.Empty;
            for (int i = 0; i < IterationCount; i++)
            {
                result += string.Join(" ", _stringPartsArray);
            }
            return result;
        }

        /// <summary>
        /// 使用StringBuilder拼接字符串
        /// </summary>
        /// <returns></returns>
        [Benchmark]
        public string StringBuilder()
        {
            StringBuilder stringBuilder = new StringBuilder();
            for (int i = 0; i < IterationCount; i++)
            {
                stringBuilder.Append(StringPart1);
                stringBuilder.Append(" ");
                stringBuilder.Append(StringPart2);
                stringBuilder.Append(" ");
                stringBuilder.Append(StringPart3);
            }
            return stringBuilder.ToString();
        }
    }

性能基准对比测试分析报告

Method Mean Error StdDev Gen0 Gen1 Allocated
PlusOperator 2,066.28 us 35.761 us 63.566 us 5238.2813 789.0625 32283.12 KB
InterpolatedString 1,984.56 us 29.949 us 28.014 us 5238.2813 789.0625 32283.12 KB
StringFormat 2,112.02 us 25.020 us 23.404 us 5257.8125 777.3438 32369.06 KB
StringConcat 2,027.09 us 28.300 us 26.472 us 5257.8125 777.3438 32369.06 KB
StringJoin 2,017.36 us 27.111 us 22.639 us 5257.8125 777.3438 32369.06 KB
StringBuilder 13.63 us 0.065 us 0.058 us 23.2544 4.6387 143.96 KB

说明:

  • Mean
    : 所有测量值的算术平均值。
  • Error
    : 99.9% 置信区间的一半。
  • StdDev
    : 所有测量值的标准差。
  • Gen0
    : 第 0 代 GC 每 1000 次操作收集一次。
  • Gen1
    : 第 1 代 GC 每 1000 次操作收集一次。
  • Gen2
    : 第 2 代 GC 每 1000 次操作收集一次。
  • Allocated
    : 每次操作分配的内存(仅托管内存,包含所有内容,1KB = 1024B)。
  • 1 ms
    : 1 毫秒(0.001 秒)。

性能基准对比测试结论

从上面的性能基准对比测试分析报告来看
StringBuilder
是性能最好的字符串拼接方式,特别是在需要频繁进行拼接的场景中。其他方式(
如+操作符

$内插字符串

String.Format

String.Concat

String.Join
)在性能上相对较差,因为它们会导致多次内存分配和复制。

因此我们在选择字符串拼接方式时,应该根据具体场景和需求进行选择。如果性能是关键因素,并且需要频繁进行拼接,则应使用
StringBuilder
。如果代码简洁性和易读性更重要,并且拼接次数较少,则可以考虑使用其他方式。