Contents

Kubernetes nginx ingress cache 实践

最近项目在对部署在香港节点的网站服务做资源加载提速,本文主要记录了在kubernetes的原生nginx ingress中引入server cache的流程和方案, 里面涉及的都是最为常见的技术,并没有什么新的东西,算是一个总结和记录。

背景和前提

当前已上线的网站都是基于Nginx Ingress + Hugo/Vue Server的模式部署的,发布采用 ArgoCD(GitOps) + Jenkins ,大致的流程如下: /images/website-pipeline.png

准备

Nginx做Server端的缓存目前有2种方式比较常见:

  1. 基于外置的中间件做数据缓存,比如Nginx官方Redis Module给出的部署介绍, 这种方式的好处就是: 缓存集中,有利于命中和清理,同时不会增加nginx本身的业务复杂度,但他也有问题,就是会引入部署的成本。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
server {
    location / {
        set $redis_key $uri;

        redis_pass     name:6379;
        default_type   text/html;
        error_page     404 = /fallback;
    }

    location = /fallback {
        proxy_pass backend;
    }
}
  1. 基于Nginx本身的proxy_cache实现,这个是官方很早就引入的功能,直接利用本地磁盘做缓存存储,考虑到网站本身的规模和系统复杂度, 我们选择了nginx自带的proxy_cache加kubernetes中的memory emptyDir做服务端缓存。

环境准备

Nginx本身已经包含了proxy_cache的模块,不过关于purge的功能却仅在商业版本中才包含,所以我们需要自己引入并制作镜像,下面是社区推荐的第三方 的purge模块:

https://github.com/FRiCKLE/ngx_cache_purge

很遗憾他并不支持批量的缓存清除,从README里面的配置指导可以看出,我们需要对缓存的资源进行逐个清除:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
http {
    proxy_cache_path  /tmp/cache  keys_zone=tmpcache:10m;

    server {
        location / {
            proxy_pass         http://127.0.0.1:8000;
            proxy_cache        tmpcache;
            proxy_cache_key    $uri$is_args$args;
        }

        location ~ /purge(/.*) {
            allow              127.0.0.1;
            deny               all;
            proxy_cache_purge  tmpcache $1$is_args$args;
        }
    }
}

而且,这个仓库已经6年没人维护了,好在最近nginx社区自己fork并维护起来了地址, 最关键的是作者还加入了我们正想要的Partial Keys功能 :) 。

Nginx ingress关于怎么制作自定义镜像的指导比较少,不过从Makefile里面能快速搜索到他们的Base Image 而且制作镜像的流程非常清晰,基于现有的流程做扩展和第三方Module引入还是比较方便的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
现有引入的Module:
nginx-http-auth-digest
ngx_http_substitutions_filter_module
nginx-opentracing
opentracing-cpp
zipkin-cpp-opentracing
dd-opentracing-cpp
ModSecurity-nginx (only supported in x86_64)
brotli
geoip2

完整的改动已经放到了我们组织的fork仓库, 需要注意的一个点就是nginx ingress官方为了支持多架构和性能,在构建镜像时引入了buildx组件, 他跟我们原生的docker build有一点区别是他并不会默认导出制作好的镜像,需要我们在命令中具体指明,如:

1
2
#具体文档,请移步: https://github.com/docker/buildx
docker buildx build --output=type=docker .

有了基础镜像,在制作controller的镜像时,只需要将BASE_IMAGE替换为我们生成好的镜像即可, 如:

1
BASE_IMAGE=opensourceways/ingress-nginx-with-purge:0.0.1 make image

有了镜像,可以在镜像中通过nginx -V确认模块以加载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
➜  ~ docker run -it --rm  opensourceway/ingress-nginx-controller-with-purge-cache:v0.35.0.0 nginx -V
nginx version: nginx/1.19.2
built by gcc 9.3.0 (Alpine 9.3.0)
built with OpenSSL 1.1.1g  21 Apr 2020
TLS SNI support enabled
<content skipped>
--add-module=/tmp/build/ngx_devel_kit-0.3.1 
--add-module=/tmp/build/set-misc-nginx-module-0.32 
--add-module=/tmp/build/headers-more-nginx-module-0.33 
--add-module=/tmp/build/nginx-http-auth-digest-cd8641886c873cf543255aeda20d23e4cd603d05 
--add-module=/tmp/build/ngx_http_substitutions_filter_module-bc58cb11844bc42735bbaef7085ea86ace46d05b 
--add-module=/tmp/build/lua-nginx-module-0.10.17 --add-module=/tmp/build/stream-lua-nginx-module-0.0.8 
--add-module=/tmp/build/lua-upstream-nginx-module-0.07 
--add-module=/tmp/build/nginx-influxdb-module-5b09391cb7b9a889687c0aa67964c06a2d933e8b 
--add-dynamic-module=/tmp/build/nginx-opentracing-0.9.0/opentracing 
--add-dynamic-module=/tmp/build/ModSecurity-nginx-b55a5778c539529ae1aa10ca49413771d52bb62e 
--add-dynamic-module=/tmp/build/ngx_http_geoip2_module-3.3 
--add-module=/tmp/build/nginx_ajp_module-bf6cd93f2098b59260de8d494f0f4b1f11a84627 
--add-module=/tmp/build/ngx_brotli --add-module=/tmp/build/ngx_cache_purge-2.5.1

