2024-06-14 00:00:00
转瞬间距上一次更新博客快半年了,主要是这段时间在享福报,享完福报后周末就开车拉着同事去徒步游玩放松一下,因此实在是没有心情来写博客(其实是自己懒,找个借口自欺一下自己 😔
不过好在端午节前终于修完了所有的 ticket,QE 小伙伴也完成了第一轮测试,项目终于有了个阶段性的成果,没了这个烦恼心情自然好了很多。正好端午节完成了一次环千岛湖骑行,就想着和《2021 五一假期环太湖骑行之旅》一样,水一篇博客来记录一下游玩时的所思所想以及所见所闻吧。那么咱们就开始回顾下端午节游玩的经历吧 😇
在《考驾照和买二手车》里曾提到过,自从去年买车之后就和同事入坑了户外徒步,每个月都会和同事一起自驾去江浙附近徒步游玩。因为一起出去玩儿的次数多了,和其中两位同事的关系也就密切了很多。其中一位是前端开发,另一位是 UI 设计师,为了后面叙述方便,就分别简称他俩为 FE
和 UI
好了。在公司每到饭点,我们仨个就一起去楼下吃麻辣烫或者油泼面,再或者晚上下班后一起骑车去耀华路附近的路边摊吃炒饭。时间长了,三个人的关系就变得非同一般了,这也是我毕业后工作五多年来结交的关系最好的两个朋友。之前在公司都是做一个小透明,就像学校里的归宅部,下班后就早早溜回家一个人玩泥巴,也不太参与同事之间的社交。这种局面从去年我买车之后才开始真正改变的,不得不说有了一辆车之后,生活的确改变了很多,变得越来越现充了。
早在过年之前,另一位同事(也是前端开发)和 UI
就计划清明节休假几天去日本田边市和歌山的熊野古道徒步。春节期间开车回老家顺路送 UI
,UI
在车里开始学日语五十音。我们周末出去徒步游玩的时候 UI
也在车里跟着多邻国学日语,有时候他突然蹦出来一句日语,整得我们一阵欢声笑语。
不过好景不长,正当 UI
每日充满期待干劲满满地学日语的时候,在 3 月底公司迎来了一波裁员,不幸 UI
在裁员的队列中。得知被裁的消息后,UI
直接愣住了,给我说的第一句话居然是:我被裁了,熊野古道徒步去不了了。我赶紧安抚他说:裁员是小事,熊野古道徒步还是要去,因为机会难得,现在放弃了会让你后悔一辈子。UI
听从了我的建议端午节和另一位同事去了熊野古道。
UI
从熊野古道徒步回来后,晚上一起吃饭的时候就和们分享了在日本游玩的经历,听的着实有些羡慕,那时就萌生了也要去熊野古道徒步的念头。接着到了四月底,同事在我们徒步小队微信群里说端午节期间上海到名古屋的往返机票只要 1500 出头,赶紧冲冲冲鸭。当时感觉我的熊野古道徒步机会来了,于是就立马在淘宝上找了个旅行社准备办一个三年多次的赴日签证。因为当时再过几天就到五一假期了,就行动起来准备签证材料。还好准备的都比较顺利,在五一放假前旅行社就牵收到了我邮寄的材料。五一过后大概等了七个工作日,签证就顺利办下来了。
签证下来后,正准备定酒店和机票,就在那时自己负责的项目出现了风险要延期发布了。当时有点纠结端午节究竟要不要休假去熊野古道徒步。那天正好到了和 leader 每个月 1:1 的时间点,就想着等和 leader 1:1 后再做决定吧。和 leader 1:1 后感觉要凉了,因为端午节前 QE 会完成第一轮的测试,端午节后要进行第二轮的回归测试,由于要搭配两个产品的版本发布,第二轮的回归测试要在这两个版本上分别测一遍。赶在 6 月底项目发布,QE 测试的压力会很大,因此需要投入研发的人力到回归测试中。当时就没有再考虑,果断放弃了熊野古道徒步的计划,等到以后项目发布之后再去吧。端午节就像往常一样,和饭搭子一块去江浙附近徒步游玩吧。
五一假期的时候和饭搭子一块去台州仙居的公盂村以及神仙居景区徒步游玩了几天,可能是路途遥远加上徒步也比较累,端午节 FE
想去享福躺平型的路线,不想再像五一那样那么累了。而 UI
也没有主见,觉着去哪里都可以。期间想过去绍兴东白山露营观星,不过由于天气原因被否了;也想过去嵊泗海岛游玩,但担心由于天气原因被困在岛上买不到回来的票就放弃了;也想过去台州温岭的水桶岙徒步赶海,因为是徒步路线也被 FE
否掉了。就这样大家纠结了很久也没有想好要去哪儿,既然这样那这次就单独行动,自己一个人出去玩儿吧。
在石头星球和游侠客小程序上都看到了千岛湖两天一夜的骑行玩法,就想着要不端午节来一次环千岛湖骑行,嗯,可以可以,冲冲冲。于是看了下江浙附近的天气预报,说是周六有小雨、周日阴天、周一多云。又看了下环千岛湖骑行的轨迹,不到 140 公里,骑两天感觉太轻松,不如挑战一下自己,一天完成环千岛湖骑行?那就定下了周日完成环千岛湖骑行。
又接着看了去千岛湖的导航路线,会路过富阳和绍兴,那周六就选择一个在富阳的徒步路线,于是就找到了杏梅尖。然后又看了下回来时的路线,可以绕一点远路去王位山跑山,然后再从莫干山景区附近上 S12 申嘉湖高速回上海。去年 12 月底的时候和饭搭子去过一次王位山徒步,感觉那里的山路真的不错,有杭州秋名山之称,十分适合跑山(练习排水沟过弯)。
于是端午节大体的游玩计划初步成型:
和往常出去游玩时一样,在 Notion 上制定了一个游玩时的计划表:
在《2021 五一假期环太湖骑行之旅》的时候骑行用的车子是 2012 款 GIANT 捷安特 ATX770-D,这辆车子从杭州搬家来上海的时候也一并带来了,一直用它骑行上下班。在去年四月份的时候想换一辆公路车了,因为 ATX770-D 车身太重骑行起来比较累,于是就在闲鱼上花了 1500 买了辆二手的捷安特 ROAM 2 跨界公路车。这辆车一个特点就是车架是山地车,轮圈可以装山地车宽胎,也可以装公路车窄胎,算是公路车和山地车的缝合怪 😂。自从把买来后它一直是我主力代步工具,每天都会骑着它上下班,这次的环千岛湖骑行就准备带着它去。
这辆 ROAM2 公路车本身没有太多问题,前段时间后轮辐条断了一根。去公司附近的捷安特专卖店问了下可以修但配件等的时间比较久,但我感觉是只换一根辐条修车的利润比较少,店员不想给修。于是就自己买来辐条进行更换,顺带更换了下前后轮的外胎和内胎。
由于小区停车很难,对于咱这种实力(习)期的新手而言简直就是噩梦。有好几次停进来的时候很顺利,开出去的时候由于旁边的车停得不讲武德导致我出库的时候被卡住,每次都要保安指挥或者打电话让车主来挪车才能出来:
为了周六早上出发的时候车子不像往常一样被堵在车位里,周五早上就将车挪到了外面方便出库的位置。后来事实证明这样做是对的,周六早上出发的时候果然有辆车和往常一样停在路上,挡住了车位线里面的车。还好有先见之明,不然早上五点半多让车主来挪车,车主也是挺奔溃的。
周五下午 TL 告诉我项目就剩最后一个待验证的 ticket 了,我瞅了一下 Jira 面板,哦豁,还真是耶。赶紧 DM 鼓励了一下 QE 小伙伴,加油加油,冲冲冲!等收到 ticket close 的邮件后,第一时间感谢了下 QE 小伙伴。这是我入职以来作为主力后端开发的第二个项目,也是我觉着最难的项目。第一轮测试结束算是一个阶段性的胜利,心情好了很多,之前一直在崩溃的边缘挣扎,今天终于要结束了。没了烦恼端午节就能有个好心情愉快地玩耍啦,在这里再次感谢我那位 QE 小伙伴。
忙完项目上的事情后看了下时间大概五点多了,就赶紧骑车溜回家,因为要把在骑的这辆公路车塞到我那辆思域车里,还不确定车里能不能放得下,为了以防万一就早点回家吧,要是真的放不下也有时间想办法来处理,不至于忙到天黑。
到家后,就将公路车的车轮和车座拆下,并用塑封膜包裹住链条和牙盘的部分,以免油泥擦碰到车上。刚开始试着将车子放进后备箱,调整了好几次都不太行。如果是辆两厢或者旅行车就好了,有点后悔当初买了三厢的思域。主要是三厢车后排放倒后空间还是很紧凑,容纳不下山地车横放的空间。于是试着把车子放在后排,没想到能完美地塞进去,就是挤占了后排的空间,不过这次就我一个人出来玩,后排不用坐人所以也没事儿。最后再用安全带固定主车架,以免禁止制动的时候车子由于惯性飞到前排来,这样吃席风险大大减少。
把车子装好之后就放心了,本以为放不下的,没想到后排的空间斜放一辆公路车还是绰绰有余滴,等到以后去莫干山或崇明岛骑行游玩的时候就可以采用相同的办法了,非常不戳。
每次要出去玩儿的时候,头天晚上就兴奋地睡不着,有点像上学的时候放寒暑假的那天晚上一直睡不着,和宿舍下铺聊天到大半夜一样。晚上睡得不是很好,凌晨四点多就不知不觉醒来了,醒来后干瞪眼睡不着。距离出发还有两个小时,干瞪眼也是浪费时间,索性就起床打开笔记本开始整理博客,思考下这篇游记的内容。一直等到 5 点半左右,就起身洗脸刷牙收拾东西,开始出发了。
收拾好东西后,就下楼准备把车从车位开出来。下楼后发现果然有个大聪明把车停在出库的路上,幸好昨天早上我把车挪走了,不然今天又要打电话让他来挪车了。因此出门前做好规划,把一些潜在的问题提前规避掉,能省不少事儿。上午先导航到绍兴,去我姐那里拿帐篷以及一些其他的露营装备。这次的导航路线还是和往常一样:S32 申嘉湖高速 -> G60 沪昆高速 -> G1522 常台高速。在浦东出沪去往宁波或绍兴方向,如果是住在 S32 申嘉湖高速以北的地方,还是挺建议这里路线的。会比走 S20 外环高速 -> G60 沪昆高速路况要好很多。因为 S20 外环高速和 G60 沪昆高速途径上海的主要城区,高速的出入口会很多,车流量也很大,节假肯定会堵车。而 S32 申嘉湖高速途径的地方基本上都是一些郊区,车流量会少一些,很少会遇到堵车的情况。不过等到今年 7 月份 S32 申嘉湖高速上海段免费之后可能会有所不同了,毕竟还是会有很多人绕一点远路来白嫖这几十公里的高速费的 😂。
因为选择了路线比较通畅且出发的比较早,路上基本上没有遇到堵车的情况,一路很顺畅地来到了绍兴,从我姐那拿到帐篷后就继续出发前往富阳杏梅尖了。之所以来拿帐篷因为想着在千岛湖骑行的时候看看能不能在千岛湖找到一个合适的露营地点,晚上就不住酒店来露营。后来事实证明我太天真了,露营什么的根本就不存在。
9 点半左右到达富阳瑶坞村,村子里的露天停车场收费 10 块。车子停好后,收拾好行李就开始跟着两步路上的轨迹徒步。富阳杏梅尖这条经典的徒步路线像空心户外、游侠客、浦东户外、华东户外每个月都会成团来的。这里不是一个正规开发的景区,不过好在当地建设和维护的都还不错,中途还有一个补给点可以休息。虽然这条路线 13 公里爬升 1000 米看着有点难度,但爬升的坡度还是相对轻松一些,不像之前去盐帮古道回来时 1.5 公里爬升 400 米那样十分陡峭难走。只要放慢节奏,正常人还是能走完全程的。
下山的时候看到一个很有意思的路标牌,上面写着 「很明显这里有条近路」,于是赶紧拍照发在我们的徒步小队微信群里。去年带饭搭子去覆卮山徒步,下山回来的时候因为我选择了抄近路把大家带到了一片深山老林里,在丛林里走了大半天差点就要叫救援,好在有惊无险最终安全下山了。从那以后我只要一提抄近路,就会引来他们的一阵嘲讽,调侃我这是大聪明行为 🤣。这次可是货真价实的近路,而 FE 回复:只有聪明的人才能看到的近路!哈哈,可能是因为那次抄近路翻车,我在他们心中的形象就变成了一个大聪明 😓。
顺着这条近路下山,小路周边被树木和芦苇覆盖的严严实实,有时候需要弯腰从这些茂密的树丛钻过去,有那么一点点丛林探险的感觉 😂。半路遇到一处小池子,在里面玩了会水,洗了洗就继续下山了。
到达停车场后才下午三点多,比预期回来早一个小时。当时太阳正烈,打开车门发现车内 50 多度。有点后悔没有带隔热的车衣,虽然挡风玻璃上撑开了遮阳伞,但隔热效果并不佳。50 多度简直像蒸拿房一样热,坐在这里一小会已经汗流浃背了,比中午在上山的时候还要热。于是导航看了下去千岛湖的路线,看看能不能在上高速前找个加油站休息会,顺便在阴凉的地方开空调让车内温度降下来。看地图导航发现去千岛湖的长深高速要堵车 30 分钟,原本一个半小时的车程现在要两个小时了。顺着路线找到了另一个高速入口刚好能绕过堵车的路段,附近还有一个加油站,十分合适,于是就开车前往加油站。
出来村子的时候看到了有个宝马车主和村民小哥发生了冲突。因为杏梅尖这里并不是一个正规开发的景区,不是本村的车进村都要交 10 块钱的停车费。开宝马的老哥很任性,就是不想交这 10 块钱的停车费,想要从逆向车道硬闯过来,我刚好路过差一点点撞上,拿二维码收费的村民小哥也被气得骂骂咧咧地问候他全家。小哥帮我看了下车没有被剐蹭到,我就尴尬地调侃了下:你看吧,人与人之间的差距比宝马与思域的差距还要大。
虽然村民收停车费不是很正规也没有发票啥的,但这种情况我们徒步的时候也遇到的可多了,不过我们每次都会很爽快地交个停车费。10 块钱的停车费可以避免很多不必要的麻烦,总不能为了省个 10 块钱绕大半天找个车位。如果停的地方不合适的话说不定还会被贴违章罚款 200 块,再或者停路边被剐蹭了也不好弄,所以为了省事和节省时间成本我们都不会太计较这些。之前去绍兴的呼狗崖徒步的时候就停在农家里,收费 10 块;去台州仙居的时候村里的露天停车场收费 10 块;去宁波风车公路的时候在童夏家村,那里建设的比较好,村口会有停车收费的闸机,然后附近还有卫生间,周边的基础设施做的很好。尽管我们徒步游玩去的都是一些免费白嫖的路线,但和当地的村民相处还是很融洽的,从来没有发生过冲突。
所以嘛,出门在外,花点小钱可以摆平的事情还是不要发生不必要的冲突,和人吵一架一天的好心情就没了。在中国,尤其是农村,说点好话讲点人情世故,互相理解一下,相处起来就会很融洽。就像虽然我没有抽烟的习惯,但我车里仍然会放一包烟,真的遇到点麻烦事儿,给大哥大叔大爷啥的递根烟,说点好话人家还是很愿意帮忙的。之前我在小区停车被堵住的时候,我都会给保安大叔递根烟,说声谢谢麻烦啦等客套话,他都会很乐意帮忙指挥我把车开出来。因为帮的次数多了,慢慢就混熟了,在小区偶尔遇到了也会相互打招呼说两句。
导航到加油站加完油,休息了一段时间继续前往淳安县城。下高速进入到淳安县城后,道路就变得十分离谱。双向两车道开着开着右侧的非机动车道就变成了机动车道,然后一过红绿灯又强行变成了两车道,我满脸???。然后旁边非机动车道的车就被强行挤了过来,每过路口的时候都要留意一下,还有次险些撞上。还有一些机动车直接开在非机动车道,遇到前面有电驴的时候又加塞过来,开车体验极差。
更离谱的是酒店门口大概有十个画车位线的车位,这些画车位线的车位都不是酒店的,而是政府规划的。停车按小时收费,每小时 5 块,停一天要 60。然后酒店住客的车就停在车位线外面的一些狭窄空位上。经过酒店保安大叔的指挥总算在一个犄角旮旯里停好了车。停好车后就把山地车拿出来装上轮子,轮子装上后发现刹车盘会蹭碟刹片,一顿操作猛如虎,调试了大半天还是会蹭那么一点点,先就这样凑活着吧,又不是不能骑。
晚上没有吃饭,简单吃了点水果和粽子就算填饱肚子了。拿出笔记本整理了下今天的日记,就睡了,希望明天的环千岛湖骑行能顺利一些。
之前看天气预报说是千岛湖周六有雨周日多云的,没想到周日早上开始下雨了。用彩云天气看了下,上午会一直下雨,中午会停一会。八点半之后雨会小一些,于是就想着等雨小一点再去骑行吧。如果因为下雨就放弃了感觉还是挺遗憾的。就这样一直等到 9 点左右,雨终于小了一些。已经九点了,再不出发晚上回来就要走夜路是十分危险的。当时内心还是有一点退缩想要放弃的想法,不过又想起了一位推友的话:
如果放弃了,我可能会后悔一辈子,所以即便还在零星下着小雨,还是毅然决然出发了,冲冲冲!
刚开始骑行的时候就遇到了不顺,由于昨晚没有调整好轮组,刹车碟片会一直蹭外层的刹车片,相当于踩着 10% 的刹车在骑,导致骑起来特别费劲。于是不得不停下来调整轮组的角度,让它刚好卡在一个不碰刹车片的位置。当时雨还突然下大了,穿着雨衣热得满头大汗,再加上忘记带毛巾了,十分地狼狈。不过好在经过一顿操作终于调整好了。调整好车子后就继续骑行,刚开始的一段距离特别难走,因为下雨怕身体被雨淋失温就不得不披着雨衣,雨衣又不透气,出汗之后汗水顺着胳膊流到袖口,手一甩就一大把汗水。,就像一只落汤鸡,好狼狈。
从九点一直骑行到 11 点半,中途休息了十几分钟。出发时只带了一瓶水,刚开始骑行的了大概 20 多公里仍然没有找到一家超市可以买水和。路上刚好碰见一个卖杨梅的老奶奶,就从她那里买了一小筐杨梅。顺便问了下老奶奶附近有没有吃饭的地方,她说还比较远,骑车可能要 20 分钟。老奶奶看着我可怜兮兮的样子,就从饭盒里拿出一个自家做的包子要送给我,让我填一下肚子。当时感觉好尴尬 😅,我就厚着脸皮有点不好意思地接受了。豆腐馅的包子是我喜欢吃的,真好。吃着吃着鼻子一酸,莫名其妙想起了已故的奶奶,小时候经常和弟弟偷偷摸摸向奶奶要零花钱,回想起那一幕着真的好怀念。想起《寻梦环游记》中的一段台词:死亡不是生命的终点,遗忘才是。
吃完包子和杨梅后就继续向前出发,争取十二点前找到一家农家乐吃午饭补充下体力。终于上午骑行了 50 公里后到了一家农家乐,在里面点了一份蛋炒饭和一瓶柚子汁。
吃饭的时候听农家乐老板讲了一段故事:他之前是上海的武警,退役之后在杭州找了份工作,这里的农家乐是租的当地村民的房子开的。他们周末会开车来这里,因为他们夫妻俩不想一直生活在大城市里,周末和节假日就来千岛湖这里体验农村的生活,这里是他们的第二个家。听的着实有些羡慕,整得我都有点想把父母接来南方农村生活了。
吃完饭后在院子里休息了一会就继续出发,现在才骑行了 50 公里,还不到一半。于是就给自己定下了等骑行到 70 公里的时候再休息一会。中途遇到了一片雏菊花田,就在附近拍照休息了一小会。这里一瓜人都没有,独享这番美景,真不错 😌。
偶见老大哥语录 「绿水青山就是金山银山」。如果和饭搭子 UI
一起来,看到这个标语后肯定又开始键政了 😂。每次和 FE
以及 UI
我们仨出去徒步的时候,政治和历史是必聊的话题,一些无关的话题聊着聊着就会扯上政治。关于政治话题,虽然我们仨的观点都各不相同,有时候也会争论的面红耳赤,不过都能互相包容理解对方,不会像小粉红那样友谊小船说翻就翻。我觉着这也是我们几个能经常聚在一起玩的原因吧,朋友之间的友谊会像生物学进化论那样经过一层层筛选和进化,那些与你相处不来的人可能玩个一两次因为观念不和就分开了。而最终能长久陪你玩下去的那些人,都是会容忍并包容你身上的缺点以及那些不和的观念。
说到政治,之前写《写在上海封城一年之后》时感触最深的是:「或许我们已经早已经听惯了你不关心政治政治会来关心你这句废话,但当经历了这场灾难(防疫闹剧)之后,我们或多或少地能感触到一个国家的政治气候的变化是如何深刻地影响和限制人类生存的状态和生存的可能。在某些性命攸关的时刻,政治它直接或间接地决定了我们还能不能在这个世界生存下去。」
有一段下坡了,为了节省体力想多白嫖一些重力势能(大雾,就骑行得特别快,速度已经达到了 56KM/h,当时如果稍不留神就有可能当场吃席,还好作为一个骑车驾龄 20 多年的老司机比较稳没有翻车 🤣。
休息完后就继续出发,现在差不多已经骑行了一半的路程了,还有 70 公里到终点。继续加油加油,冲冲冲!
到了下午五点左右,还剩 30 公里的路程,如果顺利的话 6 点半前就可以到达终点。而这时天气又开始下雨了,虽然不是很大但看天气预报 6 点之后雨量会增大。休息了一会就继续赶路,刚骑没多久在十字路口遇到两个老哥,他俩的充电宝因为上午下雨淋坏了,然后一个人手机没电了,另一个手机还剩百分之十的电。因为如果骑行回淳安县城酒店必须要看轨迹导航的,这点电量肯定是不够的。看了下我带的充电宝,还有 30% 的电量,应该够他们用的了,也没顾虑啥就借给了他俩,希望他们能安全到达淳安县城吧。
就在最后五公里的时候,雨就突然下大了起来,不得不穿上雨衣,顶着大雨继续骑行。在最后两公里,路过千岛湖大桥的时候不幸发生了点意外。当时我记得清清楚楚我骑在了红色的非机动车道上,但到了千岛湖大桥后就没了非机动车道。我就选择骑在了桥的高台上,那是一条瓷砖路,下雨之后路就特别地滑,当时一直纠结要不要把车放到机动车道去骑,这样会更安全一点?正想着要不要停下来到机动车道上骑,前轮就突然侧滑了,连车带人直接从高台上摔了下来。摔到后的第一反应就是连滚带爬赶紧躲到高台上,以免被机动车碾压到。或许是求生本能的挣扎,那一刻肾上腺素急剧上升,反应十分迅速,没有半点拖泥带水。伴随着心脏砰砰地跳动,坐在高台上久久才缓过来,感觉就像从鬼门关走了一遭。旁边机动车道中的车辆见到我的车子摔倒在路边后也很热心地打开双闪停了下来,我挥了挥手说没事儿他就慢慢地开走了。
终于在 7 点前安全到达酒店,把车子锁在路边的栏杆上就感紧到房间洗了个热水澡,顺带叫了一份外卖。吃饭的时候之前遇到的那两个老哥也将充电宝送到酒店的前台,保安帮忙上楼送到了房间。真好,人与人之间的信任就是这样奇妙,你怎么对待别人比人就怎么对待你。吃完饭后太累了很快就睡着了,本想着今晚要在千岛湖附近找个地儿露营来着,看来是我多想了,骑行归来后这么累而且这么大的雨去露营脑袋真的被驴踢了。
早上醒来后就收拾东西准备出发,6 点之后可能还要下雨。昨晚回来后车子锁在了路边还没有收拾,今早要赶在下雨前把我那辆公路车拆下来重新塞回思域车里。收拾好车子后,让保安帮忙指挥将车开到路边,就导航出发前往王位山。
这里给大家解释一下,这里提到的跑山并不是我跑着 🏃 在山里瞎逛,而是开车在山路狂飙,属实有点鬼 👻 火 🔥 少年行为 🌚。平时晚上睡觉前会刷一些交通事故相关的电子榨菜视频,目的是想根据这些交通事故学习一下防御性驾驶,比如路口不超齐头车。每当在视频里看到一些鬼火少年骑摩托在山路飙车压弯摔进排水沟里时,弹幕评论都会嘲笑他们为又菜又爱玩儿。也会看一些教你怎么开车跑山的视频,比如 90% 老司机不会!开 BRZ 跑山悟出的十条道理!慢进快出、左脚刹车、手动档跑山的正确操作方式!别被假老司机各种错误操作忽悠了、跑山需谨慎,思域上演排水渠过弯。排水渠过弯其实也是一种嘲讽,就是出弯的时候车子推头导致失控前轮一头扎进路边排水沟里,这时你只能打电话叫救援把车拖出来了,妥妥滴又菜又爱玩儿。
不过这次跑山的体验还可以,总共有六十多个弯,其中有十几个发卡弯,十分有挑战性。尤其是在过发卡弯的时候,因为入弯后的坡度很大,不得不用一档全油门轰上去,入弯前二降一减速并配合着降档补油,转速直接拉到六千转,那种燃油车发动机嗡嗡嗡的巨响真的能够激发出你去不断挑战人车合一操控极限的斗志。
正因为现在还单身,所以有大把的时间来体验这种驾驶乐趣。寻找这种人车合一的操纵感,不断地试探出人和车的一个极限,是可以提升自己的驾驶技术的,这样也能在一些紧要关头作出正确的反应来自救。不过鬼火少年终究还是会成长的,等将来我有了老婆和孩子,我开手动挡绝对比开 CVT 自动挡还要稳。因为那时候身上已经有了责任,要安全驾驶保护好家人,不能再像现在这样瞎玩了。哈哈哈,开个玩笑,将来我也可能单身一辈子,只是徒步的时候遇到过一些家长带着孩子出来玩,有点羡慕这样的生活,所有偶尔也会想象一下将来这样的生活也未尝不可。
跑完山后在周边镇子上逛了一会就返航回家,在服务区简单吃了顿午饭在车里休息了一会,玩了两天多了确实有点累了。到家后收拾完车里的东西,将车开到空地上,自己动手洗了下车。端午假期就这样完美结束了 🎉
端午节三天大概花了 1200 左右,其中油费高速费就花了 732、住宿花了 262、吃的就花了 157。花了 1200 独自一人游玩三天,感觉这个体验还是挺值的。
洋洋洒洒写完这篇游记才发现不知不觉已经水了一万多字了 🥲,可能是我很久没有写博客太想表达自己的一些内心想法和感受了,终于找回了之前坚持写博客的那种感觉。后面有机会再写一写技术相关的博客吧,不然我要变成一个生活博主了?感谢大家看完我这么多废话,谢谢大家的陪伴。祝大家生活愉快,开开心心 😋。
2023-12-29 00:00:00
2023 值得回忆和分享的两件人生大事就是:考了 C1 驾照、买了辆二手的本田十代思域
准备考驾照的动机是过年的时候父母提起想要去山西大同那边自驾玩几天,再加上刚经历了三年大健康运动(防疫闹剧)。父母想要实现的愿望就想尽早地帮他们实现,说不定那天被再来一场闹剧被关进「集中营」,那时候再后悔就来不及了。
想要在路上合法地开车就需要有个驾照,毕竟开车是一件吃席风险比较高的事情。都需要考个驾照才能合法上路,不然无证驾驶会被处以 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 公里。初冬时节,山上也没啥好看的风景,好在路比较好走些。
2023-04-20 00:00:00
去年曾写过一篇介绍如何使用 docker in pod 的方式在 Kubernetes 集群上构建容器镜像的博客 ➡️《流水线中使用 docker in pod 方式构建容器镜像》。自己负责的项目中稳定使用了一年多没啥问题,用着还是挺香的。虽然说众多 Kubernetes PaaS 平台都逐渐抛弃了 docker 作为容器运行时,但 docker 在镜像构建领域还是占据着统治地位滴。不过最近的一些项目需要构建多 CPU 架构的容器镜像,docker in pod 的方式就不太行了。于是就调研了一下 BuildKit,折腾出来 BuildKit on Kubernetes 构建镜像的新玩法分享给大家。
默认情况下,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 只用于构建镜像而已。
2023-04-16 00:00:00
时代的一粒灰尘落在个人身上就是一座山,不幸的是:我们偏偏却活在一个尘土飞扬的年代。如果要对三年防疫做一句总结的话,我愿意称之为一场政治运动型的防疫闹剧。
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·阿列克谢耶维奇 | 切尔诺贝利的悲鸣 |
2022-05-25 00:00:00
不久前写过一篇博客《使用 Redfish 自动化安装 ESXi OS》分享了如何使用 Redfish 给物理服务器自动化安装 ESXi OS。虽然在我们内部做到了一键安装/重装 ESXi OS,但想要将这套方案应用在客户的私有云机房环境中还是有很大的难度。
首先这套工具必须运行在 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": "[email protected]", "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
2022-04-30 00:00:00
从去年十一月底到现在一直在做在 VMware ESXi 上部署 超融合集群 的产品化工具,也是在最近完成了前后端的联调,五一节后开始进入测试阶段。为了测试不同的 VMware ESXi 版本和我们产品的兼容性,需要很频繁地在一些物理服务器(如戴尔、联想、惠普、浪潮、超微等)上安装 VMware ESXi OS。
之前一直都是登录 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 系列发行版的占绝大多数。