Harbor GC 问题

最近的工作比较忙,以至于网络技术的系列文章1许久不更新了。这几天在解决的问题是镜像存储服务 Harbor2,存储的 docker image 太多了。

虽然我之前在博客里面分享了一些 Docker image 构建的技巧3,以及炫耀了构建一个最小的 Redis Docker 镜像才不到 2MiB4,但是无奈,我的博客基本没有人看,所以同事上传的 image 都非常可怕,动辄就上 G,20+ GiB 的都有。现在我们的 Harbor 存储已经是 PiB 级别了。

多余的 image 就删除就好了,问题就在于,删除 image 比较复杂。分成几个步骤:

  1. 删除 image 的 Tag5
  2. 扫描整个数据库,找到没有被任何其他 image 和 tag 引用的 blob;
  3. 删除这些未被引用的 blob;

第二步尤其重要,简单来说,image 是分层的,一层就是一个 blob,一个 image 可以引用多个 blob。比如服务 A 的 Dockerfile 开头是 From: ubuntu:24.04,另一个服务 B 的 Dockerfile 开头也是 From: ubuntu:24.04,那么这两个 image 都是引用了 ubuntu 的 blob。删除服务 A 的 image 的时候,不能把 A 的 blob 都删除,因为这样的话 ubuntu 的 base image 就连带被删除了。所以我们在删除一个 image 的时候,其实并没有释放任何空间,而只是删除了 image 对 blob 的引用。这时候还不知道哪些 blob 是可以释放的,要知道哪些 blob 可以删除,就必须扫描全部的数据库,找到没有任何引用的 blob,才可以删除。难题就在扫全表这里。

这个问题就和编程语言的 GC 问题很像,不过更加简单一些,因为引用只存在于 tag 到 blob,tag 之间和 blob 之间不存在引用,也就没有环的问题。

引用计数

引用计数比较合适这个场景,因为没有环路,所以引用计数到 0 就可以直接删除,不需要扫表找孤零零的环。但是 Harbor 本身没有用这种方案,估计是因为引用记录维护起来比较难,必须准确并且处理好并发,处理不当很容易有数据误删或者出现永久的垃圾。

Mark and Sweep

这是官方的代码采用的方案,基本思路是,扫描所有的 image,对它们引用的 blob 标记为在使用中。扫描完成之后,所有从未被标记过的 blob 直接删除。

问题

如果直接用 Harbor 的 GC 方案,那么运行一次 GC 需要超过一个月的运行时间(不知道具体需要多久,因为从来没有成功跑完过)。之前的负责人设计了一个很聪明的方案,基本思路是,找到系统性能低的瓶颈,然后针对性地处理这些瓶颈。

对于前面的 3 个步骤:

  1. 删除 image 的 Tag:直接用 SQL 从数据库查询出来 image,判断是否需要保留(规则是每一个 image 只保留最近的 3 个版本),如果不需要保留,通过 API 删除;
  2. 扫描整个数据库,找到没有被任何其他 image 和 tag 引用的 blob:这一步因为是 Harbor 代码的 GC 逻辑,比较负载,还是通过 web UI 来触发的;
  3. 删除这些未被引用的 blob:Harbor 本身 sweep 的过程很慢,原因是没有并发,一个一个删除的,改进是直接通过并发删除。

这样,整体运行一次只需要一个月。

目前还是存在很多问题。我接受之后又做了一些改进:

  1. 之前的 PIC 显然是一个脚本大师,所有的工作都是通过 bash,awk,curl 这些工具完成的,每一步都需要人工操作 -> 等待完成 -> 人工操作下一步,比如到 mark and sweep 的这一步,需要人工去页面上触发 GC,然后关注执行的进度,在执行到 sweep 阶段的时候手动结束,开始运行下一步的脚本;我写了一个 300 多行的 Python 脚本,把所有的步骤串起来,这样就有了 crontab 定期执行的条件。
  2. 在第一步删除 image 的时候还是很慢,30s 只能删除一个 image,我们有千万个 image。解决办法是读了 harbor 的代码,发现 blobMgr.CleanupAssociationsForProject 这一步其实是最费时间且多余的,后面执行 GC mark 的时候一定会运行一遍。删除这个逻辑之后只需要 0.1s 就可以删除一个 image;
  3. 最后一步通过 API 删除 S3 上的数据,之前还是脚本用 curl 触发,速度太慢。使用 Python 之后就可以用 connection pool 并发删除了;
  4. 还做了其他的功能,比如支持不同的 project 自定义删除逻辑,「删除最近1年没有 pull 记录的 image」这种。

