这个功能在RHEL 8.2
及以上版本可以使用,当然RHEL对应的衍生版比如OracleLinux
、AlmaLinux
、RockyLinux
等也是可以直接使用的。
首先需要安装perf
和js-d3-flame-graph
这两个软件包:
# yum install js-d3-flame-graph perf -y
然后就可以通过perf script flamegraph -a -F 99 sleep 60
命令抓取整个系统的火焰图了,其中-a
参数表示需要记录整个系统的性能数据,-F
参数指定每秒的收集频率,sleep 60
表示收集60S的数据。
60秒后,命令自动退出并会在当前目录生成一个flamegraph.html
文件,用任意浏览器打开这个文件,即可看到火焰图。
当然,如果要收集某个进程的火焰图,可以使用perf script flamegraph -a -F 99 -p PID1,PID2 sleep 60
命令。
AWS:
https://ec2.amazonaws.com/?Action=CreateTags&ResourceId.1=ami-1a2b3c4d&ResourceId.2=i-1234567890abcdef0&Tag.1.Key=webserver&Tag.1.Value=&Tag.2.Key=stack&Tag.2.Value=Production&AUTHPARAMS
阿里云:
https://ecs.aliyuncs.com/?Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0****&ResourceId.2=i-bp1j6qtvdm8w0z1oP****&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestKey&<公共请求参数>
这种样式的接口设计,其实没有什么复杂的,相对比较特殊的地方在于,如果需要传入一个数组,则需要使用类似下标一样的Tag.N.Key
这种格式进行传递,这个传递方式,和已有的一些诸如google/go-querystring的传递方式都不太相同,总之是个很特殊的设计。
如果需要写一个类似的服务,使用和这两家相同的API格式的话,针对这种数组格式的请求反序列化是个挺麻烦的事,而且找了一圈也没有类似的开源项目做这个。
今天借助ChatGPT写了一个反序列化函数,专门用来实现服务端对类似形态API的反序列化,通过这个函数可以很方便的将Query反序列化成一个对应的Struct:
package mainimport ("encoding/json""fmt""net/url""reflect""strconv""strings")type TagRequest struct {Action string `query:"Action"`RegionID string `query:"RegionId"`ResourceIds []string `query:"ResourceId"`ResourceType string `query:"ResourceType"`Tags []Tag `query:"Tag"`}type Tag struct {Key string `query:"Key"`Value string `query:"Value"`}func Unmarshal(queryStr string, output interface{}) error {values, err := url.ParseQuery(queryStr)if err != nil {return err}return unmarshalData(values, output)}func unmarshalData(values url.Values, output interface{}) error {outputVal := reflect.ValueOf(output)if outputVal.Kind() != reflect.Ptr {return fmt.Errorf("output must be a pointer")}outputElem := outputVal.Elem()outputType := outputElem.Type()for i := 0; i < outputType.NumField(); i++ {field := outputType.Field(i)tag := field.Tag.Get("query")if tag == "" {continue}value := values.Get(tag)fieldVal := outputElem.FieldByName(field.Name)if field.Type.Kind() == reflect.Slice {elemType := field.Type.Elem()if elemType.Kind() != reflect.Struct {prefix := tag + "."arrIndex := 1for {currKey := prefix + fmt.Sprint(arrIndex)currValue := values.Get(currKey)if currValue == "" {break}currSliceVal := reflect.ValueOf(currValue)fieldVal.Set(reflect.Append(fieldVal, currSliceVal))arrIndex++}} else {prefix := tag + "."objIndex := 1outer := truefor outer {innerValues := make(url.Values)for innerKey, innerValue := range values {if strings.HasPrefix(innerKey, prefix+strconv.Itoa(objIndex)+".") {innerValues.Set(strings.TrimPrefix(innerKey, prefix+strconv.Itoa(objIndex)+"."), innerValue[0])}}if len(innerValues) == 0 {break}newStructPtr := reflect.New(elemType)err := unmarshalData(innerValues, newStructPtr.Interface())if err != nil {return err}fieldVal.Set(reflect.Append(fieldVal, newStructPtr.Elem()))objIndex++}}} else {fieldVal.Set(reflect.ValueOf(value))}}return nil}func main() {queryStr := "?Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0&ResourceId.2=i-bp1j6qtvdm8w0z1oP&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestValue&Tag.2.Key=TestKey&Tag.2.Value=TestValue"req := TagRequest{}err := Unmarshal(strings.TrimLeft(queryStr, "?"), &req)if err != nil {fmt.Println("Error:", err)return}jsonOutput, _ := json.MarshalIndent(req, "", " ")fmt.Println("Unmarshaled output:", string(jsonOutput))}
测试一下:
% go run main.goUnmarshaled output: { "Action": "TagResources", "RegionID": "cn-hangzhou", "ResourceIds": [ "i-bp1j6qtvdm8w0z1o0", "i-bp1j6qtvdm8w0z1oP" ], "ResourceType": "instance", "Tags": [ { "Key": "TestKey", "Value": "TestValue" }, { "Key": "TestKey", "Value": "TestValue" } ]}
嗯,ChatGPT牛逼!为了方便大家使用,我创建了一个项目c0refast/aws-querystring,可以方便地作为库使用:
package mainimport ("encoding/json""fmt""net/url""github.com/c0refast/aws-querystring/query")type TagRequest struct {Action string `query:"Action"`RegionID string `query:"RegionId"`ResourceIds []string `query:"ResourceId"`ResourceType string `query:"ResourceType"`Tags []Tag `query:"Tag"`}type Tag struct {Key string `query:"Key"`Value string `query:"Value"`}func main() {queryStr := "Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0&ResourceId.2=i-bp1j6qtvdm8w0z1oP&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestValue&Tag.2.Key=TestKey&Tag.2.Value=TestValue"urlValues, _ := url.ParseQuery(queryStr)req := TagRequest{}err := query.BindQuery(urlValues, &req)if err != nil {fmt.Println("Error:", err)return}jsonOutput, _ := json.MarshalIndent(req, "", " ")fmt.Println("Unmarshaled output:", string(jsonOutput))}
]]>说起来,为啥会有个DIY NAS的需求呢?一个重要的原因是家里的小宝贝出生了,不知不觉也拍了好多的照片和视频,还是希望能更长久的把这些记忆保留下来。另外呢,之前更新自己的电脑,淘汰下来一套i5 6500 CPU加16G内存以及主板的准系统,买个机箱还有电源就直接可以用了,本着废物利用的原则,做个NAS也不亏,而且还多了很多可玩性。
其实单纯从保存数据来说,将数据存放到任何一个公有云的对象存储上,是个最终极的方案,因为目前各个厂商提供的对象存储数据持久性SLA都达到了11或者12个9(99.999999999%-99.9999999999%),这基本意味着几乎不存在数据丢失的可能性了。但是确实这个方案也是最贵的,毕竟每TB存储每月都需要消耗对应的存储费用,随着时间增长,即使是最便宜的冷归档类型,也依然是个不小的消耗。
那到底需要多少的存储容量呢?针对我个人而言,目前可预见的容量,应该不会超过10T,当前1-2年内所需求的容量更小,大概只需要1到2T的样子。
针对这个容量,其实已经可以考虑全SSD的存储方案了,其实相比于使用HDD的方案,纯SSD的NAS有以下几个好处:首先是噪音角度,相比HDD运行时的“炒豆子”声来说,SSD 0噪音,这可以直接解决夜间安静环境下HDD低频噪音对睡眠质量的影响;其次是稳定性和数据安全角度,根据我们公司数据中心有比较大规模的SSD和HDD的使用经验,同时参考backblaze提供的统计数据,可以看出SSD的稳定性远超过HDD,这带来了两个优势,一个是相比HDD,SSD损坏的概率低,这可以减少存储池修复的可能性,另外因为读写速度上SSD快很多,在坏盘的情况下,SSD也可以做到更坏的修复速度,从而可以提供更好的数据持久性。
当然SSD依然还是有缺点的,很明显当前SSD比HDD依然贵很多,以当前的价格来说,SSD成本大约0.4元/GB(大多数1T SATA SSD),HDD大概只有0.12元/GB(西数HC550 16T)。但是对于我目前的容量需求来说,使用SSD的成本相比HDD没有差距太大,多花的那部分成本,对于0噪音来说是相当值得的。
除了磁盘的选型,还有一些其他的需求,诸如盘位数量大于等于4,硬盘需要支持热拔插,存储池可以动态扩容,移动端、桌面端数据自动同步等等,不过这些也都算是比较基础的需求了。
针对硬盘热拔插的需求,肯定还是要搞个硬盘笼的,不管怎么说,相比于直接把硬盘塞机箱里,有个热拔插硬盘笼一下子逼格就上来了。
所以一直花了不少精力去找合适的硬盘笼,主要还是集中于服务器的拆机件,这里给几个当时考虑的一些方案。
首先第一个选择是买浪潮的12盘位3.5寸硬盘笼,目前的价格大概150块钱的样子,还挺便宜,感觉应该都是当初Chia矿老板淘汰下来的,这些硬盘笼基本都有大4P的电源接口以及MiniSAS(SFF8087)接口,使用起来还是比较方便的,当然缺点是确实占地比较大,毕竟是适配的2U机箱,因为本来也一直坚持全闪的方案,所以3.5寸的硬盘位就没有必要了,即使很便宜,依然放弃了这个方案。
这是Intel一个颜值和功能都超级能打的硬盘笼,具体的参数可以参考Intel的Spec文档(不得不说Intel的文档写的是真的好),甚至当前这个时间点,依然在量产状态,这个笼子一般来说都是在2U机箱上做竖插24盘位的组件的,这几乎是我心目中最理想的硬盘笼选择,8盘位AnyBay,支持SATA、SAS、U.2 NVMe接口的硬盘,特别是在现在咸鱼有大量的U.2接口的大容量企业级SSD,价格十分友好。最关键的是这个硬盘笼的尺寸非常完美,可以无缝的塞进两个5.25英寸光驱位中,网上也有这个硬盘笼搭配银欣(SilverStone)SG02-F机箱组NAS的方案:全网首发【8盘位热插拔NVMe SSD NAS】DIY指南简章,不得不说这个方案真的让人流口水,但确实不得不说成本太高了。
这个硬盘笼呢,什么都好,就是成本太高,不仅仅笼子本身(大约1100+)更重要的是其配套的组件,首先这个硬盘笼是MiniSAS HD(SFF‑8643)接口的,支持这个接口的HBA或者RAID卡也比较贵,其次如果说要支持8个NVMe硬盘的话,需要准备8个OcuLink接口,那PCIe转OcuLink接口的转接卡又是不小的支出。更重要的是,8个NVMe需要32个PCIe lane,这直接超出了当前大部分平台的能力,基本只有服务器平台的CPU+主板才能支持这么多的PCIe lane,成本很可观。最后还有一个不得不考虑的问题,U.2 NVMe硬盘一般来说功耗都比较高,很多盘能到10几20W往上,如果是8块硬盘,那整体功耗可能会超过100W,所以散热的问题就不得不考虑了,这个笼子如果插NVMe硬盘的话,需要额外的散热。
所以呢,东西虽好,但确实不符合我当前的需求和预算(流下贫穷的泪水)。据说H3C也有类似的笼子,价格要便宜1半,如果大家有类似的需求可以考虑。好像类似的8盘位AnyBay硬盘笼,各家OEM都有,但是好像好买且价格合适的不多。
这是另外一个Intel的硬盘笼子,大概可以算是上面笼子的低配版,文档看这里,这个笼子支持4个NVMe + 4个SATA/SAS硬盘,价格在淘宝也相对便宜,看到加上PCIe转接卡大概1000不到可以拿下,其实是个不错的选择,但是依然超出我的预算不少(继续流下贫穷的泪水)。
Intel还有一种硬盘笼(Intel出的好东西真不少啊),文档看这里,这个笼子支持8个SATA/SAS硬盘,使用两个MiniSAS(SFF8087)接口,是一开始我选中的方案,整体还是很平衡的,淘宝大概400不到,在我找到下面的硬盘笼之前,一度准备剁手入了。
这个是我最后选择的笼子,这个硬盘笼原本是给HP DL380G6/G7升级16盘位的套件,在HP那边的编号是:507690-001
和516914-B21
(这俩编号是一个东西),这个套件包含的几个组件和对应的物料编号如下:
硬盘笼子:463173-001 496074-001
硬盘背板:507690-001 451283-002
硬盘供电线:514217-001
硬盘SAS数据线:498425-001 493228-005
为什么我选择这个硬盘笼子呢,因为它真的便宜,笼子加上背板、送供电线和两根SAS线,只需要50块钱,当然是不带硬盘托架的,不过算上硬盘托架的价钱也只需要80。80块钱真买不了上当和吃亏。说实话它也有一些小问题,比如坑爹的HP不知道为啥要设计成横向的两个4盘位,尺寸比竖向8盘位相比宽了一点点,直接导致没办法塞进2个5.25英寸光驱位。另外电源接口的设计也比较恶心,是向外的,如果想塞进机箱,那突出来的电源线会成为一个大问题,另外HP的电源接口定义也是每代一改,虽然复用了10pin的接口,但是定义并不标准,导致我花了非常长的时间去寻找各个pin的定义,生怕弄错接口定义把背板烧了,最终还是在一个德语的论坛找到一个评论说了这个硬盘笼子的定义,需要说明的是这个背板不接3.3V的供电也没有影响,所以也是淘宝了两根大4P的电源线和10pin线,自己DIY了一个电源线,最终把这个硬盘盒的供电问题解决。总的来说,主要这个笼子实在是太便宜了,便宜到它的这几个缺点都可以忍受(还是流下贫穷的泪水)。
针对上面几个硬盘笼,特别是最终我选的硬盘笼,都使用了MiniSAS(SFF8087)接口,所以要想使用硬盘笼的话,需要有支持MiniSAS接口的SATA控制器或者RAID/HBA卡,根据之前的调查,大致有几种方案:
1. 可以使用MiniSAS转4SATA线实现,不过需要注意的是,这种线是区分正反的,正向线是MiniSAS口转4SATA,需要买反接线,将4SATA转换成MiniSAS口,从而实现将主板上的4个SATA口转成一个MiniSAS口。2. 市面上还有一些基于类似ASM1166(或者类似芯片)的PCI-E转SFF-8087转接卡,但ASM1166原生只支持6SATA Port,是否两个SFF-8087端口的8个端口都能使用,这个存疑。 另外还有也有一些M.2转8口SATA扩展卡也是用的两个SFF-8087转接卡,理论上也可以使用。3. 使用拆机的服务器HBA卡,比如基于LSI SAS 2008/2308的一众原厂或者OEM HBA卡。
最终我还是选择了相对成熟的HBA卡方案,一顿精挑细选,最终选了SuperMicro家的AOC-S2308L-L8I 9217-8i
,基于SAS 2308芯片,PCIe 3.0 x8的接口,支持两个SFF-8087接口,这是一张OEM卡,对应的原厂卡型号是LSI SAS9217-8i
,市面上除了SuperMicro之外,还有很多OEM也会出相同芯片的卡,比如浪潮、IBM等等,选择还是比较多的。我选的是2308的方案,这个芯片算是2008芯片的升级,其实区别不大,最主要的升级就是从PCIe 2.0 x8变成了PCIe 3.0 x8,整体带宽会高点。另外需要注意的一点是,这两个芯片有两种固件:IT(Initiator Target)模式和IR(Integrated RAID)模式,IT模式是类似HBA卡的直通模式,没办法组建RAID;IR模式是类似RAID卡的模式,可以组建简单的RAID0和RAID1。另外这两种固件是可以互刷的,区别只是在产品名字上是9207还是9217(9217是IR模式,9207是IT模式,所以我买的卡也是原厂IR模式刷的IT固件)。最后其实这个卡有个比较大的散热方面的问题,根据原厂的User Guide文档。这张卡默认情况下有接近10W(默认9.8W,最大16W)的功耗,且最低要求200 linear feet per minute
的风量,在服务器环境下散热都不是问题,但是放到家用的机箱里,如果没有主动散热的情况下,这张卡会非常的烫手!所以最终我又找了一个12cm的风扇专门对着卡的散热片吹,从而解决散热问题。
上面的这些组件搞定,最终就是买硬盘了,之前提到现在全新的SATA SSD大概0.4元/GB,一块1TB的SSD大概400左右,说实话还是不便宜的,所以我又勇敢的选择了大船货!其实现在二手拆机SSD,量最大,最划算的还是U.2的硬盘,不到1000块钱可以买到4T左右的企业级SSD,而且这些企业级SSD寿命极高,稳定性也相当好,而且相比于SATA接口,U.2因为是PCIe链路,可以做到传输层的数据保护,可惜的是咱们的硬盘笼不支持。于是就只能选择SATA接口的SSD,一下子可选范围就少了不少,最终还是选了当前比较火的Sandisk/闪迪云盘ECO,但是相比于更火的1.92T容量的版本,我最终买了960G容量的版本,相比于1.92T这个容量点,我猜960G容量用来做系统盘的概率更大点,说不准能抽奖抽到写入量超低的盘😁。相比于全新盘,这个拆机盘的价格就很实惠了。目前的价格是960G容量版本230块,不到0.24元/GB,属实是相当划算了。
所有硬件的问题搞定,最后就只剩下软件层面的选择了,到底该用什么系统呢?一开始想使用TrueNAS,主要是看中ZFS的能力,但是试用了一小段时间TrueNAS之后,感觉这系统是真的很难用,门槛太高了,虽然运维这玩意对我来说并不是太大的瓶颈,但是确实各个方面都不太好用,特别是相关的软件生态上差很多。
于是乎就又试了试黑群晖,一开始我其实是抵制黑群晖的,因为有点担心数据安全问题,不确定会不会哪天就崩了,但是试用了一下之后,觉得确实群晖的生态做的太好了,体验拉满。于是就去简单研究了一下黑群晖的实现原理,发现其实黑群晖的相关项目都是开源的,都放在了RedPill-TTG这个组织下,其中最关键的对群晖内核的hack都在RedPill-TTG/redpill-lkm这个项目里,大致翻了翻代码,基本上就是通过加载模块的方式,欺骗群晖的内核,让其认为自己是跑在群晖专有的硬件上。了解了之后,黑群晖在我心目中好感倍增,话说感觉群晖这绝对是套路满满,都说黑群晖的尽头是白群。估计群晖官方默许黑群晖行为的原因大概和Windows一样,反正最终都会买我。
不过相比于直接把群晖跑在裸机上,我采取了一个另外的方法,把群晖跑在了虚拟机里,这样做的目的主要也是为了方便以后迁移,对我来说当前的硬件平台只是个相对临时的解决方案,为了后续能更好的跨平台迁移,所以我选择将黑群晖跑在虚拟机里,而对于HBA卡来说,采取的是硬件直通的方式直通给虚拟机,从而确保SMART等功能的正常使用。这里分享一下我现在用的虚拟机的XML,有需要的可以参考一下:
<domain type="kvm"> <name>Synology</name> <uuid>5ce24e3b-627b-468a-bcd5-53ff58d9731d</uuid> <memory>8388608</memory> <currentMemory>8388608</currentMemory> <memoryBacking> <hugepages/> </memoryBacking> <vcpu>4</vcpu> <os firmware="efi"> <firmware> <feature enabled='no' name='secure-boot'/> </firmware> <type arch="x86_64" machine="q35">hvm</type> <boot dev="hd"/> </os> <features> <acpi/> <apic/> </features> <cpu mode="host-passthrough"/> <clock offset="utc"> <timer name="rtc" tickpolicy="catchup"/> <timer name="pit" tickpolicy="delay"/> <timer name="hpet" present="no"/> </clock> <pm> <suspend-to-mem enabled="no"/> <suspend-to-disk enabled="no"/> </pm> <devices> <emulator>/usr/libexec/qemu-kvm</emulator> <disk type="file" device="disk"> <driver name="qemu" type="raw"/> <source file="/data0/Synology/boot.img"/> <target dev="sda" bus="usb"/> </disk> <interface type="bridge"> <source bridge="br0"/> <mac address="11:22:33:44:55:66"/> <model type="virtio"/> </interface> <console type="pty"/> <hostdev mode="subsystem" type="pci" managed="yes"> <source> <address domain="0" bus="1" slot="0" function="0"/> </source> </hostdev> <memballoon model="none"/> <graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'> <listen type='address' address='0.0.0.0'/> </graphics> <video> <model type='vga' vram='16384' heads='1' primary='yes'/> </video> </devices></domain>
目前的这套NAS方案已经运行了2个多月,看起来还算稳定,这段时间内没出现过大的问题。如果折腾半天就只为了存点照片也有点浪费,所以除了存储功能之外,我又跑了个PCDN业务,目前收益也还不错,我是100M的上传带宽,目前每天大概能有个5块钱的收益,至少电费能覆盖了,跑着玩玩吧~
想想当前这套还有什么不足,个人觉得一个比较大的缺点是占地,目前我是用了乔斯伯的V4做的机箱,但是硬盘笼是不太好直接放进机箱了,所以只是简单的放在了外壳上,整体相比于家用的NAS产品还是大了一圈,另外确实硬盘笼比双5.25寸光驱位大了点,即使后续换了机箱,可能也不太好直接放进去。
另外就是CPU这些硬件配置了,一方面当前这颗CPU TDP还是比较大的,65W,必须有主动散热,另外HBA卡也需要主动散热,所以离真正的0噪音还有点距离,理论上换用嵌入式的低功耗CPU+SATA转MiniSAS的方案,可以做到整机完全没有任何机械活动的部件,做到真正意义上的0噪音。
最后就是另一个方向,换到稍微低端点的数据中心CPU+主板,功耗会高一些,但是可扩展性会极大的增强,包括ECC内存以及U.2硬盘、IPMI这些都可以支持。具体往后该如何演进,还是等待当前套系统继续运行一段时间吧。
]]>于是就被各种原厂的或者OEM厂的各种型号搞晕了,因为基于这个芯片的各种OEM马甲卡实在太多了。于是乎就找到了一篇神贴,帖子里覆盖了几乎所有的LSI的RAID卡芯片以及大部分国外厂商对应的OEM卡型号,而且一直在更新,最新的一些SAS芯片也有记录,比较可惜的是国内的一些OEM厂商,特别是在淘宝保有量相当大的浪潮的OEM卡型号没有记录。
帖子的地址在:LSI RAID Controller and HBA Complete Listing Plus OEM Models。有需要的可以参考一下。
后续组NAS的经历我也会持续分享,敬请关注~
]]>在这种场景下,尽可能延长SSD的写入寿命就很重要了,而方法之一呢,就是想办法把SSD的Trim
命令给用上。
用上Trim
命令之前,可以先简单了解一下背后的逻辑,具体的可以参考Wiki,简单来说呢,因为SSD依赖垃圾回收机制来平衡NAND的磨损,但是呢具体到一整个LBA空间,只有文件系统知道哪些数据块是有效数据,所以就需要通过Trim
命令,建立文件系统空闲空间和SSD底层数据块的关联,从而让SSD的主控更好的进行垃圾回收操作,一般来说,合理的使用Trim,可以有效的提高SSD的性能和寿命。当然了,Trim
命令是ATA指令集里的,也就是SATA接口SSD才会有,对于SCSI以及SAS接口SSD,还有NVMe SSD来说,也有相应的UNMAP
和Deallocate
指令,作用都是一样的。
一般来说,在Linux下,一个设备是否支持Trim
操作,可以通过lsblk --discard
进行查看,当输出中的DISC-GRAN
和DISC-MAX
列不为0时,说明这个设备是支持Trim
操作的:
jetson-nano:chenfu:# lsblk --discardNAME DISC-ALN DISC-GRAN DISC-MAX DISC-ZEROsda 0 0B 0B 0mmcblk1 0 4M 76M 0└─mmcblk1p1 0 4M 76M 0
比如在我这个JetsonNano上,可以看到我外接的这块SSD硬盘,对应sda
设备是不支持Trim的,但是mmcblk1
这个设备,也就是装系统用的一个小的MicroSD卡是支持的。
那么问题来了,针对上面的输出,sda这块盘是不支持Trim的,那怎么样才能让他支持呢?
首先需要明确的是,因为这块盘是我通过一块USB移动硬盘盒转接到板子上的,也就意味着这块硬盘并没有用原生的SATA接口(当然这块开发版本身也不支持SATA接口)。而对于移动硬盘盒而言,将SATA口转换成USB口,会需要一个桥接芯片进行协议的转换,那么桥接芯片是否支持Trim命令的转换,就显得非常重要了。对于一些老的移动硬盘盒,大多使用的是Mass Storage Class Bulk-Only Transport (BOT)这个协议,但是对于一些比较新的桥接芯片,基本都会支持一个新的叫做USB Attached SCSI Protocol (UASP) 的新协议。所以我也查了一些资料,同样也是结合产品页的一些宣传,买了一个支持UAS协议的移动硬盘盒,根据评论看,这个硬盘盒是支持Trim的,但是大部分用户似乎都是在Windows下进行测试的,在Linux下是否真的支持,是否需要新版本内核或者驱动的支持还不知道。
等硬盘盒到手,插上之后系统lsusb看了一下:
jetson-nano:~:% lsusbBus 002 Device 002: ID 174c:225c ASMedia Technology Inc. Ugreen Storage Device
VendorId是0x174c,也就是ASMedia公司的桥接芯片,但是225c这个ProductId并没有在USB ID数据库里查到,不过从数据库里看0x1153这个ProductId对应ASM1153这款芯片来说,那225c应该是对应着ASM225CM这个芯片?从目前的资料看,这个芯片理论上是支持Trim的,至少可以通过刷新固件来解决支持的问题。
然而系统识别出sda之后,lsblk --discard
依然提示不支持Trim。
于是又搜索了一些资料,终于在Arch的SSD Wiki里找到了一些信息:
其实现在一些USB转SATA芯片(如VL715、VL716等)以及在外接NVMe硬盘盒(如IB-1817M-C31)中使用的USB转PCIe芯片(如 智微(JMicron) JMS583 )支持类似TRIM的命令。这些命令可通过 USB Attached SCSI 驱动程序(在Linux下称为”uas”)发送。然而内核可能不会自动检测到并启用这一功能。
会不会是因为芯片是支持的,但是系统默认没有开启呢?于是按Wiki里的说法,使用sg_readcap -l /dev/sda
命令读取设备的标志位:
jetson-nano:chenfu:# sg_readcap -l /dev/sdaRead Capacity results: Protection: prot_en=0, p_type=0, p_i_exponent=0 Logical block provisioning: lbpme=0, lbprz=0 Last LBA=937703087 (0x37e436af), Number of logical blocks=937703088 Logical block length=512 bytes Logical blocks per physical block exponent=0 Lowest aligned LBA=0Hence: Device size: 480103981056 bytes, 457862.8 MiB, 480.10 GB
发现Logical block provisioning: lbpme=0, lbprz=0
其中lbpme=0,因为LBPME位为0,所以内核默认是不会开启DISCARD的支持。针对这种情况,还需要继续通过sg_vpd -a /dev/sda
命令查询设备支持的命令情况:
jetson-nano:chenfu:# sg_vpd -a /dev/sdaSupported VPD pages VPD page: ...Unit serial number VPD page: Unit serial number: 704108E11D02Device Identification VPD page: Addressed logical unit: designator type: NAA, code set: Binary 0x5000000000000001Block limits VPD page (SBC): Write same non-zero (WSNZ): 0 ...Block device characteristics VPD page (SBC): Non-rotating medium (e.g. solid state) ...Logical block provisioning VPD page (SBC): Unmap command supported (LBPU): 1 Write same (16) with unmap bit supported (LBPWS): 0 Write same (10) with unmap bit supported (LBPWS10): 0 Logical block provisioning read zeros (LBPRZ): 0 Anchored LBAs supported (ANC_SUP): 0 Threshold exponent: 0 [threshold sets not supported] Descriptor present (DP): 0 Minimum percentage: 0 [not reported] Provisioning type: 0 (not known or fully provisioned) Threshold percentage: 0 [percentages not supported]
可以发现在Logical block provisioning VPD page (SBC)
段下,有Unmap command supported (LBPU): 1
,说明设备本身是支持Unmap指令的,因为前面说到,ATA中的Trim其实就是对应的SCSI中的UNMAP,所以支持UNMAP也就是支持了Trim,当然这中间的转换过程,应该是有硬盘盒的主控来完成。
那既然在物理上是支持Trim的,那剩下的就是逻辑上怎么启用的问题了,先看下目前内核识别的设备的provisioning_mode:
jetson-nano:chenfu:# cat /sys/block/sda/device/scsi_disk/0:0:0:0/provisioning_modefull
可以发现输出是full
,也就是说内核当前是没有检测到设备支持Trim特性,解决方法也比较简单,直接echo unmap
到这个文件:
jetson-nano:chenfu:# echo unmap > /sys/block/sda/device/scsi_disk/0:0:0:0/provisioning_modejetson-nano:chenfu:# lsblk --discardNAME DISC-ALN DISC-GRAN DISC-MAX DISC-ZEROsda 0 512B 4G 0mmcblk1 0 4M 76M 0└─mmcblk1p1 0 4M 76M 0
可以看到,强制指定provisioning_mode为unmap之后,lsblk --discard
的输出已经提示sda设备支持Trim了。
最后,为了能让这个特性可以在插入硬盘盒的时候自动生效,可以手动编写一个Udev的规则文件:
echo 'ACTION=="add|change", ATTRS{idVendor}=="174c", ATTRS{idProduct}=="225c", SUBSYSTEM=="scsi_disk", ATTR{provisioning_mode}="unmap"' >>/etc/udev/rules.d/10-uas-discard.rules
也就是说,当有idVendor为174c,idProduct为225c的设备(也就是我的这个硬盘盒)连接的时候,自动设置provisioning_mode为unmap。
]]>bdev reduce
模块实现背后的算法描述,于是就想着翻译一下,正好也借翻译的同时仔细理解一下背后算法的原理,当然本人的水平有限,如果译文有任何歧义,还请参考原文并以实际原文为准。SPDK的“reduce”块压缩方案使用SSD存储压缩后的块数据,同时将元数据存放到持久内存中。此元数据包含用户数据的逻辑块到压缩后的物理块的对应关系。本文档中描述的方案是通用的,不依赖于包括SPDK
在内任何特定的块设备框架。该算法会在一个叫做libreduce
的库中实现。更高层次的软件可以基于该模块创建和呈现特定的块设备。对于SPDK来说,bdev_reduce
模块封装了libreduce
库,从而在SPDK中提供一个bdev以实现压缩功能。
本方案仅仅解释压缩后的数据块和用于跟踪这些数据块的元数据的管理。它依赖于高层软件模块来执行压缩操作。对于SPDK,bdev_reduce
模块利用DPDK compressdev
框架执行压缩和解压缩。
(需要注意的是,在某些情况下,数据块可能是不可压缩的,或者无法压缩到足以实现空间节省的程度。在这些情况下,数据可能不经过压缩,直接存储在磁盘上。“压缩的存储块”包括这些不经压缩的块。)
一个压缩块存储设备是一个建立在拥有相似大小的后备存储设备之上的一个逻辑实体。其中的后备存储设备必须是精简置备(thin-provisioned)的从而才能真正意义上从后文描述的实现中获得空间节省。同样该算法除了一直使用后备存储设备上可用的编号最低的块之外,对后备存储设备的实现没有直接的了解。这保证了在精简配置的后备存储设备上使用此算法时,在实际需要空间之前不会分配对应空间。
后备存储的大小,必须考虑最坏情况,也就是所有数据都不可压缩的情况。在这种情况下,后备存储的大小和压缩块设备的大小是一致的。另外,本算法基于永远不会原地写这个前台来保证原子性,所以在更新元数据之前,可能还需要额外的一些后备存储空间来作为临时写缓存。
为了最佳性能考虑,所有后备存储设备都将以4KB为最小单位进行分配、读取和写入。这些4KB的单元被称作“后备IO单元”(backing IO units)。他们被一个称作“后备IO单元索引”(backing IO unit indices)的索引列表中以0到N-1编号进行索引。在一开始,这个完整的索引代表了“空闲后备IO单元列表”(free backing IO unit list)。
一个压缩块存储设备基于chunk进行压缩和解压操作,chunk大小至少是两个4K的后备IO单元,每个chunk所需要的后备IO单元数量,也同样表明了chunk的大小,这个数量或者大小需要在压缩块存储设备创建时指定。一个chunk,需要消耗至少1个,至多chunk大小个后备IO单元数量。举个例子,一个16KB的chunk,有可能消耗1,2,3,4个后备IO单元,最终消耗的数量取决于这个chunk的压缩率。磁盘blocks和chunk的对应关系,存储在持久内存中的一个chunk map
里。每个chunk map
包含了N个64-bit的值,其中N是每个chunk所包含的后备IO单元的数量。每个64-bit值表示一个后备IO单元的索引。一个特殊的值(举个例子,2^64-1)用来表示因为压缩节省而不需要使用实际的后备存储。chunk map
的数量,等于压缩块设备的容量除以它的chunk大小,再加上少量用于保证原子写操作额外的一些chunk map
。一开始所有的chunk map
都表示“空闲chunk map列表”。
最后,压缩块设备的逻辑映射表通过“logical map”进行表示。这里的“logical map”指的是压缩块存储设备对于对于chunk map的偏移的对于关系。logical map里每个条目是一个64-bit的值,表示所关联的chunk map。一个特殊值(UINT64_MAX)表示没有对应关联的chunk map。映射是通过将字节偏移量除以块大小得到一个索引来确定的,该索引用作块映射条目数组的数组索引。 开始时,逻辑映射表中的所有条目都没有关联的块映射。 请注意,虽然对后备存储设备的访问以 4KB 为单位,但逻辑映射表可能允许以4KB或512B为单位进行访问。
为了说明这个算法,我们将使用一个真实的非常小规模的例子。
压缩块设备的大小为64KB,chunk大小为16KB。 这会实现以下几点:
在下面的例子中,”X”符号代表上面所说的那个特殊值特殊值(2^64-1)。
+--------------------+Backing Device | | +--------------------+Free Backing IO Unit List 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | | | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 0, 1, 2, 3, 4 +---+---+---+---+Logical Map | X | X | X | X | +---+---+---+---+
+--------------------+Backing Device |01 | +--------------------+Free Backing IO Unit List 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 1, 2, 3, 4 +---+---+---+---+Logical Map | X | X | 0 | X | +---+---+---+---+
+--------------------+Backing Device |012 | +--------------------+Free Backing IO Unit List 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | 2 X X X | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 2, 3, 4 +---+---+---+---+Logical Map | 1 | X | 0 | X | +---+---+---+---+
+--------------------+Backing Device |01 34 | +--------------------+Free Backing IO Unit List 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | | 3 4 X X | | | +------------+------------+------------+------------+------------+Free Chunk Map List 1, 3, 4 +---+---+---+---+Logical Map | 2 | X | 0 | X | +---+---+---+---+
针对跨越多个chunks的请求,逻辑上这个请求会被分割成多个请求,每个请求关联一个chunk。
举例:在4KB偏移处写入20KB数据
在这个场景下,这个写请求会被分割成:一个在4KB偏移处写入12KB数据的请求(只影响逻辑映射表的第0个条目),以及一个在偏移量16KB处写入8KB的请求(只影响逻辑映射表的第1个条目)。
每个子请求都独立的基于上述的算法进行处理,直到这两个子请求全部完成时,原始的20KB写入操作才会返回。
Unmap操作通过从逻辑映射表中删除对应的(如果有)chunk map条目来实现,对应的chunk map会被放回到空闲chunk map列表中,并且任何相关的后备IO单元也会被释放并放回到空闲后备IO单元列表中。
而对于针对chunk的某一部分进行Unmap的操作,相当于对chunk的这一部分写0,如果整个chunk在多次的操作中被整体Unmap掉了,那么未压缩的数据就变成全0了,这样就可以被检测出来,在这种情况下,整个chunk的映射条目也会从逻辑映射表里被移除。
当整个chunk都被Umap掉之后,后续针对该chunk的读操作都会返回全0,这个表现就和上述在16K偏移量处读取16KB数据(Read 16KB at Offset 16KB)的例子一致。
写0操作的流程和Unmap操作类似,如果一个写0操作覆盖了整个chunk,我们也可以在逻辑映射表中完全移除整个chunk的对应条目,然后后续的读操作也会返回全0。
一个使用libreduce
模块的应用程序,有可能需要定期退出并重新启动。当应用程序重新启动的时候,会重新加载压缩卷,从而恢复到应用程序退出之前的状态。
当压缩卷被加载的时候,空闲chunk map列表和空闲后备IO单元列表会通过扫描逻辑映射表的形式进行重建。逻辑映射表只会保存有效的chunk map索引,同样的,chunk map只会保存有效后备单元索引。
任何没有被引用的chunk map以及后备IO单元,都会被认为是空的,并加入到对应的空闲列表中。
这就保证了如果系统在一个写操作的中间状态下崩溃后(比如在chunk map被更新,但还没写入逻辑映射表的过程中崩溃)重启的过程中,所有未完成的写入操作都会被忽略。
具体实现时,必须要考虑针对同一个chunk并发操作的情况。比如第一个IO需要对chunk A写入某些数据,同时又有第二个IO也需要对chunk A进行写入。在这种情况下,第二个IO必须等第一个IO完成之后才能开始。
针对类似情况的进一步优化,超出了本文档的描述范围。
后备存储设备必须是精简置备的,从而才能在压缩场景下实现空间节约。本文的算法永远都会使用(重用)后备存储上最靠近偏移量0的后备IO单元。
这确保了即使后备存储的空间和压缩块设备的大小接近,但是直到确实需要后备IO单元的时候,才会真正从后备存储设备上分配存储空间。
对于需求的用户授权也就是authorization (authz)部分,实现思路还是比较简单的,毕竟K8s的RBAC实现相对来说还是非常完善的,而且RBAC对于我们目前的用户和组织权限管理理念十分的接近。所以只需要将目前系统里的用户权限和组织关系,对应到一系列的RBAC Role和RoleBinding里,就可以实现对于用户权限的精细化控制。
而对于用户的认证authentication (authn)部分,K8s提供了非常多的身份认证策略。但是如文档里明确的一点:
Kubernetes 假定普通用户是由一个与集群无关的服务通过以下方式之一进行管理的:
- 负责分发私钥的管理员
- 类似 Keystone 或者 Google Accounts 这类用户数据库
- 包含用户名和密码列表的文件
有鉴于此,Kubernetes 并不包含用来代表普通用户账号的对象。 普通用户的信息无法通过API调用添加到集群中。
K8s并不自己管理用户实体,所以是没有办法像RBAC那样,通过创建一个“User”资源,来把某个用户添加到集群里的。
其实这个特点,对于系统集成来说,可能更是一个优点,因为这直接避免了第三方系统的用户属性和K8s“用户”属性可能存在的不兼容问题。
而对于目前的需求而言,需要做到以下几点:
针对这几个需求,又通读了一遍文档之后,最终决定使用身份认证代理这个方式,怎么理解呢:
K8s APIServer可以获取HTTP请求中的某些头部字段,根据头部字段的值来判断当前操作的用户。也就是说,如果实现一个反向代理服务器,由这个反向代理服务器实现Token的认证工作,确认用户请求的有效性,若用户请求有效,直接把用户的信息添加到HTTP请求头中,并代理到K8s Server,最终再由K8s中的RBAC规则,判断用户能否调用对应API。
这么做刚好能满足目前的需求,首先,Token的发放和验证完全和K8s没有关系,所以Token可以保持和原有系统保持不变;同样的,代理只是根据HTTP头进行验证并转发,也不会修改任何K8s API的调用方式和格式,所以也能保持很好的兼容性;又因为所有的用户请求都会经过代理服务器,所以代理服务器可以记录所有请求的详细信息,从而方便实现各种审计工作。
那么问题来了,K8s通过哪个HTTP Header获取用户信息呢?
APIServer提供了几个命令行参数:--requestheader-username-headers
、--requestheader-group-headers
、--requestheader-extra-headers-prefix
,通过这几个参数来配置HTTP头的字段名称。
其中,只有--requestheader-username-headers
这个参数是必须的,由于目前场景下只需要配置这一个参数就可以了。比如:添加--requestheader-username-headers=X-Remote-User
到APIServer启动参数,APIServer就会从请求中获取X-Remote-User这个头,并用对应的值作为当前操作的用户。
事情还没有结束,既然APIServer会从请求头中获取用户名,那么问题来了,如何确保这个请求是可信的?如何防止恶意用户,伪造请求,绕过身份认证代理服务器,直接用假冒的请求访问APIServer怎么办?这样是不是就可以模拟任何用户访问了?那一定不行,得需要有个办法来验证代理服务器的身份。不过K8s的开发者们显然考虑到了这个问题,所以APIServer提供了--requestheader-client-ca-file
和--requestheader-allowed-names
两个额外的参数,其中--requestheader-client-ca-file
是必须的,用来指定认证代理服务器证书的CA位置,如果同时指定--requestheader-allowed-names
,则在验证客户端证书发行者的同时,还会验证客户端证书的CN字段,确保不会有人用其他证书模仿代理服务器。
说到这里,整个解决方案的思路就已经比较清楚了:1.让用户带上token访问身份代理服务器;2.身份代理服务器解析token,确认用户身份后将用户名带入到请求X-Remote-User头,并转发给K8s,这里需要注意带上预先签好的客户端证书访问;3.K8s通过请求头部信息确认用户,并基于RBAC规则确认用户权限。
针对上面的方案,这里简单的使用openresty搭建了一个测试方案,主要也是因为目前的Token是jwt格式的,解析和验证也比较方便,这里贴一个比较简单的配置例子:
server {listen 80;server_name test.k8sproxy.ichenfu.com;location / {access_by_lua '-- 因为token格式是jwt,且用户名是在jwt payload里的,所以需要依赖resty.jwt这个库-- 具体的安装方式这里不详细说明,可以查找其他资料local cjson = require("cjson")local jwt = require("resty.jwt")-- 拿到用户请求的Authorization头local auth_header = ngx.var.http_Authorizationif auth_header == nil then-- 禁止没有认证信息的请求ngx.exit(ngx.HTTP_UNAUTHORIZED)endlocal _, _, jwt_token = string.find(auth_header, "Bearer%s+(.+)")if jwt_token == nil then-- 禁止认证信息有误的请求ngx.exit(ngx.HTTP_UNAUTHORIZED)end-- secret,需要保密!local secret = "ichenfu-jwt-secret"local jwt_obj = jwt:verify(secret, jwt_token)if jwt_obj.verified == false then-- 如果验证失败,说明Token有问题,禁止ngx.exit(ngx.HTTP_UNAUTHORIZED)else-- 验证成功,设置X-Remote-User头为用户名(假设用户名存储在payload中的user字段)ngx.req.set_header("X-Remote-User", jwt_obj.user)end';proxy_ssl_certificate /usr/local/openresty/nginx/conf/ssl/auth-proxy.pem;proxy_ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/auth-proxy-key.pem;proxy_pass https://test.k8scluster.ichenfu.com:6443;}}
说起来openresty确实很方便,十几行代码就搞定了一个K8s的认证代理服务器。不过在后续测试过程中,遇到了一个问题。基于上面的逻辑,用户可以拿着Token,使用kubectl访问集群,但是在实际测试过程中,发现即使在kubeconfig文件中添加了Token,甚至使用kubectl --token="xxxxxxxxx" get pods
这种在命令行里,指定Token的方式,都会提示请求失败,找不到认证信息。一开始,以为是自己lua程序写的有问题,最后通过kubectl --token="xxxxxxxxx" get pods --v=10 2>&1
把请求过程打印出来才发现,kubectl根本不会把Token带入到请求头中!
经过一番查找,找到了kubectl does not send Authorization header (or use specified auth plugin) over plain HTTP #744这个Issue。才发现原来kubectl在默认情况下,如果访问一个HTTP协议的API地址,就认为这个服务是不需要认证的,如果需要认证,那API地址必须是HTTPS协议。
所以,为了实现预期的结果,还需要修改一下nginx配置文件,把监听换成HTTPS:
server {listen 443 ssl;server_name test.k8sproxy.ichenfu.com;ssl_certificate /usr/local/openresty/nginx/conf/ssl/kubernetes.pem;ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/kubernetes-key.pem;#localtion配置保持不变#...}
最终,所有需求都完美实现!当然需求的实现方式肯定不止这一种,而且最终即使使用这种方式,可能也不太会选择openresty,但是整体实现和测试的过程还是非常有意思的,特别是“意外”地知道了kubectl对于服务器认证的相关处理,收获还是不少的。
]]>的确,Ceph的官方组件Dashboard,内置了一些非常强大的RESTful API,功能也是比较的全面。为啥又要自己写一个呢?在我们的环境里,有一个自己实现的类似Openstack的虚拟机管理平台。而这个平台对接Ceph RBD时,就是使用的Dashboard模块提供的API。个人觉得啊,官方的API,虽然功能全,但确实对于对接的用户来说,真的不是那么友好。这里举几个简单的点:
官方API基于Token进行鉴权,而Token又通过用户名和密码进行获取,并且有一个固定的过期时间,这就会有两个选择,一个暴力点的选择是不管发送什么请求,都会获取一个新的Token,这样可以保证基于新Token的请求都可以成功;或者,每次在请求之前请求auth/check接口,确认Token的有效性,如果失效了,那就重新获取;再或者,根据请求的返回值,如果出现401错误等等情况,再重新获取新的Token。但是无论是哪种方法,都会显得冗余和逻辑复杂,特别是在多线程等等环境下,还需要考虑使用单例等等。另外,这多出来的这些Token请求,确实也拖慢了整体的效率,毕竟Python写的API,确实不算快。
官方API是一个异步API,怎么理解呢?让我们先看下大部分接口的返回值,以创建RBD为例:
- 201 Created – Resource created.
- 202 Accepted – Operation is still executing. Please check the task queue.
- 400 Bad Request – Operation exception. Please check the response body for details.
- 401 Unauthorized – Unauthenticated access. Please login first.
- 403 Forbidden – Unauthorized access. Please check your permissions.
- 500 Internal Server Error – Unexpected error. Please check the response body for the stack trace.
对于一个创建请求,如果成功,则可能会有两种返回值:201表示RBD Image创建成功,可以直接使用;202表示创建任务已经被接受了,但是还没有创建成功,具体的结果,需要去队列里找结果。怎么理解呢?如果返回201,那么恭喜,这个Image可以直接被使用了。如果返回了202,那此时还不能直接使用这个Image,因为仅仅是添加了任务,必须等任务执行完成之后,Image才真正可用。那怎么去寻找这个任务结果呢?又需要我们去轮询调用Display Tasks API,然后从返回的一个列表里,自己匹配刚刚的请求,来确认什么时间任务被执行完成。这个动作实在是不太优雅,让人难受。
官方API确实也缺失了一些功能。因为我们是一个VM的环境,依赖Clone功能实现VM的OS卷分发,而Clone功能又依赖某个Image的某个Snapshot。但是翻遍了RBDSNAPSHOT章节的文档,也没有找到如何确认某个Image的某个名字Snapshot是否存在的接口,最后从获取Image详情API的返回结果里找到了Image所拥有的Snapshot列表。但是呢,除了Snapshot,这个接口也会返回所有基于该Snapshot创建的所有Clone的列表。如果像我们现在这样某个Snapshot会有成千上万个Clone(有很多VM的操作系统都是一样的)。那这个接口的返回Body就会变得无比之大,这对于Dashboard、以及客户端的解析,都会是一个不小的成本。
当然了,这些问题,也只是在我们这个特定环境下的痛点,是绝对不可以说Ceph本身的实现问题的,那这些问题,要么忍着,要么,也可以尝试改变一下。
要说到同样是一个虚拟机管理平台,Openstack是怎么面临这些问题的呢?是不是我们也可以参考一下Openstack的实现呢?很遗憾,在Openstack Cinder组件里,是直接通过librbd的Python binding实现的。可惜的是我们并没有使用Python进行开发,相对于Openstack来说,集成方式也有些区别。
不过好在Ceph官方也提供了librbd的Golang Bindinggo-ceph,原理和Python一样,也是直接基于librbd的C接口,那既然这样,我们也可以尝试基于这个库,实现一个我们自己的RBD HTTP API。不需要多么花哨的设计和功能,只需要满足最基本的功能就可以了。
实现之前,还是先整理一下我们的需求。到目前为止,需求并不复杂,当然未来可能会对接K8S或者类似的容器平台,还需要额外的其他接口,但在当前虚拟机这个场景下,我们需要的功能如下:
1. Image相关接口,包括创建Image,获取Image信息,扩容Image,设置Image QOS,删除Image2. Snapshot相关接口,包括针对Image创建Snapshot,根据Snapshot创建Clone,以及判断Image某个Snapshot是否存在(这个接口在上面提到官方API没有,但是librbd里是有相关接口的)
看起来还是比较简单的,这里举个创建Image接口的例子,顺便也算是提供了一个简单的go-ceph的使用文档,在这之前,go-ceph相关的文档确实不太好找,以至于我只能一遍看他的实现代码,一边看librbd的文档写代码:
package mainimport ("fmt""log""github.com/ceph/go-ceph/rados""github.com/ceph/go-ceph/rbd")const PoolName = "test_rbd_pool"const ImageName = "test-image-name"const ImageSize uint64 = 100 * 1024 * 1024 * 1024 // 100GBfunc main() {conn, err := rados.NewConn()if err != nil {log.Fatal(err)}// 打开默认的配置文件(/etc/ceph/ceph.conf)if err := conn.ReadDefaultConfigFile(); err != nil {log.Fatal(err)}if err := conn.Connect(); err != nil {log.Fatal(err)}defer conn.Shutdown()ctx, err := conn.OpenIOContext(PoolName)if err != nil {log.Fatal(err)}defer ctx.Destroy()// 这里使用默认配置创建,也可以根据自己需求,指定image的featuresif err := rbd.CreateImage(ctx, ImageName, ImageSize, rbd.NewRbdImageOptions()); err != nil {log.Fatal(err)}// 获取或者修改Image时,需要先OpenImage,或者OpenImageReadOnlyrbdImage, err := rbd.OpenImageReadOnly(ctx, ImageName, rbd.NoSnapshot)if err != nil {if err == rbd.ErrNotFound {log.Println("image not found")}log.Fatal(err)} else {fmt.Println(rbdImage.GetId())}}
总的来说,开发起来还是挺简单的。最终我也把上面需求的这些功能,封装成了HTTP API,代码也放到了C0reFast/rbd-api。相对官方的API来说,简单、速度快、所有操作全部是同步的,希望有一天在类似的场景下能发挥一些作用。
]]>相比Legacy启动直接读取MBR启动分区的第一个扇区作为引导的逻辑,UEFI启动变得强大了很多,在UEFI模式下,固件直接具有的读取FAT文件系统的能力,并且直接通过运行EFI可执行文件的方式进行引导。
因为这个显而易见的变化,导致对应到PXE相关的实现上,也会有相应的区别。不过相比于Legacy启动的那些方案,区别不是那么大,依然是可以做到功能上一一对应的,同样的,我们从最简单的情况开始看起。
这个方案依然是配置最简单的方案,和Legacy+PXE一样,也是需要ftp server和DHCP,只是DHCP服务器的配置有些不一样:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; #关键配置!用于指明tftp server的地址 filename "grubx64.efi"; #关键配置!用于指明bootloader的名字}
可以发现其他的都没变化,只是把filename
的配置换成了grubx64.efi
,这意味着在UEFI启动里,放弃了使用pxelinux
,转而使用了grub
,当然其实SYSLINX也是有efi的,文件名syslinux.efi
,但是因为确实用的比较少,所以在这个环境里就换成了grub
作为Bootloader。
接下来是grubx64.efi
和配置文件的准备,以rhel举例,如果本身机器就是用的UEFI启动,那直接可以从/boot/efi/EFI/redhat/grubx64.efi
拷贝,如果系统本身还是MBR安装,那可以可以参考红帽的文档
然后是配置文件,和PXELINUX
一样,grub也是支持根据客户端的不同加载不同的配置文件的。具体的可以参考一下grub的文档:8 Booting GRUB from the network
不过我们也不需要多配置文件了,只需要配置一个grub.cfg
就行,把这个配置文件放在和grubx64.efi同目录下:
set timeout=5menuentry 'RHEL' { linuxefi images/vmlinuz initrdefi images/initrd.img}
至此一个最简单的UEFI的PXE启动方式就配置完成了。
接下来就是iPXE了,相比于PXE来说,iPXE的变化要小的多,因为自带了bootloader,所以只需要把filename
换成ipxe.efi
就行了:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; #关键配置!用于指明tftp server的地址 if exists user-class and option user-class = "iPXE" { #根据user-class字段来判断客户端类型 filename "http://10.1.1.10/boot.script"; } else { filename "ipxe.efi"; }}
同样的,ipxe.efi
可以从http://boot.ipxe.org直接下载。剩下的关于boot.script
配置,和上一篇Legacy里的配置是一致的。这里就不用赘述了。
对于iPXE来说,支持通过HTTP/FTP等等基于TCP传输的协议来获取kernel和initrd文件,速度相比于之前的PXE的tftp要快很多倍,但是如果稍微有那么一点点强迫症的话,依然会觉得整个iPXE方案里始终存在一个不太和谐的点。
是的,虽然kernel和initrd等文件可以通过HTTP获取,但是对于iPXE本身,无论是Legacy模式还是UEFI模式下,都依然需要用到tftp,虽然iPXE文件本身很小,只有KB级别,不会影响启动的速度了,但是tftp始终是一个依赖,这对于一个想Keep it Simple、Stupid的启动方式来说,实在是有那么些不舒服,如果能有什么办法(当然,直接把网卡刷成iPXE的方案不算)解决掉tftp的依赖,那显然是极好的。
对于这个问题呢,设计固件的聪明人们自然也想到了,于是就在UEFI 2.5的SPEC里,加上了HTTP Boot
的功能,直接让UEFI可以从HTTP URL获取启动文件,并通过这个启动文件启动系统,对于这个方案,目前已知的情况是,Intel提供的UEFI标准实现edk2是支持的,文档可以参考Getting Started with UEFI HTTPS Boot on EDK II,除此之外,Dell、联想、HPE等等国际大厂以及虚拟机也是明确有相关文档支持的,,而对于国内的一些厂商,目前我知道的情况是大部分都支持,不过因为确实国内用的比较少,很多厂商并没有非常仔细的测试。
我找了一台Dell的服务器,目前测试是没问题的,对于HTTP Boot来说,依然是通过DHCP获取启动配置,和iPXE类似,会带上一个特殊的标记,来区分普通PXE和HTTP Boot的区别,在iPXE文档UEFI HTTP chainloading里,也明确表示了iPXE支持这种启动方式。下面是个可以参考的DHCP服务器的配置:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; if exists user-class and option user-class = "iPXE" { #根据user-class字段来判断客户端类型 filename "http://10.1.1.10/boot.script"; } elsif substring (option vendor-class-identifier, 0, 10) = "HTTPClient" { # UEFI HTTP BOOT filename "http://10.1.1.10/ipxe.efi"; } else { filename "ipxe.efi"; }}
通过判断vendor-class-identifier
字段是否是HTTPClient
,来确认是否是UEFI HTTP BOOT,如果是HTTP Boot,那就把iPXE启动文件的URL返回,这样UEFI固件就可以通过HTTP协议获取iPXE启动文件,接下来的过程就和iPXE没有区别了。如此一来,如果UFEI支持HTTP Boot并开启,那么就完全不需要tftp,只需要一个HTTP服务器就可以完成所有的启动过程了。真正意义上去除了对tftp的依赖。
当然,如果想基于grub或者其他bootloader启动的话,也是可以的,基本原理也差不多,感兴趣的话,可以试试。
对于UEFI来说,还有一个非常重要的特性:Secure boot(安全启动),开启Secure boot之后,UEFI会利用数字签名来确认EFI驱动程序或者应用程序是否是受信任的,这其中就包括了从网络下载的bootloader,一般来说,大多数常见的OS发行版都会对Secure boot进行支持,具体支持的原理,这里就不作过多的介绍了,具体的可以参考一下红帽的文档:What is UEFI Secure Boot and how it works?。
需要说明的事,这篇Blog以及上一篇Blog所讨论的方法,都不涉及Secure boot,或者说这些配置都无法在Secure boot开启的情况下正常工作。但是也需要稍微进行一些修改,就可以顺利的在Secure boot环境下启动。大致的思路就是把bootloader换成一个受信任的shim,具体的实践,还需要大家自己去测试。
最近确实花了很多时间去研究服务器的网络启动方案,从PXE开始,到HTTP Boot结束,几乎把所有相关的可能性都测试了一遍,想找到一个依赖少、稳定、兼容性好的网络启动方案。然而实际情况是,面对的OEM厂商实在是太多了,每个厂商开发固件时的侧重点也不尽相同,导致在我个人内心中最完美的HTTP Boot方案,几乎没办法真正意义上在线上环境跑通。相对的,反而是最原始功能最少的方案,跨厂商的兼容性最好。
其实一开始的目的不仅于此,如果要深究的话,其实在UEFI这种模式下,甚至有可能去除掉DHCP服务器这个依赖,理论上在UEFI模式下,固件是有自己的一套协议栈的,在PXE之前,是可以手动给网卡配置静态的IP地址,网关,DNS等等网络配置,同样的bootloader的位置也是可以静态指定的,除此之外,还有很多tricky的手段,不过这些方案总归兼容性堪忧,特别是国内的很多服务器厂商,大多对于这些高级功能缺乏测试。感兴趣的同学可以花点时间时间研究研究,还是挺有意思的😄。同样也是希望国内厂商能给力起来,像国际大厂们看齐,不断完善好固件等等各个方面。
]]>不过呢,这些问题也都不重要,关键是在整个调研的过程中,也是补足了很多似懂非懂、一知半解的技术细节,也算是一个非常大的提升了,其实短期的妥协方案,也不影响最终的实现效果。所以准备写写这段时间学习到的知识,也算是补足了之前网络上找不到技术细节的坑吧,本篇算是第一部分吧,从最简单的开始,说说当前Legacy Boot相关的网络启动方案。
虽然现在所有的服务器厂商都将BIOS
的实现换成了UEFI
,但为了兼容性考虑,所有的厂商依然提供老的BIOS
形式的基于MBR
的Legacy
引导方式,在这个模式下,通过PXE可以实现最基础的功能。
在Legacy模式下,简单来说,PXE是通过网卡内置的一个小固件(PXE Client)实现的。大致的流程可以总结成这样:在系统启动的时候,会启动网卡里的PXE Client
,固件启动后、会发起DHCP请求、当DHCP服务器收到PXE Client
的DHCP请求后,会通过预定义的字段返回给客户端IP地址信息、TFTP地址信息、以及需要加载的bootloader
的名字。PXE Client
收到这些信息后,首先会配置IP地址,此时网络就可以通信了,再根据TFTP的地址和名字信息获取到bootloader
并执行,剩下的,就由bootloader
去拉起内核和OS。
接下来我们尝试搭建一个PXE Server
试试:
首先PXE Server
依赖两个组件:DHCP Server和TFTP,先配置DHCP Server:
这里使用isc-dhcp-server
,附上配置文件:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; #关键配置!用于指明tftp server的地址 filename "pxelinux.0"; #关键配置!用于指明bootloader的名字}
其他部分的配置并不重要,针对PXE启动来说,最重要的配置只有next-server
和filename
,next-server
指明了tftp server服务器的地址,filename
指明了bootloader
的名字,有了这俩信息,PXE Client
就可以去tftp上去获取bootloader并加载了。
接下来,配置tftp。tftp其实不需要配置,安装并启动就行了。主要是需要准备好启动所需要的bootloader和内核、initrd等文件。在配置文件里的pxelinux.0
是从哪来的呢?为了简单、一般来说PXE里不使用grub作为bootloader
、使用最多的还是PXELINUX
,他是syslinux
的一部分,所以很简单,只需要安装syslinux
就行了,在我的CentOS系统里,这个文件在/usr/share/syslinux/pxelinux.0
。直接拷贝到tftp root目录下就行。需要注意的是,在syslinux
5.0以上的版本,还需要把ldlinux.c32
这个文件同步拷贝到tftp root下。
有了bootloader
、还需要bootloader
配置文件,针对pxelinux
而言,默认会根据以下的顺序加载配置文件:
/pxelinux.cfg/b8945908-d6a6-41a9-611d-74a6ab80b83d/pxelinux.cfg/01-88-99-aa-bb-cc-dd/pxelinux.cfg/C0A8025B/pxelinux.cfg/C0A8025/pxelinux.cfg/C0A802/pxelinux.cfg/C0A80/pxelinux.cfg/C0A8/pxelinux.cfg/C0A/pxelinux.cfg/C0/pxelinux.cfg/C/pxelinux.cfg/default
其中根目录指的是和pxelinux.0
相同的目录,具体每个文件所代表的含义,可以参考PXELINUX的文档这么做的目的呢,是为了方便同一个PXE Server为多个机器服务,每个机器可以通过单独的配置文件进行配置,而不用为每台客户端配置一个PXE Server了。实际上、有一个广泛使用的装机系统cobbler
、也是基于这个特性,来解决不同机器的不同装机配置问题的。
在我们只有一台机器的情况下,默认写一个pxelinux.cfg/default
文件就行了:
DEFAULT test-pxe-bootLABEL test-pxe-boot MENU LABEL ^Test Boot KERNEL vmlinuz APPEND initrd=initrd.img
配置文件很简单,指明KERNEL和initrd的位置就行,默认情况下这些文件依然会从tftp里去取。至于vmlinuz
和initrd.img
这两个文件的获取,这里就不多说了,最简单的办法,就是从发行版的iso安装镜像里去找就可以了。
到目前为止,一个最简单的PXE Server就搭建完成了。已经可以测试是否能拉起一个RamOS了。当然,我们用的PXELINUX
还有很多高级的用法,比如基于http去加载kernel和initrd,从而绕开tftp协议进行加速,或者通过dhcpd下发一些配置等等,这里就不多说了,可以继续参考PXELINUX的文档。
基于PXE的启动已经很成熟了,但是对于PXE的一个重要依赖,tftp来说,大家还是会觉得他太慢了,毕竟tftp还是比较适用于小文件的传输,在实际的应用中,如果你的initrd.img比较大的话,那么需要花的时间就比较可观了,根据我自己的测试,大概传输200M左右的数据至少也需要60s,即使你用的是25GbE的网卡,依然是这个速度。那么,有没有支持更多协议的方法呢?
答案自然是有的,那就是iPXE,相比于比较原始的PXE来说,iPXE极大的增强了功能,根据官网的描述,相比PXE来说,主要有以下的提升:
- boot from a web server via HTTP
- boot from an iSCSI SAN
- boot from a Fibre Channel SAN via FCoE
- boot from an AoE SAN
- boot from a wireless network
- boot from a wide-area network
- boot from an Infiniband network
- control the boot process with a script
可以看到,功能和可编程性提升了很多,尤其重要的,一个是可以支持HTTP协议了,另外还有脚本执行能力,易用性大幅度的提升了。
而对于iPXE,主要有两种使用方式,一种方式,就是把iPXE直接烧进网卡的ROM里,替换掉网卡老的PXE ROM,这样就直接启动到iPXE环境了,另外一种,就是使用链式加载的模式,iPXE支持通过PXE环境、ISO、UEFI、以及其他引导器运行,也就是说,可以先通过PXE启动到iPXE环境,再对iPXE环境进行配置,从而实现系统启动。
这里主要还是会以链式加载的方式进行试验,相比直接刷ROM的方式,兼容性和可操作性都比较好。
那首先第一步还是要实现PXE的配置,只是这次的bootloader要从PXELINUX
换成了iPXE
,对于DHCP Server来说,配置不会变化太大:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; #关键配置!用于指明tftp server的地址 filename "ipxe.pxe"; #关键配置!用于指明ipxe的名字}
可以看到,只要把filename换成ipxe的名字就可以了,而这个ipxe.pxe
,可以从http://boot.ipxe.org直接下载,顺便说一下,这个网站里还包括了其他环境使用的iPXE,比如UFEI使用的,或者iso、U盘启动使用的启动文件,触类旁通,可以根据需求下载不同的文件。
配置好DHCP Server、下载好文件之后,只要一启动,iPXE就会被拉起。但是问题还没结束,为什么呢,因为iPXE被拉起之后,还是会通过DHCP协议获取IP地址和其他启动的配置,但是思考一下,当iPXE获取DHCP配置的时候,按我们现在的配置,服务器依然会把filename: ipxe.pxe
返回给iPXE客户端,然后iPXE拉起iPXE,如此反复,进入了一个死循环。
所以得需要有个办法来打破这个死循环,对于这个问题,官方也给出了对应的方案,一种是将脚本直接嵌入到ipxe文件里,另外一个,就是通过配置DHCP Server实现,由于将脚本嵌入到ipxe文件里需要自己编译,那肯定不是个简单的方案,所以这里还是优先通过配置DHCP Server完成目标。
先想想原理,其实也比较简单,如果DHCP Server能感知到客户端是PXE还是iPXE,如果是PXE,就把”ipxe.pxe”作为文件名传给客户端,如果是iPXE,就把iPXE Script的文件名传给客户端,这样一个简单的if判断,就可以打破这个死循环了,而iPXE确实给我们一个非常明确的区分方式:
option domain-name-servers 10.1.1.1;option routers 10.1.1.1;default-lease-time 14400;ddns-update-style none;subnet 10.1.1.0 netmask 255.255.255.0 { range dynamic-bootp 10.1.1.100 10.1.1.120; default-lease-time 14400; max-lease-time 172800; next-server 10.1.1.10; #关键配置!用于指明tftp server的地址 if exists user-class and option user-class = "iPXE" { #根据user-class字段来判断客户端类型 filename "http://10.1.1.10/boot.script"; } else { filename "ipxe.pxe"; }}
对于iPXE来说,在发送DHCP请求的时候,会加上一个user-class Option,值是”iPXE”,根据这个信息,我们就可以区分到底现在是PXE环境还是iPXE环境了。然后就是filename的配置,因为iPXE支持http协议,所以在filename字段,就可以直接填一个URL了,这样就可以把boot.script放到一个HTTP服务器上了。一个最简单的boot.script可以这样写:
#!ipxekernel http://10.1.1.10/vmlinuz initrd=initrd.imginitrd http://10.1.1.10/initrd.imgboot
因为支持HTTP协议,内核和initrd.img文件也都可以放在HTTP服务器上了,相比用tftp协议去获取,速度快了10倍不止。同样的,因为支持HTTP协议,对于多台机器同时装机的需求,可以实现的方式就更多了。一个最简单的思路就是使用php或者其他编程语言实现一个页面,在DHCP服务器把返回filename配置成http://10.1.1.10/boot.php
,然后程序里根据不同机器的配置,生成不同的ipxe script
。iPXE根据不同机器的不同script,来定制化不同的启动逻辑。
到这里,基于UEFI Legacy Boot的网络启动方式基本就说完了,当初PXE协议的设计者们的思路还是十分清晰的。而每个bootloader对于在PXE环境下多配置文件的支持,也是很早就设计好的,在上面的这些例子里,特别是PXE的例子,除了PXELINUX
之外,理论上grub
或者其他bootloader,都可以很好的完成类似的任务,比如grub的配置文件加载顺序,也可以找到非常完整的文档。而对于后来者iPXE,确实功能上增强了不少,可编程性也有了很大的提升,同时自身也可以作为bootloader,相比之下,确实iPXE会是一个非常好的选择。
下一篇我们再说说UFEI模式下的网络启动,尽情期待!
]]>具体点呢,是会报一个iptables
相关的错误:
......WARN[2021-10-23T11:30:05.864210900+08:00] Your kernel does not support cgroup blkio throttle.write_iops_deviceINFO[2021-10-23T11:30:05.864538700+08:00] Loading containers: start.INFO[2021-10-23T11:30:06.135353300+08:00] stopping event stream following graceful shutdown error="context canceled" module=libcontainerd namespace=mobyINFO[2021-10-23T11:30:06.135542600+08:00] stopping healthcheck following graceful shutdown module=libcontainerdINFO[2021-10-23T11:30:06.136083800+08:00] stopping event stream following graceful shutdown error="context canceled" module=libcontainerd namespace=plugins.mobyfailed to start daemon: Error initializing network controller: error obtaining controller instance: unable to add return rule in DOCKER-ISOLATION-STAGE-1 chain: (iptables failed: iptables --wait -A DOCKER-ISOLATION-STAGE-1 -j RETURN: iptables v1.8.7 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain DOCKER-ISOLATION-STAGE-1 (exit status 4))
提示iptables
RULE_APPEND
也不知道是咋回事,不过想着估计和Docker的网络有点关系,还好一般情况下编译和打镜像也不需要网络隔离,所以前几天就临时在启动Docker Daemon时加上--iptables=false
参数,不加载iptables规则,当然docker builld
和docker run
的时候也得加上--network=host
使用不隔离的Host网络,勉强扛了几天。
今天有时间就查了查资料,一开始查到的大部分都是让用Docker Desktop for Windows
并且开启WSL2后端,然后在WSL2里直接用Windows的docker.exe
命令。这个方案我没试过,不过说实话即使能用,也觉得有点太别扭了。想想还是放弃了。于是继续找资料。终于找到一篇文章,文章里贴了个Ubuntu的bug report,大致意思是说:
Ubuntu从20.10开始,将默认的防火墙切换到了
nftables
实现,这个实现需要5.8版本及以上的内核,而微软在WSL2中提供的5.4版本的内核没有nftables
,所以导致iptables功能出错了。
解决方法也简单,直接把iptables
实现切换回iptables-legacy
就好了:
~]# sudo update-alternatives --config iptablesThere are 2 choices for the alternative iptables (providing /usr/sbin/iptables). Selection Path Priority Status------------------------------------------------------------* 0 /usr/sbin/iptables-nft 20 auto mode 1 /usr/sbin/iptables-legacy 10 manual mode 2 /usr/sbin/iptables-nft 20 manual modePress <enter> to keep the current choice[*], or type selection number: 1update-alternatives: using /usr/sbin/iptables-legacy to provide /usr/sbin/iptables (iptables) in manual mode
切换回去之后,果然就好了,又可以开心的用原生docker了。
最后我又看了眼现在跑的内核:
~]# uname -r5.10.60.1-microsoft-standard-WSL2
因为升级了Windows11,现在内核已经到了5.10了。按理不应该有问题才对。除非微软编译内核的时候没开相关的选项?
~]# zcat /proc/config.gz |grep NF_TABLESCONFIG_NF_TABLES=yCONFIG_NF_TABLES_INET=y# CONFIG_NF_TABLES_NETDEV is not setCONFIG_NF_TABLES_IPV4=y# CONFIG_NF_TABLES_ARP is not setCONFIG_NF_TABLES_IPV6=y# CONFIG_NF_TABLES_BRIDGE is not set
确实有些选项没打开,而且WSL的github issue里似乎也有类似的讨论:#6655、#6044、#4165。不过微软似乎也没想着解决,不过问题不大啦,也没到要自己编译内核的地步。能用就行~
]]>很幸运的拿到了京东云出的无线宝鲁班路由器的公测资格,话不多说,先上一波图:
从包装盒上可以很明显的看到路由器的配置,整体来说,和第一代区别不大,MTK MT7621的CPU、512M的内存、3LAN+1WAN千兆口,和一代相比,最大的变化在于支持了WiFi6、也就是802.11ax、相比WiFi5来说,还是提升了不少,整体的无线吞吐达到了1775Mbps。虽然没有拆机看下他的配置,但是根据猜想,大概率是+MT7915D的组合。
虽然都知道MT7621 CPU的性能一般般,但是因为这款CPU有硬件NAT引擎的存在,实际的网络流量传输完全不需要经过CPU处理,所以网络性能其实非常强大,基本上千兆宽带对他来说毫无压力,整体来说,这个配置还是比较均衡的,针对一般的家庭用户可以说是绰绰有余。
比较遗憾的是,虽然鲁班路由器是WiFi6的,但是家里没有WiFi6的终端,因为无线握手的时候,只能倾向于版本低的那一端,所以这次测试,没办法真正感受WiFi6的性能威力了。其实在拿到手的一段时间里,做了非常多的测试工作,这里就不细说了,贴几张测试时的speedtest结果图:
家里的网络是北京联通300M的宽带,实际用电脑+有线连接去测试,大概可以跑到下行380Mbps、上行38Mbps左右。
可以看到,鲁班路由器的测试结果,由于2.4G频段现在的干扰确实太多了(家里手机能搜索到的无线信号就有20多个),测速的表现一般,不过即使这样的环境,也能做到大概80多Mbps的下行速度,并且不影响上行速度,这个下行速度,即使是看视频,理论上也基本不会感受到卡顿了。
而5G频段的表现更是比较好,不穿墙的情况下,完全没有任何带宽的损失,即使在更远的位置+穿了一堵墙,也有200多Mbps的带宽,可以说在我现在的网络环境下鲁班路由器完全可以发挥宽带的能力。
重点来了!对于无线宝路由器来说,除了最基础的网络功能,其实他还具有一个更加牛掰的能力!就是作为边缘计算的计算节点,为京东云的边缘计算平台提供计算、存储和带宽。具体的原理也比较复杂,就不详细探讨了,这里可以简单说一下我作为一个程序猿的理解:
一般来说,作为家庭宽带,虽然运营商提供了300M、500M甚至的千兆的带宽,但实际上大部分时候,普通人是无法完全利用这么多带宽的,从运营商角度来说,因为大家都用不完,所以他们可以卖个更多人,来获取更多的利益。而无线宝路由器呢,可以在你不用这部分带宽的时候,利用这些闲置的带宽,缓存一些边缘计算需要的数据,比如某一个特别火的视频,这些,等到附近的人(比如和你同一个小区)刚好需要看这个视频,就完全可以直接你家中的路由器中获取数据,而不再需要跑很远到视频网站的服务器上获取数据了。
这样做的好处很多,一方面看视频的人,因为离数据的距离更小了,可以更快的加载数据,播放变的更加流畅;另一方面针对视频网站也可以少花点流量的费用,服务器的压力也少很多。一举两得。
当然京东云肯定也不会白嫖了你的带宽、每天,他会根据你对整个平台的贡献打分,根据贡献的多少,发放一部分积分给你,最终可以用这一部分积分去换京东E卡,直接当钱用!以目前的情况来说,大概我家里的宽带,每天能分到差不多2块钱,这么看来还是很不错的。
既然上面说到了这个路由器可以挣积分换钱,那是不是就存在回本的可能性?是的没错!随着时间的推移,最终积分的数量会超过购买路由所花的钱的,那个时候其实就已经回本了!而京东云还出了一个坐享其成计划,这个计划,保证了你一定能回本!具体来说,只要按照规则,使用路由器1年,即使发的积分不够你买路由器的钱,京东云也会补足这中间的差价,所以说还是非常划算的,四舍五入,啊不用四舍五入,就是不要钱!
具体的,可以看看链接里的说明:https://pro.jd.com/mall/active/2zzGFDJD2eeVEKHH93zVH4MTis82/index.html
也可以直接点击这个链接购买:https://item.jd.com/100014311519.html
kube-proxy
得tracing日志之后,除去定时同步iptables得日志之外,还出现了一些Calling handler.OnEndpointsUpdate
相关得日志输出,这些输出其实是不太寻常的:13:55:13 localhost kube-proxy[20761]: I0609 13:55:13.290051 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:14 localhost kube-proxy[20761]: I0609 13:55:14.502924 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:15 localhost kube-proxy[20761]: I0609 13:55:15.299633 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:16 localhost kube-proxy[20761]: I0609 13:55:16.515500 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:17 localhost kube-proxy[20761]: I0609 13:55:17.316952 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:18 localhost kube-proxy[20761]: I0609 13:55:18.525537 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:19 localhost kube-proxy[20761]: I0609 13:55:19.326566 20761 config.go:167] Calling handler.OnEndpointsUpdate13:55:20 localhost kube-proxy[20761]: I0609 13:55:20.541238 20761 config.go:167] Calling handler.OnEndpointsUpdate
频率大约是1s一条,如果熟悉K8S的Watch-List机制的一眼就应该可以看出来原因:因为kube-proxy
会watchEndpoints
的变化,并对这些变化做相应动作,然后某些Endpoints
更新了之后,就触发了这条日志。其实这个机制是没有问题的,在正常的K8S集群里,这些输出也没有问题。但是在我们的集群里就有些不正常了,因为我们当前的应用场景,根本就不存在需要Endpoints
的情况!那到底是什么地方触发了Endpoints
的更新?
要找到原因,首先得知道是不是真的有对应的Endpoints
,并且是不是真的再更新,比较简单,kubectl也支持针对资源的watch操作:
[root@ ~]# kubectl get endpoints --all-namespaces --watchNAMESPACE NAME ENDPOINTS AGEdefault kubernetes 192.168.0.1:6443 71dkube-system kube-controller-manager <none> 71dkube-system kube-scheduler <none> 71dkube-system kube-controller-manager <none> 71dkube-system kube-scheduler <none> 71dkube-system kube-controller-manager <none> 71dkube-system kube-scheduler <none> 71dkube-system kube-controller-manager <none> 71d
可以发现确实在kube-system
这个namespace下面有两个endpoint:kube-scheduler
、kube-controller-manager
,而且也都是不停的在被修改。只是比较奇怪的地方是谁在修改它?修改的目的是啥?于是就获取一下其中一个endpoint看下具体的信息:
[root@ ~]# kubectl -n kube-system get endpoints kube-scheduler -oyamlapiVersion: v1kind: Endpointsmetadata: annotations: control-plane.alpha.kubernetes.io/leader: '{"holderIdentity":"192-168-0-1_e1e84d39-8c11-492b-8ee0-7d6eac6b3186","leaseDurationSeconds":15,"acquireTime":"2021-05-08T10:47:31Z","renewTime":"2021-06-16T10:41:23Z","leaderTransitions":9}' creationTimestamp: "2021-04-06T08:55:30Z" name: kube-scheduler namespace: kube-system resourceVersion: "24773276" selfLink: /api/v1/namespaces/kube-system/endpoints/kube-scheduler uid: ae3d725e-679d-4410-8655-7ddacb633d1f
可以看到annotations里有个key:control-plane.alpha.kubernetes.io/leader
,好像和选举有关啊。之前知道K8S的scheduler和controller-manager以及一些自定义的controller有选举机制来保证同一时刻只有一个实例在工作,但是没仔细研究过到底是怎么实现的,难道是基于endpoints?
先去看看scheduler
的启动参数里,是不是有相关的选项,帮助我们理解一下当前的策略。果然执行kube-scheduler --help
之后发现有一些相关的输出:
Leader election flags:
--leader-elect Start a leader election client and gain leadership before executing the main loop. Enable this when running replicated components for high availability. (default true) --leader-elect-lease-duration duration The duration that non-leader candidates will wait after observing a leadership renewal until attempting to acquire leadership of a led but unrenewed leader slot. This is effectively the maximum duration that a leader can be stopped before it is replaced by another candidate. This is only applicable if leader election is enabled. (default 15s) --leader-elect-renew-deadline duration The interval between attempts by the acting master to renew a leadership slot before it stops leading. This must be less than or equal to the lease duration. This is only applicable if leader election is enabled. (default 10s) --leader-elect-resource-lock endpoints The type of resource object that is used for locking during leader election. Supported options are endpoints (default) and `configmaps`. (default "endpoints") --leader-elect-resource-name string The name of resource object that is used for locking during leader election. (default "kube-scheduler") --leader-elect-resource-namespace string The namespace of resource object that is used for locking during leader election. (default "kube-system") --leader-elect-retry-period duration The duration the clients should wait between attempting acquisition and renewal of a leadership. This is only applicable if leader election is enabled. (default 2s)
发现确实有相关的选项,针对scheduler
来说,默认选择是使用一个endpoints,叫kube-scheduler
来作为加锁的key,也可以修改为configmaps
,当然名字也可以修改。而且renew这个锁的时间间隔默认是2s,这就很符合之前的日志输出了,kube-scheduler
和kube-controller-manager
两个组件,每个都间隔2s更新对应的endpoints,刚好看起来像是每秒都会有更新,这个和日志里输出的情况是一致的。
那具体K8S的这个选举机制是怎么工作的呢?继续分析一下代码,因为我们线上是运行的K8S的v1.16.15版本,所以下面的代码都是基于这个版本。
整体上,逻辑大致分为两部分:
我们先看看控制部分的入口:
if cc.LeaderElection != nil { cc.LeaderElection.Callbacks = leaderelection.LeaderCallbacks{ OnStartedLeading: run, OnStoppedLeading: func() { klog.Fatalf("leaderelection lost") }, } leaderElector, err := leaderelection.NewLeaderElector(*cc.LeaderElection) if err != nil { return fmt.Errorf("couldn't create leader elector: %v", err) } leaderElector.Run(ctx) return fmt.Errorf("lost lease")}
入口代码比较简单,创建LeaderElector
并执行Run()
,继续看看他的内部逻辑,这部分代码在client-go里:
// Run starts the leader election loopfunc (le *LeaderElector) Run(ctx context.Context) {defer func() {runtime.HandleCrash()le.config.Callbacks.OnStoppedLeading()}()if !le.acquire(ctx) {return // ctx signalled done}ctx, cancel := context.WithCancel(ctx)defer cancel()go le.config.Callbacks.OnStartedLeading(ctx)le.renew(ctx)}
主要的逻辑也比较清楚,先尝试acquire
,如果成功,就调用回调函数并及时renew
,而在acquire
和renew
这两个函数里主要的逻辑就是根据配置的时间间隔不停的尝试调用另一个tryAcquireOrRenew
函数,我们主要看看这个函数:
// tryAcquireOrRenew tries to acquire a leader lease if it is not already acquired,// else it tries to renew the lease if it has already been acquired. Returns true// on success else returns false.func (le *LeaderElector) tryAcquireOrRenew() bool {now := metav1.Now()leaderElectionRecord := rl.LeaderElectionRecord{HolderIdentity: le.config.Lock.Identity(),LeaseDurationSeconds: int(le.config.LeaseDuration / time.Second),RenewTime: now,AcquireTime: now,}// 1. obtain or create the ElectionRecordoldLeaderElectionRecord, err := le.config.Lock.Get()if err != nil {if !errors.IsNotFound(err) {klog.Errorf("error retrieving resource lock %v: %v", le.config.Lock.Describe(), err)return false}if err = le.config.Lock.Create(leaderElectionRecord); err != nil {klog.Errorf("error initially creating leader election record: %v", err)return false}le.observedRecord = leaderElectionRecordle.observedTime = le.clock.Now()return true}// 2. Record obtained, check the Identity & Timeif !reflect.DeepEqual(le.observedRecord, *oldLeaderElectionRecord) {le.observedRecord = *oldLeaderElectionRecordle.observedTime = le.clock.Now()}if len(oldLeaderElectionRecord.HolderIdentity) > 0 &&le.observedTime.Add(le.config.LeaseDuration).After(now.Time) &&!le.IsLeader() {klog.V(4).Infof("lock is held by %v and has not yet expired", oldLeaderElectionRecord.HolderIdentity)return false}// 3. We're going to try to update. The leaderElectionRecord is set to it's default// here. Let's correct it before updating.if le.IsLeader() {leaderElectionRecord.AcquireTime = oldLeaderElectionRecord.AcquireTimeleaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions} else {leaderElectionRecord.LeaderTransitions = oldLeaderElectionRecord.LeaderTransitions + 1}// update the lock itselfif err = le.config.Lock.Update(leaderElectionRecord); err != nil {klog.Errorf("Failed to update lock: %v", err)return false}le.observedRecord = leaderElectionRecordle.observedTime = le.clock.Now()return true}
逻辑也不复杂,主要也就是调用Lock存储的Get()
、Create()
和Update
函数。那关于控制部分的逻辑基本就差不多了。
下面来看看存储相关的逻辑,入口代码在cmd/kube-scheduler/app/options/options.go
func makeLeaderElectionConfig(config kubeschedulerconfig.KubeSchedulerLeaderElectionConfiguration, client clientset.Interface, recorder record.EventRecorder) (*leaderelection.LeaderElectionConfig, error) {hostname, err := os.Hostname()if err != nil {return nil, fmt.Errorf("unable to get hostname: %v", err)}// add a uniquifier so that two processes on the same host don't accidentally both become activeid := hostname + "_" + string(uuid.NewUUID())rl, err := resourcelock.New(config.ResourceLock,config.ResourceNamespace,config.ResourceName,client.CoreV1(),client.CoordinationV1(),resourcelock.ResourceLockConfig{Identity: id,EventRecorder: recorder,})if err != nil {return nil, fmt.Errorf("couldn't create resource lock: %v", err)}return &leaderelection.LeaderElectionConfig{Lock: rl,LeaseDuration: config.LeaseDuration.Duration,RenewDeadline: config.RenewDeadline.Duration,RetryPeriod: config.RetryPeriod.Duration,WatchDog: leaderelection.NewLeaderHealthzAdaptor(time.Second * 20),Name: "kube-scheduler",}, nil}
其中最重要的调用是resourcelock.New()
这里根据Lock的类型,创建了不同的实例,我们继续看看K8S提供了哪些类型的实现:
// Manufacture will create a lock of a given type according to the input parametersfunc New(lockType string, ns string, name string, coreClient corev1.CoreV1Interface, coordinationClient coordinationv1.CoordinationV1Interface, rlc ResourceLockConfig) (Interface, error) {switch lockType {case EndpointsResourceLock:return &EndpointsLock{EndpointsMeta: metav1.ObjectMeta{Namespace: ns,Name: name,},Client: coreClient,LockConfig: rlc,}, nilcase ConfigMapsResourceLock:return &ConfigMapLock{ConfigMapMeta: metav1.ObjectMeta{Namespace: ns,Name: name,},Client: coreClient,LockConfig: rlc,}, nilcase LeasesResourceLock:return &LeaseLock{LeaseMeta: metav1.ObjectMeta{Namespace: ns,Name: name,},Client: coordinationClient,LockConfig: rlc,}, nildefault:return nil, fmt.Errorf("Invalid lock-type %s", lockType)}}
简单一看发现除了上面看到的endpoints
、configmaps
还多了个leases
类型,具体的实现也比较简单,就用endpoints
类型的Create
举例吧:
// Create attempts to create a LeaderElectionRecord annotationfunc (el *EndpointsLock) Create(ler LeaderElectionRecord) error {recordBytes, err := json.Marshal(ler)if err != nil {return err}el.e, err = el.Client.Endpoints(el.EndpointsMeta.Namespace).Create(&v1.Endpoints{ObjectMeta: metav1.ObjectMeta{Name: el.EndpointsMeta.Name,Namespace: el.EndpointsMeta.Namespace,Annotations: map[string]string{LeaderElectionRecordAnnotationKey: string(recordBytes),},},})return err}
就非常的简单,就是调用kubeclient的Endpoints相关接口,创建一个Endpoints
,同样的,其他动作也是类似。
到这里其实大体上整个选主的逻辑也基本搞清楚了,似乎没什么大问题。但总觉得目前提供的这两个基于endpoints
和configmaps
的实现不怎么优雅。因为每个Node上,kube-proxy
需要watchendpoints
的变化、而kubelet
又需要watchconfigmaps
的变化。无论选择哪个,Lock的不停Renew都会Push到所有的节点,这在无形中也会对集群多造成一点点压力。
其实从官方的实现也可以看的出来,leases
类型是独立的一个资源,没用其他的组件会watch这个资源,从一定程度上能解决这个问题,可惜的是因为有平滑升级的需求,不能直接切换到leases
类型的Lock了。所以官方也在后续1.17版本做出了一些改进,具体的可以参考:migrate leader election to lease API #81030,Migrate components to EndpointsLeases leader election lock #84084。
说回我们自己的业务,暂时也没有升级K8S得欲望,好在当前我们机器上不需要InCluster类型访问apiserver
,也没有ClusterIP的需求,所以其实不需要kube-proxy
这个组件,一不做二不休,不如直接把kube-proxy
给下线了,正好下线之后还能去掉对ipvs
模块的依赖,刚好也能解决上篇疯狂输出日志的问题,一举两得。
dmesg
日志里,老是会不停的刷下面的这个日志:03:52:42 localhost kernel: nf_conntrack: falling back to vmalloc.03:52:42 localhost kernel: IPVS: Creating netns size=2048 id=9771903:54:37 localhost kernel: nf_conntrack: falling back to vmalloc.03:54:37 localhost kernel: nf_conntrack: falling back to vmalloc.03:54:37 localhost kernel: IPVS: Creating netns size=2048 id=9772003:56:48 localhost kernel: nf_conntrack: falling back to vmalloc.03:56:48 localhost kernel: nf_conntrack: falling back to vmalloc.03:56:48 localhost kernel: IPVS: Creating netns size=2048 id=9772103:58:20 localhost kernel: IPVS: Creating netns size=2048 id=97722
本来这些日志刷就刷了,也没什么大问题,但是呢,时间一长,因为logrotate机制,会把之前产生的日志给顶掉,导致有些时候想看之前的dmesg
日志看不了了,这就比较难受了,终于,在当鸵鸟很长时间之后,想想还是找找原因,把这个问题解决一下。
如果直接用Google搜索IPVS: Creating netns size=XXX id=XXX
或者nf_conntrack: falling back to vmalloc
这些关键词,得到的解决方案不痛不痒,针对nf_conntrack,大部分的答案都是提示vm.min_free_kbytes
比较小,需要调大这个参数,或者,net.netfilter.nf_conntrack_*
相关的几个参数太大了,需要调小。而IPVS基本都没有什么相关的结果。所以我们还是得自己尝试解决一下。
一开始呢,就觉得大概率是K8S的问题,一方面因为这些日志,只有在跑了K8S的机器上才会出现;另外一方面因为这个nf_conntrack
和IPVS
和网络强相关,在K8S的Node节点上两个组件之一的kube-proxy
,也会定期去同步一些网络配置,那很明显的kube-proxy
嫌疑最大。于是我就配置了一下,把kube-proxy
的日志等级开到最大,让运行中所有的tracing日志也都打印出来:
22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.021677 20761 proxier.go:708] Syncing iptables rules22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.021703 20761 iptables.go:437] running iptables -N [KUBE-EXTERNAL-SERVICES -t filter]...22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.038491 20761 iptables.go:397] running iptables-restore [-w --noflush --counters]22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.040739 20761 proxier.go:687] syncProxyRules took 19.090909ms22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.040756 20761 bounded_frequency_runner.go:221] sync-runner: ran, next possible in 0s, periodic in 30s22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.109303 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:23 localhost kube-proxy[20761]: I0611 22:10:23.842263 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:25 localhost kube-proxy[20761]: I0611 22:10:25.120048 20761 config.go:167] Calling handler.OnEndpointsUpdate...22:10:51 localhost kube-proxy[20761]: I0611 22:10:51.268046 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:52 localhost kube-proxy[20761]: I0611 22:10:52.018512 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.040907 20761 proxier.go:708] Syncing iptables rules22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.040933 20761 iptables.go:437] running iptables -N [KUBE-EXTERNAL-SERVICES -t filter]...22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.056819 20761 iptables.go:397] running iptables-restore [-w --noflush --counters]22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.058812 20761 proxier.go:687] syncProxyRules took 17.935822ms22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.058827 20761 bounded_frequency_runner.go:221] sync-runner: ran, next possible in 0s, periodic in 30s22:10:53 localhost kube-proxy[20761]: I0611 22:10:53.278609 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:54 localhost kube-proxy[20761]: I0611 22:10:54.026900 20761 config.go:167] Calling handler.OnEndpointsUpdate22:10:55 localhost kube-proxy[20761]: I0611 22:10:55.287980 20761 config.go:167] Calling handler.OnEndpointsUpdate
从日志可以看到,确实kube-proxy
会定时同步iptables
的规则,但是周期不一样,dmesg
里日志的周期大概两分钟一次,而这里每30s就会同步一次了,而且同步的时间和日志也对应不上,那其实基本就可以排除是kube-proxy
导致的了。不过除此之外,日志里发现了比较反常的Calling handler.OnEndpointsUpdate
的输出,频率还挺高,这个和之前的预想不太相符,这里暂时忽略了,等着下篇继续分析原因吧。
既然直接原因不是kube-proxy
,那就得继续看看是为啥了。
于是就又仔细分析了一下journalctl
的输出,因为掺杂了各种K8S组件的凌乱输出,日志非常乱,不过功夫不负有心人,最终还是发现了和dmesg
输出时间高度对应的日志:
03:52:42 localhost dbus[11450]: [system] Activating via systemd: service name='org.freedesktop.hostname1' unit='dbus-org.freedesktop.hostname1.service'03:52:42 localhost dbus[11450]: [system] Successfully activated service 'org.freedesktop.hostname1'03:54:37 localhost dbus[11450]: [system] Activating via systemd: service name='org.freedesktop.hostname1' unit='dbus-org.freedesktop.hostname1.service'03:54:37 localhost dbus[11450]: [system] Successfully activated service 'org.freedesktop.hostname1'03:56:48 localhost dbus[11450]: [system] Activating via systemd: service name='org.freedesktop.hostname1' unit='dbus-org.freedesktop.hostname1.service'03:56:48 localhost dbus[11450]: [system] Successfully activated service 'org.freedesktop.hostname1'03:58:20 localhost dbus[11450]: [system] Activating via systemd: service name='org.freedesktop.hostname1' unit='dbus-org.freedesktop.hostname1.service'03:58:20 localhost dbus[11450]: [system] Successfully activated service 'org.freedesktop.hostname1'
似乎是systemd-hostnamed
?再通过journalctl -u systemd-hostnamed.service
看看hostnamed
的日志:
03:52:42 localhost systemd[1]: Starting Hostname Service...03:52:42 localhost systemd[1]: Started Hostname Service.03:54:37 localhost systemd[1]: Starting Hostname Service...03:54:37 localhost systemd[1]: Started Hostname Service.03:56:48 localhost systemd[1]: Starting Hostname Service...03:56:48 localhost systemd[1]: Started Hostname Service.03:58:20 localhost systemd[1]: Starting Hostname Service...03:58:20 localhost systemd[1]: Started Hostname Service.
还真是,那么问题来了:为什么hostnamed
启动会刷两条dmesg
日志?以及为什么hostnamed
会这样定时启动?
先说明第二个问题吧,因为这个是已知的,我们系统的配置,都是用puppet
这个自动化运维工具下发的,然后下发之后呢,为了防止人为的原因配置被修改了,所以又加了个Cron,定时2分钟+一小段随机时间执行,确保任何时候我们机器上的配置都是和我们预期是一样的。
而这些配置里,就包含了机器的主机名。具体的定义类似这样:
exec { 'set hostname': command => "hostnamectl set-hostname ${local_hostname}", unless => "test `hostnamectl --static` == '${local_hostname}'", path => ['/usr/bin', '/bin'] }
简单解释下,就是每次都会执行hostnamectl --static
命令,获得当前hostname,如果和预期不一致,就调用hostnamectl set-hostname ${local_hostname}
命令把本地的hostname修改成我们期望的。
然后再来看看第一个问题,为什么hostnamed
启动的时候会输出日志?看了下服务的service配置文件,一下就明白了:
[root@]# cat /usr/lib/systemd/system/systemd-hostnamed.service# This file is part of systemd.## systemd is free software; you can redistribute it and/or modify it# under the terms of the GNU Lesser General Public License as published by# the Free Software Foundation; either version 2.1 of the License, or# (at your option) any later version.[Unit]Description=Hostname ServiceDocumentation=man:systemd-hostnamed.service(8) man:hostname(5) man:machine-info(5)Documentation=http://www.freedesktop.org/wiki/Software/systemd/hostnamed[Service]ExecStart=/usr/lib/systemd/systemd-hostnamedBusName=org.freedesktop.hostname1CapabilityBoundingSet=CAP_SYS_ADMINWatchdogSec=3minPrivateTmp=yesPrivateDevices=yesPrivateNetwork=yesProtectSystem=yesProtectHome=yes
可以看到在Service的配置里添加了PrivateNetwork=yes
选项,那这个选项是什么作用呢?在man systemd.exec
里找到了对应的说明:
PrivateNetwork=
Takes a boolean argument. If true, sets up a new network namespace for the executed processes and configures only the loopback network device “lo” inside it. No other network devices will be available to the executed process. This is
useful to securely turn off network access by the executed process. Defaults to false. It is possible to run two or more units within the same private network namespace by using the JoinsNamespaceOf= directive, see systemd.unit(5) for
details. Note that this option will disconnect all socket families from the host, this includes AF_NETLINK and AF_UNIX. The latter has the effect that AF_UNIX sockets in the abstract socket namespace will become unavailable to the
processes (however, those located in the file system will continue to be accessible).
也就是说,如果打开PrivateNetwork
,那么systemd在启动这个服务时会创建一个新的network namespace
,来隔离这个进程和主机的network,而刚好我们的hostnamed
不需要访问网络,所以默认情况下就加上了这个限制。这个也就能解释为啥LVS模块
的日志提示是Creating netns
了,因为真的是在创建一个新的ns。同理nf_conntrack模块
的输出也是因为新ns需要一些初始化操作。
另外呢,hostnamed
这个服务也比较特殊,因为获取或者设置hostname不是一个很常见的操作,而且因为有dbus可以在需要的时候触发启动,所以这个进程运行一段时间后没有新的请求会自动退出,具体可以看下它的代码:
int main(int argc, char *argv[]) { Context context = {}; ... r = bus_event_loop_with_idle(event, bus, "org.freedesktop.hostname1", DEFAULT_EXIT_USEC, NULL, NULL); if (r < 0) { log_error_errno(r, "Failed to run event loop: %m"); goto finish; }finish: context_free(&context); return r < 0 ? EXIT_FAILURE : EXIT_SUCCESS;}
这个DEFAULT_EXIT_USEC
定义在其他文件中,原型是#define DEFAULT_EXIT_USEC (30*USEC_PER_SEC)
,也就是30s。
到这里呢,问题算是理清楚了:
1. 自动化运维脚本会定时check hostname,这会导致hostnamed被拉起来2. 因为hostnamed service描述里有PrivateNetwork=yes,所以systemd会创建network namespace3. 因为主机上加载了conntrack和ipvs相关模块(这个是kube-proxy需要的),模块会在netns里初始化,又因为一些参数的原因,会打印相关的日志4. hostnamed 30s后自定退出,导致下次执行会重新创建新的network namespace,如此反复
那最终的解决方案也比较明白了,根据上面的情况:
1. 调整自动化运维脚本,不使用hostnamectl命令获取当前主机名2. 合理调整conntrack相关参数,尽可能避免内核内存被耗尽的情况3. 因为我们的业务比较特殊,不需要kube-proxy,所以决定把kube-proxy服务停了,同时就不会依赖ipvs模块,顺便把ipvs模块也移除
其实被这个PrivateXXXX选项坑了不止一次了,之前还发生过PrivateTmp=yes
导致服务启动卡住的情况,唉一言难尽啊。
不过好事多磨啊,好不容易等到CPU和主板快递到货,到家兴致勃勃把CPU插上主板、装上散热器,又把老的主板从机箱里换出来,原来老机器有两条8G 2400MHz的内存,虽然相比现在动辄3600MHz、4000MHz的内存频率低了点,好在我也就日常使用对内存带宽和延迟也不太敏感,这个钱就省了。另外还有传家宝1060显卡、硬盘什么的统统不用换。
本来想着一次点亮,可惜事与愿违、一度心态炸裂!一开电源,发现启动不了,主板上Debug灯卡在内存上,一动不动。这就有点小尴尬了,按理现在内存这东西兼容性都比较好,为啥会卡在内存上?一顿折腾、4个插槽、两根内存、各种插法全试了一遍、又尝试恢复CMOS到出厂设置、仍然不好使。难不成真得让AMD背锅?这AMD不Yes了啊。。赶紧去主板官方网站上找了下内存兼容性列表,另外也问了一下卖家客服,看看是啥情况。
最后客服给了个说法:升级BIOS试试。然后贴了个不需要CPU的更新BIOS的视频:
这个主板拿到手需要更新BIOS我之前倒是在评论里看到过,不过他那个评论也比较老了,我心想现在买的主板,不会还是出厂BIOS版本吧。。但是不管怎么说,先升级看看吧。这个时候同事也发给我一个视频:微星b550迫击炮wifi版本出厂通病?内存兼容性不好,点不亮?超频超不上去?刷新bios即可解决!,也是类似的情况,都在骂微星的这版出厂BIOS。
好吧,赶紧找了个U盘,按视频里说的,把BIOS文件放U盘里,改名字为MSI.ROM
,插上对应的USB口,按BIOS回写按钮,嘿嘿,一切还真是按预期在走,确实BIOS更新灯和U盘指示灯在闪,看起来真的需要更新。不过有一说一这个功能还是不错的,不需要先点亮就能刷BIOS,即使BIOS刷挂了还有个挽回的方法,不错。
等了几分钟,BIOS应该是更新好了,直接重启,突然听到硬盘寻道的声音,之前这个声音是没有的!感觉有戏了!并且键盘灯也亮了,Debug灯也正常了,看起来内存识别的问题解决了。但是这个时候屏幕还不亮。好在第二个视频里提醒了一下,似乎这个主板在启动的时候如果显示器接的是DP口,会有可能不亮,但是换HDMI口就没问题。这个简单,又找了根HDMI线,一插上就亮了,能进BIOS了!
进了BIOS就简单了,把一些配置改了改,主要是启动方式和顺序啥的,保存、重启,直接就进系统了,很顺利。
这时候还剩下最后一个问题,这个DP口能用么?毕竟相比DP口而言HDMI带宽上没什么优势,而且我这个显示器因为分辨率和刷新率比较高,只能用DP口才能满血。于是就把HDMI线拔掉换上DP线,发现没问题,一切正常。到这里彻底舒了口气,可算没问题,一切正常了。
最后又加上另外一根内存,重启了几次,都没问题,正常进系统。但是有个细节还是被我观察到了,就是在BIOS自检阶段,本来应该显示一些Logo之类的,现在都不会显示,等到进了系统,需要输密码的时候,显示器才亮起来。虽然不影响使用,但肯定还是存在一些问题。这个问题应该也是视频里提到的为啥DP口没显示、HDMI能显示的原因。
既然HDMI口能用,那感觉问题不应该是主板了,会不会是显卡的问题?于是带着这个问题去搜了搜,似乎和一个UEFI的GOP特性有关,最终找到了NVIDIA的一个工具:NVIDIA GRAPHICS FIRMWARE UPDATE TOOL FOR DISPLAYPORT 1.3 AND 1.4 DISPLAYS,在这个页面里,NVIDIA提到:
Without the update, systems that are connected to a DisplayPort 1.3 / 1.4 monitor could experience blank screens on boot until the OS loads, or could experience a hang on boot.
和遇到的现象一样啊,显示器接到DP口上,在启动时进系统前会黑屏,直到进系统才能正常显示。于是赶紧下载了工具,运行、更新显卡的FIRMWARE。重启之后,嘿嘿,久违的主板Logo能显示了,进BIOS显示也正常。
啊,问题总算解决了。这么说起来,搞不好因为AMD很多年不换平台,导致BIOS兼容多代CPU变得困难,才搞得这么多Bug。相比Intel一代一换主板,问题确实多了点,怪不得AMD给人的印象就是不如Intel稳定。
总的来说,这次平台的切换,也折腾了不少,但是我依然要说出那六个字母!AMD Yes!
]]>1 bit per 10^14bit
这个水平,也就是说每读取10^14bit
的数据,其中某个bit有可能就是错误的,这会导致一些问题,比如如果是组建了一个比较大的RAID5阵列,当容量越大时,如果出现硬盘损坏,就会有很大概率无法恢复数据。当然也不是没有其他办法,比如使用RAID 6,或者购买企业级硬盘,因为企业级硬盘在当时就可以提供1 bit per 10^15bit
的指标,这样算下来读取100TB数据都是没什么大问题的。
不过随着近几年硬盘技术的发展,机械硬盘的容量越来越大,很多桌面级硬盘已经超过了10T,很显然,如果URE这个指标继续保持原有的水准,那这个硬盘就不太合格了,那么现在这些大容量硬盘的这个指标做到了多少呢?
于是我就找了找希捷桌面级硬盘的文档:
从图里可以看到,在希捷大于10TB的硬盘产品中,在最大不可恢复错误/被读数据(位)也就是URE这个指标上,已经向企业级硬盘看齐,达到了1/10E15
,另外,考虑到硬盘厂商在标称这些指标时一般会比较保守。那么从单一的指标上看,完全可以组建超过几十TB的磁盘阵列而不用担心数据丢失,不过考虑到恢复时间等等各种因素,个人依然是不建议以RAID方式组建这种超大的磁盘阵列。
当然,虽然桌面级硬盘在某些指标上已经看齐了企业级硬盘,但实际上企业级硬盘在稳定性和数据安全性方面依然会比桌面级好很多,比如一般的桌面硬盘是不会有传输层面的CRC或者ECC功能的,但是企业级硬盘,一般都会有类似的能力。当然除了这些数据安全性相关的指标,平均故障时间等等指标也会比桌面级硬盘高出许多。关于这个话题,又是有非常多的内容了,简单的可以参考一些文档,比如Intel的这篇文档Enterprise-class versus Desktop-class Hard Drives就在很多方面对比了桌面和企业级硬盘的区别。
从另外一个方面来说,比如需要基于这些机械硬盘开发一个企业级的存储系统,对数据的安全性要求极高,那么就不能100%的相信从磁盘上读取出的数据,在整个环节中,有太多的情况会导致读取出来的数据和当初写入的数据不相符。所以,在业务层,一般也需要考虑引入CRC等数据校验能力,通过读取并校验数据CRC,来保证数据一致性。
BTW,SSD硬盘,因为工作方式的不同,URE这些指标天生的就比机械硬盘好很多。同样以希捷的SSD为例:
URE这个指标是1/10E17
,比机械盘好了两个数量级!当然代价是每GB价格也贵了很多。
总的来说,随着硬件的更新,很多曾经因为硬件产生的各种限制也会慢慢被打开。当然因为硬件的更新越来越快和特性和限制越来越多,软件层也需要适应这些变化,从而更好的利用好硬件所提供的能力。
]]>还是来看代码,首先需要注意的是,MOSDFailure不是一个原始的消息,我们得先找到这个消息对应得MSG TYPE,这样才能知道Monitor是怎么处理的,还是先看src\messages\MOSDFailure.h
,找到MOSDFailure类的定义:
class MOSDFailure : public PaxosServiceMessage {// ... // 只看构造函数,发现初始化的消息类型是MSG_OSD_FAILURE MOSDFailure() : PaxosServiceMessage(MSG_OSD_FAILURE, 0, HEAD_VERSION) { }// ...};
知道了消息类型,我们就很容易知道Monitor处理的流程了,在src/mon/Monitor.cc
中,所有Monitor收到的消息都会由Monitor::dispatch_op(MonOpRequestRef op)
进行处理:
void Monitor::dispatch_op(MonOpRequestRef op){// ... switch (op->get_req()->get_type()) { // OSDs // 这里有一大堆消息 case CEPH_MSG_MON_GET_OSDMAP: case CEPH_MSG_POOLOP: case MSG_OSD_BEACON: case MSG_OSD_MARK_ME_DOWN: case MSG_OSD_MARK_ME_DEAD: case MSG_OSD_FULL: // 我们要找的MSG_OSD_FAILURE在这里 case MSG_OSD_FAILURE: case MSG_OSD_BOOT: case MSG_OSD_ALIVE: case MSG_OSD_PGTEMP: case MSG_OSD_PG_CREATED: case MSG_REMOVE_SNAPS: case MSG_MON_GET_PURGED_SNAPS: case MSG_OSD_PG_READY_TO_MERGE: paxos_service[PAXOS_OSDMAP]->dispatch(op); return;// ... }// ...}
可以看到Monitor中把MSG_OSD_FAILURE
消息交给了paxos_service[PAXOS_OSDMAP]
处理,很容易的,在Monitor构造函数里我们找到paxos_service[PAXOS_OSDMAP].reset(new OSDMonitor(cct, this, paxos, "osdmap"));
,也就是说,MSG_OSD_FAILURE
消息是由OSDMonitor真正处理的。
话不多说,直接转到src/mon/OSDMonitor.cc
,这里需要注意的是,OSDMonitor没有直接的dispatch方法,因为这个方法在基类里,这里就不多赘述了,实际dispatch
会调用到OSDMonitor::preprocess_query(MonOpRequestRef op)
以及OSDMonitor::prepare_update(MonOpRequestRef op)
这两个方法:
bool OSDMonitor::preprocess_query(MonOpRequestRef op){ op->mark_osdmon_event(__func__); Message *m = op->get_req(); switch (m->get_type()) {// ... // MSG_OSD_FAILURE 由preprocess_failure处理 case MSG_OSD_FAILURE: return preprocess_failure(op);// ... default: ceph_abort(); return true; }}bool OSDMonitor::prepare_update(MonOpRequestRef op){ op->mark_osdmon_event(__func__); Message *m = op->get_req(); switch (m->get_type()) {// ... // MSG_OSD_FAILURE 由prepare_failure处理 case MSG_OSD_FAILURE: return prepare_failure(op);// ... default: ceph_abort(); } return false;}
终于找到最终的处理方法了,先preprocess_failure
,后prepare_failure
,一般来说,preprocess_query阶段,大多数是只读阶段,如果这个方法返回true,那就意味着消息处理结束了,当然如果不是一个只读操作,那还会继续交由prepare_update阶段进行处理,如果结果是需要更新Paxos,那么就会提交给Paxos。
先继续看OSDMonitor::preprocess_failure(MonOpRequestRef op)
的实现吧:
bool OSDMonitor::preprocess_failure(MonOpRequestRef op){ op->mark_osdmon_event(__func__); auto m = op->get_req<MOSDFailure>(); // who is target_osd int badboy = m->get_target_osd(); // check permissions // 检查权限 if (check_source(op, m->fsid)) goto didit; // first, verify the reporting host is valid // 检查是不是一个正常源OSD汇报的 if (m->get_orig_source().is_osd()) { int from = m->get_orig_source().num(); if (!osdmap.exists(from) ||!osdmap.get_addrs(from).legacy_equals(m->get_orig_source_addrs()) ||(osdmap.is_down(from) && m->if_osd_failed())) { dout(5) << "preprocess_failure from dead osd." << from << ", ignoring" << dendl; send_incremental(op, m->get_epoch()+1); goto didit; } } // weird? // 检查汇报的源OSD是不是最新的 if (osdmap.is_down(badboy)) { dout(5) << "preprocess_failure dne(/dup?): osd." << m->get_target_osd() << " " << m->get_target_addrs() << ", from " << m->get_orig_source() << dendl; if (m->get_epoch() < osdmap.get_epoch()) send_incremental(op, m->get_epoch()+1); goto didit; } if (osdmap.get_addrs(badboy) != m->get_target_addrs()) { dout(5) << "preprocess_failure wrong osd: report osd." << m->get_target_osd() << " " << m->get_target_addrs() << " != map's " << osdmap.get_addrs(badboy) << ", from " << m->get_orig_source() << dendl; if (m->get_epoch() < osdmap.get_epoch()) send_incremental(op, m->get_epoch()+1); goto didit; } // already reported? // 检查汇报的目标OSD是不是已经Down了 if (osdmap.is_down(badboy) || osdmap.get_up_from(badboy) > m->get_epoch()) { dout(5) << "preprocess_failure dup/old: osd." << m->get_target_osd() << " " << m->get_target_addrs() << ", from " << m->get_orig_source() << dendl; if (m->get_epoch() < osdmap.get_epoch()) send_incremental(op, m->get_epoch()+1); goto didit; } // 检查汇报的目标OSD是不是可以被Down if (!can_mark_down(badboy)) { dout(5) << "preprocess_failure ignoring report of osd." << m->get_target_osd() << " " << m->get_target_addrs() << " from " << m->get_orig_source() << dendl; goto didit; } // 如果上述检查都没有问题,说明这是一个新发现的有问题的OSD,那需要进一步处理了 dout(10) << "preprocess_failure new: osd." << m->get_target_osd() << " " << m->get_target_addrs() << ", from " << m->get_orig_source() << dendl; return false; didit: // 所有检查失败的,不管什么原因,都会忽略这个汇报 mon->no_reply(op); return true;}
可以看到preprocess_failure
里做了很多的校验工作,确保收到的消息是一个正确的消息,并且不是已经处理过的信息,所有检查都通过,说明真的有OSD出问题了,那就需要进一步处理了。于是就进入到prepare_failure
的流程:
bool OSDMonitor::prepare_failure(MonOpRequestRef op){// ... // 如果收到的消息中标记这个OSD失效了 if (m->if_osd_failed()) { // calculate failure time utime_t now = ceph_clock_now(); utime_t failed_since = m->get_recv_stamp() - utime_t(m->failed_for, 0); // add a report if (m->is_immediate()) { mon->clog->debug() << "osd." << m->get_target_osd() << " reported immediately failed by " << m->get_orig_source(); force_failure(target_osd, reporter); return true; } mon->clog->debug() << "osd." << m->get_target_osd() << " reported failed by " << m->get_orig_source(); failure_info_t& fi = failure_info[target_osd]; // 在这个失效OSD的reporters列表里加上汇报的这个OSD MonOpRequestRef old_op = fi.add_report(reporter, failed_since, op); if (old_op) { mon->no_reply(old_op); } // 判断是不是到阈值了 return check_failure(now, target_osd, fi); } else {// .. // 如果收到的消息中标记这个OSD没有失效 // 从失效列表里把这个OSD删了failure_info.erase(target_osd);// ... return false;}
可以看到逻辑不复杂,Monitor会维护一个失效列表,收到消息后,经过一些检查,把汇报源加到失效OSD的reporters列表里,最后执行check_failure
,那后续的判断就在OSDMonitor::check_failure(utime_t now, int target_osd, failure_info_t& fi)
了,继续:
bool OSDMonitor::check_failure(utime_t now, int target_osd, failure_info_t& fi){ // already pending failure? // 先判断是不是已经在提交Paxos的队列里了,在里面了就自然不用再多处理了 if (pending_inc.new_state.count(target_osd) && pending_inc.new_state[target_osd] & CEPH_OSD_UP) { dout(10) << " already pending failure" << dendl; return true; } // ... // 关键逻辑来了,经过一系列计算,如果汇报者的数量超过了mon_osd_min_down_reporters的值,就把OSD状态提交给Paxos if (failed_for >= grace && reporters_by_subtree.size() >= g_conf().get_val<uint64_t>("mon_osd_min_down_reporters")) { dout(1) << " we have enough reporters to mark osd." << target_osd << " down" << dendl; pending_inc.new_state[target_osd] = CEPH_OSD_UP; // ... return true; } return false;}
刚看到代码的时候,会不会觉得有点奇怪,为啥提交到Paxos的是一个CEPH_OSD_UP
的值?理论上不是应该是标记为DOWN么?我们看下new_state
的这个定义就知道了:
mempool::osdmap::map<int32_t,uint32_t> new_state; // XORed onto previous state.
发现注释没,这个new_state是会异或到原值里的,也就是说,其实上面的操作是把OSD的UP
状态去掉,没有UP
状态,自然这个OSD就是DOWN状态了。
好了,到这里,基本上就能确定一个OSD要被标记为Down状态,只剩下最后一步,因为Monitor是个分布式的系统,需要Paxos保证一致性,而pending_inc
队列里的内容,就是要提交给Paxos的修改了,具体Paxos的部分,这里就不再深入分析了。
所以弄明白当前Ceph的心跳机制,理顺OSD从故障到被集群踢出的流程是十分必要的。
首先我们从Ceph OSD进程的启动main函数开始,代码在src/ceph_osd.cc
:
int main(int argc, const char **argv){// ...//首先创建前端、后端的发送、接收总共四个Messenger Messenger *ms_hb_back_client = Messenger::create(g_ceph_context, cluster_msg_type,entity_name_t::OSD(whoami), "hb_back_client",nonce, Messenger::HEARTBEAT); Messenger *ms_hb_front_client = Messenger::create(g_ceph_context, public_msg_type,entity_name_t::OSD(whoami), "hb_front_client",nonce, Messenger::HEARTBEAT); Messenger *ms_hb_back_server = Messenger::create(g_ceph_context, cluster_msg_type,entity_name_t::OSD(whoami), "hb_back_server",nonce, Messenger::HEARTBEAT); Messenger *ms_hb_front_server = Messenger::create(g_ceph_context, public_msg_type,entity_name_t::OSD(whoami), "hb_front_server",nonce, Messenger::HEARTBEAT);// ...// 接下来进行绑定,将前后端对应的IP地址绑定到对应的Messenger上 entity_addrvec_t hb_front_addrs = public_addrs; for (auto& a : hb_front_addrs.v) { a.set_port(0); } if (ms_hb_front_server->bindv(hb_front_addrs) < 0) forker.exit(1); if (ms_hb_front_client->client_bind(hb_front_addrs.front()) < 0) forker.exit(1); entity_addrvec_t hb_back_addrs = cluster_addrs; for (auto& a : hb_back_addrs.v) { a.set_port(0); } if (ms_hb_back_server->bindv(hb_back_addrs) < 0) forker.exit(1); if (ms_hb_back_client->client_bind(hb_back_addrs.front()) < 0) forker.exit(1);// ...// 创建OSD实例 osdptr = new OSD(g_ceph_context, store, whoami, ms_cluster, ms_public, ms_hb_front_client, ms_hb_back_client, ms_hb_front_server, ms_hb_back_server, ms_objecter, &mc, data_path, journal_path);// ...// 此时心跳等还没启动,真正初始化心跳是在OSD::init()里 err = osdptr->init();}
初始化心跳相关逻辑在OSD::init()方法里,我们转到src/osd/OSD.cc
:
int OSD::init(){// ...// 给各个心跳相关的Messenger加上回调,用于处理心跳返回,以及其他OSD发送到自己的心跳,具体流程后面分析 hb_front_client_messenger->add_dispatcher_head(&heartbeat_dispatcher); hb_back_client_messenger->add_dispatcher_head(&heartbeat_dispatcher); hb_front_server_messenger->add_dispatcher_head(&heartbeat_dispatcher); hb_back_server_messenger->add_dispatcher_head(&heartbeat_dispatcher);// ...// 正式启动心跳// start the heartbeat heartbeat_thread.create("osd_srv_heartbt");}
继续,看看heartbeat_thread
的定义是什么样的,代码在src/osd/OSD.h
:
struct T_Heartbeat : public Thread { OSD *osd; explicit T_Heartbeat(OSD *o) : osd(o) {} void *entry() override { osd->heartbeat_entry(); return 0; }} heartbeat_thread;
很简单,就是调用osd->heartbeat_entry()
,那就继续看OSD::heartbeat_entry()
做了哪些事,同样实现在src/osd/OSD.cc
:
void OSD::heartbeat_entry(){ std::unique_lock l(heartbeat_lock); if (is_stopping()) return; while (!heartbeat_stop) { // 调用OSD::heartbeat() heartbeat(); // 根据配置,等待一段时间,继续心跳 double wait; if (cct->_conf.get_val<bool>("debug_disable_randomized_ping")) { wait = (float)cct->_conf->osd_heartbeat_interval; } else { wait = .5 + ((float)(rand() % 10)/10.0) * (float)cct->_conf->osd_heartbeat_interval; } auto w = ceph::make_timespan(wait); dout(30) << "heartbeat_entry sleeping for " << wait << dendl; heartbeat_cond.wait_for(l, w); if (is_stopping()) return; dout(30) << "heartbeat_entry woke up" << dendl; }}void OSD::heartbeat(){// ... // 获取当前的系统负载,在心跳包中,会带上当前OSD所在机器的负载信息 // 这里用了个公式,计算的是一天的累计的负载 // get CPU load avg double loadavgs[1]; int hb_interval = cct->_conf->osd_heartbeat_interval; int n_samples = 86400; if (hb_interval > 1) { n_samples /= hb_interval; if (n_samples < 1) n_samples = 1; } if (getloadavg(loadavgs, 1) == 1) { logger->set(l_osd_loadavg, 100 * loadavgs[0]); daily_loadavg = (daily_loadavg * (n_samples - 1) + loadavgs[0]) / n_samples; dout(30) << "heartbeat: daily_loadavg " << daily_loadavg << dendl; }// ... utime_t now = ceph_clock_now(); auto mnow = service.get_mnow(); utime_t deadline = now; deadline += cct->_conf->osd_heartbeat_grace; // 遍历所有需要心跳检测的Peer // send heartbeats for (map<int,HeartbeatInfo>::iterator i = heartbeat_peers.begin(); i != heartbeat_peers.end(); ++i) { // ... // 通过集群内网络发送MOSDPing消息 i->second.con_back->send_message( new MOSDPing(monc->get_fsid(), service.get_osdmap_epoch(), MOSDPing::PING, now, mnow, mnow, service.get_up_epoch(), cct->_conf->osd_heartbeat_min_size, delta_ub)); // 如果前端网络是分离的,那从前端网络也发送MOSDPing消息 if (i->second.con_front) i->second.con_front->send_message(new MOSDPing(monc->get_fsid(), service.get_osdmap_epoch(), MOSDPing::PING, now, mnow, mnow, service.get_up_epoch(), cct->_conf->osd_heartbeat_min_size, delta_ub)); } logger->set(l_osd_hb_to, heartbeat_peers.size()); // 有个情况是就一个OSD,那就等着OSDMap更新吧。 // hmm.. am i all alone? dout(30) << "heartbeat lonely?" << dendl; if (heartbeat_peers.empty()) { if (now - last_mon_heartbeat > cct->_conf->osd_mon_heartbeat_interval && is_active()) { last_mon_heartbeat = now; dout(10) << "i have no heartbeat peers; checking mon for new map" << dendl; osdmap_subscribe(get_osdmap_epoch() + 1, false); } } dout(30) << "heartbeat done" << dendl;}
心跳逻辑还是比较好理解的,基本就是个for循环,但是有个小细节,就是heartbeat_peers
哪来的?哪些OSD会被放入到这个心跳列表?是所有的OSD么?这个列表的初始化在OSD::maybe_update_heartbeat_peers()
:
void OSD::maybe_update_heartbeat_peers(){// ... // 首先,把所有OSD负责的PG的副本OSD,加到列表里 // build heartbeat from set if (is_active()) { vector<PGRef> pgs; _get_pgs(&pgs); for (auto& pg : pgs) { pg->with_heartbeat_peers([&](int peer) { if (get_osdmap()->is_up(peer)) { _add_heartbeat_peer(peer); }}); } } // 然后再看看OSDMap,把前后相邻的OSD先算出来 // include next and previous up osds to ensure we have a fully-connected set set<int> want, extras; const int next = get_osdmap()->get_next_up_osd_after(whoami); if (next >= 0) want.insert(next); int prev = get_osdmap()->get_previous_up_osd_before(whoami); if (prev >= 0 && prev != next) want.insert(prev); // 根据配置,从OSDMap里随机找一些OSD // make sure we have at least **min_down** osds coming from different // subtree level (e.g., hosts) for fast failure detection. auto min_down = cct->_conf.get_val<uint64_t>("mon_osd_min_down_reporters"); auto subtree = cct->_conf.get_val<string>("mon_osd_reporter_subtree_level"); auto limit = std::max(min_down, (uint64_t)cct->_conf->osd_heartbeat_min_peers); get_osdmap()->get_random_up_osds_by_subtree( whoami, subtree, limit, want, &want); // 把这些OSD全加到extras列表里 for (set<int>::iterator p = want.begin(); p != want.end(); ++p) { dout(10) << " adding neighbor peer osd." << *p << dendl; extras.insert(*p); _add_heartbeat_peer(*p); } // 二次确认一下,如果有非UP的OSD,就从列表里去掉 // remove down peers; enumerate extras map<int,HeartbeatInfo>::iterator p = heartbeat_peers.begin(); while (p != heartbeat_peers.end()) { if (!get_osdmap()->is_up(p->first)) { int o = p->first; ++p; _remove_heartbeat_peer(o); continue; } if (p->second.epoch < get_osdmap_epoch()) { extras.insert(p->first); } ++p; } // 下面就是根据配置调整心跳列表OSD的数量,让数量保持在一个合理的值 // too few? for (int n = next; n >= 0; ) { if ((int)heartbeat_peers.size() >= cct->_conf->osd_heartbeat_min_peers) break; if (!extras.count(n) && !want.count(n) && n != whoami) { dout(10) << " adding random peer osd." << n << dendl; extras.insert(n); _add_heartbeat_peer(n); } n = get_osdmap()->get_next_up_osd_after(n); if (n == next) break; // came full circle; stop } // too many? for (set<int>::iterator p = extras.begin(); (int)heartbeat_peers.size() > cct->_conf->osd_heartbeat_min_peers && p != extras.end(); ++p) { if (want.count(*p)) continue; _remove_heartbeat_peer(*p); } dout(10) << "maybe_update_heartbeat_peers " << heartbeat_peers.size() << " peers, extras " << extras << dendl; // clean up stale failure pending for (auto it = failure_pending.begin(); it != failure_pending.end();) { if (heartbeat_peers.count(it->first) == 0) { send_still_alive(get_osdmap_epoch(), it->first, it->second.second); failure_pending.erase(it++); } else { it++; } }}
在OSD初始化的一开始,我们已经知道4个心跳相关Messenger注册了一个回调heartbeat_dispatcher
用于处理收到的请求。这是封装后的dispatcher,最终会执行osd->heartbeat_dispatch(m)
,这个heartbeat_dispatch
也定义在src/osd/OSD.cc
:
bool OSD::heartbeat_dispatch(Message *m){ dout(30) << "heartbeat_dispatch " << m << dendl; switch (m->get_type()) {// ...// 如果是MSG_OSD_PING类型MSG继续调用handle_osd_ping case MSG_OSD_PING: handle_osd_ping(static_cast<MOSDPing*>(m)); break; return true;}void OSD::handle_osd_ping(MOSDPing *m){ // ... switch (m->op) { // 收到PING包 case MOSDPing::PING: { // 发送回包 Message *r = new MOSDPing(monc->get_fsid(), curmap->get_epoch(), MOSDPing::PING_REPLY, m->ping_stamp, m->mono_ping_stamp, mnow, service.get_up_epoch(), cct->_conf->osd_heartbeat_min_size, sender_delta_ub); con->send_message(r);// ... else if (!curmap->exists(from) || curmap->get_down_at(from) > m->map_epoch) { /* * 如果对端异常,发送个MOSDPing::YOU_DIED包 * tell them they have died */ Message *r = new MOSDPing(monc->get_fsid(), curmap->get_epoch(), MOSDPing::YOU_DIED, m->ping_stamp, m->mono_ping_stamp, mnow, service.get_up_epoch(), cct->_conf->osd_heartbeat_min_size); con->send_message(r); } } break; /* 收到PING回包 */ case MOSDPing::PING_REPLY: { map<int, HeartbeatInfo>::iterator i = heartbeat_peers.find(from); // 如果在心跳列表里 // 根据连接更新前后端的上次心跳时间 // 如果只有后端网络,则前后端一起更新,如果前后端网络都有,则各自更新各自的 if (i != heartbeat_peers.end()) { auto acked = i->second.ping_history.find(m->ping_stamp); if (acked != i->second.ping_history.end()) { int &unacknowledged = acked->second.second; if (con == i->second.con_back) { i->second.last_rx_back = now; ceph_assert(unacknowledged > 0); --unacknowledged; /* if there is no front con, set both stamps. */ if (i->second.con_front == NULL) { i->second.last_rx_front = now; ceph_assert(unacknowledged > 0); --unacknowledged; } } else if (con == i->second.con_front) { dout(25) << "handle_osd_ping got reply from osd." << from << " first_tx " << i->second.first_tx << " last_tx " << i->second.last_tx << " last_rx_back " << i->second.last_rx_back << " last_rx_front " << i->second.last_rx_front << " -> " << now << dendl; i->second.last_rx_front = now; ceph_assert(unacknowledged > 0); --unacknowledged; }// ...还会记录一些历史信息,这里不分析了。 /* 如果Peer状态正常,清除掉之前不正常的状态 */ if (i->second.is_healthy(now)) { /* Cancel false reports */ auto failure_queue_entry = failure_queue.find(from); if (failure_queue_entry != failure_queue.end()) { dout(10) << "handle_osd_ping canceling queued " << "failure report for osd." << from << dendl; failure_queue.erase(failure_queue_entry); } auto failure_pending_entry = failure_pending.find(from); if (failure_pending_entry != failure_pending.end()) { dout(10) << "handle_osd_ping canceling in-flight " << "failure report for osd." << from << dendl; send_still_alive(curmap->get_epoch(), from, failure_pending_entry->second.second); failure_pending.erase(failure_pending_entry); } } } else { /* old replies, deprecated by newly sent pings. */ dout(10) << "handle_osd_ping no pending ping(sent at " << m->ping_stamp << ") is found, treat as covered by newly sent pings " << "and ignore" << dendl; } } } break; /* 收到MOSDPing::YOU_DIED包,更新osdmap */ case MOSDPing::YOU_DIED: dout(10) << "handle_osd_ping " << m->get_source_inst() << " says i am down in " << m->map_epoch << dendl; osdmap_subscribe(curmap->get_epoch() + 1, false); break; } heartbeat_lock.unlock(); m->put();}
心跳流程分析的差不多了,该进行心跳的超时检测和信息上报了。
心跳的超时检测,是在OSD::heartbeat_check()
方法里检测的:
void OSD::heartbeat_check(){ ceph_assert(ceph_mutex_is_locked(heartbeat_lock)); utime_t now = ceph_clock_now(); // check for incoming heartbeats (move me elsewhere?) for (map<int, HeartbeatInfo>::iterator p = heartbeat_peers.begin(); p != heartbeat_peers.end(); ++p) { // 如果一个心跳还没发呢,先跳过 if (p->second.first_tx == utime_t()) { dout(25) << "heartbeat_check we haven't sent ping to osd." << p->first << " yet, skipping" << dendl; continue; } // 如果有发生超时的情况 if (p->second.is_unhealthy(now)) { utime_t oldest_deadline = p->second.ping_history.begin()->second.first; if (p->second.last_rx_back == utime_t() || p->second.last_rx_front == utime_t()) { // fail // 一个返回都没收到,扔failure_queue队列里 failure_queue[p->first] = p->second.first_tx; } else { // fail // 收到过返回,但是依然超时了,扔failure_queue队列里 failure_queue[p->first] = std::min(p->second.last_rx_back, p->second.last_rx_front); } } }}
这个检测还是比较简单的,那么什么时候上报到Monitor呢?我们慢慢来:
在OSD:init()
阶段,做了这样一件事:
int OSD::init(){ // ... tick_timer_without_osd_lock.init(); // ... { std::lock_guard l(tick_timer_lock); // 定时执行一下C_Tick_WithoutOSDLock tick_timer_without_osd_lock.add_event_after(get_tick_interval(),new C_Tick_WithoutOSDLock(this)); }}class OSD::C_Tick_WithoutOSDLock : public Context { OSD *osd; public: explicit C_Tick_WithoutOSDLock(OSD *o) : osd(o) {} void finish(int r) override { // 实际执行的是OSD::tick_without_osd_lock() osd->tick_without_osd_lock(); }};
在OSD::tick_without_osd_lock()
中,做了很多事情,其中就包括OSD心跳超时的检测和Monitor的上报:
void OSD::tick_without_osd_lock(){ // ... if (is_active() || is_waiting_for_healthy()) { { std::lock_guard l{heartbeat_lock}; // 检测心跳超时 heartbeat_check(); } map_lock.lock_shared(); std::lock_guard l(mon_report_lock); // mon report? // 根据情况上报Monitor,最长不超过osd_mon_report_interval utime_t now = ceph_clock_now(); if (service.need_fullness_update() ||now - last_mon_report > cct->_conf->osd_mon_report_interval) { last_mon_report = now; send_full_update(); // 上报失败的OSD send_failures(); } } // 等待下次tick tick_timer_without_osd_lock.add_event_after(get_tick_interval(), new C_Tick_WithoutOSDLock(this));}
再看看OSD::send_failures()
:
void OSD::send_failures(){ // ... while (!failure_queue.empty()) { int osd = failure_queue.begin()->first; if (!failure_pending.count(osd)) { int failed_for = (int)(double)(now - failure_queue.begin()->second); // 给Monitor发个MOSDFailure消息,带上失效OSD的各种信息 monc->send_mon_message( new MOSDFailure( monc->get_fsid(), osd, osdmap->get_addrs(osd), failed_for, osdmap->get_epoch())); failure_pending[osd] = make_pair(failure_queue.begin()->second, osdmap->get_addrs(osd)); } failure_queue.erase(osd); }}
到此,OSD针对心跳的部分就基本结束了。剩下来就要看Monitor收到MOSDFailure消息之后怎么处理了。
]]>一开始呢,想着先用被废弃但是还没被删除的老network-scripts顶一顶,想回滚也很简单:执行dnf install -y network-scripts && systemctl disable NetworkManager && systemctl enable network
就好了,剩下的操作和CentOS 7里的ifup、ifdown脚本没什么区别,只是在执行的时候会出现一些警告信息提示这种方式已经废弃。
但是又想了想,面对新事物,第一个反应就是想着禁用或者删除它,特别是一个这和当初从CentOS 6切换到CentOS 7时候抵制systemd一样的么?特别是从大方向上看,NetworkManger会和systemd一样被越来越多的发行版接受,那学习一下并且适应新的网络管理方式,不是个坏事。
其实NetworkManager不是个新事物了,之前笔记本装的ArchLinux系统,很早就切换到NetworkManager了,只不过因为有GUI管理工具,而且场景比较单一,所以没有过多的关注。等到了服务器端,场景比较复杂,以前积累的东西就不管用了。最主要的,是和之前管理网络的思路出现了一些冲突,产生了一些排斥心理,觉得不好用。等静下心来仔细看了看相关的文档,也做了一些实验,适应了这种新的管理方式之后,发现NetworkManager还是值得一试的。所以还是面对几个场景分享一下几个例子,希望能帮助到更多的人完成切换吧。
要想了解一个东西,得退回到最开始,看看它的设计思路是什么。确实,NetworkManager从设计之初的想法就不太一样,man NetworkManager
里可以看到它的设计初衷:
The NetworkManager daemon attempts to make networking configuration and operation as painless and automatic as possible by
managing the primary network connection and other network interfaces, like Ethernet, Wi-Fi, and Mobile Broadband devices.
从某些角度来说,更像是Windows那样尽量让用户以最简单和最无痛的方式获得网络连接,很多操作都是自动的,而之前发行版的脚本配置模式,如果没有明确的配置文件,则不会有任何网络连接。个人觉得这个特点,在桌面端其实挺OK的,但是对于服务器来说,反而有点好心干坏事,因为这种自动的行为会影响系统管理员的预期,这也是一开始最不适应的地方,网络不按预期工作,明明没有配置,为啥网卡被启动了。不过不用担心,这些问题,都有解决办法,我们一点点去看!
在NetworkManager里,有两个非常重要的概念。基本上,理清楚这两个概念对应的含义和区别,剩下的通过文档就可以非常容易的掌握NetworkManager的使用了。
第一个概念是device,也就是设备,怎么理解呢,一个设备对应一个网口,基本上ip link
里看到的那些都有对应的设备,基本上可以认为就是物理的网卡,每个物理网卡都会是一个device,当然,有些虚拟的网卡,也会是一个device,比如网桥bridge等。当然也有些区别,物理网卡是现实存在的设备,本质上最终是NetworkManager来管理它,而虚拟机网卡就是NetworkManager因为连接需要而生成的网卡。使用nmcli device
命令,可以看到当前NetworkManager所识别的设备,以及:
- 这个设备是否在NetworkManager的管理之下- 设备所对应的连接配置信息- 设备当前的连接配置
举个例子:
[root@localhost ~]# nmcli deviceDEVICE TYPE STATE CONNECTIONeth0 ethernet connected eth0lo loopback unmanaged --
可以很明确的看到我这台机器有两个设备,分别是eth0
和lo
,其中eth0
是以太网类型,被NetworkManager管理且对应的连接配置为eth0
,而lo
是loopback,目前不被NetworkManager所管理。在终端里执行,正常的设备会显示绿色,不正常的设备显示成红色,不被管理的显示为灰色,还是很清晰的。
另外一个概念是connection,也就是连接。怎么理解呢,连接就是一系列配置,比如IP地址获取方式是DHCP或者手动配置,如果是手动配置,则配置哪些IP地址,网关,DNS等等信息,这些配置非常的多,就不一一细说了,具体的看文档就好。
需要注意的是,连接是需要最终被apply到某个device上的,而且针对同一个device,可以有多个connection。但是,在任意时刻,有且只能有一个活动的connection被apply到一个device。有点绕,但是思考一下这个场景就很容易理解了:针对笔记本的无线网卡,在公司,连接的自然是公司的WIFI,这就需要一个connection,到了家里,又需要连接家里的WIFI,这就是另外一个connection,这俩都是针对同一个device的配置,也不会同时起作用。其实这个场景在服务器上不太常见,所以一般情况下,服务器上基本就可以配置成一个connection对应一个device就解决了。
弄明白这俩者之间的关系,很多东西就很顺畅了。下面就是一些常见的例子:
弄明白connection和device的关系之后,给网卡配置IP地址就很方便了:创建一个新的connection并把它apply到我们的device上。
[root@localhost ~]# nmcli connection add type ethernet con-name eth0-static ifname eth0 ipv4.method manual ipv4.addresses "192.168.145.60/20" ipv4.gateway 192.168.144.1 ipv4.dns 114.114.114.114 ipv6.method autoConnection 'eth0-static' (3ae60979-d6f1-4dbb-8a25-ff1178e7305c) successfully added.
从命令上看应该还是很容易读懂的,创建一个新的ethernet
类型的连接,名字叫做eth0-static
,网卡名字是eth0
,IPv4手动配置,地址网关DNS等等都填上,IPv6使用自动配置。
一般情况下,连接创建后如果对应设备没有活跃的其他连接,创建的连接会直接生效,如果没生效也比较简单,直接执行:
[root@localhost ~]# nmcli connection up eth0-staticConnection successfully activated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/4)
执行完成后连接生效,可以通过nmcli connection
和ip addr
命令查看结果:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEeth0-static 3ae60979-d6f1-4dbb-8a25-ff1178e7305c ethernet eth0eth0 72534820-fb8e-4c5a-8d49-8c013441d390 ethernet --[root@localhost ~]# nmcli deviceDEVICE TYPE STATE CONNECTIONeth0 ethernet connected eth0-staticlo loopback unmanaged --[root@localhost ~]# ip addr1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:b3:80:01 brd ff:ff:ff:ff:ff:ff inet 192.168.145.59/20 brd 192.168.159.255 scope global noprefixroute eth0 valid_lft forever preferred_lft forever inet6 fe80::a7cf:fd2:7970:4bd4/64 scope link noprefixroute valid_lft forever preferred_lft forever
如果要修改一个连接,也很简单,执行nmcli connection modify XXXX ...
就行了,语法和add差不多,不过修改一个连接需要注意的是,有些修改不会直接生效,需要执行nmcli connection down XXXX; nmcli connection up XXXX
之后修改的属性才能生效。
接下来一个例子是给网卡打vlan tag,这个场景也比较常见,特别是在交换机端是trunk口的情况下:
[root@localhost ~]# nmcli connection add type vlan con-name eth1-vlan-100 ifname eth1.100 dev eth1 vlan.id 100 ipv4.method manual ipv4.addresses 192.168.100.10/24 ipv4.gateway 192.168.100.1Connection 'eth1-vlan-100' (c0036d90-1edf-4085-8b9c-691433fc5afd) successfully added.
可以发现和上个例子有一点点的不同,因为实际的流量必须通过某个设备出去,所以和之前相比需要多加上dev eth1参数,声明流量的出口。
Connection创建成功后自动激活了:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEeth0-static 3ae60979-d6f1-4dbb-8a25-ff1178e7305c ethernet eth0eth1-vlan-100 c0036d90-1edf-4085-8b9c-691433fc5afd vlan eth1.100eth0 72534820-fb8e-4c5a-8d49-8c013441d390 ethernet --[root@localhost ~]# ip addr1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:b3:80:01 brd ff:ff:ff:ff:ff:ff inet 192.168.145.59/20 brd 192.168.159.255 scope global noprefixroute eth0 valid_lft forever preferred_lft forever inet6 fe80::a7cf:fd2:7970:4bd4/64 scope link noprefixroute valid_lft forever preferred_lft forever3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:b3:80:02 brd ff:ff:ff:ff:ff:ff7: eth1.100@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether 00:15:5d:b3:80:02 brd ff:ff:ff:ff:ff:ff inet 192.168.100.10/24 brd 192.168.100.255 scope global noprefixroute eth1.100 valid_lft forever preferred_lft forever inet6 fe80::6c74:c8d8:7448:370a/64 scope link noprefixroute valid_lft forever preferred_lft forever
可以看到,因为有eth1-vlan-100
这个connection并且是Active状态,所以NetworkManager创建了一个虚拟的device:eth1.100
,如果我把这个connection给down掉之后:
[root@localhost ~]# nmcli connection down eth1-vlan-100Connection 'eth1-vlan-100' successfully deactivated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/15)[root@localhost ~]# ip a1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:b3:80:01 brd ff:ff:ff:ff:ff:ff inet 192.168.145.59/20 brd 192.168.159.255 scope global noprefixroute eth0 valid_lft forever preferred_lft forever inet6 fe80::a7cf:fd2:7970:4bd4/64 scope link noprefixroute valid_lft forever preferred_lft forever3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether 00:15:5d:b3:80:02 brd ff:ff:ff:ff:ff:ff
可以发现eth1.100
直接就没了。所以针对这些虚拟的device,它的生命周期和connection是一致的。
接下来该轮到bonding了,bonding也是经常遇到的配置了,配置方法也比较简单:
首先先把bonding master给加上,并且配置好bonding的模式和其他参数,另外,由于bonding之后IP地址一般会配置到bond设备上,在添加的时候顺便也把IP这些信息也填上:
[root@localhost ~]# nmcli connection add type bond con-name bonding-bond0 ifname bond0 bond.options "mode=balance-xor,miimon=100,xmit_hash_policy=layer3+4,updelay=5000" ipv4.method manual ipv4.addresses 192.168.100.10 ipv4.gateway 192.168.100.18.100.10/24 ipv4.gateway 192.168.100.1Connection 'bonding-bond0' (a81a11b0-547e-4c6b-9518-62ce51d17ab4) successfully added.
添加完bonding master,再把两个slave添加到master口上:
[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f0 ifname ens1f0 master bond0Connection 'bond0-slave-ens1f0' (be6285ae-e07a-468d-a302-342c233d1346) successfully added.[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f1 ifname ens1f1 master bond0Connection 'bond0-slave-ens1f1' (321aa982-5ca0-4379-b822-4200f366cc27) successfully added.
再Down/Up一下bond口:
[root@localhost ~]# nmcli connection down bonding-bond0;nmcli connection up bonding-bond0Connection 'bonding-bond0' successfully deactivated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/251123)Connection successfully activated (master waiting for slaves) (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/251126)[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEbonding-bond0 a81a11b0-547e-4c6b-9518-62ce51d17ab4 bond bond0bond0-slave-ens1f0 be6285ae-e07a-468d-a302-342c233d1346 ethernet ens1f0bond0-slave-ens1f1 321aa982-5ca0-4379-b822-4200f366cc27 ethernet ens1f1
再举个dummy网卡的例子,因为有其他部门目前在用DR模式的LVS负载均衡,所以需要配置dummy网卡和IP地址,之前也稍微看了看,也比较简单:
[root@localhost ~]# nmcli connection add type dummy con-name dummy-dummy0 ifname dummy0 ipv4.method manual ipv4.addresses "1.1.1.1/32,2.2.2.2/32,3.3.3.3/32,4.4.4.4/32"Connection 'dummy-dummy0' (e02daf93-d1bc-4ec7-a985-7435426129be) successfully added.[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICESystem eth0 5fb06bd0-0bb0-7ffb-45f1-d6edd65f3e03 ethernet eth0dummy-dummy0 e02daf93-d1bc-4ec7-a985-7435426129be dummy dummy0[root@localhost ~]# ip addr1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000 link/ether fa:16:3e:a6:14:86 brd ff:ff:ff:ff:ff:ff inet 10.185.14.232/24 brd 10.185.14.255 scope global dynamic noprefixroute eth0 valid_lft 314568640sec preferred_lft 314568640sec inet6 fe80::f816:3eff:fea6:1486/64 scope link valid_lft forever preferred_lft forever5: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000 link/ether e6:ff:39:ca:c7:91 brd ff:ff:ff:ff:ff:ff inet 1.1.1.1/32 scope global noprefixroute dummy0 valid_lft forever preferred_lft forever inet 2.2.2.2/32 scope global noprefixroute dummy0 valid_lft forever preferred_lft forever inet 3.3.3.3/32 scope global noprefixroute dummy0 valid_lft forever preferred_lft forever inet 4.4.4.4/32 scope global noprefixroute dummy0 valid_lft forever preferred_lft forever inet6 fe80::ad93:23f1:7913:b741/64 scope link noprefixroute valid_lft forever preferred_lft forever
需要注意的是,一个连接是可以配置多个IP地址的,多个IP地址之间用,
分割就可以了。
Bond+Bridge的配置在虚拟化场景比较常见,需要注意的是,有了Bridge之后,IP地址需要配置到Bridige上。
[root@localhost ~]# nmcli connection add type bridge con-name bridge-br0 ifname br0 ipv4.method manual ipv4.addresses 192.168.100.10 ipv4.gateway 192.168.100.1Connection 'bridge-br0' (6052d8ca-ed8f-474b-88dd-9414bf028a2c) successfully added.
此时创建了一个网桥br0,但是还没有任何接口连接到这个网桥上,下面需要创建个bond0口,并把bond0加到br0上。
[root@localhost ~]# nmcli connection add type bond con-name bonding-bond0 ifname bond0 bond.options "mode=balance-xor,miimon=100,xmit_hash_policy=layer3+4,updelay=5000" connection.master br0 connection.slave-type bridgeConnection 'bonding-bond0' (755f0c93-6638-41c1-a7de-5e932eba6d1f) successfully added.
这里配置比较特殊,创建bond口和上面差不多,但是多了点配置connection.master br0 connection.slave-type bridge
,这个和普通的bridge-slave口直接指定master br0
的方式不太一样,因为bond0也是个虚拟的接口,所以需要将接口的属性connection.master
配置成br0,才能实现把bond0这个虚拟接口添加到br0的功能。
后面bond0添加两个slave口还是和之前没有区别:
[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f0 ifname ens1f0 master bond0Connection 'bond0-slave-ens1f0' (7ec188d0-d2db-4f80-a6f9-b7f93ab873f5) successfully added.[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f1 ifname ens1f1 master bond0Connection 'bond0-slave-ens1f1' (655c2960-0532-482a-8227-8b98eb7f829b) successfully added.[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEbridge-br0 6052d8ca-ed8f-474b-88dd-9414bf028a2c bridge br0bond0-slave-ens1f0 7ec188d0-d2db-4f80-a6f9-b7f93ab873f5 ethernet ens1f0bond0-slave-ens1f1 655c2960-0532-482a-8227-8b98eb7f829b ethernet ens1f1bonding-bond0 755f0c93-6638-41c1-a7de-5e932eba6d1f bond bond0
好了,地狱级难度的例子来了,想要利用NetworkManager来管理OVS Bridge,这该怎么做?这个场景是我们线上在用的,实验了很多次,总算找到办法解决了。
首先,需要安装NetworkManager-ovs
这个包,这个包是NetworkManager支持OVS的插件,所以得安装并重启NetworkManager服务后生效:
[root@localhost ~]# yum install -y NetworkManager-ovs && systemctl restart NetworkManager
第二步,需要创建一个ovs-bridge
,但是呢,这里有个坑,在man nm-openvswitch
里也有一些说明:
NetworkManager only ever talks to a single OVSDB instance via an UNIX domain socket.
The configuration is made up of Bridges, Ports and Interfaces. Interfaces are always enslaved to Ports, and Ports are
always enslaved to Bridges.NetworkManager only creates Bridges, Ports and Interfaces you ask it to. Unlike ovs-vsctl, it doesn’t create the local
interface nor its port automatically.You can’t enslave Interface directly to a Bridge. You always need a Port, even if it has just one interface.
There are no VLANs. The VLAN tagging is enabled by setting a ovs-port.tag property on a Port.
There are no bonds either. The bonding is enabled by enslaving multiple Interfaces to a Port and configured by setting
properties on a port.
Bridges
Bridges are represented by connections of ovs-bridge type. Due to the limitations of OVSDB, “empty” Bridges (with no
Ports) can’t exist. NetworkManager inserts the records for Bridges into OVSDB when a Port is enslaved.Ports
Ports are represented by connections of ovs-port type. Due to the limitations of OVSDB, “empty” Ports (with no Interfaces)
can’t exist. Ports can also be configured to do VLAN tagging or Bonding. NetworkManager inserts the records for Ports into
OVSDB when an Interface is enslaved. Ports must be enslaved to a Bridge.Interfaces
Interfaces are represented by a connections enslaved to a Port. The system interfaces (that have a corresponding Linux
link) have a respective connection.type of the link (e.g. “wired”, “bond”, “dummy”, etc.). Other interfaces (“internal” or
“patch” interfaces) are of ovs-interface type. The OVSDB entries are inserted upon enslavement to a Port.
怎么理解呢,首先NetworkManager之和OVSDB通信,而OVSDB是有些限制的:1. 不允许空Bridge(没有任何Port)存在;2. 不允许空Port(没有任何Interface)存在;3. 不能直接将一个Interface接到Bridge上,必须有对应的Port才行。
不明白也没事,看下面的例子就好,首先我们要创建一个OVS Bridge ovsbr0:
[root@localhost ~]# nmcli connection add type ovs-bridge con-name ovs-br0 conn.interface-name ovsbr0Connection 'ovs-br0' (c409c13a-3bc3-42fc-a6f2-79cb315fd26b) successfully added.[root@localhost ~]# nmcli connection add type ovs-port con-name ovs-br0-port0 conn.interface-name br0-port0 master ovsbr0Connection 'ovs-br0-port0' (32982ce8-41ec-44e9-8010-da80bbefa5d4) successfully added.[root@localhost ~]# nmcli conn add type ovs-interface slave-type ovs-port conn.interface-name ovsbr0-iface0 master br0-port0 ipv4.method manual ipv4.address 192.168.2.100/24Connection 'ovs-slave-ovsbr0-iface0' (f8ba0e5e-c136-4287-aede-e4d59031d878) successfully added.
请注意,这三个connection必须完整创建好,才能真正的创建ovsbr0,这个和我们平常意识的逻辑很不一样。如果直接用ovs-vsctl
命令创建,那只需要执行ovs-vsctl add-br ovsbr0
就行了,然而在NetworkManager里,你必须把详细的内部逻辑拆分开:1. 创建个OVS Bridge ovsbr0;2. 在ovsbr0上创建个Port br0-port0;3. 创建个interface ovsbr0-iface0并连接到br0-port0上。
如此看来,ovs-vsctl命令行的操作把很多细节给隐藏掉了。
按照步骤创建上面三个connection之后,可以看到ovsbr0被创建好了:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEovs-slave-ovsbr0-iface0 f8ba0e5e-c136-4287-aede-e4d59031d878 ovs-interface ovsbr0-iface0ovs-br0 c409c13a-3bc3-42fc-a6f2-79cb315fd26b ovs-bridge ovsbr0ovs-br0-port0 32982ce8-41ec-44e9-8010-da80bbefa5d4 ovs-port br0-port0[root@localhost ~]# ovs-vsctl showa2ab0cdf-9cf1-41a5-99f4-ae81c58e3fa8 Bridge ovsbr0 Port br0-port0 Interface ovsbr0-iface0 type: internal ovs_version: "2.13.1"[root@localhost ~]# ip addr1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever10: ovs-system: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether ca:cb:22:a1:a7:fb brd ff:ff:ff:ff:ff:ff11: ovsbr0-iface0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000 link/ether c2:51:c2:2b:6d:b5 brd ff:ff:ff:ff:ff:ff inet 192.168.2.100/24 brd 192.168.2.255 scope global noprefixroute ovsbr0-iface0 valid_lft forever preferred_lft forever
创建好ovsbr0之后,需要把bond0也加进去,如果是ovs-vsctl命令操作的话,直接ovs-vsctl add-port ovsbr0 bond0
就行了,ovs-vsctl帮我们隐藏了细节。同样的操作如果用NetworkManager,就需要先创建一个Port,然后再把bond0加到这个Port上了:
[root@localhost ~]# nmcli connection add type ovs-port con-name ovs-br0-port-bond0 conn.interface-name br0-bond0 master ovsbr0Connection 'ovs-br0-port-bond0' (de863ea6-4e1b-4343-93a3-91790895256f) successfully added.[root@localhost ~]# nmcli connection add type bond con-name bonding-bond0 ifname bond0 bond.options "mode=balance-xor,miimon=100,xmit_hash_policy=layer3+4,updelay=5000" connection.master br0-bond0 connection.slave-type ovs-portConnection 'bonding-bond0' (8b233d53-65b1-4237-b835-62135bb66ada) successfully added.[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f0 ifname ens1f0 master bond0Connection 'bond0-slave-ens1f0' (6d5febe2-fc65-428a-94f1-9a782cd6b397) successfully added.[root@localhost ~]# nmcli connection add type bond-slave con-name bond0-slave-ens1f1 ifname ens1f1 master bond0Connection 'bond0-slave-ens1f1' (55ce8e7f-233d-430f-901d-f0e5f326c8c7) successfully added.[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEovs-slave-ovsbr0-iface0 f8ba0e5e-c136-4287-aede-e4d59031d878 ovs-interface ovsbr0-iface0bond0-slave-ens1f0 6d5febe2-fc65-428a-94f1-9a782cd6b397 ethernet ens1f0bond0-slave-ens1f1 55ce8e7f-233d-430f-901d-f0e5f326c8c7 ethernet ens1f1bonding-bond0 8b233d53-65b1-4237-b835-62135bb66ada bond bond0ovs-br0 c409c13a-3bc3-42fc-a6f2-79cb315fd26b ovs-bridge ovsbr0ovs-br0-port0 32982ce8-41ec-44e9-8010-da80bbefa5d4 ovs-port br0-port0ovs-br0-port-bond0 de863ea6-4e1b-4343-93a3-91790895256f ovs-port br0-bond0[root@localhost ~]# ovs-vsctl showa2ab0cdf-9cf1-41a5-99f4-ae81c58e3fa8 Bridge ovsbr0 Port br0-port0 Interface ovsbr0-iface0 type: internal Port br0-bond0 Interface bond0 type: system ovs_version: "2.13.1"[root@localhost ~]# ip addr1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever2: ens1f0: <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP> mtu 1500 qdisc mq master bond0 state UP group default qlen 1000 link/ether 0c:42:a1:70:c7:2a brd ff:ff:ff:ff:ff:ff3: ens1f1: <BROADCAST,MULTICAST,SLAVE,UP,LOWER_UP> mtu 1500 qdisc mq master bond0 state UP group default qlen 1000 link/ether 0c:42:a1:70:c7:2a brd ff:ff:ff:ff:ff:ff4: ovs-system: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether ca:cb:22:a1:a7:fb brd ff:ff:ff:ff:ff:ff5: ovsbr0-iface0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000 link/ether c2:51:c2:2b:6d:b5 brd ff:ff:ff:ff:ff:ff inet 192.168.2.100/24 brd 192.168.2.255 scope global noprefixroute ovsbr0-iface0 valid_lft forever preferred_lft forever6: bond0: <BROADCAST,MULTICAST,MASTER,UP,LOWER_UP> mtu 1500 qdisc noqueue master ovs-system state UP group default qlen 1000 link/ether 0c:42:a1:70:c7:2a brd ff:ff:ff:ff:ff:ff
可以看到,NetworkManager和直接用ovs-ctl最大的不同,就是把一些细节暴露了出来,本质上把一个接口加到Bridge上不是直接加的,而是加到了Bridge的某个Port上。但是仔细一想也没毛病,对应到现实世界的交换机,你接接线也是接到交换机的某个端口上,如果没有端口,那线往哪插呢?
好了,上面举了很多例子实现了一些我们可能会用到的场景,但是一大堆问题又来了,这些配置能持久化么?重启了机器之后还会有么?如果有,那这些配置是保存在哪里的?我能不能不用nmcli这个命令行工具了,使用配置文件,能完成网络的配置么?
这些问题的答案都是肯定的!
首先呢,针对老版本network-scripts,也就是存放在/etc/sysconfig/network-scripts/
目录下的那些ifcfg-*开头的配置文件,NetworkManager通过一个ifcfg-rh plugin去识别,这个插件在RHEL里是默认开启的,而且,针对一些配置类型,比如ethernet,bond,vlan,bridge等配置,通过nmcli创建或者修改connections,都会同步到这个目录下对应的配置文件里:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEeth0-static 3ae60979-d6f1-4dbb-8a25-ff1178e7305c ethernet eth0eth1-vlan-100 7bc246cb-140a-4515-8dc1-8efa03b789cb vlan eth1.100bridge-br0 3230425c-505d-4a97-adbe-6f26e27fe53c bridge br0eth0 72534820-fb8e-4c5a-8d49-8c013441d390 ethernet --[root@localhost ~]# ls -l /etc/sysconfig/network-scripts/total 16-rw-r--r--. 1 root root 372 Feb 20 15:13 ifcfg-bridge-br0-rw-r--r--. 1 root root 278 Feb 11 22:02 ifcfg-eth0-rw-r--r--. 1 root root 360 Feb 17 18:34 ifcfg-eth0-static-rw-r--r--. 1 root root 415 Feb 20 16:11 ifcfg-eth1-vlan-100[root@localhost ~]# cat /etc/sysconfig/network-scripts/ifcfg-eth0TYPE=EthernetPROXY_METHOD=noneBROWSER_ONLY=noBOOTPROTO=dhcpDEFROUTE=yesIPV4_FAILURE_FATAL=noIPV6INIT=yesIPV6_AUTOCONF=yesIPV6_DEFROUTE=yesIPV6_FAILURE_FATAL=noIPV6_ADDR_GEN_MODE=stable-privacyNAME=eth0UUID=72534820-fb8e-4c5a-8d49-8c013441d390DEVICE=eth0ONBOOT=yes
可以看到每个connection都对应了一个配置文件。
然后NetworkManager还会读取/etc/NetworkManager/system-connections/
目录下的配置文件,同时,通过nmcli创建和修改一些其他类型的connections,比如ovs-bridge, dummy这些,也会同步写入到这个目录下:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEeth0-static 3ae60979-d6f1-4dbb-8a25-ff1178e7305c ethernet eth0dummy-dummy0 190f363b-190b-4b98-b85c-046ec8995453 dummy dummy0eth1-vlan-100 7bc246cb-140a-4515-8dc1-8efa03b789cb vlan eth1.100bridge-br0 3230425c-505d-4a97-adbe-6f26e27fe53c bridge br0eth0 72534820-fb8e-4c5a-8d49-8c013441d390 ethernet --[root@localhost ~]# ls -l /etc/NetworkManager/system-connections/total 4-rw-------. 1 root root 310 Feb 20 16:16 dummy-dummy0.nmconnection[root@localhost ~]# cat /etc/NetworkManager/system-connections/dummy-dummy0.nmconnection[connection]id=dummy-dummy0uuid=190f363b-190b-4b98-b85c-046ec8995453type=dummyinterface-name=dummy0permissions=[dummy][ipv4]address1=1.1.1.1/32address2=2.2.2.2/32address3=3.3.3.3/32address4=4.4.4.4/32dns-search=method=manual[ipv6]addr-gen-mode=stable-privacydns-search=method=auto[proxy]
可以看到dummy-dummy0的配置被持久化在/etc/NetworkManager/system-connections/
。
所以如果要修改配置,也是可以到这两个对应目录下直接修改对应的配置文件的。但是这里有个小问题,就是修改配置文件后,NetworkManager不会自动重新加载这些配置,需要手动执行nmcli connection load XXXX
手动重载单个配置或者执行nmcli connection reload
重新加载所有的配置文件。加载完成后,要想配置真正生效,还需要执行nmcli connection down XXXX; nmcli connection up XXXX
或者nmcli device reapply XXX
来真正让配置生效。
说了这么多,还有一件很重要的事还没说:假如我真的不希望NetworkManager帮我管理某些网卡,怎么办?因为默认情况下,NetworkManager会自动得把很多设备的纳入管理,然后自动创建一堆Wired connection
,就像这样:
[root@localhost ~]# nmcli connectionNAME UUID TYPE DEVICEeth0 72534820-fb8e-4c5a-8d49-8c013441d390 ethernet eth0Wired connection 1 3a8d6eb9-9d38-3c38-9519-4918f58ee42c ethernet ens1f0_0Wired connection 2 d0efb693-45d0-4245-8018-6738f7509094 ethernet ens1f0_1Wired connection 3 b3ecf462-a0ed-4f08-a203-c4f10b4dde0b ethernet ens1f0_2Wired connection 4 5f0ca36b-2add-4815-a859-ff651238f893 ethernet ens1f0_3Wired connection 5 3f010d3e-74ba-405c-a575-eba53641fe4f ethernet ens1f0_4Wired connection 6 88e4d303-fd6b-4f66-9e4e-6743ec47c8b7 ethernet ens1f0_5Wired connection 7 2800a439-44e1-4304-9c2c-dedbbba74c40 ethernet ens1f0_6Wired connection 8 21ae8892-8a51-4c77-854c-08e9857e32d9 ethernet ens1f0_7
在我们的SR-IOV场景下更是这样,因为开启SR-IOV之后,会创建很多的网卡,然后NetworkManager不分青红皂白,全部给管理上,让人头大。所以需要一个配置能通知NetworkManager哪些网卡不需要纳入管理。
还好NetworkManager提供了这个配置项,可以声明哪些网卡不被管理:在/etc/NetworkManager/conf.d/
目录下创建unmanaged.conf
[root@localhost ~]# cat /etc/NetworkManager/conf.d/unmanaged.conf[keyfile]unmanaged-devices=mac:00:1E:65:30:D1:C4;interface-name:eth1;interface-name:ens1f0_*
具体的匹配规则有很多,可以参考man NetworkManager.conf
的Device List Format
部分,这里就不在赘述了。
重启生效后,瞬间清净了:
[root@localhost ~]# nmcli deviceDEVICE TYPE STATE CONNECTIONeth0 ethernet connected eth0ens1f0_0 ethernet unmanaged --ens1f0_1 ethernet unmanaged --ens1f0_2 ethernet unmanaged --ens1f0_3 ethernet unmanaged --lo loopback unmanaged --
好了,基本上NetworkManager基本的用法说的差不多了,总体来说,如果掌握了它的设计思路和一些细节逻辑,NetworkManager使用起来也没有那么不堪,甚至还会觉得很好用,毕竟nmcli命令行可以Tab进行自动提示,而且命令行风格整体也比较符合自然语言。
最后希望这些例子能给遇到同样问题的人一些帮助吧,起码希望NetworkManager不要成为最终迁移RHEL 8的拦路虎。
]]>答案显而易见,必然是可以的。但是如果要改物理CPU的寄存器,那确实会有些困难,不过没关系,我们还有虚拟机嘛,理论上虚拟机可以虚拟这些东西,那改动起来应该也是比较方便的。
想要修改这些寄存器,首先得先看看CPUID指令在Qemu里是怎么处理的:
经过一些搜索,发现KVM提供了一个接口KVM_SET_CPUID2,通过这个接口,可以在用户空间设置需要模拟的CPUID的信息,而我们使用Qemu,肯定是会打开KVM加速的,因此,只需要看看Qemu在这方面是怎么处理的就可以了。
知道了相关代码的关键字,找相关的逻辑就不难了,在target/i386/kvm.c文件里,定义了一个int kvm_arch_init_vcpu(CPUState *cs)
函数:
int kvm_arch_init_vcpu(CPUState *cs){ struct { struct kvm_cpuid2 cpuid; struct kvm_cpuid_entry2 entries[KVM_MAX_CPUID_ENTRIES]; } cpuid_data; /* * The kernel defines these structs with padding fields so there * should be no extra padding in our cpuid_data struct. */ QEMU_BUILD_BUG_ON(sizeof(cpuid_data) != sizeof(struct kvm_cpuid2) + sizeof(struct kvm_cpuid_entry2) * KVM_MAX_CPUID_ENTRIES); X86CPU *cpu = X86_CPU(cs); CPUX86State *env = &cpu->env; uint32_t limit, i, j, cpuid_i; uint32_t unused; struct kvm_cpuid_entry2 *c; uint32_t signature[3]; int kvm_base = KVM_CPUID_SIGNATURE; int max_nested_state_len; int r; Error *local_err = NULL; // ... cpu_x86_cpuid(env, 0, 0, &limit, &unused, &unused, &unused); for (i = 0; i <= limit; i++) { // ... switch (i) { // ... default: c->function = i; c->flags = 0; cpu_x86_cpuid(env, i, 0, &c->eax, &c->ebx, &c->ecx, &c->edx); if (!c->eax && !c->ebx && !c->ecx && !c->edx) { /* * KVM already returns all zeroes if a CPUID entry is missing, * so we can omit it and avoid hitting KVM's 80-entry limit. */ cpuid_i--; } break; } } cpu_x86_cpuid(env, 0x80000000, 0, &limit, &unused, &unused, &unused); for (i = 0x80000000; i <= limit; i++) { if (cpuid_i == KVM_MAX_CPUID_ENTRIES) { fprintf(stderr, "unsupported xlevel value: 0x%x\n", limit); abort(); } c = &cpuid_data.entries[cpuid_i++]; switch (i) { // ... default: c->function = i; c->flags = 0; cpu_x86_cpuid(env, i, 0, &c->eax, &c->ebx, &c->ecx, &c->edx); if (!c->eax && !c->ebx && !c->ecx && !c->edx) { /* * KVM already returns all zeroes if a CPUID entry is missing, * so we can omit it and avoid hitting KVM's 80-entry limit. */ cpuid_i--; } break; } } cpuid_data.cpuid.nent = cpuid_i; cpuid_data.cpuid.padding = 0; // 上面的代码都是在构造一个完整的cpuid_data r = kvm_vcpu_ioctl(cs, KVM_SET_CPUID2, &cpuid_data); // 通过KVM接口设置CPUID if (r) { goto fail; } // ... return 0; fail: migrate_del_blocker(invtsc_mig_blocker); return r;}
函数实现挺长,不过大部分都是些判断逻辑,最主要的两个逻辑:一个是构造KVM需要的cpuid_data数据,主要就是循环获取所有的CPUID信息,填充结构体;然后就是通过KVM_SET_CPUID2
接口把数据设置给KVM。
其中,在获取CPUID信息的时候,调用了cpu_x86_cpuid()
这个函数,这个函数的定义在target/i386/cpu.c:
void cpu_x86_cpuid(CPUX86State *env, uint32_t index, uint32_t count, uint32_t *eax, uint32_t *ebx, uint32_t *ecx, uint32_t *edx){ X86CPU *cpu = env_archcpu(env); CPUState *cs = env_cpu(env); uint32_t die_offset; uint32_t limit; uint32_t signature[3]; X86CPUTopoInfo topo_info; // ... switch(index) { // ... case 0x80000002: case 0x80000003: case 0x80000004: *eax = env->cpuid_model[(index - 0x80000002) * 4 + 0]; *ebx = env->cpuid_model[(index - 0x80000002) * 4 + 1]; *ecx = env->cpuid_model[(index - 0x80000002) * 4 + 2]; *edx = env->cpuid_model[(index - 0x80000002) * 4 + 3]; break; // ... default: /* reserved values: zero */ *eax = 0; *ebx = 0; *ecx = 0; *edx = 0; break; }}
这个函数实现也是非常长,也是很多的case分支,但是大部分我们不用关心,只需要看0x80000002
到0x80000004
这几个case就行,代码也很简单,就是把env->cpuid_model
的值赋值到对应的寄存器里。
看到这里,修改寄存器的方式就很明确了,直接修改env->cpuid_model
里的值就可以了。其实还会有些小问题,比如参数里的CPUX86State *env
具体是从哪来的,这个问题比较复杂,但是也很值得去研究,下次会专门开文章分析这部分逻辑。
要修改env->cpuid_model
,先看看定义,在target/i386/cpu.h被定义成uint32_t cpuid_model[12]
,很合理,三个ID,每个ID 4个寄存器,一共12个uint32。
然后呢,还需要寻找一个string到uint32_t
的转换逻辑,简单看了一下代码里有个x86_cpuid_set_model_id函数
static void x86_cpuid_set_model_id(Object *obj, const char *model_id, Error **errp){ X86CPU *cpu = X86_CPU(obj); CPUX86State *env = &cpu->env; int c, len, i; if (model_id == NULL) { model_id = ""; } len = strlen(model_id); memset(env->cpuid_model, 0, 48); for (i = 0; i < 48; i++) { if (i >= len) { c = '\0'; } else { c = (uint8_t)model_id[i]; } env->cpuid_model[i >> 2] |= c << (8 * (i & 3)); }}
稍微有些区别,因为x86_cpuid_set_model_id
函数参是一个X86CPU类型,但是问题不大,我们稍微修改一下逻辑,新建个函数set_fake_cpuid_model
,把cpuid_model修改成Intel(R) Xeon(R) A Really Fast CPU @ 10.0 GHz
:
static void set_fake_cpuid_model(uint32_t fake_cpuid_model[12]){ // 这里修改成任何想填的信息 const char *fake_model_id = "Intel(R) Xeon(R) A Really Fast CPU @ 10.0 GHz"; memset(fake_cpuid_model, 0, 48); int c, len, i; len = strlen(fake_model_id); for (i = 0; i < 48; i++) { if (i >= len) { c = '\0'; } else { c = (uint8_t)fake_model_id[i]; } fake_cpuid_model[i >> 2] |= c << (8 * (i & 3)); }}
然后在cpu_x86_cpuid
函数里多加一行:
void cpu_x86_cpuid(CPUX86State *env, uint32_t index, uint32_t count, uint32_t *eax, uint32_t *ebx, uint32_t *ecx, uint32_t *edx){ X86CPU *cpu = env_archcpu(env); CPUState *cs = env_cpu(env); uint32_t die_offset; uint32_t limit; uint32_t signature[3]; X86CPUTopoInfo topo_info; // ... switch(index) { // ... case 0x80000002: case 0x80000003: case 0x80000004: set_fake_cpuid_model(env->cpuid_model); // 将CPUID设置成我们需要的 *eax = env->cpuid_model[(index - 0x80000002) * 4 + 0]; *ebx = env->cpuid_model[(index - 0x80000002) * 4 + 1]; *ecx = env->cpuid_model[(index - 0x80000002) * 4 + 2]; *edx = env->cpuid_model[(index - 0x80000002) * 4 + 3]; break; // ... }}
虽然暴力了点,但是作为测试的话,先实现测试的功能就好。如果确实需要有类似的逻辑,理论上放到X86CPU
结构体初始化的地方,或者干脆自定义一个CPU类型,会比较友好。
最后,编译,运行!