深入理解基于 eBPF 的 C/C++ 内存泄漏分析

news2024/11/18 20:01:20

对于 C/C++ 程序员来说,内存泄露问题是一个老生常谈的问题。排查内存泄露的方法有很多,比如使用 valgrind、gdb、asan、tsan 等工具,但是这些工具都有各自的局限性,比如 valgrind 会使程序运行速度变慢,gdb 需要了解代码并且手动打断点,asan 和 tsan 需要重新编译程序。对于比较复杂,并且在运行中的服务来说,这些方法都不是很方便。

好在有了 eBPF,我们可以使用它来分析内存泄露问题,不需要重新编译程序,对程序运行速度的影响也很小。eBPF 的强大有目共睹,不过 eBPF 也不是银弹,用来分析内存泄露也还是有很多问题需要解决,本文接下来就来探讨一下基于 eBPF 检测会遇到的常见问题。

内存泄露模拟

在 C/C++ 中,内存泄露是指程序在运行过程中,由于某些原因导致未能释放已经不再使用的内存,从而造成系统内存的浪费。内存泄露问题一旦发生,会导致程序运行速度减慢,甚至进程 OOM 被杀掉。内存泄露问题的发生,往往是由于在编写程序时,没有及时释放内存;或者是由于程序设计的缺陷,导致程序在运行过程中,无法释放已经不再使用的内存。

下面是一个简单的内存泄露模拟程序,程序会在循环中分配内存,但是没有释放,从而导致内存泄露。main 程序如下,发生泄露的函数调用链路是 main->caller->slowMemoryLeak:

#include <iostream>

namespace LeakLib {
    void slowMemoryLeak();
}

int caller() {
    std::cout << "In caller" << std::endl;
    LeakLib::slowMemoryLeak();
    return 0;
}
int main() {
    std::cout << "Starting slow memory leak program..." << std::endl;
    caller();
    return 0;
}

其中内存泄露的代码在 slowMemoryLeak 函数中,具体如下:

namespace LeakLib {
    void slowMemoryLeak() {
        int arrSize = 10;
        while (true) {
            int* p = new int[arrSize];
            for (int i = 0; i < arrSize; ++i) {
                p[i] = i; // Assign values to occupy physical memory
            }
            sleep(1); // wait for 1 second
        }
    }
}

注意这里编译的时候,带了帧指针选项(由 -fno-omit-frame-pointer 选项控制),这是因为 eBPF 工具需要用到帧指针来进行调用栈回溯。如果这里忽略掉帧指针的话(-fomit-frame-pointer),基于 eBPF 的工具就拿不到内存泄露的堆栈信息。完整编译命令如下(-g 可以不用加,不过这里也先加上,方便用 gdb 查看一些信息):

$ g++ main.cpp leak_lib.cpp -o main -fno-omit-frame-pointer -g

memleak 分析

接下来基于 eBPF 来进行内存分析泄露,BCC 自带了一个 memleak 内存分析工具,可以用来分析内存泄露的调用堆栈。拿前面的示例泄露代码来说,编译后执行程序,然后执行内存泄露检测 memleak -p $(pgrep main) --combined-only。

目前版本的 memleak 工具有 bug,在带 --combined-only 打印的时候,会报错。修改比较简单,我已经提了 PR #4769,已经被合并进 master。仔细看脚本的输出,发现这里调用堆栈其实不太完整,丢失了 slowMemoryLeak 这个函数调用。

[11:19:44] Top 10 stacks with outstanding allocations:
	480 bytes in 12 allocations from stack
		operator new(unsigned long)+0x1c [libstdc++.so.6.0.30]
		caller()+0x31 [main]
		main+0x31 [main]
		__libc_start_call_main+0x7a [libc.so.6]

调用链不完整

这里为啥会丢失中间的函数调用呢?我们知道eBPF 相关的工具,是通过 frame pointer 指针来进行调用堆栈回溯的,具体原理可以参考朋友的文章 消失的调用栈帧-基于fp的栈回溯原理解析。如果遇到调用链不完整,基本都是因为帧指针丢失,下面来验证下。

首先用 objdump -d -S main > main_with_source.asm 来生成带源码的汇编指令,找到 slowMemoryLeak 函数的汇编代码,如下图所示:

从这段汇编代码中,可以看到 new int[] 对应的是一次 _Znam@plt 的调用。这是 C++ 的 operator new[] 的名字修饰(name mangling)后的形式,如下:

$ c++filt _Znam
operator new[](unsigned long)

我们知道在 C++ 中,new 操作用来动态分配内存,通常会最终调用底层的内存分配函数如 malloc。这里 _Znam@plt 是通过 PLT(Procedure Linkage Table) 进行的,它是一个动态解析的符号,通常是 libstdc++(或其他 C++ 标准库的实现)中实现的 operator new[]。_Znam@plt 对应的汇编代码如下:

0000000000001030 <_Znam@plt>:
    1030:       ff 25 ca 2f 00 00       jmp    *0x2fca(%rip)        # 4000 <_Znam@GLIBCXX_3.4>
    1036:       68 00 00 00 00          push   $0x0
    103b:       e9 e0 ff ff ff          jmp    1020 <_init+0x20>

这里并没有像 slowMemoryLeak 调用一样去做 push %rbp 的操作,所以会丢失堆栈信息。这里为什么会没有保留帧指针呢?前面编译的时候带了 -fno-omit-frame-pointer 能保证我们自己的代码带上帧指针,但是对于 libstdc++ 这些依赖到的标准库,我们是无法控制的。当前系统的 C++ 标准库在编译的时候,并没有带上帧指针,可能是因为这样可以减少函数调用的开销(减少执行的指令)。是否在编译的时候默认带上 -fno-omit-frame-pointer 还是比较有争议,文章最后专门放一节:默认开启帧指针来讨论。

相关视频推荐

5种内存泄漏检测方式,让你重新理解内存管理

4种内存泄漏的解决方案,每一种背后都有哪些隐藏技术

面对内存再不发怵,手把手带你实现内存池(自行准备linux环境)

免费学习地址:Linux C/C++开发(后端/音视频/游戏/嵌入式/高性能网络/存储/基础架构/安全)

需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

tcmalloc 泄露分析

如果想拿到完整的内存泄露函数调用链路,可以带上帧指针重新编译 libstdc++,不过标准库重新编译比较麻烦。其实日常用的比较多的是 tcmalloc,内存分配管理更加高效些。这里为了验证上面的代码在 tcmalloc 下的表现,我用 -fno-omit-frame-pointer 帧指针编译了 tcmalloc 库。如下:

git clone https://github.com/gperftools/gperftools.git
cd gperftools
./autogen.sh
./configure CXXFLAGS="-fno-omit-frame-pointer" --prefix=/path/to/install/dir
make
make install

接着运行上面的二进制,重新用 memleak 来检查内存泄露,注意这里用 -O 把 libtcmalloc.so 动态库的路径也传递给了 memleak。参数值存在 obj 中,在 attach_uprobe 中用到,指定了要附加 uprobes 或 uretprobes 的二进制对象,可以是要跟踪的函数的库路径或可执行文件。详细文档可以参考 bcc: 4. attach_uprobe。比如下面的调用方法:

# 在 libc 的 getaddrinfo 函数入口打桩,当进入函数时,会调用自定义的 do_entry 函数
b.attach_uprobe(name="c", sym="getaddrinfo", fn_name="do_entry")

注意在前面的示例中,没有指定 -O,默认就是 “c”,也就是用 libc 分配内存。在用 tcmalloc 动态库的时候,这里 attach_uprobe 和 attach_uretprobe 必须要指定库路径:

bpf.attach_uprobe(name=obj, sym=sym, fn_name=fn_prefix + "_enter", pid=pid)
bpf.attach_uretprobe(name=obj, sym=sym, fn_name=fn_prefix + "_exit", pid=pid)

不过工具的输出有点出乎语料,这里竟然没有输出任何泄露的堆栈了:

$ memleak -p $(pgrep main) --combined-only -O /usr/local/lib/libtcmalloc.so
Attaching to pid 1409827, Ctrl+C to quit.
[19:55:45] Top 10 stacks with outstanding allocations:

[19:55:50] Top 10 stacks with outstanding allocations:

明明 new 分配的内存没有释放,为什么 eBPF 的工具检测不到呢?

