SSL: автоматичне оновлення

Kubernetes — сертифікат у TLS-секреті оновлюється сам

CronJob раз на добу забирає свіжий сертифікат і оновлює TLS-секрет, з яким працює ingress; ключ лишається в тому самому секреті.

Оновлюється сам

Підходить, якщо сайт працює в Kubernetes за ingress (ingress-nginx, Traefik, HAProxy Ingress тощо) і сертифікат лежить у секреті типу kubernetes.io/tls. Ingress-контролери стежать за секретами самі: оновили секрет — за хвилину-дві сайт віддає новий сертифікат.

Як це працює: CronJob раз на добу забирає сертифікат за посиланням, порівнює з тим, що в секреті, перевіряє, що він підходить до ключа з того самого секрету, і замінює лише tls.crt. Ключ нікуди не копіюється. Права задачі — читати й змінювати один секрет в одному namespace.

Що знадобиться

  • Посилання на сертифікат із кабінету: https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН.
  • kubectl з доступом до namespace сайту.
  • Вихід із кластера в інтернет (до api.zrazu.com і до дзеркал Alpine Linux — образ ставить curl, openssl і jq при кожному запуску).

Крок 1. TLS-секрет (якщо його ще немає)

Завантажте https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН/fullchain.pem у файл fullchain.pem і створіть секрет зі своїм ключем:

curl -fsS -o fullchain.pem "https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН/fullchain.pem"
kubectl -n НАЗВА_NAMESPACE create secret tls zrazu-tls --cert=fullchain.pem --key=key.pem

В Ingress сайту вкажіть цей секрет: spec.tls[].secretName: zrazu-tls.

Крок 2. CronJob

Збережіть текст нижче у файл zrazu-ssl.yaml. Якщо секрет у вас називається інакше, замініть zrazu-tls (він трапляється двічі).

# ZRAZU: оновлення сертифіката в TLS-секреті за особистим посиланням. Запускається раз на добу.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: zrazu-ssl
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: zrazu-ssl
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["zrazu-tls"]
    verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: zrazu-ssl
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: zrazu-ssl
subjects:
  - kind: ServiceAccount
    name: zrazu-ssl
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: zrazu-ssl
spec:
  schedule: "17 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          serviceAccountName: zrazu-ssl
          restartPolicy: Never
          containers:
            - name: update
              image: alpine:3.20
              env:
                - name: PULL
                  value: "https://api.zrazu.com/ssl-pull/zsp_ВАШ_ТОКЕН"
                - name: SECRET
                  value: "zrazu-tls"
              command: ["/bin/sh", "-c"]
              args:
                - |
                  set -eu
                  apk add --no-cache curl openssl jq >/dev/null
                  SA=/var/run/secrets/kubernetes.io/serviceaccount
                  API="https://kubernetes.default.svc/api/v1/namespaces/$(cat $SA/namespace)/secrets/$SECRET"
                  k() { curl -fsS --cacert "$SA/ca.crt" -H "Authorization: Bearer $(cat $SA/token)" "$@"; }
                  cd /tmp
                  curl -fsS --retry 3 --max-time 60 -o new.pem "$PULL/fullchain.pem"
                  grep -q "BEGIN CERTIFICATE" new.pem || { echo "ZRAZU: not a certificate"; exit 1; }
                  k "$API" > secret.json
                  jq -r '.data["tls.crt"]' secret.json | base64 -d > current.pem
                  if cmp -s new.pem current.pem; then echo "ZRAZU: unchanged"; exit 0; fi
                  jq -r '.data["tls.key"]' secret.json | base64 -d > key.pem
                  cert_pub=$(openssl x509 -in new.pem -noout -pubkey | openssl sha256)
                  key_pub=$(openssl pkey -in key.pem -pubout | openssl sha256)
                  rm -f key.pem
                  [ "$cert_pub" = "$key_pub" ] || { echo "ZRAZU: certificate is for another key"; exit 1; }
                  openssl x509 -in new.pem -noout -checkend 0 >/dev/null || { echo "ZRAZU: expired"; exit 1; }
                  crt=$(base64 < new.pem | tr -d '\n')
                  k -X PATCH -H "Content-Type: application/merge-patch+json" --data "{\"data\":{\"tls.crt\":\"$crt\"}}" "$API" >/dev/null
                  echo "ZRAZU: updated, valid until $(openssl x509 -in new.pem -noout -enddate | cut -d= -f2)"

Застосуйте в namespace сайту:

kubectl -n НАЗВА_NAMESPACE apply -f zrazu-ssl.yaml

Перевірка

  1. Запустіть задачу один раз руками: kubectl -n НАЗВА_NAMESPACE create job --from=cronjob/zrazu-ssl zrazu-ssl-test.
  2. Журнал: kubectl -n НАЗВА_NAMESPACE logs job/zrazu-ssl-test — «updated» або «unchanged».
  3. Сайт: https://zrazu.com/tools/ssl/example.com/.
  4. Приберіть пробну задачу: kubectl -n НАЗВА_NAMESPACE delete job zrazu-ssl-test.

Якщо не вийшло

  • 403 у журналі — Role не на той секрет чи не в тому namespace: назва в resourceNames має збігатися з назвою секрету.
  • apk: … network error — кластер не має виходу в інтернет до дзеркал Alpine. Зберіть свій образ із curl, openssl і jq і приберіть рядок apk add.
  • «certificate is for another key» — сертифікат перевипускали з новим ключем: перестворіть секрет (крок 1) з новим ключем.
  • Секрет керує cert-manager — тоді ця інструкція не потрібна: cert-manager поновлює сертифікат сам через ACME-сервер ZRAZU (у кабінеті, «Встановлення» → ACME).

Ця сторінка в Markdown