Simon Arneaud 发布的文章

代码生成器是非常有用的工具。我有时使用 jinja2 的命令行版本来生成高度冗余的配置文件和其他文本文件,但它在转换数据方面功能有限。显然,Jinja2 的作者有不同的想法,而我想要类似于 列表推导 list comprehensions 或 D 语言的 可组合范围 composable range 算法之类的东西。

我决定制作一个类似于 Jinja2 的工具,但让我可以通过使用范围算法转换数据来生成复杂的文件。这个想法非常简单:一个直接用 D 语言代码重写的模板语言。因为它 就是 D 语言,它可以支持 D 语言所能做的一切。我想要一个独立的代码生成器,但是由于 D 语言的 mixin 特性,同样的模板语言可以作为嵌入式模板语言工作(例如,Web 应用程序中的 HTML)。有关该技巧的更多信息,请参阅 这篇 关于在编译时使用 mixins 将 Brainfuck 转换为 D 和机器代码的文章。

像往常一样,源码在 GitLab 上这篇文章中的例子也可以在这里找到

Hello world 示例

这是一个演示这个想法的例子:

Hello [= retro("dlrow") ]!
[: enum one = 1; :]
1 + 1 = [= one + one ]

[= some_expression ] 类似于 Jinja2 中的 {{ some_expression }},它在输出中呈现一个值。[: some_statement; :] 类似于 {% some_statement %} ,用于执行完整的代码语句。我更改了语法,因为 D 也大量使用花括号,并且将两者混合使模板难以阅读(还有一些特殊的非 D 指令,比如 include,它们被包裹在 [<>] 中)。

如果你将上面的内容保存到一个名为 hello.txt.dj 的文件中并运行 djinn 命令行工具,你会得到一个名为 hello.txt 的文件,其中包含你可能猜到的内容:

Hello world!
1 + 1 = 2

