【翻译】Stack Overflow 的 HTTPS 化:漫漫长路的终点(二)

原文地址:HTTPS on Stack Overflow: The End of a Long Road
作者:Nick Craver
译者: 罗晟 & 狄敬超

前文地址:【翻译】Stack Overflow 的 HTTPS 化:漫漫长路的终点(一)

Cloudflare

我们评估了很多 CDN/DDoS 防护层供应商。最终选择了 Cloudflare,主要是考虑到他们的基础设施、快速响应、还有他们承诺的 Railgun。那么我们如何测试使用了 Cloudfalre 之后用户的真实效果?是否需要部署服务来获取用户数据?答案是不需要!

Stack Overflow 的数据量非常大:月 PV 过十亿。记得我们上面讲的客户端耗时纪录吗?我们每天都有几百万的访问了,所以不是直接可以问他们吗?我们是可以这么做,只需要在页面中嵌入 <iframe> 就行了。Cloudflare 已经是我们 cdn.sstatic.net(我们共用的无 cookie 的静态内容域)的托管商了。但是这是通过一条CNAME DNS 纪录来做的,我们把 DNS 指向他们的 DNS。所以要用 Cloudflare 来当代理服务的话,我们需要他们指向我们的 DNS。所以我们先需要测试他们 DNS 的性能。

实际上,要测试性能我们需要把二级域名给他们,而不是 something.stackoverflow.com,因为这样可能会有不一致的胶水记录而导致多次查询。明确一下,一级域名 (TLDs)指的是 .com, .net, .org, .dance, .duck, .fail, .gripe, .here, .horse, .ing, .kim, .lol, .ninja, .pink, .red, .vodka. 和 .wtf。 注意,这些域名尾缀都是,我可没开玩笑。 二级域名 (SLDs) 就多了一级,比如 stackoverflow.com, superuser.com 等等。我们需要测的就是这些域名的行为及表现。因此,我们就有了 teststackoverflow.com,通过这个新域名,我们在全球范围内测试 DNS 性能。对一部分比例的用户,通过嵌一个 <iframe>(在测试中开关),我们可以轻松地获取用户访问 DNS 的相关数据。

注意,测试过程最少需要 24 小时。在各个时区,互联网的表现会随着用户作息或者 Netflix 的使用情况等发生变化。所以要测试一个国家,需要完整的一天数据。最好是在工作日(而不要半天落在周六)。我们知道会有各种意外情况。互联网的性能并不是稳定的,我们要通过数据来证明这一点。

我们最初的假设是,多增加了的一个节点会带来额外的延时,我们会因此损失一部分页面加载性能。但是 DNS 性能上的增加其实弥补了这一块。比起我们只有一个数据中心来说,Cloudflare 的 DNS 服务器部署在离用户更近的地方,这一块性能要好得多得多。我希望我们能有空来放出这一块的数据,只不过这一块需要很多处理(以及托管),而我现在也没有足够多的时间。

接下来,我们开始将 teststackoverflow.com 放在 Cloudflare 的代理上做链路加速,同样也是放在 <iframe> 中。我们发现美国和加拿大的服务由于多余的节点而变慢,但是世界其他地方都是持平或者更好。这满足我们的期望。我们开始使用 Cloudflare 的网络对接我们的服务。期间发生了一些 DDos 的攻击,不过这是另外的事了。那么,为什么我们接受在美国和加拿大地区慢一点呢?因为每个页面加载需要的时间仅为 200-300ms,哪怕慢一点也还是飞快。当时我们认为 Railgun 可以将这些损耗弥补回来。

这些测试完成之后,我们为了预防 DDos 工作,做了一些其他工作。我们接入了额外的 ISP 服务商以供我们的 CDN/代理层对接。毕竟如果能绕过攻击的话,我们没必要在代理层做防护。现在每个机房都有 4 个 ISP 服务商(译者注:相当于电信、联通、移动、教育网),两组路由器,他们之间使用 BGP 协议。我们还额外添置了两组负载均衡器专门用于处理 CDN/代理层的流量。

Cloudflare: Railgun

与此配套,我们启用了两组 Railgun。Railgun 的原理是在 Cloudflare 那边,使用 memcached 匹配 URL 进行缓存数据。当 Railgun 启用的时候,每个页面(有一个大小阈值)都会被缓存下来。那么在下一次请求时候,如果在这个 URL 在 Cloudflare 节点上和我们这里都缓存的话,我们仍然会问 web 服务器最新的数据。但是我们不需要传输完整的数据,只需要把传输和上次请求的差异数据传给 Cloudflure。他们把这个差异运用于他们的缓存上,然后再发回给客户端。这时候, gzip 压缩 的操作也从 Stack Overflow 的 9 台 Web Server 转移到了一个 Railgun 服务上,这台服务器得是 CPU 密集型的——我指出这点是因为,这项服务需要评估、购买,并且部署在我们这边。

