2024年3月

问题背景

在你所不知道的端口耗尽(一)中,介绍了经典的客户端端口耗尽问题,在本篇文章中会介绍另外一种端口耗尽问题,即SNAT端口耗尽

什么是SNAT

SNAT是源网络地址转换(Source Network Address Translation)的缩写,它是网络地址转换(NAT)的一种形式。SNAT主要用于将内部网络的私有IP地址转换为外部网络可见的公共IP地址。在互联网通信中,源IP地址通常是内部设备的私有IP地址,它不会被互联网上的其他设备识别。通过使用SNAT,网关设备可以将这些私有地址转换为一个或多个已注册的公共IP地址,使得内部设备能够与外部网络进行通信而无需直接暴露它们的私有IP地址。

端口是有限的

因为IPv4地址的不足,NAT技术非常普遍,它也加剧了端口号的需求。每个IPv4地址最多可以拥有65535个端口(0-65535,但0-1023通常保留给特定用途,所以实际可用端口更少)。因为每个NAT映射都需要一个独立的端口,所以在有大量内部设备和外部通信时,可能会发生端口耗尽事件。当所有端口号都被占用时,新的网络连接将无法建立,这会导致网络服务不可用或性能下降。

拓展阅读 1 - Azure的出站连接方法

  • 使用Load Balancer的前端IP地址建立出站连接,每个IP地址可提供64K的端口
    image

  • 将 NAT 网关关联到子网,NAT 网关具有高度的可扩展性和可靠性,没有 SNAT 端口耗尽的问题。
    image

  • 将公共 IP 分配到虚拟机,未使用 SNAT
    image

拓展阅读 2 - SNAT port可以同时用于TCP和UDP

SNAT(源地址转换,或称为源NAT)通常工作在网络层上,因此它是独立于传输层协议的。这意味着SNAT既可以用于TCP(传输控制协议)也可以用于UDP(用户数据报协议),甚至其他传输层协议。

拓展阅读 3 - 如果目标端口不同,则可以将一个 TCP SNAT 端口用于到同一目标 IP 的多个连接

在TCP连接中,一个连接由四个元组来唯一确定:源IP地址、源端口号、目标IP地址和目标端口号。只要这四个元素中的任意一个不同,就可以创建一个新的唯一连接。因此,如果内部设备都使用同一个外部IP地址(SNAT后的地址)连接到同一个目标IP的不同目标端口,它们可以共享同一个源端口号。

如何解决

  • 新增IP
  • 考虑用NAT Gateway
  • 修改应用程序以重复使用连接
  • 修改应用程序以使用连接池

哈喽大家好,我是咸鱼。

作为用户的我们在 "上网冲浪" 的时候总是希望快一点,尤其是抢演唱会门票的时候,但是现实并非如此,有时候我们会遇到页面加载缓慢、响应延迟的情况。

而 HTTP 协议作为互联网世界的基础,从网站打开速度到移动应用的响应时间,HTTP 性能的优化直接关系到我们在网络世界的舒适体验程度。

更快的响应时间和速度能够
提供更好的用户体验
,不但如此,还可以降低服务器和网络带宽的使用,从而
节省相关的成本

那么今天我们就来聊聊
HTTP 性能如何进行优化

数据压缩

随着互联网的发展,网路上传输的数据越来越大,随随便便一个文件几个 G 甚至上百 G,就算你是百兆、千兆带宽也扛不住。

如何能够在有限的带宽里传输更多的数据,常见的解决方式是
数据压缩

如果压缩率能有 50%,例如 100K 的数据能够压缩成 50K 的大小,那么就相当于在带宽不变的情况下网速提升了一倍,加速的效果是非常明显的。

使用常见的压缩算法(如 gzip 和 br )对数据进行压缩,不但如此,我们可以对 HTTP 中传输的各种数据进行针对性地压缩,做到【对症下药】。

  • HTML/CSS/JS

对于这类纯文本格式数据,我们在进行压缩时通常会去除其中多余的空格、换行和注释等元素。尽管压缩后的文本可能看起来比较混乱,对人类可读性较差,但这对计算机并不影响流畅阅读。

  • JPG/JPEG/PNG

对于这类图片格式数据,虽然它本身已经被压缩过了,不能被 gzip、br 处理,但仍然有优化的空间。

例如,可以考虑去除图片中的拍摄时间、地点、机型等元数据,适度降低分辨率和缩小尺寸。

此外,尽量采用高压缩率的格式,有损格式可以选择 JPEG,而无损格式则可以考虑使用 webp 格式。

  • 小数据

对于较小的数据,HTTP 中有一种被称为 “资源合并”(Concatenation)的优化方式,即将多个小资源合并成一个大资源,通过单个请求下载到客户端,随后由客户端使用 JavaScript、CSS 等进行拆分使用。

这种方式虽然减少了请求的次数,但是处理起来比较麻烦。

需要注意的是,在数据压缩的时候应当注意选择适当的压缩率,不要追求最高压缩比,否则会耗费服务器的计算资源,增加响应时间,反而会“得不偿失”。

上面讲的都是针对 HTTP 报文里的 body 的压缩方式,对于 header 的压缩在 HTTP/1 里是没有的(HTTP/2 才有)。

不过我们可以采取一些手段来减少 header 的大小,不必要的字段就尽量不发(例如 User-Agent、Server、X-Powered-By)

使用缓存

数据 “千里迢迢” 从服务端到客户端,我们可以把这些 “来之不易” 的数据【暂时保存】起来,以便在下次请求时直接复用,从而避免多次请求带来的高昂成本。

比如说网站上访问量最高的网页、热点新闻,尤其是【读多写少】的数据,把它们缓存下来能够把巨大的流量挡在外面,减轻服务器的压力,对性能的改善是非常显著的。

HTTP 传输的每一个环节基本上都会有缓存,不过大致可以分成:

  • 浏览器端缓存

  • 服务端缓存

  • 中间传输缓存

在浏览器端,通过与服务端协商相关的缓存策略,将一些资源缓存到本地,以便在下次访问时进行复用。

