Logo

site iconReimu | 木子

SRE,素食者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Reimu | 木子 RSS 预览

端午节游玩:徒步、骑行、跑山

2024-06-14 00:00:00

转瞬间距上一次更新博客快半年了,主要是这段时间在享福报,享完福报后周末就开车拉着同事去徒步游玩放松一下,因此实在是没有心情来写博客(其实是自己懒,找个借口自欺一下自己 😔

不过好在端午节前终于修完了所有的 ticket,QE 小伙伴也完成了第一轮测试,项目终于有了个阶段性的成果,没了这个烦恼心情自然好了很多。正好端午节完成了一次环千岛湖骑行,就想着和《2021 五一假期环太湖骑行之旅》一样,水一篇博客来记录一下游玩时的所思所想以及所见所闻吧。那么咱们就开始回顾下端午节游玩的经历吧 😇

计划篇

饭搭子

在《考驾照和买二手车》里曾提到过,自从去年买车之后就和同事入坑了户外徒步,每个月都会和同事一起自驾去江浙附近徒步游玩。因为一起出去玩儿的次数多了,和其中两位同事的关系也就密切了很多。其中一位是前端开发,另一位是 UI 设计师,为了后面叙述方便,就分别简称他俩为 FEUI 好了。在公司每到饭点,我们仨个就一起去楼下吃麻辣烫或者油泼面,再或者晚上下班后一起骑车去耀华路附近的路边摊吃炒饭。时间长了,三个人的关系就变得非同一般了,这也是我毕业后工作五多年来结交的关系最好的两个朋友。之前在公司都是做一个小透明,就像学校里的归宅部,下班后就早早溜回家一个人玩泥巴,也不太参与同事之间的社交。这种局面从去年我买车之后才开始真正改变的,不得不说有了一辆车之后,生活的确改变了很多,变得越来越现充了。

熊野古道徒步

早在过年之前,另一位同事(也是前端开发)和 UI 就计划清明节休假几天去日本田边市和歌山的熊野古道徒步。春节期间开车回老家顺路送 UIUI 在车里开始学日语五十音。我们周末出去徒步游玩的时候 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 月底的时候和饭搭子去过一次王位山徒步,感觉那里的山路真的不错,有杭州秋名山之称,十分适合跑山(练习排水沟过弯)。

于是端午节大体的游玩计划初步成型:

  • Day1:富阳杏梅尖徒步,13 公里爬升 1000 米;
  • Day2:环千岛湖 138 公里骑行;
  • Day3:余杭王位山跑山,练习排水沟过弯 🤣;

和往常出去游玩时一样,在 Notion 上制定了一个游玩时的计划表:

image-20240610203217734

准备篇

公路车

在《2021 五一假期环太湖骑行之旅》的时候骑行用的车子是 2012 款 GIANT 捷安特 ATX770-D,这辆车子从杭州搬家来上海的时候也一并带来了,一直用它骑行上下班。在去年四月份的时候想换一辆公路车了,因为 ATX770-D 车身太重骑行起来比较累,于是就在闲鱼上花了 1500 买了辆二手的捷安特 ROAM 2 跨界公路车。这辆车一个特点就是车架是山地车,轮圈可以装山地车宽胎,也可以装公路车窄胎,算是公路车和山地车的缝合怪 😂。自从把买来后它一直是我主力代步工具,每天都会骑着它上下班,这次的环千岛湖骑行就准备带着它去。

这辆 ROAM2 公路车本身没有太多问题,前段时间后轮辐条断了一根。去公司附近的捷安特专卖店问了下可以修但配件等的时间比较久,但我感觉是只换一根辐条修车的利润比较少,店员不想给修。于是就自己买来辐条进行更换,顺带更换了下前后轮的外胎和内胎。

小区停车

由于小区停车很难,对于咱这种实力(习)期的新手而言简直就是噩梦。有好几次停进来的时候很顺利,开出去的时候由于旁边的车停得不讲武德导致我出库的时候被卡住,每次都要保安指挥或者打电话让车主来挪车才能出来:

  • 我们小区的车道是一个 U 形的单行路,路的左侧可以侧位停车,然后路的右侧有一些画库位线的车位,当侧位停的车太靠外的时候就会导致右侧的车右转出来的时候没有足够转弯半径,由于这被卡住过一次。然后保安指挥了我很久仍没能将车开出来,最后保安只能骂骂咧咧地联系车主让他来挪车,我觉着应该不是我菜 😅;
  • 我们楼下也有一排车位,当车位满了的时候有些车主就直接停在路上,由此被堵住过三次,这种情况只联系车主来挪车,有的车主电话打不通,保安就会帮忙到车主家里叫他来挪车;
  • 有车车停得很随意,不是右前轮压住库位线就是车头太靠前,旁边的车位想要左转出来的时候车轮就会碰到马路牙子,再加路不到两米宽,倒库和出库的时候都很极限地贴着旁车或者轮胎碰着路沿石。因为这被卡住一次,那时候保安让车主下来挪车往后倒一点就能将车开出来了;

为了周六早上出发的时候车子不像往常一样被堵在车位里,周五早上就将车挪到了外面方便出库的位置。后来事实证明这样做是对的,周六早上出发的时候果然有辆车和往常一样停在路上,挡住了车位线里面的车。还好有先见之明,不然早上五点半多让车主来挪车,车主也是挺奔溃的。

Ticket 修完啦

周五下午 TL 告诉我项目就剩最后一个待验证的 ticket 了,我瞅了一下 Jira 面板,哦豁,还真是耶。赶紧 DM 鼓励了一下 QE 小伙伴,加油加油,冲冲冲!等收到 ticket close 的邮件后,第一时间感谢了下 QE 小伙伴。这是我入职以来作为主力后端开发的第二个项目,也是我觉着最难的项目。第一轮测试结束算是一个阶段性的胜利,心情好了很多,之前一直在崩溃的边缘挣扎,今天终于要结束了。没了烦恼端午节就能有个好心情愉快地玩耍啦,在这里再次感谢我那位 QE 小伙伴。

image-20240608200713378

收拾车子

忙完项目上的事情后看了下时间大概五点多了,就赶紧骑车溜回家,因为要把在骑的这辆公路车塞到我那辆思域车里,还不确定车里能不能放得下,为了以防万一就早点回家吧,要是真的放不下也有时间想办法来处理,不至于忙到天黑。

到家后,就将公路车的车轮和车座拆下,并用塑封膜包裹住链条和牙盘的部分,以免油泥擦碰到车上。刚开始试着将车子放进后备箱,调整了好几次都不太行。如果是辆两厢或者旅行车就好了,有点后悔当初买了三厢的思域。主要是三厢车后排放倒后空间还是很紧凑,容纳不下山地车横放的空间。于是试着把车子放在后排,没想到能完美地塞进去,就是挤占了后排的空间,不过这次就我一个人出来玩,后排不用坐人所以也没事儿。最后再用安全带固定主车架,以免禁止制动的时候车子由于惯性飞到前排来,这样吃席风险大大减少。

IMG_6177

把车子装好之后就放心了,本以为放不下的,没想到后排的空间斜放一辆公路车还是绰绰有余滴,等到以后去莫干山或崇明岛骑行游玩的时候就可以采用相同的办法了,非常不戳。

Day1 杏梅尖徒步

出发

每次要出去玩儿的时候,头天晚上就兴奋地睡不着,有点像上学的时候放寒暑假的那天晚上一直睡不着,和宿舍下铺聊天到大半夜一样。晚上睡得不是很好,凌晨四点多就不知不觉醒来了,醒来后干瞪眼睡不着。距离出发还有两个小时,干瞪眼也是浪费时间,索性就起床打开笔记本开始整理博客,思考下这篇游记的内容。一直等到 5 点半左右,就起身洗脸刷牙收拾东西,开始出发了。

收拾好东西后,就下楼准备把车从车位开出来。下楼后发现果然有个大聪明把车停在出库的路上,幸好昨天早上我把车挪走了,不然今天又要打电话让他来挪车了。因此出门前做好规划,把一些潜在的问题提前规避掉,能省不少事儿。上午先导航到绍兴,去我姐那里拿帐篷以及一些其他的露营装备。这次的导航路线还是和往常一样:S32 申嘉湖高速 -> G60 沪昆高速 -> G1522 常台高速。在浦东出沪去往宁波或绍兴方向,如果是住在 S32 申嘉湖高速以北的地方,还是挺建议这里路线的。会比走 S20 外环高速 -> G60 沪昆高速路况要好很多。因为 S20 外环高速和 G60 沪昆高速途径上海的主要城区,高速的出入口会很多,车流量也很大,节假肯定会堵车。而 S32 申嘉湖高速途径的地方基本上都是一些郊区,车流量会少一些,很少会遇到堵车的情况。不过等到今年 7 月份 S32 申嘉湖高速上海段免费之后可能会有所不同了,毕竟还是会有很多人绕一点远路来白嫖这几十公里的高速费的 😂。

IMG_6139

因为选择了路线比较通畅且出发的比较早,路上基本上没有遇到堵车的情况,一路很顺畅地来到了绍兴,从我姐那拿到帐篷后就继续出发前往富阳杏梅尖了。之所以来拿帐篷因为想着在千岛湖骑行的时候看看能不能在千岛湖找到一个合适的露营地点,晚上就不住酒店来露营。后来事实证明我太天真了,露营什么的根本就不存在。

杏梅尖徒步

9 点半左右到达富阳瑶坞村,村子里的露天停车场收费 10 块。车子停好后,收拾好行李就开始跟着两步路上的轨迹徒步。富阳杏梅尖这条经典的徒步路线像空心户外、游侠客、浦东户外、华东户外每个月都会成团来的。这里不是一个正规开发的景区,不过好在当地建设和维护的都还不错,中途还有一个补给点可以休息。虽然这条路线 13 公里爬升 1000 米看着有点难度,但爬升的坡度还是相对轻松一些,不像之前去盐帮古道回来时 1.5 公里爬升 400 米那样十分陡峭难走。只要放慢节奏,正常人还是能走完全程的。

  • 午餐,一个大大的甜瓜就填饱肚子了 😋

IMG_6151

  • 登顶杏梅尖,景色真不戳 😆

IMG_6167 2

  • 途中的蚊虫很多,坐下休息的时候一只小蜜蜂落在我胳膊上

IMG_6161

下山的时候看到一个很有意思的路标牌,上面写着 「很明显这里有条近路」,于是赶紧拍照发在我们的徒步小队微信群里。去年带饭搭子去覆卮山徒步,下山回来的时候因为我选择了抄近路把大家带到了一片深山老林里,在丛林里走了大半天差点就要叫救援,好在有惊无险最终安全下山了。从那以后我只要一提抄近路,就会引来他们的一阵嘲讽,调侃我这是大聪明行为 🤣。这次可是货真价实的近路,而 FE 回复:只有聪明的人才能看到的近路!哈哈,可能是因为那次抄近路翻车,我在他们心中的形象就变成了一个大聪明 😓。

IMG_6173

顺着这条近路下山,小路周边被树木和芦苇覆盖的严严实实,有时候需要弯腰从这些茂密的树丛钻过去,有那么一点点丛林探险的感觉 😂。半路遇到一处小池子,在里面玩了会水,洗了洗就继续下山了。

IMG_6171

到达停车场后才下午三点多,比预期回来早一个小时。当时太阳正烈,打开车门发现车内 50 多度。有点后悔没有带隔热的车衣,虽然挡风玻璃上撑开了遮阳伞,但隔热效果并不佳。50 多度简直像蒸拿房一样热,坐在这里一小会已经汗流浃背了,比中午在上山的时候还要热。于是导航看了下去千岛湖的路线,看看能不能在上高速前找个加油站休息会,顺便在阴凉的地方开空调让车内温度降下来。看地图导航发现去千岛湖的长深高速要堵车 30 分钟,原本一个半小时的车程现在要两个小时了。顺着路线找到了另一个高速入口刚好能绕过堵车的路段,附近还有一个加油站,十分合适,于是就开车前往加油站。

IMG_6175

停车费

出来村子的时候看到了有个宝马车主和村民小哥发生了冲突。因为杏梅尖这里并不是一个正规开发的景区,不是本村的车进村都要交 10 块钱的停车费。开宝马的老哥很任性,就是不想交这 10 块钱的停车费,想要从逆向车道硬闯过来,我刚好路过差一点点撞上,拿二维码收费的村民小哥也被气得骂骂咧咧地问候他全家。小哥帮我看了下车没有被剐蹭到,我就尴尬地调侃了下:你看吧,人与人之间的差距比宝马与思域的差距还要大。

虽然村民收停车费不是很正规也没有发票啥的,但这种情况我们徒步的时候也遇到的可多了,不过我们每次都会很爽快地交个停车费。10 块钱的停车费可以避免很多不必要的麻烦,总不能为了省个 10 块钱绕大半天找个车位。如果停的地方不合适的话说不定还会被贴违章罚款 200 块,再或者停路边被剐蹭了也不好弄,所以为了省事和节省时间成本我们都不会太计较这些。之前去绍兴的呼狗崖徒步的时候就停在农家里,收费 10 块;去台州仙居的时候村里的露天停车场收费 10 块;去宁波风车公路的时候在童夏家村,那里建设的比较好,村口会有停车收费的闸机,然后附近还有卫生间,周边的基础设施做的很好。尽管我们徒步游玩去的都是一些免费白嫖的路线,但和当地的村民相处还是很融洽的,从来没有发生过冲突。

所以嘛,出门在外,花点小钱可以摆平的事情还是不要发生不必要的冲突,和人吵一架一天的好心情就没了。在中国,尤其是农村,说点好话讲点人情世故,互相理解一下,相处起来就会很融洽。就像虽然我没有抽烟的习惯,但我车里仍然会放一包烟,真的遇到点麻烦事儿,给大哥大叔大爷啥的递根烟,说点好话人家还是很愿意帮忙的。之前我在小区停车被堵住的时候,我都会给保安大叔递根烟,说声谢谢麻烦啦等客套话,他都会很乐意帮忙指挥我把车开出来。因为帮的次数多了,慢慢就混熟了,在小区偶尔遇到了也会相互打招呼说两句。

淳安县城

导航到加油站加完油,休息了一段时间继续前往淳安县城。下高速进入到淳安县城后,道路就变得十分离谱。双向两车道开着开着右侧的非机动车道就变成了机动车道,然后一过红绿灯又强行变成了两车道,我满脸???。然后旁边非机动车道的车就被强行挤了过来,每过路口的时候都要留意一下,还有次险些撞上。还有一些机动车直接开在非机动车道,遇到前面有电驴的时候又加塞过来,开车体验极差。

更离谱的是酒店门口大概有十个画车位线的车位,这些画车位线的车位都不是酒店的,而是政府规划的。停车按小时收费,每小时 5 块,停一天要 60。然后酒店住客的车就停在车位线外面的一些狭窄空位上。经过酒店保安大叔的指挥总算在一个犄角旮旯里停好了车。停好车后就把山地车拿出来装上轮子,轮子装上后发现刹车盘会蹭碟刹片,一顿操作猛如虎,调试了大半天还是会蹭那么一点点,先就这样凑活着吧,又不是不能骑。

晚上没有吃饭,简单吃了点水果和粽子就算填饱肚子了。拿出笔记本整理了下今天的日记,就睡了,希望明天的环千岛湖骑行能顺利一些。

Day2 环千岛湖骑行

出发

之前看天气预报说是千岛湖周六有雨周日多云的,没想到周日早上开始下雨了。用彩云天气看了下,上午会一直下雨,中午会停一会。八点半之后雨会小一些,于是就想着等雨小一点再去骑行吧。如果因为下雨就放弃了感觉还是挺遗憾的。就这样一直等到 9 点左右,雨终于小了一些。已经九点了,再不出发晚上回来就要走夜路是十分危险的。当时内心还是有一点退缩想要放弃的想法,不过又想起了一位推友的话:

image-20240612215737254

如果放弃了,我可能会后悔一辈子,所以即便还在零星下着小雨,还是毅然决然出发了,冲冲冲!

骑行

刚开始骑行的时候就遇到了不顺,由于昨晚没有调整好轮组,刹车碟片会一直蹭外层的刹车片,相当于踩着 10% 的刹车在骑,导致骑起来特别费劲。于是不得不停下来调整轮组的角度,让它刚好卡在一个不碰刹车片的位置。当时雨还突然下大了,穿着雨衣热得满头大汗,再加上忘记带毛巾了,十分地狼狈。不过好在经过一顿操作终于调整好了。调整好车子后就继续骑行,刚开始的一段距离特别难走,因为下雨怕身体被雨淋失温就不得不披着雨衣,雨衣又不透气,出汗之后汗水顺着胳膊流到袖口,手一甩就一大把汗水。,就像一只落汤鸡,好狼狈。

  • 在桥上遇到了几个驴友,看来雨中骑行的铁憨憨不止我一个 🤡,心态瞬间好了很多。

IMG_6189

从九点一直骑行到 11 点半,中途休息了十几分钟。出发时只带了一瓶水,刚开始骑行的了大概 20 多公里仍然没有找到一家超市可以买水和。路上刚好碰见一个卖杨梅的老奶奶,就从她那里买了一小筐杨梅。顺便问了下老奶奶附近有没有吃饭的地方,她说还比较远,骑车可能要 20 分钟。老奶奶看着我可怜兮兮的样子,就从饭盒里拿出一个自家做的包子要送给我,让我填一下肚子。当时感觉好尴尬 😅,我就厚着脸皮有点不好意思地接受了。豆腐馅的包子是我喜欢吃的,真好。吃着吃着鼻子一酸,莫名其妙想起了已故的奶奶,小时候经常和弟弟偷偷摸摸向奶奶要零花钱,回想起那一幕着真的好怀念。想起《寻梦环游记》中的一段台词:死亡不是生命的终点,遗忘才是

吃完包子和杨梅后就继续向前出发,争取十二点前找到一家农家乐吃午饭补充下体力。终于上午骑行了 50 公里后到了一家农家乐,在里面点了一份蛋炒饭和一瓶柚子汁。

IMG_6192

吃饭的时候听农家乐老板讲了一段故事:他之前是上海的武警,退役之后在杭州找了份工作,这里的农家乐是租的当地村民的房子开的。他们周末会开车来这里,因为他们夫妻俩不想一直生活在大城市里,周末和节假日就来千岛湖这里体验农村的生活,这里是他们的第二个家。听的着实有些羡慕,整得我都有点想把父母接来南方农村生活了。

吃完饭后在院子里休息了一会就继续出发,现在才骑行了 50 公里,还不到一半。于是就给自己定下了等骑行到 70 公里的时候再休息一会。中途遇到了一片雏菊花田,就在附近拍照休息了一小会。这里一瓜人都没有,独享这番美景,真不错 😌。

IMG_6194

偶见老大哥语录 「绿水青山就是金山银山」。如果和饭搭子 UI 一起来,看到这个标语后肯定又开始键政了 😂。每次和 FE 以及 UI 我们仨出去徒步的时候,政治和历史是必聊的话题,一些无关的话题聊着聊着就会扯上政治。关于政治话题,虽然我们仨的观点都各不相同,有时候也会争论的面红耳赤,不过都能互相包容理解对方,不会像小粉红那样友谊小船说翻就翻。我觉着这也是我们几个能经常聚在一起玩的原因吧,朋友之间的友谊会像生物学进化论那样经过一层层筛选和进化,那些与你相处不来的人可能玩个一两次因为观念不和就分开了。而最终能长久陪你玩下去的那些人,都是会容忍并包容你身上的缺点以及那些不和的观念。

说到政治,之前写《写在上海封城一年之后》时感触最深的是:「或许我们已经早已经听惯了你不关心政治政治会来关心你这句废话,但当经历了这场灾难(防疫闹剧)之后,我们或多或少地能感触到一个国家的政治气候的变化是如何深刻地影响和限制人类生存的状态和生存的可能。在某些性命攸关的时刻,政治它直接或间接地决定了我们还能不能在这个世界生存下去。」

IMG_6197

有一段下坡了,为了节省体力想多白嫖一些重力势能(大雾,就骑行得特别快,速度已经达到了 56KM/h,当时如果稍不留神就有可能当场吃席,还好作为一个骑车驾龄 20 多年的老司机比较稳没有翻车 🤣。

IMG_6201

休息完后就继续出发,现在差不多已经骑行了一半的路程了,还有 70 公里到终点。继续加油加油,冲冲冲!

IMG_6203

到了下午五点左右,还剩 30 公里的路程,如果顺利的话 6 点半前就可以到达终点。而这时天气又开始下雨了,虽然不是很大但看天气预报 6 点之后雨量会增大。休息了一会就继续赶路,刚骑没多久在十字路口遇到两个老哥,他俩的充电宝因为上午下雨淋坏了,然后一个人手机没电了,另一个手机还剩百分之十的电。因为如果骑行回淳安县城酒店必须要看轨迹导航的,这点电量肯定是不够的。看了下我带的充电宝,还有 30% 的电量,应该够他们用的了,也没顾虑啥就借给了他俩,希望他们能安全到达淳安县城吧。

摔车

就在最后五公里的时候,雨就突然下大了起来,不得不穿上雨衣,顶着大雨继续骑行。在最后两公里,路过千岛湖大桥的时候不幸发生了点意外。当时我记得清清楚楚我骑在了红色的非机动车道上,但到了千岛湖大桥后就没了非机动车道。我就选择骑在了桥的高台上,那是一条瓷砖路,下雨之后路就特别地滑,当时一直纠结要不要把车放到机动车道去骑,这样会更安全一点?正想着要不要停下来到机动车道上骑,前轮就突然侧滑了,连车带人直接从高台上摔了下来。摔到后的第一反应就是连滚带爬赶紧躲到高台上,以免被机动车碾压到。或许是求生本能的挣扎,那一刻肾上腺素急剧上升,反应十分迅速,没有半点拖泥带水。伴随着心脏砰砰地跳动,坐在高台上久久才缓过来,感觉就像从鬼门关走了一遭。旁边机动车道中的车辆见到我的车子摔倒在路边后也很热心地打开双闪停了下来,我挥了挥手说没事儿他就慢慢地开走了。

IMG_6209

归来

终于在 7 点前安全到达酒店,把车子锁在路边的栏杆上就感紧到房间洗了个热水澡,顺带叫了一份外卖。吃饭的时候之前遇到的那两个老哥也将充电宝送到酒店的前台,保安帮忙上楼送到了房间。真好,人与人之间的信任就是这样奇妙,你怎么对待别人比人就怎么对待你。吃完饭后太累了很快就睡着了,本想着今晚要在千岛湖附近找个地儿露营来着,看来是我多想了,骑行归来后这么累而且这么大的雨去露营脑袋真的被驴踢了。

IMG_6879

Day3 王位山跑山

出发

早上醒来后就收拾东西准备出发,6 点之后可能还要下雨。昨晚回来后车子锁在了路边还没有收拾,今早要赶在下雨前把我那辆公路车拆下来重新塞回思域车里。收拾好车子后,让保安帮忙指挥将车开到路边,就导航出发前往王位山。

跑山

这里给大家解释一下,这里提到的跑山并不是我跑着 🏃 在山里瞎逛,而是开车在山路狂飙,属实有点鬼 👻 火 🔥 少年行为 🌚。平时晚上睡觉前会刷一些交通事故相关的电子榨菜视频,目的是想根据这些交通事故学习一下防御性驾驶,比如路口不超齐头车。每当在视频里看到一些鬼火少年骑摩托在山路飙车压弯摔进排水沟里时,弹幕评论都会嘲笑他们为又菜又爱玩儿。也会看一些教你怎么开车跑山的视频,比如 90% 老司机不会!开 BRZ 跑山悟出的十条道理!慢进快出、左脚刹车手动档跑山的正确操作方式!别被假老司机各种错误操作忽悠了跑山需谨慎,思域上演排水渠过弯。排水渠过弯其实也是一种嘲讽,就是出弯的时候车子推头导致失控前轮一头扎进路边排水沟里,这时你只能打电话叫救援把车拖出来了,妥妥滴又菜又爱玩儿

不过这次跑山的体验还可以,总共有六十多个弯,其中有十几个发卡弯,十分有挑战性。尤其是在过发卡弯的时候,因为入弯后的坡度很大,不得不用一档全油门轰上去,入弯前二降一减速并配合着降档补油,转速直接拉到六千转,那种燃油车发动机嗡嗡嗡的巨响真的能够激发出你去不断挑战人车合一操控极限的斗志。

IMG_6227

正因为现在还单身,所以有大把的时间来体验这种驾驶乐趣。寻找这种人车合一的操纵感,不断地试探出人和车的一个极限,是可以提升自己的驾驶技术的,这样也能在一些紧要关头作出正确的反应来自救。不过鬼火少年终究还是会成长的,等将来我有了老婆和孩子,我开手动挡绝对比开 CVT 自动挡还要稳。因为那时候身上已经有了责任,要安全驾驶保护好家人,不能再像现在这样瞎玩了。哈哈哈,开个玩笑,将来我也可能单身一辈子,只是徒步的时候遇到过一些家长带着孩子出来玩,有点羡慕这样的生活,所有偶尔也会想象一下将来这样的生活也未尝不可。

回家

跑完山后在周边镇子上逛了一会就返航回家,在服务区简单吃了顿午饭在车里休息了一会,玩了两天多了确实有点累了。到家后收拾完车里的东西,将车开到空地上,自己动手洗了下车。端午假期就这样完美结束了 🎉

总结篇

总结

  • 雨天骑行很危险,一定好多加小心带好保命头盔,自从回来后我每天上下班路上骑车都会带上头盔,虽然长这么大骑车没摔过;
  • 整体的计划完成度 90%,因为下雨的关系导致周日露营取消了。其实露营也不太现实,因为骑行回来后身心疲惫只想洗个热水澡躺床上睡大觉,去露营睡帐篷不妥妥找罪受吗;
  • 行程去的地方都是冷门小众免费的路线,不存在堵车,也没有人山人海排队现场,游玩体验极佳;

费用

端午节三天大概花了 1200 左右,其中油费高速费就花了 732、住宿花了 262、吃的就花了 157。花了 1200 独自一人游玩三天,感觉这个体验还是挺值的。

image-20240612224210576

尾声

洋洋洒洒写完这篇游记才发现不知不觉已经水了一万多字了 🥲,可能是我很久没有写博客太想表达自己的一些内心想法和感受了,终于找回了之前坚持写博客的那种感觉。后面有机会再写一写技术相关的博客吧,不然我要变成一个生活博主了?感谢大家看完我这么多废话,谢谢大家的陪伴。祝大家生活愉快,开开心心 😋。

考驾照和买二手车

2023-12-29 00:00:00

2023 值得回忆和分享的两件人生大事就是:考了 C1 驾照、买了辆二手的本田十代思域

考驾照

动机

准备考驾照的动机是过年的时候父母提起想要去山西大同那边自驾玩几天,再加上刚经历了三年大健康运动(防疫闹剧)。父母想要实现的愿望就想尽早地帮他们实现,说不定那天被再来一场闹剧被关进「集中营」,那时候再后悔就来不及了。

想要在路上合法地开车就需要有个驾照,毕竟开车是一件吃席风险比较高的事情。都需要考个驾照才能合法上路,不然无证驾驶会被处以 200 以上 2000 元以下罚款,并处 15 天以下拘留。那我们就考个驾照吧。

我觉着考驾照的最佳时机是高考完的那个暑假或者大学时,因为刚满十八周岁到了考驾照的年龄、时间也很充裕,而且那时候手脚灵活、脑袋还比较好使。可惜那段时间都在瞎玩,没有去考驾照。觉着自己又买不起车又不怎么开车,对当时的自己来讲考驾照没啥用。事实证明,驾照考的越早越好,当你会开车以及有了车之后,你的生活出行距离范围会扩大很多,也会方便很多。像我这样,工作之后再去考驾照,时间成本可谓是真高,为了练车和考试我还用掉了五天的宝贵年假 😭。

驾校报名

过年回来的第一周就开始联系在上海的朋友找个靠谱的驾校,朋友之前是在特斯拉卖车的,所以认识了一个驾校教练,刚好他的老婆也跟着这个教练练车。于是经介绍加了教练的微信,确认了在上海考驾照的一些基本信息:

  • 外地户口考驾照不需要居住证;
  • 在宝山练科目二、嘉定练科目三、金山考科目二和科目三;
  • 每周练车一到两次,每次两个小时左右(因为我住在浦东,离练车地方比较远,特意让教练多练会);
  • 科目一考完十天后就可以预约科目二和科目三考试;
  • 每个科目挂科后需要等 10 天才能预约下一场考试;
  • 从考完科一到拿驾照顺利的话最快一个月;

关于费用:

  • 体检以及以及证件照 120;
  • 驾校这边共收取 4600 (报名、培训、场地练车费用);
  • 科一到科四的考试费每次 40 需要自己在 12123 上缴纳;
  • 科二考试场地模拟费,200 一圈、500 三圈、900 不限圈数;
  • 科目三场地模拟费,500 三个线路各一圈、850 两圈;

我当时考的时候没有学时限制,现在上海这边考驾照要求学时达到后才能预约相应科目的考试,所以驾照是考的越早越省事,后面肯定会越来越严的。听教练说明年练车的时候就要人脸识别了,越来越难,所以如果你在上海还想考驾照,就马上行动起来别再拖啦。

另外在上海考驾照,通过驾校无疑是最方便省事儿的,虽然可以自学直考,但是有场地限制以及需要车辆安装副驾的刹车踏板,对没有车的人来说是比较麻烦的,而且成本不见得比驾校要低。对于其他关于驾考的信息,推荐看一下 上海学车考驾照经历分享,科二科三一把过,两个月内快速拿到 C2 驾照。 这个视频。

面签

面签就是去驾校办理一个类似于学籍的东西,把身份证、照片、驾照类型、住址等信息录入到上海交警驾照考试系统里。面签完成后,需要自己在 12123 上注册账户,等到车管所那边录入信息后才能在 12123 上预约科目一考试。如果 12123 上提示没有找到学员信息,很可能是车管所那边在建档的时候出现问题,比如存在异地档案、身份信息错误、有其他违章(比如骑电车不带头盔或闯红灯被抓到)。这时一定要及时联系驾校那边问下什么情况,及时处理,不要一直干等着。我当时就是因为存在异地档案没有注销被拖了两周🥹。

科目一

科目一比较简单,花了 90 块钱氪金充值了驾校一点通的会员,每天有空的时候就刷题。等到 12123 上能预约科目一考试的时候,就约了最近的一场考试。考科目一的时候提前半个小时到了考场,排队的人已经七八十人了。到考场后是不按预约场次排序的,只要预约了当天的场次就能随到随考。科一共 100 道题,大部分题都刷过,所以只要掌握一点技巧多刷题多背就能稳过。

科目二

驾校这边一般是科目一过了之后才开始科目二练车,没有考过科目一教练是不给安排科目二练车的。所以科目一一定不要挂,挂了之后需要等两周才能重考,在这期间只能干等着,十分浪费时间。

上海驾考科目二主要考下面这几项:

  • 倒车入库(难度五星)
  • 侧方位停车(难度三星)
  • 曲线行驶(难度三星)
  • 窄路掉头(难度一星)
  • 直角转弯(难度三星)
  • 半坡起步(难度四星)
  • 紧急停车(难度一星)

科目二我练习了大概 10 次就预约去考试了,当时感觉状态良好绝对能一把过的。没想到很快打脸了,考第一把的时候左倒库压线挂掉了,第二把在半坡起步的时候溜车挂掉了 😭。后来听教练说我开的那辆考试车离合器有问题,半坡起步的时候,离合器送到半联动时如果直接送开刹车大概率会溜车,只要上了这辆车十有八九的会挂掉,很多考生都挂在我开的那辆车上了。无奈只能等待十天继续预约下一次考试,而每次只能预约四天后的考试,所以当挂科后需要等待两周才能重新考试。

考第二次的时候十分紧张,不幸两把都在倒库的时候挂掉了;接着等待两周再考第三次,第三次的时候又因为倒库挂掉了。考了三次都挂掉了,当时就陷入了自我怀疑中,自己适不适合开车呀。听同事调侃说他考科三挂了四次,五十步笑百步,心理有了些许安慰。挂了三次,还剩两次机会的嘛,即便如此还是要硬着头皮继续考,不能就因此放弃了。

第四次考试的时候,换了一个考场,模拟的时候状态并不是很好,教练也觉着我过不了,但没想到最终一把满分过了 🎉。个人感觉考科二最主要的是心态,只要不紧张,放松一点慢慢开就能稳过 😂。我第一次考试也不怎么紧张,状态也是最好的,无奈运气不好碰上了个有问题的车就挂掉了。

科目二考试最麻烦的就是考场在金山,需要头天晚上过去住在考场附近的酒店,或者凌晨四点多跟着教练的车去考场。为了多睡会我选择头天晚上去住在考场附近的酒店,早上六点起床到考场进行模拟。由于只请了半天的假,考完后就得赶紧回公司继续搬砖。而每次挂科后,回到公司的心情极差,尤其是同事问起考过没有,心里贼难受 😣。

科目三

科目二考过后就开始练习科目三,科目三练车时间并不是很长,大概就练了六次,每次一个半小时。考试的时候也是头天晚上去,早上早起去考场进行模拟。科三考试会在三个路线里面随机挑选一个,所以模拟的时候三个线路都会模拟一圈。科三考试不同于科二,科二考试副驾驶是没有监考员的,而科三有,并且监考员就是我们的教练。有个熟人在旁边心态会好很多,所以不会像科二那样紧张,很顺利地一把满分过了。

当时考试是我们四个人学员一块考,一辆车四个人轮流进行模拟,等到都模拟完已经十二点了,在车上呆了将近六个小时,身心疲惫的。等到下午轮到我们考试的时候也已经一点多了,考完之后就感觉像是做梦一样这么顺利地一把过了,比科目二考试顺利多了。

科目四

科目四和科目一差不多,只要刷点题就能轻松过。考试的时候带上在驾校面签时留下的那张证件照,科四考过之后,就会当场办驾驶证,然后进行宣誓就能拿到驾照啦。

买车

动机

考完驾照后还没有买车的打算,直到有一天和我姐商量好国庆带父母去山西自驾游玩后,才想赶紧买辆车练练手,方便在自驾游的时候代替老爸开车,让老爸休息会。当时想买车的动机主要是想练练手,于是想着能不能租一辆车来开练手呢?事实证明租车练手不太可行,因为绝大多数租车平台上要求驾龄至少三个月,而我刚拿驾照不到一个月 😂。于是就下定决心买辆车吧,拥有一台属于自己的车。

选车

现在回想起来,大概率是受好友 Nova Kwok 的思域 FK7 改车笔记 的影响,当时买车的时候只认准了本田的十代思域(人称鬼 👻 火 🔥 思域),其他车型一概没有考虑,而且还是手动挡的思域。十代思域在同级别同价位的车型中,性能和操控性无疑是最有性价比的选择。至于隔音差、异响多、碰撞测试 B 柱断裂、发动机积炭、织布座椅、内饰用料一般等缺点,无疑是印证了本田就是买发动机送车的刻板印象 😂。

为什买手动挡而不是自动挡:

  • 手动挡便宜呗,而且本田的自动挡 CVT 变速箱有点脆弱,经不起暴力驾驶;
  • 驾驶乐趣?体验人车合一的感觉 🤔(大雾
  • 手动挡的车更省油,事实上确实如此,我开了大概 5000 多公里,百公里油耗一直维持在 5.7L 左右;

为什买二手车而不是新车:

  • 新车比较贵,当时新车价格差不多 13 万左右,加上保险以及购置税,落地差不多 14 万,而二手车没有购置税保险也便宜,落地只需要 8 万多,相差 5 万多,所以二手车更划算些;
  • 刚拿驾照不久,新手上路,新车不小心剐蹭或出事故后很心疼的,二手车用来实习期新手练车,小磕小碰也不心疼;

闲鱼找车

二手车购买的方式无疑就两种:车商和个人,其逻辑和租房十分相似。车商就是个中介,买家和卖家的钱都赚。对于咱这种日常生活都要靠抠和薅来养家糊口的穷哥们,能省则省,所以买二手车当然不能从车商那里买啦,从个人车主那里买无疑是最具性价比的。当时在闲鱼上大概找了下面这几辆车:

车型 上牌年份 公里数 价格 城市 备注
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 上提前预约,没有预约的话是无法进入到车管所进行验车的,而那天的预约名额已经没有了,本以为要等到下周一才能过户了。还好车主帮忙找了个黄牛帮忙办理的过户,不用提前预约,就能把车开进车管所验车。用了不到一个小时就办完了所有手续,还是黄牛帮忙办比较省心。

拿到新的行驶证,这辆车就属于咱的啦。终于有了一辆自己的车,虽然是辆二手车但感觉还是很开心 🥳。把车主送到高铁站,道别之后就自己一个人从嘉兴开车回上海。因为还在实习期不能单独上高速,再加上自从考完科三后一个多月没再看过车,自己一个人也不敢上高速,就走国道省道慢慢地开回了上海。当天的感觉就是没想到买辆二手车这么顺利,没出啥意外。头天晚上聊好价钱,第二天上午看车并顺利过户,下午顺利开回上海,期间没出啥幺蛾子。

二手车避坑

根据个人买车的经历,分享几条二手车避坑的建议,如果你有买二手车的打算,可以参考一下:

  • 尽量买个人车主,大多数车商不靠谱,而且性价比较低,如果非要选择车商的话一定要选择一个靠谱的车商;
    • 闲鱼上有很多虚假的二手车车源,很多帖子实际上就是引流骗你过去看车,和租房一个套路;
  • 去看车前,可以先在闲鱼上找人查一下 4S 店的维保记录以及保险的出险记录:
    • 根据 4S 店维保记录判断公里数是否有猫腻,4S 店保养时一般都会记录公里数的;
    • 根据保养记录判断车主是否爱惜车子,是否及时保养;
    • 根据维修记录判断车子是否维修过发动机、变速箱等;
    • 根据出险记录判断事故大致类型,是否是事故车、火烧车、泡水车;
  • 一定要再找个懂车或专门验车的师傅帮忙一起看车:
    • 通过读取 ECU 发送机运行时间、变速箱运行里程、上次 ABS 触发里程等信息来判断是否为调表车;
    • 把车开到修理厂举升起来,看底盘以及车架是否正常、油底壳是否漏油、发送机工况等;
    • 打开发动机机舱看防撞梁、车架、副车架、水箱、中冷、机脚等部件是否有钣金维修的痕迹;
    • 用漆膜仪测量车上数否有钣金维修的痕迹判断车子是否有过事故;
    • 后备箱备胎坑、车门、A/B/C 柱是否有钣金维修的痕迹;
    • 查看车椅子螺丝、安全带螺丝、安全带、安全气囊等是否有拆卸的痕迹;
  • 办理过户如果想图个省事儿的话,建议找个靠谱的黄牛帮忙办理,这样可以帮你节省很多时间和繁琐的手续;

改装

车子买来后,就开始给自己的爱车进行合法改装,升级一下装备。从 《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 公里。初冬时节,山上也没啥好看的风景,好在路比较好走些。

使用 BuildKit on Kubernetes 构建多架构容器镜像

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 构建镜像的新玩法分享给大家。

qemu VS native

默认情况下,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

BuildKit 是一个将 source code 通过自定义的构建语法转换为 build artifacts 的开源构建工具,被称为下一代镜像构建工具。同时它也是 docker 的一部分,负责容器镜像的构建。我们平时使用 docker build 命令时就是它负责后端容器镜像的构建。BuildKit 它支持四种不同的驱动来执行镜像的构建:

  • docker:使用内嵌在 Docker 守护程序中的 BuildKit 库。默认情况下 docker build 就是这种方式;
  • docker-container:创建一个专门的 BuildKit 容器,将 BuildKit 运行在容器中,有点类似于 docker in docker;
  • kubernetes:在 kubernetes 集群中创建 BuildKit pod,类似于我之前提到的 docker in pod 的方式;
  • remote:通过 TCP 或 SSH 等方式连接一个远端的 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 集群有点奇葩需求不多的缘故吧。在此,咱推荐使用 k3skubekey 来部署异构 Kubernetes 集群。

BuildKit on 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 createKubernetes 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 --from=builder /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 --from=dep /root/rpmbuild/RPMS/ /COPY --from=builder /root/rpmbuild/RPMS/ /

其中 RUN rpmbuild --define "dist .oe1" -ba open-vm-tools.spec --quiet 这个步骤是构建和编译 RPM 里的二进制文件因此十分耗费 CPU 资源,也是整个镜像构建最耗时的一部分。

# copy rpms to localFROM scratchCOPY --from=dep /root/rpmbuild/RPMS/ /COPY --from=builder /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 流水线

首先,我们需要定制自己的 Jenkins slave pod 的基础镜像,将 docker 和 buildx 这两个二进制工具添加进来。需要注意的是,这里的 docker 命令行只是作为客户端使用,因此我们可以直接从 docker 的官方镜像中提取此二进制文件。不同的项目需要不同的工具集,可以参考我的 Dockerfile

FROM python:3.10-slimARG BUILDER_NAME=kubeCOPY --from=docker.io/library/docker:20.10.12-dind-rootless /usr/local/bin/docker /usr/local/bin/dockerCOPY --from=docker.io/docker/buildx-bin:v0.10 /buildx /usr/libexec/docker/cli-plugins/docker-buildx

这里还有一个冷门的 Dockerfile 的小技巧:通过 COPY --from= 的方式来下载一些二进制工具。基本上我写的 Dockerfile 都会用它,可谓是屡试不爽 身经百战了😎。别再用 wget/curl 这种方式傻乎乎地安装这些二进制工具啦,一句 COPY --from= 不知道高到哪里去了。

接下来,我们需要自定义 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)性十足。

即便是去年六月初上海解封后,依旧没有从那种恐惧中解脱出来,反而变得越来越自闭,直到现在此刻的心态和上海封城那段时间没有太大区别。思想审查、文字狱、集中营、白色恐怖、谎言欺骗,这些并没有因为疫情结束而消失,每天的感触就像是在历史与现实的夹缝中苟活,对未来充满着无限的恐惧。

或许我们已经早已经听惯了你不关心政治政治会来关心你这句废话,但当经历了这场灾难(防疫闹剧)之后,我们或多或少地能感触到一个国家的政治气候的变化是如何深刻地影响和限制人类生存的状态和生存的可能。在某些性命攸关的时刻,政治它直接或间接地决定了我们还能不能在这个世界生存下去。

从去年上海封城到现在,在这一年多的时间里,博客已经很少再更新了,也慢慢地淡出了推特。除了正常的工作生活外,大部分时间和精力都是在研究和思考这个国家和社会为什么会上演着一场场荒诞至极的防疫闹剧。结合这三年防疫闹剧期间所发生、暴露出的一切,以及自己的生活感受,我越来越有一种想探究这场防疫闹剧的政治基础是如何一步步建立起来的想法。于是近期就整理了一些最近一年多的时间里所读的书以及一些个人的想法。

读书笔记

孔飞力《叫魂:1768 年中國妖術大恐慌》

第一次读这本书是在 2021 年五一假期期间,那时我正在 2021 五一假期环太湖骑行之旅中。2022 年上海封城后,我又重新读了一遍。再次读这本书的动机,已经想不太清了。我记得当时看到了一些令人匪夷所思的防疫闹剧,比如某个小区的防疫工作人员把居民团购买的菜扔进垃圾桶里,这种防疫闹剧令我十分费解。我不能理解大白和红袖章这些群体以防疫为名拥有了一点权力后就会上演着一场场令人匪夷所思的防疫闹剧。

还有就是当时在推特上看到了一个以防疫为名殴打村民的短视频,让我再一次想起了我的另一段切身经历:在 2020 年武汉肺炎刚爆发的时候,我外婆在大年初六那天不幸离开了这个世界。依稀记得那天去我外婆家的路也被封得严严实实,没办法开车去,只能骑电动车。在村与村交界的路上被当地的红袖章拦住不让过,给他说明了原因也死活不让过。真是欺人太甚,气得我直接踹开挡板骑车电车硬闯了过去。随后红袖章拨打了当地派出所的电话,两辆警车十几号人来抓捕我。当时一直就想不清为什么一个普通的村民戴上红袖章之后就如此膨胀,直到读了这本书后我慢慢地想通了。

这本书向人们展示了中国社会普遍存在的一个现象:社会上到处表现出冤冤相报的敌意。中国的专制制度从公元前 221 年秦始皇统一中国开始,沿袭了两千多年,有着丰厚的历史积淀。即便是在今天,权力仍垄断在专制统治阶层手中,让普通民众享有权力十分困难,而当以叫魂或防疫为名的“幻觉权力”进入社会之后,普通民众就拥有了互相报复的武器。他们所能爆发出来的威力正如书中所描写的那样恐怖。

一旦官府认真发起对妖术的清剿,普通人就有了很好的机会来清算宿怨或谋取私利。这是扔在大街上的上了膛的武器,每个人——无论恶棍或良善——都可以取而用之。在这个权力对普通民众来说向来稀缺的社会里,以“叫魂”罪名来恶意中伤他人成了普通人的一种突然可得的权力。对任何受到横暴的族人或贪婪的债主逼迫的人来说,这一权力为他们提供了某种解脱;对害怕受到迫害的人,它提供了一块盾牌;对想得到好处的人,它提供了奖赏;对妒嫉者,它是一种补偿;对恶棍,它是一种力量;对虐待狂,它则是一种乐趣。

施行妖术和提出妖术指控所折射反映出来的是人们的无权无势状态。对一些无权无势的普通民众来说,弘历的清剿给他们带来了慷慨的机会。即使在今天,让普通民众享有权力仍是一个还未实现的许诺。毫不奇怪,冤冤相报(这是“受困扰社会”中最为普遍的社会进攻方式)仍然是中国社会生活的一个显著特点。

没有人会哀悼旧中国的官僚制度。即使按照当时的标准,它所造成的社会伤害也已超出了仅仅压碎几个无依无助的游民踝骨的程度。但不论是好事还是坏事,它的特性却可以阻挡任何一种狂热。没有这样一个应急的锚碇,中国就会在风暴中急剧偏航。在缺乏一种可行的替代制度的情况下,统治者就可以利用操纵民众的恐惧,将之转变为可怕的力量。生活于我们时代的那些异见人士和因社会背景或怪异信仰而易受指控的替罪羊,便会成为这种力量的攻击目标。

当代中国的历史中充满了这种幻觉权力进入社会的例子。我还记得 1982 年在北京与一个老红卫兵的谈话。他当时是一个低收入的服务工。他感慨地说,毛泽东的文化革命对于像他这样没有正式资格循常规途径在社会上进身的人来说是一个黄金时代,毛号召年轻人起来革命造反,这一来自顶端的突然可得的权力使他的野心得到了满足。他抱怨说,现在的社会样样都要通过考试,他再也没有希望从现在这个最底层的位置爬上去了。

高华《历史笔记》

《历史笔记》是我在 2022 年读的第一本书,从元旦开始阅读,花费了相当长的时间和精力才读完。这本书是高华教授的遗作,共分为上下两卷四编,繁体中文。

第一编《⾰命、内战与⺠族主义》分论国⺠党共产党两党 1949 年前各⾃的历史。作为国共内战胜利方的中共是本章的论述重点,所选文章不仅反映其革命夺权历程,还映射出 1949 年后政治实践的某些雏形。

民族主義與民主主義是一對雙胞胎,區別在於:民族主義,強調集體認同和國家認同;民主主義,強調個人本位,個人權利,個人自由。從理論上講,當國家、民族面臨嚴重的危機時,國民應讓渡出自己的一部分個人權利,以服從於國家利益,支持國家戰勝危機,而國家的最終目的是保護個人自由。但是在近代以來,民族主義經常吞噬民主主義,這主要是由中國近代的政治和大的環境造成的。也和人們認識的誤區,統治階級的狹隘和自私有關。

第⼆编《断裂与延续》主要论及⽑泽东时代,内容涵盖了三反五反、大跃进运动、四清运动、林彪事件等多个历史事件。其中十分推荐大家去仔细地去读一下《大躍進運動與國家權力的擴張》这个章节,从某种程度上我觉着这场防疫闹剧和大跃进运动背后的政治逻辑极其相似。

1958 年由毛澤東親自發動、席捲全國的大躍進運動,是一場具有空想烏托邦性質的政治運動。今天人們憶及當年的大躍進,馬上會聯想到「高產衛星」、「全民煉鋼」、「公社食堂」等帶有荒誕色彩的景象。然而大躍進並非僅僅是一場烏托邦運動,在大躍進期間,國家權力借着這場運動的推動,以前所未有的規模急速地向社會各個領域擴張。大躍進運動使國家權威得以擴大和強化,不僅深刻地改變了中國社會的面貌,也大大加強了民眾對國家權威的認知。

在大躍進期間,國家意志透過強有力的政治動員和組織措施得以全力貫徹,國家權力在這個過程中急速擴張。

與以往歷次政治運動相比,大躍進是一場規模更大的群眾性運動,這場運動不僅促使國家權威向城鄉全面滲透,而且在社會生活所有領域都建立、鞏固和強化了國家權力。

令人驚奇的是,即使到了這一步,一些領導幹部仍在繼續隱瞞饑荒的真相。周恩來以後回憶道,在 I960 年夏天召開的北戴河會議上,他本人「已經意識到糧食有問題,但大家不承認,結果把真實情況給掩蓋起來了」。

直至 1960 年 10 月,《人民日報》在國慶社論中才對形勢作出了新的解釋。社論稱,「兩年來,全國大部分地區連續遭受嚴重的自然災害,造成糧食嚴重減產」。社論並宣稱,「人民公社已使我國農民永遠擺脱了那種每遭自然災害必然有成百萬、成千萬人饑餓、逃荒和死亡的歷史命運」。社論作者當然知道,就在這篇社論發表之時,全國各地農村正在發生大面積餓死人的情況,但事實歸事實,宣傳歸宣傳,他們選擇採取了「硬着頭皮頂住」的方針。

还有《1949-1965 年中國社會的政治分層》章节里提到的向党交心运动十分有有意思,大跃进时期都是要把心交给党的,比现在安个反诈 app 把隐私交给党高到不知道哪里去了 😂。

全體教師聯合舉行改造促進大會,他們抬着「大紅心」的標誌上街遊行。4 月 4 日,南京市各高校師生與科研機關的民主人士共三千餘人,高舉「把心交給黨」、「把知識交給人民」的旗幟在南京市舉行大遊行,之後,又舉行了社會主義自我改造促進大會。4 月 21 日,南京市工商界三千多人召開大會,宣佈「立即開展向黨交心運動」,民建中央主席黃炎培親臨會場予以鼓勵。4 月 22 日,南京市工商界和民主黨派提出向黨「交心」要「快、透、深、真」的口號,表示要把「接受黨的領導和走社會主義道路的三心二意,躍進到一心一意」。江蘇省宗教界人士也開展了「交心」運動,天主教界通過「自選」、「自聖」主教,「使全省天主教出現了一個新的局面」。在「交心」運動中,全省 11 個城市民主黨派和工商界人士 4,106 人,共交心 47 萬條。據當時的記載稱,這次交心「大量暴露了他們長期隱瞞的腐朽思想和反動行為」12()。對於工商界和民主人士的「交心」,組織上規定的原則是「自梳自理,求醫會診」。先讓他們對照要求、自我批判,然後引導他們懇請黨員和領導對他們的「壞思想」有針對性地進行批評,並鼓勵他們打破庸俗的情面觀,「比先進,比幹勁」,互相展開批評和思想鬥爭,以使「交心」落在實處,防止「交心」走過場。

經過對「二十二個文件」的逐字逐句的精讀,和反復對照檢查,個人原來的小資產階級的自我意識開始分裂。隨着「發掘本心」的逐步深入,學習者普遍對自己的缺點錯誤產生了羞愧意識,出身剝削階級家庭的知識分子黨員更自慚形穢,認為自己確實如毛澤東所言,除了讀了一些如同「狗屎」般無用的書之外,對共產黨和人民的價值無多,尤其嚴重的是,剝削階級的家庭背景,甚至還會使自己在革命的關鍵時刻動搖革命立場,在客觀上危害革命!這樣的自我壓力有如大山般沉重,使許多知識分子黨員原有的沾沾自喜、驕傲自滿等不良習氣一掃而空。

按照毛澤東的看法,一個人的階級立場必然決定了他的觀點和態度。例如:你是不是在心裏還欣賞資產階級個性自由、個性解放的錯誤思想?你是否心悦誠服地把一切都獻給黨?你是否真正同意你所出身的剝削階級家庭是骯髒和反動的?你對沒有文化的工農群眾是滿心鄙夷,還是甘心做他們的小學生?你對黨的考驗是真心接受,還是抱冤叫屈?

第三编《「從『大破』走向『大立』」:文革中的「新生事物」》是高華教授生前承擔了香港中文大學中國文化研究所《中華人民共和國史》第七卷的寫作任務。他已列出該卷寫作綱要,惜乎天不假年,只完成了十餘萬字的文稿。

毛為什麼要發動「文革」?「文革」是如何發動起來的?我認為毛澤東發動「文革」有兩方面的動因,第一個因素:「文革」集中體現了毛對他所理想的社會主義的追求;第二個因素:他認為自己已大權旁落,而急於追回,這兩方面的因素互相纏繞,緊密的交融在一起。

國家的領導者為了快速建立起一個強大的社會主義的國家,他們一直在謀求一種「最好的」治理中國的制度或管理形式,他們有許多創造,建構了一種新意識形態敍述,中國傳統的思想及制度資源,革命年代的經驗與蘇聯因素融為一體,都被運用其中,被用來統合社會大眾的意識。他們也非常重視做動員、組織民眾的工作,使社會的組織化、軍事化程度不斷增強

第四編《讀書有感》包含多篇書評,論及對象既有風雲人物,也有平頭百姓,既有追隨國民黨政權遷台的作家,也有大陸人所皆知的左翼文人。本章通過對他們回憶的評議展現出多角度的時代變遷與個體感受。

我認為,學歷史、讀歷史,記住余英時先生的一段話是很重要的。他説:學歷史的好處不是光看歷史教訓,歷史教訓也是很少人接受,前面犯多少錯誤,到後面還是繼續。因為人性就是大權在握或利益在手,但難以捨棄,權力和利益的關口,有人過得去,也有人過不去。所以我認為讀歷史的最大好處是使我們懂得人性。

在大學讀書的那幾年,我知道,雖然毛澤東晚年的錯誤已被批評,但毛的極左的一套仍根深蒂固,它已滲透到當代人思想意識的深處,成為某種習慣性思維,表現在中國現代史、中共黨史研究領域,就是官學甚行,為聖人避諱,或研究為某種權威著述作注腳,幾乎成為一種流行的風尚。

杨继绳《墓碑:一九五八—一九六二年中國大饑荒紀實》

我是去年上海解封后才开始读这本书的。经历了封城,我的心态已经麻木了,无论发生什么荒唐的事情,我已经习以为常了。读这本书的时候,有好几次我都想大哭一场,眼泪和鼻涕都止不住。因为我们现在所经历的悲剧在六十多年前已经发生过一次,而官僚体制应对灾难的方式和六十年前相比没有多少改变。

从武汉肺炎刚爆发时的谎报瞒报、训诫李文亮医生再到后来西安的”掩耳到零”等招式在六十年前已经使用过了。六十年前怎么应对大饥荒的,我们现在就是怎么应对防疫的,没有任何改变。

各级政府千方百计地对外封锁饥饿的消息。公安局控制了所有的邮局,向外面发出的信件一律扣留。中共信阳地委让邮局扣了 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·阿列克谢耶维奇 切尔诺贝利的悲鸣

使用 Packer 构建虚拟机镜像踩的坑

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 等一堆依赖了,真正做到开箱即用。

于是本文分享一下如何使用 PackerVMware vSphere 环境上构建虚拟机镜像的方案,以及如何在这个虚拟机中运行一个 k3s 集群,然后通过 argo-workflow 工作流引擎运行 redfish-esxi-os-installer 来对裸金属服务器进行自动化安装 ESXi OS 的操作。

劝退三连 😂

Packer

很早之前玩儿 VMware ESXi 的时候还没有接触到 Packer,那时候只能使用手搓虚拟机模版的方式,费时费力还容易出错,下面就介绍一下这个自动化构建虚拟机镜像的工具。

简介

Packerhashicorp 公司开源的一个虚拟机镜像构建工具,与它类似的工具还有 OpenStack diskimage-builderAWS 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 构建虚拟机镜像则是由一系列的配置文件缝合而成,主要由 BuildersProvisionersPost-processors 这三部分组成。其中 Builder 主要是与 IaaS Provider 构建器相关的一些参数;Provisioner 用来配置构建过程中需要运行的一些任务;Post-processors 用于配置构建动作完成后的一些后处理操作;下面就依次介绍一下这几个配置的详细使用说明:

另外 Packer 推荐的配置语法是 HCL2,但个人觉着 HCL 的语法风格怪怪的,不如 json 那样整洁好看 😅,因此下面我统一使用 json 来进行配置,其实参数都一样,只是格式不相同而已。

vars/var-file

Packer 的变量配置文件有点类似于 Ansible 中的 vars。一个比较合理的方式就是按照每个参数的作用域进行分类整理,将它们统一放在一个单独的配置文件中,这样维护起来会更方便一些。参考了 image-builder 项目中的 ova 构建后我根据参数的不同作用划分成了如下几个配置文件:

  • vcenter.json:主要用于配置一些与 vCenter 相关的参数,比如 datastore、datacenter、resource_pool、vcenter_server 等;另外像 vcenter 的用户名和密码建议使用环境变量的方式,避免明文编码在文件当中;
{  "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"}
  • centos7.json:主要用于配置一些通过 ISO 安装 CentOS 的参数,比如 ISO 的下载地址、ISO 的 checksum、kickstart 文件路径、关机命令、isolinux 启动参数等;
{  "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"}
  • photon3.json:主要用于配置一些通过 ISO 安装 Photon3 OS 的参数,和上面的 centos7.json 作用基本一致;
{  "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"}
  • common.json:一些公共参数,比如虚拟机的 ssh 用户名和密码(要和 kickstart 中设置的保持一致)、虚拟机的一些硬件配置如 CPU、内存、硬盘、虚拟机版本、网卡类型、存储控制器类型等;
{  "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

Builder 就是告诉 Packer 要使用什么类型的构建器构建什么样的虚拟机镜像,主要是与底层 IaaS 资源提供商相关的配置。比如 vSphere Builder 中有如下两种构建器:

  • vsphere-iso 从 ISO 安装 OS 开始构建,通常情况下构建为一个虚拟机或虚拟机模版
  • vsphere-clone 通过 clone 虚拟机的方式进行构建,通常情况下构建产物为导出后的 OVF/OVA 文件

不同类型的 Builder 配置参数也会有所不同,每个参数的详细用途和说明可以参考 Packer 官方的文档,在这里就不一一说明了。因为 Packer 的参数配置是在是太多太复杂了,很难三言两语讲清楚。最佳的方式就是阅读官方的文档和一些其他项目的实现方式,照葫芦画瓢学就行。

builders.json:里面的配置参数大多都是引用的 var-file 中的参数,将这些参数单独抽出来的好处就是不同的 builder 之间可以复用一些公共参数。比如 vsphere-iso 和 vsphere-clone 这两种不同的 builder 与 vCenter 相关的 datacenter、datastore、vcenter_server 等参数都是其实相同的。

  • vsphere-iso :通过 ISO 安装 OS 构建一个虚拟机或虚拟机模版
{  "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`}}"    }  ]}
  • vsphere-clone:通过 clone 虚拟机构建一个虚拟机,并导出虚拟机 OVF 模版
{  "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

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"      ]    }  ]}

post-processors

一些构建后的操作, 比如 "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 里封装一下,那么多的参数选项手动输起来能把人气疯 😂

  • 首先定义一些默认的参数,比如构建版本、构建时间、base 模版名称、导出 ova 文件名称等等。
# 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)
  • 然后定义 vars 和 var-file 参数
# 是否为强制构建,增加 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                     # 一些公共配置
  • 最后定义 make targrt
.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
  • 构建 BASE 模版
# 通过 PACKER_BASE_OS 参数设置 base os 是 photon3 还是 centos7$ make build-template PACKER_BASE_OS=photon3
  • 构建 OVF 模版并导出为 OVA
# 通过 PACKER_BASE_OS 参数设置 base os 是 photon3 还是 centos7$ make build-ovf PACKER_BASE_OS=photon3

构建流程

将 Packer 的配置文件以及 Makefile 封装好之后,我们就可以运行 make build-templatemake build-ovf 命令来构建虚拟机模版了,整体的构建流程如下:

  • 先使用 ISO 构建一个与业务无关的 base 虚拟机
  • 在 base 虚拟机之上通过 vsphere-clone 方式构建业务虚拟机
  • 导出 OVF 虚拟机文件,打包成 OVA 格式的虚拟机模版

通过 vsphere-iso 构建 Base 虚拟机

base 虚拟机有点类似于 Dockerfile 中的 FROM base 镜像。在 Packer 中我们可以把一些很少会改动的内容做成一个 base 虚拟机。然后从这个 base 虚拟机克隆出一台新的虚拟机来完成接下来的构建流程,这样能够节省整体的构建耗时,使得构建效率更高一些。

  • centos7 构建输出日志
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
  • Photon3 构建输出日志
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 虚拟机的主要步骤和原理:

  • 下载 ISO 文件到本地的 ${HOME}/.cache/packer 目录,并以 checksum.iso 方式保存,这样的好处就是便于缓存 ISO 文件,避免重复下载;
  • 上传本地 ISO 文件到 vCenter 的 datastore 中,默认保存在 datastore 的 packer_cache 目录下,如果 ISO 文件已经存在了,则会跳过上传的流程;
  • 创建虚拟机,配置虚拟机硬件,挂载上传的 ISO 文件到虚拟机上的 CD/ROM,设置 boot 启动项为 CD/ROM
  • 如果 boot_media_path 是 http 类型的则在本地随机监听一个 TCP 端口来运行一个 http 服务,用于提供 kickstart 文件的 HTTP 下载功能;如果是目录类型的则将 kickstart 文件创建成一个软盘文件,并将该文件上传到 datastore 中,将软盘文件插入到虚拟机中;
  • 虚拟机开机启动到 ISO 引导页面,通过 vCenter API 发送键盘输入,插入 kickstart 文件的路径;
  • 通过 vCenter API 发送回车键盘输入,ISO 中的 OS 安装程序读取 kickstart 进行 OS 安装;
  • 在 kickstart 脚本里安装 open-vm-tools 工具;
  • 等待 OS 安装完成,安装完成重启后进入安装好的 OS,OS 启动后通过 DHCP 获取 IP 地址;
  • 通过 vm-tools 获取到虚拟机的 IP 地址,然后 ssh 连接到虚拟机执行关机命令;
  • 虚拟机关机,卸载 ISO 和软驱等不需要的设备;
  • 创建快照或者将虚拟机转换为模版;

个人觉着这里比较好玩儿就是居然可以通过 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-clone 构建业务虚拟机并导出 OVF/OVA

通过 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 命令的输出我们大致可以推断出构建流程:

  • clone 虚拟机,修改虚拟机的硬件配置
  • 虚拟机开机,通过 vm-tools 获取虚拟机的 IP 地址
  • 获取到虚拟机的 IP 地址后等待 ssh 能够正常连接
  • ssh 能够正常连接后,通过 scp 的方式上传文件
  • ssh 远程执行虚拟机里的 install.sh 脚本
  • 执行虚拟机关机命令
  • 创建虚拟机快照
  • 导出虚拟机 OVF 文件
  • 导出构建配置参数的 manifest.json 文件
  • 执行 ova.py 脚本,根据 manifest.json 配置参数将 OVF 格式转换成 OVA

至此,整个的虚拟机模版的构建流程算是完成了,最终我们的到一个 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

argo-workflow 和 k3s

在虚拟机内使用 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

之所以使用 argo-workflow 而不是使用像 docker、nerdctl 这些命令行工具来运行 redfish-esxi-os-installer ,是因为通过 argo-workflow 来编排我们的安装部署任务能够比较方便地实现多个任务同时运行、获取任务执行的进度及日志、获取任务执行的耗时、停止重试等功能。使用 argo-workflow 来编排我们的安装部署任务,并通过 argo-workflow 的 RESTful API 获取部署任务的进度日志等信息,这样做更云原生一些(🤣

argo-workfloe-apis

在我们内部其实最终目的是准备将该方案做成一个产品化的工具,提供一个 Web UI 用来进行配置部署参数以及展示部署的进度日志等功能。当初设计方案的时候也是参考了一下 VMware Tanzu 社区版 :部署 Tanzu 管理集群的时候需要有一个已经存在的 k8s 集群,或者通过 Tanzu 新部署一个 kind 集群。部署一个 tanzu 管理集群可以通过 tanzu 命令行的方式,也可以通过 Tanzu Web UI 的方式,Tanzu Web UI 的方式其实就是一个偏向于产品化的工具。在 VMware Tanzu kubernetes 发行版部署尝鲜 我曾分享过 Tanzu 的部署方式,感兴趣的话可以去看一下。

tanzu-cluster

该方案主要是面向一些产品化的场景,由于引入了 K8s 这个庞然大物,整体的技术栈会复杂一些,但也有一些好处啦 😅。

k8s and k3s

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 格式

使用 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 来完成的。

安装 open-vm-tool 失败

由于 ISO 中并不包含 open-vm-tool 软件包,这就需要在 ISO 安装 OS 的过程中联网安装 open-vm-tools。如果安装的时候网络抖动了就可能会导致 open-vm-tools 安装失败。open-vm-tools 安装失败 packer 是无法感知到的,只能一直等到获取虚拟机 IP 超时后退出执行。目前没有很好的办法,只能在 kickstart 里安装 open-vm-tools 的时候进行重试直到 open-vm-tools 安装成功。

减少导出后 vmdk 文件大小

曾经在 手搓虚拟机模板 文章中分析过通过 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}

Photon3

之前在 轻量级容器优化型 Linux 发行版 Photon OS 里分享过 VMware 的 Linux 发行版 Photon。不同于传统的 Linux 发行版 Photon 的系统十分精简,使用它替代 CentOS 能够一定程度上减少系统资源的占用,导出后的 vmdk 文件也要比 CentOS 小一些。

goss

在构建的过程中我们在 k3s 集群上安装了一些其他的组件,比如提供文件上传和下载服务的 filebrowser 以及 workflow 工作流引擎 argo-workflow,为了保证这些服务的正常运行,我们就需要通过不同的方式去检查这些服务是否正常。一般是通过 kubectl get 等命令查看 deployment、pod、daemonset 等服务是否正常运行,或者通过 curl 访问这些这些服务的健康检查 API。

由于检查项比较多且十分繁琐,使用传统的 shell 脚本来做这并不是很方便,需要解析每个命令的退出码以及返回值。因此我们使用 goss 通过 YAML 格式的配置文件来定义一些检查项,让它批量来执行这些检查,而不用在 shell 对每个检查项写一堆的 awk/grep 等命令来 check 了。

  • k3s.yaml:用于检查 k3s 以及它默认自带的服务是否正常运行
# 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
  • goss.yaml:用于检查我们部署的一些服务是否正常
# 通过 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 虚拟机后 Pod 状态异常

将 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

参考

Packer 相关