本质上是用最少的改动自动化原来的 GC 逻辑,目前运行一次的时间是 3 天。已经足够满足需求了,因为不需要人工执行,所以 3天和 3 个小时区别不大。

上一个负责人留下的文档详细记录的 Harbor GC 的逻辑以及改进点,比 Harbor 官方的文档还要详细。有了这些我半重写 GC 的逻辑就简单很多。

在他之前,是另一个负责 Harbor 的同事。阅读代码并找到瓶颈是需要很大的勇气的,且不一定行得通,可能花了很大的力气,最后发现这个事情做起来就只能这么慢。

但是问题还是要解决。所以他那时用了另一个有意思的方案:

  1. 搭建另一套一模一样的 Harbor 集群,复制以前的用户名,权限,project 等数据,但是把 blob 和 image 数据删除;
  2. 搭建一套 Nginx 代理,Nginx 转发逻辑是:
    • 对于 push,转发到新集群;
    • 对于 pull,先 pull 新集群,如果得到 404,就转发到老的集群,这样以前的数据都可以读;
  3. 在 1年之后,完全删除老的集群;

这是一个很有意思的「用运维手段解决技术问题」的例子,在 SRE 的工作中,迫于没有对软件的实现的控制力,我们经常需要用运维手段来解决代码实现上的问题。

  1. 计算机网络实用技术 ↩︎
  2. https://goharbor.io/ ↩︎
  3. Docker 镜像构建的一些技巧 ↩︎
  4. Build 一个最小的 Redis Docker Image ↩︎
  5. https://docs.docker.com/reference/cli/docker/image/tag/ ↩︎
 

大巴

我上初中的时候,村里去县城唯一的公共交通就是一辆大巴车。「大」也说不上,应该叫中巴车。车是邻村一个叫王文义的人买的。他把车停在我们村,每天早上骑摩托车从自己的村子跑到我们村,然后以我们村为起点,一路接上邻村的人,去县城的停车场。从我们村走,可以拉更多的人。走到王文义的村子,顺路接上王文义的老婆,王文义的老婆在车上卖票,王文义开车,一张票 7 块,过年的时候一张票 10 块。

我 12 岁的时候上初中。我们村的学生大部分都去镇上的中学读,我爸重视教育,送我到县城读。每天就是坐这个中巴车去学校,在学校住 12 天,每两个星期回家一次。刚开始每天都想家,住在学校里很不习惯,12 个人一间宿舍,没有办法洗澡,宿舍臭烘烘的。周五下午离开学校,坐在车上,是心情最好的时候,因为从现在开始距离学校越来越远了。周日的下午在车上,是心情最差的时候,在家收拾好东西,上了车,就没有退路了,只有一条去学校的路。

读高中的学生也是坐这辆车,需要去县城的村民也是坐这辆车,所以每周日下午格外人多。座位上全部坐满,车的走道也是挤满了人, 挤得满满当当,超载了两倍还多。

车比较破,车内的地板是一张铁皮,有的地方还破了小洞。有一次我坐在破洞的旁边,怀着郁闷的心情,透过洞看路上的小石子向后飞去。奔驰的客车,我和路面只有一张铁皮之隔。

后来王文义换了一辆新的宇通牌客车,涂着崭新的绿色油漆,座位也没有污渍,比以前也大了,可以叫做「大巴车」了。

高中生是周日中午回学校,初中生是周一早上。冬天天短,起个大早去坐车的只有我一个初中生。有一个冬天的早晨,格外的冷,我去车站的时候还伸手不见五指。我到车站,等了一会,王文义骑着摩托车来了。还不到发车时间,他打开门让我上车等,然后去发动车子。却发现油箱被冻住了,车发动不起来。他让我在上面坐着,我从玻璃看到他从附近的人家门外扯了一把干草,点了火塞在油箱下面烤,也顺便点了一只烟。看到这我就不淡定了,赶紧下了车,站在他旁边,看着火焰在车底燃烧,想象着发生巨大爆炸,把这几吨重的铁皮炸到天上的情形。过了一会,王文义的烟抽完了,回到驾驶室尝试发动,结果还真发动着了。他下车灭了火,就起步去县城了,车上还是只有我一个人。