常见的缓存策略包括 HTTP 头中的
Cache-Control

Expires
以及
ETag
等,通过这些标识,浏览器可以判断是否需要从服务端重新获取资源,或者直接使用本地缓存。

在服务端,可以借助专门的缓存中间件,如 Memcache 或 Redis,将计算得到的中间结果和资源存储在内存或硬盘中。

这样,Web 服务器在处理请求时会首先检查缓存,如果找到相应的数据,就能够立即返回给客户端,避免了访问后端服务或数据库的时间开销。

而关于中间传输的缓存,常见的有 HTTP 代理缓存和 CDN(Content Delivery Network,内容分发网络)。

HTTP 代理缓存,通过使用增加了缓存功能的 HTTP 代理服务器(例如 Nginx),缓存源服务器的数据,分发给下游的客户端。

而使用 CDN 把源服务器的内容逐级缓存到网络中的每一个 CDN 节点中,这样用户在上网的时候就不直接访问源站,而是访问离它最近的一个 CDN 节点(其实就是缓存了源服务器内容的代理服务器)

使用高性能 web 服务器

除了传输过程中对数据进行压缩和使用缓存,我们还可以在服务端下手。

首先我们应该选择高性能的 Web 服务器,最常见的应该就是 Nginx 了。

作为一款【高性能,轻量级】的 Web 服务器,【进程池+单线程】的工作模式让 Nginx 消耗较少的 CPU 和内存,非常轻量,而【I/O 多路复用】又使得 Nginx 的工作效率大大提升。

我们还可以通过 Nginx 实现【动静分离】:把动态页面和静态页面交给不同的服务器来解析,来加快解析速度,提高请求的访问效率,降低原来单个服务器的压力。

# Nginx 动静分离配置
server {
  listen 80 ; 
  
  location ~* \.(png)$ {
    root /var/images/png/;
  }
  
  location ~* \.(php)$ {
    proxy_pass http://php_back_end;
  }
}

不但如此,在配置 Nginx 的时候还应该
开启 HTTP 长连接

这样做可以平均握手成本到多次请求中,避免了每个请求都要进行 TCP 连接建立和断开的开销,从而提高了性能。连接的复用使得后续的请求不再需要进行完整的三次握手过程,减少了连接建立的延迟。

升级 HTTP/2

除了上面的【数据压缩】、【使用缓存】、【使用高性能 web 服务器】,HTTP 性能优化还有一个选择,那就是【把协议由 HTTP/1 升级到 HTTP/2】

HTTP/2 基于 Google 的 SPDY 协议,完全兼容 HTTP/1,我们来看看它的一些优点。

  • 头部压缩

报文 Header 一般会携带 “User Agent”、“Cookie”、“Accept”、“Server” 等许多固定的头字段,多达几百字节甚至上千字节,但 Body 却经常只有几十字节(比如 GET 请求)

更要命的是,成千上万的请求响应报文里有很多字段值都是重复的,非常浪费,导致大量带宽消耗在了这些冗余度极高的数据上。

为了压缩头部,HTTP/2 开发了专门的 “
HPACK
” 算法:在客户端和服务器两端建立“字典”,用索引号表示重复的字符串,还釆用哈夫曼编码来压缩整数和字符串,可以达到 50%~90% 的高压缩率。

  • 二进制格式

相较于 HTTP/1 里纯文本形式的报文,HTTP/2 全面采用二进制格式,既方便计算机解析,而且体积小、速度快,使性能大大提高。

除此之外,HTTP/2 使用虚拟的流(stream)传输消息,解决了“队头阻塞”问题,同时实现了“多路复用”,提高连接的利用率;

今天这篇文章讲了 HTTP 性能优化的一些方法,希望能对你有帮助!

自 2022 年以来,GenAI 无疑已成为一种普遍的技术趋势。在本文中,我们将探索 DevOps 中令人兴奋的 GenAI 领域,讨论其潜在优势、局限性、新兴趋势和最佳实践,深入了解 AI 支持的 DevOps 前沿世界,并探索这一强大组合如何重塑软件工程的未来。

DevOps 中的 GenAI 介绍

随着 ChatGPT、Bard 和其他 GenAI 工具的兴起,许多企业现在都在考虑利用 GenAI 的最佳方法来提高效率和节约成本。AI 让我们可以深入研究,提出更多问题,获得更多的信息。

在当今快速发展的技术领域,
DevOps 和 AI 的交汇点正在出现一个新的领域
。技术主管们正在认识到 GenAI 在 DevOps 中的变革潜力,自动化与协作在此交汇,以推动软件工程的创新和效率。

GenAI 与人工智能

人工智能(AI)是一个广义的术语,它
包含一系列广泛的技术和方法,使机器能够模仿人类智能
,完成通常需要人类智能才能完成的任务。它涉及开发能够处理信息、推理、从数据中学习并做出决策或预测的算法和模型。

另一方面,GenAI 是人工智能的一个
特定子集或应用
。它是指使用 AI 技术生成新的原创内容,如图像、文本、音乐、视频甚至编码。GenAI 模型旨在从训练数据中学习模式和结构,然后利用这些知识创建与训练数据相似的新的现实内容。

GenAI 利用深度学习算法,如 Generative Adversarial Networks (GAN) 或 Variational Autoencoders (VAEs),生成训练数据中不存在的内容。

GenAI 模型

大语言模型(LLM)的显著进步给各个领域带来了变革,包括 Dall-E、MidJourney、Stable Diffusion 和 Lensa 等主流图像生成技术,以及 ChatGPT 的对话式 AI 和 Copilot 的代码生成技术。

大型 Transformer 架构、通过人类反馈进行强化学习(RLHF)、增强嵌入和潜在扩散技术的集成,赋予了这些模型在各种应用中被灵活运用的能力。

而当前的 GenAI 与过去的模型最大的不同之处也被认为是在于它的
运行规模

GenAI 如何在 DevOps 中发挥作用?

DevOps 中的 GenAI 将人工智能技术的力量与 DevOps 的原则相结合,使团队能够自动化软件开发和部署流程的各个阶段。从代码生成到测试、监控甚至故障排除,GenAI 为 DevOps 实践带来了
全新的速度、准确性和可扩展性

