Kubernetes 使用 Zuul 网关时上游 Tomcat 400 报错问题排查

在 Kubernetes 里,使用 Zuul 转发请求到上游的 Tomcat 服务时,Tomcat 报了一个很诡异的错误:

1
The host [zuul.host,zuul.host] is not valid

其中 zuul.host 是 Zuul 服务的域名。

看到这个报错时,有两个点很奇怪。一是 [zuul.host, zuul.host] 是怎么来的,为什么它重复了一遍。二是为什么 host 的值会是这个,应该是 Tomcat 服务的域名。

网上搜了一圈,没找到相关资料,只能一步步来挖了。

由于是 Tomcat 直接报错,未进入 Spring Boot 内部,配置的 Sentry 也没捕捉到该错误,只能通过添加 Tomcat 日志配置打印相应的 Header。

1
2
3
4
# application.properties
server.tomcat.basedir=logs/tomcat-logs
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.pattern=%t "%r" %s (%D ms) -Host (%{Host}i) -X-Forwarded-host (%{X-Forwarded-Host}i)

日志打印出了两个很有意思的值,Host 和 X-Forwarded-Host 的值是同样的,都为 [zuul.host, zuul.host]

1
2
Host: [zuul.host, zuul.host]
X-Forwarded-Host: [zuul.host, zuul.host]

这时可以确定,Tomcat 确实收到了一个诡异的请求,由于 Host Header 值无效导致 Tomcat 400 报错。但问题是,这请求是怎么来的呢?只能一步步往前追查,在 Zuul 服务中添加相应的日志配置。

1
2
// ZuulFilter.java
log.info("LOG_HEADER: {} Host-({}) X-Forwarded-Host-({})", request.getRequestURI(), request.getHeader("Host"), request.getHeader("X-Forwarded-Host"));
1
LOG_HEADER: /api/v1/login/app  Host-(zuul.host) X-Forwarded-Host-(zuul.host)

从日志中可以看出,Zuul 服务接收到的请求 Host 和 X-Forwarded-Host 都为 zuul.host,再加上 Zuul 默认情况下也会为转发的请求添加 X-Forwarded-Host,所以 Tomcat 收到的时候 X-Forwarded-Host 为两次的 zuul.host。现在已经知道 X-Forwarded-Host 的值是如何来的,但 Host 值的原因还没找到。

没其他线索的情况下,我本地往 Tomcat 服务发送了几次请求,X-Forwarded-Host 的值从零个到多个,发现了些许端倪。在 Tomcat 打印的日志中,Host 和 X-Forwarded-Host 的值都是一样的。而当请求的 X-Forwarded-Host 值零个或一个时,请求返回 200。X-Forwarded-Host 值大于一个时,请求返回 400。这时我有了一个猜想,难道 X-Forwarded-Host 的值会被复制到 Host 吗?

按照这个想法,找到了相关的 ISSUE,原来 Ingress 在开启 use-forwarded-headers 后,会将 X-Forwarded-Host 的值复制到 Host,但由于没有考虑 X-Forwarded-Host 为多个值的情况,导致 Host 不合法。这个问题已经在 ingress-nginx 0.24 版本被修复。

请求的整个流程,如下图所示。

找到了问题根源,那解决起来就简单了。有以下三种方法:

  1. 升级 ingress-nginx 至 0.24 及以上版本,从根本上解决这个问题。
  2. 配置 zuul.addProxyHeaders = false,使得 Zuul 不添加额外的 X-Forwarded-Host
  3. Zuul 直接使用 Tomcat 服务的 Service Name 进行访问,不经过 Ingress。

参考

图解密码技术

对称密码

对称密码是一种用相同的密钥进行加密和解密的技术,用于确保消息的机密性。尽管对称密码能够确保消息的机密性,但需要解决将解密密钥配送给接受者的密钥配送问题。
一次性密码本的原理是将明文与一串随机的比特序列进行 XOR 运算。即使用暴力破解法遍历整个密钥空间,也绝对无法被破译。
随着计算机的进步,现在 DES 已经能够被暴力破解,强度大不如前了。
AES 是取代其 DES 而成为新标准的一种对称密码算法。在 2000 年从候选算法中选出了一种名为 Rijndael 的对称密码算法,并将其确定为了 AES。

转码微信 speex

