★2025/1/29 GithubのURLを変更しました

★2026/5/28 curlだけで更新するのをやめて、kubectlを都度installして使うように変更しました

Kubernetesでは、Deployment等のPod templateに対しイメージタグにてlatestを指定することは推奨されておりません

そのため、sha256によるコンテナイメージのダイジェスト値指定か、バージョンタグを用いた指定を行う必要があります。

Pod templateにバージョンないしはdigest値を書くということは、手動でアップデートの管理を行うということです。
別に大した手間ではないですしそれでも良いのですが、せっかく(何が??)ですので自動更新をさせてみたいものです。

但し、X.Y.ZバージョンのうちY部分の更新は何らかの管理者によるマイグレーション操作が発生する可能性を鑑みて、Z版のみ更新できるようにしてみました。(Z版で手動マイグレが必要にならない根拠は、無し。)

本来であれば以前記載した、「Deploymentでのアプリケーション単一性維持が保証できない問題」も含めて解決できる、何らかのOperatorを実装するほうがスマートです。しかし、当たり前ですがそういった実装には手間がかかりますので今回はKubernetesのCronJobでお茶を濁します。

下準備として、PodからkubeAPIを叩くためにServiceAccountやRole等を作成します。以下に例を示します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: updater
  namespace: hollo-1
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: get-edit-deploy
  namespace: hollo-1
rules:
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "list", "watch", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: updater
  namespace: hollo-1
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: get-edit-deploy
subjects:
- apiGroup: ""
  kind: ServiceAccount
  name: updater
  namespace: hollo-1

次に、Pod内部で実行するスクリプトを用意します。

Workerを分離している関係でTARGET_DEPLOYMENTSに複数記載がありますが、適宜変更してください。

なお、hollo-webとhollo-workerが同時にDBのマイグレーションを実行しようとする動作が発生しうるため、おそらく本当は下記コードだと良くないと思います。Worker側のargs変えてマイグレを止める+何らかの方法でWEB側のマイグレが終わるのを待つ をしないといけないですが面倒。一旦replicas=0にScaleさせて、マイグレだけ実行させた方が早いかも。

MastodonへのToot参考 : https://gist.github.com/YuzuRyo61/6388e6e8bde15712348e95714c82b0e1

#!/usr/bin/env bash

set -Eeuo pipefail
[[ "${DEBUG:-0}" == "1" ]] && set -x

TARGET_DEPLOYMENTS="${TARGET_DEPLOYMENTS:-hollo-web hollo-worker}"
IMAGE_REPO="fedify-dev/hollo"
IMAGE_ARCH="${IMAGE_ARCH:-amd64}"
INSTANCE_ADDRESS="${INSTANCE_ADDRESS:-<mastodon server fqdn>}"
ACCESS_TOKEN="${ACCESS_TOKEN:-}"
ROLLOUT_TIMEOUT="${ROLLOUT_TIMEOUT:-180s}"

SA_NAMESPACE_FILE="/run/secrets/kubernetes.io/serviceaccount/namespace"

if [[ ! -f "${SA_NAMESPACE_FILE}" ]]; then
  SA_NAMESPACE_FILE="/var/run/secrets/kubernetes.io/serviceaccount/namespace"
fi

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "[ERROR] command not found: $1" >&2
    exit 1
  }
}

for cmd in curl jq kubectl; do
  require_cmd "${cmd}"
done

NAMESPACE="${NAMESPACE:-}"
if [[ -z "${NAMESPACE}" && -f "${SA_NAMESPACE_FILE}" ]]; then
  NAMESPACE="$(<"${SA_NAMESPACE_FILE}")"
fi

if [[ -z "${NAMESPACE}" ]]; then
  echo "[ERROR] namespace is empty. Set NAMESPACE env or run in-cluster with serviceaccount namespace file." >&2
  exit 1
fi

post_status() {
  local msg="$1"

  if [[ -z "${ACCESS_TOKEN}" ]]; then
    echo "[WARN] ACCESS_TOKEN is empty. Skip status post." >&2
    return 0
  fi

  if ! curl -fsS --connect-timeout 5 --max-time 20 \
    -X POST \
    -d "status=${msg}" \
    -d "visibility=private" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    "https://${INSTANCE_ADDRESS}/api/v1/statuses" >/dev/null 2>&1; then
    echo "[WARN] Failed to post status to ${INSTANCE_ADDRESS}" >&2
  fi
}

parse_semver() {
  local v="$1"
  if [[ "${v}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
    echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
  else
    echo "[ERROR] version is not strict semver (x.y.z): ${v}" >&2
    exit 1
  fi
}

TOKEN="$(curl -fsS "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull" | jq -er '.token')"

MANIFEST_DIGEST="$(
  curl -fsS \
    -H "Accept: application/vnd.oci.image.index.v1+json" \
    -H "Authorization: Bearer ${TOKEN}" \
    "https://ghcr.io/v2/${IMAGE_REPO}/manifests/latest" | \
  jq -er --arg arch "${IMAGE_ARCH}" '.manifests[] | select(.platform.architecture == $arch) | .digest' | \
  head -n 1
)"

REPOVER="$(
  curl -fsS \
    -H "Accept: application/vnd.oci.image.manifest.v1+json" \
    -H "Authorization: Bearer ${TOKEN}" \
    "https://ghcr.io/v2/${IMAGE_REPO}/manifests/${MANIFEST_DIGEST}" | \
  jq -er '.annotations["org.opencontainers.image.version"]'
)"