深入工具实现

在猜测原因之前,先仔细看下 memleak 工具的代码,完整梳理下工具的实现原理。首先能明确的一点是,工具最后的输出部分,是每个调用栈以及其泄露的内存量。为了拿到这个结果,用 eBPF 分别在内存分配和释放的时候打桩,记录下当前调用栈的内存分配/释放量,然后进行统计。核心的逻辑如下:

  1. gen_alloc_enter: 在各种分配内存的地方,比如 malloc, cmalloc, realloc 等函数入口(malloc_enter)打桩(attach_uprobe),获取当前调用堆栈 id 和分配的内存大小,记录在名为 sizes 的字典中;

  2. gen_alloc_exit2: 在分配内存的函数退出位置(malloc_exit)打桩(attach_uretprobe),拿到此次分配的内存起始地址,同时从 sizes 字段拿到分配内存大小,记录 (address, stack_info) 在 allocs 字典中; 同时用 update_statistics_add 更新最后的结果字典 combined_allocs,存储栈信息和分配的内存大小,次数信息;

  3. gen_free_enter: 在释放内存的函数入口处打桩(gen_free_enter),从前面 allocs 字典中根据要释放的内存起始地址,拿到对应的栈信息,然后用 update_statistics_del 更新结果字典 combined_allocs,也就是在统计中,减去当前堆栈的内存分配总量和次数。

GDB 堆栈跟踪

接着回到前面的问题,tcmalloc 通过 new 分配的内存,为啥统计不到呢?很大可能是因为 tcmalloc 底层分配和释放内存的函数并不是 malloc/free,也不在 memleak 工具的 probe 打桩的函数内。那么怎么知道前面示例代码中,分配内存的调用链路呢?比较简单的方法就是用 GDB 调试来跟踪,注意编译 tcmalloc 库的时候,带上 debug 信息,如下:

$ ./configure CXXFLAGS="-g -fno-omit-frame-pointer" CFLAGS="-g -fno-omit-frame-pointer"

编译好后,可以用 objdump 查看 ELF 文件的头信息和各个段的列表,验证动态库中是否有 debug 信息,如下:

$ objdump -h /usr/local/lib/libtcmalloc_debug.so.4 | grep debug
/usr/local/lib/libtcmalloc_debug.so.4:     file format elf64-x86-64
 29 .debug_aranges 000082c0  0000000000000000  0000000000000000  000b8c67  2**0
 30 .debug_info   00157418  0000000000000000  0000000000000000  000c0f27  2**0
 31 .debug_abbrev 00018a9b  0000000000000000  0000000000000000  0021833f  2**0
 32 .debug_line   00028924  0000000000000000  0000000000000000  00230dda  2**0
 33 .debug_str    0009695d  0000000000000000  0000000000000000  002596fe  2**0
 34 .debug_ranges 00008b30  0000000000000000  0000000000000000  002f005b  2**0

接着重新用 debug 版本的动态库编译二进制,用 gdb 跟踪进 new 操作符的内部,得到结果如下图。可以看到确实没有调用 malloc 函数。

其实 tcmalloc 的内存分配策略还是很复杂的,里面有各种预先分配好的内存链表,申请不同大小的内存空间时,有不少的策略来选择合适的内存地址。

正常内存泄露分析

前面不管是 glibc 还是 tcmalloc,用 new 来分配内存的时候,memleak 拿到的分析结果都不是很完美。这是因为用 eBPF 分析内存泄露,必须满足两个前提:

  1. 编译二进制的时候带上帧指针(frame pointer),如果有依赖到标准库或者第三方库,也都必须带上帧指针;

  2. 实际分配内存的函数,必须在工具的 probe 打桩的函数内,比如 malloc, cmalloc, realloc 等函数;

那么下面就来看下满足这两个条件后,内存泄露的分析结果。修改上面的 leak_lib.cpp 中内存分配的代码:

// int* p = new int[arrSize];
int* p = (int*)malloc(arrSize * sizeof(int));

然后重新编译运行程序,这时候 memleak 就能拿到完整的调用栈信息了,如下:

$ g++ main.cpp leak_lib.cpp -o main -fno-omit-frame-pointer -g
# run main binary here