3 年之后,我开始读高中,还是每两个周坐他的车上学,回家。生活还是一样的麻木,每天学习超过18个小时。最喜欢周六的中午坐车回家,最讨厌周日下午坐在回县城的车上。每两周的休息时间只有不到一天。

后来我去读大学,就再也没有坐过他的车了。每次回家我坐飞机去机场,然后坐芳姐的车去滨海酒店。父亲会去滨海酒店等我,开车带我回家。

芳姐是我哥介绍给我的,任何时候需要去坐飞机,或者从机场回家,只需要在微信上和芳姐说一声,芳姐安排一辆车点对点送到机场,除了我还会顺路接上其他需要去机场的人,一个人 60,7 座车跑一趟可以赚 360。 这些人常年跑机场,从来没有误过飞机。

只是过年的时候人流量大,活也多,一次司机和我说他三天只睡了6个小时,让我听了有些害怕。

大学快毕业的时候,村里就通了公交车,去县城价格更便宜了,但是为了连接更多的村庄,公交车也绕了,原来需要 40 分钟,现在要至少一个半小时。王文义的车也再也没有出现在我们村里。

 

LRO/GRO 对于网络吞吐的影响

打开这个抓包文件,可以马上确认这是一个发送的数据比较多的连接1,因为 TCP sequence number 上升的很快,IP 层的包都是用最大的 MTU 发送的。

抓包文件截图

分析长肥管道,可以使用之前介绍过的技术,用 tcptrace 来分析。