然而,要在这种方法中取得成功,就必须进行缜密的规划,并全面掌握 DevOps 和人工智能的概念。

AI 在 DevOps 中的优势

通过利用 GenAI,企业可以在软件开发生命周期中释放出众多优势。
提高应用程序性能、主动检测和解决操作问题、实时检测威胁、团队间更顺畅的协作以及持续监控代码质量
,这些只是 GenAI 为 DevOps 带来的优势的几个例子。

优化应用程序性能

通过自动执行重复性任务和分析海量数据,人工智能赋予了 DevOps 团队
更快、更精确的决策能力

在 DevOps 领域,可以利用人工智能创建预测分析模型,预测系统性能,从而优化应用程序性能。

更快上市

借助自动化和更高的准确性,DevOps 团队可以更快地交付软件,同时保持高质量。这不仅能让企业在竞争激烈的市场中保持领先地位,还能让企业快速响应客户需求,适应快速变化的业务需求。

自动化

AI 驱动的自动化可简化整个 DevOps 流程,包括测试、部署等。
它消除了人工干预测试、调试和代码生成等重复性任务的需要
。工作量的减少使 DevOps 团队能够专注于设计和开发创新功能等高价值活动。

实时检测威胁

在 DevOps 安全领域,人工智能在识别和及时处理威胁和漏洞方面发挥着举足轻重的作用。通过
识别应用程序、服务器和网络中的异常行为模式
,人工智能可以实时检测潜在的安全风险。将安全检查集成到 DevOps 管道中可确保应用程序在部署前得到彻底保护。

快速响应问题

通过实施自然语言处理(NLP)和机器学习,人工智能促进了 DevOps 团队之间的无缝沟通与协作。通过采用 AI 驱动的 chatbots,团队成员可以获得
全天候支持、常见问题协助和知识共享功能
,从而更顺利、更快速地响应问题。

提高质量

DevOps 环境中的 AI 可减少人工错误,最大限度地降低人工干预的必要性。
它加快了开发速度,同时提高了代码质量,最终节省了时间,降低了成本
。通过人工智能进行持续监控,
可确保软件开发保持高效并维持高质量水平

AI 在 DevOps 中的局限性

采用 AI 支持的 DevOps 在企业中越来越受欢迎。然而,必须了解与 DevOps 中的生成人工智能相关的限制和挑战。

应仔细考虑实施成本、数据隐私法规以及对熟练人员的需求等因素,以确保成功集成并取得最佳成果。

{{uploading-image-510346.png(uploading...)}}

实施成本

要完全实施人工智能化 DevOps,需要在昂贵的硬件、软件和熟练人员方面进行大量投资。
与 AI 系统相关
的费用给寻求采用人工智能 DevOps 的企业带来了巨大挑战,使许多企业难以承受。

严格的数据隐私法规

实施严格的数据隐私法规
是另一个障碍。AI 驱动的 DevOps 在很大程度上依赖于数据,但在许多司法管辖区,有关个人数据保护的法律禁止公司收集、处理和利用个人数据进行分析。因此,由于严格的隐私法规,AI 支持的 DevOps 在访问和分析数据方面遇到了巨大挑战。

过时的信息

如果考虑到最流行的 GenAI 工具之一 ChatGPT,就不难理解为什么输出的信息并非完美无瑕。ChatCPT 的训练基于静态的文本集合,这就将其知识局限于 2021 年之前的信息。

此外,ChatGPT 缺乏访问实时外部资源(如网络)的能力,因此只能固定存储一年多以前的数据。

次优解决方案

GenAI 在 DevOps 中的一个局限性是
生成不正确或次优解决方案的固有风险
。AI 模型是在历史数据和模式的基础上进行训练的,而历史数据和模式可能无法完全反映真实世界场景的复杂性和背景。

必要的人工监督

虽然 GenAI 为 DevOps 带来了重大进步,但必须承认在此过程中需要熟练的人工监督。尽管 GenAI 具有自动化功能,但人类的专业知识对于有效决策、质量控制和处理复杂场景仍然非常宝贵。

DevOps 专家对于验证 GenAI 生成的输出结果,确保其符合预期目标、行业最佳实践和合规要求至关重要。

无法避免偏差

在 DevOps 的背景下,AI 生成模型可能会受到与训练数据中的偏差有关的限制。DevOps 流程严重依赖 AI 生成的输出来进行决策、自动化和解决问题。但是,如果用于开发这些生成模型的训练数据也包含偏差,这些偏差就会传播并影响 DevOps 工作流程中的关键决策过程。

Siddhartha Allen 就对关于偏差的问题进行过阐述,由于偏见很难量化,如果存在嵌入式偏见,这些偏见就会在构建事物的过程中显现出来,即使是人工智能工具也无法避免。

DevOps 顾问兼联合发言人 Darasimi Oluwaniyi 认为,大型语言模型,如 Open AI 和 Google Bard,使用大量来自互联网的各种数据来训练它们的算法,就意味着它们会从互联网上接收到密集的偏见。

而消除偏见的最佳解决方案在于
提供反馈的人要有足够多样化的背景,以确保在偏见方面覆盖到各个基础
。尽管无法永远完全避免偏见,但至少可以确保通过人工监控尽可能地减少偏见。

需要不断改进

随着新技术、框架和安全威胁的出现,必须不断对其进行调整和微调,以保持其相关性和有效性。这意味着您的团队必须具备相关领域的知识和经验,以评估生成式人工智能模型的性能,并进行必要的调整以优化其结果。

DevOps 中的 GenAI 与 MLops 有何不同?

MLOps 是 Machine Learning Operations 的简称,专注于
在生产环境中部署、管理和监控机器学习模型的运维方面
。它包含一系列实践、工具和工作流程,旨在简化机器学习模型的开发和部署,确保其在实际应用中的扩展性、可靠性和性能。