$ memleak -p $(pgrep main) --combined-only
Attaching to pid 2025595, Ctrl+C to quit.
[10:21:09] Top 10 stacks with outstanding allocations:
	200 bytes in 5 allocations from stack
		LeakLib::slowMemoryLeak()+0x20 [main]
		caller()+0x31 [main]
		main+0x31 [main]
		__libc_start_call_main+0x7a [libc.so.6]
[10:21:14] Top 10 stacks with outstanding allocations:
	400 bytes in 10 allocations from stack
		LeakLib::slowMemoryLeak()+0x20 [main]
		caller()+0x31 [main]
		main+0x31 [main]
		__libc_start_call_main+0x7a [libc.so.6]

如果分配内存的时候用 tcmalloc,也是可以拿到完整的泄露堆栈。

内存火焰图可视化

在我之前的 复杂 C++ 项目堆栈保留以及 ebpf 性能分析 这篇文章中,用 BCC 工具做 cpu profile 的时候,可以用 FlameGraph 把输出结果转成 CPU 火焰图,很清楚就能找到 cpu 的热点代码。对于内存泄露,我们同样也可以生成内存火焰图。

内存火焰图的生成步骤也类似 cpu 的,先用采集工具比如 BCC 脚本采集数据,然后将采集到的数据转换为 FlameGraph 可以理解的格式,之后就可以使用 FlameGraph 脚本将转换后的数据生成一个 SVG 图像。每个函数调用都对应图像中的一块,块的宽度表示该函数在采样中出现的频率,从而可以识别资源使用的热点。FlameGraph 识别的每行数据的格式通常如下:

[堆栈跟踪] [采样值]
main;foo;bar 58

这里的“堆栈跟踪”是指函数调用栈的一个快照,通常是一个由分号分隔的函数名列表,表示从调用栈底部(通常是 main 函数或者线程的起点)到顶部(当前执行的函数)的路径。而“采样值”可能是在该调用栈上花费的 CPU 时间、内存使用量或者是其他的资源指标。对于内存泄露分析,采样值可以是内存泄露量,或者内存泄露次数。

可惜的是,现在的 memleak 还不支持生成可以转换火焰图的数据格式。不过这里改起来并不难,PR 4766 有实现一个简单的版本,下面就用这个 PR 里的代码为例,来生成内存泄露火焰图。

可以看到这里生成的采集文件很简单,如上面所说的格式:

__libc_start_call_main+0x7a [libc.so.6];main+0x31 [main];caller()+0x31 [main];LeakLib::slowMemoryLeak()+0x20 [main] 480

最后用 FlameGraph 脚本来生成火焰图,如下:

默认开启帧指针

文章最后再来解决下前面留下的一个比较有争议的话题,是否在编译的时候默认开启帧指针。我们知道 eBPF 工具依赖帧指针才能进行调用栈回溯,其实栈回溯的方法有不少,比如:

  • DWARF: 调试信息中增加堆栈信息,不需要帧指针也能进行回溯,但缺点是性能比较差,因为需要将堆栈信息复制到用户空间来进行回溯;

  • ORC: 内核中为了展开堆栈创建的一种格式,其目的与 DWARF 相同,只是简单得多,不能在用户空间使用;

  • CTF Frame: 一种新的格式,比 eh_frame 更紧凑,展开堆栈速度更快,并且更容易实现。 仍在开发中,不知道什么时候能用上。

所以如果想用比较低的开销,拿到完整的堆栈信息,帧指针是目前最好的方法。既然帧指针这么好,为什么有些地方不默认开启呢?在 Linux 的 Fedora 发行版社区中,是否默认打开该选项引起了激烈的讨论,最终达成一致,在 Fedora Linux 38 中,所有的库都会默认开启 -fno-omit-frame-pointer 编译,详细过程可以看 Fedora wiki: Changes/fno-omit-frame-pointer。

上面 Wiki 中对打开帧指针带来的影响有一个性能基准测试,从结果来看:

  • 带帧指针使用 GCC 编译的内核,速度会慢 2.4%;

  • 使用帧指针构建 openssl/botan/zstd 等库,没有受到显着影响;

  • 对于 CPython 的基准测试性能影响在 1-10%;

  • Redis 的基准测试基本没性能影响;

