Featured image of post 基于 Kubernetes 和 Webhook 的证书自动化处理方案

基于 Kubernetes 和 Webhook 的证书自动化处理方案

现在的SSL证书,通过ACME申请的有效期仅3个月,而很多地方在证书变化后要重新刷新。比如某些服务不会自动reload,某些平台托管的证书也要更新,有没有更简单的方案呢,本文给了一个解决方案。

背景

其实这个需求很早就有,我家内部网络有个反代服务,它不支持热加载证书,每每证书过期即使cert-manager给续期了,我仍然要记得手动重启一下对应的服务。三个月一次,我忍受了。但在最近将博客的图床切换到使用腾讯云对象存储COS,同时也使用上其CDN加速,便需要托管博客相关证书到腾讯云,这再一次考验我的忍受力。不行了,此事不能再忍啦!

需求分析与方案思考

因为我的服务是部署在kubernetes集群内的,证书是通过cert-manager来申请或续期的,证书当申请成功后,在集群中会以secret的形式保存,故我应该在证书有变化时,触发两种行为:

  • 更新/重启某个deployment
  • 触发一些自定义行为

这其中,更新重启某个deployment是比较简单的可以通过k8s api实现。而触发自定义行为,比如上传证书到腾讯云这种比较特殊的行为如果直接集成到方案中会有点紧耦合,之前我们已经有一个工具Webhook,何不提供一种触发外部webhook的能力,只要将secret相关信息传递给webhook,特例的行为由webhook的执行单元来实现是更轻便的方案。

方案调研

在网上搜索了一下可能的方案,acme.sh是提供了renewal-hook的,倒是可以直接集成类似的功能,可惜我用的cert-manager并没有,有一个issue讨论过此问题,但似乎没有给出官方解决方案。

有一个开源的weaveworks/Watch,提供了对应文件或目录,并执行命令。这方便我们发起webhook,但是集群内资源的操作(重启)等缺失内置能力。 还有另一个方案reourcewatcher是Java写的,第一部分功能具备,但没有webhook能力,使用者可能不多,文档较少。

想着比较简单,何不动手写一个?

我的方案

资源变化感知之secret-watcher

我们只需要200行左右代码就可以实现这两个功能,代码放在了kevin1sMe/secret-watcher。顺便提供了镜像方便使用,大小只有12MB。 实现上只需要借助k8s的EventHandler机制,在有secret add或update时,做我们想要的动作(Action)。

 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
34
35
36
	// 创建Clientset
	clientset, err := kubernetes.NewForConfig(config)
	if err != nil {
		panic(err.Error())
	}

	// 创建Informer工厂并指定命名空间
	factory := informers.NewSharedInformerFactoryWithOptions(clientset, time.Hour*12, informers.WithNamespace(secretNS))
	secretInformer := factory.Core().V1().Secrets().Informer()

	// 添加事件处理程序
	secretInformer.AddEventHandler(cache.FilteringResourceEventHandler{
		FilterFunc: func(obj interface{}) bool {
			// 转换对象为 Secret 类型
			secret, ok := obj.(*corev1.Secret)
			if !ok {
				// 如果转换失败,返回 false 表示不处理该事件
				return false
			}
			// 如果 Secret 名称匹配,返回 true 表示处理该事件
			return secret.Name == secretName
		},
		Handler: cache.ResourceEventHandlerFuncs{
			AddFunc: func(obj interface{}) {
				log.Info().Msgf("Secret [%s] added", secretName)
				Handle(clientset, yconfig.Actions, obj.(*corev1.Secret))
			},
			UpdateFunc: func(oldObj, newObj interface{}) {
				log.Info().Msgf("Secret [%s] update", secretName)
				Handle(clientset, yconfig.Actions, newObj.(*corev1.Secret))
			},
			DeleteFunc: func(obj interface{}) {
				log.Warn().Msgf("Secret [%s] deleted", secretName)
			},
		},
	})

我们想要的就是Restart Deployment或Webhook,具体实现见代码。然后一个简单配置即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
watch:
  name: "my-secret"
  namespace: "default"