如果你使用过 Jinja2,你可能想知道第二行发生了什么。Djinn 有一个简化格式化和空格处理的特殊规则:如果源代码行包含 [: 语句或 [< 指令但不包含任何非空格输出,则整行都会被忽略输出。空行则仍会原样呈现。

生成数据

好的,现在来讲一些更实用的东西:生成 CSV 数据。

x,f(x)
[: import std.mathspecial;
foreach (x; iota(-1.0, 1.0, 0.1)) :]
[= "%0.1f,%g", x, normalDistribution(x) ]

一个 [=] 对可以包含多个用逗号分隔的表达式。如果第一个表达式是一个由双引号包裹的字符串,则会被解释为 格式化字符串。下面是输出结果:

x,f(x)
-1.0,0.158655
-0.9,0.18406
-0.8,0.211855
-0.7,0.241964
-0.6,0.274253
-0.5,0.308538
-0.4,0.344578
-0.3,0.382089
-0.2,0.42074
-0.1,0.460172
0.0,0.5
0.1,0.539828
0.2,0.57926
0.3,0.617911
0.4,0.655422
0.5,0.691462
0.6,0.725747
0.7,0.758036
0.8,0.788145
0.9,0.81594

制作图片

这个例子展示了一个图片的生成过程。经典的 Netpbm 图像库定义了一堆图像格式,其中一些是基于文本的。例如,这是一个 3 x 3 向量的图像:

P2 # PGM 格式标识
3 3 # 宽和高
7 # 代表纯白色的值(0 代表黑色)
7 0 7
0 0 0
7 0 7

你可以将上述文本保存到名为 cross.pgm 之类的文件中,很多图像工具都知道如何解析它。下面是一些 Djinn 代码,它以相同的格式生成 Mandelbrot 集 分形:

[:
import std.complex;
enum W = 640;
enum H = 480;
enum kMaxIter = 20;
ubyte mb(uint x, uint y)
{
    const c = complex(3.0 * (x - W / 1.5) / W, 2.0 * (y - H / 2.0) / H);
    auto z = complex(0.0);
    ubyte ret = kMaxIter;
    while (abs(z) <= 2 && --ret) z = z * z + c;
    return ret;
}
:]
P2
[= W ] [= H ]
[= kMaxIter ]
[: foreach (y; 0..H) :]
[= "%(%s %)", iota(W).map!(x => mb(x, y)) ]

生成的文件大约为 800 kB,但它可以很好地被压缩为 PNG:

$ # 使用 GraphicsMagick 进行转换
$ gm convert mandelbrot.pgm mandelbrot.png

结果如下:

解决谜题

这里有一个谜题:

一个 5 行 5 列的网格需要用 1 到 5 的数字填充,每个数字在每一行中限使用一次,在每列中限使用一次(即,制作一个 5 行 5 列的 拉丁方格 Latin square )。相邻单元格中的数字还必须满足所有 > 大于号表示的不等式。

几个月前我使用了 线性规划 linear programming (LP)。线性规划问题是具有线性约束的连续变量系统。这次我将使用 混合整数线性规划 mixed integer linear programming (MILP),它通过允许整数约束变量来归纳 LP。事实证明,这足以成为 NP 完备的,而 MILP 恰好可以很好地模拟这个谜题。

在上一篇文章中,我使用 Julia 库 JuMP 来帮助解决这个问题。这次我将使用 CPLEX:基于文本的格式,它受到多个 LP 和 MILP 求解器的支持(如果需要,可以通过现成的工具轻松转换为其他格式)。这是上一篇文章中 CPLEX 格式的 LP:

Minimize
  obj: v
Subject To
  ptotal: pr + pp + ps = 1
  rock: 4 ps - 5 pp - v <= 0
  paper: 5 pr - 8 ps - v <= 0
  scissors: 8 pp - 4 pr - v <= 0
Bounds
  0 <= pr <= 1
  0 <= pp <= 1
  0 <= ps <= 1
End

CPLEX 格式易于阅读,但复杂度高的问题需要大量变量和约束来建模,这使得手工编码既痛苦又容易出错。有一些特定领域的语言,例如 ZIMPL,用于以高级方式描述 MILP 和 LP。对于许多问题来说,它们非常酷,但最终它们不如具有良好库(如 JuMP)支持的通用语言或使用 D 语言的代码生成器那样富有表现力。

我将使用两组变量来模拟这个谜题:v_{r,c}i_{r,c,v}v_{r,c} 将保存 r 行 c 列单元格的值(从 1 到 5)。i_{r,c,v} 是一个二进制指示器,如果 r 行 c 列的单元格的值是 v,则该指示器值为 1,否则为 0。这两组变量是网格的冗余表示,但第一种表示更容易对不等式约束进行建模,而第二种表示更容易对唯一性约束进行建模。我只需要添加一些额外的约束来强制这两个表示是一致的。但首先,让我们从每个单元格必须只有一个值的基本约束开始。从数学上讲,这意味着给定行和列的所有指示器都必须为 0,但只有一个值为 1 的例外。这可以通过以下等式强制约束:

[i_{r,c,1} + i_{r,c,2} + i_{r,c,3} + i_{r,c,4} + i_{r,c,5} = 1]

可以使用以下 Djinn 代码生成对所有行和列的 CPLEX 约束:

\ 单元格只有一个值
[:
foreach (r; iota(N))
foreach (c; iota(N))
:]
    [= "%-(%s + %)", vs.map!(v => ivar(r, c, v)) ] = 1
[::]

ivar() 是一个辅助函数,它为我们提供变量名为 i 的字符串标识符,而 vs 存储从 1 到 5 的数字以方便使用。行和列内唯一性的约束完全相同,但在 i 的其他两个维度上迭代。

为了使变量组 i 与变量组 v 保持一致,我们需要如下约束(请记住,变量组 i 中只有一个元素的值是非零的):

[i_{r,c,1} + 2i_{r,c,2} + 3i_{r,c,3} + 4i_{r,c,4} + 5i_{r,c,5} = v_{r,c}]

CPLEX 要求所有变量都位于左侧,因此 Djinn 代码如下所示:

\ 连接变量组 i 和变量组 v
[:
foreach (r; iota(N))
foreach (c; iota(N))
:]
    [= "%-(%s + %)", vs.map!(v => text(v, ' ', ivar(r, c, v))) ] - [= vvar(r,c) ] = 0
[::]

不等符号相邻的和左下角值为为 4 单元格的约束写起来都很简单。剩下的便是将指示器变量声明为二进制,并为变量组 v 设置边界。加上变量的边界,总共有 150 个变量和 111 个约束 你可以在仓库中看到完整的代码

GNU 线性规划工具集 有一个命令行工具可以解决这个 CPLEX MILP。不幸的是,它的输出是一个包含了所有内容的体积很大的转储,所以我使用 awk 命令来提取需要的内容:

$ time glpsol --lp inequality.lp -o /dev/stdout | awk '/v[0-9][0-9]/ { print $2, $4 }' | sort
v00 1
v01 3
v02 2
v03 5
v04 4
v10 2
v11 5
v12 4
v13 1
v14 3
v20 3
v21 1
v22 5
v23 4
v24 2
v30 5
v31 4
v32 3
v33 2
v34 1
v40 4
v41 2
v42 1
v43 3
v44 5

real    0m0.114s
user    0m0.106s
sys     0m0.005s

这是在原始网格中写出的解决方案:

这些例子只是用来玩的,但我相信你已经明白了。顺便说一下,Djinn 代码仓库的 README.md 文件本身是使用 Djinn 模板生成的。

正如我所说,Djinn 也可以用作嵌入在 D 语言代码中的编译期模板语言。我最初只是想要一个代码生成器,得益于 D 语言的元编程功能,这算是一个额外获得的功能。


via: https://theartofmachinery.com/2021/01/01/djinn.html

作者:Simon Arneaud 选题:lujun9972 译者:hanszhao80 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

我职业生涯的大部分时间都是自由职业者。有时候,我也会和一些想要辞掉全职工作去做一些承包或服务业务的人聊天。到目前为止,我们新手最常犯的错误就是为自己定价。

这篇有用的博客文章为例,它分析了美国雇员与自由职业者的收入间的对比。据估计,在美国,作为一名自由职业者,你需要获得 14 万美元的收入,才能获得相当于 10 万美元的雇员薪酬。我记得当我第一次创业时,我发现这样的计算非常有用。而,有些人看到结果会想:“哎呀,如果我是自由职业者,我得赚 1.4 倍的钱。我真的能做到吗?”

不,不,不,这种想法是落后的。

如何给自己定价

让我们举个例子。假设你是一名全职软件工程师,年收入 10 万美元,你正考虑转成承保。

当你是自由职业者时候,你必须像做企业一样思考,因为这就是你的谋生方式。所以,你必须把所有的成本加起来,并计算出如何收回这些成本。电子表格的口碑很差(有一些很好的理由),但它们实际上非常有用,尤其是对这些东西(以及作为企业主要进行的许多其他计算)。

第一个要加入统计的成本是那 10 万美元。如果这听起来很奇怪,那就是所谓的“机会成本”。你本可以继续工作以赚到 10 万美元;而没有赚到这 10 万美元,实际上是在规划业务时的一种成本。把这一费用和其他你实际使用的任何其它就业福利一起记下来。如果你的雇主提供工作日午餐,那就加上一年中每个工作日午餐的费用。如果你的雇主为员工提供健身软件的折扣,但你却没有使用该软件,那么不要将该福利作为机会成本。

其他费用取决于你在做什么和你住在哪里。员工医疗保险在澳大利亚不像在美国那么重要。另一方面,强制性养老金支付(类似于美国的 401(K) 计划)是一个大问题。我有自己的公司,我的主要非工资成本是保险、会计/档案、法律(合同审查等)、债务催收和各种在线服务成本。如果你在计算一些耐用的东西(比如一张桌子),把成本除以你预计使用该东西的年数。

总之,到目前为止,这基本上就是 Caleb 的博客文章中的内容,所以为了简单起见,我将假设同样的 10 万美元的名义工资和 14 万美元的等效业务成本。(当然,一切都要根据自己的情况进行调整。)现在你需要想办法收回这笔成本。澳大利亚一年大约有 255 个工作日,所以如果你能把它们全部外包出去,你每天要收取 550 美元(外加销售税)。在现实中,你不可能为一整年的工作计费。我采取了一种风险更高的方法,在我目前从事自营职业的过去 6 年里,我的平均回报率约为 60%-70%。埃森哲的年度财务报告 说他们的承包商的“利用率”大约为 90%,我想这意味着他们付费了 90% 的总工作日的费用。让我们假设你的工作适度,75% 的工作日都有付账。这意味着你可以在 255 天的 75%(即 191 天) 里收回了 14 万美元的成本,每天开出 730 美元的账单(加上销售税)。

误区

刚接触合同的人通常会对这样的数字做出反应,并会想,“见鬼?!这么高啊!”。这只是一个计算示例,但服务价格通常相当于你天真猜测的全职雇员工资的两倍或两倍以上。然而,这日薪是通过一个简单的计算得出的,那就是你需要收取多少钱才能获得相当于 10 万美元的工资。这是一回事。不这样想才是关键的错误。

新的承包商往往还不敢确定,他们要求那么多,听起来是不是很贪婪?如果你的客户有考虑采购你,他们也在做大致相同的计算。“我可以付给 Gentle Blog Reader 每天 730 美元,只要我愿意,我也可以花大约 14 万美元雇佣一个我甚至不是每天都真正需要的全职雇员。”从雇主的角度来看,10 万美元的薪水实际上也不是 10 万美元。把价格建立在名义基本工资的基础上是没有意义的。即使你是在销售 B2C 产品,你的潜在竞争对手也不会降价,至少不会持续降价。

为什么这很重要

这个具体的例子是针对承包的,但这是商业经济学的一条基本规则:除非你正在尝试一些风险极高的高收入行为(我们知道 Pets.com 的结果),否则你需要计算出你的成本,并设定足够高的价格来弥补这些成本。

一些人仍然对他们需要设定的价格感到不安,他们认为降低价格是合理的。也许他们会这样想:“我是个很好的人,如果我每天只收 400 美元,我的客户会更高兴。”问题是你得不到同样的客户。那些愿意为雇员支付 10 万美元年薪的客户,不会为承包商支付 400 美元一天来做同样的工作。相反,在实践中,你可能会得到一些好客户,他们只是没有每天 730 美元的预算,但同时你也会得到一大堆非常糟糕的客户。想想看。如果一个陌生人以 50 美元的价格卖给你一枚看起来很精美的钻戒,你会付钱吗?还是宁愿以正常价格买另一枚戒指?

我要强调的是,我只是从 Caleb 的博客文章中提取了数据,而且一切都是相对的。用你自己的数字代替吧。在世界上大多数地区,每天 400 美元可能是一个令人难以置信的价格。然而,如果你是硅谷的一名高级金融科技开发人员,每天收费 400 美元只会让你成为吸引糟糕客户的磁铁。大多数优秀的人都会知道有些事情不对劲,他们会被吓跑。

我说的糟糕客户是什么意思?浏览一下 “来自地狱的客户”博客 吧。它包括很多基本的烦恼,比如客户永远不会得到满足,或者提出无理要求,或者浪费你的时间,一直到彻头彻尾的辱骂,或者让你按规格工作,然后辩称自己不应该付钱,因为“我不想要”。有些客户根本就不付钱。

如果你不够重视你自己的产品,也不要因为你的客户不够重视你的产品而感到震惊。

不过,情况会变得更糟。好客户倾向于与其他好客户合作。如果你总是按时做到,你会和那些浪费你时间的人一起工作吗?如果你尊重他人,你会和那些不讲道理、辱骂他人的人一起工作吗?一般来说,你的好客户会把你介绍给其他好客户。糟糕的客户则恰恰相反,如果他们甚至会感激地把你推荐给任何人的话。因此,如果你的价格合适,你的生意会随着你的声誉而增长。如果你收费过低,你会发现自己陷入了一个恶性循环,你不仅会赔钱,而且会发现越来越难获得适当的报酬。

这些都只是平均水平,如果你幸运的话,低收费也能吸引到好客户,如果你不幸的话,价格合理也仍然会得到坏客户。然而,如果你的收入已经很低了,那么每一个坏客户都会对你造成伤害。希望超越平均水平不是一个好计划。

“但没人付那么多钱!”

假设你是一名经验丰富的全职工程师,你决定尝试独立工作。你可能会发现,你的计算比率似乎比你在自由职业网站上看到的要高。这是因为在自由职业网站上建立声誉很难。自由职业者网站对于那些主要想要低价的临时买家来说是最有用的。

我想,很多聪明的工程师都认为,职业社交是很难的,而且需要非常外向的性格,所以他们不得不依靠自由职业网站来工作。坏消息是,你需要建立良好的声誉才能拿到高薪。好消息是,只要他们拥有所需的技能,大多数人都可以做到。社交并不是去参加所谓的“社交活动”(实际上,这些活动对社交来说都很糟糕)。建立关系网的技巧可以写一篇全新的博客文章,但关键是在他们的日常生活中找到好客户,并做一些让他们不断回头的事情,甚至可能让你找到其他好客户。

在任何情况下,不要让自由职业网站或其他任何东西把你的价格定在你可以从全职工资中拿到的等价物以下。事实上,你甚至可能比你现在的工资还高,这就是为什么这是 “定价基础原则”。收费过低会扼杀你的自主创业生涯。


via: https://theartofmachinery.com/2021/07/04/pricing_as_contractor_101.html

作者:Simon Arneaud 选题:lujun9972 译者:CN-QUAN 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

我通常会抽象地总结我为他人所做的工作(出于显而易见的原因),但是我被允许公开谈论一个网站:Vocal 。我去年为它做了一些 SRE 工作。实际上早在 2 月份,我就在 GraphQL 悉尼会议上做过一次演讲,不过这篇博客推迟了一点才发表。

Vocal 是一个基于 GraphQL 的网站,它获得了人们的关注,然后就遇到了可扩展性问题,而我是来解决这个问题的。这篇文章会讲述我的工作。显然,如果你正在扩展一个 GraphQL 站点,你会发现这篇文章很有用,但其中大部分内容讲的都是当一个站点获得了足够的流量而出现的必须解决的技术问题。如果你对站点可扩展性有兴趣,你可能想先阅读 最近我发表的一系列关于可扩展性的文章

Vocal

Vocal 是一个博客平台,内容包括日记、电影评论、文章评论、食谱、专业或业余摄影、美容和生活小贴士以及诗歌,当然,还有可爱的猫猫和狗狗照片。

Vocal 的不同之处在于,它允许人们制作观众感兴趣的作品而获得报酬。作者的页面每次被浏览都可以获得一小笔钱,还能获得其他用户的捐赠。有很多专业人士在这个平台上展示他们的工作,但对于大多数普通用户来说,他们只是把 Vocal 当作一个兴趣爱好,碰巧还能赚些零花钱作为奖励。

Vocal 是新泽西初创公司 Jerrick Media 的产品,更新:Jerrick Media 已经更名为 Creatd,在纳斯达克上市。2015 年,他们与 Thinkmill 合作一起开发,Thinkmill 是一家悉尼中型软件开发咨询公司,擅长 JavaScript、React 和 GraphQL 开发。

剧透

不幸的是,有人告诉我,由于法律原因,我不能提供具体的流量数字,但公开的信息可以说明。Alexa 对所有网站按照流量进行排名。这是我演讲中展示的 Alexa 排名图,从 2019 年 11 月到今年 2 月,Vocal 流量增长到全球排名第 5567 位。

去年 11 月到今年 2 月 Vocal 的全球排名从 9574 名增长到 5567 名

曲线增长变慢是正常的,因为它需要越来越多的流量来赢得每个位置。Vocal 现在排名 4900 名左右,显然还有很长的路要走,但对于一家初创公司来说,这一点也不寒酸。大多数初创公司都很乐意与 Vocal 互换排名。

在网站升级后不久,Creatd 开展了一项营销活动,使流量翻了一番。在技术方面,我们要做的就是观察仪表盘上的上升的数字。自发布以来的 9 个月里,只有两个平台问题需要员工干预:3 月份每五年一次的 AWS RDS 证书轮换,以及一款应用推出时遇到的 Terraform 错误。作为一名 SRE,我很高兴看到 Vocal 不需要太多的平台工作来保持运行。更新:该系统也抗过了 2020 年的美国大选,没有任何意外。

以下是本文技术内容的概述:

  • 技术和历史背景
  • 从 MongoDB 迁移到 Postgres
  • 部署基础设施的改造
  • 使应用程序兼容扩展措施
  • 让 HTTP 缓存发挥作用
  • 其他一些性能调整

一些背景信息

Thinkmill 使用 Next.js(一个基于 React 的 Web 框架)构建了一个网站,和 Keystone 在 MongoDB 前面提供的 GraphQL API 进行交互。Keystone 是一个基于 GraphQL 的无头 CMS 库:在 JavaScripy 中定义一个模式,将它与一些数据存储挂钩,并获得一个自动生成的 GraphQL API 用于数据访问。这是一个自由开源软件项目,由 Thinkmill 提供商业支持。

Vocal V2

Vocal 的第一版就受到了关注,它找到了一个喜欢它的用户群,并不断壮大,最终 Creatd 请求 Thinkmill 帮助开发 V2,并于去年 9 月成功推出。Creatd 员工避免了 第二个系统效应,他们一般都是根据用户的反馈进行改变,所以他们 主要是 UI 和功能更改,我就不赘述了。相反,我将讨论下我的工作内容:使新站点更加健壮和可扩展。

声明:我很感谢能与 Creatd 以及 Thinkmill 在 Vocal 上的合作,并且他们允许我发表这个故事,但 我仍然是一名独立顾问,我写这篇文章没有报酬,甚至没有被要求写它,这仍然是我自己的个人博客。

迁移数据库

Thinkmill 在使用 MongoDB 时遇到了几个可扩展性问题,因此决定升级到 Keystone 5 以利用其新的 Postgres 支持。

如果你从事技术工作的时间足够长,那你可能还记得 00 年代末的 “NOSQL” 营销,这可能听起来很有趣。NoSQL 的一个重要特点是,像 Postgres 这样的关系数据库(SQL)不像 MongoDB 这样“网站级规模”的 NoSQL 数据库那样具有可扩展性。从技术上将,这种说法是正确的,但 NoSQL 数据库的可扩展性来自它可以有效处理各种查询的折衷。简单的非关系数据库(如文档数据库和键值数据库)有其一席之地,但当它们用作应用的通用后端时,应用程序通常会在超出关系数据库的理论扩展限制之前,就超出了数据库的查询限制。Vocal 的原本的大多数数据库查询在 MongoDB 上运行良好,但随着时间推移,越来越多的查询需要特殊技巧才能工作。

在技术要求方面,Vocal 与维基百科非常相似。维基百科是世界上最大的网站之一,它运行在 MySQL(或者说它的分支 MariaDB)上。当然,这需要一些重要的工程来实现,但在可预见的未来,我认为关系数据库不会对 Vocal 的扩展构成严重威胁。

我做过一个比较,托管的 AWS RDS Postgres 实例的成本不到旧 MongoDB 实例的五分之一,但 Postgres 实例的 CPU 使用率仍然低于 10%,尽管它提供的流量比旧站点多。这主要是因为一些重要的查询在文档数据库架构下一直效率很低。

迁移的过程可以新写一篇博客文章来讲述,但基本上是 Thinkmill 开发人员构建了一个 ETL 管道,使用 MoSQL 来完成这项繁重的工作。由于 Keystone 对于 Postgres 支持仍然比较基础,但它是一个 FOSS 项目,所以我能够解决在 SQL 生成性能方面遇到的问题。对于这类事情,我总是推荐 Markys Winand 的 SQL 博文:使用 Luke 索引现代 SQL。他的文章很友好,甚至对那些暂时不太关注 SQL 人来说也是容易理解的,但他拥有你大多数需要的理论知识。如果你仍然有问题,一本好的、专注于即可性能的书可以帮助你。

平台

架构

V1 是几个 Node.js 应用,运行在 Cloudflare(作为 CDN)背后的单个虚拟专用服务器(VPS)上。我喜欢把避免过度工程化作为一个高度优先事项,所以我对这一架构竖起了大拇指。然而,在 V2 开始开发时,很明显,Vocal 已经超越了这个简单的架构。在处理巨大峰值流量时,它没有给 Thinkmill 开发人员提供很多选择,而且它很难在不停机情况下安全部署更新。

这是 V2 的新架构:

Vocal V2 的技术架构,请求从 CDN 进入,然后经过 AWS 的负载均衡。负载均衡将流量分配到两个应用程序 “Platform” 和 “Website”。“Platform” 是一款 Keystone 应用程序,将数据存储在 Redis 和 Postgres 中。

基本上就是两个 Node.js 应用程序复制放在负载均衡器后面,非常简单。有些人认为可扩展架构要比这复杂得多,但是我曾经在一些比 Vocal 规模大几个数量级的网站工作过,它们仍然只是在负载均衡器后面复制服务,带有 DB 后端。你仔细想想,如果平台架构需要随着站点的增长而变得越来越复杂,那么它就不是真正“可扩展的”。网站可扩展性主要是解决那些破坏可扩展的实现细节。

如果流量增长得足够多,Vocal 的架构可能需要一些补充,但它变得更加复杂的主要原因是新功能。例如,如果(出于某种原因)Vocal 将来需要处理实时地理空间数据,那将是一个与博客文章截然不同的技术,所以我预期它会进行架构上的更改。大型网站架构的复杂性主要来自于复杂的功能。

不知道未来的架构应该是什么样子很正常,所以我总是建议你尽可能从简单开始。修复一个简单架构要比复杂架构更容易,成本也更低。此外,不必要的复杂架构更有可能出现错误,而这些错误将更难调试。

顺便说一下,Vocal 分成了两个应用程序,但这并不重要。一个常见的扩展错误是,以可扩展的名义过早地将应用分割成更小的服务,但将应用分割在错误的位置,从而导致更多的可扩展性问题。作为一个单体应用,Vocal 可以扩展的很好,但它的分割做的也很好。

基础设施

Thinkmill 有一些人有使用 AWS 经验,但它主要是一个开发车间,需要一些比之前的 Vocal 部署更容易上手的东西。我最终在 AWS Fargate 上部署了新的 Vocal,这是弹性容器服务(ECS)的一个相对较新的后端。在过去,许多人希望 ECS 作为一个“托管服务运行 Docker 容器”的简单产品,但人们仍然必须构建和管理自己的服务器集群,这让人感到失望。使用 ECS Fargate,AWS 就可以管理集群了。它支持运行带有复制、健康检查、滚动更新、自动缩放和简单警报等基本功能的 Docker 容器。

一个好的替代方案是平台即服务(PaaS),比如 App Engine 或 Heroku。Thinkmill 已经在简单的项目中使用它们,但通常在其他项目中需要更大的灵活性。在 PaaS 上运行的网站规模要大得多,但 Vocal 的规模决定了使用自定义云部署是有经济意义的。

另一个明显的替代方案是 Kubernetes。Kubernetes 比 ECS Fargate 拥有更多的功能,但它的成本要高得多 —— 无论是资源开销还是维护(例如定期节点升级)所需的人员。一般来说,我不向任何没有专门 DevOps 员工的地方推荐 Kubernetes。Fargate 具有 Vocal 需要的功能,使得 Thinkmill 和 Creatd 能专心于网站改进,而不是忙碌于搭建基础设施。

另一种选择是“无服务器”功能产品,例如 AWS Lambda 或 Google 云。它们非常适合处理流量很低或很不规则的服务,但是 ECS Fargate 的自动伸缩功能足以满足 Vocal 的后端。这些产品的另一个优点是,它们允许开发人员在云环境中部署东西,但无需了解很多关于云环境的知识。权衡的结果是,无服务器产品与开发过程、测试以及调试过程紧密耦合。Thinkmill 内部已经有足够的 AWS 专业知识来管理 Fargate 的部署,任何知道如何开发 Node.js 简单的 Hello World 应用程序的开发人员都可以在 Vocal 上工作,而无需了解无服务器功能或 Fargate 的知识。

ECS Fargate 的一个明显缺点是供应商锁定。但是,避免供应商锁定是一种权衡,就像避免停机一样。如果你担心迁移,那么在平台独立性花费比迁移上更多的钱是没有意义的。在 Vocal 中,依赖于 Fargate 的代码总量小于 500 行 Terraform。最重要的是 Vocal 应用程序代码本身与平台无关,它可以在普通开发人员的机器上运行,然后打包成一个 Docker 容器,几乎可以运行在任何可以运行 Docker 容器的地方,包括 ECS Fargate。

Fargate 的另一个缺点是设置复杂。与 AWS 中的大多数东西一样,它处于一个 VPC、子网、IAM 策略世界中。幸运的是,这类东西是相对静态的(不像服务器集群一样需要维护)。

制作一个可扩展的应用程序

如果你想轻松地运行一个大规模的应用程序,需要做一大堆正确的事情。如果你遵循 应用程序设计的十二个守则 the Twelve-Factor App design ,事情会变得容易,所以我不会在这里赘述。

如果员工无法规模化操作,那么构建一个“可扩展”系统就毫无意义 —— 就像在独轮车上安装喷气式发动机一样。使 Vocal 可扩展的一个重要部分是建立 CI/CD 和 基础设施即代码 之类的东西。同样,有些部署的思路也不值得考虑,因为它们使生产与开发环境相差太大(参阅 应用程序设计守则第十守则)。生产和开发之间的任何差异都会降低应用程序的开发速度,并且(在实践中)最终可能会导致错误。

缓存

缓存是一个很大的话题 —— 我曾经做过 一个关于 HTTP 缓存的演讲,相对比较基础。我将在这里谈论缓存对于 GraphQL 的重要性。

首先,一个重要的警告:每当遇到性能问题时,你可能会想:“我可以将这个值放入缓存中吗,以便再次使用时速度更快?”微基准测试 总是 告诉你:是的。 然而,由于缓存一致性等问题,随处设置缓存往往会使整个系统 变慢。以下是我对于缓存的检查表:

  1. 是否需要通过缓存解决性能问题
  2. 再仔细想想(非缓存的性能往往更加健壮)
  3. 是否可以通过改进现有的缓存来解决问题
  4. 如果所有都失败了,那么可以考虑添加新的缓存

在 Web 系统中,你经常使用的一个缓存是 HTTP 缓存系统,因此,在添加额外缓存之前,试着使用 HTTP 缓存是一个好主意。我将在这篇文章中重点讨论这一点。

另一个常见的陷阱是使用哈希映射或应用程序内部其他东西进行缓存。它在本地开发中效果很好,但在扩展时表现糟糕。最好的办法是使用支持显式缓存库,支持 Redis 或 Memcached 这样的可插拔后端。

基础知识

HTTP 规范中有两种类型缓存:私有和公共。私有缓存不会和多个用户共享数据 —— 实际上就是用户的浏览器缓存。其余的就是公共缓存。它们包括受你控制的(例如 CDN、Varnish 或 Nginx 等服务器)和不受你控制的(代理)。代理缓存在当今的 HTTPS 世界中很少见,但一些公司网络会有。

缓存查找键通常基于 URL,因此如果你遵循“相同的内容,相同的 URL;不同的内容,不同的 URL” 规则,即,给每个页面一个规范的 URL,避免从同一个 URL 返回不同的内容这样“聪明”的技巧,缓存就会强壮一点。显然,这对 GraphQL API 端点有影响(我将在后面讨论)。

你的服务器可以采用自定义配置,但配置 HTTP 缓存的主要方法是在 Web 响应上设置 HTTP 头。最重要的标头是 cache-control。下面这一行说明所有缓存都可以缓存页面长达 3600 秒(一小时):

cache-control: max-age=3600, public

对于针对用户的页面(例如用户设置页面),重要的是使用 private 而不是 public 来告诉公共缓存不要存储响应,防止其提供给其他用户。

另一个常见的标头是 vary,它告诉缓存,响应是基于 URL 之外的一些内容而变化。(实际上,它将 HTTP 头和 URL 一起添加到缓存键中。)这是一个非常生硬的工具,这就是为什么尽可能使用良好 URL 结构的原因,但一个重要的用例是告诉浏览器响应取决于登录的 cookie,以便在登录或注销时更新页面。

vary: cookie

如果页面根据登录状态而变化,你需要 cache-control:private(和 vary:cookie),即使是在公开的、已登出的页面版本上,以确保响应不会混淆。

其他有用的标头包括 etaglast-modified,但我不会在这里介绍它们。你可能仍然会看到一些诸如 expirespragma:cache 这种老旧的 HTTP 标头。它们早在 1997 年就被 HTTP/1.1 淘汰了,所以我只在我想禁用缓存或者我感到偏执时才使用它们。

客户端标头

鲜为人知的是,HTTP 规范允许在客户端请求中使用 cache-control 标头以减少缓存时间并获得最新响应。不幸的是,似乎大多数浏览器都不支持大于 0 的 max-age ,但如果你有时在更新后需要一个最新响应,no-cache 会很有用。

HTTP 缓存和 GraphQL

如上,正常的缓存键是 URL。但是 GraphQL API 通常只使用一个端点(比如说 /api/)。如果你希望 GraphQL 查询可以缓存,你需要查询参数出现在 URL 路径中,例如 /api/?query={user{id}}&variables={"x":99}(忽略了 URL 转义)。诀窍是将 GraphQL 客户端配置为使用 HTTP GET 请求进行查询(例如,apollo-link-http 设置为 useGETForQueries )。

Mutation 不能缓存,所以它们仍然需要使用 HTTP POST 请求。对于 POST 请求,缓存只会看到 /api/ 作为 URL 路径,但缓存会直接拒绝缓存 POST 请求。请记住,GET 用于非 Mutation 查询(即幂等),POST 用于 Mutation(即非幂等)。在一种情况下,你可能希望避免使用 GET 查询:如果查询变量包含敏感信息。URL 经常出现在日志文件、浏览器历史记录和聊天中,因此 URL 中包含敏感信息通常是一个坏主意。无论如何,像身份验证这种事情应该作为不可缓存的 Mutation 来完成,这是一个特殊的情况,值得记住。

不幸的是,有一个问题:GraphQL 查询往往比 REST API URL 大得多。如果你简单地切换到基于 GET 的查询,你会得到一些非常长的 URL,很容易超过 2000 字节的限制,目前一些流行的浏览器和服务器还不会接受它们。一种解决方案是发送某种查询 ID,而不是发送整个查询,即类似于 /api/?queryId=42&variables={"x":99}。Apollo GraphQL 服务器对此支持两种方式:

一种方法是 从代码中提取所有 GraphQL 查询,并构建一个服务器端和客户端共享的查询表。缺点之一是这会使构建过程更加复杂,另一个缺点是它将客户端项目与服务器项目耦合,这与 GraphQL 的一个主要卖点背道而驰。还有一个缺点是 X 版本和 Y 版本的代码可能识别一组不同的查询,这会成为一个问题,因为 1:复制的应用程序将在更新推出或回滚期间提供多个版本,2:客户端可能会使用缓存的 JavaScript,即使你升级或降级服务器。

另一种方式是 Apollo GraphQL 所宣称的 自动持久查询(APQ)。对于 APQ 而言,查询 ID 是查询的哈希值。客户端向服务器发出请求,通过哈希查询。如果服务器无法识别该查询,则客户端会在 POST 请求中发送完整的查询,服务器会保存此次查询的散列值,以便下次识别。

HTTP 缓存和 Keystone 5

如上所述,Vocal 使用 Keystone 5 生成 GraphQL API,而 Keystone 5 和 Apollo GraphQL 服务器配合一起工作。那么我们是如何设置缓存标头的呢?

Apollo 支持 GraphQL 模式的 缓存提示 cache hint 。巧妙地是,Apollo 会收集查询涉及的所有内容的所有缓存提示,然后它会自动计算适当的缓存标头值。例如,以这个查询为例:

query userAvatarUrl {
    authenticatedUser {
        name
        avatar_url
    }
}

如果 name 的最长期限为 1 天,而 avatar_url 的最长期限为 1 小时,则整体缓存最长期限将是最小值,即 1 小时。authenticatedUser 取决于登录 cookie,因此它需要一个 private 提示,它会覆盖其他字段的 public,因此生成的 HTTP 头将是 cache-control:max-age=3600,private

对 Keystone 列表和字段添加了缓存提示支持。以下是一个简单例子,在文档的待办列表演示中给一个字段添加缓存提示:

const keystone = new Keystone({
  name: 'Keystone To-Do List',
  adapter: new MongooseAdapter(),
});

keystone.createList('Todo', {
  schemaDoc: 'A list of things which need to be done',
  fields: {
    name: {
      type: Text,
      schemaDoc: 'This is the thing you need to do',
      isRequired: true,
      cacheHint: {
        scope: 'PUBLIC',
        maxAge: 3600,
      },
    },
  },
});

另一个问题:CORS

令人沮丧的是, 跨域资源共享 Cross-Origin Resource Sharing (CORS)规则会与基于 API 网站中的缓存产生冲突。

在深入问题细节之前,让我们跳到最简单的解决方案:将主站点和 API 放在一个域名上。如果你的站点和 API 位于同一个域名上,就不必担心 CORS 规则(但你可能需要考虑 限制 cookie)。如果你的 API 专门用于网站,这是最简单的解决方案,你可以愉快地跳过这一节。

在 Vocal V1 中,网站(Next.js)和平台(Keystone GraphQL)应用程序处于不同域(vocal.mediaapi.vocal.media)。为了保护用户免受恶意网站的侵害,现代浏览器不会随便让一个网站与另一个网站进行交互。因此,在允许 vocal.mediaapi.vocal.media 发出请求之前,浏览器会对 api.vocal.media 进行“预检”。这是一个使用 OPTIONS 方法的 HTTP 请求,主要是询问跨域资源共享是否允许。预检通过后,浏览器会发出最初的正常请求。

令人沮丧的是,预检是针对每个 URL 的。浏览器为每个 URL 发出一个新的 OPTIONS 请求,服务器每次都会响应。服务器没法说 vocal.media 是所有 api.vocal.media 请求的可信来源 。当所有内容都是对一个 API 端点的 POST 请求时,这个问题并不严重,但是在为每个查询提供 GET 式 URL 后,每个查询都因预检而延迟。更令人沮丧的是,HTTP 规范说 OPTIONS 请求不能被缓存,所以你会发现你所有的 GraphQL 数据都被完美地缓存在用户身旁的 CDN 中,但浏览器仍然必须向源服务器发出所有的预检请求。

如果你不能只使用一个共享的域,有几种解决方案。

如果你的 API 足够简单,你或许可以利用 CORS 规则的例外

某些缓存服务器可以配置为忽略 HTTP 规范,任何情况都会缓存 OPTIONS 请求。例如,基于 Varnish 的缓存和 AWS CloudFrone。这不如完全避免预检那么有效,但比默认的要好。

另一个很魔改的选项是 JSONP。当心:如果做错了,那么可能会创建安全漏洞。

让 Vocal 更好地利用缓存

让 HTTP 缓存在底层工作之后,我需要让应用程序更好地利用它。

HTTP 缓存的一个限制是它在响应级别上要么是全有要么是全无的。大多数响应都可以缓存,但如果一个字节不能缓存,那整个页面就无法缓存。作为一个博客平台,大多数 Vocal 数据都是可缓存的,但在旧网站上,由于右上角的菜单栏,几乎没有页面可以缓存。对于匿名用户,菜单栏将显示邀请用户登录或创建账号的链接。对于已登录用户,它会变成用户头像和用户个人资料菜单,因为页面会根据用户登录状态而变化,所以无法在 CDN 中缓存任何页面。

Vocal 的一个典型页面。该页面的大部分内容都是高度可缓存的内容,但在旧网站中,由于右上角有一个小菜单,实际上没有一个内容是可缓存的。

这些页面是由 React 组件的服务器端渲染(SSR)的。解决方法是将所有依赖于登录 cookie 的 React 组件,强制它们 只在客户端进行延迟呈现。现在,服务器会返回完全通用的页面,其中包含用于个性化组件(如登录菜单栏)的占位符。当页面在浏览器中加载时,这些占位符将通过调用 GraphQL API 在客户端填充。通用页面可以安全地缓存到 CDN 中。

这一技巧不仅提高了缓存命中率,还帮助改善了人们感知的页面加载时间。空白屏幕和旋转动画让我们不耐烦,但一旦第一个内容出现,它会分散我们几百毫秒的注意力。如果人们在社交媒体上点击一个 Vocal 帖子的链接,主要内容就会立即从 CDN 中出现,很少有人会注意到,有些组件直到几百毫秒后才会完全出现。

顺便说一下,另一个让用户更快地看到第一个内容的技巧是 流式渲染,而不是等待整个页面渲染完成后再发送。不幸的是,Node.js 还不支持这个功能

拆分响应来提高可缓存性也适用于 GraphQL。通过一个请求查询多个数据片段的能力通常是 GraphQL 的一个优势,但如果响应的不同部分具有差别很大的缓存,那么最好将它们分开。举个简单的例子,Vocal 的分页组件需要知道当前页面的页数和内容。最初,组件在一个查询中同时获取两个页面,但由于页面的总数是所有页面的一个常量,所有我将其设置为一个单独的查询,以便缓存它。

缓存的好处

缓存的明显好处是它减轻了 Vocal 后端服务器的负载。这很好。但是依赖缓存来获得容量是危险的,你仍然需要一个备份计划,以便当有一天你不可避免地放弃缓存。

提高页面响应速度是使用缓存是一个更好的理由。

其他一些好处可能不那么明显。峰值流量往往是高度本地化的。如果一个有很多社交媒体粉丝的人分享了一个页面的链接,那么 Vocal 的流量就会大幅上升,但主要是指向分享的那个页面及其元素。这就是为什么缓存擅长吸收最糟糕的流量峰值,它使后端流量模式相对更平滑,更容易被自动伸缩处理。

另一个好处是 优雅的退化 graceful degradation 。即使后端由于某些原因出现了严重的问题,站点最受欢迎的部分仍然可以通过 CDN 缓存来提供服务。

其他的性能调整

正如我常说的,可扩展的秘诀不是让事情变得更复杂。它只是让事情变得不比需要的更复杂,然后彻底解决所有阻碍扩展的东西。扩展 Vocal 的规模涉及到许多小事,在这篇文章中无法一一说明。

一个经验:对于分布式系统中难以调试的问题,最困难的部分通常是获取正确的数据,从而了解发生的原因。我能想到很多时候,我被困住了,只能靠猜测来“即兴发挥”,而不是想办法找到正确的数据。有时这行得通,但对复杂的问题却不行。

一个相关技巧是,你可以通过获取系统中每个组件的实时数据(甚至只是 tail -F 的日志),在不同的窗口中显示,然后在另一个窗口中单击网站来了解很多信息。比如:“为什么切换这个复选框会在后台产生这么多的 DB 查询?”

这里有个修复的例子。有些页面需要几秒钟以上的时间来呈现,但这个情况只会在部署环境中使用 SSR 时会出现。监控仪表盘没有显示任何 CPU 使用量峰值,应用程序也没有使用磁盘,所以这表明应用程序可能正在等待网络请求,可能是对后端的请求。在开发环境中,我可以使用 sysstat 工具来记录 CPU、RAM、磁盘使用情况,以及 Postgres 语句日志和正常的应用日志来观察应用程序是如何工作的。Node.js 支持探测跟踪 HTTP 请求,比如使用 bpftrace,但处于某些无聊的原因,它们不能在开发环境中工作,所以我在源代码中找到了探针,并创建了一个带有请求日志的 Node.js 版本。我使用 tcpdump 记录网络数据,这让我找到了问题所在:对于网站发出的每一个 API 请求,都要创建一个新的网络连接到 “Platform”。(如果这都不起作用,我想我会在应用程序中添加请求跟踪功能。)

网络连接在本地机器上速度很快,但在现实网络上却不可忽略。建立加密连接(比在生产环境中)需要更长时间。如果你向一个服务器(比如一个 API)发出大量请求,保持连接打开并重用它是很重要的。浏览器会自动这么做,但 Node.js 默认不会,因为它不知道你是否发出了很多请求,所以这个问题只出现在 SSR 上。与许多漫长的调试过程一样,修复却非常简单:只需将 SSR 配置为 保持连接存活,这样会使页面的呈现时间大幅下降。

如果你想了解更多这方面的知识,我强烈建议你阅读《高性能浏览器网络》这本书(可免费在线阅读),并跟进 Brendan Gregg 发表的指南

你的站点呢?

实际上,我们还可以做很多事情来提升 Vocal 的速度,但我们没有全做。这是因为在初创公司和在大公司身为一个固定员工做 SRE 工作还是有很大区别的。我们的目标、预算和发布日期都很紧张,但最终我们的网站得到了很大改善,给了用户他们想要的东西。

同样的,你的站点有它自己的目标,并且可能与 Vocal 有很大的不同。然而,我希望这篇文章和它的链接至少能给你一些有用的思路,为用户创造更好的东西。


via: https://theartofmachinery.com/2020/06/29/scaling_a_graphql_site.html

作者:Simon Arneaud 选题:lujun9972 译者:MjSeven 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

现代应用程序开发的一大优点是,像硬件故障或如何设置 RAID 这类问题是由云提供商操心的。优秀的云供应商不太可能丢失你的应用数据,所以有时我会被询问现在为什么还要备份?下面是一些现实世界的故事。

故事之一

第一个故事来自一个数据科学项目:它基本上是一个从正在进行的研究中来收集数据的庞大而复杂的管道,然后用各种不同的方式处理以满足一些尖端模型的需要。这个面向用户的应用程序还没有推出,但是一个由数据科学家和开发人员组成的团队已经为建立这个模型和它的数据集工作了好几个月。

在项目中工作的人有他们自己的实验工作的开发环境。他们会在终端中做一些类似 export ENVIRONMENT=simonsdev 的事情,然后所有在终端上运行的软件都会在那个环境下运行,而不是在生产环境下。

该团队迫切需要推出一个面向用户的应用程序,以便那些花钱的人能够从他们几个月的投资中真正看到一些回报。在一个星期六,一位工程师试图赶工一些工作。他在晚上很晚的时候做完了一个实验,决定收拾东西回家。他启动了一个清理脚本来删除他的开发环境中的所有内容,但奇怪的是,这比平时花费了更长的时间。这时他意识到,他已经忘记了哪个终端被配置为指向哪个环境。(LCTT 译注:意即删除了生产环境。)

故事之二

第二个故事来自于一个商业的网页和手机应用。后端有一个由一组工程师负责的微服务体系结构。这意味着部署需要协调,但是使用正式的发布过程和自动化简化了一些。新代码在准备好后会被审查并合并到主干中,并且高层开发人员通常会为每个微服务标记版本,然后自动部署到临时环境。临时环境中的版本会被定期收集到一个元版本中,在自动部署到生产环境之前,该版本会得到各个人的签署(这是一个合规环境)。

有一天,一位开发人员正在开发一个复杂的功能,而其他开发该微服务的开发人员都同意将他们正在开发的代码提交到主干,也都知道它还不能被实际发布。长话短说,并不是团队中的每个人都收到了消息,而代码就进入了发布管道。更糟糕的是,那些实验性代码需要一种新的方式来表示用户配置文件数据,因此它有一个临时数据迁移,它在推出到生产环境时运行,损坏了所有的用户配置文件。

故事之三

第三个故事来自另一款网页应用。这个有一个更简单的架构:大部分代码在一个应用程序中,数据在数据库中。然而,这个应用程序也是在很大的截止日期压力下编写的。事实证明,在开发初期,当彻底更改的数据库架构很常见时,添加一项功能来检测此类更改并清理旧数据,这实际上对发布前的早期开发很有用,并且始终只是作为开发环境的临时功能。不幸的是,在匆忙构建应用的其余部分并推出时,我们忘记了这些代码。当然,直到有一天它在生产环境中被触发了。

事后分析

对于任何故障的事后分析,很容易忽视大局,最终将一切归咎于一些小细节。一个特例是发现某人犯了一些错误,然后责怪那个人。这些故事中的所有工程师实际上都是优秀的工程师(雇佣 SRE 顾问的公司不是那些在长期雇佣中偷工减料的公司),所以解雇他们,换掉他们并不能解决任何问题。即使你拥有 100 倍的开发人员,它仍然是有限的,所以在足够的复杂性和压力下,错误也会发生。最重要的解决方案是备份,无论你如何丢失数据(包括来自恶意软件,这是最近新闻中的一个热门话题),它都能帮助你。如果你无法容忍没有副本,就不要只有一个副本。

故事之一的结局很糟糕:没有备份。该项目的六个月的数据收集白干了。顺便说一句,有些地方只保留一个每日快照作为备份,这个故事也是一个很好的例子,说明了这也会出错:如果数据丢失发生在星期六,并且你准备在星期一尝试恢复,那么一日备份就只能得到星期日的一个空数据备份。

故事之二并不算好,但结果要好得多。备份是可用的,但数据迁移也是可逆的。不好的部分是发布是在推出前完成的,并且修复工作必须在生产站点关闭时进行编码。我讲这个故事的主要原因是为了提醒大家,备份并不仅仅是灾难性的数据丢失。部分数据损坏也会发生,而且可能会更加混乱。

故事之三还好。尽管少量数据永久丢失,但大部分数据可以从备份中恢复。团队中的每个人都对没有标记极其明显的危险代码感到非常难过。我没有参与早期的开发,但我感觉很糟糕,因为恢复数据所需的时间比正常情况要长得多。如果有一个经过良好测试的恢复过程,我认为该站点应该在总共不到 15 分钟的时间内重新上线。但是第一次恢复没有成功,我不得不调试它为什么不能成功,然后重试。当一个生产站点宕机了,需要你重新启动它,每过 10 秒钟都感觉过了一个世纪。值得庆幸的是,老板们比某些人更能理解我们。他们实际上松了一口气,因为这一场可能使公司沉没的一次性灾难只导致了几分钟的数据丢失和不到一个小时的停机时间。

在实践中,备份“成功”但恢复失败的情况极为普遍。很多时候,小型数据集上进行恢复测试是可以正常工作的,但在生产规模的大数据集上就会失败。当每个人都压力过大时,灾难最有可能发生,而生产站点的故障只会增加压力。在时间合适的时候测试和记录完整的恢复过程是一个非常好的主意。


via: https://theartofmachinery.com/2021/06/06/how_apps_lose_data.html

作者:Simon Arneaud 选题:lujun9972 译者:PearFL 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

这要从一次咨询的失误说起:政府组织 A 让政府组织 B 开发一个 Web 应用程序。政府机构 B 把部分工作外包给某个人。后来,项目的托管和维护被外包给一家私人公司 C。C 公司发现,之前外包的人(已经离开很久了)构建了一个自定义的 Docker 镜像,并将其成为系统构建的依赖项,但这个人没有提交原始的 Dockerfile。C 公司有合同义务管理这个 Docker 镜像,可是他们他们没有源代码。C 公司偶尔叫我进去做各种工作,所以处理一些关于这个神秘 Docker 镜像的事情就成了我的工作。

幸运的是,Docker 镜像的格式比想象的透明多了。虽然还需要做一些侦查工作,但只要解剖一个镜像文件,就能发现很多东西。例如,这里有一个 Prettier 代码格式化 的镜像可供快速浏览。

首先,让 Docker 守护进程 daemon 拉取镜像,然后将镜像提取到文件中:

docker pull tmknom/prettier:2.0.5
docker save tmknom/prettier:2.0.5 > prettier.tar

是的,该文件只是一个典型 tarball 格式的归档文件:

$ tar xvf prettier.tar
6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/
6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/VERSION
6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/json
6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar
88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json
a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/
a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/VERSION
a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/json
a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar
d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/
d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/VERSION
d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/json
d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/layer.tar
manifest.json
repositories

如你所见,Docker 在命名时经常使用 哈希 hash 。我们看看 manifest.json。它是以难以阅读的压缩 JSON 写的,不过 JSON 瑞士军刀 jq 可以很好地打印 JSON:

$ jq . manifest.json
[
  {
    "Config": "88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json",
    "RepoTags": [
      "tmknom/prettier:2.0.5"
    ],
    "Layers": [
      "a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar",
      "d4f612de5397f1fc91272cfbad245b89eac8fa4ad9f0fc10a40ffbb54a356cb4/layer.tar",
      "6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar"
    ]
  }
]

请注意,这三个 Layer 对应三个以哈希命名的目录。我们以后再看。现在,让我们看看 Config 键指向的 JSON 文件。它有点长,所以我只在这里转储第一部分:

$ jq . 88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json | head -n 20
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "--help"
    ],
    "ArgsEscaped": true,
    "Image": "sha256:93e72874b338c1e0734025e1d8ebe259d4f16265dc2840f88c4c754e1c01ba0a",

最重要的是 history 列表,它列出了镜像中的每一层。Docker 镜像由这些层堆叠而成。Dockerfile 中几乎每条命令都会变成一个层,描述该命令对镜像所做的更改。如果你执行 RUN script.sh 命令创建了 really_big_file,然后用 RUN rm really_big_file 命令删除文件,Docker 镜像实际生成两层:一个包含 really_big_file,一个包含 .wh.really_big_file 记录来删除它。整个镜像文件大小不变。这就是为什么你会经常看到像 RUN script.sh && rm really_big_file 这样的 Dockerfile 命令链接在一起——它保障所有更改都合并到一层中。

以下是该 Docker 镜像中记录的所有层。注意,大多数层不改变文件系统镜像,并且 empty_layer 标记为 true。以下只有三个层是非空的,与我们之前描述的相符。

$ jq .history 88f38be28f05f38dba94ce0c1328ebe2b963b65848ab96594f8172a9c3b0f25b.json
[
  {
    "created": "2020-04-24T01:05:03.608058404Z",
    "created_by": "/bin/sh -c #(nop) ADD file:b91adb67b670d3a6ff9463e48b7def903ed516be66fc4282d22c53e41512be49 in / "
  },
  {
    "created": "2020-04-24T01:05:03.92860976Z",
    "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:06.617130538Z",
    "created_by": "/bin/sh -c #(nop)  ARG BUILD_DATE",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:07.020521808Z",
    "created_by": "/bin/sh -c #(nop)  ARG VCS_REF",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:07.36915054Z",
    "created_by": "/bin/sh -c #(nop)  ARG VERSION",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:07.708820086Z",
    "created_by": "/bin/sh -c #(nop)  ARG REPO_NAME",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:08.06429638Z",
    "created_by": "/bin/sh -c #(nop)  LABEL org.label-schema.vendor=tmknom org.label-schema.name=tmknom/prettier org.label-schema.description=Prettier is an opinionated code formatter. org.label-schema.build-date=2020-04-29T06:34:01Z org
.label-schema.version=2.0.5 org.label-schema.vcs-ref=35d2587 org.label-schema.vcs-url=https://github.com/tmknom/prettier org.label-schema.usage=https://github.com/tmknom/prettier/blob/master/README.md#usage org.label-schema.docker.cmd=do
cker run --rm -v $PWD:/work tmknom/prettier --parser=markdown --write '**/*.md' org.label-schema.schema-version=1.0",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:08.511269907Z",
    "created_by": "/bin/sh -c #(nop)  ARG NODEJS_VERSION=12.15.0-r1",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:08.775876657Z",
    "created_by": "/bin/sh -c #(nop)  ARG PRETTIER_VERSION",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:26.399622951Z",
    "created_by": "|6 BUILD_DATE=2020-04-29T06:34:01Z NODEJS_VERSION=12.15.0-r1 PRETTIER_VERSION=2.0.5 REPO_NAME=tmknom/prettier VCS_REF=35d2587 VERSION=2.0.5 /bin/sh -c set -x &&     apk add --no-cache nodejs=${NODEJS_VERSION} nodejs-np
m=${NODEJS_VERSION} &&     npm install -g prettier@${PRETTIER_VERSION} &&     npm cache clean --force &&     apk del nodejs-npm"
  },
  {
    "created": "2020-04-29T06:34:26.764034848Z",
    "created_by": "/bin/sh -c #(nop) WORKDIR /work"
  },
  {
    "created": "2020-04-29T06:34:27.092671047Z",
    "created_by": "/bin/sh -c #(nop)  ENTRYPOINT [\"/usr/bin/prettier\"]",
    "empty_layer": true
  },
  {
    "created": "2020-04-29T06:34:27.406606712Z",
    "created_by": "/bin/sh -c #(nop)  CMD [\"--help\"]",
    "empty_layer": true
  }
]

太棒了!所有的命令都在 created_by 字段中,我们几乎可以用这些命令重建 Dockerfile。但不是完全可以。最上面的 ADD 命令实际上没有给我们需要添加的文件。COPY 命令也没有全部信息。我们还失去了 FROM 语句,因为它们扩展成了从基础 Docker 镜像继承的所有层。

我们可以通过查看 时间戳 timestamp ,按 Dockerfile 对层进行分组。大多数层的时间戳相差不到一分钟,代表每一层构建所需的时间。但是前两层是 2020-04-24,其余的是 2020-04-29。这是因为前两层来自一个基础 Docker 镜像。理想情况下,我们可以找出一个 FROM 命令来获得这个镜像,这样我们就有了一个可维护的 Dockerfile。

manifest.json 展示第一个非空层是 a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/layer.tar。让我们看看它:

$ cd a9cc4ace48cd792ef888ade20810f82f6c24aaf2436f30337a2a712cd054dc97/
$ tar tf layer.tf | head
bin/
bin/arch
bin/ash
bin/base64
bin/bbconfig
bin/busybox
bin/cat
bin/chgrp
bin/chmod
bin/chown

看起来它可能是一个 操作系统 operating system 基础镜像,这也是你期望从典型 Dockerfile 中看到的。Tarball 中有 488 个条目,如果你浏览一下,就会发现一些有趣的条目:

...
dev/
etc/
etc/alpine-release
etc/apk/
etc/apk/arch
etc/apk/keys/
etc/apk/keys/[email protected]
etc/apk/keys/[email protected]
etc/apk/keys/[email protected]
etc/apk/protected_paths.d/
etc/apk/repositories
etc/apk/world
etc/conf.d/
...

果不其然,这是一个 Alpine 镜像,如果你注意到其他层使用 apk 命令安装软件包,你可能已经猜到了。让我们解压 tarball 看看:

$ mkdir files
$ cd files
$ tar xf ../layer.tar
$ ls
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
$ cat etc/alpine-release
3.11.6

如果你拉取、解压 alpine:3.11.6,你会发现里面有一个非空层,layer.tar 与 Prettier 镜像基础层中的 layer.tar 是一样的。

出于兴趣,另外两个非空层是什么?第二层是包含 Prettier 安装包的主层。它有 528 个条目,包含 Prettier、一堆依赖项和证书更新:

...
usr/lib/libuv.so.1
usr/lib/libuv.so.1.0.0
usr/lib/node_modules/
usr/lib/node_modules/prettier/
usr/lib/node_modules/prettier/LICENSE
usr/lib/node_modules/prettier/README.md
usr/lib/node_modules/prettier/bin-prettier.js
usr/lib/node_modules/prettier/doc.js
usr/lib/node_modules/prettier/index.js
usr/lib/node_modules/prettier/package.json
usr/lib/node_modules/prettier/parser-angular.js
usr/lib/node_modules/prettier/parser-babel.js
usr/lib/node_modules/prettier/parser-flow.js
usr/lib/node_modules/prettier/parser-glimmer.js
usr/lib/node_modules/prettier/parser-graphql.js
usr/lib/node_modules/prettier/parser-html.js
usr/lib/node_modules/prettier/parser-markdown.js
usr/lib/node_modules/prettier/parser-postcss.js
usr/lib/node_modules/prettier/parser-typescript.js
usr/lib/node_modules/prettier/parser-yaml.js
usr/lib/node_modules/prettier/standalone.js
usr/lib/node_modules/prettier/third-party.js
usr/local/
usr/local/share/
usr/local/share/ca-certificates/
usr/sbin/
usr/sbin/update-ca-certificates
usr/share/
usr/share/ca-certificates/
usr/share/ca-certificates/mozilla/
usr/share/ca-certificates/mozilla/ACCVRAIZ1.crt
usr/share/ca-certificates/mozilla/AC_RAIZ_FNMT-RCM.crt
usr/share/ca-certificates/mozilla/Actalis_Authentication_Root_CA.crt
...

第三层由 WORKDIR /work 命令创建,它只包含一个条目:

$ tar tf 6c37da2ee7de579a0bf5495df32ba3e7807b0a42e2a02779206d165f55f1ba70/layer.tar
work/

原始 Dockerfile 在 Prettier 的 git 仓库中


via: https://theartofmachinery.com/2021/03/18/reverse_engineering_a_docker_image.html

作者:Simon Arneaud 选题:lujun9972 译者:DCOLIVERSUN 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

大约五年前的今天,我上交了 Google 员工证,然后走出了悉尼 Google 办公室,开启了一段自谋职业的崭新生活。我认为我应该详述一下这个故事,因为我通过阅读 Michael Lynch 的作品而收获颇丰。正如你所看到的,我仍然花费了几年时间才开始考虑写这篇文章,但是最终我告诉自己,倘若我不在五周年纪念日写它,我就永远也不会写了。

这篇文章有点儿长,但是我希望它对那些对于在大型技术公司工作感兴趣的新开发人员或是想要离职的大型企业雇员能够有所帮助。我将谈谈我进入 Google,在 Google 工作和辞职的故事,以及之后我做了什么。如果你想了解更多的细节,可以随时询问,不过我已经有很多博文要写,所以不能保证有什么深入的内容。

同样地,冒着显而易见的劳工风险:我已经有 5 年不在 Google 工作了,所以请不要以这个故事来作为当今 Google 或是 Google 雇员经历全貌的字面描述。但是,我认为其中的许多内容仍然与一般性的技术职业有关。

通往 Google 的艰辛道路

2005 年,我获得了第一份带薪的编程工作,是在当地的电力公司工作,把一些旧的 Pascal 代码用不同的编译器在不同的操作系统上运行。这基本上只是我为了挣外快而做的暑期工,同年我还刚刚开始攻读我数学和物理的学位。他们很高兴有一个本科生能够胜任这份工作。我被这些大人吓了一跳,因为他们不仅只是对我的编程爱好感兴趣,而且真的还会为此给我钱。

直到 2007 年毕业以前,我一直在做类似的工作。我喜欢编程工作,而 Google 是一家从事着很酷的编程工作的很酷的公司,因此我申请了实习。 Google 的面试过程以困难而著称,所以我花了好几个星期时间练习了所有我在网上能够找到的 Google 面试题。我认为 13 年里面试流程并没有发生太大的变化 —— 我提交了简历,受邀参加了几轮电话面试,这些面试问的几乎都是算法问题(我记得有一个动态规划问题和一个分治几何问题)。我通过了最初的几轮面试,然后受邀前往悉尼接受了由 Google 的工程师们进行的为期一天的现场面试。我回到家里,等待 Google HR 的电话,这个过程漫长得像是有一辈子。我被拒绝了。

对于我们收到的拒绝和失败感到难过很自然,因此我们并不会经常谈及它们。但是出于同样的原因,其他人也不会去谈论他们自己的失败,这只会使得情况变得更加糟糕。当我后来真的进入 Google 时,我觉得作为一个此前被拒绝过的人,我一定有哪里做得不对,但是有一天我和一群同事坐在一张桌子旁,开始交谈。那时候我才发现,实际上我身边的很多人都至少被拒绝过一次。我甚至都不是“最差的”。有个家伙开玩笑说,他肯定是因为 Google HR 厌倦了拒绝他才得以进来的。我说的也是一些相当厉害的工程师 —— 有些人负责着我一直在用的代码,而我打赌你也在用。

进行面试的公司通常会为每个名额面试两名或更多的候选人。这意味着比起录用,会有更多的拒绝,所以一般面试参与者被拒绝的可能性要大于被录用。然而我们一直忘记了这一点。四个开发人员参加面试,一个被录用了,其他三个在社交媒体上抱怨这场面试是如何的漏洞百出,因为他们个人被拒绝了。当然,面试远非完美,但是我们需要停止如此个人化地谈论它。

只要你能够找到问题所在并知道如何去改进自己,拒绝和失败就没有那么糟糕。Google 的面试主要针对算法,我在其中磕磕拌拌地摸索,但绝对没有能够脱颖而出。

在被 Google 拒绝以后,我得到了两样东西,并进行了为期一年的休假。第一件东西是澳大利亚商务编号(ABN),我用它来提供数学与科学补习课程,以及技术工作合同。我获得的另一样东西是一张大学科技图书馆的借书证。我当时并不打算再次去参加 Google 的面试,但是那次的面试经历告诉我还有很多东西是我所不知道的。我就在图书馆开设课程给大家做辅导,并在期间阅读书籍。顺便说一句,有些人认为我为我的补习业务所做的所有这些财务工作和其他东西很奇怪,而大多数补习老师都只收现金。但是我学到了许多对我日后生活很有帮助的东西,所以我一点儿都不后悔。

2009 年,我根据一个叫 Persi Diaconis 的魔术师转行为数学家的作品,进行了一个数学荣誉课程(也就是学士学位四年级)。计算机科学系让我选修他们的一个算法单元作为其中的一部分。

就像我所说的那样,我本来并没有打算再去 Google 面试,但是让我快速地讲讲这是怎么发生的。我从高中就开始学习日语,因此在 2012 年,我决定尝试在东京生活。这基本上行得通,除了我犯了一个相当大的错误 —— 我没有任何日语方面的纸质资质证明,因此很难获得工作面试。最终,我的一个已经被 Google 录用的朋友建议我再试一次。与 Google 所有的办事处一样, Google 东京的官方商务语言是英语,因此他们不要求我具有日语资质证明。

Google 面试,再一次

我的朋友向 Google HR 推荐了我。这绝对有帮助,但是如果你自己得到了被推荐的机会,也不要太过于兴奋。它所能够确保的是你的简历会被注意到(不是小事)并且免去一次电话面试,但你仍然得通过剩下的电话面试和现场面试。

这一次我用来自 Project EulerGoogle CodeJam 的题进行练习。电话面试过程中,我不得不在 Google Doc 上进行一些在线编程,这有点儿尴尬,但是除此以外电话面试一切顺利。然后我受邀前往六本木的 Mori Tower 办公室进行了为期一天的现场面试。

Mori Tower in Tokyo, where I interviewed for Google. It's the sixth tallest building in the city, which means it's huge.

我的首个面试非常糟糕。我的脑子僵住了。我知道我能够解出那些题目,但是直到面试官走出房间我才想出答案。我立刻就感到很放松,并且意识到这是一个三元搜索问题。这是在是很令人沮丧,但是我觉得继续前进,看看剩下的面试进展如何。

其中的两道面试题很糟糕。其中之一直至今日仍然是我遇到过的最糟糕的面试问题。面试官说:“你用同一输入运行一个程序两次,得到了两个不同的结果。告诉我这是为什么。”我回答道:“当这种情况在现代计算机上发生而且并不在我的预期之中时,通常是竞态条件。”他只说:“不,这不是竞态条件。”然后看着我等着我的下一个回答。如果他有兴趣讨论一下的话,这个问题本该是一个很棒的问题,但是很显然他实际上只想玩“猜猜神秘数”。对于我所说的几乎全部内容,他只是回答:“不。”显然,该程序完全是确定性的,不存储任何状态,并且不依赖于环境(例如磁盘或是实时时钟),但却在每次执行时都给出不同的结果。我怀疑我们对于“被存储的状态”或是“环境”的含义还是某些东西有着不同的理解,但是我无法区分。有一次(变得绝望了)我试着问电子元件的温度变化是否会有影响,而他说:“不,那会是一个竞态条件,我已经告诉过你这不是竞态条件了。”最终,面试结束了,而我仍然不知道那个秘密数字是什么。

我讲这个故事的原因是,我听说过许多更为平淡的恐怖故事,用以证明面试官是憎恶面试者的坏人。然而,与流行的刻板印象所相反的是,当天的大多数面试基本上都还可以,面试官也很友好并且很尊重人。面试也着实很困难,因此最好减少面试官的工作量。希望那个“猜数字”面试官从 Google HR 那里得到的反馈是,他的问题对于作出聘用决定没什么帮助。

这次,面试带来了一份要约,但是有一个小问题:这份工作在悉尼,担任站点可靠性工程师(SRE)。我以前从未听说过 SRE,但是我和一位悉尼的资深 SRE 通了电话,他解释说他注意到了我在天然气行业从事嵌入式工程的经历,并且认为 SRE 会和适合我,因为同样强调可靠性与拟合紧密约束。

在东京花了大约一年时间来建立起自己的生活,我不想抛弃一切然后搬到悉尼,但是我绝不可能会拒绝一份来自 Google 的要约。与招聘人员交谈时,我确实犯了一个非常愚蠢的错误:我被问到当时能赚多少钱,然后我就脱口而出。别这么做。这意味着不管在面试中发生了什么事情,或是你上一份工作中被底薪了多少,或者其它什么。你可能会被拒绝,或者会在原来的薪水基础上得到一些象征性的提升,并且如果你试图进一步协商,会被认为疯狂而又不合理。就我而言,我的收入甚至远远低于 Google 的入门级职位。我无法肯定地说全是这样,但是在 2013 年我搬到了悉尼,在 Google Maps 成为了一名新毕业生级别的 SRE。

悉尼的 Google Maps SRE

像 Maps 这样的产品实际上是若干个软件项目,每个都有自己的开发人员团队。甚至诸如路线查找之类的功能实际上也是多个软件项目 —— 从交通时刻表数据收集,到线路计算,再到结果渲染,等等等等。 SRE 的工作包含两个方面:一方面是为各个项目提供待命,实时响应任何生产事故;另一方面(在无需救火时)则是将生产事故中所积攒的经验应用到其他项目中去,并且发现其中可能出错的方式,或是发现使其性能更好的机会。Google 的 SRE 还需要像开发人员的内部咨询小组一样,对部署实践、自动化、监控或是类似的问题提供咨询。

这项工作相当紧张。作为一个团队,我们每周至少需要处理一次生产事故,否则就要为更多的服务提供支持。每个礼拜,悉尼的所有 SRE 都会聚在一起,交流发生过的故障事件或是有关如何使事情更好地运转的新技巧。学习曲线的感觉就像是再次成为了一名本科生。

我有时会感到震惊,听说我选择离开 Google 的人会问:“但是你不会想念那些福利吗?!”物质上的福利(例如伙食等等)绝对很棒,但是它们是你可以买到的东西,因此,不,它们不是我所想念的东西。如果你问我所想念的是什么,我会说是在那里工作的人们。与你可能听说过的不同,傲慢的人不喜欢在 Google 之类的地方工作。有一个臭名昭著的故事,一个自恋的人在 Google 找了份工作,并假装自己是各方面的顶级专家,让自己尴尬不已。他待了不到半年就离开了。总的来说,与我工作过的其他地方相比,这里的文化在傲慢、指责以及政治方面很少。另一方面,Google 并没有垄断好同事。

不过,有一种公司政治是个大问题。晋升需要“展示影响”,而众所周知的是,要做到这一点最简单的方法是发布一些新事物(不是惟一的方法,但是最简单)。结果是 Googler 们比起改进现有的解决方案,对于推广他们自己内测品质的原型方案更感兴趣。在 SRE 之间,我们经常开玩笑说, Google 内部有两种软件:一种是老的东西,工作得很好,但已经废弃了,甚至连考虑使用都是不够谷歌化的;另一种是热门的新东西,尽管它们还不能用,但却是今天 100% 可以使用的官方工具。作为 SRE,我们经常亲眼看到新的热点事物出了什么问题(有时甚至在没出 alpha 之前它就已经成了过时的旧东西)。(我此前已经对这类事物进行了更为深入的讨论。

这不是我们这些愤世疾俗的 SRE 所想象的东西;这在公司中被公认为是一个问题,而我记得有人向我保证,晋升委员会已经开始通过维护工作等方式寻找关于其影响的证据。

晋升申请

2015 年,在 Google 工作了两年之后,我的经理告诉我,现在是时候申请一个高于我新毕业生水准的晋升了。晋升过程是每年两次由晋升委员会进行集中管理的。你可以先提出申请,然后加上一份对你所从事过的项目的简短描述,再加上同事的推荐信。委员会将会进行审查,然后给你赞成或反对的意见。仅仅有你经理的推荐是不够的,因为你的经理有想让你获得晋升的动机。手下有高级别的员工有助于你自己的职业发展。

长话短说,我提交了我的申请,而委员会说不。事实上,这是个相当糟糕的拒绝。我不记得详细的答复了,但感觉就像是委员会在我的申请中寻找可以不屑一顾的东西。例如,我从事过的一个项目是一个内部工具,它出现了功能需求的积压。我查看了这个项目,发现根本问题在于它已经超出了构建它的键值存储,需要一个合适的数据库。我主张切换到关系数据库,并实现了它:模式、数据迁移、查询、实时站点迁移等等。新查询的速度要快得多,而且(更重要的是)可以有效地支持新功能。在进行迁移之前,我必须要解决的一个问题是大部分代码没有被测试所覆盖,而这是由于大部分的代码都不可测试。我使用依赖注入以及我此前讨论过的其他技巧重构了代码,而这使我能够构建一组回归测试套件。我记得这个项目被驳回主要是被评价为测试单元的编写是“新毕业生水平的工作”。

我的经理真的很支持我,并且写了上诉。他没有给我看,但是我认为这是可以被缩减成 “WTF” 的若干页(更雄辩而详尽地论述)。以下是一些我也认为这一回复有点 “WTF” 的原因:

Google SRE 有一种“关键人物”的概念。一个项目的关键人物有两个角色:一个是比起其他 SRE 对于软件项目有着更为深入的了解,以便你能够回答他们可能会提出的问题;另一个角色是作为项目本身的开发人员的第一联络人,以便他们的所有 SRE 问题都能得到回答。 Google 的职业阶梯指南说,关键人物不应该处于“新毕业生水准”,而应该晋升。正如我在申请中所写的,我是三个项目的关键人物。

我的关键人物经历使得想要找到同意支持我的晋升申请的资深开发人员很容易。当他们发现我是新毕业生级别时都十分震惊。他们都同意支持我的申请,认可我已经处在了一个更高的级别。

在我的申请之中,我提到曾担任过一组新毕业实习生的导师。当我提出申请时,他们之中的许多人都已经被聘用为了正式雇员。我足够资深,可以去担任他们的导师,但是还绝不足以晋升到比他们更高的级别。

给我经理上诉的回复与最初的审查截然不同。这次,我“大大超出了对于我‘新毕业生’级别工作的期望”,但是问题在于他们需要稍多一些时间来确保我能够晋升到新毕业生加一的级别。我被告知在接下来的 6 个月时间里,倘若我能够继续超出预期,直到下一个晋升周期,也许那时我就会得到晋升。上诉结束了;这就是最终结果。

我写了一封电子邮件,表示我要采取另一种选择。就像许多科技公司一样, Google 也有员工持股计划。在开始工作时,你会得到一笔象征性的补助金,而在各个“投资”里程碑时刻,你会收到真正的股份。我的下一次股票授予是在几个月之后。从那以后,我将不再为 Google 工作。

我离开的原因

任何辞职的决定并不容易,而某天你或许会面临同样的抉择。以下是一些有助于我作出决定的因素。(我在以前的一篇贴子里对一些这类想法进行了更深入的解释。

如果你思考一下,考虑到我并不是字面意义上真正的应届毕业生, Google 的评价应该是这样的:“你正在做一些非常错误的事情。在 X、 Y 还有 Z 方面有所改进之前,你根本不会得到晋升。”被告知“你远远超出了预期,但是我们还需要 6 个月左右的时间”,这是毫无道理的。没有人关注我是否有能力做好我的工作。我得到了许多借口,但是没有能够帮助我提高的任何有用反馈。(注意:有时候你必须要明确地要求反馈。经理们可能会陷入捍卫自己所给出的绩效评级的陷阱,而不会去考虑报告是否需要反馈。)

我也不确定晋升委员会会在 6 个月里看到什么他们在已经过去的 2 年时间里都没有看到的问题。他们难保不会再要求 6 个月时间?如果我需要花上多年时间来证明自己以获得新毕业生加一的级别晋升,那么我升到新毕业生加二的时候得有多老呢?

刚加入 Google 时,我的工作级别无关紧要,因为我当时学到了那么多东西,并且能在我的简历里写入一家著名的公司。两年过后,等式变得不同了。 Google 所提供给我的未来所具有的价值正在下降,而 Google 之外机会的价值却正在上升。 Google 的职位实际上在 Google 之外几乎毫无意义。在过去的 5 年间,许多人都问过我在 Google 做过什么,但是没有一个人问我在 Google 是什么职位,也没人称我为“新毕业生”。尽管我在短期内受到了财务方面的打击,但实际上在我上交员工证的那天我就已经得到了晋升。

值得称赞的是,Google 没有做过任何类似于以下的事情,但是在其他公司中却很常见:试图让员工对于要求加薪感到内疚。在几年前我工作过的地方,一些工程师在一次成功发布会后,在许多紧要关头要求加薪。管理层扮演起了受害者的角色,并且指责工程师们是在“强迫他们”。(大约 6 个月时间后,他们失去了自己大部分的工程团队。)如果你真的愿意就辞职时间进行配合(例如,在发布日期之后,而不是前一周),并且愿意记录下你的知识并做了整理等等,那么你仅仅是由于雇主支付给你的工资不足而“强迫他们”。

名义上,我在 Google 留下了大量未授予的股票。但是知道你拥有股票时,股票才属于你。我只是得到了未来会有分红的承诺,而我可以将其除以所需的时间来将其转换为同等的工资率。为这项投资工作 2 个月是值得的,为了剩余的投资工作数年是不值得的。不要被授予股票的偏见所迷惑。

什么时候不应该辞职呢?嗯,与在其他地方相比,你能得到的很多吗?公司的职业发展道路不是天上掉下来的,他们是一系列的业务报价,代表着你将为什么样的公司评估而工作。如果你认为自己能得到很多(考虑到所有的薪酬和像是工作环境之类的无形资产),很好!否则,是时候认真考虑一下下一步该做什么了。

离开 Google 之后

我应当警告你的是,我采取了高增长的战略,但是牺牲了短期稳定性。如果对你而言稳定性更为重要,你应该做出不一样的选择。我的 A 计划、 B 计划、 C 计划都失败了,我最终花费了几个月时间苦苦找寻出路。最后,我在一家小型网店得到了一份合同,为 Safety Town 工作,一家政府建立的面向孩子们的道路安全网站。那里的薪水较之于 Google 是一个巨大的缩减,尤其是考虑到这是我几个月以来的第一份工作。但是,你知道,我真的很享受这个项目。当然了,它不像 Google 那么“酷”,而且可能一些学校里的孩子也不觉得它酷。另一方面,在 Google,我只是机器中的一个螺丝钉。 Safety Town 有一个小团队,每个人都扮演着至关重要的角色。在 Safety Town 项目中,我是后端工程师, Safety Town 当时是我唯一需要费心的事情。而且可能一些孩子已经在这个网站上学到了一两件有关道路安全的事情。从那以后,我做了很多项目,大多数都更大,但是我仍然会向人们展示 Safety Town。

Screenshot of Safety Town home page, owned by Australian NSW government.

我记得 Google 悉尼办事处的一张海报,上面写着:“飞向月球吧!即使你错过了,你也会降落在群星之中!”人们很容易忘记,即使你不是在为知名公司或初创公司做“登月计划”,你也可以拥有高质量的生活。

这儿有一个帮助我获得合同的窍门。我会去参加悉尼的科技活动,站在能看到求职公告板的范围之中,等着看见有人在上面写东西。假设他们正在为一个保险公司项目写 CSS 开发方面的信息。即使我对 CSS 或保险不是特别感兴趣,我也会晃悠过去说:“嗨,这是个什么类型的保险项目?”这是最容易的开启谈话的方式,因为在他们努力往求职公告板上的狭小缝隙中写字的时候,满脑子都是这个项目。通常情况下,这样的谈话仍然不会为我带来一份工作,但是偶尔也会发现一些我能够帮上忙的东西。有些活动没有求职公告板,但是组织者们往往很乐意把麦克风递给别人几分钟。这为他们的活动增添了社区参与度。

我在做了一个政府采购的网站后,我取得了重大的突破,因为我学会了不至于对政府采购一窍不通。很难确切说出这些知识的价值,但是不到一年过后,我就签署了一份政府合同,比我此前所期望的要多了 40%。(不过,我如今没有做那么多的政府和大型企业的工作了。)

大约一年半过后,我有了自己的一人公司。随着我声誉的建立,我逐渐获得了更多类似于 SRE 的工作。基本上,从事开发工作是我的“工作”,然后几个月后就有一个需要 SRE/DevOps 帮助的人联系了我。我事实上既喜欢 SRE,也喜欢纯开发工作,但是供求关系意味着 SRE 工作是个好工作。我仍然可以在空余时间编程。

说起这个,工作与生活的平衡是我在新生活中最喜欢的事情。没有人在两份合同之间给我酬劳,但是我可以通过在业余项目中学习新东西来充分利用这一间隙。在一个漫长而又紧张的合同之后,我休息了一下,进行了为期一个月的背包徒步旅行,探索了日本乡村。这是我期待了很长时间的一次旅行,但是在入职 Google 之前我需要更多的钱,而在 Google 供职期间我又需要更多的时间。自营职业远非没有压力,也不是适合每一个人的,但是有的压力会让你感到死气沉沉,有的压力则会让你越发充满活力。于我而言,自主营生是第二种,我想说,和在 Google 时相比,过去的 5 年间我的压力总体上有所减轻。对于我来说,至少我能够诚实地说我不后悔当初加入 Google,也不后悔当初离开 Google。


via: https://theartofmachinery.com/2020/08/04/leaving_google.html

作者:Simon Arneaud 选题:lujun9972 译者:JonnieWayy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出