echo "REPOVER: ${REPOVER}"

read -r REPO_MAJOR REPO_MINOR REPO_PATCH <<< "$(parse_semver "${REPOVER}")"

read -r -a DEPLOYMENT_LIST <<< "${TARGET_DEPLOYMENTS}"

if [[ "${#DEPLOYMENT_LIST[@]}" -eq 0 ]]; then
  echo "[ERROR] TARGET_DEPLOYMENTS is empty" >&2
  exit 1
fi

TARGET_IMAGE="ghcr.io/${IMAGE_REPO}:${REPOVER}"

for DEPLOYNAME in "${DEPLOYMENT_LIST[@]}"; do
  CONTAINER_NAME="$(kubectl -n "${NAMESPACE}" get deployment "${DEPLOYNAME}" -o jsonpath='{.spec.template.spec.containers[0].name}')"
  RUN_IMAGE="$(kubectl -n "${NAMESPACE}" get deployment "${DEPLOYNAME}" -o jsonpath='{.spec.template.spec.containers[0].image}')"
  RUNVER="${RUN_IMAGE##*:}"

  if [[ -z "${CONTAINER_NAME}" || -z "${RUN_IMAGE}" ]]; then
    echo "[ERROR] failed to read deployment/container info via kubectl: ${DEPLOYNAME}" >&2
    exit 1
  fi

  echo "${DEPLOYNAME} RUNVER: ${RUNVER}"

  read -r RUN_MAJOR RUN_MINOR RUN_PATCH <<< "$(parse_semver "${RUNVER}")"

  if [[ "${REPO_MAJOR}" -ne "${RUN_MAJOR}" ]]; then
    echo "${DEPLOYNAME}: X version different"
    post_status "Hollo autoUpdater / ${DEPLOYNAME} X version different RunVer:${RUNVER} / Latest:${REPOVER}"
    continue
  fi

  if [[ "${REPO_MINOR}" -ne "${RUN_MINOR}" ]]; then
    echo "${DEPLOYNAME}: Y version different"
    post_status "Hollo autoUpdater / ${DEPLOYNAME} Y version different RunVer:${RUNVER} / Latest:${REPOVER}"
    continue
  fi

  if [[ "${REPO_PATCH}" -eq "${RUN_PATCH}" ]]; then
    echo "${DEPLOYNAME}: do nothing"
    post_status "Hollo autoUpdater / ${DEPLOYNAME} do nothing RunVer:${RUNVER}"
    continue
  fi

  echo "${DEPLOYNAME}: do upgrade"
  echo "set image: ${DEPLOYNAME} ${CONTAINER_NAME}=${TARGET_IMAGE}"
  post_status "Hollo autoUpdater / ${DEPLOYNAME} Upgrade start ${RUNVER} to ${REPOVER}"
  kubectl -n "${NAMESPACE}" set image "deployment/${DEPLOYNAME}" "${CONTAINER_NAME}=${TARGET_IMAGE}" >/dev/null
  kubectl -n "${NAMESPACE}" rollout status "deployment/${DEPLOYNAME}" --timeout="${ROLLOUT_TIMEOUT}" >/dev/null
  post_status "Hollo autoUpdater / ${DEPLOYNAME} Upgrade completed ${RUNVER} to ${REPOVER}"
done

上記スクリプトを含めたイメージを作成するのは手間ですので、ConfigMapを使いJobで起動されるContainerにファイルとしてマウントします。

kubectl create cm -n hollo-1 vercheck-shell --from-file=./vercheck.sh --dry-run=client --save-config -o yaml | kubectl apply -f -

bot機能を使う場合は、別途Mastodonへ投稿するためのアクセストークンを作成し、以下のようなSecretを作成します。

apiVersion: v1
kind: Secret
metadata:
  name: updater-secrets
  namespace: hollo-1
type: Opaque
stringData:
  access_token: "〜〜〜"

それではUpdater本体をDeployしましょう。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: z-release-autoupdater
  namespace: hollo-1
spec:
  schedule: "16 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: updater
          volumes:
          - name: vercheck-shell
            configMap:
              name: vercheck-shell
              items:
              - key: vercheck.sh
                path: vercheck.sh
                mode: 0777
          containers:
          - name: updater
            image: alpine:latest
            imagePullPolicy: Always
            env:
            - name: ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: updater-secrets
                  key: access_token
            - name: TARGET_DEPLOYMENTS
              value: "hollo-web hollo-worker"
            volumeMounts:
              - name: vercheck-shell
                mountPath: /app
            command:
            - /bin/sh
            - -c
            - apk add --no-cache bash ca-certificates curl jq kubectl >/dev/null && exec bash /app/vercheck.sh
          restartPolicy: Never

それではテストしてみましょう。CronJobからJobを手動でcreateしてみます。

kubectl create job -n hollo-1 --from=cronjob/z-release-autoupdater test

以下のようにPodが生成され、無事に動作しました。(^o^)v

user@k-cp-1:~/hollo-1$ k get pod -n hollo-1 | grep test
test-mshz4                             0/1     Completed   0              3m

※免責:本記事が原因で問題が発生しても一切保証しません。よろしくお願いします。