DevOps 中的 GenAI 特指生成式模型在 DevOps 领域中的应用,而 MLops 则不同,它超越了生成式模型的使用范围。虽然 MLops 可以将 GenAI 技术用于数据增强或合成数据生成等任务,但其范围要广泛得多。

MLops 涉及机器学习模型的整个生命周期,包括数据准备、模型训练、验证、部署以及持续监控和维护。它侧重于实现数据科学家、ML 工程师和运维团队之间的高效协作,以确保将 ML 模型无缝集成到生产系统中。

在Java编程语言中,
NullPointerException
(简称NPE)是一种常见的运行时异常,当程序试图访问或操作一个还未初始化(即值为null)的对象引用时,Java虚拟机就会抛出
NullPointerException
。如果我们在日常开发中,不能很好的去规避NPE,那么可能因为数据或者其他问题就会导致线上问题。。。很烦。。。。

阿里巴巴开发手册规约中也说明防止NPE,是程序员的基本素养。。。
image.png

接下来我们先谈谈几种可能会出现空指针异常的方式。

出现空指针异常的情况

  • 访问空对象的属性或调用空对象的方法
    当一个对象是null时,试图访问一个对象的属性或调用其方法,就会触发空指针异常。
String text = null;
int length = text.length();

User user = null;
String userName = user.getUserName();
  • 数组为null或者数组元素为null
    当尝试访问数组中的某个索引处的元素,而该元素为
    null
    时,同样会导致空指针异常。
String[] strs = null;
int length = strs.length;

String[] strs = new String[3];  
int length = strs[2].length();
  • 集合中null元素访问
    当集合中存在null元素,当我们遍历集合,访问到这个元素的属性或者方法时也会抛出NPE,这种情况也会出现在我们的日常开发中,有时候就会因为数据问题导致这种情况发生,常常也莫名其妙。。。。
List<String> list = Lists.newArrayList();  
list.add(null);  
System.out.println(list.get(0).length());
  • 调用的方法返回null
    调用某个方法,期望其返回一个非null的对象,但实际返回了null。当然这种情况等同于访问空对象的属性或者方法。这在实际开发过程中极易出现的一种情况。比如我们使用
    Mybatis
    从数据库中查询一条记录时,数据不存在,就会返回null。这种情况尤为注意。
private User getUserInfo(){  
    return null;  
}

User user = getUserInfo();
String userName = user.getUserName();
  • 使用基本数据类型的包装类
    在使用基本数据类型的包装类时,如果未正确初始化,再转成int时,可能导致空指针异常。
Integer i = null;  
int num = i;

以上大概是我想到或者常遇到的一些可能会发生NPE的情况,如果还有其他情况,可以贴出来讨论。

那么我们该如何避免NPE呢?

避免NPE的几种方式

  • 访问对象前要谨慎
    在使用对象之前,始终检查它是否为null。这包括方法参数、返回值以及对象的属性。在访问对象的方法或属性之前,使用条件语句判断对象是否为null。比如我们在访问User对象前,一定要判null
User user = new User();  
if (user != null){  
    String userName = user.getUserName();  
    Address address = user.getAddress();  
    if (address != null){  
        String coutry = address.getCountry();  
    }  
}

或者我们的user是从一个方法中获取的,例如数据库中查询,那么我们在访问这个对象前,一定要判null,如果为null要抛出对应的业务异常,然后我们就可以在接口响应中对应返回错误的信息即可,此时就算是一个正常的流程了。这点尤为重要,一定要注意。

User user = userManager.getUserById(Long userId);
if (user == null){
	throw new ServiceException(""当前查询的对象不存在);
}

关于SpringBoot项目中捕获自定义业务异常,统一异常管理,统一结果返回,可以参考这篇文章:
SpringBoot统一结果返回,统一异常处理,大牛都这么玩 | 码农Academy的博客

当然如果使我们在写
User getUserById(Long id)
返回对象或者
List<User> listUserByIds(List<Long> idList)
时我们可以不返回
null
,可以返回一个对象默认信息或者一个空集合,这样调用方就不会出现NPE风险,当然我们不强制返回一个对象或者空集合,但是必须添加注释充分 说明什么情况下会返回null值。这也是阿里巴巴开发手册规约的建议。
image.png

  • 使用Optional类
    JDK8以上版本提供了
    Optional
    类,它是一个容器对象,可用于包装可能为null的值。我们可以使用它判断null问题,同时也解决了多层级访问问题,配合使用orElse时,会先执行orElse方法,然后执行逻辑代码,不管是否出现了空指针。
String country = Optional.ofNullable(user)  
        .map(User::getAddress)  
        .map(Address::getCountry)  
        .orElse("");

String country = Optional.ofNullable(user)  
        .map(User::getAddress)  
        .map(Address::getCountry)  
        .orElseGet(() -> defaultContry());

private String defaultContry(){
	return "CN";
}

我们还可以使用orElseThrow()方法,当Optional中的对象是一个null时我们直接抛出异常:

String userName = Optional.ofNullable(user).map(User::getUserName).orElseThrow(() -> new ServiceException("当前用户信息不存在"));
  • 使用断言避免空指针
    使用Java断言(assert)来检查变量是否为null。但要注意,断言通常在开发和测试阶段启用,而在生产环境中可能被禁用(在生产环境中,通常不会启用断言以避免不必要的性能开销以及防止潜在的错误信息泄漏)。
User user = new User();
assert user != null : "user should not be null";
Address address = user.getAddress();  
assert address != null : "address should not be null";  
String coutry = address.getCountry();  
  • 使用@Nullable注解
    使用
    javax.annotation.Nullable
    注解,
    @Nullable
    注解通常用于标记一个方法的参数、返回值或者字段可能为null。这个注解并非Java标准库的一部分,但在一些第三方库(如JSR 305库中的
    javax.annotation.Nullable
    ,以及Google Guava和JetBrains的Kotlin标准库等)中广泛使用,并且被许多IDE和静态分析工具支持。以便在编译期或开发工具中提示可能的NPE风险。
@Nullable  
private static User getUserById(Long userId){  
    return null;  
}  
  
private static void handlerUser(@Nullable User user){  
    System.out.println(user.getUserName());  
}