当然,不止是 Fedora 社区倾向默认开启,著名性能优化专家 Brendan Gregg 在一次分享中,建议在 gcc 中直接将 -fno-omit-frame-pointer 设为默认编译选项:

• Once upon a tme, x86 had fewer registers, and the frame pointer register was reused for general purpose to improve performance. This breaks system stack walking. • gcc provides -fno-omit-frame-pointer to fix this – Please make this the default in gcc!

此外,在一篇关于 DWARF 展开的论文 提到有 Google 的开发者在分享中提到过,google 的核心代码编译的时候都带上了帧指针。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1472573.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

★【递归】【构造二叉树】Leetcode 106.从中序与后序遍历序列构造二叉树

★【递归】【构造二叉树】Leetcode 106.从中序与后序遍历序列构造二叉树 105. 从前序与中序遍历序列构造二叉树 106.从中序与后序遍历序列构造二叉树:star:思路分析递归解法 105. 从前序与中序遍历序列构造二叉树递归解法 ---------------&#x1f388;&#x1f388;题目链接&a…

跳房子留脚印

题目描述 N个格子里从左到右里面的数字是0,1,2,3&#xff0c;…N-1。小米和小白2个同学玩跳房子游戏&#xff0c;开始从第0格跳&#xff0c;跳出第N-1格子就停。小米同学每次跳过2格&#xff0c;再向前走1格&#xff0c;再跳过2格&#xff0c;向前走1格…。小白同学每次跳过4格…

DevOps VS 敏捷的区别是什么?

原文链接&#xff1a;DevOps VS 敏捷_软件开发生产线 CodeArts_理论实践_DevOps概览 当我们面对敏捷和DevOps的时候&#xff0c;总会不可避免的思考下面这些问题&#xff1a; 敏捷是什么&#xff1f;DevOps是什么&#xff1f;两者有什么区别&#xff1f;持续集成不是XP里面的…

JS基本知识

JS作用域的内存模型 每个函数都有各自的作用域、作用域链、变量对象、执行环境。 一、函数初始化&#xff1a;3作用域链。 函数被调用&#xff1a;1作用域、2变量对象、4执行环境。执行结束后&#xff0c;1作用域、3作用域链、4执行环境被销毁、2变量对象&#xff08;如果函…

【前后端的那些事】文件上传组件封装

文章目录 效果前端代码后端代码组件封装 效果 前端代码 /views/file/file.vue <template><el-row><el-uploadv-model:file-list"fileList"class"upload-demo"multiple:auto-upload"false":on-preview"handlePreview"…

Java SpringBoot 整合 MyBatis 小案例

Java SpringBoot 整合 MyBatis 小案例 基础配置&#xff08;注意版本号&#xff0c;容易报错&#xff09; pom.xml <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0" xmlns:xsi"http…

面试经典150题 -- 二叉树搜索树 (总结)

总的链接 : https://leetcode.cn/studyplan/top-interview-150/ 二叉搜索树相关概念 : 二叉搜索树是一个有序树。 若它的左子树不空&#xff0c;则左子树上所有结点的值均小于它的根结点的值&#xff1b;若它的右子树不空&#xff0c;则右子树上所有结点的值均大于它的根结…

SWIFT:自我认知微调

文档:https://github.com/modelscope/swift/blob/main/docs/source/LLM/%E8%87%AA%E6%88%91%E8%AE%A4%E7%9F%A5%E5%BE%AE%E8%B0%83%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5.md ​​​​​​代码: Swift是如何把自我认知数据集融合到训练集中呢? 1:相关的3个参数

阿里云ECS u1实例性能怎么样?199元一年

阿里云服务器ECS u1实例&#xff0c;2核4G&#xff0c;5M固定带宽&#xff0c;80G ESSD Entry盘优惠价格199元一年&#xff0c;性能很不错&#xff0c;CPU采用Intel Xeon Platinum可扩展处理器&#xff0c;购买限制条件为企业客户专享&#xff0c;实名认证信息是企业用户即可&a…

设计模式(二)单例模式的七种写法