有了镜像,下一步就是配置了。

主要配置

  1. proxy_cache_path:
1
2
3
Syntax:	proxy_cache_path path [levels=levels] [use_temp_path=on|off] keys_zone=name:size [inactive=time] [max_size=size] [min_free=size] [manager_files=number] [manager_sleep=time] [manager_threshold=time] [loader_files=number] [loader_sleep=time] [loader_threshold=time] [purger=on|off] [purger_files=number] [purger_sleep=time] [purger_threshold=time];
Default:	—
Context:	http

这个是cache的全局配置,其中几个比较重要的字段介绍如下:

  • path: 缓存的配置路径
  • keys_zone:<name:size>: 缓存的key区域,当开启某个domain的访问缓存时,使用proxy_cache指定对应的key_zone, 注意缓存的key是存储在内存中的,所以需要指定存储的上限,根据官方文档,1mb基本可以存储8k个key。
  • use_temp_path:是否启用temp目录用于上游返回内容的保存,nginx的cache在开启temp_path的时候会有2步,保存结果到temp目录,拷贝到cache目录,建议关闭。
  • inactive: 缓存的保留时间retention, 如果超过时间资源没有被访问,缓存将被清除。
  • max_size: 缓存数据的最大占用空间,超出后空间将被清理(基于最近最少使用原则),
  • levels + proxy_cache_key: 这2个字段配合起来,决定了我们缓存存储的路径,其中level决定了存储的目录层级以及目录名,cache_key决定了最终的文件名,举个例子,比如我的配置如下:
1
2
proxy_cache_key $uri$is_args$args;
proxy_cache_path /tmp/statics_cache levels=1:2 keys_zone=statics_cache:50m use_temp_path=off max_size=500m inactive=1h;

当我们访问http://somedomain.com/public/img/grafana_icon.svg的时候,对proxy_cache_key(/img/grafana_icon.svg)进行MD5计算,得:

1
2
➜  ~ md5 -s "/public/img/grafana_icon.svg"
MD5 ("/public/img/grafana_icon.svg") = e07fe02651e785ebf74c2c5c0abae094

那我们最终的存储路径就是:/tmp/statics_cache/4/09/e07fe02651e785ebf74c2c5c0abae094, 其中目录信息反向截图。

  1. proxy_cache_lock: cache锁,在多个请求访问同样资源,且开启cache的情况下,只有一个请求有权限去生成cache或访问cache。
  2. proxy_cache_valid: cache的有效时间,超期的缓存将从上游重新获取, 我们可以基于返回结果做差异化配置,比如proxy_cache_valid 404 1m;设置404近保留1分钟。
  3. proxy_ignore_headers:忽略响应中的某些header,比如: proxy_ignore_headers Cache-Control就能忽略上游的缓存策略,避免对我们的server cache造成影响。
  4. proxy_cache_methods: 缓存的http方法,默认会包含GET和HEAD。
  5. proxy_cache_min_uses: 设置资源访问多少次后再缓存,提高阈值能确保高频率的资源被缓存和使用。 基于上面的配置,我们就能在nginx-ingress中configmap中引入http-snippet, 修改deployment确保emptyDir挂载:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: v1
data:
  http-snippet: |
    proxy_cache_path /tmp/statics_cache levels=1:2 keys_zone=statics_cache:50m use_temp_path=off max_size=500m inactive=1h;    
kind: ConfigMap
metadata:
  name: nginx-configuration
  namespace: ingress-nginx
# other attribute in deployment are ignored.
volumes:
- name: nginx-proxy-cache
  emptyDir:
    medium: Memory
    sizeLimit: "500Mi"

启用cache