public static void main(String[] args) {  
  Long userId = 0L;  
  User user = getUserById(userId);  
  String userName = user.getUserName();  
  handlerUser(user);  
}

此时IDEA就会警告会出现NPE风险
image.png

  • 借助工具扫描代码
    在Java开发中,我们还可以使用以下工具扫描代码以发现潜在的空指针异常风险。
  1. IntelliJ IDEA
    :内置了强大的静态代码分析器,能够检测出可能的NPE和其他代码问题。
  2. SonarQube
    /
    SonarLint
    :提供持续集成和本地IDE插件形式的静态代码分析,能找出潜在的空指针以及其他质量或安全问题。Sonar可以定时扫描仓库中的代码,可以发现代码中的一些潜在风险,可以通过一些通知例如邮件等告知代码提交者这段代码的风险。
  3. FindBugs
    (现更名为SpotBugs):另一个开源的静态分析工具,能够发现潜在的bug,包括可能导致NPE的情况。
  4. 阿里巴巴Java开发规约插件
    : 对于Eclipse和IntelliJ IDEA都有相应的插件版本,基于阿里巴巴内部Java编码规范,包含了对可能出现NPE情况的检测。

补充一点

在JDK 17中引入的Helpful NullPointerExceptions特性确实增强了空指针异常信息的准确性与可用性。当发生NullPointerException时,JVM现在能够提供更精确的位置信息,特别是在链式调用场景下,它会指出导致空指针异常的具体对象引用。这有助于开发者更快地定位到代码中的问题所在,无需通过堆栈跟踪逐层分析来判断哪个对象引用为null。
假如我们访问
user.getAddress().getCountry().length()
时,在JDK17以前,如果发生了空指针异常,他只会打印出来发生了空指针异常,但是并没有告知到底是user对象还是address对象还是coutnry发生了异常:

Exception in thread "main" java.lang.NullPointerException
	at com.study.base.core.base.NpeTest.main(NpeTest.java:23)

但是在JDK17以后,借助Helpful NullPointerExceptions特性,异常信息将更加精确,可能会类似打印这样的信息,精确到那个值发生了空指针异常:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Address.getCountry()" because "user.address" is null
	at com.study.base.core.base.NpeTest.main(NpeTest.java:23)

这又多了一个升级JDK到17以上的理由。

结论

NullPointerException(NPE)是Java开发中常见的运行时异常,源于对未初始化或已置为null的对象引用进行操作。在实际开发过程中,进行非空检查、使用Optional类以及采用Null安全注解以及使用检查工具等策略可以有效避免此类异常的发生。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

本文为从零开始写 Docker 系列第三篇,在
mydocker run
基础上基于 cgroups 实现容器的资源限制。


完整代码见:
https://github.com/lixd/mydocker
欢迎 Star




如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


推荐阅读以下文章对 docker 基本实现有一个大致认识:




开发环境如下:

root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.2 LTS
Release: 20.04
Codename: focal
root@mydocker:~# uname -r
5.4.0-74-generic


注意:需要使用 root 用户

1. 概述

上一节中,已经可以通过命令行
mydocker run -it
的方式创建并启动容器。这一节,将
通过 cgroups 实现对容器资源进行限制

这一节中,将实现通过增加
-mem

-cpu
等 flag 来实现容器 cpu、内存资源限制,完整命令如下:

./mydocker run -it -mem 10m -cpu 20 /bin/sh

包含以下几个部分:

  • mydocker run 命令增加对应 flag
  • 实现统一 CgroupsManager
  • 实现各个 Subsystem
  • 容器创建、停止时调用对应方法配置 cgroup

2. 具体实现

2.1. Cli 增加 flags

run 命令增加
mem

cpu

cpuset
这几个 flag,具体如下:

var runCommand = cli.Command{
Name: "run",
Usage: `Create a container with namespace and cgroups limit
mydocker run -it [command]`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "it", // 简单起见,这里把 -i 和 -t 参数合并成一个
Usage: "enable tty",
},
cli.StringFlag{
Name: "mem", // 限制进程内存使用量,为了避免和 stress 命令的 -m 参数冲突 这里使用 -mem,到时候可以看下解决冲突的方法
Usage: "memory limit,e.g.: -mem 100m",
},
cli.StringFlag{
Name: "cpu",
Usage: "cpu quota,e.g.: -cpu 100", // 限制进程 cpu 使用率
},
cli.StringFlag{
Name: "cpuset",
Usage: "cpuset limit,e.g.: -cpuset 2,4", // 限制进程 cpu 使用率
},
},
/*
这里是run命令执行的真正函数。
1.判断参数是否包含command
2.获取用户指定的command
3.调用Run function去准备启动容器:
*/
Action: func(context *cli.Context) error {
if len(context.Args()) < 1 {
return fmt.Errorf("missing container command")
}

var cmdArray []string
for _, arg := range context.Args() {
cmdArray = append(cmdArray, arg)
}

tty := context.Bool("it")
resConf := &subsystems.ResourceConfig{
MemoryLimit: context.String("mem"),
CpuSet: context.String("cpuset"),
CpuCfsQuota: context.Int("cpu"),
}
Run(tty, cmdArray, resConf)
return nil
},
}

从命令行中解析对应参数并传给 subsystem 以配置 cgroups。

2.2. Subsystem 实现

核心思想就是根据传递过来的参数,创建对应的 cgroups 并配置 subsystem。

例如指定了 -m 100m 就创建 memory subsystem,限制只能使用 100m 内存

定义 Subsystem

首先定义 Subsystem,这里将其抽象为一个接口以适应各种 subsystem:

// Subsystem 接口,每个Subsystem可以实现下面的4个接口,
// 这里将cgroup抽象成了path,原因是cgroup在hierarchy的路径,便是虚拟文件系统中的虚拟路径
type Subsystem interface {
// Name 返回当前Subsystem的名称,比如cpu、memory
Name() string
// Set 设置某个cgroup在这个Subsystem中的资源限制
Set(path string, res *ResourceConfig) error
// Apply 将进程添加到某个cgroup中
Apply(path string, pid int) error
// Remove 移除某个cgroup
Remove(path string) error
}