actions:
  - name: "restart deploy"
    strategy: "RestartDeploy"
    selector:
      namespace: "default"
      labels: "app=nginx"

  - name: "webhook for upload secret"
    strategy: "Webhook"
    url: "https://webhook"
    header: "Authorization: Bearer your-key-here"

这里有个问题,这个webhook服务哪里提供比较好?

证书更新之Webhook

我之前有一篇文章提到过如何用Webhook进行服务集成,有这个经验后,只要简单配置即可处理上面的回调。比如:

 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
34
35
36
37
38
    {
        "id": "upload-cert-to-tencentcloud",
        "execute-command": "/etc/webhook/upload-cert-to-tencentcloud.sh",
        "command-working-directory": "/etc/webhook",
        "response-message": "I got the request",
        "response-headers": [
            {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
            }
        ],
        "pass-arguments-to-command": [
            {
                "source": "payload",
                "name": "tls.crt",
                "base64decode": true 
            },
            {
                "source": "payload",
                "name": "tls.key",
                "base64decode": true 
            }
        ],
        "trigger-rule": {
            "and": [
                {
                    "match": {
                        "type": "value",
                        "value": "Bearer your-key-xxx",
                        "parameter": {
                            "source": "header",
                            "name": "Authorization"
                        }
                    }
                }
            ]
        }
    }

上面会解析对应的证书信息,并且传递参数给脚本,然后我写可以直接写个shell脚本简单处理。不过对于腾讯云的证书更新,最简单的使用方式或是基于它的命令行工具tccli来解决。而我们默认的webhook镜像是没有集成这个工具的,所以还需要自己在原基础镜像上打一个集成tccli的镜像:

1
2
3
FROM soulteary/webhook:extend-3.6.0
RUN apt-get update && apt-get install -y mysql-client
RUN pip3 install tccli

然后,我们可以用脚本愉快的使用了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/sh

crt=$1
key=$2

echo $crt | base64 -d >/tmp/cert.pem
echo $key | base64 -d >/tmp/key.pem

# 上传证书(重复会返回之前上传的ID)
resp=$(tccli ssl UploadCertificate --cli-unfold-argument \
	--CertificatePublicKey "$(cat /tmp/cert.pem)" \
	--CertificatePrivateKey "$(cat /tmp/key.pem)" \
	--Repeatable False)
echo "resp: $resp"

# 获取证书ID
cert_id=$(echo $resp | egrep -o '"CertificateId": "[^"]+"' | cut -d'"' -f4)
echo "cert_id: $cert_id"

# 修改CDN加速域名配置
tccli cdn ModifyDomainConfig --cli-unfold-argument --Domain img.gameapp.club --Route 'Https.CertInfo.CertId' --Value "{\"update\":\"$cert_id\"}"

echo "update cdn domain img.gameapp.club cert_id to $cert_id"

最终,只要集群中证书被续期或刷新,就会重启我们的目标服务,同时发起webhook调用,最后将我们在腾讯云上对象的证书刷新,避免了所有手动操作。 然而这个方法还是不太舒服,webhook镜像需要随着业务变化而塞些内容进去总是让我觉得不够优雅。

证书更新之Github workflow

我想起曾经在Github action中有插件很方便使用tccli命令等,也方便一些密钥管理的复用,所以我们是否能通过调用Github Action来实现上面脚本中的一堆逻辑呢?这样我们的webhook脚本可以很干净,也不用再自己重新定制镜像了呢! 并且Github支持远程触发对应的workflow,可查看workflow_dispatch。起初我担心证书因为过长,是否能支持input达几千字符的,看了一下文档终于放心下来:

1
2
3
* 工作流还将接收 github.event.inputs 上下文中的输入。 inputs 上下文和 github.event.inputs 上下文中的信息完全相同,但 inputs 上下文将布尔值保留为布尔值,而不是将它们转换为字符串。 choice 类型解析为字符串,是单个可选选项。
* inputs 的顶级属性的最大数目为 10。
* inputs 的最大有效负载为 65,535 个字符。