当需要网站的server cache时,需要在具体的Ingress中做如下配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
nginx.ingress.kubernetes.io/proxy-buffering: "on"
nginx.ingress.kubernetes.io/server-snippet: |
  proxy_cache statics_cache;
  proxy_cache_lock on;
  proxy_cache_key $server_name$uri$is_args$args;
  proxy_ignore_headers Cache-Control;
  proxy_ignore_headers Set-Cookie;
  proxy_cache_valid 60m;
  add_header X-Cache-Status $upstream_cache_status;
  location ~ /purge(/.*) {
      allow 127.0.0.1;
      allow 172.20.0.0/16;      
      deny all;
      proxy_cache_purge  statics_cache $server_name$1$is_args$args;
  }

说明如下:

  1. proxy_buffering: 这个是开启缓存的前提,nginx ingress默认是关闭的。
  2. add_header: 在返回的Header中添加cache status (MISS,HIT,EXPIRED),用于查看缓存命中情况,如果我们在location server等多处有定义add_header, 需要注意的一点是:
1
2
# from nginx document
There could be several add_header directives. These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.
  1. proxy_cache_key: 在nginx ingress中我们一般会服务多个domain,所以这里的key需要引入server_name避免相互干扰。

开启Purge

首先并不是所有的情况下都需要purge,毕竟现在有很多解决方案可以确保版本更新后缓存自动失效. 比如在生成资源时对文件名做hash:

1
zh-cn_image_0183048952.d04e2e5f.png

或者是在请求中带上发布版本的版本号参数:

1
/abc.css?v=20201012

这些都会导致缓存的key失效,从而避免干扰,不过我们并不能保证所有的情况下都能满足,所以需要支持通过API调用清理失效的缓存,purge模块支持我们配置单独的API用于网站缓存资源清理(前缀匹配):

1
2
3
4
5
6
location ~ /purge(/.*) {
  allow 127.0.0.1
  allow 172.20.0.0/16;
  deny all;
  proxy_cache_purge  statics_cache $server_name$1$is_args$args;
}

上面的配置段需要同样添加到server-snippet中,且仅支持内网访问,注意proxy_cache_purge的key需要跟proxy_cache_key保持一致。 我们通过API测试,结果是some-domain的缓存资源都会被清除:

1
$: curl  -H "Host:some-domain" -k  https://<nginx-endpoint>/purge/*
1
2
3
4
<html>
    <head><title>Successful purge</title></head>
    <body bgcolor="white"><center><h1>Successful purge</h1><p>Key : some-domain/*</p></center></body>
</html>

不过,我们还有一个问题: nginx ingress都是多实例的,清理单个实例还不够,当前原生的Kubernetes中并没有类似的概念可以支持我们一次性清理,只能考虑将purge的请求复制多份到endpoints分别处理, 大致的逻辑如下Stackoverflow:

1
2
3
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token);
curl https://kubernetes.default.svc/api/v1/namespaces/default/endpoints --silent \
--header "Authorization: Bearer $TOKEN" --insecure | jq -rM ".items[].subsets[].addresses[].ip" | xargs curl

我们搭建了一个Basic Auth的Http Server用于响应流水线中的清除缓存动作。 有了他我们就差最后一步,跟发布的流水线对接, 前文有提到我们的发布流程是基于Jenkins + ArgoCD实现的, 考虑到ArgoCD本身就有Webhook机制,所以我们的应用的部署代码仓库需要新增如下资源:

触发Purge

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: batch/v1
kind: Job
metadata:
  name: website-nginx-purge
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  ttlSecondsAfterFinished: 600
  template:
    spec:
      containers:
        - name: purge-trigger
          image: curlimages/curl:7.72.0
          env:
            - name: USERNAME
              valueFrom:
                secretKeyRef:
                  key: username
                  name: purge-secrets
            - name: PASSWORD
              valueFrom:
                secretKeyRef:
                  key: password
                  name: purge-secrets
          command:
            - curl
            - -u
            - $(USERNAME):$(PASSWORD)
            - --fail
            - https://<api-endpoints>/nginx-purger/purge?hostname=<hostname-to-purge>
      restartPolicy: Never
  backoffLimit: 2

这样一旦我们更新网站,ArgoCD的Sync行为结束,nginx ingress中的cache就会被自动清除:

1
2
3
nginx cache purged for host: some-domain on ingress instance 172.20.0.103 
nginx cache purged for host: some-domain on ingress instance 172.20.0.144
nginx cache purged for host: some-domain on ingress instance 172.20.0.56

需要注意的是:

  1. argocd.argoproj.io/hook-delete-policy: BeforeHookCreation: 能确保每次synced后Job资源删除后执行。
  2. argo的application不能设置为自动Sync,这样会导致缓存被频繁清除。

全文完。