// ResourceConfig 用于传递资源限制配置的结构体,包含内存限制,CPU 时间片权重,CPU核心数
type ResourceConfig struct {
MemoryLimit string
CpuCfsQuota int
CpuShare string
CpuSet string
}
  • Name:Subsystem 名称,例如 memory、cpu。
  • Set:根据 ResourceConfig 设置 cgroup,即将阈值写入对应配置文件。
  • 例如 memory subsystem 则需将配置写入 memory.limit_in_bytes 文件
  • cpu subsystem 则是写入 cpu.cfs_period_us 和 cpu.cfs_quota_us
  • Apply:将对应 pid 添加到指定 cgroup 里
  • Remove:删除指定 cgroup

不同 Subsystem 只需要实现该接口即可。

找到 cgroup 挂载路径

在实现 Subsystem 之前,我们还需要先找到 cgroup 的挂载路径,这样才能对 cgroup 进行操作。


那么,是如何找到挂载了 subsystem 的 hierarchy 的挂载目录的呢?

先来熟悉下
/proc/{pid}/mountinfo
文件,如下:

$ cat /proc/24334/mountinfo
613 609 0:59 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro
620 613 0:61 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,mode=755
624 620 0:26 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/systemd ro,nosuid,nodev,noexec,relatime master:5 - cgroup cgroup rw,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd
625 620 0:29 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/perf_event ro,nosuid,nodev,noexec,relatime master:6 - cgroup cgroup rw,perf_event
627 620 0:30 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/net_cls,net_prio ro,nosuid,nodev,noexec,relatime master:7 - cgroup cgroup rw,net_cls,net_prio
629 620 0:31 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/cpu,cpuacct ro,nosuid,nodev,noexec,relatime master:8 - cgroup cgroup rw,cpu,cpuacct
631 620 0:32 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/pids ro,nosuid,nodev,noexec,relatime master:9 - cgroup cgroup rw,pids
634 620 0:33 / /sys/fs/cgroup/rdma ro,nosuid,nodev,noexec,relatime master:10 - cgroup cgroup rw,rdma
641 620 0:38 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/hugetlb ro,nosuid,nodev,noexec,relatime master:15 - cgroup cgroup rw,hugetlb
644 620 0:39 /docker/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791 /sys/fs/cgroup/freezer ro,nosuid,nodev,noexec,relatime master:16 - cgroup cgroup rw,freezer
646 611 0:52 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw
647 611 0:63 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k
648 609 253:1 /var/lib/docker/containers/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791/resolv.conf /etc/resolv.conf rw,relatime - xfs /dev/vda1 rw,attr2,inode64,noquota
649 609 253:1 /var/lib/docker/containers/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d791/hostname /etc/hostname rw,relatime - xfs /dev/vda1 rw,attr2,inode64,noquota
650 609 253:1 /var/lib/docker/containers/fa4b9c67d031dd83cedbad7b744b4ae64eb689c5089f77d0c95379bd3b66d7
# ... 省略

通过
/proc/self/mountinfo
可以找出 与当前进程相关的 mount 信息。


而 Cgroups 的 hierarchy 的虚拟文件系统是以 cgroup 类型文件系统的 mount 挂载上去的,其中的 option 中加上 subsystem,代表挂载的 subsystem 类型。

根据这个就可以在 mountinfo 中找到对应的 subsystem 的挂载目录了,比如 memory:

/sys/fs/cgroup/memory ro,nosuid,nodev,noexec,relatime master:11 - cgroup cgroup rw,memory

通过最后的 option 是
rw, memory
看出这一条挂载的 subsystem 是 memory。

那么在
/sys/fs/cgroup/memory
中创建文件夹对应创建的 cgroup,就可以用来做内存的限制。

根据这个规则我们就可以找到对应 cgroup 挂载位置了,具体代码实现如下。

下面的
getCgroupPath
函数是找到对应 subsystem 挂载的 hierarchy 相对路径对应的 cgroup 在虚拟文件系统中的路径,然后通过这个目录的读写去操作 cgroup

const mountPointIndex = 4

// getCgroupPath 找到cgroup在文件系统中的绝对路径
/*
实际就是将根目录和cgroup名称拼接成一个路径。
如果指定了自动创建,就先检测一下是否存在,如果对应的目录不存在,则说明cgroup不存在,这里就给创建一个
*/
func getCgroupPath(subsystem string, cgroupPath string, autoCreate bool) (string, error) {
// 不需要自动创建就直接返回
cgroupRoot := findCgroupMountpoint(subsystem)
absPath := path.Join(cgroupRoot, cgroupPath)
if !autoCreate {
return absPath, nil
}
// 指定自动创建时才判断是否存在
_, err := os.Stat(absPath)
// 只有不存在才创建
if err != nil && os.IsNotExist(err) {
err = os.Mkdir(absPath, constant.Perm0755)
return absPath, err
}
// 其他错误或者没有错误都直接返回,如果err=nil,那么errors.Wrap(err, "")也会是nil
return absPath, errors.Wrap(err, "create cgroup")
}

// findCgroupMountpoint 通过/proc/self/mountinfo找出挂载了某个subsystem的hierarchy cgroup根节点所在的目录
func findCgroupMountpoint(subsystem string) string {
// /proc/self/mountinfo 为当前进程的 mountinfo 信息
// 可以直接通过 cat /proc/self/mountinfo 命令查看
f, err := os.Open("/proc/self/mountinfo")
if err != nil {
return ""
}
defer f.Close()
// 这里主要根据各种字符串处理来找到目标位置
scanner := bufio.NewScanner(f)
for scanner.Scan() {
// txt 大概是这样的:104 85 0:20 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,memory
txt := scanner.Text()
// 然后按照空格分割
fields := strings.Split(txt, " ")
// 对最后一个元素按逗号进行分割,这里的最后一个元素就是 rw,memory
// 其中的的 memory 就表示这是一个 memory subsystem
subsystems := strings.Split(fields[len(fields)-1], ",")
for _, opt := range subsystems {
if opt == subsystem {
// 如果等于指定的 subsystem,那么就返回这个挂载点跟目录,就是第四个元素,
// 这里就是`/sys/fs/cgroup/memory`,即我们要找的根目录
return fields[mountPointIndex]
}
}
}

if err = scanner.Err(); err != nil {
log.Error("read err:", err)
return ""
}
return ""
}