相关文章设计模式系列 面试的时候&#xff0c;问到许多年轻的Android开发他所会的设计模式是什么&#xff0c;基本上都会提到单例模式&#xff0c;但是对单例模式也是一知半解&#xff0c;在Android开发中我们经常会运用单例模式&#xff0c;所以我们还是要更了解单例模式才对…

SQL注入漏洞解析--less-46

我们先看一下46关 他说让我们先输入一个数字作为sort,那我们就先输入数字看一下 当我们分别输入1&#xff0c;2&#xff0c;3可以看到按照字母顺序进行了排序&#xff0c;所以它便是一个使用了order by语句进行排序的查询的一种查询输出方式 当输入时出现报错提示&#xff0c;说…

YOLOv9图像标注和格式转换

一、软件安装 labelimg安装&#xff08;anaconda&#xff09; 方法一、 pip install labelImg 方法二、 pip install PyQt5 -i https://pypi.tuna.tsinghua.edu.cn/simple/ pip install pyqt5-tools -i https://pypi.tuna.tsinghua.edu.cn/simple/ pip install lxml -i ht…

【迪文屏幕】上电后默认显示背景图片是0

以往的开发&#xff0c;背景图片都是从0开始命名的&#xff0c;这次鬼使神差的没有使用0&#xff0c;而是从1开始命名&#xff0c;上电后不显示&#xff0c;咨询了技术支持&#xff0c;屏幕上电后默认显示0图片&#xff0c;增加了0图片之后&#xff0c;显示正常。 当然有一种情…

浅谈 TCP 三次握手

文章目录 三次握手 三次握手 首先我们需要明确&#xff0c;三次握手的目的是什么&#xff1f; 是为了通信双方之间建立连接&#xff0c;然后传输数据。 那么建立连接的条件是什么呢&#xff1f; 需要确保通信的双方都确认彼此的接收和发送能力正常&#xff0c;满足这个条件&a…

PDF文件转换为图片

现在确实有很多线上的工具可以把pdf文件转为图片&#xff0c;比如smallpdf等等&#xff0c;都很好用。但我们有时会碰到一些敏感数据&#xff0c;或者要批量去转&#xff0c;那么需要自己写脚本来实现&#xff0c;以下脚本可以提供这个功能~ def pdf2img(pdf_dir, result_path…

通过二叉树例题深入理解递归问题

目录 引入&#xff1a; 例1&#xff1a;二叉树的前序遍历&#xff1a; 例2&#xff1a; N叉树的前序遍历&#xff1a; 例3&#xff1a;二叉树的最大深度&#xff1a; 例4&#xff1a;二叉树的最小深度 例5&#xff1a;N叉树的最大深度&#xff1a; 例6&#xff1a;左叶子…

Python实用技巧:处理JSON文件写入换行问题

Python实用技巧&#xff1a;处理JSON文件写入换行问题 &#x1f308; 个人主页&#xff1a;高斯小哥 &#x1f525; 高质量专栏&#xff1a;Matplotlib之旅&#xff1a;零基础精通数据可视化、Python基础【高质量合集】、PyTorch零基础入门教程 &#x1f448; 希望得到您的订阅…

springboot启动自动配置

1.自定义项命名启动类规范&#xff1a; 功能在前名字在后比如 aliyun-oss-spring-boot-starter starter表示启动 springboot版本需要2.7.5 2.创建springboot工程把该删除的文件删除 3.启动类 pom文件导入自定义配置类依赖 比如 <!--第2步--><!-- 启动类 pom…

CSP-202209-3-防疫大数据

CSP-202209-3-防疫大数据 解题思路 一、数据结构定义 对于大模拟的题&#xff0c;合适的数据结构选择十分重要&#xff0c;正确的数据结构选择能够有效的提升解题效率 // 漫游消息结构体 struct RoamingData {int date, user, region; };vector<RoamingData> roamin…

阿里云服务器2024年最新价格表,高性价比配置任你选

随着云计算技术的不断发展&#xff0c;越来越多的企业和个人开始选择云服务器来搭建自己的业务平台。作为国内领先的云服务提供商&#xff0c;阿里云一直致力于为广大用户提供稳定、高效、安全的云计算服务。2024年&#xff0c;阿里云再次升级其服务器产品线&#xff0c;推出了…