我们可以很简单的写一个workflow:

 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
name: Update SSL cert to TencentCloud

on:
  workflow_dispatch:
    inputs:
      CertPem:
        description: "pem证书"
        required: true
        default: ""
      CertKey:
        description: "key证书"
        required: true
        default: ""
jobs:
  deploy:
    runs-on: ubuntu-latest
    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
    container:
      image: tencentcom/tencentcloud-cli
    steps:
      - name: Set environment variable from input
        run: |
          echo "Pem=${{ github.event.inputs.CertPem }}" >> $GITHUB_ENV
          echo "Key=${{ github.event.inputs.CertKey }}" >> $GITHUB_ENV          
      - name: Setup tccli
        run: |
          tccli configure set secretId ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
          tccli configure set secretKey ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
          tccli configure set output json          
      - name: update cert to tencentcloud
        run: |
          echo "Pem length: ${#Pem}"
          echo "Key length: ${#Key}"
          decodePem=`echo $Pem | base64 -d`
          decodeKey=`echo $Key | base64 -d`
          resp=$(tccli ssl UploadCertificate --cli-unfold-argument \
            --CertificatePublicKey "$decodePem" \
            --CertificatePrivateKey "$decodeKey" \
            --Repeatable False)
          echo "resp: $resp"
          # 获取证书ID
          cert_id=$(echo $resp | egrep -o '"CertificateId": "[^"]+"' | cut -d'"' -f4)
          echo "cert_id: $cert_id"
          # 修改CDN加速域名配置
          tccli cdn ModifyDomainConfig --cli-unfold-argument --Domain img.gameapp.club --Route  'Https.CertInfo.CertId' --Value "{\"update\":\"$cert_id\"}"
          echo "update cdn domain img.gameapp.club cert_id to $cert_id"

          # 获取一些证书信息
          echo "parse subject & expire"
          subject=$(echo $Pem | base64 -d  | openssl  x509 -subject -noout)
          date=$(echo $Pem | base64 -d | openssl  x509 -dates -noout)
          echo "Subject=$subject" >> $GITHUB_ENV
          echo "Date=$(echo $date | sed 's/\n/<br>/g')" >> $GITHUB_ENV          
      - name: pushover-actions
        uses: umahmood/pushover-actions@main
        env:
          PUSHOVER_TOKEN: ${{ secrets.PUSHOVER_TOKEN }}
          PUSHOVER_USER: ${{ secrets.PUSHOVER_USER }}
        with:
          title: "上传证书到腾讯云成功"
          status: ${{ job.status }}
          message: "${{ env.Subject }} \n\n${{ env.Date }}"

我们现在很容易借助于action的能力,集成各种第三方功能而不必修改镜像了。上面顺便还把pushover通知也做上了呢? 现在我们原本webhook的地方就可简洁了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/sh

crt=$1
key=$2
echo "crt length: ${#crt}"
echo "key length: ${#key}"

# 尝试触发一下github workflow
GHP="github_pat_xxx-your-github-token"
curl -X POST \
	-H "Accept: application/vnd.github.v3+json" \
	-H "Authorization: token $GHP" \
	https://api.github.com/repos/kevin1sMe/secret-watcher/actions/workflows/update-tencent-cert.yml/dispatches \
	-d "{\"ref\":\"main\", \"inputs\":{\"CertPem\":\"${crt}\", \"CertKey\":\"${key}\"}}"

见最后更新结果: update-cert-pushover

后记

经过一番折腾,我们把证书更新的问题基本解决了。这过程中借助于webhook的能力,我们找到了耦合度较低的解决方案。我们最后还利用了Github的Action简化了部分工作。当我心满意足时,再搜索了一下Github的仓库看类似功能,有一个开源方案stakater/Reloader映入我的眼前,似乎我们前面做的它都有了?黑人问号!

不光如此,它功能好像还有点强大,我只能借口说:“它搞得好像有点复杂,我这个还是更简单。”。好的,我们是学知识的,就此打住:)

EOF

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:

扫码关注公众号