接下来我们就可以实现各个 Subsystem 了。

memory subsystem

memory subsystem 实现如下:

type MemorySubSystem struct {
}

// Name 返回cgroup名字
func (s *MemorySubSystem) Name() string {
return "memory"
}

// Set 设置cgroupPath对应的cgroup的内存资源限制
func (s *MemorySubSystem) Set(cgroupPath string, res *ResourceConfig) error {
if res.MemoryLimit == "" {
return nil
}
subsysCgroupPath, err := getCgroupPath(s.Name(), cgroupPath, true)
if err != nil {
return err
}
// 设置这个cgroup的内存限制,即将限制写入到cgroup对应目录的memory.limit_in_bytes 文件中。
if err := os.WriteFile(path.Join(subsysCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit), constant.Perm0644); err != nil {
return fmt.Errorf("set cgroup memory fail %v", err)
}
return nil
}

// Apply 将pid加入到cgroupPath对应的cgroup中
func (s *MemorySubSystem) Apply(cgroupPath string, pid int, res *ResourceConfig) error {
if res.MemoryLimit == "" {
return nil
}
subsysCgroupPath, err := getCgroupPath(s.Name(), cgroupPath, false)
if err != nil {
return errors.Wrapf(err, "get cgroup %s", cgroupPath)
}
if err := os.WriteFile(path.Join(subsysCgroupPath, "tasks"), []byte(strconv.Itoa(pid)), constant.Perm0644); err != nil {
return fmt.Errorf("set cgroup proc fail %v", err)
}
return nil
}

// Remove 删除cgroupPath对应的cgroup
func (s *MemorySubSystem) Remove(cgroupPath string) error {
subsysCgroupPath, err := getCgroupPath(s.Name(), cgroupPath, false)
if err != nil {
return err
}
return os.RemoveAll(subsysCgroupPath)
}

可以看到,其实就是将操作 cgroups 的命令换成了 Go 语法罢了:

  • 比如限制内存则是往
    memory.limit_in_bytes
    里写入指定值
  • 添加某个进程到 cgroup 中就是往对应的 tasks 文件中写入对应的 pid
  • 删除 cgroup 就是把对应目录删掉

另外的cpu subsystem 就不再贴代码了,都是类似的操作。

2.3 CgroupManager

实现了各个 Subsystem 之后,我们在往上抽象一层,
定义一个 CgroupManager 来统一管理各个 subsystem

这样调用方就可以不用管具体的底层的实现了,只需要调用 CgroupManager 即可。

实现也很简单,就是循环调用每个 subsystem 中的方法。

type CgroupManager struct {
// cgroup在hierarchy中的路径 相当于创建的cgroup目录相对于root cgroup目录的路径
Path string
// 资源配置
Resource *subsystems.ResourceConfig
}

func NewCgroupManager(path string) *CgroupManager {
return &CgroupManager{
Path: path,
}
}

// Apply 将进程pid加入到这个cgroup中
func (c *CgroupManager) Apply(pid int) error {
for _, subSysIns := range subsystems.SubsystemsIns {
err := subSysIns.Apply(c.Path, pid)
if err != nil {
logrus.Errorf("apply subsystem:%s err:%s", subSysIns.Name(), err)
}
}
return nil
}

// Set 设置cgroup资源限制
func (c *CgroupManager) Set(res *subsystems.ResourceConfig) error {
for _, subSysIns := range subsystems.SubsystemsIns {
err := subSysIns.Set(c.Path, res)
if err != nil {
logrus.Errorf("apply subsystem:%s err:%s", subSysIns.Name(), err)
}
}
return nil
}

// Destroy 释放cgroup
func (c *CgroupManager) Destroy() error {
for _, subSysIns := range subsystems.SubsystemsIns {
if err := subSysIns.Remove(c.Path); err != nil {
logrus.Warnf("remove cgroup fail %v", err)
}
}
return nil
}
  • Set:配置 cgroup,具体实现则是循环调用各个 Subsystem 的 Set 方法
  • Apply:将指定 pid 进程加入到 cgroup 中,实现同上
  • Destroy:释放 cgroup,实现同上

通过 CgroupManager 将 Subsystem 进行封装,对上层隐藏了资源限制的配置,以及将进程移动到 cgroup 中等操作细节。

具体 CgroupManager 和 Subsystem 的调用流程如下所示:

resource-limit-by-cgroups

如图所示,CgroupManager 在配置容器资源限制时

  • 首先会初始化 Subsystem 实例;
  • 然后遍历调用 Subsystem 实例的 Set 方法,创建和配置不同 Subsystem 挂载的 hierarchy 中的 cgroup;
  • 接着再通过调用 Subsystem 实例的 Apply 方法将容器进程对应 PID 分别加入到那些 cgroup 中,以实现容器的资源限制;
  • 最后容器运行结束时则调用 Subsystem 实例的 Destory 销毁 cgroup。

到此,整个 Cgroups 的实现就算是完成了,后续剩下的就是在启动的时候,解析参数,并配置对应的 Cgroups 即可。

2.4. 实现 Cgroups 配置

至此,实现了参数解析以及 CgroupManager,剩下的只需要使用 flag 中解析的参数初始化 CgroupManager 实例并调用相关方法即可实现 Cgroups 配置。


前面讲解析的参数组装为 subsystems.ResourceConfig 对象,传递到 Run 方法中,

然后 Run 方法则根据前面解析好的 config 设置对应的 Cgroups。