H5 可以使用微信 jssdk 提供的录音接口,将录音上传到微信的服务器。而后端可以通过获取微信录音的接口下载录音。
录音有两种格式,一种是 8K 采样率的 amr 格式。还有一种是 16K 采样率的 speex 格式。speex 格式的录音更清晰,当然文件也更大。同样的录音,speex 格式大约是 amr 格式的 4 倍。但同时,amr 格式的录音失真十分严重。如果有播放、语音识别的需求,建议还是采用 speex 格式的录音。
不知出于什么考虑,微信对 speex 格式的录音做了加工,得使用 speex 官方解码库结合微信的解码库才能进行转码。
下文介绍如何使用 docker 编译可转码微信 speex 的程序。

如何用 Markdown 写 PPT

reveal-js 是通过网页来制作 PPT 的 JavaScript 框架,通过它可以轻松制作精致的网页 PPT。它自身也支持 Markdown。
但使用过几次之后,感觉对于日常使用还是稍重了些。每次新建 PPT 都需要复制粘贴 boilerplate code。

reveal-md 让你从这些繁琐的步骤中解放出来。它使用 reveal-js 自带的 Markdown 功能,让你只需写 Markdown,其他的事它帮你搞定。

安装

简单地说下如何使用。

1
2
3
4
mkdir slides && cd slides
yarn init -y
yarn add reveal-md
yarn exec reveal-md demo

此时炫酷的 PPT 网页就在你眼前呈现了。

语法

标准的 Markdown 语法,默认 --- 作为 PPT 页面的分割线。

1
2
3
4
5
6
7
8
9
10
11
12
# Title

* Point 1
* Point 2

---

## Second slide

> Best quote ever.

Note: speaker notes FTW!

演示命令

演示命令 reveal-md,启动本地 server 并使用 reveal.js 渲染 Markdown 文件。

1
yarn exec reveal-md demo.md

更详细的配置请查看 reveal-md 文档reveal-js 文档

Rails 通过 path 实现 subdomain —— default_url_options 的妙用

最近在开发微信开放平台,需要通过 url 来区别不同的第三方。最简单直接的方法,就是通过 subdomain 来实现。
比如有两个第三方 A、B,那么 A 的域名为 A.example.com,B 的域名为 B.example.com。但这次由于 https 证书的问题只能通过 path 来实现,也就是 A 的域名为 www.example.com/A,B 的域名为 www.example.com/B。

但通过 path 实现会有一个严重的问题,就是如何保证生成的 url 带有第三方的信息。

1
2
3
scope path: '/:third_party' do
resources :orders
end

上面的配置使用 orders_path 时会报错 missing required keys: [:third_path]
最笨的办法莫过于给所有的 url 手动加上,但这方法随着项目增大会变得不可行。

我们希望 url 的生成可以更加智能,当访问 url 为 www.example.com/A 时,使用 orders_path 可以自动生成 /A/orders。从逻辑上,这是行得通的。

首先想到 route 可以添加 default 配置,于是做了以下尝试,可惜报错了。在 route 时只能配置静态的默认值。

1
2
3
4
# undefined local variable or method `params'
scope path: '/:third_party', default: {third_party: params[:third_party]} do
resources :orders
end

那么,有没有类似的方法,提供 url default 配置的功能呢?这时就轮到 default_url_options 出场了。
default_url_options 的用处不仅仅是在 config 时设置默认的 host,每个 Controller 都有该方法,调用 url_helper 时会结合该方法生成最终的 url。

在对应的 Controller 里添加以下代码,这时调用 orders_path 就会根据域名中的 third_parth 生成对应的域名了。

1
2
3
def default_url_options
{third_party: params[:third_party]}
end

解决了生成 url 的问题,我们还需要解决测试时 url 的问题。这里我对 ActionController::TestCase 进行了 monkey patch,供大家参考。

1
2
3
4
5
6
7
8
9
10
11
class ActionController::TestCase

alias_method :orig_process, :process

def process(action, method: "GET", params: {}, session: nil, body: nil, flash: {}, format: nil, xhr: false, as: nil)
if @controller.is_a? WechatBaseController
params.merge!({app_alias: 'test'})
end
orig_process(action, method: method, params: params, session: session, body: body, flash: flash, format: format, xhr: xhr, as: as)
end
end

微信开发总结

最近开发了基于微信的相关项目,主要是微信的网页开发、微信支付以及发送消息这块的内容。项目本身没什么难度,但由于微信封闭的体系、混乱的配置以及分散的文档,爬了不少的坑,这里总结下经验。

文档

先说说微信的文档。微信的文档不是统一的,比如公众平台技术文档在一个网站,而支付文档在另一个网站,但支付文档所使用的 js 的相关文档又在前一个网站。除此之外,还有小程序文档开放平台文档。从这混乱的文档相信你也能或多或少体会到开发的难处。

配置

出于安全性(更多是封闭性)的考虑,微信的配置繁琐且复杂,这里我把整个流程都记录下来以供参考。
首先登录公众平台,在开发-基本配置里获得 AppSecret 并保存下来,之后网站就不会再显示开发者密码,只能重置。
同样在该页面,配置 IP 白名单。如果你需要接收用户公众号的消息以及事件推送,同一页面添加服务器配置。
然后,在设置-公众号设置-功能设置,按照说明对业务域名、JS 接口安全域名、网页授权域名进行设置。
如果有微信支付的需求,去微信商户平台,商户平台-产品中心-开发配置中设置公众号支付的授权目录。注意,授权目录必须是发起支付网址的上一级目录。举例来说,
发起支付的网址为 www.xxx.com/orders/22/pay,那么支付目录就必须为 www.xxx.com/orders/22/,填 www.xxx.com/ 或者 www.xxx.com/orders/ 都是不行的。再加上支付的授权目录只能填 5 个,发起支付的域名得注意设计。

PS: 没有公众号的朋友可以通过微信的测试号来试验除微信支付以外大部分的功能。

互联网创业核心技术:构建可伸缩的 Web 应用

《互联网创业核心技术:构建可伸缩的 Web 应用》整本书围绕“可伸缩”三个字,对 Web 应用的每一层展开了全面细致的解说。
如果你和我一样,对负载均衡、水平伸缩之类的概念不是很了解,或者不清楚它在整个架构中的使用,那么你应该来读下这本书。
读完之后你会发现架构这东西是有迹可循的,是围绕一系列基本的原则建立起来的。

一图胜千言,这本书不仅有着易懂的语言,而且有大量简洁的示意图。

FactoryGirl 源码浅析

FactoryGirl 是我个人十分喜欢的 Gem,它能很方便地模拟测试数据。使用技巧可见 FactoryGirl 技巧。出于兴趣我研究了下它的源代码,说实话比想象得要复杂。由于 FactoryGirl 配置的灵活性以及 Ruby 本身的语言特点,使得它代码整体上比较飘逸。
这里我会对它最基础的方法进行分析,版本为 4.8.0。

FactoryGirl.define

先从定义 Factory 开始。下面的代码定义了名为 user 的 Factory,它有一个名为 name 的属性,值为 Kay。

1
2
3
4
5
FactoryGirl.define do
factory :user do
name 'Kay'
end
end

正则表达式必知必会

匹配单个字符

匹配纯文本

正则:Ben

Hello, my name is Ben. Please visit my website at http://www.forta.com/.

匹配任意字符

. 匹配任意单个字符。在绝大多数的正则表达式实现里,. 只能匹配除换行符以外的任何单个字符。
正则:sales.

sales1.xls
orders3.xls
sales2.xls
sales3.xls
apac1.xls
europe2.xls
na1.xls
na2.xls
sa1.xls

Rails 多台服务器上 js 不一致的问题

发现问题

周二发布正式的时候,发生了匪夷所思的事情。
打开发布的网页,有一定的几率操作会没有反应。但在测试的时候却从没有这个问题发生。通过调试工具查看网页,发现一半的网页会加载 application-b1a 开头的文件,而另一半会加载 application-745 开头的文件。加载 applicaton-745 时会报 404,使得操作没有反应。

这里简单地说下发布的情况。该服务会发布到 A、B 两台服务器上,nginx 接收请求并转发到 A、B 上的实例。
查看正式服务器上的文件发现,A 上存在 application-b1a 文件,B 上存在 application-745 文件。看来 nginx 会查找 服务器 A 上的文件,由于找不到 application-745 而返回 404。先把 B 上的 applicaiton-b1a 复制到 A,临时修复这个问题。

排查原因

为什么两个服务器上生成的 js 文件名会不一致?我们先简单地回顾下 Asset Pipeline 生成 js 的过程。正式环境上, Asset Pipeline 会预编译文件,生成类似于 application-908e25f4bf641868d8683022a5b62f54.js 的文件。其中 908e 这串表示摘要,是根据文件的内容生成的 MD5,当 js 文件发生改变时生成的摘要也会发生变化。

1
2
3
4
5
// 引用 js
<%= javascript_include_tag "application" %>

// 生成的 html
<script src="/assets/application-908e25f4bf641868d8683022a5b62f54.js"></script>