准备考驾照的动机是过年的时候父母提起想要去山西大同那边自驾玩几天,再加上刚经历了三年大健康运动(防疫闹剧)。父母想要实现的愿望就想尽早地帮他们实现,说不定那天被再来一场闹剧被关进「集中营」,那时候再后悔就来不及了。
想要在路上合法地开车就需要有个驾照,毕竟开车是一件吃席风险比较高的事情。都需要考个驾照才能合法上路,不然无证驾驶会被处以 200 以上 2000 元以下罚款,并处 15 天以下拘留。那我们就考个驾照吧。
我觉着考驾照的最佳时机是高考完的那个暑假或者大学时,因为刚满十八周岁到了考驾照的年龄、时间也很充裕,而且那时候手脚灵活、脑袋还比较好使。可惜那段时间都在瞎玩,没有去考驾照。觉着自己又买不起车又不怎么开车,对当时的自己来讲考驾照没啥用。事实证明,驾照考的越早越好,当你会开车以及有了车之后,你的生活出行距离范围会扩大很多,也会方便很多。像我这样,工作之后再去考驾照,时间成本可谓是真高,为了练车和考试我还用掉了五天的宝贵年假 😭。
过年回来的第一周就开始联系在上海的朋友找个靠谱的驾校,朋友之前是在特斯拉卖车的,所以认识了一个驾校教练,刚好他的老婆也跟着这个教练练车。于是经介绍加了教练的微信,确认了在上海考驾照的一些基本信息:
关于费用:
我当时考的时候没有学时限制,现在上海这边考驾照要求学时达到后才能预约相应科目的考试,所以驾照是考的越早越省事,后面肯定会越来越严的。听教练说明年练车的时候就要人脸识别了,越来越难,所以如果你在上海还想考驾照,就马上行动起来别再拖啦。
另外在上海考驾照,通过驾校无疑是最方便省事儿的,虽然可以自学直考,但是有场地限制以及需要车辆安装副驾的刹车踏板,对没有车的人来说是比较麻烦的,而且成本不见得比驾校要低。对于其他关于驾考的信息,推荐看一下 上海学车考驾照经历分享,科二科三一把过,两个月内快速拿到 C2 驾照。 这个视频。
面签就是去驾校办理一个类似于学籍的东西,把身份证、照片、驾照类型、住址等信息录入到上海交警驾照考试系统里。面签完成后,需要自己在 12123 上注册账户,等到车管所那边录入信息后才能在 12123 上预约科目一考试。如果 12123 上提示没有找到学员信息,很可能是车管所那边在建档的时候出现问题,比如存在异地档案、身份信息错误、有其他违章(比如骑电车不带头盔或闯红灯被抓到)。这时一定要及时联系驾校那边问下什么情况,及时处理,不要一直干等着。我当时就是因为存在异地档案没有注销被拖了两周🥹。
科目一比较简单,花了 90 块钱氪金充值了驾校一点通的会员,每天有空的时候就刷题。等到 12123 上能预约科目一考试的时候,就约了最近的一场考试。考科目一的时候提前半个小时到了考场,排队的人已经七八十人了。到考场后是不按预约场次排序的,只要预约了当天的场次就能随到随考。科一共 100 道题,大部分题都刷过,所以只要掌握一点技巧多刷题多背就能稳过。
驾校这边一般是科目一过了之后才开始科目二练车,没有考过科目一教练是不给安排科目二练车的。所以科目一一定不要挂,挂了之后需要等两周才能重考,在这期间只能干等着,十分浪费时间。
上海驾考科目二主要考下面这几项:
科目二我练习了大概 10 次就预约去考试了,当时感觉状态良好绝对能一把过的。没想到很快打脸了,考第一把的时候左倒库压线挂掉了,第二把在半坡起步的时候溜车挂掉了 😭。后来听教练说我开的那辆考试车离合器有问题,半坡起步的时候,离合器送到半联动时如果直接送开刹车大概率会溜车,只要上了这辆车十有八九的会挂掉,很多考生都挂在我开的那辆车上了。无奈只能等待十天继续预约下一次考试,而每次只能预约四天后的考试,所以当挂科后需要等待两周才能重新考试。
考第二次的时候十分紧张,不幸两把都在倒库的时候挂掉了;接着等待两周再考第三次,第三次的时候又因为倒库挂掉了。考了三次都挂掉了,当时就陷入了自我怀疑中,自己适不适合开车呀。听同事调侃说他考科三挂了四次,五十步笑百步,心理有了些许安慰。挂了三次,还剩两次机会的嘛,即便如此还是要硬着头皮继续考,不能就因此放弃了。
第四次考试的时候,换了一个考场,模拟的时候状态并不是很好,教练也觉着我过不了,但没想到最终一把满分过了 🎉。个人感觉考科二最主要的是心态,只要不紧张,放松一点慢慢开就能稳过 😂。我第一次考试也不怎么紧张,状态也是最好的,无奈运气不好碰上了个有问题的车就挂掉了。
科目二考试最麻烦的就是考场在金山,需要头天晚上过去住在考场附近的酒店,或者凌晨四点多跟着教练的车去考场。为了多睡会我选择头天晚上去住在考场附近的酒店,早上六点起床到考场进行模拟。由于只请了半天的假,考完后就得赶紧回公司继续搬砖。而每次挂科后,回到公司的心情极差,尤其是同事问起考过没有,心里贼难受 😣。
科目二考过后就开始练习科目三,科目三练车时间并不是很长,大概就练了六次,每次一个半小时。考试的时候也是头天晚上去,早上早起去考场进行模拟。科三考试会在三个路线里面随机挑选一个,所以模拟的时候三个线路都会模拟一圈。科三考试不同于科二,科二考试副驾驶是没有监考员的,而科三有,并且监考员就是我们的教练。有个熟人在旁边心态会好很多,所以不会像科二那样紧张,很顺利地一把满分过了。
当时考试是我们四个人学员一块考,一辆车四个人轮流进行模拟,等到都模拟完已经十二点了,在车上呆了将近六个小时,身心疲惫的。等到下午轮到我们考试的时候也已经一点多了,考完之后就感觉像是做梦一样这么顺利地一把过了,比科目二考试顺利多了。
科目四和科目一差不多,只要刷点题就能轻松过。考试的时候带上在驾校面签时留下的那张证件照,科四考过之后,就会当场办驾驶证,然后进行宣誓就能拿到驾照啦。
考完驾照后还没有买车的打算,直到有一天和我姐商量好国庆带父母去山西自驾游玩后,才想赶紧买辆车练练手,方便在自驾游的时候代替老爸开车,让老爸休息会。当时想买车的动机主要是想练练手,于是想着能不能租一辆车来开练手呢?事实证明租车练手不太可行,因为绝大多数租车平台上要求驾龄至少三个月,而我刚拿驾照不到一个月 😂。于是就下定决心买辆车吧,拥有一台属于自己的车。
现在回想起来,大概率是受好友 Nova Kwok 的思域 FK7 改车笔记 的影响,当时买车的时候只认准了本田的十代思域(人称鬼 👻 火 🔥 思域),其他车型一概没有考虑,而且还是手动挡的思域。十代思域在同级别同价位的车型中,性能和操控性无疑是最有性价比的选择。至于隔音差、异响多、碰撞测试 B 柱断裂、发动机积炭、织布座椅、内饰用料一般等缺点,无疑是印证了本田就是买发动机送车的刻板印象 😂。
为什买手动挡而不是自动挡:
为什买二手车而不是新车:
二手车购买的方式无疑就两种:车商和个人,其逻辑和租房十分相似。车商就是个中介,买家和卖家的钱都赚。对于咱这种日常生活都要靠抠和薅来养家糊口的穷哥们,能省则省,所以买二手车当然不能从车商那里买啦,从个人车主那里买无疑是最具性价比的。当时在闲鱼上大概找了下面这几辆车:
车型 | 上牌年份 | 公里数 | 价格 | 城市 | 备注 |
---|---|---|---|---|---|
2016 款十代思域 FC1 | 2018 年 10 月 | 4.8 万 | 7.3 万 | 上海 | 怀疑是事故车 |
2016 款十代思域 FC1 | 2018 年 10 月 | 5.6 万 | 8 万 | 上海 | 改装件比较多 |
2019 款十代思域 FC1 | 2021 年 6 月 | 2.2 万 | 8.3 万 | 杭州 | 最终买下的车 |
2021 款十代思域 FK7 | 2021 年 1 月 | 2.8 万 | 10.8 万 | 上海 | 看中却被买走的车 |
2021 款十代思域 FK7 | 2020 年 7 月 | 3.2 万 | 11 万 | 台州 | 价格略贵,性价比低 |
十代思域大概分三厢(底盘代号 FC1)和两厢(底盘代号 FK7)两个车型,三厢 FC1 又分为 2016 款、2019 款两种,FK7 则只有 2021 款,总体上 FK7 价格比 FC1 要高出两万左右,能在接受范围。
头天晚上和卖家在微信上沟通好周六上午去看车,约好了时间和地点。周六的时候找了驾校的教练开车一块去的,还给教练买了包烟,毕竟为了帮我看车还把上午学员练车的时间都推掉了,不意思一下也不太好。
卖家把车开来后,看了下感觉外观很破,翼子板和保险杠都有十分明显的破损,内饰磨损的十分严重,内饰门把手和扶手箱都磨出皮了,比十几万公里的教练车磨损的还严重。心想就这磨损程度公里也不会太低,不可能五万不到。于是就和卖家商量了一下找个第三方的监测机构验一下车,看看车真实的车况。卖家也是同意了,不过他支支吾吾地说一会有事要离开,还是约到下午吧。
中午的时候就在闲鱼上找了个验车的师傅,和卖家越好下午 5 点开到一家汽修店验车。验车的定金都付好了,下午的时候卖家居然把我给鸽了,说什么都不来验车。结合上午的看车经历,感觉这辆大概率是辆事故车,估计车主也心虚了,不敢来验车。
自从有了第一次看车的教训后面也就谨慎多了,再三纠结之后就选择了杭州的这辆 2019 款十代思域 FC1。虽然看上了 10.8 万的 FK7 ,无奈手慢,卖家已经把车卖掉了,只能选择这辆在杭州的 FC1 了。由于车管所周六日不上班,所以就约好周五开来看车然后再去过户。周四和 leader 请了一天假,下班后就坐动车去了杭州,在杭州住了一晚。晚上在闲鱼上花了 299 找了个二手车检测的师傅,并约好了明天的看车地点。
第二天看车的时候约在了一个修理厂附近,看车师傅先是拿一个读取 ECU 数据的设备看了下发送机的运行时间,以及变速箱里记录的行驶里程。结合 4S 店维保记录可以确定这不是一辆调表车。一般一手的个人车主卖车很少会有调表的情况,车商那里的二手车买到调表车的概率极大。然后师傅又拿漆膜仪排查里下车辆整体钣金维修的情况,也基本和车主描述的一致,没有大事故只有几处刮蹭。最后在修理店举升机上看了下发送机底盘,也没啥大碍。前前后后验车大概用了一个多小时,各个方面看的都十分仔细,花点钱请个专业的师傅来看还是挺值的得。
大概上午九点左右看完车后,觉着没啥问题就和车主一块去车管所办理过户手续。车主的车牌是嘉兴的,而我刚好也想挂嘉兴的车牌。因为上海的燃油车蓝牌拍卖至少要 10 万,而且什么时候能拍到也不确定,所以挂浙江或者江苏的车牌是最合适的。虽然上海有外牌限行政策,但考虑到我工作日也很少会用到车,也就是节假日开车出去玩,所以外牌限行对咱来说也没啥问题。
在嘉兴过户需要在 12123 上提前预约,没有预约的话是无法进入到车管所进行验车的,而那天的预约名额已经没有了,本以为要等到下周一才能过户了。还好车主帮忙找了个黄牛帮忙办理的过户,不用提前预约,就能把车开进车管所验车。用了不到一个小时就办完了所有手续,还是黄牛帮忙办比较省心。
拿到新的行驶证,这辆车就属于咱的啦。终于有了一辆自己的车,虽然是辆二手车但感觉还是很开心 🥳。把车主送到高铁站,道别之后就自己一个人从嘉兴开车回上海。因为还在实习期不能单独上高速,再加上自从考完科三后一个多月没再看过车,自己一个人也不敢上高速,就走国道省道慢慢地开回了上海。当天的感觉就是没想到买辆二手车这么顺利,没出啥意外。头天晚上聊好价钱,第二天上午看车并顺利过户,下午顺利开回上海,期间没出啥幺蛾子。
根据个人买车的经历,分享几条二手车避坑的建议,如果你有买二手车的打算,可以参考一下:
车子买来后,就开始给自己的爱车进行合法改装,升级一下装备。从 《Nova Kwok 的思域 FK7 改车笔记》 那里偷来一个类似的表格:
名称 | 时间 | 价格 (CNY) | 推荐指数 |
---|---|---|---|
本田原厂胎压监测 | 2023 年 9 月 | 530+300 工时费用 | ★★★★★ |
马牌 UC7 (215/55/R16) | 2023 年 11 月 | 709*4=2836 | ★★★★ |
MX72 前后刹车片 | 2023 年 11 月 | 2300+300 工时费 | ★★★ |
endless S-Four 刹车油 | 2023 年 11 月 | 320+ 100 工时费 | ★★★ |
碳纤维方向盘 | 2023 年 10 月 | 480(原厂置换) | ★★ |
RAZO 踏板 | 2023 年 9 月 | 320+100 工时费 | ★★★ |
雨刷 | 2023 年 9 月 | 180 | ★★ |
顶吧 | 2023 年 10 月 | 175 | ★★ |
发动机防护板 | 2023 年 10 月 | 210 | ★★ |
Type R 排挡头 | 2023 年 9 月 | 180 | ★★★★ |
后备箱隔音棉 | 2023 年 11 月 | 90 | ★★★★ |
达芬奇车机助手 | 2023 年 9 月 | 168 | ★★ |
车开回来的第二天就在淘宝上买了达芬奇车机助手,破解了原厂的车机。刚开始用的时候感觉很新奇,一堆花里胡哨的功能,但后来很少用了。目前最大的用途就是自定义仪表盘,把老婆头像放在上面,就没有别的用途了。之前有用过一段时间在仪表盘上显示导航,但需要连接手机热点才能支持,如果用 U 盘离线地图的话会和 CarPlay 冲突,使用起来并不是很方便就没再使用了。
本田原厂的胎压检测,十分推荐,安装在方向盘左下侧的原厂预留位置,从原厂预留的取电插口取电,要比一些 USB 供电的胎压监测美观很多。
当时换完胎压监测后,修车师傅忘记给左前轮打胎压了,我一直以为是胎压传感器的问题,就没有去管。直到后来用打气筒测了一下,胎压确实只有 1.7 Bar。我还开车走高速去了一趟沈家湾码头,现在回想起来后背突然发凉,幸好没出意外,不然的话可能从东海大桥那里爆胎掉到海里了 😨。
原厂排挡头的材质是橡胶塑料,感觉摸起来不是很爽,就在换成了全铝合金的排挡头,手感贼棒。唯一的缺点就是夏天高温天气时烫手 🔥、冬天冷的时候冻手 🥶。
改装的碳纤维方向盘,感觉不是很值得
国庆回来的路上在高速上经历了一次差点追尾的事故后,就感觉到车子的刹车距离有点上,刹车前半段空行程比较长。为了能提升刹车性能减少刹车距离,就准备改装一下轮胎和刹车片。由于原厂的 215/55R16 尺寸的轮胎可选择的范围比较少,比较流向的马牌 MC6 和米其林 PS4 都没有 16 寸的,为了合法改装不更改轮胎轮毂尺寸就无奈选择了马牌的 UC7 轮胎。
前后轮的刹车盘换成了 Endless MX72,顺带更换 endless S-Four 的刹车油,更换完成后感觉刹车脚感变软了,但刹车制动距离缺失比原厂好了很多。尤其是紧急急刹的时候,前半段的空行程缩短了不少。
原厂的底盘防护板只有薄薄的一层铝合金,更换成了加厚全钢的护板。更换完成后就后悔了,因为加装的护板会影响到剧烈碰撞后发动机下沉,发送机可能会由于无法下沉而突破防火墙挤入驾驶室,驾驶员有吃席的风险。其实如果用车环境比较好,比如在市区和高速上开,不开一些烂路,原厂的护板已经足够了,完全没必要更换。
原厂后备箱的隔音棉给减配掉了,在后备箱装一些大件物品的时候容易刮到上面的音响和一些螺丝。于是就加装了一个隔音棉,看起来靠谱了很多。
十代思域是要求每 5000 公里更换一次机油,由于搭载的是 1.5T 地球梦(缸内直喷涡轮增压)发动机,积碳问题会严重一些,本田官方就要求每 5000 公里加注一瓶燃油宝以缓解积碳问题,故地球梦发动机也被称之为积碳梦发动机 😂。
感觉四儿子店保养使用的机油和机滤都比较差,所以没有去 4S 店保养,自己买好机油机滤在京东养车店里上花 50 块钱就能换好。京东上搞活动的时候 HKS 0W-20 4L 机油大概需要 360 块左右、小瓶 DDR 燃油宝 100 块、本田原厂 HAMP 滤芯 48 块、更换机油机滤工时费 50 块,每次保养加起来差不多需要 550 块。
我开车比较温柔,不会大脚油门大脚刹车,所以百公里油耗基本维持在 5.7L 左右,路况好的话 5.2L 左右,上任车主的百公里油耗是 7.2L。对于一款家用买菜车,5.7L 的油耗确实不错了,如果油价能跌回 6 块,这个加油费用估计和新能源电车齐平了。
国庆回来之后,和同事一起入坑了户外徒步,买了各种户外徒步的装备:徒步鞋、徒步包、水袋、速干衣、速干袜、冲锋衣等。每到周六的时候和三四个同事自驾去江浙沪附近徒步,再也不像以前那样周末只会宅在家里玩泥巴了。同事们已经在计划明年在公司成立一个户外徒步俱乐部,每次和同事出来徒步游玩的时候都有一种自费团建的感觉 😂。
10 月底的时候,长兴岛郊野公园有个柑橘节,于是和同事商量着去公园采摘橘子。那天周六中午去的,郊野公园对面的荒地停车场停的车也是满满的,公园里面露营的人也很多。好在整个公园的占地面积比较大,虽然人多但也不会感觉到拥挤。
当时和同事报的华东户外的周末团去的,本想着去看四明山秋景,但领队选的路线很一般,路上没有任何秋景可看,走的都是防火道,各种陡坡。期间还有两个大聪明在关门点没到达被领队劝回下撤后,又偷偷跟在了后面,领队没有发现他俩。由于他们两个体力不行等到回去的时候他们还没有回到集合店,等了一个多小时这两个大聪明才联系领队说天黑找不到回去的路了。最终留下了两个领队带去山上找他们,其他人跟车回上海。
由于两个大聪明不听从领队的安排,导致到了七点半多领队才让司机回上海,而对于大型客车,8 点过后是要限速 80KM/h 的,最终回到上海市已经十一点半了。也就是从这之后,我和同事再也不想报团去徒步了,不想再遇到一些自以为是的大聪明。
十一月中旬和同事带着他女朋友一块去的,就是想去看那里的银杏,不过去风景没有想象中的那么好。很多高大的银杏树叶子还不够黄,但矮小的银杏树叶子都快掉光了,没找到那种一片金黄的银杏林。
十二月上旬去的,石井水库周边的水杉林风景确实不错,这也是去过众多徒步路线中体验最好的一个景点了。
十二月中旬去的王位山,距离莫莫干山比较近,当时想去龙王山看雪景和雾凇,但怕技术不好在雪天开车容易翻车,就改去了王位山。这条路线上人很少,到达王位山山顶后,有一段有雾凇的竹林,风景还可以。虽然比不上长白山般的浙西天地,但好在人少,体验也不错。
十一月初时和同事一块去的,把车停在雪窦岭景区里的停车场。然后再爬山走到雪窦岭山上的水库看水衫,但感觉风景一般,没有石井水库的水杉好看些。从雪窦岭山上下来后就围着千年香榧林景区来了个大环线,当天大概走了 20 公里。初冬时节,山上也没啥好看的风景,好在路比较好走些。
]]>默认情况下,docker build 只能构建出与 docker 主机相同 CPU 架构的容器镜像。如果要在同一台主机上构建多 CPU 架构的镜像,需要配置 qemu 或 binfmt。例如,在 amd64 主机上构建 arm64 架构的镜像,可以使用 tonistiigi/binfmt 项目,在主机上运行 docker run --privileged --rm tonistiigi/binfmt --install arm64
命令来安装一个 CPU 指令集的模拟器,以处理不同 CPU 架构之间的指令集翻译问题。同样我们在 GitHub 上通过 GitHub Action 提供的 runner 来构建多 CPU 架构的容器镜像,也是采用类似的方式。
- name: Set up QEMU uses: docker/setup-qemu-action@v2- name: Set up Docker Buildx uses: docker/setup-buildx-action@v2- name: Build open-vm-tool rpms to local uses: docker/build-push-action@v2 with: context: . file: Dockerfile platforms: linux/${{ matrix.arch }} outputs: type=local,dest=artifacts
然而,这种方式构建多 CPU 架构的镜像存在着比较严重的性能问题。尤其是在编译构建一些 C/C++ 项目时,由于 CPU 指令需要翻译的问题,会导致编译速度十分慢缓慢。例如,使用 GitHub 官方提供的机器上构建 open-vm-tools 这个 RPM 包,构建相同 CPU 架构的 amd64 镜像只需要不到 10 分钟就能完成,而构建异构的 arm64 镜像则接近一个小时,构建速度相差 6 倍之多。如果将 arm64 的镜像放到相同 CPU 架构的主机上来构建,构建时间和 amd64 差不太多。
由此可见,在同一台机器上构建异构的容器镜像有着比较严重的性能问题。因此构建多 CPU 架构的容器镜像性能最好的方案就是在对应 CPU 架构的机器上来构建,这种原生的构建方式由于没有 CPU 指令翻译这一开销性能当然是最棒滴,这种方式也被称之为 native nodes provide。
BuildKit 是一个将 source code 通过自定义的构建语法转换为 build artifacts 的开源构建工具,被称为下一代镜像构建工具。同时它也是 docker 的一部分,负责容器镜像的构建。我们平时使用 docker build 命令时就是它负责后端容器镜像的构建。BuildKit 它支持四种不同的驱动来执行镜像的构建:
不同的驱动所支持的特性也不太一样:
Feature | docker | docker-container | kubernetes | remote |
---|---|---|---|---|
Automatically load image | ✅ | |||
Cache export | Inline only | ✅ | ✅ | ✅ |
Tarball output | ✅ | ✅ | ✅ | |
Multi-arch images | ✅ | ✅ | ✅ | |
BuildKit configuration | ✅ | ✅ | Managed externally |
如果想要使用原生方式构建多 CPU 架构的容器镜像,则需要为 BuildKit 创建多个不同的 driver。同时,由于该构建方案运行在 Kubernetes 集群上,我们当然是采用 Kubernetes 这个 driver 啦。然而,这要求 Kubernetes 集群必须是一个异构集群,即集群中的 node 节点必须同时包含对应 CPU 架构的机器。然而,这也引出了另一个尴尬难题:目前主流的 Kubernetes 部署工具对异构 Kubernetes 集群的支持并不是十分完善,因为异构的 kubernetes 集群有点奇葩需求不多的缘故吧。在此,咱推荐使用 k3s 或 kubekey 来部署异构 Kubernetes 集群。
其实在 kubernetes 集群中部署 buildkit 官方是提供了一些 manifest,不过并不适合我们现在的这个场景,因此我们使用 buildx 来部署。Buildx 是一个 Docker CLI 插件,它扩展了 docker build 命令的镜像构建功能,完全支持 BuildKit builder 工具包提供的特性。它提供了与 docker build 相似的操作体验,并增加了许多新的构建特性,例如多架构镜像构建和并发构建。
在部署 BuildKit 前我们需要先把异构的 kubernetes 集群部署好,部署的方式和流程本文就不在赘述了,可以参考 k3s 或 kubekey 的官方文档。部署好之后我们将 kubeconfig 文件复制到本机并配置好 kubectl 连接这个 kubernetes 集群。
$ kubectl get node -o wide --show-labelsNAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME LABELSproduct-builder-ci-arm-node-02 Ready <none> 11d v1.26.3+k3s1 192.168.26.20 <none> Ubuntu 22.04.1 LTS 5.15.0-69-generic containerd://1.6.19-k3s1 beta.kubernetes.io/arch=arm64,beta.kubernetes.io/os=linux,kubernetes.io/arch=arm64,kubernetes.io/hostname=product-builder-ci-arm-node-02,kubernetes.io/os=linuxcluster-installer Ready control-plane,master 11d v1.26.3+k3s1 192.168.28.253 <none> Ubuntu 20.04.2 LTS 5.4.0-146-generic containerd://1.6.19-k3s1 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=cluster-installer,kubernetes.io/os=linux,node-role.kubernetes.io/control-plane=true,node-role.kubernetes.io/master=true
准备好 kubernetes 集群后我们我们还需要安装 docker-cli 以及 buildx 插件
# 安装 docker,如果已经安装可以跳过该步骤$ curl -fsSL https://get.docker.com -o get-docker.shsudo sh get-docker.sh# 安装 buildx docker-cli 插件$ BUILDX_VERSION=v0.10.4$ mkdir -p $HOME/.docker/cli-plugins$ wget https://github.com/docker/buildx/releases/download/$BUILDX_VERSION/buildx-$BUILDX_VERSION.linux-amd64$ mv buildx-$BUILDX_VERSION.linux-amd64 $HOME/.docker/cli-plugins/docker-buildx$ chmod +x $HOME/.docker/cli-plugins/docker-buildx$ docker buildx versiongithub.com/docker/buildx v0.10.4 c513d34049e499c53468deac6c4267ee72948f02
接着我们参考 docker buildx create 和 Kubernetes driver 文档在 kubernetes 集群中部署 amd64 和 arm64 CPU 架构对应的 builder。
# 创建一个单独的 namespace 来运行 buildkit$ kubectl create namespace buildkit --dry-run=client -o yaml | kubectl apply -f -# 创建 linux/amd64 CPU 架构的 builder$ docker buildx create \ --bootstrap \ --name=kube \ --driver=kubernetes \ --platform=linux/amd64 \ --node=builder-amd64 \ --driver-opt=namespace=buildkit,replicas=2,nodeselector="kubernetes.io/arch=amd64"# 创建 linux/arm64 CPU 架构的 builder$ docker buildx create \ --append \ --bootstrap \ --name=kube \ --driver=kubernetes \ --platform=linux/arm64 \ --node=builder-arm64 \ --driver-opt=namespace=buildkit,replicas=2,nodeselector="kubernetes.io/arch=arm64"# 查看 builder 的 deployment 是否正常运行$ kubectl get deploy -n buildkitNAME READY UP-TO-DATE AVAILABLE AGEbuilder-amd64 2/2 2 2 60sbuilder-arm64 2/2 2 2 30s# 最后将 docker 默认的的 builder 设置为我们创建的这个$ docker buildx use kube
docker buildx create 参数
名称 | 描述 |
---|---|
--append | 追加一个构建节点到 builder 实例中 |
--bootstrap | builder 实例创建后进行初始化启动 |
--buildkitd-flags | 配置 buildkitd 进程的参数 |
--config | 指定 BuildKit 配置文件 |
--driver | 指定驱动 (支持: docker , docker-container , kubernetes ) |
--driver-opt | 驱动选项 |
--leave | 从 builder 实例中移除一个构建节点 |
--name | 指定 Builder 实例的名称 |
--node | 创建或修改一个构建节点 |
--platform | 强制指定节点的平台信息 |
--use | 创建成功后,自动切换到该 builder 实例 |
--driver-opt
kubernetes driver 参数
Parameter | Description |
---|---|
image | buildkit 的容器镜像 |
namespace | buildkit 部署在哪个 namespace |
replicas | deployment 的副本数 |
requests.cpu | pod 的资源限额配置,如果并发构建的任务比较多建议多给点或者不配置 |
requests.memory | 同上 |
limits.cpu | 同上 |
limits.memory | 同上 |
nodeselector | node 标签选择器,这里我们给对应 CPU 架构的 builder 添加上 kubernetes.io/arch=$arch 这个 node 标签选择器来限制运行在指定节点上。 |
tolerations | 污点容忍配置 |
rootless | 是否选择 rootless 模式。不过要求 kubernetes 版本在 1.19 以上并推荐使用 Ubuntu 内核 Using Ubuntu host kernel is recommended。个人感觉 rootless 模式限制比较多而且也有一堆问题,不建议使用。 |
loadbalance | 负载均衡模式,无特殊要求使用默认值即可。 |
qemu.install | 是否安装 qemu 以支持在同一台机器上构建多架构的镜像,这种方式就倒车回去了,违背了我们这个方案的初衷,不建议使用 |
qemu.image | qemu 模拟器的镜像,不建议使用 |
部署好之后我们运行 docker buildx inspect
就可以查看到 builder 的详细信息
$ docker buildx inspect kubeName: kubeDriver: kubernetesLast Activity: 2023-04-19 00:27:57 +0000 UTCNodes:Name: builder-amd64Endpoint: kubernetes:///kube?deployment=builder-amd64&kubeconfig=Driver Options: nodeselector="kubernetes.io/arch=amd64" replicas="2" namespace="buildkit"Status: runningBuildkit: v0.11.5Platforms: linux/amd64*, linux/amd64/v2, linux/amd64/v3, linux/386Name: builder-amd64Endpoint: kubernetes:///kube?deployment=builder-amd64&kubeconfig=Driver Options: replicas="2" namespace="buildkit" nodeselector="kubernetes.io/arch=amd64"Status: runningBuildkit: v0.11.5Platforms: linux/amd64*, linux/amd64/v2, linux/amd64/v3, linux/386Name: builder-arm64Endpoint: kubernetes:///kube?deployment=builder-arm64&kubeconfig=Driver Options: image="docker.io/moby/buildkit:v0.11.5" namespace="buildkit" nodeselector="kubernetes.io/arch=arm64" replicas="2"Status: runningBuildkit: v0.11.5Platforms: linux/arm64*Name: builder-arm64Endpoint: kubernetes:///kube?deployment=builder-arm64&kubeconfig=Driver Options: nodeselector="kubernetes.io/arch=arm64" replicas="2" image="docker.io/moby/buildkit:v0.11.5" namespace="buildkit"Status: runningBuildkit: v0.11.5Platforms: linux/arm64*
同时 buildx 会在当前用户的 ~/.docker/buildx/instances/kube
路径下 生成一个 json 格式的配置文件,通过这个配置文件再加上 kubeconfig 文件就可以使用 buildx 来连接 buildkit 构建镜像啦。
{ "Name": "kube", "Driver": "kubernetes", "Nodes": [ { "Name": "builder-amd64", "Endpoint": "kubernetes:///kube?deployment=builder-amd64&kubeconfig=", "Platforms": [ { "architecture": "amd64", "os": "linux" } ], "Flags": null, "DriverOpts": { "namespace": "buildkit", "nodeselector": "kubernetes.io/arch=amd64", "replicas": "2" }, "Files": null }, { "Name": "builder-arm64", "Endpoint": "kubernetes:///kube?deployment=builder-arm64&kubeconfig=", "Platforms": [ { "architecture": "arm64", "os": "linux" } ], "Flags": null, "DriverOpts": { "image": "docker.io/moby/buildkit:v0.11.5", "namespace": "buildkit", "nodeselector": "kubernetes.io/arch=arm64", "replicas": "2" }, "Files": null } ], "Dynamic": false}
我们将 buildx 生成的配置文件创建为 configmap 保存在 kubernetes 集群中,后面我们需要将这个 configmap 挂载到 pod 里。
$ kubectl create cm buildx.config --from-file=data=$HOME/.docker/buildx/instances/kube
是骡子是马拉出来遛遛,我们就以构建 open-vm-tools-oe2003 RPM 为例来验证一下咱的这个方案究竟靠不靠谱 🤣。这个项目是给某为的 openEuler 2003 构建 open-vm-tools rpm 包用的 Dockerfile 如下。
FROM openeuler/openeuler:20.03 as builderRUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && \ dnf install rpmdevtools* dnf-utils -y && \ rpmdev-setuptree# clone open-vm-tools source code and update spec file for fixes oe2003 build errorARG COMMIT_ID=8a7f961ARG GIT_REPO=https://gitee.com/src-openeuler/open-vm-tools.gitWORKDIR /root/rpmbuild/SOURCESRUN git clone $GIT_REPO . && \ git reset --hard $COMMIT_ID && \ sed -i 's#^%{_bindir}/vmhgfs-fuse$##g' open-vm-tools.spec && \ sed -i 's#^%{_bindir}/vmware-vmblock-fuse$##g' open-vm-tools.spec && \ sed -i 's#gdk-pixbuf-xlib#gdk-pixbuf2-xlib#g' open-vm-tools.spec# install open-vm-tools rpm build dependenciesRUN yum-builddep -y open-vm-tools.specRUN rpmbuild --define "dist .oe1" -ba open-vm-tools.spec --quiet# download rpm runtime dependenciesFROM openeuler/openeuler:20.03 as depCOPY /root/rpmbuild/RPMS/ /root/rpmbuild/RPMS/RUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && \ dnf install -y --downloadonly --downloaddir=/root/rpmbuild/RPMS/$(arch) /root/rpmbuild/RPMS/$(arch)/*.rpm# copy rpms to localFROM scratchCOPY /root/rpmbuild/RPMS/ /COPY /root/rpmbuild/RPMS/ /
其中 RUN rpmbuild --define "dist .oe1" -ba open-vm-tools.spec --quiet
这个步骤是构建和编译 RPM 里的二进制文件因此十分耗费 CPU 资源,也是整个镜像构建最耗时的一部分。
# copy rpms to localFROM scratchCOPY /root/rpmbuild/RPMS/ /COPY /root/rpmbuild/RPMS/ /
因为我们构建的目标产物是 RPM 包文件并不需要把镜像 push 到镜像仓库中,所以 Dockerfile
最后面这一段是为了将构建产物捞出来输出到我们本地的目录上,buildx 对应的参数就是 --output type=local,dest=path
。同时为了排除 cache 的影响,我们再加上 --no-cache
参数构建过程中不使用缓存。接着我们运行 docker build 命令进行构建,看一下构建的用时是多久 🤓
DOCKER_BUILDKIT=1 docker buildx build \--no-cache \--ulimit nofile=1024:1024 \--platform linux/amd64,linux/arm64 \-f /root/usr/src/github.com/muzi502/open-vm-tools-oe2003/Dockerfile \--output type=local,dest=/root/usr/src/github.com/muzi502/open-vm-tools-oe2003/output \/root/usr/src/github.com/muzi502/open-vm-tools-oe2003[+] Building 364.6s (30/30) FINISHED => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 1.35kB 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 1.35kB 0.0s => [linux/amd64 internal] load metadata for docker.io/openeuler/openeuler:20.03 2.1s => [linux/arm64 internal] load metadata for docker.io/openeuler/openeuler:20.03 2.1s => [auth] openeuler/openeuler:pull token for registry-1.docker.io 0.0s => [auth] openeuler/openeuler:pull token for registry-1.docker.io 0.0s => CACHED [linux/arm64 builder 1/6] FROM docker.io/openeuler/openeuler:20.03@sha256:4aef44f5d6af7b07b02a9a3b29cbac5f1f109779209d7649a2e 0.0s => => resolve docker.io/openeuler/openeuler:20.03@sha256:4aef44f5d6af7b07b02a9a3b29cbac5f1f109779209d7649a2ea196a681a52ee 0.0s => [linux/arm64 builder 2/6] RUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && 54.6s => CACHED [linux/amd64 builder 1/6] FROM docker.io/openeuler/openeuler:20.03@sha256:4aef44f5d6af7b07b02a9a3b29cbac5f1f109779209d7649a2e 0.0s => => resolve docker.io/openeuler/openeuler:20.03@sha256:4aef44f5d6af7b07b02a9a3b29cbac5f1f109779209d7649a2ea196a681a52ee 0.0s => [linux/amd64 builder 2/6] RUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && 65.1s => [linux/arm64 builder 3/6] WORKDIR /root/rpmbuild/SOURCES 0.3s => [linux/arm64 builder 4/6] RUN git clone https://gitee.com/src-openeuler/open-vm-tools.git . && git reset --hard 8a7f961 && s 1.8s => [linux/arm64 builder 5/6] RUN yum-builddep -y open-vm-tools.spec 58.8s => [linux/amd64 builder 3/6] WORKDIR /root/rpmbuild/SOURCES 0.3s => [linux/amd64 builder 4/6] RUN git clone https://gitee.com/src-openeuler/open-vm-tools.git . && git reset --hard 8a7f961 && s 2.1s => [linux/amd64 builder 5/6] RUN yum-builddep -y open-vm-tools.spec 71.9s => [linux/arm64 builder 6/6] RUN rpmbuild --define "dist .oe1" -ba open-vm-tools.spec --quiet 175.2s => [linux/amd64 builder 6/6] RUN rpmbuild --define "dist .oe1" -ba open-vm-tools.spec --quiet 181.4s => [linux/arm64 dep 2/3] COPY --from=builder /root/rpmbuild/RPMS/ /root/rpmbuild/RPMS/ 0.1s => [linux/arm64 dep 3/3] RUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && dnf 31.6s => [linux/amd64 dep 2/3] COPY --from=builder /root/rpmbuild/RPMS/ /root/rpmbuild/RPMS/ 0.1s => [linux/amd64 dep 3/3] RUN sed -i "s#repo.openeuler.org#repo.huaweicloud.com/openeuler#g" /etc/yum.repos.d/openEuler.repo && dnf 39.2s => [linux/arm64 stage-2 1/2] COPY --from=dep /root/rpmbuild/RPMS/ / 0.1s => [linux/arm64 stage-2 2/2] COPY --from=builder /root/rpmbuild/RPMS/ / 0.2s => exporting to client directory 2.4s => => copying files linux/arm64 35.93MB 2.3s => [linux/amd64 stage-2 1/2] COPY --from=dep /root/rpmbuild/RPMS/ / 0.1s => [linux/amd64 stage-2 2/2] COPY --from=builder /root/rpmbuild/RPMS/ / 0.2s => exporting to client directory 1.6s => => copying files linux/amd64 36.59MB 1.6stree rpms
用时对比 | amd64 (Intel(R) Xeon(R) Silver 4110 CPU @ 2.10GHz) | arm64(HUAWEI Kunpeng 920 5250 2.6 GHz) |
---|---|---|
yum-builddep | 71.9s | 58.8s |
rpmbuild | 181.4s | 175.2s |
通过上面的构建用时对比可以看到 arm64 的机器上构建比 amd64 要快一点,是由于 Kunpeng 920 5250 CPU 主频比 Intel Xeon 4110 高的缘故,如果主频拉齐的话二者的构建速度应该是差不多的。可惜我们 IDC 内部的机器 CPU 大多是十几块钱包邮还送硅脂的钥匙串(某宝上搜 E5 v3/v4)找不到合适的机器进行 PK 对比,大家自己脑补一下吧🥹,要不汝给咱点 CPU 😂。
总之我们这套方案实现的效果还是蛮不错滴,比用 qemu 模拟多架构的方式不知道高到哪里去了 🤓。
首先,我们需要定制自己的 Jenkins slave pod 的基础镜像,将 docker 和 buildx 这两个二进制工具添加进来。需要注意的是,这里的 docker 命令行只是作为客户端使用,因此我们可以直接从 docker 的官方镜像中提取此二进制文件。不同的项目需要不同的工具集,可以参考我的 Dockerfile。
FROM python:3.10-slimARG BUILDER_NAME=kubeCOPY /usr/local/bin/docker /usr/local/bin/dockerCOPY /buildx /usr/libexec/docker/cli-plugins/docker-buildx
这里还有一个冷门的 Dockerfile 的小技巧:通过 COPY --from=
的方式来下载一些二进制工具。基本上我写的 Dockerfile 都会用它,可谓是屡试不爽 身经百战了
😎。别再用 wget/curl 这种方式傻乎乎地安装这些二进制工具啦,一句 COPY --from=
不知道高到哪里去了。
分享一个比较冷门的 Dockerfile 的小技巧:
— Reimu (@muzi_ii) May 6, 2022
当你要安装一个 binary 工具时(比如 jq、yq、kubectl、helm、docker 等等),可以考虑直接从它们的镜像里 COPY 过来,替代使用 wget/curl 下载安装的方式,比如:
COPY --from=docker:20.10.12-dind-rootless /usr/local/bin/docker /usr/local/bin/docker pic.twitter.com/4ZWFqk5EEv
接下来,我们需要自定义 Jenkins Kubernetes 插件的 Pod 模板,将我们上面创建的 buildx 配置文件的 configMap 通过 volume 挂载到 Pod 中。这个 Jenkins slave Pod 就可以在 k8s 中通过 Service Accounts 加上 buildx 配置文件来连接 buildkit 了。可以参考我这个 Jenkinsfile。
// Kubernetes pod template to run.podTemplate( cloud: JENKINS_CLOUD, namespace: POD_NAMESPACE, name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podmetadata: annotations: kubectl.kubernetes.io/default-container: runnerspec: nodeSelector: kubernetes.io/arch: amd64 containers: - name: runner image: ${POD_IMAGE} imagePullPolicy: Always tty: true volumeMounts: # 将 buildx 配置文件挂载到当前用户的 /root/.docker/buildx/instances/kube 目录下 - name: buildx-config mountPath: /root/.docker/buildx/instances/kube readOnly: true subPath: kube env: - name: HOST_IP valueFrom: fieldRef: fieldPath: status.hostIP - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "docker.io/jenkins/inbound-agent:4.11.2-4-alpine" imagePullPolicy: IfNotPresent volumes: # 配置 configmap 挂载 - name: buildx-config configMap: name: buildx.config items: - key: data path: kube"""
当 Jenkins slave pod 创建好之后,我们还需要进行一些初始化配置,例如设置 buildx 和登录镜像仓库等。我们可以在 Jenkins pipeline 中增加一个 Init 的 stage 来完成这些操作。
stage("Init") { withCredentials([usernamePassword(credentialsId: "${REGISTRY_CREDENTIALS_ID}", passwordVariable: "REGISTRY_PASSWORD", usernameVariable: "REGISTRY_USERNAME")]) { sh """ # 将 docker buildx build 重命名为 docker build docker buildx install # 设置 buildx 使用的 builder,不然会默认使用 unix:///var/run/docker.sock docker buildx use kube # 登录镜像仓库 docker login ${REGISTRY} -u '${REGISTRY_USERNAME}' -p '${REGISTRY_PASSWORD}' """ }}
构建镜像时,我们可以在 buildkit 部署节点上运行 pstree 命令,来查看构建的过程。
root@product-builder-master:~# pstree -l -c -a -p -h -A 2637buildkitd,2637 |-buildkit-runc,989505 --log /var/lib/buildkit/runc-overlayfs/executor/runc-log.json --log-format json run --bundle /var/lib/buildkit/runc-overlayfs/executor/82zvcfesf5g19t2682g3j9hrr 82zvcfesf5g19t2682g3j9hrr | |-rpmbuild,989519 --define dist .oe1 -ba open-vm-tools.spec --quiet | | `-sh,989562 -e /var/tmp/rpm-tmp.xKly7N | | `-make,995708 -O -j64 V=1 VERBOSE=1
通过 buildkitd 的进程树,我们可以看到 buildkitd 进程中有一个 buildkit-runc 的子进程。它会在一个 runc 容器中运行 Dockerfile 中对应的命令。因此,我们可以得知 buildkit on kubernetes 和之前的 docker in pod 实现原理是类似的,只不过这里的 buildkit 只用于构建镜像而已。
2022 依旧是人类社会倒车和加速灭亡的一年。不过有幸的是,在经济下行压力、白纸运动抗议、国际共存舆论等众多因素的影响下,年末当权者终于叫停了这场政治运动型的防疫闹剧,底层的屁民韭菜们终于有了口喘气活下去的机会。回想起去年的现在,还被封在家里、还在抢菜、还在为明天吃什么发愁、还在担心这场荒诞至极的防疫闹剧什么时候能结束、还在担心这场闹剧的现实会一直持续下去。回想起那段时间唯独两天一次的支性检测不敢半点耽搁、老老实实做核酸、戴口罩、出示健康码、被训得服服帖帖,可谓是奴(zhi)性十足。
即便是去年六月初上海解封后,依旧没有从那种恐惧中解脱出来,反而变得越来越自闭,直到现在此刻的心态和上海封城那段时间没有太大区别。思想审查、文字狱、集中营、白色恐怖、谎言欺骗,这些并没有因为疫情结束而消失,每天的感触就像是在历史与现实的夹缝中苟活,对未来充满着无限的恐惧。
或许我们已经早已经听惯了你不关心政治政治会来关心你这句废话,但当经历了这场灾难(防疫闹剧)之后,我们或多或少地能感触到一个国家的政治气候的变化是如何深刻地影响和限制人类生存的状态和生存的可能。在某些性命攸关的时刻,政治它直接或间接地决定了我们还能不能在这个世界生存下去。
从去年上海封城到现在,在这一年多的时间里,博客已经很少再更新了,也慢慢地淡出了推特。除了正常的工作生活外,大部分时间和精力都是在研究和思考这个国家和社会为什么会上演着一场场荒诞至极的防疫闹剧。结合这三年防疫闹剧期间所发生、暴露出的一切,以及自己的生活感受,我越来越有一种想探究这场防疫闹剧的政治基础是如何一步步建立起来的想法。于是近期就整理了一些最近一年多的时间里所读的书以及一些个人的想法。
第一次读这本书是在 2021 年五一假期期间,那时我正在 2021 五一假期环太湖骑行之旅中。2022 年上海封城后,我又重新读了一遍。再次读这本书的动机,已经想不太清了。我记得当时看到了一些令人匪夷所思的防疫闹剧,比如某个小区的防疫工作人员把居民团购买的菜扔进垃圾桶里,这种防疫闹剧令我十分费解。我不能理解大白和红袖章这些群体以防疫为名拥有了一点权力后就会上演着一场场令人匪夷所思的防疫闹剧。
还有就是当时在推特上看到了一个以防疫为名殴打村民的短视频,让我再一次想起了我的另一段切身经历:在 2020 年武汉肺炎刚爆发的时候,我外婆在大年初六那天不幸离开了这个世界。依稀记得那天去我外婆家的路也被封得严严实实,没办法开车去,只能骑电动车。在村与村交界的路上被当地的红袖章拦住不让过,给他说明了原因也死活不让过。真是欺人太甚,气得我直接踹开挡板骑车电车硬闯了过去。随后红袖章拨打了当地派出所的电话,两辆警车十几号人来抓捕我。当时一直就想不清为什么一个普通的村民戴上红袖章之后就如此膨胀,直到读了这本书后我慢慢地想通了。
这本书向人们展示了中国社会普遍存在的一个现象:社会上到处表现出冤冤相报的敌意。中国的专制制度从公元前 221 年秦始皇统一中国开始,沿袭了两千多年,有着丰厚的历史积淀。即便是在今天,权力仍垄断在专制统治阶层手中,让普通民众享有权力十分困难,而当以叫魂或防疫为名的“幻觉权力”进入社会之后,普通民众就拥有了互相报复的武器。他们所能爆发出来的威力正如书中所描写的那样恐怖。
一旦官府认真发起对妖术的清剿,普通人就有了很好的机会来清算宿怨或谋取私利。这是扔在大街上的上了膛的武器,每个人——无论恶棍或良善——都可以取而用之。在这个权力对普通民众来说向来稀缺的社会里,以“叫魂”罪名来恶意中伤他人成了普通人的一种突然可得的权力。对任何受到横暴的族人或贪婪的债主逼迫的人来说,这一权力为他们提供了某种解脱;对害怕受到迫害的人,它提供了一块盾牌;对想得到好处的人,它提供了奖赏;对妒嫉者,它是一种补偿;对恶棍,它是一种力量;对虐待狂,它则是一种乐趣。
施行妖术和提出妖术指控所折射反映出来的是人们的无权无势状态。对一些无权无势的普通民众来说,弘历的清剿给他们带来了慷慨的机会。即使在今天,让普通民众享有权力仍是一个还未实现的许诺。毫不奇怪,冤冤相报(这是“受困扰社会”中最为普遍的社会进攻方式)仍然是中国社会生活的一个显著特点。
没有人会哀悼旧中国的官僚制度。即使按照当时的标准,它所造成的社会伤害也已超出了仅仅压碎几个无依无助的游民踝骨的程度。但不论是好事还是坏事,它的特性却可以阻挡任何一种狂热。没有这样一个应急的锚碇,中国就会在风暴中急剧偏航。在缺乏一种可行的替代制度的情况下,统治者就可以利用操纵民众的恐惧,将之转变为可怕的力量。生活于我们时代的那些异见人士和因社会背景或怪异信仰而易受指控的替罪羊,便会成为这种力量的攻击目标。
当代中国的历史中充满了这种幻觉权力进入社会的例子。我还记得 1982 年在北京与一个老红卫兵的谈话。他当时是一个低收入的服务工。他感慨地说,毛泽东的文化革命对于像他这样没有正式资格循常规途径在社会上进身的人来说是一个黄金时代,毛号召年轻人起来革命造反,这一来自顶端的突然可得的权力使他的野心得到了满足。他抱怨说,现在的社会样样都要通过考试,他再也没有希望从现在这个最底层的位置爬上去了。
最近在读高华的《历史笔记》,感触最深的就是:六十年前大饥荒时基层干部对死亡人数谎报瞒报、中央政府拒不承认事实、新闻媒体跟着愚民洗脑。再看看我们所处的时代,从武汉肺炎谎报瞒报到西安的掩耳到“零”。六十年过去了,还是熟悉的味道。
— Reimu (@muzi_ii) January 14, 2022
所以我十分相信“对未来充满希望的人往往对历史一无所知”。 pic.twitter.com/QNhH5AfxPs
《历史笔记》是我在 2022 年读的第一本书,从元旦开始阅读,花费了相当长的时间和精力才读完。这本书是高华教授的遗作,共分为上下两卷四编,繁体中文。
第一编《⾰命、内战与⺠族主义》分论国⺠党共产党两党 1949 年前各⾃的历史。作为国共内战胜利方的中共是本章的论述重点,所选文章不仅反映其革命夺权历程,还映射出 1949 年后政治实践的某些雏形。
民族主義與民主主義是一對雙胞胎,區別在於:民族主義,強調集體認同和國家認同;民主主義,強調個人本位,個人權利,個人自由。從理論上講,當國家、民族面臨嚴重的危機時,國民應讓渡出自己的一部分個人權利,以服從於國家利益,支持國家戰勝危機,而國家的最終目的是保護個人自由。但是在近代以來,民族主義經常吞噬民主主義,這主要是由中國近代的政治和大的環境造成的。也和人們認識的誤區,統治階級的狹隘和自私有關。
第⼆编《断裂与延续》主要论及⽑泽东时代,内容涵盖了三反五反、大跃进运动、四清运动、林彪事件等多个历史事件。其中十分推荐大家去仔细地去读一下《大躍進運動與國家權力的擴張》这个章节,从某种程度上我觉着这场防疫闹剧和大跃进运动背后的政治逻辑极其相似。
1958 年由毛澤東親自發動、席捲全國的大躍進運動,是一場具有空想烏托邦性質的政治運動。今天人們憶及當年的大躍進,馬上會聯想到「高產衛星」、「全民煉鋼」、「公社食堂」等帶有荒誕色彩的景象。然而大躍進並非僅僅是一場烏托邦運動,在大躍進期間,國家權力借着這場運動的推動,以前所未有的規模急速地向社會各個領域擴張。大躍進運動使國家權威得以擴大和強化,不僅深刻地改變了中國社會的面貌,也大大加強了民眾對國家權威的認知。
在大躍進期間,國家意志透過強有力的政治動員和組織措施得以全力貫徹,國家權力在這個過程中急速擴張。
與以往歷次政治運動相比,大躍進是一場規模更大的群眾性運動,這場運動不僅促使國家權威向城鄉全面滲透,而且在社會生活所有領域都建立、鞏固和強化了國家權力。
令人驚奇的是,即使到了這一步,一些領導幹部仍在繼續隱瞞饑荒的真相。周恩來以後回憶道,在 I960 年夏天召開的北戴河會議上,他本人「已經意識到糧食有問題,但大家不承認,結果把真實情況給掩蓋起來了」。
直至 1960 年 10 月,《人民日報》在國慶社論中才對形勢作出了新的解釋。社論稱,「兩年來,全國大部分地區連續遭受嚴重的自然災害,造成糧食嚴重減產」。社論並宣稱,「人民公社已使我國農民永遠擺脱了那種每遭自然災害必然有成百萬、成千萬人饑餓、逃荒和死亡的歷史命運」。社論作者當然知道,就在這篇社論發表之時,全國各地農村正在發生大面積餓死人的情況,但事實歸事實,宣傳歸宣傳,他們選擇採取了「硬着頭皮頂住」的方針。
还有《1949-1965 年中國社會的政治分層》章节里提到的向党交心运动十分有有意思,大跃进时期都是要把心交给党的,比现在安个反诈 app 把隐私交给党高到不知道哪里去了 😂。
全體教師聯合舉行改造促進大會,他們抬着「大紅心」的標誌上街遊行。4 月 4 日,南京市各高校師生與科研機關的民主人士共三千餘人,高舉「把心交給黨」、「把知識交給人民」的旗幟在南京市舉行大遊行,之後,又舉行了社會主義自我改造促進大會。4 月 21 日,南京市工商界三千多人召開大會,宣佈「立即開展向黨交心運動」,民建中央主席黃炎培親臨會場予以鼓勵。4 月 22 日,南京市工商界和民主黨派提出向黨「交心」要「快、透、深、真」的口號,表示要把「接受黨的領導和走社會主義道路的三心二意,躍進到一心一意」。江蘇省宗教界人士也開展了「交心」運動,天主教界通過「自選」、「自聖」主教,「使全省天主教出現了一個新的局面」。在「交心」運動中,全省 11 個城市民主黨派和工商界人士 4,106 人,共交心 47 萬條。據當時的記載稱,這次交心「大量暴露了他們長期隱瞞的腐朽思想和反動行為」12()。對於工商界和民主人士的「交心」,組織上規定的原則是「自梳自理,求醫會診」。先讓他們對照要求、自我批判,然後引導他們懇請黨員和領導對他們的「壞思想」有針對性地進行批評,並鼓勵他們打破庸俗的情面觀,「比先進,比幹勁」,互相展開批評和思想鬥爭,以使「交心」落在實處,防止「交心」走過場。
經過對「二十二個文件」的逐字逐句的精讀,和反復對照檢查,個人原來的小資產階級的自我意識開始分裂。隨着「發掘本心」的逐步深入,學習者普遍對自己的缺點錯誤產生了羞愧意識,出身剝削階級家庭的知識分子黨員更自慚形穢,認為自己確實如毛澤東所言,除了讀了一些如同「狗屎」般無用的書之外,對共產黨和人民的價值無多,尤其嚴重的是,剝削階級的家庭背景,甚至還會使自己在革命的關鍵時刻動搖革命立場,在客觀上危害革命!這樣的自我壓力有如大山般沉重,使許多知識分子黨員原有的沾沾自喜、驕傲自滿等不良習氣一掃而空。
按照毛澤東的看法,一個人的階級立場必然決定了他的觀點和態度。例如:你是不是在心裏還欣賞資產階級個性自由、個性解放的錯誤思想?你是否心悦誠服地把一切都獻給黨?你是否真正同意你所出身的剝削階級家庭是骯髒和反動的?你對沒有文化的工農群眾是滿心鄙夷,還是甘心做他們的小學生?你對黨的考驗是真心接受,還是抱冤叫屈?
第三编《「從『大破』走向『大立』」:文革中的「新生事物」》是高華教授生前承擔了香港中文大學中國文化研究所《中華人民共和國史》第七卷的寫作任務。他已列出該卷寫作綱要,惜乎天不假年,只完成了十餘萬字的文稿。
毛為什麼要發動「文革」?「文革」是如何發動起來的?我認為毛澤東發動「文革」有兩方面的動因,第一個因素:「文革」集中體現了毛對他所理想的社會主義的追求;第二個因素:他認為自己已大權旁落,而急於追回,這兩方面的因素互相纏繞,緊密的交融在一起。
國家的領導者為了快速建立起一個強大的社會主義的國家,他們一直在謀求一種「最好的」治理中國的制度或管理形式,他們有許多創造,建構了一種新意識形態敍述,中國傳統的思想及制度資源,革命年代的經驗與蘇聯因素融為一體,都被運用其中,被用來統合社會大眾的意識。他們也非常重視做動員、組織民眾的工作,使社會的組織化、軍事化程度不斷增強
第四編《讀書有感》包含多篇書評,論及對象既有風雲人物,也有平頭百姓,既有追隨國民黨政權遷台的作家,也有大陸人所皆知的左翼文人。本章通過對他們回憶的評議展現出多角度的時代變遷與個體感受。
我認為,學歷史、讀歷史,記住余英時先生的一段話是很重要的。他説:學歷史的好處不是光看歷史教訓,歷史教訓也是很少人接受,前面犯多少錯誤,到後面還是繼續。因為人性就是大權在握或利益在手,但難以捨棄,權力和利益的關口,有人過得去,也有人過不去。所以我認為讀歷史的最大好處是使我們懂得人性。
在大學讀書的那幾年,我知道,雖然毛澤東晚年的錯誤已被批評,但毛的極左的一套仍根深蒂固,它已滲透到當代人思想意識的深處,成為某種習慣性思維,表現在中國現代史、中共黨史研究領域,就是官學甚行,為聖人避諱,或研究為某種權威著述作注腳,幾乎成為一種流行的風尚。
親身經歷了上海封城之後再讀楊繼繩的著作《墓碑:中國六十年代大饑荒紀實》,又一次對這個社會陷入了深深的絕望之中😭。
— Reimu (@muzi_ii) June 28, 2022
任何災難都可以被用來塑造成正確的集體記憶,然後成為政權合法性的組成部分。正是這種對民族記憶的大清洗和對罪惡的強制遺忘,遂使得相同的歷史悲劇一次次不斷地重演。 pic.twitter.com/DfcE6V28Vw
我是去年上海解封后才开始读这本书的。经历了封城,我的心态已经麻木了,无论发生什么荒唐的事情,我已经习以为常了。读这本书的时候,有好几次我都想大哭一场,眼泪和鼻涕都止不住。因为我们现在所经历的悲剧在六十多年前已经发生过一次,而官僚体制应对灾难的方式和六十年前相比没有多少改变。
从武汉肺炎刚爆发时的谎报瞒报、训诫李文亮医生再到后来西安的”掩耳到零”等招式在六十年前已经使用过了。六十年前怎么应对大饥荒的,我们现在就是怎么应对防疫的,没有任何改变。
各级政府千方百计地对外封锁饥饿的消息。公安局控制了所有的邮局,向外面发出的信件一律扣留。中共信阳地委让邮局扣了 12000 多封向外求助的信。为了不让外出逃荒的饥民走漏消息,在村口封锁,不准外逃。对已经外逃的饥民则以“盲流”的罪名游街、拷打或其它惩罚。
1960 年 3 月 12 日卫生所的干部王启云写信给党中央,反映饿死人的严重问题,要求中央仿照“包文丞陈州放粮”,公安局侦破后,对王启云进行残酷的批判斗争。
伞陂公社第一次向上报的死亡人数 523 人,第二次报的是 3889 人(后又改为 2907 人),后来省委工作组调查结果是 6668 人。
用空洞的“全国形势一派大好”淡化人们实实在在的饥饿,压制人们对饥饿的不满。
就在信阳大量饿死人、人相食普遍发生的时候,《河南日报》还宣传形势一派大好,连续发表七篇“向共产主义进军”的文章。
在饿殍遍地的情况下,1960 年《河南日报》的元旦社论却以“开门红 春意浓”为题,继续粉饰太平,仍坚持全面跃进。
农民明明是饿死了,还不能说是因饥饿而死的。县委领导人赵玉书和董安春到武店公社考城大队检查浮肿病情况,问医师王善良:“为什么浮肿病总是治不好,少什么药?”王医生回答说:“少一味粮食!”赵、董二人立即决定,将王医生交大会批斗后逮捕。
在大批农民饿死的时刻,1960 年 2 月 16 日到 18 日,贵州省委召开了三天地、州、市委第一书记会议,主要讨论农村公共食堂问题。这个会不是解决食堂缺粮的问题,而是闭眼不看现实,向中共中央写了一个假报告――《关于农村公共食堂的报告》
强大的政治思想工作使人们驯服,新闻封锁使人愚昧。饿死上百万人的“信阳事件”、饿死三分之一人口的“通渭问题”,不仅当时邻近地区不知有其事,甚到几十年后还严加保密。
死人明明是饿死的,而说成是年老死的,疾病死的,把非正常死亡说成是正常死亡。有些地方还不允许死者家属哭丧带孝,不准埋坟,对反映死人情况的来信加以扣压,甚至对来信者进行打击;有的干部因为如实向组织反映了死人的情况还挨了斗争。”“因为怕犯错误,怕受处分,怕摘掉乌纱帽,而不敢暴露真实情况;越不敢暴露,问题就发展越大;问题越大,就越不敢暴露。”
上海养老院将活人装尸袋要求殡仪馆火化 这种荒唐事历史上已经发生过不止一次了
省委副秘书长周颐在雅安考察时看到不少肿得很严重的病人,问他们为什么不去医院治疗,他们说:医院条件很坏,在那里死得更快些。金堂县五星管理区的肿病医院是牛棚改的,清洁卫生没有搞彻底,臭气难闻。病房没有门,四周没有墙,90% 的病人睡地铺,铺草很薄。有的病人没被子,白天还喊冷。广汉县金鱼公社医院院长黄某,把活人装进棺材埋掉。
温江清平公社社员李方平饿得奄奄一息,县委检查团下来检查生活,管区干部怕他走漏风声,便把他关进保管室关了三天,生产队长报告说李已死,管区干部下令“死了把他埋了算球”。社员张绍春薅油菜饿倒在田头,队长以为他死了,赶快挖了个坑想把他埋了,埋到一半,张醒过来,大叫“活埋人了……”,吓得队长扔掉锄头就跑。
一些地区规定死人后“四不准”:一不准浅埋,要深埋三尺,上面种上庄稼;二不准哭;三不准埋在路旁;四不准戴孝。更恶劣的是黄湾公社张湾小队规定死了人不仅不准戴白布,还叫人披红!
二十多岁的民工任文厚被打死后,水库派人直接将尸体拉到该家坟地埋葬,父母想看上一眼都不允许。
同样为了应付上级的视察,官僚组织如何上演共谋闹剧的也是如出一辙。
为了应付上级检查,把大部分人力、畜力、肥料,都调到公路铁路两旁,调到社与社、县与县的交界处,做出样子,而里面却是大片土地抛荒。
在外宾所到之处,完全布置了一派丰饶、富裕的景象:湖里有穿着漂亮的女子悠闲地划船唱歌,在路旁的小店里食品丰富。省委所划定了外宾活动的地方,不让老百姓进入,特意布置假象欺骗外宾。
1959 年 12 月 9 日,我下放到和政县苏集公社。这里群众没有粮吃,饿得干瘦、浮肿,有的冻饿而死。榆树皮都被剥光吃掉了!有一天县上来电话,说张鹏图副省长要到康乐视察,命令我们连夜组织人把公路两边被剥光皮的榆树,统统砍掉,运到隐蔽的地方去。人都快饿死了,哪有力量去砍树、抬树?我们办不到,留下榆树正好让张鹏图副省长看看。
还有被集中隔离关进方舱的方式也是熟悉的味道
有的地方把社员自留地里的南瓜苗拔出来栽到集休的地里,结果全部死光。强占房屋,逼人搬家,不搬就强行把东西扔到外面。强行收走各家做饭的锅,甚至当着社员的面把锅砸烂了,老人要求留下一口锅烧水也不行。大通桥大队为了办农场,乘社员下地生产之机,将大通桥东头一个小庄子社员家的东西全部抛了出来,房屋由大队占领。社员无家可归,痛哭流涕。
乔山大队 31 个村庄,1960 年 6 月,总支书记梅某强迫群众在半天之内并成 6 个庄子,拆掉房子 300 多间,党员不干开除党籍,团员不干开除团籍,社员不干不给饭吃。说是建新村,实际上旧房子拆了新房子没有建,社员无家可归,100 多人被迫集中居住,有 14 户 40 人住在 3 间通连的房子里,晚上大门上锁,民兵持棍把门,尿尿拉屎都在一起。
食堂缺柴也是一个普遍问题。解决缺柴的办法一是砍树,二是拆房。全县树木被砍达 80% 以上,全县房屋倒塌和被扒 10 万间以上。有的地方挖坟劈棺当柴烧。在田野劈棺后剩下片片白骨,令人胆寒。
对大饥荒的反思也值得我们认真思考这场防疫闹剧
这是一场人类历史上空前的悲剧。在气候正常的年景,没有战争,没有瘟疫,却有几千万人死于饥饿,却有大范围的“人相食”,这是人类历史上绝无仅有的异数。
总路线,大跃进,人民公社,当时合称为“三面红旗”。这是 1958 年令中国人狂热的政治旗帜,是造成三年大饥荒的直接原因,也就是大饥荒的祸根。
的确,造成中国几千万人饿死的根本原因是极权制度。当然,我不是说极权制度必然造成如此大规模的死亡,而是说极权制度最容易造成重大政策失误,一旦出现重大政策失误又很难纠正。更重要的是,在这种制度下,政府垄断了一切生产和生活资源,出现灾难以后,普通百姓没有自救能力,只能坐以待毙。
为什么没有纠错机制?这是专制制度固有的缺陷。1958 年指导思想的错误,不仅仅是领袖和领导集团的错误,而是制度性错误。
权制度就是这样使民族性堕落。大跃进和文化大革命中,人们表现的那样疯狂,那样的残忍,正是民族性堕落的结果,也正是极权制度的“政绩”。
中国有句古话:“上有好者,下必甚焉”。这是在专制制度下,下级官员迎合上级的情形。1958 年的情况也是如此。处在一层一层的权力阶梯上的官员们,总是把最高层的意志一步一步地推向极端。
中共中央和毛泽东没有从制度、政策、指导思想方面寻找大饥荒的原因,而把大量死人的原因归罪于早已被打入十八层地狱的地、富、反、坏、右。说是因“民主革命不彻底”,而使阶级敌人篡夺基层领导权。这显然是违背事实。
以下是我个人推荐的历史书籍。在经历了三年的防疫闹剧之后,结合自己的切身经历再次阅读这些历史书籍,就会有种亲临历史的错觉,仿佛活在历史与现实的夹缝之中。这些历史事件并不遥远,就像昨天一样清晰地铭刻在我们的脑海中。
作者 | 书名 |
---|---|
徐贲 | 人以什么理由来记忆 |
周雪光 | 中国国家治理的制度逻辑:一个组织学研究 |
谢岳 | 维稳的政治逻辑 |
笑蜀 | 历史的先声:半个世纪前的承诺 |
赵紫阳 | 改革历程 |
徐中约 | 中国近代史 |
高华 | 身份和差异:1949-1965 年中国社会的政治分层 |
高华 | 在历史的风陵渡口 |
高华 | 历史笔记 |
杨继绳 | 天地翻覆:中国文化大革命史 |
杨继绳 | 中国改革年代的政治斗争 |
杨继绳 | 中国当代社会阶层分析 |
杨继绳 | 墓碑: 中国六十年代大饥荒纪实 |
橙实 山川 等 | 文革笑料集 |
【美】傅高义 | 邓小平时代 |
【美】亨利·基辛格 | 世界秩序 |
【美】亨利·基辛格 | 论中国 |
【英】乔治·奥威尔 | 1984 |
【美】孔飞力 | 叫魂:1768 年中国妖术大恐慌 |
【美】芭芭拉·德米克 | 我们最幸福:北韩人民的真实生活 |
【捷克】哈维尔 | 哈维尔文集 |
【捷克】伊凡·克里玛 | 布拉格精神 |
【白俄】S·A·阿列克谢耶维奇 | 切尔诺贝利的悲鸣 |
首先这套工具必须运行在 Linux 中才行,对于 Bare Metal 裸服务器来讲还没有安装任何 OS,这就引申出了鸡生蛋蛋生鸡的尴尬难题。虽然可以给其中的一台物理服务器安装上一个 Linux 发行版比如 CentOS,然后再将这套自动化安装 ESXi OS 的工具搭建上去,但这会额外占用一台物理服务器,客户也肯定不愿意接受。
真实的实施场景中,可行的方案就是将这套工具运行在实施人员的笔记本电脑或者客户提供的台式机上。这又引申出了一个另外的难题:实施人员的笔记本电脑或者客户提供的台式机运行的大都是 Windows 系统,在 Windows 上安装 Ansible、Make、Python3 等一堆依赖,想想就不太现实,而且稳定性和兼容性很难得到保障,以及开发环境和运行环境不一致导致一些其他的奇奇怪怪的问题。虽然该工具支持容器化运行能够解决开发环境和运行环境不一致的问题,但在 Windows 上安装 docker 也比较繁琐和麻烦。
这时候就要搬出计算机科学中的至理名言: 计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
Any problem in computer science can be solved by another layer of indirection.
既然我们这套工具目前只能在 Linux 上稳定运行,那么我们不如就将这套工具和它所运行的环境封装在一个“中间容器”里,比如虚拟机。使用者只需要安装像 VMware Workstation 或者 Oracle VirtualBox 虚拟化管理软件运行这台虚拟机不就行了。一切皆可套娃(🤣
其实原理就像 docker 容器那样,我们将这套工具和它所依赖的运行环境在构建虚拟机的时候将它们全部打包在一起,使用者只需要想办法将这个虚拟机运行起来,就能一键使用我们这个工具,不必再手动安装 Ansible 和 Python3 等一堆依赖了,真正做到开箱即用。
于是本文分享一下如何使用 Packer 在 VMware vSphere 环境上构建虚拟机镜像的方案,以及如何在这个虚拟机中运行一个 k3s 集群,然后通过 argo-workflow 工作流引擎运行 redfish-esxi-os-installer 来对裸金属服务器进行自动化安装 ESXi OS 的操作。
很早之前玩儿 VMware ESXi 的时候还没有接触到 Packer,那时候只能使用手搓虚拟机模版的方式,费时费力还容易出错,下面就介绍一下这个自动化构建虚拟机镜像的工具。
Packer 是 hashicorp 公司开源的一个虚拟机镜像构建工具,与它类似的工具还有 OpenStack diskimage-builder、AWS EC2 Image Builder ,但是这两个只支持自家的平台。Packer 能够支持主流的公有云、私有云以及混合云,比它俩高到不知道哪里去了。可以这么来理解:Packer 在 IaaS 虚拟化领域的地位就像 Docker 在 PaaS 容器虚拟中那样重要,一个是虚拟机镜像的构建,另一个容器镜像的构建,有趣的是两者都是在 2013 年成立的项目。
Kubernetes 社区的 image-builder 项目就是使用 Packer 构建一些公有云及私有云的虚拟机模版提供给 cluster-api 项目使用,十分推荐大家去看下这个项目的代码,刚开始我也是从这个项目熟悉 Packer 的,并从中抄袭借鉴了很多内容 😅。
下面就介绍一下 Packer 的基本使用方法
对于 Linux 发行版,建议直接下载二进制安装包来安装,通过包管理器安装感觉有点麻烦
$ wget https://releases.hashicorp.com/packer/1.8.0/packer_1.8.0_linux_amd64.zip$ unzip packer_1.8.0_linux_amd64.zip$ mv packer /usr/local/bin/packer
如果是 macOS 用户直接 brew install packer
命令一把梭就能安装好
不同于 Docker 有一个 Dockerfile 文件来定义如何构建容器镜像,Packer 构建虚拟机镜像则是由一系列的配置文件缝合而成,主要由 Builders 、Provisioners 、Post-processors 这三部分组成。其中 Builder 主要是与 IaaS Provider 构建器相关的一些参数;Provisioner 用来配置构建过程中需要运行的一些任务;Post-processors 用于配置构建动作完成后的一些后处理操作;下面就依次介绍一下这几个配置的详细使用说明:
另外 Packer 推荐的配置语法是 HCL2,但个人觉着 HCL 的语法风格怪怪的,不如 json 那样整洁好看 😅,因此下面我统一使用 json 来进行配置,其实参数都一样,只是格式不相同而已。
Packer 的变量配置文件有点类似于 Ansible 中的 vars。一个比较合理的方式就是按照每个参数的作用域进行分类整理,将它们统一放在一个单独的配置文件中,这样维护起来会更方便一些。参考了 image-builder 项目中的 ova 构建后我根据参数的不同作用划分成了如下几个配置文件:
{ "folder": "Packer", "resource_pool": "Packer", "cluster": "Packer", "datacenter": "Packer", "datastore": "Packer", "convert_to_template": "false", "create_snapshot": "true", "linked_clone": "true", "network": "VM Network", "password": "password", "username": "administrator@vsphere.local", "vcenter_server": "vcenter.k8s.li", "insecure_connection": "true"}
{ "boot_command_prefix": "<tab> text ks=hd:fd0:", "boot_command_suffix": "/7/ks.cfg<enter><wait>", "boot_media_path": "/HTTP", "build_name": "centos-7", "distro_arch": "amd64", "distro_name": "centos", "distro_version": "7", "floppy_dirs": "./kickstart/{{user `distro_name`}}/http/", "guest_os_type": "centos7-64", "iso_checksum": "07b94e6b1a0b0260b94c83d6bb76b26bf7a310dc78d7a9c7432809fb9bc6194a", "iso_checksum_type": "sha256", "iso_url": "https://mirrors.edge.kernel.org/centos/7.9.2009/isos/x86_64/CentOS-7-x86_64-Minimal-2009.iso", "os_display_name": "CentOS 7", "shutdown_command": "shutdown -h now", "vsphere_guest_os_type": "centos7_64Guest"}
{ "boot_command_prefix": "<esc><wait> vmlinuz initrd=initrd.img root/dev/ram0 loglevel=3 photon.media=cdrom ks=", "boot_command_suffix": "/3/ks.json<enter><wait>", "boot_media_path": "http://{{ .HTTPIP }}:{{ .HTTPPort }}", "build_name": "photon-3", "distro_arch": "amd64", "distro_name": "photon", "distro_version": "3", "guest_os_type": "vmware-photon-64", "http_directory": "./kickstart/{{user `distro_name`}}/http/", "iso_checksum": "c2883a42e402a2330d9c39b4d1e071cf9b3b5898", "iso_checksum_type": "sha1", "iso_url": "https://packages.vmware.com/photon/3.0/Rev3/iso/photon-minimal-3.0-a383732.iso", "os_display_name": "VMware Photon OS 64-bit", "shutdown_command": "shutdown now", "vsphere_guest_os_type": "vmwarePhoton64Guest"}
{ "ssh_username": "root", "ssh_password": "password", "boot_wait": "15s", "disk_controller_type": "lsilogic", "disk_thin_provisioned": "true", "disk_type_id": "0", "firmware": "bios", "cpu": "2", "cpu_cores": "1", "memory": "4096", "disk_size": "65536", "network_card": "e1000", "ssh_timeout": "3m", "vmx_version": "14", "base_build_version": "{{user `template`}}", "build_timestamp": "{{timestamp}}", "build_name": "k3s", "build_version": "{{user `ova_name`}}", "export_manifest": "none", "output_dir": "./output/{{user `build_version`}}"}
Builder 就是告诉 Packer 要使用什么类型的构建器构建什么样的虚拟机镜像,主要是与底层 IaaS 资源提供商相关的配置。比如 vSphere Builder 中有如下两种构建器:
不同类型的 Builder 配置参数也会有所不同,每个参数的详细用途和说明可以参考 Packer 官方的文档,在这里就不一一说明了。因为 Packer 的参数配置是在是太多太复杂了,很难三言两语讲清楚。最佳的方式就是阅读官方的文档和一些其他项目的实现方式,照葫芦画瓢学就行。
builders.json:里面的配置参数大多都是引用的 var-file 中的参数,将这些参数单独抽出来的好处就是不同的 builder 之间可以复用一些公共参数。比如 vsphere-iso 和 vsphere-clone 这两种不同的 builder 与 vCenter 相关的 datacenter、datastore、vcenter_server 等参数都是其实相同的。
{ "builders": [ { "CPUs": "{{user `cpu`}}", "RAM": "{{user `memory`}}", "boot_command": [ "{{user `boot_command_prefix`}}", "{{user `boot_media_path`}}", "{{user `boot_command_suffix`}}" ], "boot_wait": "{{user `boot_wait`}}", "cluster": "{{user `cluster`}}", "communicator": "ssh", "convert_to_template": "{{user `convert_to_template`}}", "cpu_cores": "{{user `cpu_cores`}}", "create_snapshot": "{{user `create_snapshot`}}", "datacenter": "{{user `datacenter`}}", "datastore": "{{user `datastore`}}", "disk_controller_type": "{{user `disk_controller_type`}}", "firmware": "{{user `firmware`}}", "floppy_dirs": "{{ user `floppy_dirs`}}", "folder": "{{user `folder`}}", "guest_os_type": "{{user `vsphere_guest_os_type`}}", "host": "{{user `host`}}", "http_directory": "{{ user `http_directory`}}", "insecure_connection": "{{user `insecure_connection`}}", "iso_checksum": "{{user `iso_checksum_type`}}:{{user `iso_checksum`}}", "iso_urls": "{{user `iso_url`}}", "name": "vsphere-iso-base", "network_adapters": [ { "network": "{{user `network`}}", "network_card": "{{user `network_card`}}" } ], "password": "{{user `password`}}", "shutdown_command": "echo '{{user `ssh_password`}}' | sudo -S -E sh -c '{{user `shutdown_command`}}'", "ssh_clear_authorized_keys": "false", "ssh_password": "{{user `ssh_password`}}", "ssh_timeout": "4h", "ssh_username": "{{user `ssh_username`}}", "storage": [ { "disk_size": "{{user `disk_size`}}", "disk_thin_provisioned": "{{user `disk_thin_provisioned`}}" } ], "type": "vsphere-iso", "username": "{{user `username`}}", "vcenter_server": "{{user `vcenter_server`}}", "vm_name": "{{user `base_build_version`}}", "vm_version": "{{user `vmx_version`}}" } ]}
{ "builders": [ { "CPUs": "{{user `cpu`}}", "RAM": "{{user `memory`}}", "cluster": "{{user `cluster`}}", "communicator": "ssh", "convert_to_template": "{{user `convert_to_template`}}", "cpu_cores": "{{user `cpu_cores`}}", "create_snapshot": "{{user `create_snapshot`}}", "datacenter": "{{user `datacenter`}}", "datastore": "{{user `datastore`}}", "export": { "force": true, "manifest": "{{ user `export_manifest`}}", "output_directory": "{{user `output_dir`}}" }, "folder": "{{user `folder`}}", "host": "{{user `host`}}", "insecure_connection": "{{user `insecure_connection`}}", "linked_clone": "{{user `linked_clone`}}", "name": "vsphere-clone", "network": "{{user `network`}}", "password": "{{user `password`}}", "shutdown_command": "echo '{{user `ssh_password`}}' | sudo -S -E sh -c '{{user `shutdown_command`}}'", "ssh_password": "{{user `ssh_password`}}", "ssh_timeout": "4h", "ssh_username": "{{user `ssh_username`}}", "template": "{{user `template`}}", "type": "vsphere-clone", "username": "{{user `username`}}", "vcenter_server": "{{user `vcenter_server`}}", "vm_name": "{{user `build_version`}}" } ]}
Provisioner 就是告诉 Packer 要如何构建镜像,有点类似于 Dockerile 中的 RUN/COPY/ADD 等指令,用于执行一些命令/脚本、往虚拟机里添加一些文件、调用第三方插件执行一些操作等。
在这个配置文件中我先使用 file 模块将一些脚本和依赖文件上传到虚拟机中,然后使用 shell 模块在虚拟机中执行 install.sh 安装脚本。如果构建的 builder 比较多,比如需要支持多个 Linux 发行版,这种场景建议使用 Ansible。由于我在 ISO 安装 OS 的构建流程中已经将一些与 OS 发行版相关的操作完成了,在这里使用 shell 执行的操作不需要区分哪个 Linux 发行版,所以就没有使用 ansible。
{ "provisioners": [ { "type": "file", "source": "scripts", "destination": "/root", "except": [ "vsphere-iso-base" ] }, { "type": "file", "source": "resources", "destination": "/root", "except": [ "vsphere-iso-base" ] }, { "type": "shell", "environment_vars": [ "INSECURE_REGISTRY={{user `insecure_registry`}}" ], "inline": "bash /root/scripts/install.sh", "except": [ "vsphere-iso-base" ] } ]}
一些构建后的操作, 比如 "type": "manifest"
可以导出一些构建过程中的配置参数,给后续的其他操作来使用。再比如 "type": "shell-local"
就是执行一些 shell 脚本,在这里就是执行一个 Python 脚本将 OVF 转换成 OVA。
{ "post-processors": [ { "custom_data": { "release_version": "{{user `release_version`}}", "build_date": "{{isotime}}", "build_name": "{{user `build_name`}}", "build_timestamp": "{{user `build_timestamp`}}", "build_type": "node", "cpu": "{{user `cpu`}}", "memory": "{{user `memory`}}", "disk_size": "{{user `disk_size`}}", "distro_arch": "{{ user `distro_arch` }}", "distro_name": "{{ user `distro_name` }}", "distro_version": "{{ user `distro_version` }}", "firmware": "{{user `firmware`}}", "guest_os_type": "{{user `guest_os_type`}}", "os_name": "{{user `os_display_name`}}", "vsphere_guest_os_type": "{{user `vsphere_guest_os_type`}}" }, "name": "packer-manifest", "output": "{{user `output_dir`}}/packer-manifest.json", "strip_path": true, "type": "manifest", "except": [ "vsphere-iso-base" ] }, { "inline": [ "python3 ./scripts/ova.py --vmx {{user `vmx_version`}} --ovf_template {{user `ovf_template`}} --build_dir={{user `output_dir`}}" ], "except": [ "vsphere-iso-base" ], "name": "vsphere", "type": "shell-local" } ]}
packer-vsphere-example 项目的目录结构如下:
../packer-vsphere-example├── kickstart # kickstart 配置文件存放目录├── Makefile # makefile,make 命令的操作的入口├── packer # packer 配置文件│ ├── builder.json # packer builder 配置文件│ ├── centos7.json # centos iso 安装 os 的配置│ ├── common.json # 一些公共配置参数│ ├── photon3.json # photon3 iso 安装 os 的配置│ └── vcenter.json # vcenter 相关的配置├── resources # 一些 k8s manifests 文件└── scripts # 构建过程中需要用到的脚本文件
与 docker 类似,packer 执行构建操作的子命令同样也是 build,即 packer build
,不过 packer build 命令支持的选项并没有 docker 那么丰富。最核心选项就是 -except, -only, -var, -var-file 这几个:
$ packer buildOptions:# 控制终端颜色输出 -color=false Disable color output. (Default: color) # debug 模式,类似于断点的方式运行 -debug Debug mode enabled for builds. # 排除一些 builder,有点类似于 ansible 的 --skip-tags -except=foo,bar,baz Run all builds and post-processors other than these. # 指定运行某些 builder,有点类似于 ansible 的 --tags -only=foo,bar,baz Build only the specified builds. # 强制构建,如果构建目标已经存在则强制删除重新构建 -force Force a build to continue if artifacts exist, deletes existing artifacts. -machine-readable Produce machine-readable output. # 出现错误之后的动作,cleanup 清理所有操作、abort 中断执行、ask 询问、 -on-error=[cleanup|abort|ask|run-cleanup-provisioner] If the build fails do: clean up (default), abort, ask, or run-cleanup-provisioner. # 并行运行的 builder 数量,默认没有限制,有点类似于 ansible 中的 --forks 参数 -parallel-builds=1 Number of builds to run in parallel. 1 disables parallelization. 0 means no limit (Default: 0) # UI 输出的时间戳 -timestamp-ui Enable prefixing of each ui output with an RFC3339 timestamp. # 变量参数,有点类似于 ansible 的 -e 选项 -var 'key=value' Variable for templates, can be used multiple times. # 变量文件,有点类似于 ansible 的 -e@ 选项 -var-file=path JSON or HCL2 file containing user variables.# 指定一些 var 参数以及 var-file 文件,最后一个参数是 builder 的配置文件路径$ packer build --var ova_name=k3s-photon3-c4ca93f --var release_version=c4ca93f --var ovf_template=/root/usr/src/github.com/muzi502/packer-vsphere-example/scripts/ovf_template.xml --var template=base-os-photon3 --var username=${VCENTER_USERNAME} --var password=${VCENTER_PASSWORD} --var vcenter_server=${VCENTER_SERVER} --var build_name=k3s-photon3 --var output_dir=/root/usr/src/github.com/muzi502/packer-vsphere-example/output/k3s-photon3-c4ca93f -only vsphere-clone -var-file=/root/usr/src/github.com/muzi502/packer-vsphere-example/packer/vcenter.json -var-file=/root/usr/src/github.com/muzi502/packer-vsphere-example/packer/photon3.json -var-file=/root/usr/src/github.com/muzi502/packer-vsphere-example/packer/common.json /root/usr/src/github.com/muzi502/packer-vsphere-example/packer/builder.json
上面那个又长又臭的 packer build 命令我们在 Makefile 里封装一下,那么多的参数选项手动输起来能把人气疯 😂
# Ensure Make is run with bash shell as some syntax below is bash-specificSHELL:=/usr/bin/env bash.DEFAULT_GOAL:=help# Full directory of where the Makefile residesROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))RELEASE_VERSION ?= $(shell git describe --tags --always --dirty)RELEASE_TIME ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')PACKER_IMAGE ?= hashicorp/packer:1.8PACKER_CONFIG_DIR = $(ROOT_DIR)/packerPACKER_FORCE ?= falsePACKER_OVA_PREFIX ?= k3sPACKER_BASE_OS ?= centos7PACKER_OUTPUT_DIR ?= $(ROOT_DIR)/outputPACKER_TEMPLATE_NAME ?= base-os-$(PACKER_BASE_OS)OVF_TEMPLATE ?= $(ROOT_DIR)/scripts/ovf_template.xmlPACKER_OVA_NAME ?= $(PACKER_OVA_PREFIX)-$(PACKER_BASE_OS)-$(RELEASE_VERSION)
# 是否为强制构建,增加 force 参数ifeq ($(PACKER_FORCE), true) PACKER_FORCE_ARG = --force=trueendif# 定义 vars 可变参数,比如 vcenter 用户名、密码 等参数PACKER_VARS = $(PACKER_FORCE_ARG) \ # 是否强制构建--var ova_name=$(PACKER_OVA_NAME) \ # OVA 文件名--var release_version=$(RELEASE_VERSION) \ # 发布版本--var ovf_template=$(OVF_TEMPLATE) \ # OVF 模版文件--var template=$(PACKER_TEMPLATE_NAME) \ # OVA 的 base 虚拟机模版名称--var username=$${VCENTER_USERNAME} \ # vCenter 用户名(环境变量)--var password=$${VCENTER_PASSWORD} \ # vCenter 密码(环境变量)--var vcenter_server=$${VCENTER_SERVER} \ # vCenter 访问地址(环境变量)--var build_name=$(PACKER_OVA_PREFIX)-$(PACKER_BASE_OS) \ # 构建名称--var output_dir=$(PACKER_OUTPUT_DIR)/$(PACKER_OVA_NAME) # OVA 导出的目录# 定义 var-file 参数PACKER_VAR_FILES = -var-file=$(PACKER_CONFIG_DIR)/vcenter.json \ # vCenter 的参数配置-var-file=$(PACKER_CONFIG_DIR)/$(PACKER_BASE_OS).json \ # OS 的参数配置-var-file=$(PACKER_CONFIG_DIR)/common.json # 一些公共配置
.PHONY: build-template# 通过 ISO 安装 OS 构建一个 base 虚拟机build-template: ## build the base os template by isopacker build $(PACKER_VARS) -only vsphere-iso-base $(PACKER_VAR_FILES) $(PACKER_CONFIG_DIR)/builder.json.PHONY: build-ovf# 通过 clone 方式构建并导出 OVF/OVAbuild-ovf: ## build the ovf template by clone the base os templatepacker build $(PACKER_VARS) -only vsphere-clone $(PACKER_VAR_FILES) $(PACKER_CONFIG_DIR)/builder.json
# 通过 PACKER_BASE_OS 参数设置 base os 是 photon3 还是 centos7$ make build-template PACKER_BASE_OS=photon3
# 通过 PACKER_BASE_OS 参数设置 base os 是 photon3 还是 centos7$ make build-ovf PACKER_BASE_OS=photon3
将 Packer 的配置文件以及 Makefile 封装好之后,我们就可以运行 make build-template
和 make build-ovf
命令来构建虚拟机模版了,整体的构建流程如下:
base 虚拟机有点类似于 Dockerfile 中的 FROM base 镜像。在 Packer 中我们可以把一些很少会改动的内容做成一个 base 虚拟机。然后从这个 base 虚拟机克隆出一台新的虚拟机来完成接下来的构建流程,这样能够节省整体的构建耗时,使得构建效率更高一些。
vsphere-iso-base: output will be in this color.==> vsphere-iso-base: File /root/.cache/packer/e476ea1d3ef3c2e3966a7081ac4239cd5ae5e8a3.iso already uploaded; continuing==> vsphere-iso-base: File [Packer] packer_cache//e476ea1d3ef3c2e3966a7081ac4239cd5ae5e8a3.iso already exists; skipping upload.==> vsphere-iso-base: the vm/template Packer/base-os-centos7 already exists, but deleting it due to -force flag==> vsphere-iso-base: Creating VM...==> vsphere-iso-base: Customizing hardware...==> vsphere-iso-base: Mounting ISO images...==> vsphere-iso-base: Adding configuration parameters...==> vsphere-iso-base: Creating floppy disk... vsphere-iso-base: Copying files flatly from floppy_files vsphere-iso-base: Done copying files from floppy_files vsphere-iso-base: Collecting paths from floppy_dirs vsphere-iso-base: Resulting paths from floppy_dirs : [./kickstart/centos/http/] vsphere-iso-base: Recursively copying : ./kickstart/centos/http/ vsphere-iso-base: Done copying paths from floppy_dirs vsphere-iso-base: Copying files from floppy_content vsphere-iso-base: Done copying files from floppy_content==> vsphere-iso-base: Uploading created floppy image==> vsphere-iso-base: Adding generated Floppy...==> vsphere-iso-base: Set boot order temporary...==> vsphere-iso-base: Power on VM...==> vsphere-iso-base: Waiting 15s for boot...==> vsphere-iso-base: Typing boot command...==> vsphere-iso-base: Waiting for IP...==> vsphere-iso-base: IP address: 192.168.29.46==> vsphere-iso-base: Using SSH communicator to connect: 192.168.29.46==> vsphere-iso-base: Waiting for SSH to become available...==> vsphere-iso-base: Connected to SSH!==> vsphere-iso-base: Executing shutdown command...==> vsphere-iso-base: Deleting Floppy drives...==> vsphere-iso-base: Deleting Floppy image...==> vsphere-iso-base: Eject CD-ROM drives...==> vsphere-iso-base: Creating snapshot...==> vsphere-iso-base: Clear boot order...Build 'vsphere-iso-base' finished after 6 minutes 42 seconds.==> Wait completed after 6 minutes 42 seconds==> Builds finished. The artifacts of successful builds are:--> vsphere-iso-base: base-os-centos7[root@localhost:/vmfs/volumes/622aec5b-de94a27c-948e-00505680fb1d] ls packer_cache/51511394170e64707b662ca8db012be4d23e121f.iso d3e175624fc2d704975ce9a149f8f270e4768727.iso e476ea1d3ef3c2e3966a7081ac4239cd5ae5e8a3.iso[root@localhost:/vmfs/volumes/622aec5b-de94a27c-948e-00505680fb1d] ls -alh base-os-centos7/total 4281536drwxr-xr-x 1 root root 72.0K Apr 1 09:17 .drwxr-xr-t 1 root root 76.0K Apr 1 09:17 ..-rw------- 1 root root 4.0G Apr 1 09:17 base-os-centos7-3ea6b205.vswp-rw-r--r-- 1 root root 253 Apr 1 09:17 base-os-centos7-65ff34a3.hlog-rw------- 1 root root 64.0G Apr 1 09:17 base-os-centos7-flat.vmdk-rw------- 1 root root 8.5K Apr 1 09:17 base-os-centos7.nvram-rw------- 1 root root 482 Apr 1 09:17 base-os-centos7.vmdk-rw-r--r-- 1 root root 0 Apr 1 09:17 base-os-centos7.vmsd-rwxr-xr-x 1 root root 2.3K Apr 1 09:17 base-os-centos7.vmx-rw------- 1 root root 0 Apr 1 09:17 base-os-centos7.vmx.lck-rwxr-xr-x 1 root root 2.2K Apr 1 09:17 base-os-centos7.vmx~-rw------- 1 root root 1.4M Apr 1 09:17 packer-tmp-created-floppy.flp-rw-r--r-- 1 root root 96.1K Apr 1 09:17 vmware.logroot@devbox-fedora:/root # scp 192.168.24.43:/vmfs/volumes/Packer/base-os-centos7/packer-tmp-created-floppy.flp .root@devbox-fedora:/root # mount packer-tmp-created-floppy.flp /mntroot@devbox-fedora:/root # readlink /dev/disk/by-label/packer../../loop2root@devbox-fedora:/root # ls /mnt/HTTP/7/KS.CFGKS.CFG
vsphere-iso-base: output will be in this color.==> vsphere-iso-base: File /root/.cache/packer/d3e175624fc2d704975ce9a149f8f270e4768727.iso already uploaded; continuing==> vsphere-iso-base: File [Packer] packer_cache//d3e175624fc2d704975ce9a149f8f270e4768727.iso already exists; skipping upload.==> vsphere-iso-base: the vm/template Packer/base-os-photon3 already exists, but deleting it due to -force flag==> vsphere-iso-base: Creating VM...==> vsphere-iso-base: Customizing hardware...==> vsphere-iso-base: Mounting ISO images...==> vsphere-iso-base: Adding configuration parameters...==> vsphere-iso-base: Starting HTTP server on port 8674==> vsphere-iso-base: Set boot order temporary...==> vsphere-iso-base: Power on VM...==> vsphere-iso-base: Waiting 15s for boot...==> vsphere-iso-base: HTTP server is working at http://192.168.29.171:8674/==> vsphere-iso-base: Typing boot command...==> vsphere-iso-base: Waiting for IP...==> vsphere-iso-base: IP address: 192.168.29.208==> vsphere-iso-base: Using SSH communicator to connect: 192.168.29.208==> vsphere-iso-base: Waiting for SSH to become available...==> vsphere-iso-base: Connected to SSH!==> vsphere-iso-base: Executing shutdown command...==> vsphere-iso-base: Deleting Floppy drives...==> vsphere-iso-base: Eject CD-ROM drives...==> vsphere-iso-base: Creating snapshot...==> vsphere-iso-base: Clear boot order...Build 'vsphere-iso-base' finished after 5 minutes 24 seconds.==> Wait completed after 5 minutes 24 seconds==> Builds finished. The artifacts of successful builds are:--> vsphere-iso-base: base-os-photon3
通过 packer build
命令的输出我们大致可以推断出通过 vsphere-iso 构建 Base 虚拟机的主要步骤和原理:
个人觉着这里比较好玩儿就是居然可以通过 vCenter 或 ESXi 的 PutUsbScanCodes API 来给虚拟机发送一些键盘输入的指令,感觉这简直太神奇啦 😂。之前我们的项目是将 kickstart 文件构建成一个 ISO 文件,然后通过重新构建源 ISO 的方式来修改 isolinux 启动参数。后来感觉这种重新构建 ISO 的方式太蠢了,于是就参考 Packer 的思路使用 govc 里内置的 vm.keystrokes 命令来给虚拟机发送键盘指令,完成指定 kickstart 文件路径参数启动的操作。具体的 govc 操作命令可以参考如下:
# 发送 tab 键,进入到 ISO 启动参数编辑页面$ govc vm.keystrokes -vm='centos-vm-192' -c='KEY_TAB'# 发送 Right Control + U 键清空输入框$ govc vm.keystrokes -vm='centos-vm-192' -rc=true -c='KEY_U'# 输入 isolinux 的启动参数配置,通过 ks=hd:LABEL=KS:/ks.cfg 指定 kickstart 路径,LABEL 为构建 ISO 时设置的 lable$ govc vm.keystrokes -vm='centos-vm-192' -s='vmlinuz initrd=initrd.img ks=hd:LABEL=KS:/ks.cfg inst.stage2=hd:LABEL=CentOS\\x207\\x20x86_64 quiet console=ttyS0'# 按下回车键,开始安装 OS$ govc vm.keystrokes -vm='centos-vm-192' -c='KEY_ENTER'
通过 vsphere-iso 构建 Base 虚拟机之后,我们就使用这个 base 虚拟机克隆出一台新的虚拟机,用来构建我们的业务虚拟机镜像,将 k3s, argo-workflow, redfish-esxi-os-installer 这一堆工具打包进去;
vsphere-clone: output will be in this color.==> vsphere-clone: Cloning VM...==> vsphere-clone: Customizing hardware...==> vsphere-clone: Power on VM...==> vsphere-clone: Waiting for IP...==> vsphere-clone: IP address: 192.168.30.112==> vsphere-clone: Using SSH communicator to connect: 192.168.30.112==> vsphere-clone: Waiting for SSH to become available...==> vsphere-clone: Connected to SSH!==> vsphere-clone: Uploading scripts => /root==> vsphere-clone: Uploading resources => /root==> vsphere-clone: Provisioning with shell script: /tmp/packer-shell557168976==> vsphere-clone: Executing shutdown command...==> vsphere-clone: Creating snapshot... vsphere-clone: Starting export... vsphere-clone: Downloading: k3s-photon3-c4ca93f-disk-0.vmdk vsphere-clone: Exporting file: k3s-photon3-c4ca93f-disk-0.vmdk vsphere-clone: Writing ovf...==> vsphere-clone: Running post-processor: packer-manifest (type manifest)==> vsphere-clone: Running post-processor: vsphere (type shell-local)==> vsphere-clone (shell-local): Running local shell script: /tmp/packer-shell2376077966 vsphere-clone (shell-local): image-build-ova: cd /root/usr/src/github.com/muzi502/packer-vsphere-example/output/k3s-photon3-c4ca93f vsphere-clone (shell-local): image-build-ova: create ovf k3s-photon3-c4ca93f.ovf vsphere-clone (shell-local): image-build-ova: create ova manifest k3s-photon3-c4ca93f.mf vsphere-clone (shell-local): image-build-ova: creating OVA using tar vsphere-clone (shell-local): image-build-ova: ['tar', '-c', '-f', 'k3s-photon3-c4ca93f.ova', 'k3s-photon3-c4ca93f.ovf', 'k3s-photon3-c4ca93f.mf', 'k3s-photon3-c4ca93f-disk-0.vmdk'] vsphere-clone (shell-local): image-build-ova: create ova checksum k3s-photon3-c4ca93f.ova.sha256Build 'vsphere-clone' finished after 14 minutes 16 seconds.==> Wait completed after 14 minutes 16 seconds==> Builds finished. The artifacts of successful builds are:--> vsphere-clone: k3s-photon3-c4ca93f--> vsphere-clone: k3s-photon3-c4ca93f--> vsphere-clone: k3s-photon3-c4ca93f
通过 packer build 命令的输出我们大致可以推断出构建流程:
至此,整个的虚拟机模版的构建流程算是完成了,最终我们的到一个 OVA 格式的虚拟机模版。使用的时候只需要在本地机器上安装好 VMware Workstation 或者 Oracle VirtualBox 就能一键导入该虚拟机,开机后就可以使用啦,算是做到了开箱即用的效果。
output└── k3s-photon3-c4ca93f ├── k3s-photon3-c4ca93f-disk-0.vmdk ├── k3s-photon3-c4ca93f.mf ├── k3s-photon3-c4ca93f.ova ├── k3s-photon3-c4ca93f.ova.sha256 ├── k3s-photon3-c4ca93f.ovf └── packer-manifest.json
在虚拟机内使用 redfish-esxi-os-installer 有点特殊,是将它放在 argo-workflow 的 Pod 内来执行的。在 workflow 模版文件 workflow.yaml 中我们定义了若干个 steps 来运行 redfish-esxi-os-installer。
apiVersion: argoproj.io/v1alpha1kind: Workflowmetadata: generateName: redfish-esxi-os-installer- namespace: defaultspec: entrypoint: redfish-esxi-os-installer templates: - name: redfish-esxi-os-installer steps: - - arguments: parameters: - name: command value: pre-check name: Precheck template: installer - - arguments: parameters: - name: command value: build-iso name: BuildISO template: installer - - arguments: parameters: - name: command value: mount-iso name: MountISO template: installer - - arguments: parameters: - name: command value: reboot name: Reboot template: installer - - arguments: parameters: - name: command value: post-check name: Postcheck template: installer - - arguments: parameters: - name: command value: umount-iso name: UmountISO template: installer - container: name: installer image: ghcr.io/muzi502/redfish-esxi-os-installer:v0.1.0-alpha.1 command: - bash - -c - | make inventory && make {{inputs.parameters.command}} env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: HOST_IP valueFrom: fieldRef: fieldPath: status.hostIP - name: SRC_ISO_DIR value: /data/iso - name: HTTP_DIR value: /data/iso/redfish - name: HTTP_URL value: http://$(HOST_IP)/files/iso/redfish - name: ESXI_ISO valueFrom: configMapKeyRef: name: redfish-esxi-os-installer-config key: esxi_iso securityContext: privileged: true volumeMounts: - mountPath: /ansible/config.yaml name: config readOnly: true subPath: config.yaml - mountPath: /data name: data inputs: parameters: - name: command name: installer retryStrategy: limit: "2" retryPolicy: OnFailure volumes: - configMap: items: - key: config path: config.yaml name: redfish-esxi-os-installer-config name: config - name: data hostPath: path: /data type: DirectoryOrCreate
由于目前没有 Web UI 和后端 Server 所以还是需要手动编辑 /root/resources/workflow/configmap.yaml 配置文件,然后再执行 kubectl create -f /root/resources/workflow
命令创建 workflow 工作流。
workflow 创建了之后,就可以通过 argo 命令查看 workflow 执行的进度和状态
root@localhost [ ~/resources/workflow ]# argo get redfish-esxi-os-installer-tjjqzName: redfish-esxi-os-installer-tjjqzNamespace: defaultServiceAccount: unset (will run with the default ServiceAccount)Status: SucceededConditions: PodRunning False Completed TrueCreated: Mon May 23 11:07:31 +0000 (16 minutes ago)Started: Mon May 23 11:07:31 +0000 (16 minutes ago)Finished: Mon May 23 11:23:38 +0000 (19 seconds ago)Duration: 16 minutes 7 secondsProgress: 6/6ResourcesDuration: 29m45s*(1 cpu),29m45s*(100Mi memory)STEP TEMPLATE PODNAME DURATION MESSAGE ✔ redfish-esxi-os-installer-tjjqz redfish-esxi-os-installer ├───✔ Precheck(0) installer redfish-esxi-os-installer-tjjqz-647555770 11s ├───✔ BuildISO(0) installer redfish-esxi-os-installer-tjjqz-3078771217 14s ├───✔ MountISO(0) installer redfish-esxi-os-installer-tjjqz-4099695623 19s ├───✔ Reboot(0) installer redfish-esxi-os-installer-tjjqz-413209187 7s ├───✔ Postcheck(0) installer redfish-esxi-os-installer-tjjqz-2674696793 14m └───✔ UmountISO(0) installer redfish-esxi-os-installer-tjjqz-430254503 13s
之所以使用 argo-workflow 而不是使用像 docker、nerdctl 这些命令行工具来运行 redfish-esxi-os-installer ,是因为通过 argo-workflow 来编排我们的安装部署任务能够比较方便地实现多个任务同时运行、获取任务执行的进度及日志、获取任务执行的耗时、停止重试等功能。使用 argo-workflow 来编排我们的安装部署任务,并通过 argo-workflow 的 RESTful API 获取部署任务的进度日志等信息,这样做更云原生一些(🤣
在我们内部其实最终目的是准备将该方案做成一个产品化的工具,提供一个 Web UI 用来进行配置部署参数以及展示部署的进度日志等功能。当初设计方案的时候也是参考了一下 VMware Tanzu 社区版 :部署 Tanzu 管理集群的时候需要有一个已经存在的 k8s 集群,或者通过 Tanzu 新部署一个 kind 集群。部署一个 tanzu 管理集群可以通过 tanzu 命令行的方式,也可以通过 Tanzu Web UI 的方式,Tanzu Web UI 的方式其实就是一个偏向于产品化的工具。在 VMware Tanzu kubernetes 发行版部署尝鲜 我曾分享过 Tanzu 的部署方式,感兴趣的话可以去看一下。
该方案主要是面向一些产品化的场景,由于引入了 K8s 这个庞然大物,整体的技术栈会复杂一些,但也有一些好处啦 😅。
argo-workflow 需要依赖一个 k8s 集群才能运行,我们内部测试了 kubekey、sealos、kubespray、k3s 几种常见的部署工具。综合评定下来 k3s 集群占用的资源最少。参考 K3s 资源分析 给出的资源要求,最小只需要 768M 内存就能运行。对于硬件资源不太充足的笔记本电脑来讲,k3s 无疑是目前最佳的方案。
另外还有一个十分重要的原因就是 k3s server 更换单 control plan 节点的 IP 地址十分方便,对用户来说是无感知的。这样就可以将安装 k3s 的操作在构建 OVA 的时候完成,而不是在使用的时候手动执行安装脚本来安装。
只要开机运行虚拟机能够通过 DHCP 分配到一个内网 IPv4 地址或者手动配置一个静态 IP,k3s 就能够正常运行起来,能够真正做到开箱即用,而不是像 kubekey、sealos、kubespray 那样傻乎乎地填写一个复杂无比的配置文件,然后再执行一些命令来安装 k8s 集群。这种导入虚拟机开即用的方式,对用户来讲十分友好。
当然在使用 kubekey、sealos、kubespray 在构建虚拟机的时候安装好 k8s 集群也不是不可行,只不过我们构建时候虚拟机的 IP 地址(比如 10.172.20.223)和使用时的 IP 地址(比如 192.168.20.11)基本上是不会相同的。给 k8s control plain 节点更换 IP 的操作 阳明博主 曾在 如何修改 Kubernetes 节点 IP 地址? 文章中分享过他的经历,看完后直接把我整不会了,感觉操作起来实在是太麻烦了,还不如重新部署一套新的 k8s 方便呢 😂
其实构建虚拟机模版的时候安装 k8s 的思路最初我是借鉴的 cluster-api 项目 😂。即将部署 k8s 依赖的一些文件和容器镜像构建在虚拟机模版当中,部署 k8s 的时候不需要再联网下载这些依赖资源了。不同的是,我们通过 k3s 直接提前将 k8s 集群部署好了,也就省去了让用户执行部署的操作。
综上,选用 k3s 作为该方案的 K8s 底座无疑是最佳的啦(
使用了一段时间后感觉 Packer 的复杂度和上手难度要比 Docker 构建容器镜像要高出一个数量级。可能是因为虚拟机并不像容器镜像那样有 OCI 这种统一的构建、分发、运行工业标准。虚拟机的创建克隆等操作与底层的 IaaS 供应商耦合的十分紧密,这就导致不同 IaaS 供应商比如 vSphere、kvm/qemu 他们之间能够复用的配置参数并不多。比如 vSphere 里有 datastore、datacenter、resource_pool、folder 等概念,但 kvm/qemu 中缺没有,这就导致很难将它们统一成一个配置。
使用 OVA 而不是 vagrant.box、vmdk、raw、qcow2 等其他格式是因为 OVA 支持支持一键导入的特性,在 Windows 上使用起来比较方便。毕竟 Windows 上安装 Vagrant 或者 qemu/KVM 也够你折腾的了,VMware Workstation 或者 Oracle VirtualBox 使用得更广泛一些。
另外 Packer 并不支持直接将虚拟机导出为 OVA 的方式,默认情况下只会通过 vCenter 的 API 导出为 ovf。如果需要 OVA 格式,需要将 OVF 打包成 OVA。在 ISSUE Add support for exporting to OVA in vsphere-iso builder #9645 也有人反馈了支持 OVA 导出的需求,但 Packer 至今仍未支持。将 OVF 转换为 OVA 我是参考的 image-builder 项目的 image-build-ova.py 来完成的。
由于 ISO 中并不包含 open-vm-tool 软件包,这就需要在 ISO 安装 OS 的过程中联网安装 open-vm-tools。如果安装的时候网络抖动了就可能会导致 open-vm-tools 安装失败。open-vm-tools 安装失败 packer 是无法感知到的,只能一直等到获取虚拟机 IP 超时后退出执行。目前没有很好的办法,只能在 kickstart 里安装 open-vm-tools 的时候进行重试直到 open-vm-tools 安装成功。
曾经在 手搓虚拟机模板 文章中分析过通过 dd 置零的方式可以大幅减少虚拟机导出后的 vmdk 文件大
464M Aug 28 16:15 Ubuntu1804-2.ova # 置零后的大小1.3G Aug 28 15:48 Ubuntu1804.ova # 置零前的大小
需要注意的是,在 dd 置零之前要先停止 k3s 服务,不然置零的时候会占满 root 根分区导致 kubelet 启动 GC 将一些镜像给删除掉。之前导出虚拟机后发现少了一些镜像,排查了好久才发现是 kubelet GC 把我的镜像给删掉了,踩了个大坑,可气死我了 😡
另外也可以删除一些不必要的文件,比如 containerd 中 io.containerd.content.v1.content/blobs/sha256
一些镜像 layer 的原始 blob 文件是不需要的,可以将它们给删除掉,这样能够减少部分磁盘空间占用;
function cleanup(){ # stop k3s server for for prevent it starting the garbage collection to delete images systemctl stop k3s # Ensure on next boot that network devices get assigned unique IDs. sed -i '/^\(HWADDR\|UUID\)=/d' /etc/sysconfig/network-scripts/ifcfg-* 2>/dev/null || true # Clean up network interface persistence find /var/log -type f -exec truncate --size=0 {} \; rm -rf /tmp/* /var/tmp/* # cleanup all blob files of registry download image find /var/lib/rancher/k3s/agent/containerd/io.containerd.content.v1.content/blobs/sha256 -size +1M -type f -delete # zero out the rest of the free space using dd, then delete the written file. dd if=/dev/zero of=/EMPTY bs=4M status=progress || rm -f /EMPTY dd if=/dev/zero of=/data/EMPTY bs=4M status=progress || rm -f /data/EMPTY # run sync so Packer doesn't quit too early, before the large file is deleted. sync yum clean all}
之前在 轻量级容器优化型 Linux 发行版 Photon OS 里分享过 VMware 的 Linux 发行版 Photon。不同于传统的 Linux 发行版 Photon 的系统十分精简,使用它替代 CentOS 能够一定程度上减少系统资源的占用,导出后的 vmdk 文件也要比 CentOS 小一些。
在构建的过程中我们在 k3s 集群上安装了一些其他的组件,比如提供文件上传和下载服务的 filebrowser 以及 workflow 工作流引擎 argo-workflow,为了保证这些服务的正常运行,我们就需要通过不同的方式去检查这些服务是否正常。一般是通过 kubectl get 等命令查看 deployment、pod、daemonset 等服务是否正常运行,或者通过 curl 访问这些这些服务的健康检查 API。
由于检查项比较多且十分繁琐,使用传统的 shell 脚本来做这并不是很方便,需要解析每个命令的退出码以及返回值。因此我们使用 goss 通过 YAML 格式的配置文件来定义一些检查项,让它批量来执行这些检查,而不用在 shell 对每个检查项写一堆的 awk/grep 等命令来 check 了。
# DNS 类型的检查dns: # 检查 coredns 是否能够正常解析到 kubernetes apiserver 的 service IP 地址 kubernetes.default.svc.cluster.local: resolvable: true addrs: - 10.43.0.1 server: 10.43.0.10 timeout: 600 skip: false# TCP/UDP 端口类型的检查addr: # 检查 coredns 的 UDP 53 端口是否正常 udp://10.43.0.10:53: reachable: true timeout: 500# 检查 cni0 网桥是否存在interface: cni0: exists: true addrs: - 10.42.0.1/24# 本机端口类型的检查port: # 检查 ssh 22 端口是否正常 tcp:22: listening: true ip: - 0.0.0.0 skip: false # 检查 kubernetes apiserver 6443 端口是否正常 tcp6:6443: listening: true skip: false# 检查一些 systemd 服务的检查service: # 默认禁用 firewalld 服务 firewalld: enabled: false running: false # 确保 sshd 服务正常运行 sshd: enabled: true running: true skip: false # 检查 k3s 服务是否正常运行 k3s: enabled: true running: true skip: false# 定义一些 shell 命令执行的检查command: # 检查 kubernetes cheduler 组件是否正常 check_k8s_scheduler_health: exec: curl -k https://127.0.0.1:10259/healthz # 退出码是否为 0 exit-status: 0 stderr: [] # 标准输出中是否包含正确的输出值 stdout: ["ok"] skip: false # 检查 kubernetes controller-manager 是否正常 check_k8s_controller-manager_health: exec: curl -k https://127.0.0.1:10257/healthz exit-status: 0 stderr: [] stdout: ["ok"] skip: false # 检查 cluster-info 中输出的组件运行状态是否正常 check_cluster_status: exec: kubectl cluster-info | grep 'is running' exit-status: 0 stderr: [] timeout: 0 stdout: - CoreDNS - Kubernetes control plane skip: false # 检查节点是否处于 Ready 状态 check_node_status: exec: kubectl get node -o jsonpath='{.items[].status}' | jq -r '.conditions[-1].type' exit-status: 0 stderr: [] timeout: 0 stdout: - Ready skip: false # 检查节点 IP 是否正确 check_node_address: exec: kubectl get node -o wide -o json | jq -r '.items[0].status.addresses[] | select(.type == "InternalIP") | .address' exit-status: 0 stderr: [] timeout: 0 stdout: - {{ .Vars.ip_address }} skip: false # 检查 traefik loadBalancer 的 IP 地址是否正确 check_traefik_address: exec: kubectl -n kube-system get svc traefik -o json | jq -r '.status.loadBalancer.ingress[].ip' exit-status: 0 stderr: [] timeout: 0 stdout: - {{ .Vars.ip_address }} skip: false # 检查 containerd 容器运行是否正常 check_container_status: exec: crictl ps --output=json | jq -r '.containers[].metadata.name' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - coredns - /lb-.*-443/ - /lb-.*-80/ - traefik skip: false # 检查 kube-system namespace 下的 pod 是否正常 check_kube_system_namespace_pod_status: exec: kubectl get pod -n kube-system -o json | jq -r '.items[] | select((.status.phase != "Running") and (.status.phase != "Succeeded") and (.status.phase != "Completed"))' exit-status: 0 stderr: [] timeout: 0 stdout: ["!string"] # 检查 k8s deployment 服务是否都正常 check_k8s_deployment_status: exec: kubectl get deploy --all-namespaces -o json | jq -r '.items[]| select(.status.replicas == .status.availableReplicas) | .metadata.name' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - coredns - traefik skip: false # 检查 svclb-traefik daemonset 是否正常 check_k8s_daemonset_status: exec: kubectl get daemonset --all-namespaces -o json | jq -r '.items[]| select(.status.replicas == .status.availableReplicas) | .metadata.name' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - svclb-traefik skip: false
# 通过 include 其他 gossfile 方式将上面定义的 k3s.yaml 检查项也包含进来gossfile: k3s.yaml: {}dns: # 检查部署的 filebrowser deployment 的 service IP 是否能正常解析到 filebrowser.default.svc.cluster.local: resolvable: true server: 10.43.0.10 timeout: 600 skip: false # 检查部署的 argo-workflow deployment 的 service IP 是否能正常解析到 argo-workflow-argo-workflows-server.default.svc.cluster.local: resolvable: true server: 10.43.0.10 timeout: 600 skip: false# 一些 HTTP 请求方式的检查http: # 检查 filebrowser 服务是否正常运行,类似于 pod 里的存活探针 http://{{ .Vars.ip_address }}/filebrowser/: status: 200 timeout: 600 skip: false method: GET # 检查 argo-workflow 是否正常运行 http://{{ .Vars.ip_address }}/workflows/api/v1/version: status: 200 timeout: 600 skip: false method: GET# 同样也是一些 shell 命令的检查项目command: # 检查容器镜像是否齐全,避免缺镜像的问题 check_container_images: exec: crictl images --output=json | jq -r '.images[].repoTags[]' | awk -F '/' '{print $NF}' | awk -F ':' '{print $1}' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - argocli - argoexec - workflow-controller - filebrowser - nginx skip: false # 检查容器运行的状态是否正常 check_container_status: exec: crictl ps --output=json | jq -r '.containers[].metadata.name' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - argo-server - controller - nginx - filebrowser skip: false # 检查一些 deployment 的状态是否正常 check_k8s_deployment_status: exec: kubectl get deploy -n default -o json | jq -r '.items[]| select(.status.replicas == .status.availableReplicas) | .metadata.name' | sort -u exit-status: 0 stderr: [] timeout: 0 stdout: - argo-workflow-argo-workflows-server - argo-workflow-argo-workflows-workflow-controller - filebrowser skip: false# 一些硬件参数的检查,比如 CPU 核心数、内存大小、可用内存大小matching: check_vm_cpu_core: content: {{ .Vars.cpu_core_number }} matches: gt: 1 check_vm_memory_size: content: {{ .Vars.memory_size }} matches: gt: 1880000 check_available_memory_size: content: {{ .Vars.available_memory_size }} matches: gt: 600000
另外 goss 也比较适合做一些巡检的工作。比如在一个 k8s 集群中进行巡检:检查集群内 pod 的状态、kubernetes 组件的状态、CNI 运行状态、节点的网络、磁盘存储空间、CPU 负载、内核参数、daemonset 服务状态等,都可以参照上述方式定义一系列的检查项,使用 goss 来帮我们自动完成巡检。
将 OVA 虚拟机在 VMware Workstation 上导入之后,由于虚拟机 IP 的变化可能会导致一些 Pod 处于异常的状态,这时候就需要对这些 Pod 进行强制删除,强制重启一下才能恢复正常。因此需要需要在虚拟机里增加一个 prepare.sh 脚本用来重启这些状态异常的 Pod。当导入 OVA 虚拟机后运行这个脚本让所有的 Pod 都正常运行起来,然后再调用 goss 来检查其他服务是否正常。
#!/bin/bashset -o errexitset -o nounsetset -o pipefailkubectl get pods --no-headers -n kube-system | grep -E '0/2|0/1|Error|Unknown|CreateContainerError|CrashLoopBackOff' | awk '{print $1}' | xargs -t -I {} kubectl delete pod -n kube-system --grace-period=0 --force {} > /dev/null 2>&1 || truekubectl get pods --no-headers -n default | grep -E '0/1|Error|Unknown|CreateContainerError|CrashLoopBackOff' | awk '{print $1}' | xargs -t -I {} kubectl delete pod -n default --grace-period=0 --force {} > /dev/null 2>&1 || truewhile true; do if kubectl get pods --no-headers --all-namespaces | grep -Ev 'Running|Completed'; then echo "Waiting for service readiness" sleep 10 else break fidonecd ${HOME}/.gosscat > vars.yaml << EOFip_address: $(ip r get 1 | sed "s/ uid.*//g" | awk '{print $NF}' | head -n1)cpu_core_number: $(grep -c ^processor /proc/cpuinfo)memory_size: $(grep '^MemTotal:' /proc/meminfo | awk '{print $2}')available_memory_size: $(grep '^MemAvailable:' /proc/meminfo | awk '{print $2}')EOFgoss --vars vars.yaml -g goss.yaml validate --retry-timeout=10s
之前一直都是登录 IPMI 管理页面,挂载远程的 ISO 文件手动安装。安装完成之后还需要配置 ESXi 管理网络的 IP 地址。整体的安装流程比较繁琐,而且物理服务器每次重启和开机都十分耗时,对经常要安装 ESXi 的 QE 小伙伴来讲十分痛苦。
为了后续测试起来爽快一点,不用再为安装 ESXi OS 而烦恼,于是就基于 Redfish 快速实现了一套自动化安装 ESXi OS 的工具 redfish-esxi-os-installer。通过它我们内部的戴尔、联想、HPE 服务器安装 ESXi OS 只需要填写一个配置文件并选择需要安装的 ESXi ISO,运行一下 Jenkins Job 等待十几分钟就能自动安装好。原本需要一个多小时的工作量,现在只需要运行一下 Jenkins Job 帮助我们自动安装好 ESXi OS 啦 😂,真是爽歪歪。
五一假期刚开始,正好有时间抽空整理一下最近学到的东西,和大家分享一下这套自动化安装 ESXi OS 工具。
目前市面上主流的裸金属服务器自动化安装 OS 的工具有 PXE 和 IPMI/Redfish 两种。
虽然内部也有 PXE 服务可用,但重启服务器和设置服务器的引导项为 PXE 启动仍然需要手动登录 IPMI 管理页面进行操作,无法做到自动重启和自动重装,仍有一定的工作量。而且 PXE 安装 OS 无法解决为每台服务器配置各自的安装盘和管理网络网卡及静态 IP 地址的问题,遂放弃。
Redfish 的概念和原理什么的就懒得介绍了,下面就直接剽窃一下官方的文档吧 😅:
DMTF
的Redfish®
是一个标准API
,旨在为融合、混合IT
和软件定义数据中心(SDDC
)提供简单和安全管理。在
Redfish
出现之前,现代数据中心环境中缺乏互操作管理标准。随着机构越来越青睐于大规模的解决方案,传统标准不足以成功管理大量简单的多节点服务器或混合基础设施。IPMI
是一种较早的带外管理标准,仅限于“最小公共集”命令集(例如,开机/关机/重启、温度值、文本控制台等),由于供应商扩展在所有平台上并不常见,导致了客户常用的功能集减少。许多用户开发了自己的紧密集成工具,但是也不得不依赖带内管理软件。而对于企业级用户来说,设备都是上千台,其需要统一的管理界面,就要对接不同供应商的
API
。当基本IPMI
功能已经不太好满足大规模Scale-out
环境时,如何以更便捷的方式调用服务器高级管理功能就是一个新的需求。为了寻求一个基于广泛使用的工具来加快发展的现代接口,现如今,客户需要一个使用互联网和
web
服务环境中常见的协议、结构和安全模型定义的API
。
Redfish
可扩展平台管理API
(The Redfish Scalable Platforms Management API
)是一种新的规范,其使用RESTful
接口语义来访问定义在模型格式中的数据,用于执行带外系统管理 (out of band systems management
)。其适用于大规模的服务器,从独立的服务器到机架式和刀片式的服务器环境,而且也同样适用于大规模的云环境。
Redfish
的第1
版侧重于服务器,为IPMI-over-LAN
提供了一个安全、多节点的替代品。随后的Redfish
版本增加了对网络接口(例如NIC
、CNA
和FC HBA
)、PCIe
交换、本地存储、NVDIMM
、多功能适配器和可组合性以及固件更新服务、软件更新推送方法和安全特权映射的管理。此外,Redfish
主机接口规范允许在操作系统上运行应用程序和工具,包括在启动前(固件)阶段-与Redfish
管理服务沟通。在定义
Redfish
标准时,协议与数据模型可分开并允许独立地修改。以模式为基础的数据模型是可伸缩和可扩展的,并且随着行业的发展,它将越来越具有人类可读性定义。
通过 Redfish 我们可以对服务器进行挂载/卸载 ISO、设置 BIOS 启动项、开机/关机/重启等操作。只需要使用一些特定的 ansible 模块,将它们缝合起来就能将整个流程跑通。
内部的服务器戴尔、联想、HPE 的较多,这三家厂商对 Redfish 支持的也比较完善。于是这个 ESXi OS 自动化安装工具 redfish-esxi-os-installer 就基于 Redfish 并结合 Jenkins 实现了一套自动化安装 ESXi OS 的方案,下面就详细介绍一下这套方案的安装流程和技术实现细节。
该步骤主要是获取 ESXi OS 所要安装的硬盘和管理网络网卡设备信息。
要指定 ESXi OS 安装的硬盘,可以通过硬盘型号或序列号的方式。如果当前服务器已经安装了 ESXi,登录到 ESXi 则可以查看到所安装硬盘的型号:
DELLBOSS VD
(注意中间的空格不要省略);ThinkSystem M.2
Device Model
,比如:╭─root@esxi-debian-nas ~╰─# smartctl -x /dev/sdbsmartctl 6.6 2017-11-05 r4594 [x86_64-linux-4.19.0-18-amd64] (local build)Copyright (C) 2002-17, Bruce Allen, Christian Franke, www.smartmontools.org=== START OF INFORMATION SECTION ===Device Model: HGST HUH721212ALE604Serial Number: 5PJAMUHDLU WWN Device Id: 5 000cca 291e10521
如果有多块型号相同的硬盘,ESXi 会默认选择第一块,如果要指定某一块硬盘则使用 WWN 号的方式,获取 WWN ID 的命令如下:
╭─root@esxi-debian-nas ~╰─# smartctl -x /dev/sdb | sed -n "s/LU WWN Device Id:/naa./p" | tr -d ' 'naa.5000cca291e10521
vmnic4
通过以上方式确定好 ESXi OS 所安装的硬盘型号或序列号,以及 ESXi 默认管理网络 vSwitch0 所关联的物理网卡设备名或 MAC 地址之后,我们就将这些配置参数填入到该配置文件当中。后面的工具会使用该配置为每台机器生成不同的 kickstart 文件,在 kickstart 文件中指定 ESXi OS 安装的硬盘,ESXi 管理网络所使用的网卡,以及设置静态 IP、子网掩码、网关、主机名等参数。
hosts:- ipmi: vendor: lenovo # 服务器厂商名 [dell, lenovo, hpe] address: 10.172.70.186 # IPMI IP 地址 username: username # IPMI 用户名 password: password # IPMI 密码 esxi: esxi_disk: ThinkSystem M.2 # ESXi OS 所安装硬盘的型号或序列号 password: password # ESXi 的 root 用户密码 address: 10.172.69.86 # ESXi 管理网络 IP 地址 gateway: 10.172.64.1 # ESXi 管理网络网关 netmask: 255.255.240.0 # ESXi 管理网络子网掩码 hostname: esxi-69-86 # ESXi 主机名(可选) mgtnic: vmnic4 # ESXi 管理网络网卡名称或MAC 地址- ipmi: vendor: dell address: 10.172.18.191 username: username password: password esxi: esxi_disk: DELLBOSS VD password: password address: 10.172.18.95 gateway: 10.172.16.1 netmask: 255.255.240.0 mgtnic: B4:96:91:A7:3F:D6
在 tools.sh 脚本中通过 yq 命令行工具解析 config.yaml
配置文件,得到每台主机的配置信息,并根据该信息生成一个 ansible 的 inventory 文件
function rendder_host_info(){ local index=$1 vendor=$(yq -e eval ".hosts.[$index].ipmi.vendor" ${CONFIG}) os_disk="$(yq -e eval ".hosts.[$index].esxi.esxi_disk" ${CONFIG})" esxi_mgtnic=$(yq -e eval ".hosts.[$index].esxi.mgtnic" ${CONFIG}) esxi_address=$(yq -e eval ".hosts.[$index].esxi.address" ${CONFIG}) esxi_gateway=$(yq -e eval ".hosts.[$index].esxi.gateway" ${CONFIG}) esxi_netmask=$(yq -e eval ".hosts.[$index].esxi.netmask" ${CONFIG}) esxi_password=$(yq -e eval ".hosts.[$index].esxi.password" ${CONFIG}) ipmi_address=$(yq -e eval ".hosts.[$index].ipmi.address" ${CONFIG}) ipmi_username=$(yq -e eval ".hosts.[$index].ipmi.username" ${CONFIG}) ipmi_password=$(yq -e eval ".hosts.[$index].ipmi.password" ${CONFIG}) esxi_hostname="$(yq -e eval ".hosts.[$index].esxi.hostname" ${CONFIG} 2> /dev/null || true)"}function gen_inventory(){ cat << EOF > ${INVENTORY}_hpe__dell__lenovo_[all:children]hpedelllenovoEOF for i in $(seq 0 `expr ${nums} - 1`); do rendder_host_info ${i} host_info="${ipmi_address} username=${ipmi_username} password=${ipmi_password} esxi_address=${esxi_address} esxi_password=${esxi_password}" sed -i "/_${vendor}_/a ${host_info}" ${INVENTORY} done sed -i "s#^_dell_#[dell]#g;s#^_lenovo_#[lenovo]#g;s#_hpe_#[hpe]#g" ${INVENTORY} echo "gen inventory success"}
生成后的 inventory 文件内容如下,根据不同的厂商名称进行分组
[hpe]10.172.18.191 username=username password=password esxi_address=10.172.18.95 esxi_password=password[dell]10.172.18.192 username=username password=password esxi_address=10.172.18.96 esxi_password=password[lenovo]10.172.18.193 username=username password=password esxi_address=10.172.18.97 esxi_password=password[all:children]hpedelllenovo
通过 Redfish 的 GetSystemInventory 命令获取服务器的 inventory 清单来检查登录 Redfish 是否正常,用户名或密码是否正确。
- name: Getting system inventory community.general.redfish_info: category: Systems command: GetSystemInventory baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}"
在 tools.sh 同样使用 yq 命令行工具渲染配置文件,得到每台主机的配置信息,为每台主机生成一个特定的 kickstart 文件。
在 kickstart 文件中我们我们可以通过 install --overwritevmfs --firstdisk="${ESXI_DISK}"
配置 ESXi OS 安装在哪一块硬盘上;
通过 network --bootproto=static
为 ESXi 管理网络配置静态 IP、子网掩码、网关、主机名、物理网卡等参数。需要注意的是,如果使用 MAC 地址指定网卡,MAC 地址必须为大写,因此需要使用 tr 进行了一下大小写转换;
通过 clearpart --alldrives --overwritevmfs
可以清除所有硬盘上的分区,我们安装时一般是将它们全部清理掉,方便进行测试;
最后再开启 SSH 服务并开启 sshServer 的防火墙,方便后续测试使用;
function gen_iso_ks(){ local ISO_KS=$1 local ESXI_DISK=${os_disk} local IP_ADDRESS=${esxi_address} local NETMASK=${esxi_netmask} local GATEWAY=${esxi_gateway} local DNS_SERVER="${GATEWAY}" local PASSWORD=${esxi_password} local HOSTNAME="$(echo ${esxi_hostname} | sed "s/null/esxi-${esxi_address//./-}/")" local MGTNIC=$(echo ${esxi_mgtnic} | tr '[a-z]' '[A-Z]' | sed 's/VMNIC/vmnic/g') cat << EOF > ${ISO_KS}vmaccepteula# Set the root password for the DCUI and Tech Support Moderootpw ${PASSWORD}# Set the keyboardkeyboard 'US Default'# wipe exisiting VMFS store # CAREFUL!clearpart --alldrives --overwritevmfs# Install on the first local disk available on machineinstall --overwritevmfs --firstdisk="${ESXI_DISK}"# Set the network to DHCP on the first network adapternetwork --bootproto=static --hostname=${HOSTNAME} --ip=${IP_ADDRESS} --gateway=${GATEWAY} --nameserver=${DNS_SERVER} --netmask=${NETMASK} --device="${MGTNIC}"reboot%firstboot --interpreter=busybox# Enable SSHvim-cmd hostsvc/enable_sshvim-cmd hostsvc/start_sshesxcli network firewall ruleset set --enabled=false --ruleset-id=sshServerEOF}
这一步的操作主要是修改 ESXi ISO 的启动项配置,配置 ks 文件的路径,主要是修改 ISO 文件里的 boot.cfg
和 efi/boot/boot.cfg
文件。在启动参数中加入 ks=cdrom:/KS.CFG
用于指定 ESXi OS 安装通过读取 kickstart 脚本的方式来完成。
sed -i -e 's#cdromBoot#ks=cdrom:/KS.CFG systemMediaSize=small#g' boot.cfgsed -i -e 's#cdromBoot#ks=cdrom:/KS.CFG systemMediaSize=small#g' efi/boot/boot.cfg
另外在 VMware 的 KB Boot option to configure the size of ESXi system partitions (81166) 中,提到过可以设置 systemMediaSize=small
来调整 VMFS-L 分区的大小。ESXi 7.0 版本之后会默认创建一个 VMFS-L 分区,如果 SATA DOM 盘比较小的话比如只有 128G,建议设置此参数。不然可能会导致安装完 ESXi OS 之后磁盘剩余的空间都被 VMFS-L 分区给占用,导致没有一个本地的数据存储可以使用。
修改好 ESXi 的启动配置之后,我们再使用 genisoimage 命令重新构建一个 ESXi ISO 文件,将构建好的 ISO 文件放到一个 http 文件服务的目录下,如 nginx 的 /usr/share/nginx/html/iso
。后面将会通过 http 的方式将 ISO 挂载到服务器的虚拟光驱上。
function rebuild_esxi_iso() { local dest_iso_mount_dir=$1 local dest_iso_path=$2 pushd ${dest_iso_mount_dir} > /dev/null sed -i -e 's#cdromBoot#ks=cdrom:/KS.CFG systemMediaSize=small#g' boot.cfg sed -i -e 's#cdromBoot#ks=cdrom:/KS.CFG systemMediaSize=small#g' efi/boot/boot.cfg genisoimage -J \ -R \ -o ${dest_iso_path} \ -relaxed-filenames \ -b isolinux.bin \ -c boot.cat \ -no-emul-boot \ -boot-load-size 4 \ -boot-info-table \ -eltorito-alt-boot \ -eltorito-boot efiboot.img \ -quiet --no-emul-boot \ . > /dev/null popd > /dev/null}
重新构建好 ESXi ISO 之后的 nginx 目录结构如下:
# tree /usr/share/nginx/html/iso//usr/share/nginx/html/iso/├── redfish│ ├── 172.20.18.191│ │ └── VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso # 重新构建的 ISO│ ├── 172.20.18.192│ │ └── VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso # 重新构建的 ISO│ ├── 172.20.18.193│ │ └── VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso # 重新构建的 ISO│ └── 172.20.70.186│ └── VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso # 重新构建的 ISO├── VMware-VMvisor-Installer-6.7.0.update03-14320388.x86_64.iso # 原 ISO├── VMware-VMvisor-Installer-7.0U2a-17867351.x86_64.iso # 原 ISO└── VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso # 原 ISO
redfish 插入/弹出 ISO 操作有现成可用的 ansible 模块可以使用,不必重复造轮子。不同的服务器厂商调用的模块可能会有所不同,不过参数基本上是相同的。
如果当前服务器上已经挂载了一些其他的 ISO,要将他们全部弹出才行,不然在挂载 ISO 的时候会失败退出,并且也能避免多个 ISO 重启启动的时候引起冲突启动到另一个 ISO 中。
- name: Lenovo | Eject all Virtual Media community.general.xcc_redfish_command: category: Manager command: VirtualMediaEject baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" resource_id: "1" when: - inventory_hostname in groups['lenovo'] tags: - mount-iso - umount-iso
GetVirtualMedia
命令获取到一个 ISO 的 URL 列表,然后再根据这个列表一一弹出。- name: Get virtual media details community.general.redfish_info: baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" category: "Manager" command: "GetVirtualMedia" register: result tags: - mount-iso - umount-iso when: - inventory_hostname not in groups['lenovo']- name: Eject virtual media community.general.redfish_command: baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" category: "Manager" command: "VirtualMediaEject" virtual_media: image_url: "{{ item }}" with_items: "{{ result.redfish_facts.virtual_media.entries[0][1] | selectattr('ConnectedVia', 'equalto','URI') | map(attribute='Image') | list }}" when: - inventory_hostname not in groups['lenovo'] tags: - mount-iso - umount-iso
在弹出一个 ISO 的时候需要先知道 ISO 的 URL,感觉有点奇葩 😂。更合理的应该是需要一个挂载点的标识,就像比 Linux 上的挂载点。在 umount 挂载的设备时,只需要知道挂载点即可,不需要知道挂载的设备是什么。在 ISSUE VirtualMediaEject should not require image_url 中有大佬反馈过在弹出 ISO 的时候不应该需要 image url,不过被 maintainer 给否决了 😅。
Yes, at least with the behavior we’ve implemented today the image URL is needed since the expectation is the user is specifying the image URL for the ISO to eject. I think we need to consider some things first before making changes.
If the image URL is not given, then what exactly should be ejected? All virtual media your example indicates? This seems a bit heavy handed in my opinion, but others might like this behavior. Redfish itself doesn’t support an “eject all” type of operation, and I suspect the script you’re referencing is either using OEM actions or is just looping on all slots and ejecting everything.
Should a user be allowed specify an alternative identifier (such as the “Id” of the virtual media instance) in order to control what slot is ejected?
Certainly would like opinions from others for desired behavior. I do like the idea of keeping the mandatory argument list as minimal as possible, but would like to agree upon the desired behavior first.
community.general.xcc_redfish_command
模块,redfish 的 command 为 VirtualMediaInsert;- name: Lenovo | Insert {{ image_url }} Virtual Media community.general.xcc_redfish_command: category: Manager command: VirtualMediaInsert baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" virtual_media: image_url: "{{ image_url }}" media_types: - CD - DVD resource_id: "1" when: - inventory_hostname in groups['lenovo'] tags: - mount-iso
community.general.redfish_command
模块,command 和联想的相同;- name: Insert {{ image_url }} ISO as virtual media device community.general.redfish_command: baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" category: "Manager" command: "VirtualMediaInsert" virtual_media: image_url: "{{ image_url }}" media_types: - CD - DVD when: - inventory_hostname not in groups['lenovo'] tags: - mount-iso
需要注意的是:如果使用 community.general.redfish_command
模块为联想的服务器挂载 ISO 会提示 4xx 错误,必须使用 community.general.xcc_redfish_command
模块才行。
此过程是将服务器的启动项设置为虚拟光驱,不同厂商的服务器调用的 ansible 模块可能也会有所不同。
- name: Set one-time boot device to {{ bootdevice }} community.general.redfish_command: category: Systems command: SetOneTimeBoot bootdevice: "{{ bootdevice }}" baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" timeout: 20 when: - inventory_hostname not in groups['dell']
- name: Dell | set iDRAC attribute for one-time boot from virtual CD community.general.idrac_redfish_config: baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" category: "Manager" command: "SetManagerAttributes" manager_attributes: ServerBoot.1.BootOnce: "Enabled" ServerBoot.1.FirstBootDevice: "VCD-DVD" when: - inventory_hostname in groups['dell']
重启服务器直接调用 community.general.redfish_command
模块就可以。不过需要注意的是,重启服务器之前要保证服务器当前状态为开启状态,因此调用一下 redfish 的 PowerOn 命令对服务器进行开机,如果已处于开机状态则无影响,然后再调用 PowerForceRestart 命令重启服务器。
- hosts: all name: Power Force Restart the host gather_facts: false tasks: - name: Turn system power on community.general.redfish_command: category: Systems command: PowerOn baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" - name: Reboot system community.general.redfish_command: category: Systems command: PowerForceRestart baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" timeout: 20 tags: - reboot
这里还有优化的空间,就是根据电源的状态决定是重启还是开机,不过有点麻烦懒得弄了 😅
服务器重启之后,我们通过 govc 命令不断尝试连接 ESXi 主机,如果能够正常连接则说明 ESXi OS 已经安装完成了。一般情况下等待 15 分钟左右就能安装完成,期间需要重启服务器两次,每次重启大概需要 5 分钟左右,实际上 ESXi 进入安装页面到安装完成只需要 5 分钟左右,服务器开机自检占用的时间会稍微长一点。
- hosts: all name: Wait for the ESXi OS installation to complete gather_facts: false vars: esxi_username: "root" govc_url: "https://{{ esxi_username }}:{{ esxi_password }}@{{ esxi_address }}" tasks: - name: "Wait for {{ inventory_hostname }} install ESXi {{ esxi_address }} host to be complete" shell: "govc about -k=true -u={{ govc_url}}" retries: 60 delay: 30 register: result until: result.rc == 0 tags: - post-check
为了方便操作,将上述流程使用 Makefile 进行封装一下,如果不配置 Jenkins Job 的话,可以在本地填写好 config.yaml
配置文件,然后运行 make 命令来进行相关操作。
SRC_ISO_DIR ?= /usr/share/nginx/html/isoHTTP_DIR ?= /usr/share/nginx/html/iso/redfishHTTP_URL ?= http://172.20.17.20/iso/redfishESXI_ISO ?= VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.isoSRC_ISO_DIR # 原 ESXi ISO 的存放目录ESXI_ISO # ESXi ISO 的文件名,如 VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.isoHTTP_DIR # HTTP 服务器的静态文件存放目录,比如 /usr/share/nginx/html 或 /var/www/html # 重新构建好的 ISO 文件将存放到这个目录当中HTTP_URL # HTTP 服务器的 URL 地址,比如 http://172.20.29.171/iso/redfish
make docke-run # 在 docker 容器里运行所有操作,好处就是不用再安装一堆 ansible 等工具的依赖make inventory # 根据 config.yaml 配置文件生成 ansible 的 inventory 文件make pre-check # 检查生成的 inventory 文件是否正确,连接 redfish 是否正常make build-iso # 为每台主机生成 kickstart 文件并重新构建 ESXi OS ISO 文件make mount-iso # 将构建好的 ISO 文件通过 redfish 挂载到物理服务器的虚拟光驱,并设备启动项make reboot # 重启服务器,进入到虚拟光驱启动 ESXi inatllermake post-check # 等待 ESXi OS 安装完成make install-os # 运行 pre-check, mount-iso, reboot, post-check
虽然在 Makefile 里封装了比较方便的命令操作,但是对于不太熟悉这套流程的使用人员来讲还是不够便捷。对于使用人员来讲不需要知道具体的流程是什么,因此还需要提供一个更为便捷的入口来使用这套工具,对外屏蔽掉技术实现的细节。
在我们内部,老牌 CI 工具 Jenkins 大叔十分受欢迎,使用的十分普遍。之前同事也常调侃:我们内部的 Jenkins 虽然达不到人手一个的数量,但每个团队有两三个自己的 Jenkins 再正常不过了
🤣。因此提供了一个 Jenkins Job 来运行这套安装工具再完美不过了。这样使用人员就不用再 clone repo 代码,傻乎乎地运行一些 make 命令了,毕竟一个 Jenkins build 的按钮比 make 命令好好用得太多。
我们组的 Jenkins 比较特殊,是使用 kubernetes Pod 作为动态 Jenkins slave 节点,即每运行一个 Jenkins Job 就会根据定义的 Pod 模版创建一个 Pod 到指定的 Kubernetes 集群中,然后 Jenkinsfile 中定义的 stage 都会运行在这个 Pod 容器内。这些内容可以参考一下我之前写的 Jenkins 大叔与 kubernetes 船长手牵手 🧑🤝🧑。
如果你熟悉 Jenkins 的话,可以创建一个 Jenkins Job ,并在 Job 中设置好如下几个参数,并将这个 Jenkinsfile 中的内容复制到 Jenkins Job 的配置中。
参数名 | 参数类型 | 说明 |
---|---|---|
esxi_iso | ArrayList | ESXi ISO 文件名列表 |
http_server | String | HTTP 服务器的 IP 地址 |
http_dir | String | HTTP 服务器的文件目录路径 |
config_yaml | Text | config.yaml 配置文件内容 |
// params of jenkins jobdef ESXI_ISO = params.esxi_isodef CONFIG_YAML = params.config_yamldef HTTP_SERVER = params.http_server// default params for the jobdef HTTP_DIR = params.http_dir ?: "/usr/share/nginx/html"def SRC_ISO_DIR = params.src_iso_dir ?: "${HTTP_DIR}/iso"def DEST_ISO_DIR = params.dest_iso_dir ?: "${HTTP_DIR}/iso/redfish"def WORKSPACE = env.WORKSPACEdef JOB_NAME = "${env.JOB_BASE_NAME}"def BUILD_NUMBER = "${env.BUILD_NUMBER}"def POD_NAME = "jenkins-${JOB_NAME}-${BUILD_NUMBER}"def POD_IMAGE = params.pod_image ?: "ghcr.io/muzi502/redfish-esxi-os-installer:v0.1.0-alpha.1"// Kubernetes pod template to run.podTemplate( cloud: "kubernetes", namespace: "default", name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podspec: containers: - name: runner image: ${POD_IMAGE} imagePullPolicy: Always tty: true volumeMounts: - name: http-dir mountPath: ${HTTP_DIR} securityContext: privileged: true env: - name: ESXI_ISO value: ${ESXI_ISO} - name: SRC_ISO_DIR value: ${SRC_ISO_DIR} - name: HTTP_DIR value: ${DEST_ISO_DIR} - name: HTTP_URL value: http://${HTTP_SERVER}/iso/redfish - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "jenkins/inbound-agent:4.11.2-4-alpine" imagePullPolicy: IfNotPresent volumes: - name: http-dir nfs: server: ${HTTP_SERVER} path: ${HTTP_DIR}""",) { node(POD_NAME) { try { container("runner") { writeFile file: 'config.yaml', text: "${CONFIG_YAML}" stage("Inventory") { sh """ cp -rf /ansible/* . make inventory """ } stage("Precheck") { sh """ make pre-check """ } if (params.build_iso) { stage("Build-iso") { sh """ make build-iso """ } } stage("Mount-iso") { sh """ make mount-iso """ } stage("Reboot") { sh """ make reboot sleep 60 """ } stage("Postcheck") { sh """ make post-check """ } } stage("Success"){ MESSAGE = "【Succeed】Jenkins Job ${JOB_NAME}-${BUILD_NUMBER} Link: ${BUILD_URL}" // slackSend(channel: '${SLACK_CHANNE}', color: 'good', message: "${MESSAGE}") } } catch (Exception e) { MESSAGE = "【Failed】Jenkins Job ${JOB_NAME}-${BUILD_NUMBER} Link: ${BUILD_URL}" // slackSend(channel: '${SLACK_CHANNE}', color: 'warning', message: "${MESSAGE}") throw e } }}
或者参考 Export/import jobs in Jenkins 将这个 Job 的配置导入到 Jenkins 当中,并设置好上面提到的几个参数。
整体上该方案有一点不足的就是需要人为地确认 ESXi OS 安装硬盘的型号/序列号,以及 ESXi 管理网络所使用的物理网卡。其实是可以通过 redfish 的 API 来统一地获取,然后再根据这些硬件设备信息进行选择,这样就不用登录到每一台物理服务器上进行查看了。
但考虑到实现成本,工作量会翻倍,而且我们的服务器都是固定的,只要人为确认一次就可以,下一次重装 ESXi OS 的时候只需要复制粘贴上一次的硬件配置即可,所以目前并没有打算做获取硬件信息的功能。
而且即便是将硬件信息获取出来,如果没有一个可视化的 Web UI 展示这些设备信息,也很难从一堆硬件数据中找出特定的设备,对这些数据进行 UI 展示工作量也会翻倍,因此暂时不再考虑这个功能了。
有些服务器比如 HPE 在挂载一个不存在的 ISO 时并不会报错,当时我排查了好久才发现 😂,我一直以为是启动项设置的问题。因此在挂载 ISO 之前我们可以通过 curl 的方式检查一下 ISO 的 URL 是否正确,如果 404 不存在的话就报错退出。
- hosts: all name: Mount {{ image_url }} ISO gather_facts: false tasks: - name: Check {{ image_url }} ISO file exists shell: "curl -sI {{ image_url }}" register: response failed_when: "'200 OK' not in response.stdout or '404 Not Found' in response.stdout" tags: - mount-iso
目前的方案是为将 ESXi 的 kickstart 文件 KS.CFG 放到了 ESXi OS ISO 镜像里,由于每台主机的 kickstart 文件都不相同,这就需要为每台服务器构建一个 ISO 文件,如果机器数量比较多的话,可能会占用大量的磁盘存储空间,效率上会有些问题。也尝试过将 kickstart 文件单独放到一个 ISO 中,大体的思路如下:
$ genisoimage -o /tmp/ks.iso -V KS ks.cfg
$ sed -i -e 's#cdromBoot#ks=hd:KS:/ks.cfg systemMediaSize=small#g' boot.cfg$ sed -i -e 's#cdromBoot#ks=hd:KS:/ks.cfg systemMediaSize=small#g' efi/boot/boot.cfg
- name: Insert {{ item }} ISO as virtual media device community.general.redfish_command: baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" category: "Manager" command: "VirtualMediaInsert" virtual_media: image_url: "{{ item }}" media_types: - CD - DVD with_items: - "{{ esxi_iso_url }}" - "{{ ks_iso_url }}" when: - inventory_hostname not in groups['lenovo'] tags: - mount-iso
等这些都修改好之后我满怀期待地运行了 make mount-iso 命令等到奇迹的发生,没想到直接翻车了!不支持挂载两个 ISO,白白高兴一场,真气人 😡
TASK [Insert {{ item }} ISO as virtual media device] ******************************************************************************************changed: [10.172.18.191] => (item=http://10.172.29.171/iso/redfish/VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso)changed: [10.172.18.192] => (item=http://10.172.29.171/iso/redfish/VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso)changed: [10.172.18.193] => (item=http://10.172.29.171/iso/redfish/VMware-VMvisor-Installer-7.0U3d-19482537.x86_64.iso)failed: [10.172.18.193] (item=http://10.172.29.171/iso/redfish/10.172.18.193/ks.iso) => {"ansible_loop_var": "item", "changed": false, "item": "http://10.172.29.171/iso/redfish/10.172.18.193/ks.iso", "msg": "Unable to find an available VirtualMedia resource supporting ['CD', 'DVD']"}failed: [10.172.18.192] (item=http://10.172.29.171/iso/redfish/10.172.18.192/ks.iso) => {"ansible_loop_var": "item", "changed": false, "item": "http://10.172.29.171/iso/redfish/10.172.18.192/ks.iso", "msg": "Unable to find an available VirtualMedia resource supporting ['CD', 'DVD']"}failed: [10.172.18.191] (item=http://10.172.29.171/iso/redfish/10.172.18.191/ks.iso) => {"ansible_loop_var": "item", "changed": false, "item": "http://10.172.29.171/iso/redfish/10.172.18.191/ks.iso", "msg": "Unable to find an available VirtualMedia resource supporting ['CD', 'DVD']"}
或许将 ISO 替换成软盘 floppy 的方式可能行得通,不过当我看了 create-a-virtual-floppy-image-without-mount 后直接把我整不会了,没想创建一个软盘文件到这么麻烦,还是直接放弃该方案吧 🌚。
多说一句,之所以想到使用软盘的方式是因为之前在玩 Packer 的时候,研究过它就是将 kickstart 文件制作成一个软盘,插入到虚拟机中。虚拟机开机后通过 vCenter API 发送键盘输入,插入 kickstart 的路径,anaconda 执行自动化安装 OS。
==> vsphere-iso-base: Creating VM...==> vsphere-iso-base: Customizing hardware...==> vsphere-iso-base: Mounting ISO images...==> vsphere-iso-base: Adding configuration parameters...==> vsphere-iso-base: Creating floppy disk... vsphere-iso-base: Copying files flatly from floppy_files vsphere-iso-base: Done copying files from floppy_files vsphere-iso-base: Collecting paths from floppy_dirs vsphere-iso-base: Resulting paths from floppy_dirs : [./kickstart/centos/http/] vsphere-iso-base: Recursively copying : ./kickstart/centos/http/ vsphere-iso-base: Done copying paths from floppy_dirs vsphere-iso-base: Copying files from floppy_content vsphere-iso-base: Done copying files from floppy_content==> vsphere-iso-base: Uploading created floppy image==> vsphere-iso-base: Adding generated Floppy...==> vsphere-iso-base: Set boot order temporary...==> vsphere-iso-base: Power on VM...==> vsphere-iso-base: Waiting 15s for boot...==> vsphere-iso-base: Typing boot command...==> vsphere-iso-base: Waiting for IP...root@devbox-fedora:/root # scp 192.168.24.43:/vmfs/volumes/Packer/base-os-centos7/packer-tmp-created-floppy.flp .packer-tmp-created-floppy.flp 100% 1440KB 89.4MB/s 00:00root@devbox-fedora:/root # mount packer-tmp-created-floppy.flp /mntroot@devbox-fedora:/root # readlink /dev/disk/by-label/packer../../loop2root@devbox-fedora:/root # df -h /mntFilesystem Size Used Avail Use% Mounted on/dev/loop2 1.4M 16K 1.4M 2% /mntroot@devbox-fedora:/root #root@devbox-fedora:/root # ls /mntHTTProot@devbox-fedora:/root # ls /mnt/HTTP7root@devbox-fedora:/root # ls /mnt/HTTP/7KS.CFG
不一定可行,在通过 http 方式读取 kickstart 文件之前,ESXi OS installer 需要有一个 IP 地址才行。如果服务器如果有多块网卡的话,就很难确定是否分配到一个 IP,使用默认 DHCP 的方式并不一定能获取到正确的 IP 地址。因此读取 kickstart 文件的方式还是建议使用 ISO 的方式,这样在安装 OS 时对网络环境无依赖,更稳定一些。
目前该方案只支持 ESXi OS 的安装,其他 OS 的自动化安装其实原理是一样的。比如 CentOS 同样也是修改 kickstart 文件。如果要指定 OS 所安装的磁盘可以参考一下戴尔官方的一篇文档 Automating Operating System Deployment to Dell BOSS – Techniques for Different Operating Systems 。
%include /tmp/bootdisk.cfg%pre# Use DELLBOSS device for OS install if present.BOSS_DEV=$(find /dev -name "*DELLBOSS*" -printf %P"\n" | egrep -v -e part -e scsi| head -1)if [ -n "$BOSS_DEV" ]; then echo ignoredisk --only-use="$BOSS_DEV" > /tmp/bootdisk.cfgfi%end
如果要为某块物理网卡配置 IP 地址,可以根据 MAC 地址找到对应的物理网卡,然后将静态 IP 配置写入到网卡配置文件当中。比如 CentOS 在 kickstart 中为某块物理网卡配置静态 IP,可以采用如下方式:
MAC_ADDRESS 在生成 kickstart 文件的时候根据 config.yaml 动态修改的# MAC_ADDRESS=B4:96:91:A7:3F:D6# 根据 MAC 地址获取到网卡设备的名称NIC=$(grep -l ${MAC_ADDRESS} /sys/class/net/*/address | awk -F'/' '{print $5}')# 将网卡静态 IP 配置写入到文件当中cat << EOF > /etc/sysconfig/network-scripts/ifcfg-${NIC}TYPE=EhternetBOOTPROTO=staticDEFROUTE=yesNAME=${NIC}DEVICE=${NIC}ONBOOT=yesIPADDR=${IP}NETMASK=${NETMASK}GATEWAY=${GATEWAY}EOF
由于时间关系,在这里就不再进行深入讲解了,在这里只是提供一个方法和思路。至于 Debian/Ubuntu 发行版,还是你们自己摸索吧,因为我工作中确实没有在物理服务器上安装这些发行版的场景,毕竟国内企业私有云环境中使用 CentOS/RedHat 系列发行版的占绝大多数。
govc 是 VMware 官方 govmomi 库的一个封装实现。使用它可以完成对 ESXi 主机或 vCenter 的一些操作。比如创建虚拟机、管理快照等。基本上能在 ESXi 或 vCenter 上的操作,在 govmomi 中都有对应的实现。目前 govc 支持的 ESXi / vCenter 版本有 7.0, 6.7, 6.5 , 6.0 (5.x 版本太老了,干脆放弃吧),另外也支持 VMware Workstation 的某些版本。
使用 govc 连接 ESXi 主机或 vCenter 可以通过设置环境变量或者命令行参数,建议使用环境变量,如果通过命令行 flag 的话,将明文规定用户名和密码输出来有一定的安全风险。
Options: -cert= Certificate [GOVC_CERTIFICATE] -dc= Datacenter [GOVC_DATACENTER] -debug=false Store debug logs [GOVC_DEBUG] -dump=false Enable Go output -host= Host system [GOVC_HOST] -host.dns= Find host by FQDN -host.ip= Find host by IP address -host.ipath= Find host by inventory path -host.uuid= Find host by UUID -json=false Enable JSON output -k=false Skip verification of server certificate [GOVC_INSECURE] -key= Private key [GOVC_PRIVATE_KEY] -persist-session=true Persist session to disk [GOVC_PERSIST_SESSION] -tls-ca-certs= TLS CA certificates file [GOVC_TLS_CA_CERTS] -tls-known-hosts= TLS known hosts file [GOVC_TLS_KNOWN_HOSTS] -trace=false Write SOAP/REST traffic to stderr -u=https://root@esxi.yoi.li/sdk ESX or vCenter URL [GOVC_URL] -verbose=false Write request/response data to stderr -vim-namespace=vim25 Vim namespace [GOVC_VIM_NAMESPACE] -vim-version=7.0 Vim version [GOVC_VIM_VERSION] -xml=false Enable XML output
通过 GOVC_URL
环境变量指定 ESXi 主机或 vCenter 的 URL,登录的用户名和密码可设置在 GOVC_URL 中或者单独设置 GOVC_USERNAME
和 GOVC_PASSWORD
。如果 https 证书是自签的域名或者 IP 需要通过设置 GOVC_INSECURE=true
参数来允许不安全的 https 连接。
$ export GOVC_URL="https://root:password@esxi.k8s.li"$ export GOVC_INSECURE=true$ govc aboutFullName: VMware ESXi 7.0.2 build-17867351Name: VMware ESXiVendor: VMware, Inc.Version: 7.0.2Build: 17867351OS type: vmnix-x86API type: HostAgentAPI version: 7.0.2.0Product ID: embeddedEsxUUID:
如果用户名和密码当中有特殊字符比如 \ @ /
,建议分别设置 GOVC_URL
、GOVC_USERNAME
和 GOVC_PASSWORD
这样能避免特殊字符在 GOVC_URL
出现一些奇奇怪怪的问题。
govc host.info
通过 host.info 自命命令可以得到 ESXi 主机的基本信息,
$ govc host.infoName: hp-esxi.lan Path: /ha-datacenter/host/hp-esxi.lan/hp-esxi.lan Manufacturer: HPE Logical CPUs: 6 CPUs @ 3000MHz Processor type: Genuine Intel(R) CPU 0000 @ 3.00GHz CPU usage: 3444 MHz (19.1%) Memory: 32613MB Memory usage: 26745 MB (82.0%) Boot time: 2021-12-05 06:11:53.42802 +0000 UTC State: connected
如果加上 -json 参数会得到一个至少 3w 行的 json 输出,里面包含的 ESXi 主机的所有信息,然后可以使用 jq 命令去过滤出一些自己所需要的参数。
╭─root@esxi-debian-devbox ~╰─# govc host.info -json=true > host_info.json╭─root@esxi-debian-devbox ~╰─# wc host_info.json 34522 73430 1188718 host_info.json╭─root@esxi-debian-devbox ~╰─# govc host.info -json | jq '.HostSystems[0].Summary.Hardware'{ "Vendor": "HPE", "Model": "ProLiant MicroServer Gen10 Plus", "Uuid": "30363150", "MemorySize": 34197471232, "CpuModel": "Genuine Intel(R) CPU 0000 @ 3.00GHz", "CpuMhz": 3000, "NumCpuPkgs": 1, "NumCpuCores": 6, "NumCpuThreads": 6, "NumNics": 4, "NumHBAs": 3}
如果加上 -dump 参数,则会以 Golang 结构体的格式来输出,输出的内容也是包含了 ESXi 主机的所有信息,用它可以比较方便地定位某个信息的结构体,这一点对基于 govmomi 来开发其他的功能来说十分方便。尤其是在写单元测试的时候,可以从这里 dump 出一些数据来进行 mock。需要注意的是,并不是所有的子命令都支持 json 格式的输出。
mo.HostSystem{ ManagedEntity: mo.ManagedEntity{ ExtensibleManagedObject: mo.ExtensibleManagedObject{ Self: types.ManagedObjectReference{Type:"HostSystem", Value:"ha-host"}, Value: nil, AvailableField: nil, }, Parent: &types.ManagedObjectReference{Type:"ComputeResource", Value:"ha-compute-res"}, CustomValue: nil, OverallStatus: "green", ConfigStatus: "yellow", ConfigIssue: []types.BaseEvent{ &types.RemoteTSMEnabledEvent{ HostEvent: types.HostEvent{ Event: types.Event{ Key: 1, ChainId: 0, CreatedTime: time.Now(), UserName: "", Datacenter: (*types.DatacenterEventArgument)(nil), ComputeResource: (*types.ComputeResourceEventArgument)(nil), Host: &types.HostEventArgument{ EntityEventArgument: types.EntityEventArgument{ EventArgument: types.EventArgument{}, Name: "hp-esxi.lan", }, Host: types.ManagedObjectReference{Type:"HostSystem", Value:"ha-host"}, }, Vm: (*types.VmEventArgument)(nil), Ds: (*types.DatastoreEventArgument)(nil), Net: (*types.NetworkEventArgument)(nil), Dvs: (*types.DvsEventArgument)(nil), FullFormattedMessage: "SSH for the host hp-esxi.lan has been enabled", ChangeTag: "", }, }, }, },
在写单元测试的时候,我经常用它来 mock 一些特殊硬件设备的信息,这比自己手写这些结构体要方便很多。比如以 mpx.vmhba<Adapter>:C<Channel>:T<Target>:L<LUN>
命名的硬盘可以通过 PlugStoreTopology
这个结构体来获取该硬盘的 NAA 号:
func getDiskIDByHostPlugStoreTopology(hpst *types.HostPlugStoreTopology, diskName string) string {for _, path := range hpst.Path {if path.Name == diskName {s := strings.Split(path.Target, "-sas.")return s[len(s)-1]}}return ""}// 单元测试代码如下:var plugStoreTopology = &types.HostPlugStoreTopology{Path: []types.HostPlugStoreTopologyPath{{Key: "key-vim.host.PlugStoreTopology.Path-vmhba0:C0:T1:L0",Name: "vmhba0:C0:T1:L0",ChannelNumber: 0,TargetNumber: 1,LunNumber: 0,Adapter: "key-vim.host.PlugStoreTopology.Adapter-vmhba0",Target: "key-vim.host.PlugStoreTopology.Target-sas.500056b3d93828c0",Device: "key-vim.host.PlugStoreTopology.Device-020000000055cd2e414dc39d4e494e54454c20",},},}func TestGetDiskIDByHostPlugStoreTopology(t *testing.T) {tests := []struct {name stringwant string}{{name: "vmhba0:C0:T1:L0",want: "500056b3d93828c0",},{name: "vmhba0:C0:T2:L0",want: "",},{name: "",want: "",},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {if got := getDiskIDByHostPlugStoreTopology(plugStoreTopology, tt.name); got != tt.want {t.Errorf("getDiskIDByHostPlugStoreTopology() = %v, want %v", got, tt.want)}})}}
再比如 NVMe 硬盘可以通过 NvmeTopology
这个数据对象获取它的序列号
func getNVMeIDByHostNvmeTopology(hnt *types.HostNvmeTopology, diskName string) string {for _, adapter := range hnt.Adapter {for _, controller := range adapter.ConnectedController {for _, ns := range controller.AttachedNamespace {if ns.Name == diskName {return strings.TrimSpace(controller.SerialNumber)}}}}return ""}// 单元测试代码如下:var nvmeTopology = &types.HostNvmeTopology{Adapter: []types.HostNvmeTopologyInterface{{Key: "key-vim.host.NvmeTopology.Interface-vmhba0",Adapter: "key-vim.host.PcieHba-vmhba0",ConnectedController: []types.HostNvmeController{{Key: "key-vim.host.NvmeController-256",ControllerNumber: 256,Subnqn: "nqn.2021-06.com.intel:PHAB123502CU1P9SGN ",Name: "nqn.2021-06.com.intel:PHAB123502CU1P9SGN",AssociatedAdapter: "key-vim.host.PcieHba-vmhba0",TransportType: "pcie",FusedOperationSupported: false,NumberOfQueues: 2,QueueSize: 1024,AttachedNamespace: []types.HostNvmeNamespace{{Key: "key-vim.host.NvmeNamespace-t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB________00035CB406E4D25C@256",Name: "t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB________00035CB406E4D25C",Id: 1,BlockSize: 512,CapacityInBlocks: 3125627568,},},VendorId: "0x8086",Model: "Dell Ent NVMe P5600 MU U.2 1.6TB ",SerialNumber: "PHAB123502CU1P9SGN ",FirmwareVersion: "1.0.0 PCIe",},},},{Key: "key-vim.host.NvmeTopology.Interface-vmhba1",Adapter: "key-vim.host.PcieHba-vmhba1",ConnectedController: []types.HostNvmeController{{Key: "key-vim.host.NvmeController-257",ControllerNumber: 257,Subnqn: "nqn.2021-06.com.intel:PHAB123602H81P9SGN ",Name: "nqn.2021-06.com.intel:PHAB123602H81P9SGN",AssociatedAdapter: "key-vim.host.PcieHba-vmhba1",TransportType: "pcie",FusedOperationSupported: false,NumberOfQueues: 2,QueueSize: 1024,AttachedNamespace: []types.HostNvmeNamespace{{Key: "key-vim.host.NvmeNamespace-t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB________00035CEE23E4D25C@257",Name: "t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB________00035CEE23E4D25C",Id: 1,BlockSize: 512,CapacityInBlocks: 3125627568,},},VendorId: "0x8086",Model: "Dell Ent NVMe P5600 MU U.2 1.6TB ",SerialNumber: "PHAB123602H81P9SGN ",FirmwareVersion: "1.0.0 PCIe",},},},},}func TestGetNVMeIDByHostNvmeTopology(t *testing.T) {tests := []struct {name stringwant string}{{name: "t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB________00035CEE23E4D25C",want: "PHAB123602H81P9SGN",},{name: "t10.NVMe____Dell_Ent_NVMe_P5600_MU_U.2_1.6TB",want: "",},{name: "",want: "",},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {if got := getNVMeIDByHostNvmeTopology(nvmeTopology, tt.name); got != tt.want {t.Errorf("getNVMeIDByHostNvmeTopology() = %v, want %v", got, tt.want)}})}}
通过 host.option.ls
可以列出当前 ESXi 主机所有的配置选项
$ govc host.option.lsAnnotations.WelcomeMessage:BufferCache.FlushInterval: 30000BufferCache.HardMaxDirty: 95BufferCache.PerFileHardMaxDirty: 50BufferCache.SoftMaxDirty: 15CBRC.DCacheMemReserved: 400CBRC.Enable: falseCOW.COWMaxHeapSizeMB: 192COW.COWMaxREPageCacheszMB: 256COW.COWMinREPageCacheszMB: 0COW.COWREPageCacheEviction: 1Config.Defaults.host.TAAworkaround: trueConfig.Defaults.monitor.if_pschange_mc_workaround: falseConfig.Defaults.security.host.ruissl: trueConfig.Defaults.vGPU.consolidation: falseConfig.Etc.issue:Config.Etc.motd: The time and date of this login have been sent to the system logs.
通过 host.option.set
可以设置 ESXi 主机参数,例如如果想要配置 NFS 存储心跳超时时间可以通过如下方式
$ govc host.option.set NFS.HeartbeatTimeout 30
通过 host.service
可以对 ESXi 主机上的服务进行相关操作。
$ govc host.serviceWhere ACTION is one of: start, stop, restart, status, enable, disable# 启动 ssh 服务$ govc host.service start TSM-SSH# 将 ssh 服务设置为开机自启$ govc host.service enable TSM-SSH# 查看 ssh 服务的状态$ govc host.service status TSM-SSH
$ VM_NAME="centos-test"$ govc vm.create -ds='datastore*' -net='VM Network' -net.adapter=vmxnet3 -disk 1G -on=false ${VM_NAME}$ govc vm.change -cpu.reservation=%d -memory-pin=true -vm ${VM_NAME}$ govc vm.change -g centos7_64Guest -c %d -m 16384 -latency high -vm ${VM_NAME}$ govc device.cdrom.add -vm ${VM_NAME}$ govc device.cdrom.insert -vm ${VM_NAME} -device cdrom-3000$ govc device.connect -vm ${VM_NAME} cdrom-3000$ govc vm.power -on=true ${VM_NAME}
docker 虽然作为一个 CRI 在 Kubernetes 社区一直被人诟病,但我们要知道 CRI 仅仅是 docker 的一部分功能而已。对于本地开发测试或者 CI/CD 流水线镜像构建来讲,依然有很多地方严重地依赖着 docker。比如 GitHub 上容器镜像构建的 Action 里, docker 官方的 build-push-action 是众多项目首选的方式。即便是 docker 的竞争对手 podman + skopeo + buildah 三剑客它们自身的容器镜像也是采用 docker 来构建的 multi-arch-build.yaml:
jobs: multi: name: multi-arch image build env: REPONAME: buildah # No easy way to parse this out of $GITHUB_REPOSITORY # Server/namespace value used to format FQIN REPONAME_QUAY_REGISTRY: quay.io/buildah CONTAINERS_QUAY_REGISTRY: quay.io/containers # list of architectures for build PLATFORMS: linux/amd64,linux/s390x,linux/ppc64le,linux/arm64 # Command to execute in container to obtain project version number VERSION_CMD: "buildah --version" # build several images (upstream, testing, stable) in parallel strategy: # By default, failure of one matrix item cancels all others fail-fast: false matrix: # Builds are located under contrib/<reponame>image/<source> directory source: - upstream - testing - stable runs-on: ubuntu-latest # internal registry caches build for inspection before push services: registry: image: quay.io/libpod/registry:2 ports: - 5000:5000 steps: - name: Checkout uses: actions/checkout@v2 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 with: driver-opts: network=host install: true - name: Build and locally push image uses: docker/build-push-action@v2 with: context: contrib/${{ env.REPONAME }}image/${{ matrix.source }} file: ./contrib/${{ env.REPONAME }}image/${{ matrix.source }}/Dockerfile platforms: ${{ env.PLATFORMS }} push: true tags: localhost:5000/${{ env.REPONAME }}/${{ matrix.source }}
我们的 CI/CD 流水线是使用 Jenkins + Kubernetes plugin 的方式在 Kubernetes 上动态地创建 Pod 作为 Jenkins Slave。在使用 docker 作为容器时的情况下,Jenkins Slave Pod 将宿主机上的 /var/run/docker.sock
文件通过 hostPath 的方式挂载到 pod 容器内,容器内的 docker CLI 就能通过该 sock 与宿主机的 docker 守护进程进行通信,这样在 pod 容器内就可以无缝地使用 docker build 、push 等命令了。
// Kubernetes pod template to run.podTemplate( cloud: "kubernetes", namespace: "default", name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podspec: containers: - name: debian image: "${JENKINS_POD_IMAGE_NAME}" imagePullPolicy: IfNotPresent tty: true volumeMounts: - name: dockersock mountPath: /var/run/docker.sock - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "jenkins/inbound-agent:4.3-4-alpine" imagePullPolicy: IfNotPresent volumes: - name: dockersock hostPath: path: /var/run/docker.sock""",)
当不再使用 docker 作为 Kubernetes 的容器运行时之后,宿主机上则就没有了 docker 守护进程,挂载 /var/run/docker.sock
的方式也就凉凉了,因此我们需要找到一些替代的方法。
目前能想到的有两种方案:方案一是替代掉 docker 使用其他镜像构建工具比如 podman + skopeo + buildah。陈少文博主在《基于 Kubernetes 的 Jenkins 服务也可以去 Docker 了》详细地讲过该方案。但我们的 Makefile 里缝合了一些 docker buildKit 的特性参数并不能地通过 alias docker=podman
别名的方式简单粗暴地给替换掉 😂。
比如 podman 构建镜像就不支持 --output type=local,dest=path
Support custom build outputs #3789 这种特性。目前看来 podman 想要完全取代掉 docker 的老大哥地位仍还有很长的路要走,尤其 podman 还没有解决自身的镜像是由 docker 来构建的这个尴尬难题。
方案二就是继续使用 docker 作为镜像构建工具,虽然集群节点上没有了 docker 守护进程,但这并不意味着在 Kubernetes 集群里就无法使用 docker 了。我们可以换种方式将 docker 作为一个 pod 运行在 kubernetes 集群中,而非以 systemd 的方式部署在节点上。然后通过 service IP 或 Node IP 访问 docker 的 TCP 端口进行通信,这样也能无缝地继续使用 docker 。于是在 dind (docker-in-docker) 的基础上就有了 dinp (docker-in-pod) 的套娃操作,其实二者本质上都是相同的,只不过是部署方式和访问方式不太相同而已。
对比一下这两种方案,方案一通过 alias docker=podman
使用 podman 替代 docker 有点投机取巧,在正式的生产环境流水线中应该很少会被采用,除非你的 Makefile 或者镜像构建脚本中没有依赖 docker 的特性参数,能够完全兼容 podman;方案二比较稳定可靠,它无非就是将之前的宿主机节点上的 docker 守护进程替换成了集群内的 Pod,对于使用者而言只需要修改一下访问 docker 的方式,即 DOCKER_HOST
环境变量即可。因此本文选用方案二来给大家介绍几种在 K8s 集群里部署和使用 dind/dinp 的方式。
不同于 docker in docker,docker in pod 并不关心底层的容器运行时是什么,可以是 docker 也可以是 containerd。在 pod 内运行和使用 docker 个人总结出以下三种比较合适的方式,可以根据不同的场景选择一个合适的:
将 dind 容器作为 sidecar 容器 来运行,主容器通过 localhost 的方式访问 docker 的 2375/2376 TCP 端口。这种方案的好处就是如果创建了多个 Pod,各个 Pod 之间是相互独立的,dind 容器不会共享给其他 pod 使用,隔离性比较好。缺点也比较明显,每一个 Pod 都带一个 dind 容器占用的系统资源比较多,有点大材小用的感觉;
apiVersion: v1kind: Podmetadata: name: dinp-sidecarspec: containers: - image: docker:20.10.12 name: debug command: ["sleep", "3600"] env: - name: DOCKER_TLS_VERIFY value: "" - name: DOCKER_HOST value: tcp://localhost:2375 - name: dind image: docker:20.10.12-dind-rootless args: ["--insecure-registry=$(REGISTRY)"] env: # 如果镜像仓库域名为自签证书,需要在这里配置 insecure-registry - name: REGISTRY value: hub.k8s.li - name: DOCKER_TLS_CERTDIR value: "" - name: DOCKER_HOST value: tcp://localhost:2375 securityContext: privileged: true tty: true # 使用 docker info 命令就绪探针来确保 dind 容器正常启动 readinessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 10 failureThreshold: 6
daemonset 则是在集群的每一个 Node 节点上运行一个 dind Pod,并且使用 hostNetwork 方式来暴露 2375/2376 TCP 端口。使用者则通过 status.hostIP
访问宿主机的 2375/2376 TCP 端口来与 docker 进行通信;另外再通过 hostPath 挂载的方式来将 dind 容器内的 /var/lib/docker
数据持久化存储下来,能够缓存一些数据提高镜像构建的效率。
apiVersion: apps/v1kind: DaemonSetmetadata: name: dinp-daemonset namespace: defaultspec: selector: matchLabels: name: dinp-daemonset template: metadata: labels: name: dinp-daemonset spec: hostNetwork: true containers: - name: dind image: docker:20.10.12-dind args: ["--insecure-registry=$(REGISTRY)"] env: - name: REGISTRY value: hub.k8s.li - name: DOCKER_TLS_CERTDIR value: "" securityContext: privileged: true tty: true volumeMounts: - name: docker-storage mountPath: /var/lib/docker readinessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 10 failureThreshold: 6 livenessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 60 failureThreshold: 10 volumes: - name: docker-storage hostPath: path: /var/lib/docker
Deployment 方式则是在集群中部署一个或多个 dind Pod,使用者通过 service IP 来访问 docker 的 2375/2376 端口,如果是以非 TLS 方式启动 dind 容器,使用 service IP 来访问 docker 要比前面的 daemonset 使用 host IP 安全性要好一些。
apiVersion: apps/v1kind: Deploymentmetadata: name: dinp-deployment namespace: default labels: name: dinp-deploymentspec: replicas: 1 selector: matchLabels: name: dinp-deployment template: metadata: labels: name: dinp-deployment spec: containers: - name: dind image: docker:20.10.12-dind args: ["--insecure-registry=$(REGISTRY)"] env: - name: REGISTRY value: hub.k8s.li - name: DOCKER_TLS_CERTDIR value: "" - name: DOCKER_HOST value: tcp://localhost:2375 securityContext: privileged: true tty: true volumeMounts: - name: docker-storage mountPath: /var/lib/docker readinessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 10 failureThreshold: 6 livenessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 60 failureThreshold: 10 volumes: - name: docker-storage hostPath: path: /var/lib/docker---kind: ServiceapiVersion: v1metadata: # 定义 service name,使用者通过它来访问 docker 的 2375 端口 name: dinp-deploymentspec: selector: name: dinp-deployment ports: - protocol: TCP port: 2375 targetPort: 2375
在 Jenkins 的 podTemplate 模版里,可以根据 dinp 部署方式的不同选用以下几种不同的模版:
Pod 内容器共享同一个网络协议栈,因此可以通过 localhost 来访问 docker 的 TCP 端口,另外最好使用 rootless 模式启动 dind 容器,这样能在同一节点上运行多个这样的 Pod 实例。
def JOB_NAME = "${env.JOB_BASE_NAME}"def BUILD_NUMBER = "${env.BUILD_NUMBER}"def POD_NAME = "jenkins-${JOB_NAME}-${BUILD_NUMBER}"def K8S_CLUSTER = params.k8s_cluster ?: kubernetes// Kubernetes pod template to run.podTemplate( cloud: K8S_CLUSTER, namespace: "default", name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podspec: containers: - name: runner image: golang:1.17-buster imagePullPolicy: IfNotPresent tty: true env: - name: DOCKER_HOST vaule: tcp://localhost:2375 - name: DOCKER_TLS_VERIFY value: "" - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "jenkins/inbound-agent:4.11.2-4-alpine" imagePullPolicy: IfNotPresent - name: dind image: docker:20.10.12-dind-rootless args: ["--insecure-registry=$(REGISTRY)"] env: - name: REGISTRY value: hub.k8s.li - name: DOCKER_TLS_CERTDIR value: "" securityContext: privileged: true tty: true readinessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 10 failureThreshold: 6""",) { node(POD_NAME) { container("runner") { stage("Checkout") { retry(10) { checkout([ $class: 'GitSCM', branches: scm.branches, doGenerateSubmoduleConfigurations: scm.doGenerateSubmoduleConfigurations, extensions: [[$class: 'CloneOption', noTags: false, shallow: false, depth: 0, reference: '']], userRemoteConfigs: scm.userRemoteConfigs, ]) } } stage("Build") { sh """ # make docker-build docker build -t app:v1.0.0-alpha.1 . """ } } }}
由于使用的是 hostNetwork,因此可以通过 host IP 来访问 docker 的 TCP 端口,当然也可以像 deployment 那样通过 service Name 来访问,在这里就不演示了。
def JOB_NAME = "${env.JOB_BASE_NAME}"def BUILD_NUMBER = "${env.BUILD_NUMBER}"def POD_NAME = "jenkins-${JOB_NAME}-${BUILD_NUMBER}"def K8S_CLUSTER = params.k8s_cluster ?: kubernetes// Kubernetes pod template to run.podTemplate( cloud: K8S_CLUSTER, namespace: "default", name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podspec: containers: - name: runner image: golang:1.17-buster imagePullPolicy: IfNotPresent tty: true env: - name: DOCKER_HOST valueFrom: fieldRef: fieldPath: status.hostIP - name: DOCKER_TLS_VERIFY value: "" - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "jenkins/inbound-agent:4.11.2-4-alpine" imagePullPolicy: IfNotPresent""",) { node(POD_NAME) { container("runner") { stage("Checkout") { retry(10) { checkout([ $class: 'GitSCM', branches: scm.branches, doGenerateSubmoduleConfigurations: scm.doGenerateSubmoduleConfigurations, extensions: [[$class: 'CloneOption', noTags: false, shallow: false, depth: 0, reference: '']], userRemoteConfigs: scm.userRemoteConfigs, ]) } } stage("Build") { sh """ # make docker-build docker build -t app:v1.0.0-alpha.1 . """ } } }}
通过 service name 访问 docker,其他参数和 daemonset 都是相同的
def JOB_NAME = "${env.JOB_BASE_NAME}"def BUILD_NUMBER = "${env.BUILD_NUMBER}"def POD_NAME = "jenkins-${JOB_NAME}-${BUILD_NUMBER}"def K8S_CLUSTER = params.k8s_cluster ?: kubernetes// Kubernetes pod template to run.podTemplate( cloud: K8S_CLUSTER, namespace: "default", name: POD_NAME, label: POD_NAME, yaml: """apiVersion: v1kind: Podspec: containers: - name: runner image: golang:1.17-buster imagePullPolicy: IfNotPresent tty: true env: - name: DOCKER_HOST value: tcp://dinp-deployment:2375 - name: DOCKER_TLS_VERIFY value: "" - name: jnlp args: ["\$(JENKINS_SECRET)", "\$(JENKINS_NAME)"] image: "jenkins/inbound-agent:4.11.2-4-alpine" imagePullPolicy: IfNotPresent""",) { node(POD_NAME) { container("runner") { stage("Checkout") { retry(10) { checkout([ $class: 'GitSCM', branches: scm.branches, doGenerateSubmoduleConfigurations: scm.doGenerateSubmoduleConfigurations, extensions: [[$class: 'CloneOption', noTags: false, shallow: false, depth: 0, reference: '']], userRemoteConfigs: scm.userRemoteConfigs, ]) } } stage("Build") { sh """ # make docker-build docker build -t app:v1.0.0-alpha.1 . """ } } }}
有些时候 dind 无法正常启动,所以一定要设置就绪探针,来确保 diind 容器能够正常启动
readinessProbe: exec: command: ["docker", "info"] initialDelaySeconds: 10 failureThreshold: 6
docker 默认是以 TLS 方式启动,监听端口为 2376,如果设置环境变量 DOCKER_TLS_CERTDIR
为空则就以非 TLS 模式启动,监听端口为 2375,这时就不会校验 TLS 证书。如果使用 2376 端口,则就需要一个持久化存储来将 docker 生成的证书共享给客户端,这点比较麻烦。因此如果不想瞎折腾还是使用 2375 非 TLS 方式吧 😂。
以 pod 方式运行 docker,无论是否是 rootless 模式,都要在 pod 容器的 securityContext
中设置 privileged: true
,否则 pod 无法正常启动。而且 rootless 模式也有一定的限制,需要依赖一些内核的特性,目前也只是实验阶段,没有特殊的需求还是尽量不要使用 rootless 特性吧。
[root@localhost ~]# kubectl logs -f dinp-sidecarerror: a container name must be specified for pod dinp-sidecar, choose one of: [debug dind][root@localhost ~]# kubectl logs -f dinp-sidecar -c dindDevice "ip_tables" does not exist.ip_tables 27126 4 iptable_raw,iptable_mangle,iptable_filter,iptable_natmodprobe: can't change directory to '/lib/modules': No such file or directoryWARN[0000] failed to mount sysfs, falling back to read-only mount: operation not permittedWARN[0000] failed to mount sysfs: operation not permittedopen: No such file or directory[rootlesskit:child ] error: executing [[ip tuntap add name tap0 mode tap] [ip link set tap0 address 02:50:00:00:00:01]]: exit status 1
rootless 模式下需要依赖一些内核参数 Run the Docker daemon as a non-root user (Rootless mode)。在 CentOS 7.9 上会出现 dind-rootless: failed to start up dind rootless in k8s due to max_user_namespaces 问题。解决方案是在修改一下 user.max_user_namespaces=28633
内核参数。
Add user.max_user_namespaces=28633 to /etc/sysctl.conf (or /etc/sysctl.d) and run sudo sysctl -p
[root@localhost ~]# kubectl get pod -wNAME READY STATUS RESTARTS AGEdinp-deployment-cf488bfd8-g8vxx 0/1 CrashLoopBackOff 1 (2s ago) 4s[root@localhost ~]# kubectl logs -f dinp-deployment-cf488bfd8-m5cmsDevice "ip_tables" does not exist.ip_tables 27126 5 iptable_raw,iptable_mangle,iptable_filter,iptable_natmodprobe: can't change directory to '/lib/modules': No such file or directoryerror: attempting to run rootless dockerd but need 'user.max_user_namespaces' (/proc/sys/user/max_user_namespaces) set to a sufficiently large value
如果是使用 deployment 方式部署 dinp,一个 node 节点上只能有一个 dinp Pod,多余的 Pod 无法正常启动。因此如果想要运行多个 dinp Pod,建议使用 daemonset 方式运行它;
[root@localhost ~]# kubectl get deployNAME READY UP-TO-DATE AVAILABLE AGEdinp-deployment 1/3 3 1 4m16s[root@localhost ~]# kubectl get pod -wNAME READY STATUS RESTARTS AGEdinp-deployment-547bd9bb6d-2mn6c 0/1 Running 3 (61s ago) 4m9sdinp-deployment-547bd9bb6d-8ht8l 1/1 Running 0 4m9sdinp-deployment-547bd9bb6d-x5vpv 0/1 Running 3 (61s ago) 4m9s[root@localhost ~]# kubectl logs -f dinp-deployment-547bd9bb6d-2mn6cINFO[2022-03-14T14:14:10.905652548Z] Starting upWARN[2022-03-14T14:14:10.906986721Z] could not change group /var/run/docker.sock to docker: group docker not foundWARN[2022-03-14T14:14:10.907249071Z] Binding to IP address without --tlsverify is insecure and gives root access on this machine to everyone who has access to your network. host="tcp://0.0.0.0:2375"WARN[2022-03-14T14:14:10.907269951Z] Binding to an IP address, even on localhost, can also give access to scripts run in a browser. Be safe out there! host="tcp://0.0.0.0:2375"WARN[2022-03-14T14:14:11.908057635Z] Binding to an IP address without --tlsverify is deprecated. Startup is intentionally being slowed down to show this message host="tcp://0.0.0.0:2375"WARN[2022-03-14T14:14:11.908103696Z] Please consider generating tls certificates with client validation to prevent exposing unauthenticated root access to your network host="tcp://0.0.0.0:2375"WARN[2022-03-14T14:14:11.908114541Z] You can override this by explicitly specifying '--tls=false' or '--tlsverify=false' host="tcp://0.0.0.0:2375"WARN[2022-03-14T14:14:11.908125477Z] Support for listening on TCP without authentication or explicit intent to run without authentication will be removed in the next release host="tcp://0.0.0.0:2375"INFO[2022-03-14T14:14:26.914587276Z] libcontainerd: started new containerd process pid=41INFO[2022-03-14T14:14:26.914697125Z] parsed scheme: "unix" module=grpcINFO[2022-03-14T14:14:26.914710376Z] scheme "unix" not registered, fallback to default scheme module=grpcINFO[2022-03-14T14:14:26.914785052Z] ccResolverWrapper: sending update to cc: {[{unix:///var/run/docker/containerd/containerd.sock <nil> 0 <nil>}] <nil> <nil>} module=grpcINFO[2022-03-14T14:14:26.914796039Z] ClientConn switching balancer to "pick_first" module=grpcINFO[2022-03-14T14:14:26.930311832Z] starting containerd revision=7b11cfaabd73bb80907dd23182b9347b4245eb5d version=v1.4.12INFO[2022-03-14T14:14:26.953641900Z] loading plugin "io.containerd.content.v1.content"... type=io.containerd.content.v1INFO[2022-03-14T14:14:26.953721059Z] loading plugin "io.containerd.snapshotter.v1.aufs"... type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960295816Z] skip loading plugin "io.containerd.snapshotter.v1.aufs"... error="aufs is not supported (modprobe aufs failed: exit status 1 \"ip: can't find device 'aufs'\\nmodprobe: can't change directory to '/lib/modules': No such file or directory\\n\"): skip plugin" type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960329840Z] loading plugin "io.containerd.snapshotter.v1.btrfs"... type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960524514Z] skip loading plugin "io.containerd.snapshotter.v1.btrfs"... error="path /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.btrfs (xfs) must be a btrfs filesystem to be used with the btrfs snapshotter: skip plugin" type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960537441Z] loading plugin "io.containerd.snapshotter.v1.devmapper"... type=io.containerd.snapshotter.v1WARN[2022-03-14T14:14:26.960558843Z] failed to load plugin io.containerd.snapshotter.v1.devmapper error="devmapper not configured"INFO[2022-03-14T14:14:26.960569516Z] loading plugin "io.containerd.snapshotter.v1.native"... type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960593224Z] loading plugin "io.containerd.snapshotter.v1.overlayfs"... type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960678728Z] loading plugin "io.containerd.snapshotter.v1.zfs"... type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960814844Z] skip loading plugin "io.containerd.snapshotter.v1.zfs"... error="path /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.zfs must be a zfs filesystem to be used with the zfs snapshotter: skip plugin" type=io.containerd.snapshotter.v1INFO[2022-03-14T14:14:26.960827133Z] loading plugin "io.containerd.metadata.v1.bolt"... type=io.containerd.metadata.v1WARN[2022-03-14T14:14:26.960839223Z] could not use snapshotter devmapper in metadata plugin error="devmapper not configured"INFO[2022-03-14T14:14:26.960848698Z] metadata content store policy set policy=sharedWARN[2022-03-14T14:14:27.915528371Z] grpc: addrConn.createTransport failed to connect to {unix:///var/run/docker/containerd/containerd.sock <nil> 0 <nil>}. Err :connection error: desc = "transport: error while dialing: dial unix:///var/run/docker/containerd/containerd.sock: timeout". Reconnecting... module=grpcWARN[2022-03-14T14:14:30.722257725Z] grpc: addrConn.createTransport failed to connect to {unix:///var/run/docker/containerd/containerd.sock <nil> 0 <nil>}. Err :connection error: desc = "transport: error while dialing: dial unix:///var/run/docker/containerd/containerd.sock: timeout". Reconnecting... module=grpcWARN[2022-03-14T14:14:35.549453706Z] grpc: addrConn.createTransport failed to connect to {unix:///var/run/docker/containerd/containerd.sock <nil> 0 <nil>}. Err :connection error: desc = "transport: error while dialing: dial unix:///var/run/docker/containerd/containerd.sock: timeout". Reconnecting... module=grpcWARN[2022-03-14T14:14:41.759010407Z] grpc: addrConn.createTransport failed to connect to {unix:///var/run/docker/containerd/containerd.sock <nil> 0 <nil>}. Err :connection error: desc = "transport: error while dialing: dial unix:///var/run/docker/containerd/containerd.sock: timeout". Reconnecting... module=grpcfailed to start containerd: timeout waiting for containerd to start
陈少文博主曾在 《/var/lib/docker 能不能挂载远端存储》提到过 docker 目前并支持将 /var/lib/docker
挂载远程存储使用,因此建议使用 hostPath 的方式保存 docker 的持久化存储数据。
本次测试使用的 Docker 版本为 20.10.6,不能将
/var/lib/docker
挂载远程存储使用。主要原因是容器的实现依赖于内核的能力(xttrs),而类似 NFS Server 这种远程存储无法提供这些能力。如果采用 Device Mapper 进行映射,使用磁盘挂载存在可行性,但只能用于迁移而不能实现共享。
INFO[2022-03-13T13:43:08.750810130Z] ClientConn switching balancer to "pick_first" module=grpcERRO[2022-03-13T13:43:08.781932359Z] failed to mount overlay: invalid argument storage-driver=overlay2ERRO[2022-03-13T13:43:08.782078828Z] exec: "fuse-overlayfs": executable file not found in $PATH storage-driver=fuse-overlayfsERRO[2022-03-13T13:43:08.793311119Z] AUFS was not found in /proc/filesystems storage-driver=aufsERRO[2022-03-13T13:43:08.813505621Z] failed to mount overlay: invalid argument storage-driver=overlayERRO[2022-03-13T13:43:08.813529990Z] Failed to built-in GetDriver graph devicemapper /var/lib/dockerINFO[2022-03-13T13:43:08.897769363Z] Loading containers: start.WARN[2022-03-13T13:43:08.919252078Z] Running modprobe bridge br_netfilter failed with message: ip: can't find device 'bridge'[root@localhost dinp]# kubectl exec -it dinp-sidecar -c debug sh/ # docker pull alpineUsing default tag: latestError response from daemon: error creating temporary lease: file resize error: truncate /var/lib/docker/containerd/daemon/io.containerd.metadata.v1.bolt/meta.db: bad file descriptor: unknown
现在项目的一个需求就是在集群部署完成或失败之后需要将 workflow 的日志归档保存下来。虽然可以在 workflow 的 spec 字段中使用 archiveLogs: true
来让 argo 帮我们自动归档日志,但这个特性依赖于一个 S3 对象存储 Artifact Repository 。这就意味着还要再部署一个支持 S3 对象存储的组件比如 Minio ,直接把我给整不会了 🌚
其实嘛这个需求很简单的,我就想保存一个日志文件而已,你还再让我安装一个 Minio,实在是太过分了!本来系统的资源十分有限,需要尽可能减少安装一些不必要依赖,为的就是将资源利用率将到最低。但现在为了归档存储一个日志文件储而大动干戈装一个 minio 实在是不划算。这就好比你费了好大功夫部署一套 3 节点的 kubernetes 集群,然而就为了运行一个静态博客那样滑稽 😂
Deployed my blog on Kubernetes pic.twitter.com/XHXWLrmYO4
— For DevOps Eyes Only (@dexhorthy) April 24, 2017
对于咱这种 用不起
S3 对象存储的穷人家孩子,还是想一些其他办法吧,毕竟自己动手丰衣足食。
实现起来也比较简单,对于咱这种 YAML 工程师来说,kubectl 自然再熟悉不过了。想要获取 workflow 的日志,只需要通过 kubectl logs 命令获取出 workflow 所创的 pod 日志就行了呀,要什么 S3 对象存储 😖
对于同一个 workflow 来将,每个 stage 所创建出来的 pod name 有一定的规律。在定义 workflow 的时候,generateName 参数通常使用 ${name}-
格式。以 -
作为分隔符,最后一个字段是随机生成的一个数字 ID,倒数第二个字段则是 argo 随机生成的 workflow ID,剩余前面的字符则是我们定义的 generateName。
apiVersion: argoproj.io/v1alpha1kind: Workflowmetadata: generateName: archive-log-test-
archive-log-test-jzt8n-3498199655 0/2 Completed 0 4m18sarchive-log-test-jzt8n-3618624526 0/2 Completed 0 4m8sarchive-log-test-jzt8n-2123203324 0/2 Completed 0 3m58s
在 pod 的 labels 中同样也包含着该 workflow 所对应的 ID,因此我们可以根据此 labels 过滤出该 workflow 所创建出来的 pod。
apiVersion: v1kind: Podmetadata: annotations: workflows.argoproj.io/node-id: archive-log-test-jzt8n-3498199655 workflows.argoproj.io/node-name: archive-log-test-jzt8n[0].list-default-running-pods creationTimestamp: "2022-02-28T12:53:32Z" labels: workflows.argoproj.io/completed: "true" workflows.argoproj.io/workflow: archive-log-test-jzt8n name: archive-log-test-jzt8n-3498199655 namespace: default ownerReferences: - apiVersion: argoproj.io/v1alpha1 blockOwnerDeletion: true controller: true kind: Workflow name: archive-log-test-jzt8n uid: e91df2cb-b567-4cf0-9be5-3dd6c72854cd resourceVersion: "1251330" uid: ce37a709-8236-445b-8d00-a7926fa18ed0
通过 -l lables
过滤出一个 workflow 所创建的 pod;通过 --sort-by
以创建时间进行排序;通过 -o name
只输出 pod 的 name:
$ kubectl get pods -l workflows.argoproj.io/workflow=archive-log-test-jzt8n --sort-by='.metadata.creationTimestamp' -o namepod/archive-log-test-jzt8n-3498199655pod/archive-log-test-jzt8n-3618624526pod/archive-log-test-jzt8n-2123203324
通过上面的步骤我们就可以获取到一个 workflow 所创建的 pod 列表。然后再通过 kubectl logs 命令获取 pod 中 main 容器的日志,为方便区分日志的所对应的 workflow ,我们就以 workflow 的 ID 为前缀名。
$ kubectl logs archive-log-test-jzt8n-3618624526 -c main
LOG_PATH=/var/logNAME=archive-log-test-jzt8nkubectl get pods -l workflows.argoproj.io/workflow=${NAME} \--sort-by='.metadata.creationTimestamp' -o name \| xargs -I {} -t kubectl logs {} -c main >> ${LOG_PATH}/${NAME}.log
根据 argo-workflow 官方提供的 exit-handlers.yaml example,我们就照葫芦画瓢搓一个 workflow 退出后自动调用使用 kubectl 获取 workflow 日志的一个 step,定义的 exit-handler 内容如下:
- name: exit-handler container: name: "kubectl" image: lachlanevenson/k8s-kubectl:v1.23.2 command: - sh - -c - | kubectl get pods -l workflows.argoproj.io/workflow=${POD_NAME%-*} \ --sort-by=".metadata.creationTimestamp" -o name | grep -v ${POD_NAME} \ | xargs -I {} -t kubectl logs {} -c main >> ${LOG_PATH}/${POD_NAME%-*}.log env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: LOG_PATH value: /var/log/workflow resources: {} volumeMounts: - name: nfs-datastore mountPath: /var/log/workflow retryStrategy: limit: "5" retryPolicy: OnFailureentrypoint: archive-log-testserviceAccountName: defaultvolumes: - name: nfs-datastore nfs: server: NFS_SERVER path: /data/workflow/logonExit: exit-handler
将上述定义的 exit-handler
内容复制粘贴到你的 workflow spec 配置中就可以。由于日志需要持久化存储,我这里使用的是 NFS 存储,也可以根据自己的需要换成其他存储,只需要修改一下 volumes
配置即可。
完整的 workflow example 如下:
apiVersion: argoproj.io/v1alpha1kind: Workflowmetadata: generateName: archive-log-test- namespace: defaultspec: templates: - name: archive-log-test steps: - - name: list-default-running-pods template: kubectl arguments: parameters: - name: namespace value: default - - name: list-kube-system-running-pods template: kubectl arguments: parameters: - name: namespace value: kube-system - name: kubectl inputs: parameters: - name: namespace container: name: "kubectl" image: lachlanevenson/k8s-kubectl:v1.23.2 command: - sh - -c - | kubectl get pods --field-selector=status.phase=Running -n {{inputs.parameters.namespace}} - name: exit-handler container: name: "kubectl" image: lachlanevenson/k8s-kubectl:v1.23.2 command: - sh - -c - | kubectl get pods -l workflows.argoproj.io/workflow=${POD_NAME%-*} \ --sort-by=".metadata.creationTimestamp" -o name | grep -v ${POD_NAME} \ | xargs -I {} -t kubectl logs {} -c main >> ${LOG_PATH}/${POD_NAME%-*}.log env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: LOG_PATH value: /var/log/workflow resources: {} volumeMounts: - name: nfs-datastore mountPath: /var/log/workflow retryStrategy: limit: "5" retryPolicy: OnFailure entrypoint: archive-log-test serviceAccountName: default volumes: - name: nfs-datastore nfs: server: NFS_SERVER path: /data/workflow/log onExit: exit-handler
]]>目前将 IaaS 资源创建与 K8s 集群部署结合起来也有比较成熟的方案,比如基于 cluster-api 项目的 tanzu 。本文就以 VMware Tanzu 社区版 为例在一台物理服务器上,从安装 ESXi OS 到部署完成 Tanzu Workload 集群,来体验一下这种部署方案的与众不同之处。
整个部署流程所需要的依文件赖如下,可以先将这些依赖下载到本地的机器上,方便后续使用。
root@devbox:/root/tanzu # tree -sh.├── [ 12M] govc_Linux_x86_64.tar.gz├── [ 895M] photon-3-kube-v1.21.2+vmware.1-tkg.2-12816990095845873721.ova├── [ 225M] photon-ova-4.0-c001795b80.ova├── [ 170M] tce-linux-amd64-v0.9.1.tar.gz├── [ 9.0G] VMware-VCSA-all-7.0.3-18778458.iso└── [ 390M] VMware-VMvisor-Installer-7.0U2a-17867351.x86_64.iso
文件 | 用途 | 下载方式 |
---|---|---|
VMware-VMvisor-Installer-7.0U2a-17867351.x86_64.iso | 安装 ESXi OS | VMware 需账户 |
VMware-VCSA-all-7.0.3-19234570.iso | 安装 vCenter | VMware 需账户 |
photon-ova-4.0-c001795b80.ova | bootstrap 节点 | VMware |
photon-3-kube-v1.21.2+vmware.1-tkg.2-12816990095845873721.ova | tanzu 集群节点 | VMware 需账户 |
tce-linux-amd64-v0.9.1.tar.gz | tanzu 社区版 | GitHub release |
govc_Linux_x86_64.tar.gz | 安装/配置 vCenter | GitHub release |
注意 ESXi 和 vCenter 的版本最好是 7.0 及以上,我只在 ESXi 7.0.2 和 vCenter 7.0.3 上测试过,其他版本可能会有些差异;另外 ESXi 的版本不建议使用最新的 7.0.3,因为有比较严重的 bug,官方也建议用户生产环境不要使用该版本了 vSphere 7.0 Update 3 Critical Known Issues - Workarounds & Fix (86287) 。
在本地机器上安装好 govc 和 jq,这两个工具后面在配置 vCenter 的时候会用到。
$ brew install govc jq
$ tar -xf govc_Linux_x86_64.tar.gz -C /usr/local/bin$ apt install jq -y
ESXi OS 的安装网上有很多教程,没有太多值得讲解的地方,因此就参照一下其他大佬写的博客或者官方的安装文档 VMware ESXi 安装和设置 来就行;需要注意一点,ESXi OS 安装时 VMFSL 分区将会占用大量的存储空间,这将会使得 ESXi OS 安装所在的磁盘最终创建出来的 datastore 比预期小很多,而且这个 VMFSL 分区在安装好之后就很难再做调整了。因此如果磁盘存储空间比较紧张,在安装 ESXi OS 之前可以考虑下如何去掉这个分区;或者和我一样将 ESXI OS 安装在了一个 16G 的 USB Dom 盘上,不过生产环境不建议采用这种方案 😂(其实个人觉着安装在 U 盘上问题不大,ESXi OS 启动之后是加载到内存中运行的,不会对 U 盘有大量的读写操作,只不过在机房中 U 盘被人不小心拔走就凉了。
# ESXi 节点的 IPexport ESXI_IP="192.168.18.47"# ESXi 登录的用户名,初次安装后默认为 rootexport GOVC_USERNAME="root"# 在 ESXi 安装时设置的 root 密码export GOVC_PASSWORD="admin@2022"# 允许不安全的 SSL 连接export GOVC_INSECURE=trueexport GOVC_URL="https://${ESXI_IP}"export GOVC_DATASTORE=datastore1
Name: localhost.local Path: /ha-datacenter/host/localhost/localhost Manufacturer: Dell Logical CPUs: 20 CPUs @ 2394MHz Processor type: Intel(R) Xeon(R) Silver 4210R CPU @ 2.40GHz CPU usage: 579 MHz (1.2%) Memory: 261765MB Memory usage: 16457 MB (6.3%) Boot time: 2022-02-02 11:53:59.630124 +0000 UTC State: connected
按照 VMware 官方的 vCenter 安装文档 关于 vCenter Server 安装和设置 来安装实在是过于繁琐,其实官方的 ISO 安装方式无非是运行一个 installer web 服务,然后在浏览器上配置好 vCenter 虚拟机的参数,再将填写的配置信息在部署 vcsa 虚拟机的时候注入到 ova 的配置参数中。
知道这个安装过程的原理之后我们也可以自己配置 vCenter 的参数信息,然后通过 govc 来部署 ova;这比使用 UI 的方式简单方便很多,最终只需要填写一个配置文件,一条命令就可以部署完成啦。
$ mount -o loop VMware-VCSA-all-7.0.3-18778458.iso /mnt$ ls /mnt/vcsa/VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10.ova/mnt/vcsa/VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10.ova
#!/usr/bin/env bashVCSA_OVA_FILE=$1set -o errexitset -o nounsetset -o pipefail# ESXi 的 IP 地址export ESXI_IP="192.168.18.47"# ESXi 的用户名export GOVC_USERNAME="root"# ESXI 的密码export GOVC_PASSWORD="admin@2020"# 安装 vCenter 虚拟机使用的 datastore 名称export GOVC_DATASTORE=datastore1export GOVC_INSECURE=trueexport GOVC_URL="https://${ESXI_IP}"# vCenter 的登录密码VM_PASSWORD="admin@2020"# vCenter 的 IP 地址VM_IP=192.168.20.92# vCenter 虚拟机的名称VM_NAME=vCenter-Server-Appliance# vCenter 虚拟机使用的网络VM_NETWORK="VM Network"# DNS 服务器VM_DNS="223.6.6.6"# NTP 服务器VM_NTP="0.pool.ntp.org"deploy_vcsa_vm(){ config=$(govc host.info -k -json | jq -r '.HostSystems[].Config') gateway=$(jq -r '.Network.IpRouteConfig.DefaultGateway' <<<"$config") route=$(jq -r '.Network.RouteTableInfo.IpRoute[] | select(.DeviceName == "vmk0") | select(.Gateway == "0.0.0.0")' <<<"$config") prefix=$(jq -r '.PrefixLength' <<<"$route") opts=( cis.vmdir.password=${VM_PASSWORD} cis.appliance.root.passwd=${VM_PASSWORD} cis.appliance.root.shell=/bin/bash cis.deployment.node.type=embedded cis.vmdir.domain-name=vsphere.local cis.vmdir.site-name=VCSA cis.appliance.net.addr.family=ipv4 cis.appliance.ssh.enabled=True cis.ceip_enabled=False cis.deployment.autoconfig=True cis.appliance.net.addr=${VM_IP} cis.appliance.net.prefix=${prefix} cis.appliance.net.dns.servers=${VM_DNS} cis.appliance.net.gateway=$gateway cis.appliance.ntp.servers="${VM_NTP}" cis.appliance.net.mode=static ) props=$(printf -- "guestinfo.%s\n" "${opts[@]}" | jq --slurp -R 'split("\n") | map(select(. != "")) | map(split("=")) | map({"Key": .[0], "Value": .[1]})') cat <<EOF | govc import.${VCSA_OVA_FILE##*.} -options - "${VCSA_OVA_FILE}" { "Name": "${VM_NAME}", "Deployment": "tiny", "DiskProvisioning": "thin", "IPProtocol": "IPv4", "Annotation": "VMware vCenter Server Appliance", "PowerOn": false, "WaitForIP": false, "InjectOvfEnv": true, "NetworkMapping": [ { "Name": "Network 1", "Network": "${VM_NETWORK}" } ], "PropertyMapping": ${props} }EOF}deploy_vcsa_vmgovc vm.change -vm "${VM_NAME}" -g vmwarePhoton64Guestgovc vm.power -on "${VM_NAME}"govc vm.ip -a "${VM_NAME}"
# 执行该脚本,第一个参数传入 vCenter ISO 中 vcsa ova 文件的绝对路径$ bash install-vcsa.sh /mnt/vcsa/VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10.ova[03-02-22 18:40:19] Uploading VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10-disk1.vmdk... OK[03-02-22 18:41:09] Uploading VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10-disk2.vmdk... (29%, 52.5MiB/s)[03-02-22 18:43:08] Uploading VMware-vCenter-Server-Appliance-7.0.3.00100-18778458_OVF10-disk2.vmdk... OK[03-02-22 18:43:08] Injecting OVF environment...Powering on VirtualMachine:3... OKfe80::20c:29ff:fe03:2f80
export GOVC_URL="https://192.168.20.92"export GOVC_USERNAME="administrator@vsphere.local"export GOVC_PASSWORD="admin@2022"export GOVC_INSECURE=trueexport GOVC_DATASTORE=datastore1
$ govc aboutFullName: VMware vCenter Server 7.0.3 build-18778458Name: VMware vCenter ServerVendor: VMware, Inc.Version: 7.0.3Build: 18778458OS type: linux-x64API type: VirtualCenterAPI version: 7.0.3.0Product ID: vpxUUID: 0b49e119-e38f-4fbc-84a8-d7a0e548027d
这一步骤主要是配置 vCenter:创建 Datacenter、cluster、folder 等资源,并将 ESXi 主机添加到 cluster 中;
# 创建 Datacenter 数据中心$ govc datacenter.create SH-IDC# 创建 Cluster 集群$ govc cluster.create -dc=SH-IDC Tanzu-Cluster# 将 ESXi 主机添加到 Cluster 当中$ govc cluster.add -dc=SH-IDC -cluster=Tanzu-Cluster -hostname=192.168.18.47 --username=root -password='admin@2020' -noverify# 创建 folder,用于将 Tanzu 的节点虚拟机存放到该文件夹下$ govc folder.create /SH-IDC/vm/Tanzu-node# 导入 tanzu 汲取节点的虚拟机 ova 模版$ govc import.ova -dc='SH-IDC' -ds='datastore1' photon-3-kube-v1.21.2+vmware.1-tkg.2-12816990095845873721.ova# 将虚拟机转换为模版,后续 tanzu 集群将以该模版创建虚拟机$ govc vm.markastemplate photon-3-kube-v1.21.2
bootstrap 节点节点是用于运行 tanzu 部署工具的节点,官方是支持 Linux/macOS/Windows 三种操作系统的,但有一些比较严格的要求:
Arch: x86; ARM is currently unsupported |
---|
RAM: 6 GB |
CPU: 2 |
Docker Add your non-root user account to the docker user group. Create the group if it does not already exist. This lets the Tanzu CLI access the Docker socket, which is owned by the root user. For more information, see steps 1 to 4 in the Manage Docker as a non-root user procedure in the Docker documentation. |
Kubectl |
Latest version of Chrome, Firefox, Safari, Internet Explorer, or Edge |
System time is synchronized with a Network Time Protocol (NTP) server. |
Ensure your bootstrap machine is using cgroup v1. For more information, see Check and set the cgroup. |
在这里为了避免这些麻烦的配置,我就直接使用的 VMware 官方的 Photon OS 4.0 Rev2 ,下载 OVA 格式的镜像直接导入到 ESXi 主机启动一台虚拟机即可,能节省不少麻烦的配置;还有一个好处就是在一台单独的虚拟机上运行 tanzu 部署工具不会污染本地的开发环境。
$ wget https://packages.vmware.com/photon/4.0/Rev2/ova/photon-ova-4.0-c001795b80.ova# 导入 OVA 虚拟机模版$ govc import.ova -ds='datastore1' -name bootstrap-node photon-ova-4.0-c001795b80.ova# 修改一下虚拟机的配置,调整为 4C8G$ govc vm.change -c 4 -m 8192 -vm bootstrap-node# 开启虚拟机$ govc vm.power -on bootstrap-node# 查看虚拟机获取到的 IPv4 地址$ govc vm.ip -a -wait 1m bootstrap-node$ ssh root@192.168.74.10# 密码默认为 changeme,输入完密码之后提示在输入一遍 changeme,然后再修改新的密码root@photon-machine [ ~ ]# cat /etc/os-releaseNAME="VMware Photon OS"VERSION="4.0"ID=photonVERSION_ID=4.0PRETTY_NAME="VMware Photon OS/Linux"ANSI_COLOR="1;34"HOME_URL="https://vmware.github.io/photon/"BUG_REPORT_URL="https://github.com/vmware/photon/issues"
root@photon-machine [ ~ ]# tdnf install sudo tar -yroot@photon-machine [ ~ ]# curl -LO https://dl.k8s.io/release/v1.21.2/bin/linux/amd64/kubectlroot@photon-machine [ ~ ]# sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
root@photon-machine [ ~ ]# systemctl enable docker --now
root@photon-machine [ ~ ]# curl -LO https://github.com/vmware-tanzu/community-edition/releases/download/v0.9.1/tce-linux-amd64-v0.9.1.tar.gzroot@photon-machine [ ~ ]# tar -xf tce-linux-amd64-v0.9.1.tar.gzroot@photon-machine [ ~ ]# cd tce-linux-amd64-v0.9.1/root@photon-machine [ ~ ]# bash install.sh
然而不幸地翻车了, install.sh 脚本中禁止 root 用户运行
+ ALLOW_INSTALL_AS_ROOT=+ [[ 0 -eq 0 ]]+ [[ '' != \t\r\u\e ]]+ echo 'Do not run this script as root'Do not run this script as root+ exit 1
我就偏偏要以 root 用户来运行怎么惹 😡
# sed 去掉第一个 exit 1 就可以了root@photon-machine [ ~ ]# sed -i.bak "s/exit 1//" install.shroot@photon-machine [ ~ ]# bash install.sh
安装好之后会输出 Installation complete!
(讲真官方的 install.sh 脚本输出很不友好,污染我的 terminal
+ tanzu init| initializing ✔ successfully initialized CLI++ tanzu plugin repo list++ grep tce+ TCE_REPO=+ [[ -z '' ]]+ tanzu plugin repo add --name tce --gcp-bucket-name tce-tanzu-cli-plugins --gcp-root-path artifacts++ tanzu plugin repo list++ grep core-admin+ TCE_REPO=+ [[ -z '' ]]+ tanzu plugin repo add --name core-admin --gcp-bucket-name tce-tanzu-cli-framework-admin --gcp-root-path artifacts-admin+ echo 'Installation complete!'Installation complete!
先是部署一个 tanzu 的管理集群,有两种方式,一种是通过 官方文档 提到的通过 Web UI 的方式。目前这个 UI 界面比较拉垮,它主要是用来让用户填写一些配置参数,然后调用后台的 tanzu 命令来部署集群。并把集群部署的日志和进度展示出来;部署完成之后,这个 UI 又不能管理这些集群,又不支持部署 workload 集群(
另一种就是通过 tanzu 命令指定配置文件来部署,这种方式不需要通过浏览器在 web 页面上傻乎乎地点来点去填一些参数,只需要提前填写好一个 yaml 格式的配置文件即可。下面我们就采用 tanzu 命令来部署集群,管理集群的配置文件模版如下:
# Cluster Pod IP 的 CIDRCLUSTER_CIDR: 100.96.0.0/11# Service 的 CIDRSERVICE_CIDR: 100.64.0.0/13# 集群的名称CLUSTER_NAME: tanzu-control-plan# 集群的类型CLUSTER_PLAN: dev# 集群节点的 archOS_ARCH: amd64# 集群节点的 OS 名称OS_NAME: photon# 集群节点 OS 版本OS_VERSION: "3"# 基础设施资源的提供方INFRASTRUCTURE_PROVIDER: vsphere# 集群的 VIPVSPHERE_CONTROL_PLANE_ENDPOINT: 192.168.75.194# control-plan 节点的磁盘大小VSPHERE_CONTROL_PLANE_DISK_GIB: "20"# control-plan 节点的内存大小VSPHERE_CONTROL_PLANE_MEM_MIB: "8192"# control-plan 节点的 CPU 核心数量VSPHERE_CONTROL_PLANE_NUM_CPUS: "4"# work 节点的磁盘大小VSPHERE_WORKER_DISK_GIB: "20"# work 节点的内存大小VSPHERE_WORKER_MEM_MIB: "4096"# work 节点的 CPU 核心数量VSPHERE_WORKER_NUM_CPUS: "2"# vCenter 的 Datacenter 路径VSPHERE_DATACENTER: /SH-IDC# 虚拟机创建的 Datastore 路径VSPHERE_DATASTORE: /SH-IDC/datastore/datastore1# 虚拟机创建的文件夹VSPHERE_FOLDER: /SH-IDC/vm/Tanzu-node# 虚拟机使用的网络VSPHERE_NETWORK: /SH-IDC/network/VM Network# 虚拟机关联的资源池VSPHERE_RESOURCE_POOL: /SH-IDC/host/Tanzu-Cluster/Resources# vCenter 的 IPVSPHERE_SERVER: 192.168.75.110# vCenter 的用户名VSPHERE_USERNAME: administrator@vsphere.local# vCenter 的密码,以 base64 编码VSPHERE_PASSWORD: <encoded:base64password># vCenter 的证书指纹,可以通过 govc about.cert -json | jq -r '.ThumbprintSHA1' 获取VSPHERE_TLS_THUMBPRINT: EB:F3:D8:7A:E8:3D:1A:59:B0:DE:73:96:DC:B9:5F:13:86:EF:B6:27# 虚拟机注入的 ssh 公钥,需要用它来 ssh 登录集群节点VSPHERE_SSH_AUTHORIZED_KEY: ssh-rsa# 一些默认参数AVI_ENABLE: "false"IDENTITY_MANAGEMENT_TYPE: noneENABLE_AUDIT_LOGGING: "false"ENABLE_CEIP_PARTICIPATION: "false"TKG_HTTP_PROXY_ENABLED: "false"DEPLOY_TKG_ON_VSPHERE7: "true"
$ tanzu management-cluster create --file tanzu-mgt-cluster.yaml -v6# 如果没有配置 VSPHERE_TLS_THUMBPRINT 会有一个确认 vSphere thumbprint 的交互,输入 Y 就可以Validating the pre-requisites...Do you want to continue with the vSphere thumbprint EB:F3:D8:7A:E8:3D:1A:59:B0:DE:73:96:DC:B9:5F:13:86:EF:B6:27 [y/N]: y
root@photon-machine [ ~ ]# tanzu management-cluster create --file tanzu-mgt-cluster.yaml -v 6compatibility file (/root/.config/tanzu/tkg/compatibility/tkg-compatibility.yaml) already exists, skipping downloadBOM files inside /root/.config/tanzu/tkg/bom already exists, skipping downloadCEIP Opt-in status: falseValidating the pre-requisites...vSphere 7.0 Environment Detected.You have connected to a vSphere 7.0 environment which does not have vSphere with Tanzu enabled. vSphere with Tanzu includesan integrated Tanzu Kubernetes Grid Service which turns a vSphere cluster into a platform for running Kubernetes workloads in dedicatedresource pools. Configuring Tanzu Kubernetes Grid Service is done through vSphere HTML5 client.Tanzu Kubernetes Grid Service is the preferred way to consume Tanzu Kubernetes Grid in vSphere 7.0 environments. Alternatively you maydeploy a non-integrated Tanzu Kubernetes Grid instance on vSphere 7.0.Deploying TKG management cluster on vSphere 7.0 ...Identity Provider not configured. Some authentication features won't work.Checking if VSPHERE_CONTROL_PLANE_ENDPOINT 192.168.20.94 is already in useSetting up management cluster...Validating configuration...Using infrastructure provider vsphere:v0.7.10Generating cluster configuration...Setting up bootstrapper...Fetching configuration for kind node image...kindConfig: &{{Cluster kind.x-k8s.io/v1alpha4} [{ map[] [{/var/run/docker.sock /var/run/docker.sock false false }] [] [] []}] { 0 100.96.0.0/11 100.64.0.0/13 false } map[] map[] [apiVersion: kubeadm.k8s.io/v1beta2kind: ClusterConfigurationimageRepository: projects.registry.vmware.com/tkgetcd: local: imageRepository: projects.registry.vmware.com/tkg imageTag: v3.4.13_vmware.15dns: type: CoreDNS imageRepository: projects.registry.vmware.com/tkg imageTag: v1.8.0_vmware.5] [] [] []}Creating kind cluster: tkg-kind-c7vj6kds0a6sf43e6210Creating cluster "tkg-kind-c7vj6kds0a6sf43e6210" ...Ensuring node image (projects.registry.vmware.com/tkg/kind/node:v1.21.2_vmware.1) ...Pulling image: projects.registry.vmware.com/tkg/kind/node:v1.21.2_vmware.1 ...Preparing nodes ...Writing configuration ...Starting control-plane ...Installing CNI ...Installing StorageClass ...Waiting 2m0s for control-plane = Ready ...Ready after 19sBootstrapper created. Kubeconfig: /root/.kube-tkg/tmp/config_3fkzTCOLInstalling providers on bootstrapper...Fetching providersInstalling cert-manager Version="v1.1.0"Waiting for cert-manager to be available...Installing Provider="cluster-api" Version="v0.3.23" TargetNamespace="capi-system"Installing Provider="bootstrap-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-bootstrap-system"Installing Provider="control-plane-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-control-plane-system"Installing Provider="infrastructure-vsphere" Version="v0.7.10" TargetNamespace="capv-system"installed Component=="cluster-api" Type=="CoreProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="BootstrapProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="ControlPlaneProvider" Version=="v0.3.23"installed Component=="vsphere" Type=="InfrastructureProvider" Version=="v0.7.10"Waiting for provider infrastructure-vsphereWaiting for provider control-plane-kubeadmWaiting for provider cluster-apiWaiting for provider bootstrap-kubeadmWaiting for resource capi-kubeadm-control-plane-controller-manager of type *v1.Deployment to be up and runningpods are not yet running for deployment 'capi-kubeadm-control-plane-controller-manager' in namespace 'capi-kubeadm-control-plane-system', retryingPassed waiting on provider bootstrap-kubeadm after 25.205820854spods are not yet running for deployment 'capi-controller-manager' in namespace 'capi-webhook-system', retryingPassed waiting on provider infrastructure-vsphere after 30.185406332sPassed waiting on provider cluster-api after 30.213216243sSuccess waiting on all providers.Start creating management cluster...patch cluster object with operation status:{"metadata": {"annotations": {"TKGOperationInfo" : "{\"Operation\":\"Create\",\"OperationStartTimestamp\":\"2022-02-06 02:35:34.30219421 +0000 UTC\",\"OperationTimeout\":1800}","TKGOperationLastObservedTimestamp" : "2022-02-06 02:35:34.30219421 +0000 UTC"}}}cluster control plane is still being initialized, retryingGetting secret for clusterWaiting for resource tanzu-control-plan-kubeconfig of type *v1.Secret to be up and runningSaving management cluster kubeconfig into /root/.kube/configInstalling providers on management cluster...Fetching providersInstalling cert-manager Version="v1.1.0"Waiting for cert-manager to be available...Installing Provider="cluster-api" Version="v0.3.23" TargetNamespace="capi-system"Installing Provider="bootstrap-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-bootstrap-system"Installing Provider="control-plane-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-control-plane-system"Installing Provider="infrastructure-vsphere" Version="v0.7.10" TargetNamespace="capv-system"installed Component=="cluster-api" Type=="CoreProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="BootstrapProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="ControlPlaneProvider" Version=="v0.3.23"installed Component=="vsphere" Type=="InfrastructureProvider" Version=="v0.7.10"Waiting for provider control-plane-kubeadmWaiting for provider bootstrap-kubeadmWaiting for provider infrastructure-vsphereWaiting for provider cluster-apiWaiting for resource capi-kubeadm-control-plane-controller-manager of type *v1.Deployment to be up and runningPassed waiting on provider control-plane-kubeadm after 10.046865402sWaiting for resource antrea-controller of type *v1.Deployment to be up and runningMoving all Cluster API objects from bootstrap cluster to management cluster...Performing move...Discovering Cluster API objectsMoving Cluster API objects Clusters=1Creating objects in the target clusterDeleting objects from the source clusterWaiting for additional components to be up and running...Waiting for packages to be up and running...Waiting for package: antreaWaiting for package: metrics-serverWaiting for package: tanzu-addons-managerWaiting for package: vsphere-cpiWaiting for package: vsphere-csiWaiting for resource antrea of type *v1alpha1.PackageInstall to be up and runningWaiting for resource vsphere-cpi of type *v1alpha1.PackageInstall to be up and runningWaiting for resource vsphere-csi of type *v1alpha1.PackageInstall to be up and runningWaiting for resource metrics-server of type *v1alpha1.PackageInstall to be up and runningWaiting for resource tanzu-addons-manager of type *v1alpha1.PackageInstall to be up and runningSuccessfully reconciled package: antreaSuccessfully reconciled package: vsphere-csiSuccessfully reconciled package: metrics-serverContext set for management cluster tanzu-control-plan as 'tanzu-control-plan-admin@tanzu-control-plan'.Deleting kind cluster: tkg-kind-c7vj6kds0a6sf43e6210Management cluster created!You can now create your first workload cluster by running the following: tanzu cluster create [name] -f [file]Some addons might be getting installed! Check their status by running the following: kubectl get apps -A
root@photon-machine [ ~ ]# cp ${HOME}/.kube-tkg/config ${HOME}/.kube/config
# 管理集群的 cluster 资源信息,管理集群的 CR 默认保存在了 tkg-system namespace 下root@photon-machine [ ~ ]# kubectl get cluster -ANAMESPACE NAME PHASEtkg-system tanzu-control-plan Provisioned# 管理集群的 machine 资源信息root@photon-machine [ ~ ]# kubectl get machine -ANAMESPACE NAME PROVIDERID PHASE VERSIONtkg-system tanzu-control-plan-control-plane-gs4bl vsphere://4239c450-f621-d78e-3c44-4ac8890c0cd3 Running v1.21.2+vmware.1tkg-system tanzu-control-plan-md-0-7cdc97c7c6-kxcnx vsphere://4239d776-c04c-aacc-db12-3380542a6d03 Provisioned v1.21.2+vmware.1# 运行的组件状态root@photon-machine [ ~ ]# kubectl get pod -ANAMESPACE NAME READY STATUS RESTARTS AGEcapi-kubeadm-bootstrap-system capi-kubeadm-bootstrap-controller-manager-6494884869-wlzhx 2/2 Running 0 8m37scapi-kubeadm-control-plane-system capi-kubeadm-control-plane-controller-manager-857d687b9d-tpznv 2/2 Running 0 8m35scapi-system capi-controller-manager-778bd4dfb9-tkvwg 2/2 Running 0 8m41scapi-webhook-system capi-controller-manager-9995bdc94-svjm2 2/2 Running 0 8m41scapi-webhook-system capi-kubeadm-bootstrap-controller-manager-68845b65f8-sllgv 2/2 Running 0 8m38scapi-webhook-system capi-kubeadm-control-plane-controller-manager-9847c6747-vvz6g 2/2 Running 0 8m35scapi-webhook-system capv-controller-manager-55bf67fbd5-4t46v 2/2 Running 0 8m31scapv-system capv-controller-manager-587fbf697f-bbzs9 2/2 Running 0 8m31scert-manager cert-manager-77f6fb8fd5-8tq6n 1/1 Running 0 11mcert-manager cert-manager-cainjector-6bd4cff7bb-6vlzx 1/1 Running 0 11mcert-manager cert-manager-webhook-fbfcb9d6c-qpkbc 1/1 Running 0 11mkube-system antrea-agent-5m9d4 2/2 Running 0 6mkube-system antrea-agent-8mpr7 2/2 Running 0 5m40skube-system antrea-controller-5bbcb98667-hklss 1/1 Running 0 5m50skube-system coredns-8dcb5c56b-ckvb7 1/1 Running 0 12mkube-system coredns-8dcb5c56b-d98hf 1/1 Running 0 12mkube-system etcd-tanzu-control-plan-control-plane-gs4bl 1/1 Running 0 12mkube-system kube-apiserver-tanzu-control-plan-control-plane-gs4bl 1/1 Running 0 12mkube-system kube-controller-manager-tanzu-control-plan-control-plane-gs4bl 1/1 Running 0 12mkube-system kube-proxy-d4wq4 1/1 Running 0 12mkube-system kube-proxy-nhkgg 1/1 Running 0 11mkube-system kube-scheduler-tanzu-control-plan-control-plane-gs4bl 1/1 Running 0 12mkube-system kube-vip-tanzu-control-plan-control-plane-gs4bl 1/1 Running 0 12mkube-system metrics-server-59fcb9fcf-xjznj 1/1 Running 0 6m29skube-system vsphere-cloud-controller-manager-kzffm 1/1 Running 0 5m50skube-system vsphere-csi-controller-74675c9488-q9h5c 6/6 Running 0 6m31skube-system vsphere-csi-node-dmvvr 3/3 Running 0 6m31skube-system vsphere-csi-node-k6x98 3/3 Running 0 6m31stkg-system kapp-controller-6499b8866-xnql7 1/1 Running 0 10mtkg-system tanzu-addons-controller-manager-657c587556-rpbjm 1/1 Running 0 7m58stkg-system tanzu-capabilities-controller-manager-6ff97656b8-cq7m7 1/1 Running 0 11mtkr-system tkr-controller-manager-6bc455b5d4-wm98s 1/1 Running 0 10m
结合 tanzu 的源码 和部署输出的日志我们大体可以得知,tanzu 管理集群部署大致分为如下几步:
// https://github.com/vmware-tanzu/tanzu-framework/blob/main/pkg/v1/tkg/client/init.go// management cluster init step constantsconst (StepConfigPrerequisite = "Configure prerequisite"StepValidateConfiguration = "Validate configuration"StepGenerateClusterConfiguration = "Generate cluster configuration"StepSetupBootstrapCluster = "Setup bootstrap cluster"StepInstallProvidersOnBootstrapCluster = "Install providers on bootstrap cluster"StepCreateManagementCluster = "Create management cluster"StepInstallProvidersOnRegionalCluster = "Install providers on management cluster"StepMoveClusterAPIObjects = "Move cluster-api objects from bootstrap cluster to management cluster")// InitRegionSteps management cluster init step sequencevar InitRegionSteps = []string{StepConfigPrerequisite,StepValidateConfiguration,StepGenerateClusterConfiguration,StepSetupBootstrapCluster,StepInstallProvidersOnBootstrapCluster,StepCreateManagementCluster,StepInstallProvidersOnRegionalCluster,StepMoveClusterAPIObjects,}
tkg-compatibility
和 tkg-bom
镜像,用于检查环境的兼容性;Downloading TKG compatibility file from 'projects.registry.vmware.com/tkg/framework-zshippable/tkg-compatibility'Downloading the TKG Bill of Materials (BOM) file from 'projects.registry.vmware.com/tkg/tkg-bom:v1.4.0'Downloading the TKr Bill of Materials (BOM) file from 'projects.registry.vmware.com/tkg/tkr-bom:v1.21.2_vmware.1-tkg.1'ERROR 2022/02/06 02:24:46 svType != tvType; key=release, st=map[string]interface {}, tt=<nil>, sv=map[version:], tv=<nil>CEIP Opt-in status: false
Validating the pre-requisites...vSphere 7.0 Environment Detected.You have connected to a vSphere 7.0 environment which does not have vSphere with Tanzu enabled. vSphere with Tanzu includesan integrated Tanzu Kubernetes Grid Service which turns a vSphere cluster into a platform for running Kubernetes workloads in dedicatedresource pools. Configuring Tanzu Kubernetes Grid Service is done through vSphere HTML5 client.Tanzu Kubernetes Grid Service is the preferred way to consume Tanzu Kubernetes Grid in vSphere 7.0 environments. Alternatively you maydeploy a non-integrated Tanzu Kubernetes Grid instance on vSphere 7.0.Deploying TKG management cluster on vSphere 7.0 ...Identity Provider not configured. Some authentication features won't work.Checking if VSPHERE_CONTROL_PLANE_ENDPOINT 192.168.20.94 is already in useSetting up management cluster...Validating configuration...Using infrastructure provider vsphere:v0.7.10
Generating cluster configuration...
Setting up bootstrapper...Fetching configuration for kind node image...kindConfig: &{{Cluster kind.x-k8s.io/v1alpha4} [{ map[] [{/var/run/docker.sock /var/run/docker.sock false false }] [] [] []}] { 0 100.96.0.0/11 100.64.0.0/13 false } map[] map[] [apiVersion: kubeadm.k8s.io/v1beta2kind: ClusterConfigurationimageRepository: projects.registry.vmware.com/tkgetcd: local: imageRepository: projects.registry.vmware.com/tkg imageTag: v3.4.13_vmware.15dns: type: CoreDNS imageRepository: projects.registry.vmware.com/tkg imageTag: v1.8.0_vmware.5] [] [] []}Creating kind cluster: tkg-kind-c7vj6kds0a6sf43e6210Creating cluster "tkg-kind-c7vj6kds0a6sf43e6210" ...Ensuring node image (projects.registry.vmware.com/tkg/kind/node:v1.21.2_vmware.1) ...Pulling image: projects.registry.vmware.com/tkg/kind/node:v1.21.2_vmware.1 ...Preparing nodes ...Writing configuration ...Starting control-plane ...Installing CNI ...Installing StorageClass ...Waiting 2m0s for control-plane = Ready ...Ready after 19sBootstrapper created. Kubeconfig: /root/.kube-tkg/tmp/config_3fkzTCOL
Installing providers on bootstrapper...Fetching providers# 安装 cert-manager 主要是为了生成 k8s 集群部署所依赖的那一堆证书Installing cert-manager Version="v1.1.0"Waiting for cert-manager to be available...Installing Provider="cluster-api" Version="v0.3.23" TargetNamespace="capi-system"Installing Provider="bootstrap-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-bootstrap-system"Installing Provider="control-plane-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-control-plane-system"Installing Provider="infrastructure-vsphere" Version="v0.7.10" TargetNamespace="capv-system"installed Component=="cluster-api" Type=="CoreProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="BootstrapProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="ControlPlaneProvider" Version=="v0.3.23"installed Component=="vsphere" Type=="InfrastructureProvider" Version=="v0.7.10"Waiting for provider infrastructure-vsphereWaiting for provider control-plane-kubeadmWaiting for provider cluster-apiWaiting for provider bootstrap-kubeadmPassed waiting on provider infrastructure-vsphere after 30.185406332sPassed waiting on provider cluster-api after 30.213216243sSuccess waiting on all providers.
Start creating management cluster...patch cluster object with operation status:{"metadata": {"annotations": {"TKGOperationInfo" : "{\"Operation\":\"Create\",\"OperationStartTimestamp\":\"2022-02-06 02:35:34.30219421 +0000 UTC\",\"OperationTimeout\":1800}","TKGOperationLastObservedTimestamp" : "2022-02-06 02:35:34.30219421 +0000 UTC"}}}cluster control plane is still being initialized, retryingGetting secret for clusterWaiting for resource tanzu-control-plan-kubeconfig of type *v1.Secret to be up and runningSaving management cluster kubeconfig into /root/.kube/config
Installing providers on management cluster...Fetching providersInstalling cert-manager Version="v1.1.0"Waiting for cert-manager to be available...Installing Provider="cluster-api" Version="v0.3.23" TargetNamespace="capi-system"Installing Provider="bootstrap-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-bootstrap-system"Installing Provider="control-plane-kubeadm" Version="v0.3.23" TargetNamespace="capi-kubeadm-control-plane-system"Installing Provider="infrastructure-vsphere" Version="v0.7.10" TargetNamespace="capv-system"installed Component=="cluster-api" Type=="CoreProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="BootstrapProvider" Version=="v0.3.23"installed Component=="kubeadm" Type=="ControlPlaneProvider" Version=="v0.3.23"installed Component=="vsphere" Type=="InfrastructureProvider" Version=="v0.7.10"Waiting for provider control-plane-kubeadmWaiting for provider bootstrap-kubeadmWaiting for provider infrastructure-vsphereWaiting for provider cluster-apiWaiting for resource capv-controller-manager of type *v1.Deployment to be up and runningPassed waiting on provider infrastructure-vsphere after 20.091935635sPassed waiting on provider cluster-api after 20.109419304sSuccess waiting on all providers.Waiting for the management cluster to get ready for move...Waiting for resource tanzu-control-plan of type *v1alpha3.Cluster to be up and runningWaiting for resources type *v1alpha3.MachineDeploymentList to be up and runningWaiting for resources type *v1alpha3.MachineList to be up and runningWaiting for addons installation...Waiting for resources type *v1alpha3.ClusterResourceSetList to be up and runningWaiting for resource antrea-controller of type *v1.Deployment to be up and running
Moving all Cluster API objects from bootstrap cluster to management cluster...Performing move...Discovering Cluster API objectsMoving Cluster API objects Clusters=1Creating objects in the target clusterDeleting objects from the source clusterContext set for management cluster tanzu-control-plan as 'tanzu-control-plan-admin@tanzu-control-plan'.Deleting kind cluster: tkg-kind-c7vj6kds0a6sf43e6210Management cluster created!You can now create your first workload cluster by running the following: tanzu cluster create [name] -f [file]Some addons might be getting installed! Check their status by running the following: kubectl get apps -A
部署完成后会删除 bootstrap 集群,因为 bootstrap 集群中的资源已经转移到了管理集群中,它继续存在的意义不大。
上面我们只是部署好了一个 tanzu 管理集群,我们真正的工作负载并不适合运行在这个集群上,因此我们还需要再部署一个 workload 集群,类似于 k8s 集群中的 worker 节点。部署 workload 集群的时候不再依赖 bootstrap 集群,而是使用管理集群。
根据官方文档 vSphere Workload Cluster Template 中给出的模版创建一个配置文件,然后再通过 tanzu 命令来部署即可。配置文件内容如下:
# Cluster Pod IP 的 CIDRCLUSTER_CIDR: 100.96.0.0/11# Service 的 CIDRSERVICE_CIDR: 100.64.0.0/13# 集群的名称CLUSTER_NAME: tanzu-workload-cluster# 集群的类型CLUSTER_PLAN: dev# 集群节点的 archOS_ARCH: amd64# 集群节点的 OS 名称OS_NAME: photon# 集群节点 OS 版本OS_VERSION: "3"# 基础设施资源的提供方INFRASTRUCTURE_PROVIDER: vsphere# cluster, machine 等自定义资源创建的 namespaceNAMESPACE: default# CNI 选用类型,目前应该只支持 VMware 自家的 antreaCNI: antrea# 集群的 VIPVSPHERE_CONTROL_PLANE_ENDPOINT: 192.168.20.95# control-plan 节点的磁盘大小VSPHERE_CONTROL_PLANE_DISK_GIB: "20"# control-plan 节点的内存大小VSPHERE_CONTROL_PLANE_MEM_MIB: "8192"# control-plan 节点的 CPU 核心数量VSPHERE_CONTROL_PLANE_NUM_CPUS: "4"# work 节点的磁盘大小VSPHERE_WORKER_DISK_GIB: "20"# work 节点的内存大小VSPHERE_WORKER_MEM_MIB: "4096"# work 节点的 CPU 核心数量VSPHERE_WORKER_NUM_CPUS: "2"# vCenter 的 Datacenter 路径VSPHERE_DATACENTER: /SH-IDC# 虚拟机创建的 Datastore 路径VSPHERE_DATASTORE: /SH-IDC/datastore/datastore1# 虚拟机创建的文件夹VSPHERE_FOLDER: /SH-IDC/vm/Tanzu-node# 虚拟机使用的网络VSPHERE_NETWORK: /SH-IDC/network/VM Network# 虚拟机关联的资源池VSPHERE_RESOURCE_POOL: /SH-IDC/host/Tanzu-Cluster/Resources# vCenter 的 IPVSPHERE_SERVER: 192.168.20.92# vCenter 的用户名VSPHERE_USERNAME: administrator@vsphere.local# vCenter 的密码,以 base64 编码VSPHERE_PASSWORD: <encoded:YWRtaW5AMjAyMA==># vCenter 的证书指纹,可以通过 govc about.cert -json | jq -r '.ThumbprintSHA1' 获取VSPHERE_TLS_THUMBPRINT: CB:23:48:E8:93:34:AD:27:D8:FD:88:1C:D7:08:4B:47:9B:12:F4:E0# 虚拟机注入的 ssh 公钥,需要用它来 ssh 登录集群节点VSPHERE_SSH_AUTHORIZED_KEY: ssh-rsa# 一些默认参数AVI_ENABLE: "false"IDENTITY_MANAGEMENT_TYPE: noneENABLE_AUDIT_LOGGING: "false"ENABLE_CEIP_PARTICIPATION: "false"TKG_HTTP_PROXY_ENABLED: "false"DEPLOY_TKG_ON_VSPHERE7: "true"# 是否开启虚拟机健康检查ENABLE_MHC: trueMHC_UNKNOWN_STATUS_TIMEOUT: 5mMHC_FALSE_STATUS_TIMEOUT: 12m# 是否部署 vsphere cis 组件ENABLE_DEFAULT_STORAGE_CLASS: true# 是否开启集群自动扩缩容ENABLE_AUTOSCALER: false
root@photon-machine [ ~ ]# tanzu cluster create tanzu-workload-cluster --file tanzu-workload-cluster.yamlValidating configuration...Warning: Pinniped configuration not found. Skipping pinniped configuration in workload cluster. Please refer to the documentation to check if you can configure pinniped on workload cluster manuallyCreating workload cluster 'tanzu-workload-cluster'...Waiting for cluster to be initialized...Waiting for cluster nodes to be available...Waiting for cluster autoscaler to be available...Unable to wait for autoscaler deployment to be ready. reason: deployments.apps "tanzu-workload-cluster-cluster-autoscaler" not foundWaiting for addons installation...Waiting for packages to be up and running...Workload cluster 'tanzu-workload-cluster' created
root@photon-machine [ ~ ]# kubectl get clusterNAME PHASEtanzu-workload-cluster Provisioned# machine 状态处于 Running 说明节点已经正常运行了root@photon-machine [ ~ ]# kubectl get machineNAME PROVIDERID PHASE VERSIONtanzu-workload-cluster-control-plane-4tdwq vsphere://423950ac-1c6d-e5ef-3132-77b6a53cf626 Running v1.21.2+vmware.1tanzu-workload-cluster-md-0-8555bbbfc-74vdg vsphere://4239b83b-6003-d990-4555-a72ac4dec484 Running v1.21.2+vmware.1
集群部署好之后,如果想对集群节点进行扩缩容,我们可以像 deployment 的一样,只需要修改一些 CR 的信息即可。cluster-api 相关组件会 watch 到这些 CR 的变化,并根据它的 spec 信息进行一系列调谐操作。如果当前集群节点数量低于所定义的节点副本数量,则会自动调用对应的 Provider 创建虚拟机,并对虚拟机进行初始化操作,将它转换为 k8s 里的一个 node 资源;
即扩容 master 节点,通过修改 KubeadmControlPlane
这个 CR 中的 replicas
副本数即可:
root@photon-machine [ ~ ]# kubectl scale kcp tanzu-workload-cluster-control-plane --replicas=3# 可以看到 machine 已经处于 Provisioning 状态,说明集群节点对应的虚拟机正在创建中root@photon-machine [ ~ ]# kubectl get machineNAME PROVIDERID PHASE VERSIONtanzu-workload-cluster-control-plane-4tdwq vsphere://423950ac-1c6d-e5ef-3132-77b6a53cf626 Running v1.21.2+vmware.1tanzu-workload-cluster-control-plane-mkmd2 Provisioning v1.21.2+vmware.1tanzu-workload-cluster-md-0-8555bbbfc-74vdg vsphere://4239b83b-6003-d990-4555-a72ac4dec484 Running v1.21.2+vmware.1
扩容 worker 节点,通过修改 MachineDeployment
这个 CR 中的 replicas
副本数即可:
root@photon-machine [ ~ ]# kubectl scale md tanzu-workload-cluster-md-0 --replicas=3root@photon-machine [ ~ ]# kubectl get machineNAME PROVIDERID PHASE VERSIONtanzu-workload-cluster-control-plane-4tdwq vsphere://423950ac-1c6d-e5ef-3132-77b6a53cf626 Running v1.21.2+vmware.1tanzu-workload-cluster-control-plane-mkmd2 vsphere://4239278c-0503-f03a-08b8-df92286bcdd7 Running v1.21.2+vmware.1tanzu-workload-cluster-control-plane-rt5mb vsphere://4239c882-2fe5-a394-60c0-616941a6363e Running v1.21.2+vmware.1tanzu-workload-cluster-md-0-8555bbbfc-4hlqk vsphere://42395deb-e706-8b4b-a44f-c755c222575c Running v1.21.2+vmware.1tanzu-workload-cluster-md-0-8555bbbfc-74vdg vsphere://4239b83b-6003-d990-4555-a72ac4dec484 Running v1.21.2+vmware.1tanzu-workload-cluster-md-0-8555bbbfc-ftmlp vsphere://42399640-8e94-85e5-c4bd-8436d84966e0 Running v1.21.2+vmware.1
本文只是介绍了 tanzu 集群部署的大体流程,里面包含了 cluster-api 相关的概念在本文并没有做深入的分析,因为实在是太复杂了 😂,到现在我还是没太理解其中的一些原理,因此后续我再单独写一篇博客来讲解一些 cluster-api 相关的内容,到那时候在结合本文来看就容易理解很多。
自从换了新工作之后,则开始负责 超融合产品 集群部署相关工作,因此也会接触很多 镜像
,不过这个镜像是操作系统的 ISO 镜像而不是容器镜像 😂。虽然两者都统称为镜像,但两者有着本质的区别。
首先两者构建的方式有本质的很大的区别,ISO 镜像一般使用 mkisofs
或者 genisoimage
等命令将一个包含操作系统安装所有文件目录构建为一个 ISO 镜像;而容器镜像构建则是根据 Dockerfile
文件使用相应的容器镜像构建工具来一层一层构建;
另外 ISO 镜像挂载后是只读的,这就意味着如果想要修改 ISO 镜像中的一个文件(比如 kickstart 文件),则需要先将 ISO 镜像中的所有内容负责到一个可以读写的目录中,在这个读写的目录中进行修改和重新构建 ISO 操作。
╭─root@esxi-debian-devbox ~/build╰─# mount -o loop CentOS-7-x86_64-Minimal-2009.iso /mnt/isomount: /mnt/iso: WARNING: device write-protected, mounted read-only.╭─root@esxi-debian-devbox ~/build╰─# touch /mnt/iso/kickstart.cfgtouch: cannot touch '/mnt/iso/kickstart.cfg': Read-only file system
在日常工作中经常会对一些已有的 ISO 镜像进行重新构建,重新构建 ISO 的效率根据不同的方式也会有所不同,本文就整理了三种不同重新构建 ISO 镜像的方案供大家参考。
以下是按照 RedHat 官方文档 WORKING WITH ISO IMAGES 中的操作步骤进行 ISO 重新构建。
/mn/iso
目录下;╭─root@esxi-debian-devbox ~/build╰─# mount -o loop CentOS-7-x86_64-Minimal-2009.iso /mnt/isomount: /mnt/iso: WARNING: device write-protected, mounted read-only.
╭─root@esxi-debian-devbox ~/build╰─# rsync -avrut --force /mnt/iso/ /mnt/build/
# 使用 genisoimage 命令构建 ISO 镜像,在 CentOS 上可以使用 mkisofs 命令,参数上会有一些差异╭─root@esxi-debian-devbox ~/build╰─# genisoimage -U -r -v -T -J -joliet-long -V "CentOS 7 x86_64" -volset "CentOS 7 x86_64" -A "CentOS 7 x86_64" -b isolinux/isolinux.bin -c isolinux/boot.cat -no-emul-boot -boot-load-size 4 -no-emul-boot -o /mnt/CentOS-7-x86_64-Minimal-2009-dev.iso .Total translation table size: 124658Total rockridge attributes bytes: 55187Total directory bytes: 100352Path table size(bytes): 140Done with: The File(s) Block(s) 527985Writing: Ending Padblock Start Block 528101Done with: Ending Padblock Block(s) 150Max brk space used a4000528251 extents written (1031 MB)# 给 ISO 镜像生成 md5 校验╭─root@esxi-debian-devbox ~/build╰─# implantisomd5 /mnt/CentOS-7-x86_64-Minimal-2009-dev.isoInserting md5sum into iso image...md5 = 9ddf5277bcb1d8679c367dfa93f9b162Inserting fragment md5sums into iso image...fragmd5 = f39e2822ec1ae832a69ae399ea4bd3e891eeb31e9deb9c536f529c15bbebfrags = 20Setting supported flag to 0
对于 ISO 镜像比较小或者该操作不是很频繁的情况下按照这种方式是最省事儿的,但如果是 ISO 镜像比较大,或者是在 CI/CD 流水线中频繁地重新构建镜像,每次都要 cp 复制原 ISO 镜像的内容确实比较浪费时间。那有没有一个更加高效的方法呢 🤔️
经过一番摸索,折腾出来两种可以避免使用 cp 复制这种占用大量 IO 操作的构建方案,可以根据不同的场景进行选择。
熟悉 docker 镜像的应该都知道镜像是只读的,使用镜像的时候则是通过联合挂载的方式将镜像的每一层 layer 挂载为只读层,将容器实际运行的目录挂载为读写层,而容器运行期间在读写层的所有操作不会影响到镜像原有的内容。容器镜像挂载的方式使用最多的是 overlay2 技术,在 overlay2 在打包发布流水线中的应用 和 深入浅出容器镜像的一生 🤔 中咱曾对它进行过比较深入的研究和使用,对 overlay2 技术感兴趣的可以翻看一下这两篇博客,本文就不再详解其中的技术原理了,只对使用 overlay2 技术重新构建 ISO 镜像的可行性进行一下分析。
╭─root@esxi-debian-devbox ~╰─# mkdir -p /mnt/overlay2/{lower,upper,work,merged}╭─root@esxi-debian-devbox ~╰─# cd /mnt/overlay2
lower
目录╭─root@esxi-debian-devbox /mnt/overlay2╰─# mount -o loop /root/build/CentOS-7-x86_64-Minimal-2009.iso lowermount: /mnt/overlay2/lower: WARNING: device write-protected, mounted read-only.
merged
目录╭─root@esxi-debian-devbox /mnt/overlay2╰─# mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged╭─root@esxi-debian-devbox /mnt/overlay2╰─# cd merged
╭─root@esxi-debian-devbox /mnt/overlay2/merged╰─# echo '# this is a kickstart config file' > kickstart.cfg╭─root@esxi-debian-devbox /mnt/overlay2/merged╰─# genisoimage -U -r -v -T -J -joliet-long -V "CentOS 7 x86_64" -volset "CentOS 7 x86_64" -A "CentOS 7 x86_64" -b isolinux/isolinux.bin -c isolinux/boot.cat -no-emul-boot -boot-load-size 4 -no-emul-boot -o /mnt/CentOS-7-x86_64-Minimal-2009-dev.iso .Total translation table size: 124658Total rockridge attributes bytes: 55187Total directory bytes: 100352Path table size(bytes): 140Done with: The File(s) Block(s) 527985Writing: Ending Padblock Start Block 528101Done with: Ending Padblock Block(s) 150Max brk space used a4000528251 extents written (1031 MB)
╭─root@esxi-debian-devbox /mnt/overlay2/merged╰─# mount -o loop /mnt/CentOS-7-x86_64-Minimal-2009-dev.iso /mnt/newisomount: /mnt/newiso: WARNING: device write-protected, mounted read-only.╭─root@esxi-debian-devbox /mnt/overlay2/merged╰─# cat /mnt/newiso/kickstart.cfg# this is a kickstart config file
前面讲到了使用 overlay2 的方式避免复制原镜像内容进行重新构建镜像的方案,但是 overlay2 对于不是很熟悉的人来讲还是比较复杂,光 lowerdir、upperdir、workdir、mergeddir 这四个文件夹的作用和原理就把人直接给整不会了。那么还有没有更为简单一点的方式呢?
别说还真有,只不过这种方式的用途比较局限。如果仅仅是用于修改 ISO 中的一个文件或者目录,可以将该文件或目录以 bind
挂载的方式将它挂载到 ISO 目录目录对应的文件上。
原理就是虽然 ISO 目录本身是只读的,但它里面的文件和目录是可以作为一个挂载点的。也就是说我把文件 A 挂载到文件 B,并不是在修改文件 B,这就是 Unix/Linux 文件系统十分奇妙的地方。同样运用 bind 挂载的还有 docker 的 volume 以及 pod 的 volume 也是运用同样的原理,以 bind 的方式将宿主机上的目录或文件挂载到容器运行对应的目录上。对于修改只读 ISO 里的文件/目录我们当然也可以这样做。废话不多说来实践验证一下:
/mn/iso
目录╭─root@esxi-debian-devbox ~/build╰─# mount -o loop CentOS-7-x86_64-Minimal-2009.iso /mnt/isomount: /mnt/iso: WARNING: device write-protected, mounted read-only.
/mnt/files/ks.cfg
文件,并写入我们需要的内容╭─root@esxi-debian-devbox ~/build╰─# mkdir -p /mnt/files╭─root@esxi-debian-devbox ~/build╰─# echo '# this is a kickstart config file' > /mnt/files/ks.cfg
╭─root@esxi-debian-devbox /mnt/build╰─# mount --bind /mnt/files/ks.cfg /mnt/iso/EULA╭─root@esxi-debian-devbox /mnt/build╰─# cat /mnt/iso/EULA# this is a kickstart config file
╭─root@esxi-debian-devbox /mnt/iso╰─# genisoimage -U -r -v -T -J -joliet-long -V "CentOS 7 x86_64" -volset "CentOS 7 x86_64" -A "CentOS 7 x86_64" -b isolinux/isolinux.bin -c isolinux/boot.cat -no-emul-boot -boot-load-size 4 -boot-info-table -no-emul-boot -o /mnt/CentOS-7-x86_64-Minimal-2009-dev.iso .
╭─root@esxi-debian-devbox /mnt/iso╰─# mkdir /mnt/newiso╭─root@esxi-debian-devbox /mnt/iso╰─# mount -o loop /mnt/CentOS-7-x86_64-Minimal-2009-dev.iso /mnt/newisomount: /mnt/newiso: WARNING: device write-protected, mounted read-only.╭─root@esxi-debian-devbox /mnt/iso╰─# cat /mnt/newiso/EULA# this is a kickstart config file
验证通过,确实可以!不过这种方式很局限,比较适用于修改单个文件如 kickstart.cfg
,如果是要新增文件那还是使用上文提到的 overlay2 的方式更为方便一些。
虽然 ISO 镜像和容器镜像二者有着本质的差别,但对于只读和联合挂载的这些特性二者可以相互借鉴滴。
不止如此 overlay2 这种联合挂载的特性,还可以用在其他地方。比如我有一个公共的 NFS 共享服务器,共享着一些目录,所有人都可以以 root 用户并以读写的权限进行 NFS 挂载。这种情况下很难保障一些重要的文件和数据被误删。这时候就可以使用 overlay2 的方式将一些重要的文件数据挂载为 overlay2 的 lowerdir 只读层,保证这些数据就如容器镜像一样,每次挂载使用的时候都作为一个只读层。所有的读写操作都在 overlay2 的 merged 那一层,不会真正影响到只读层的内容。
草草地水了一篇博客,是不是没有用的知识又增加了 😂