打开 Statistics > TCP Stream Graphs > Time Sequence (tcptrace),可以看到下图。(如果是一个直线,说明方向看反了,点击 Switch Direction.

tcptrace 图

由于没有抓到这个 stream 的 TCP 3次卧手包,我们不知道 window scaling 是多少,所以这条绿线就可以直接忽略了。剩余的看起来一点问题没有,cwnd 打开并且保持的很好,也没有很多 SACK。在 200ms 左右有一次丢包。但是看 sequence 上涨的趋势来说,并没有造成多大的影响,很快补回来了。所以这里不是主要原因。

Sequence 上涨的趋势没有太大问题,还会有超时,那么问题就可能出在——上涨的速度不够快。同样的转发链路,我们不禁怀疑,是不是新的设备比旧的设备转发性能低?每一个包都慢几个 us,总的吞吐就低?

可以打开正常的转发抓包做对比:

转发效率高的 tcptrace

这个线确实可能更加斜一点,但是斜多少呢?我们可以看吞吐的图。

性能低的 tcpdump throughput
性能正常的 tcpdump throughput

棕色的线对应实际的传输速率(右侧的 Y 轴)。可以看到,正常情况下吞吐可以达到 220Mbps 左右,但是换上新的设备只有 140Mbps 左右。在大部分 HTTP 请求中,对于小的包,延迟的变化不会特别大,但是在长肥管道中,吞吐低就会造成传输数据就会出现差距。导致部分请求超时。

其实,新旧设备的转发速度并没有根本的区别,造成吞吐不同的原因,发生在别处。

这两幅图的对比也揭示了更加深层次的原因:即左侧的 Y 轴。

左侧 Y 轴,以及图中的蓝色点,含义是 packet 的 size 的分布,每一个点代表了一个 packet size。第一幅图中,所有的 packet 都是使用最大的 MTU 发送的。内层 overlay(VxLAN Tunnel 里面)的 MTU 是 1450.

而下图中,packet 的 size 居然超过了 MTU!

之前的一篇有关 MTU 的讨论2,我们知道,发送超过 MTU 的包是会被其他的设备丢弃的,那么为什么我们从 tcpdump 能看到超过 MTU 的包呢?这是因为网卡帮我们把收到的多个小包给合并成了一个大包,再交给操作系统(Kernel)处理,这部分现在一般是在网卡的硬件上来完成的,所以我们抓包看到的(即操作系统看到的)是网卡合并处理之后的包。这叫做 Large Receive Offload,LRO。

LRO

为什么要这么做呢?因为 CPU 是通用处理器,它能做很多事情。很忙。为了提高性能,在硬件上做的很多优化都是让其他的硬件去分担 CPU 的工作。比如:

  • 让 GPU 来代替 CPU 做矩阵运算;
  • 用专用的设备来卸载 TLS3
  • 让网卡卸载 vlan,把小包合成大包,等等;

网卡擅长做重复但是简单的事情,合并小包再是再合适不过啦!

而 CPU 的工作量主要和处理多少包有关,和包的长度关系不大,长度是 1 的包(在 kernel 里面是 skb)和长度是 10000 的包,对于 cpu 来说,只是一个 length 的 value 不同而已。包的内容是业务逻辑,主要是由应用程序处理的,在 Kernel 里面,主要关注的是包的 header。假设 CPU 的能力是每秒处理 10 万个包,如果每一个包的长度是 1Kb,那么吞吐就是 10Mbps;但如果包的平均长度是 100Kb,那么吞吐可以达到 1Gbps。所以有了网卡给我们做 LRO,就可以有效提高 CPU 的吞吐。

到现在,原因就清晰了:新设备上了之后 LRO 失效,由于服务器的网卡不再执行 LRO 功能,吞吐就下降了很多,导致了部分请求超时。

那么为什么换了新的设备之后,服务器的网卡 LRO 就失效了呢?服务器网卡 LRO 和网络设备又有什么关系?

由于做不做 LRO 是服务器的网卡的硬件实现,我们无法查看硬件的设计。但是从其他地方对于 LRO/GRO 的描述,我们可以得到一些启发。

Linux 可以在没有硬件的支持下,用软件的方式实现 Generic Receive Offload, GRO (当然了,性能肯定是要差一些)。Kernel 的文档对于 GRO 的描述4如下:

Generic receive offload is the complement to GSO. Ideally any frame assembled by GRO should be segmented to create an identical sequence of frames using GSO, and any sequence of frames segmented by GSO should be able to be reassembled back to the original by GRO. The only exception to this is IPv4 ID in the case that the DF bit is set for a given IP header. If the value of the IPv4 ID is not sequentially incrementing it will be altered so that it is when a frame assembled via GRO is segmented via GSO.

除了 GRO,还有一种机制是 GSO,即 Kernel 在发送 TCP 流的时候,无须自己把每一个 Segment 切分成符合 MTU 大小再发送,而是可以直接发送,由网卡硬件来做这个切分操作。

为了让 GRO 和 GSO 是互相可逆的,即 GRO 之后的包可以通过 GSO 还原出来。需要保证:

  • IP 包的 DF 设置为1,禁止 IP Fragmentation;
  • IP 包的 DF 如果是0,那么 IP 的 ID 必须是连续的;

两个规则只要符合一条即可。

如果 DF 为1,很好理解,GRO 和 GSO 很容易逆向出来。

如果 DF 为0,ID 连续,比如 100,101,102,那么合成一个大包,大包的 ID 是 100,也可以逆向出来。但是如果 ID 不连续,比如 101,105,107,那么合成一个大包之后,就丢失原始的信息了。

对于 VxLAN 的包,在 DPDK 的文档5中,由明确要求外层的 IP 包和内层的 IP 包都要遵守这个规则:

  • outer IPv4 ID. The IPv4 ID fields of the packets, whose DF bit in the outer IPv4 header is 0, should be increased by 1.
  • inner TCP sequence number
  • inner IPv4 ID. The IPv4 ID fields of the packets, whose DF bit in the inner IPv4 header is 0, should be increased by 1.

查看吞吐慢的 tcpdump,可以发现 outer 的 ip.df 是0,而且 ID 不连续,所以无法做 LRO/GRO。

外层 ip.id 不连续

虽然我没有想到保证可逆可以带来哪些好处,但是从网上找到的资料来看,这个是在「ip.df=0 并且 ip.id 不连续的时候,不做 GRO」唯一的理由了。在另一处的邮件讨论中6,netdev 维护者以这个原则为理由拒绝了合并。起因是 Alexander Duyck 希望添加这个 patch,以达到效果:对于 overlay 的包,GRO 不再看外层包的 ip.id ,外层可以使用 fixed header,只看内层包的 ip.id 是否连续。这样,很多(实现不正确的)网络设备也可以享受 GRO 的好处了,但是因为会打破可逆的原则,所以没有被合并。

PS:上一篇文章问题中很多读者提到 GSO,为什么是 GRO 而不是 GSO 呢?因为 9999 是 server 端口,所以 192.168.1.100:9999 是 server 端,抓包文件显示的主要流量是 client 上传给 server 的,不是 server 发给 client 的。另一个细节是,.100 发给 .200 的 delta time,一般比 .200 发给 .100 的 delta time 要低,也可以佐证 .100 是 server 端。

  1. TCP 长肥管道性能分析 ↩︎
  2. 有关 MTU 和 MSS 的一切 ↩︎
  3. https://en.wikipedia.org/wiki/TLS_acceleration ↩︎
  4. Segmentation Offloads ↩︎
  5. VxLAN GRO ↩︎
  6. [RFC,7/9] GSO: Support partial segmentation offload ↩︎

==抓包破案录==

这篇文章是抓包破案录系列文章(之前叫做《计算机网络实用技术》,后来改名了)中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网络抓包与分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

如果您正在阅读的是题目类的文章,这个目录内容正好用来隔离其他读者的评论。读完题目可以稍作暂停,进行思考,继续向下滑动,可能会被其他的读者剧透答案。

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?答案和解析
  7. 网工闯了什么祸?答案和解析阅读加餐!
  8. 网络断断续续……答案和解析
  9. 延迟增加了多少?答案和解析
  10. 压测的时候 QPS 为什么上不去?答案和解析
  11. 重新认识 TCP 的握手和挥手答案和解析
  12. TCP 下载速度为什么这么慢?答案和解析
  13. 请求为什么超时了?答案和解析
  14. 0.01% 的概率超时问题答案和解析
  15. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
 

0.01% 的概率超时问题

前言:这个系列(以及我的博客)好久不更新了,原因有两个,一个是我在学习用双拼打字,手跟不上脑子,写的东西读起来不顺畅,不过现在已经复健了。双拼确实能极大地减少按键次数,在 AI 的时代,每个人需要和 AI 对话,那么怎么赶上时代的潮流,从芸芸众人脱颖而出呢?我的建议是:练习打字,打字打得快,和 AI 沟通效率高,做 AI 时代的佼佼者;第二个原因是我最近在思考人生的意义。上次录制博客 laike9m 提到了存在主义危机,第一次知道这个词,我觉得我就是陷入了存在主义危机。苦苦思索人生的意义,没有思考出什么结果。看了一些书,看的是莫言,刘震云,看了一本漫画,《我以为这辈子完蛋了- [美]艾莉·布罗什》,让我思考了很多。但是依然没有结论。人生没有思考明白,问题先来了。

有一天,我们在上线新的设备,上线之后,用户反馈他们的服务出现了网络超时的错误。超时的概率大概在 0.01%,并且出现的时间和我们上线新的设备的时间完全一致。我们把新上线的设备隔离(不再处理线上流量)用户的服务没有再出现错误了。

我们对新设备的性能非常有信心,不应该比原来的设备转发速度还低。这中间一定是有什么问题。

拓扑图简化如下:

拓扑图

其中,用户的 Client 和 Server 侧之间的网络是无法连通的,我们的网络设备会把用户的 Ethernet 包封装到 UDP 里面发送(overlay,原理就和 VPN 一样),这个设备提供了封装,转发的服务。但是用户的 Client 和 Server 感受不到中间这个 tunnel 的存在,Client 和 Server 之间的 IP 地址是可以直接 ping 通的,TCP 也是可以连通的,全靠我们的设备在中间做了转发。

我们做了一些常规检查没有发现问题,然后重新上线新的设备,要求用户在 Server 端进行抓包。得到文件如下。在一般的问题分析中,我们一般只看 packet 的 header 就够了,不需要看 application 层的 TCP payload,所以在抓包的时候我们会截断 TCP 的 payload,这样,在下载抓包文件和交流的时候,更方便一些,并不影响问题的分析。

请据此分析,造成小概率超时的问题在哪里。

如果没有头绪,请看下面的提示。

在找不到问题的时候,我们会对比正常情况下的表现,通过正常和异常的情况的不同来寻找线索。以下是原有环境的抓包文件,没有超时的请求。

对比两个抓包问题,请分析问题的根因。

==抓包破案录==

这篇文章是抓包破案录系列文章(之前叫做《计算机网络实用技术》,后来改名了)中的一篇,这个系列正在连载中,我计划用这个系列的文章来分享一些网络抓包分析的实用技术。这些文章都是总结了我的工作经历中遇到的问题,经过精心构造和编写,每个文件附带抓包文件,通过实战来学习网络抓包与分析。

如果本文对您有帮助,欢迎扫博客右侧二维码打赏支持,正是订阅者的支持,让我公开写这个系列成为可能,感谢!

如果您正在阅读的是题目类的文章,这个目录内容正好用来隔离其他读者的评论。读完题目可以稍作暂停,进行思考,继续向下滑动,可能会被其他的读者剧透答案。

没有链接的目录还没有写完,敬请期待……

  1. 序章
  2. 抓包技术以及技巧
  3. 理解网络的分层模型
  4. 数据是如何路由的
  5. 网络问题排查的思路和技巧
  6. 不可以用路由器?答案和解析
  7. 网工闯了什么祸?答案和解析阅读加餐!
  8. 网络断断续续……答案和解析
  9. 延迟增加了多少?答案和解析
  10. 压测的时候 QPS 为什么上不去?答案和解析
  11. 重新认识 TCP 的握手和挥手答案和解析
  12. TCP 下载速度为什么这么慢?答案和解析
  13. 请求为什么超时了?答案和解析
  14. 0.01% 的概率超时问题答案和解析
  15. 后记:学习网络的一点经验分享
与本博客的其他页面不同,本页面使用 署名-非商业性使用-禁止演绎 4.0 国际 协议。
 

PDF 电子书重排和裁剪

很久之前画重金买的阅读器1是 A4 纸大小,无论是阅读电子书还是 paper 都很好。但是后来莫名其妙地屏幕部分区域失灵了(情况和这里2介绍的差不多),维修找不到售后,京东推给 SONY,SONY 客服根本不知道有这么个产品,所以索性换了另一款阅读器:remarkable2.

这件事也加深了我 SONY 品控差的印象,之前的买过的 SONY 产品还包括 PSV,遥感漂移了(好像 switch 也有这类问题,所以可以饶恕吧);PS4 手柄莫名其妙也坏了,PS4 主机后来也坏了。以后不想再买 SONY 的产品了……

回到 Remarkable2,这款屏幕是 10.3 英寸,没有比之前的尺寸小很多,有一些 PDF 阅读起来就不太方便。有一些阅读器支持重排版和裁剪,有这个功能就解决问题了。但是仔细一想——重排版和裁剪不应该是一个软件功能吗?那么直接使用软件对 PDF 进行处理,然后阅读处理之后的文档不是也可以解决问题吗?

然后就发现了这个软件 K2pdfopt3,可以重新版本 PDF 为阅读器的尺寸。并且可以自动删除 PDF 的白边。

比如下面这个文档,对于印刷比较友好,左侧页面有右侧的留白,右侧页面有左侧的留白,但是使用阅读器,就浪费空间了。

K2pdfopt 可以自动裁切这种空白,命令是:

k2pdfopt input.pdf -h 1872 -w 1404  -dpi 226 -p 1-50 -wrap+ -m -o output.pdf -ui- 

效果如下。

裁剪之后就没有浪费的空白页面了

双栏的论文 PDF 页可以改成单栏的:

k2pdfopt paper1.pdf -mode 2col -col 2 -n -fc- -x -y -t -ds 2  -h 1872 -w 1404 -dpi 226 -m -o output_paper.pdf
对双栏 PDF 重排

最后在阅读器上的效果如下:

  1. https://www.kawabangga.com/posts/3161 ↩︎
  2. https://www.bilibili.com/video/BV1dt411R75L/ ↩︎
  3. https://www.willus.com/k2pdfopt/ ↩︎