举个例子,想象一下,两个用户打开同一个问题的页面。从浏览效果来看,他们的页面技术上长得几乎一样,仅仅有细微的差别。如果我们大部分的传输内容只是一个 diff 的话,这将是一个巨大的性能提升。

总而言之,Railgun 通过减少大量数据传输的方式提高性能。当它顺利工作的时候确实是这样。除此之外,还有一个额外的优点:请求不会重置连接。由于 TCP 慢启动,当连接环境较为复杂时候,可能导致连接被限流。而 Railgun 始终以固定的连接数连接到 Cloudflare 的终端,对用户请求采用了多路复用,从而其不会受慢启动影响。小的 diff 也减少了慢启动的开销。

很可惜,我们由于种种原因我们在使用 Railgun 过程中一直遇到问题。据我所知,我们拥有当时最大的 Railgun 部署规模,这把 Railgun 逼到了极限。尽管我们花了一年追踪各种问题,最终还是不得不放弃了。这种状况不仅没有给我们省钱,还耗费了更多的精力。现在几年过去了。如果你正在评估使用 Railgun,你最好看最新的版本,他们一直在做优化。我也建议你自己做决定是否使用 Railgun。

Fastly

我们最近才迁到 Fastly,因为我们在讲 CDN/代理层,我也会顺带一提。由于很多技术工作在 Cloudflare 那边已经完成,所以迁移本身并没有什么值得说的。大家会更感兴趣的是:为什么迁移?毕竟 Cloudflare 在各方面是不错的:丰富的数据中心、稳定的带宽价格、包含 DNS 服务。答案是:它不再是我们最佳的选择了。Flastly 提供了一些我们更为看中的特性:灵活的终端节点控制能力、配置快速分发、自动配置分发。并不是说 Cloudflare 不行,只是它不再适合 Stack Overflow 了。

事实胜于雄辩:如果我不认可 Cloudflare,我的私人博客不可能选择它,嘿,就是这个博客,你现在正在阅读的。

Fastly 吸引我们的主要功能是提供了 VarnishVCL。这提供了高度的终端可定制性。有些功能吧,Cloudfalre 无法快速提供(因为他们是通用化的,会影响所有用户),在 Fastly 我们可以自己做。这是这两家架构上的差异,这种「代码级别高可配置」对于我们很适用。同时,我们也很喜欢他们在沟通、基础设施的开放性。

我来展示一个 VCL 好用在哪里的例子。最近我们遇到 .NET 4.6.2 的一个超恶心 bug,它会导致 max-age 有超过 2000 年的缓存时间。快速解决方法是在终端节点上有需要的时候去覆盖掉这个头部,当我写这篇文章的时候,这个 VCL 配置是这样的:

sub vcl_fetch {
  if (beresp.http.Cache-Control) {
      if (req.url.path ~ "^/users/flair/") {
          set beresp.http.Cache-Control = "public, max-age=180";
      } else {
          set beresp.http.Cache-Control = "private";
      }
  }

这将给用户能力展示页 3 分钟的缓存时间(数据量还好),其余页面都不设置。这是一个为解决紧急时间的非常便于部署的全局性解决方案。 我们很开心现在有能力在终端做一些事情。我们的 Jason Harvey 负责 VCL 配置,并写了一些自动化推送的功能。我们基于一个 Go 的开源库 fastlyctl 做了开发。

另一个 Fastly 的特点是可以使用我们自己的证书,Cloudflare 虽然也有这个服务,但是费用太高。如我上文提到的,我们现在已经具备使用 HTTP/2 推送的能力。但是,Fastly 就不支持 DNS,这个在 Cloudflare 那里是支持的。现在我们需要自己解决 DNS 的问题了。可能最有意思的就是这些来回的折腾吧?

全局 DNS

当我们从 Cloudflare 迁移到 Fastly 时候,我们必须评估并部署一个新的 DNS 供应商。这里有篇 Mark Henderson 写的 文章 。鉴于此,我们必须管理:

  • 我们自己的 DNS 服务器(备用)
  • Name.com 的服务器(为了那些不需要 HTTPS 的跳转服务)
  • Cloudflare DNS
  • Route 53 DNS
  • Google DNS
  • Azure DNS
  • 其他一些(测试时候使用)

这个本身就是另一个项目了。为了高效管理,我们开发了 DNSControl。这现在已经是开源项目了托管在 GiHub 上,使用 Go 语言编写。 简而言之,每当我们推送 JavaScript 的配置到 git,它都会马上在全球范围里面部署好 DNS 配置。这里有一个简单的例子,我们拿 askubuntu.com 做示范:

D('askubuntu.com', REG_NAMECOM,
    DnsProvider(R53,2),
    DnsProvider(GOOGLECLOUD,2),
    SPF,
    TXT('@', 'google-site-verification=PgJFv7ljJQmUa7wupnJgoim3Lx22fbQzyhES7-Q9cv8'), // webmasters
    A('@', ADDRESS24, FASTLY_ON),
    CNAME('www', '@'),
    CNAME('chat', 'chat.stackexchange.com.'),
    A('meta', ADDRESS24, FASTLY_ON),
END)

太棒了,接下来我们就可以使用客户端响应测试工具来测试啦!上面提到的工具可以实时告诉我们真实部署情况,而不是模拟数据。但是我们还需要测试所有部分都正常。

测试

客户端响应测试的追踪可以方便我们做性能测试,但这个并不适合用来做配置测试。客户端响应测试非常适合展现结果,但是配置有时候并没有界面,所以我们开发了 httpUnit (后来知道这个项目重名了 )。这也是一个使用 Go 语言的开源项目。以 teststackoverflow.com 举例,使用的配置如下:

[[plan]]
    label = "teststackoverflow_com"
    url = "http://teststackoverflow.com"
    ips = ["28i"]
    text = "<title>Test Stack Overflow Domain</title>"
    tags = ["so"]
[[plan]]
    label = "tls_teststackoverflow_com"
    url = "https://teststackoverflow.com"
    ips = ["28"]
    text = "<title>Test Stack Overflow Domain</title>"
    tags = ["so"]

每次我们更新一下防火墙、证书、绑定、跳转时都有必要测一下。我们必须保证我们的修改不会影响用户访问(先在预发布环境进行部署)。 httpUnit 就是我们来做集成测试的工具。

我们还有一个开发的内部工具(由亲爱的 Tom Limoncelli 开发),用来管理我们负载均衡上面的 VIP 地址 。我们先在一个备用负载均衡上面测试完成,然后将所有流量切过去,让之前的主负载均衡保持一个稳定状态。如果期间发生任何问题,我们可以轻易回滚。如果一切顺利,我们就把这个变更应用到那台负载均衡上。这个工具叫做 keepctl(keepalived control 的简称),时间允许的话很快就会整理开源出来。

应用层准备

上面提到的只是架构方面的工作。这通常是由 Stack Overflow 的几名网站可靠性工程师组成的团队完成的。而应用层也有很多需要完成的工作。这个列表会很长,先让我拿点咖啡和零食再慢慢说。

很重要的一点是,Stack Overflow 与 Stack Exchange 的架构 Q&A 采用了多租户技术。这意味着如果你访问 stackoverflow.com 或者 superuser.com 又或者 bicycles.stackexchange.com,你返回到的其实是同一台服务器上的同一个 w3wp.exe 进程。我们通过浏览器发送的 Host 请求头来改变请求的上下文。为了更好地理解我们下文中提到的一些概念,你需要知道我们代码中的 Current.Site 其实指的是 请求 中的站点。Current.Site.Url()Current.Site.Paths.FaviconUrl 也是基于同样的概念。

换一句话说:我们的 Q&A 全站都是跑在同一个服务器上的同一个进程,而用户对此没有感知。我们在九台服务器上每一台跑一个进程,只是为了发布版本和冗余的问题。

全局登录

整个项目中有一些看起来可以独立出来(事实上也是),不过也同属于整个大 HTTPS 迁移中的一部分。登录就是其中一个项目。我首先来说说这个,因为这比别它变化都要早上线。

在 Stack Overflow(及 Stack Exchange)的头五六年里,你登录的是一个个的独立网站。比如,stackoverflow.comstackexchange.com 以及 gaming.stackexchange.com 都有它们自己的 cookies。值得注意的是:meta.gaming.stackexchange.com 的登录 cookie 是从 gaming.stackexchange.com 带过来的。这些是我们上面讨论证书时提到的 meta 站点。他们的登录信息是相关联的,你只能通过父站点登录。在技术上说并没有什么特别的,但考虑到用户体验就很糟糕了。你必须一个一个站登录。我们用「全局认证」的方法来「修复」了这个问题,方法是在页面上放一个 <iframe>,内面访问一下 stackauth.com。如果用户在别处登录过的话,它也会在这个站点上登录,至少会去试试。这个体验还行,但是会有弹出框问你是否点击重载以登录,这样就又不是太好。我们可以做得更好的。对了,你也可以去问问 Kevin Montrose 关于移动 Safari 的匿名模式,你会震惊的。

于是我们有了「通用登录」。为什么用「通用」这个名字?因为我们已经用过「全局」了。我们就是如此单纯。所幸 cookies 也很单纯的东西。父域名里的 cookie(如 stackexchange.com)在你的浏览器里被带到所有子域名里去(如 gaming.stackexchange.com)。如果我们只二级域名的话,其实我们的域名并不多:

是的,我们有一些域名是跳转到上面的列表中的,比如 askdifferent.com。但是这些只是跳转而已,它们没有 cookies 也无需登录。

这里有很多细节的后端工作我没有提(归功于 Geoff DalgasAdam Lear),但大体思路就是,当你登录的时候,我们把这些域名都写入一个 cookie。我们是通过第三方的 cookie 和随机数来做的。当你登录其中任意一个网站的时候,我们在页面上都会放 6 个 <img> 标签来往其它域名写入 cookie,本质上就完成了登录工作。这并不能在 所有情况 下都适用(尤其是移动 Safari 简直是要命了),但和之前比起来那是好得多了。

客户端的代码不复杂,基本上长这样:

$.post('/users/login/universal/request', function (data, text, req) {
    $.each(data, function (arrayId, group) {
        var url = '//' + group.Host + '/users/login/universal.gif?authToken=' + 
            encodeURIComponent(group.Token) + '&nonce=' + encodeURIComponent(group.Nonce);
        $(function () { $('#footer').append('![](' + url + ')</img>'); });
    });
}, 'json');

但是要做到这点,我们必须上升到账号级别的认证(之前是用户级别)、改变读取 cookie 的方式、改变这些 meta 站的登录工作方式,同时还要将这一新的变动整合到其它应用中。比如说,Careers(现在拆成了 Talent 和 Jobs)用的是另一份代码库。我们需要让这些应用读取相应的 cookies,然后通过 API 调用 Q&A 应用来获取账户。我们部署了一个 NuGet 库来减少重复代码。底线是:你在一个地方登录,就在所有域名都登录。不弹框,不重载页面。

技术的层面上看,我们不用再关心 *.*.stackexchange.com 是什么了,只要它们是 stackexchange.com 下就行。这看起来和 HTTPS 没有关系,但这让我们可以把 meta.gaming.stackexchange.com 变成 gaming.meta.stackexchange.com 而不影响用户。

本地 HTTPS 开发

要想做得更好的话,本地环境应该尽量与开发和生产环境保持一致。幸好我们用的是 IIS,这件事情还简单的。我们使用一个工具来设置开发者环境,这个工具的名字叫「本地开发设置」——单纯吧?它可以安装工具(Visual Studio、git、SSMS 等)、服务(SQL Server、Redis、Elasticsearch)、仓库、数据库、网站以及一些其它东西。做好了基本的工具设置之后,我们要做的只是添加 SSL/TLS 证书。主要的思路如下:

Websites = @(
    @{
        Directory = "StackOverflow";
        Site = "local.mse.com";
        Aliases = "discuss.local.area51.lse.com", "local.sstatic.net";
        Databases = "Sites.Database", "Local.StackExchange.Meta", "Local.Area51", "Local.Area51.Meta";
        Certificate = $true;
    },
    @{
        Directory = "StackExchange.Website";
        Site = "local.lse.com";
        Databases = "Sites.Database", "Local.StackExchange", "Local.StackExchange.Meta", "Local.Area51.Meta";
        Certificate = $true;
    }
)

我把使用到的代码放在了一个 gist 上:Register-Websites.psm1。我们通过 host 头来设置网站(通过别名添加),如果直连的话就给它一个证书(嗯,现在应该把这个行为默认改为 $true 了),然后允许 AppPool 账号来访问数据库,于是我们本地也在使用 https:// 开发了。嗯,我知道我们应该把这个设置过程开源出来,不过我们仍需去掉一些专有的业务。会有这么一天的。

为什么这件事情很重要? 在此之前,我们从 /content 加载静态内容,而不是从另一个域名。这很方便,但也隐藏了类似于跨域请求(CORS)的问题。在同一个域名下用同一个协议能正常加载的资源,换到开发或者生产环境下就有可能出错。「在我这里是好的。」

当我们使用和生产环境中同样协议以及同样架构的 CDN 还有域名设置时,我们就可以在开发机器上找出并修复更多的问题。比如,你是否知道,从 https:// 跳转到 http:// 时,浏览器是不会发送 referer 的?这是一个安全上的问题,referer 头中可能带有以明文传输的敏感信息。

「Nick 你就扯吧,我们能拿到从 Google 拿到 referer 啊!」确实。但是这是因为他们主动选择这一行为。如果你看一下 Google 的搜索页面,你可以看到这样的 <meta> 指令:

<meta content="origin" id="mref" name="referrer">

这也就是为什么你可以取到 referer。

好的,我们已经设置好了,现在该做些什么呢?

推荐阅读更多精彩内容