func Run(tty bool, comArray []string, res *subsystems.ResourceConfig) {
parent, writePipe := container.NewParentProcess(tty)
if parent == nil {
log.Errorf("New parent process error")
return
}
if err := parent.Start(); err != nil {
log.Errorf("Run parent.Start err:%v", err)
}
// 创建cgroup manager, 并通过调用set和apply设置资源限制并使限制在容器上生效
cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
defer cgroupManager.Destroy()
_ = cgroupManager.Set(res)
_ = cgroupManager.Apply(parent.Process.Pid)
// 再子进程创建后才能通过匹配来发送参数
sendInitCommand(comArray, writePipe)
_ = parent.Wait()
}

核心代码如下:

cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
defer cgroupManager.Destroy()
_ = cgroupManager.Set(res)
_ = cgroupManager.Apply(parent.Process.Pid)

创建一个 Cgroups,然后调用 Set 方法写入对应的配置信息,最后调用 Apply 将子进程 pid 加入到这个 Cgroups 中。

这样就完成了 Cgroups 全部配置,已经可以限制子进程的资源使用了。

同时通过
defer cgroupManager.Destroy()
指定了 Destory 方法,以实现在容器退出后自动销毁对应 cgroup。

3. 测试

这里主要通过跑
stress
命令来测试 Cgroups 限制是否生效。

宿主机上需要先安装 stress,因为此时还没有切换 rootfs,因此可以直接使用宿主机上安装的程序。

memory

memory subsystem 测试流程如下:

  • 启动容器并使用
    -mem
    flag 限制内存

  • 使用程序占用大量内存,测试能否超过阈值

为了演示创建了下面这样一个程序,用来向系统申请内存,它会每秒消耗 1M 的内存。

vi ~/mem-allocate.c

完整内容如下:

cat > ~/mem-allocate.c <<EOF
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MB (1024 * 1024)

int main(int argc, char *argv[])
{
char *p;
int i = 0;
while(1) {
p = (char *)malloc(MB);
memset(p, 0, MB);
printf("%dM memory allocated\n", ++i);
sleep(1);
}

return 0;
}
EOF

编译一下

gcc ~/mem-allocate.c -o ~/mem-allocate

然后启动容器

root@mydocker:~/feat-cgroups/mydocker# ./mydocker run -it -mem 10m /bin/sh
{"level":"info","msg":"command all is /bin/sh","time":"2024-01-08T13:19:37+08:00"}
{"level":"info","msg":"init come on","time":"2024-01-08T13:19:37+08:00"}
{"level":"info","msg":"Find path /bin/sh","time":"2024-01-08T13:19:37+08:00"}

并在容器中运行内存占用程序

# ~/mem-allocate
1M memory allocated
2M memory allocated
3M memory allocated
4M memory allocated
5M memory allocated
6M memory allocated
7M memory allocated
8M memory allocated
9M memory allocated
Killed

可以看到,在第 10 次申请时由于达到了内存上限(10M) 程序直接被 OOM Kill 了。

内存和 CPU 不同,如果进程超过了分配的内存阈值,Linux 内核会采取 OOM (Out of Memory) 机制,以保证系统不会因内存不足而崩溃。

说明,我们的内存限制是生效的

最后在看一下 mydocker 创建的 cgroup 在哪儿

查看 memory subsystem 中的内容:

root@mydocker:~# ls /sys/fs/cgroup/memory/
cgroup.clone_children memory.kmem.tcp.failcnt memory.soft_limit_in_bytes mydocker-cgroup
tasks user.slice

可以看到,有一个 mydocker-cgroup 目录,这就是为 mydocker 容器创建的 memory cgroup。

查看内部的具体内容:

root@mydocker:~# cat /sys/fs/cgroup/memory/mydocker-cgroup/memory.limit_in_bytes
10485760
root@mydocker:~# cat /sys/fs/cgroup/memory/mydocker-cgroup/tasks
116477

memory.limit_in_bytes
中的配置 10485760 转换下单位就是我们启动时配置的 10m。


tasks
中的 116477 就是容器进程 PID。

最后,当我们执行 exit,退出容器后
mydocker-cgroup
也会随之删除。

cpu

cpu subsystem 测试也类似:

  • 启动容器并使用
    -cpu
    flag 限制 cpu

  • 使用程序占用大量 cpu,测试能否超过阈值

启动容器并限制只能占用 0.1核 cpu:

root@mydocker:~/feat-cgroups/mydocker# ./mydocker run -it -cpu 10 /bin/sh
{"level":"info","msg":"init come on","time":"2024-01-08T13:25:03+08:00"}
{"level":"info","msg":"command all is /bin/sh","time":"2024-01-08T13:25:03+08:00"}
{"level":"info","msg":"Find path /bin/sh","time":"2024-01-08T13:25:03+08:00"}

然后在容器中启动一个 while 循环

# while : ; do : ; done &

理论上,该 while 循环会占用满一整个 cpu,但是该容器被限制了只能占用 0.1 核 cpu。

查看CPU占用情况:

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
116464 root 20 0 2608 88 0 R 10.0 0.0 0:05.37 sh

稳稳的被限制在了 10%,说明 cpu subsystem 也是生效的。

4. 小结

整个实现不算复杂,不过只要理解了实现原理,再写就很简单了。

几个重要的点如下:

  • 1)找到 cgroup 挂载路径:根据
    /proc/self/mountinfo
    中的信息,按规则解析得到 cgroup 挂载路径
  • 2)各个 Subsystem 的实现:使用 Go 实现 Cgroups 配置
  • 3)使用匿名管道传递参数:在父进程和子进程间使用了匿名管道来传递参数。

resource-limit-by-cgroups

具体流程就是:

  • 1)解析命令行参数,取到 Cgroups 相关参数
  • 比如 -mem 100m 表示把内存限制到100m
  • 又比如 -cpu 20 表示把 cpu 限制在 20%
  • 2)根据参数,创建对应 Cgroups 并配置对应的 subsystem,最后把 fork 出来的子进程 pid 加入到这个 Cgroups 中即可。
  • 1)创建 Cgroups
  • 2)配置 subsystem
  • 3)fork 出子进程
  • 4)把子进程 pid 加入到 Cgroups 中
  • 3)子进程(容器)结束时删除对于的 Cgroup


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


最后再推荐下 cgroups 相关的几篇文章: