作为程序员我们应该非常熟悉一个问题: 从输入网址到看到页面这个过程发生了什么? 相信大家对这个过程非常熟悉, 这篇文章我们针对www.banchengyun.com
这个域名, 着重讨论请求到达服务器后是怎么被处理并返回官网页面的
先来看一下整体的请求链路图
简单解释一下上图中几个方框的标注:
SLB: 由阿里云提供基于 ECS 的负载均衡服务, 请求到 SLB 后 SLB 会根据负载均衡算法将请求转发到指定的服务器组
网关(Traefik): 集群的入口, 请求会经过网关路由后转发到指定的 Pod 中
OSS: 所有前端资源(.js / .css / .html ...)都存放在 OSS 上
总结一下, www.banchengyun.com
域名指向 SLB, 请求到达 SLB 后 SLB 将请求转发到运行着网关的 ECS 的指定端口, 网关根据路由规则再把请求转发到集群内的 Nginx 服务, Nginx 反向代理指定的 OSS 文件, 这里的文件指的是index.html
文件, 其它的资源文件并不经由这个链路输出
下面贴出简单的网关和 Nginx 的配置文件以加深理解
复制 apiVersion : traefik.containo.us/v1alpha1
kind : IngressRoute
metadata :
name : www.banchengyun.com
spec :
routes :
- kind : Rule
# 匹配域名
match : Host(`www.banchengyun.com`)
services :
- kind : Service
# Nginx服务
name : nginx-proxy
port : 80
复制 server {
listen 80 ;
location / {
proxy_pass "https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/banchengyun/index.html" ;
}
}
到这里已经实现了完整的链路, 但是我们有多个域名指向不同的 index.html, 这个简单的配置不能满足我们的需求, 假设有如下的域名映射
yunying.banchengyun.com
-> https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/yunying/index.html
zhike.banchengyun.com
-> https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/zhike/index.html
要解决这个场景其实也很简单, 只需要加上对应的网关Rule
并给 Nginx 加一个 map 结构即可
复制 apiVersion : traefik.containo.us/v1alpha1
kind : IngressRoute
metadata :
name : yunying.banchengyun.com
spec :
routes :
- kind : Rule
match : Host(`yunying.banchengyun.com`)
services :
- kind : Service
name : nginx-proxy
port : 80
# zhike.banchengyun.com配置同上, 省略
复制 # 通过$host的值匹配map的元素并赋值给$oss_target变量
map $host $oss_target {
"yunying.banchengyun.com" "https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/yunying/index.html" ;
"zhike.banchengyun.com" "https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/zhike/index.html" ;
default "" ;
}
server {
listen 80 ;
location / {
if ( $oss_target = "" ) {
return 404 ;
}
proxy_pass $oss_target;
}
}
到这里我们解决了多域名映射的问题, 但是每次新加域名都要修改 Nginx 配置并 reload, 并且与我们所使用的基于 Helm Chart 的部署方案理念不太一致, 每个项目都是一个 Helm Chart, 项目下的不同环境(prod/dev...)都是基于同一个 Chart 的 Release, 我们需要把属于一个项目的所有配置都集中存放到一个 Chart 中, 这样才可以快速复制环境和确保各个环境的配置一致, 简而言之域名和 OSS 文件的映射关系不能统一放置在公共的 Nginx 配置文件中
除了在 Nginx 写映射还有什么办法能让 Nginx 知道这个域名应该指向哪个 OSS 文件呢? 我们把看回链路图, 网关是知道请求的域名的, 那我们有没有办法让网关告诉 Nginx 这个请求需要映射那个 OSS 文件呢? Traefik 给我们提供了一个中间件, 允许我们修改请求头后转发到后端服务, 那我们可以通过配置中间件的方法, 让网关告诉 Nginx 需要映射哪个 OSS 文件
复制 apiVersion : traefik.containo.us/v1alpha1
kind : Middleware
metadata :
name : add-headers
spec :
headers :
customRequestHeaders :
# 添加请求头
X-Proxy-OSS-Filename : 'https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/zhike/index.html'
---
apiVersion : traefik.containo.us/v1alpha1
kind : IngressRoute
metadata :
name : www.banchengyun.com
spec :
routes :
- kind : Rule
match : Host(`www.banchengyun.com`)
middlewares :
- name : add-headers
services :
- kind : Service
name : nginx-proxy
port : 80
这样请求转发到nginx-proxy
的时候就会带上一个X-Proxy-OSS-Filename: https://bcy-web-static.oss-cn-hangzhou.aliyuncs.com/zhike/index.html
的请求头, Nginx 就比较好办了
复制 server {
listen 80 ;
location / {
proxy_pass $http_x_proxy_oss_filename;
}
}
这样 Nginx 就变成一个无状态的实例, 添加域名映射只需要编写对应的Middleware
和IngressRoute
即可, 无需修改 Nginx 配置和 reload
到这里已经解决了所有的问题但是还有优化空间, index.html
是随着前端项目构建更新的, 这个频率其实不高, 我们可以利用 Nginx 的代理缓存功能缓存index.html
, 这样在缓存有效期内 Nginx 都不会去请求 OSS 而是直接读取缓存
复制 # 创建一个10m大小的osscache的缓存区
proxy_cache_path /var/run/osscache keys_zone=osscache: 10m ;
server {
listen 80 ;
location / {
# 指定使用osscache缓存区
proxy_cache osscache;
# 使用X-Proxy-OSS-Filename头作为缓存标识
proxy_cache_key $http_x_proxy_oss_filename;
# 缓存有效期设置为1分钟
proxy_cache_valid any 1m ;
proxy_pass $http_x_proxy_oss_filename;
}
}
Nginx 使用磁盘作为缓存介质, 我们可以用给 Nginx 服务挂载一个基于内存的文件系统让 Nginx 读取缓存可以更快一些
复制 apiVersion : apps/v1
kind : Deployment
metadata :
name : nginx-proxy
labels :
app : nginx-proxy
spec :
replicas : 4
template :
metadata :
name : nginx-proxy
labels :
app : nginx-proxy
spec :
volumes :
# 声明一个Memory介质的emptyDir卷
- name : osscache
emptyDir :
medium : Memory
containers :
- name : nginx-proxy
image : nginx
imagePullPolicy : IfNotPresent
volumeMounts :
# 挂载Memory介质的emptyDir到/var/run/osscache目录
- mountPath : /var/run/osscache
name : osscache
restartPolicy : Always
selector :
matchLabels :
app : nginx-proxy
Ok 了吗? 我们再来思考一个场景, 如果某个同学不小心推了一个有 BUG 的版本, 在 OSS 上index.html
是一个软连接, 我们可以马上将index.html
替换为上一个版本, 但是这时候 Nginx 缓存的还是旧版本, 最坏的情况下需要等待 1 分钟才能恢复, 我们需要设计一个刷新的功能以便在出现问题的时候快速刷新缓存
从proxy_cache_key $http_x_proxy_oss_filename;
配置可以看出, 缓存是以 X-Proxy-OSS-Filename 头作为缓存标识的, 只要修改一下这个变量的值就可以使缓存失效, 不幸的是这个头是写死在Middleware
配置中的, 我们需要一种更便捷的方案, 假如我们要刷新www.banchengyun.com
这个域名, 我们只需要访问www.banchengyun.com?__refresh__=1
方案敲定下面就开始实现, 由于 Nginx 不具备太多的动态功能所以我们需要使用 openresty, openresty = Nginx + Lua, 可以使用 Lua 语言控制和影响 Nginx 的行为
假定我们给每个 OSS 文件都设置一个版本号, 每次刷新都给版本号+1, 然后拼接到 X-Proxy-OSS-Filename 头所指定的 URL 后面, 然后再传递给proxy_pass
和proxy_cache_key
指令, 这样就可以实现刷新的功能了
复制 # 声明一个lua共享dict
lua_shared_dict osstarget_versi on 1m ;
server {
listen 80 ;
location / {
# 判断__refresh__参数是否为空
if ( $arg___refresh__ != "" ) {
# 调用lua代码
content_by_lua_block {
app. refresh ();
}
break ;
}
# 使用lua代码设置变量$oss_target
set_by_lua_block $oss_target { return app.getOssTarget(); }
# 指定使用osscache缓存区
proxy_cache osscache;
# 使用X-Proxy-OSS-Filename头作为缓存标识
proxy_cache_key $oss_target;
# 缓存有效期设置为1分钟
proxy_cache_valid any 1m ;
proxy_pass $oss_target;
}
}
上面是简单的 openresty Nginx 的配置, 下面我们来编写 Lua
复制 local _G = {}
_G . refresh = function ()
-- 读取X-Proxy-OSS-Filename头
local target = ngx.var.http_x_proxy_oss_filename
-- 在共享dict中写入一个key为$target值为当前时间戳的数据作为版本号
ngx.shared.osstarget_version: set (target, ngx. now ())
end
_G . getOssTarget = function ()
local target = ngx.var.http_x_proxy_oss_filename
-- 从共享dict读取$target
local v, _ = ngx.shared.osstarget_version: get (target)
if not v then
v = ngx. now ()
ngx.shared.osstarget_version: set (v, ngx. now ())
end
-- 把版本号作为参数v添加到URL中
return target ... "?v=" ... v
end
return _G
到这里就实现了刷新缓存的功能, 当访问www.banchengyun.com?__refresh__=1
时, refresh
方法将当前时间戳作为版本号写在 shared_dict 中, getOssTarget
读取版本号拼接到 URL 中, 刷新后 URL 就不一样了缓存也就失效了
Ok 了吗? 上面nginx-proxy
的服务有replicas: 4
字段说明这个服务是有 4 个实例, 刷新请求只会刷新其中一个实例, 我们需要让每个实例刷新的时候都顺便刷新其它实例, 这很简单只需要逐个请求并带上refresh =1 参数即可, 需要解决的问题是, 怎么让一个实例知道其它实例的访问地址呢? k8s 为我们提供了一种headless
的 Service 服务, 正常情况下我们通过 DNS 查询一个域名 DNS 会返回一个 A 记录指向一个 IP 地址
复制 bash-5.1# nslookup nginx-proxy.default.svc.cluster.local
Server: 172.21.0.10
Address: 172.21.0.10:53
Name: nginx-proxy.default.svc.cluster.local
Address: 172.21.7.215
那我们查询一个headless
Service 会怎样呢?
复制 bash-5.1# nslookup nginx-proxy-headless.default.svc.cluster.local
Server: 172.21.0.10
Address: 172.21.0.10:53
Name: nginx-proxy-headless.default.svc.cluster.local
Address: 172.20.7.135
Name: nginx-proxy-headless.default.svc.cluster.local
Address: 172.20.1.106
可以看到nginx-proxy-headless.default.svc.cluster.local
返回了多个记录, 每一个记录都是一个 Nginx 实例的地址, 方案确定就可以干活了
复制 -- headless Service地址
local headlessSvc = "nginx-proxy-headless.default.svc.cluster.local"
-- 实例自己的IP, 避免循环调用
local selfIp = "1.1.1.1"
-- 集群DNS服务的IP
local clusterDns = "2.2.2.2"
function dispatchRefresh ( target )
-- 如果是被动刷新则不派发刷新请求
if ngx.var.arg_no_dispatch then
return
end
-- 通过集群DNS查询headless Service
local r, err = resolver: new ({
nameservers = { _clusterDns },
retrans = 5 ,
timeout = 2000
})
if err then
ngx. log (ngx.STDERR, err)
return
end
local answers, err, _ = r: query (headlessSvc)
if err then
ngx. log (ngx.ERR, "dispatchWebProxyRefresh: query error: " , err)
return
end
if answers.errcode then
ngx. log (ngx.ERR, "dispatchWebProxyRefresh: query error: " , answers.errstr)
return
end
-- 遍历answers
for _, ans in ipairs (answers) do
local addr = ans.address
-- 判断是否是自己的IP
if addr ~= selfIP then
-- 调用/refresh这个location并传入参数
local res = ngx. local . capture ( "/refresh" , {
args = fmt ( "host=%s&target=%s" , addr, target)
})
ngx. log (ngx.INFO, fmt ( "dispatchWebProxyRefresh: request %s %s" , addr, res.body))
end
end
end
这里着重分析 40~42 行的内容, 可以看到在遍历得到其它实例的 IP 后, 调用了/refresh
这个 location 并传入了实例 IP 和 target 参数, 这里是利用 Nginx 的反向代理功能间接实现请求其它实例的功能, 下面来看看/refresh
复制 location = /refresh {
# 只允许内部调用
internal ;
# 从参数里获取target并设置为X-Proxy-OSS-Filename头的值
proxy_set_header X-Proxy-OSS-Filename $arg_target;
# 从参数获取实例IP(host), 拼接__refresh__=1参数触发刷新, no_dispatch=1避免再次派发刷新请求
proxy_pass "http://$arg_host?__refresh__=1&no_dispatch=1" ;
}
总结一下, dispatchRefresh
函数通过集群 DNS 查询得到所有实例的 IP 地址, 然后通过/refresh
块逐个请求, /refresh
块从capture
方法传递过来的args
中分别获取target
OSS 文件地址和host
实例 IP 地址, 然后通过proxy_pass
进行反向代理间接发起请求
以上就是整个链路的分析, 感兴趣的小伙伴可以查看完整代码