さくらのクラウド上に構築したKubernetesクラスタでDevOpsを効率化してみた

筑波大学学園祭実行委員会 情報メディアシステム局では、学園祭開催に向けて、公式Webサイトの制作、学園祭企画者向けサービス、そして本年度から新たな試みとして生配信視聴ページなど様々なサービスを開発し、保守しています。

従来よりも効率の良い開発体験、サービスの安定的な運用を行うためRancher Kubernetes Engine2(以下RKE2)を用いたコンテナオーケストレーションを採用し、実際に利用しました。

この記事では情報メディアシステム局でのRKE2を用いたコンテナオーケストレーションでの構築例をご紹介します。

雙峰祭とは

雙峰祭(公式サイト)とは、筑波大学で例年11月頃に開催されている学園祭です。

第50回目となる2024年度の雙峰祭は11月2日〜4日の3日間にわたって行われ、3万人を超える方々に来場いただきました。

Kubernetes環境が必要となった経緯

情報メディアシステム局では学園祭の実施に向け複数人によるWeb開発を行っています。ソースコードはGitHubのレポジトリで管理していますが、共同制作となるとCI/CDを導入し、コードに問題がないかを自動で検証してレビュー作業の効率化を行います。

情報メディアシステム局ではCI/CDの稼働にGitHub Actionsを利用しています。公開レポジトリであれば稼働時間制限に余裕がありますが、Webページの制作時点では非公開情報も含まれるため、公開レポジトリと設定する訳にはいかず、非公開レポジトリにする必要があります。この場合、レポジトリ単位の稼働時間ではなく、オーガナイゼーションに含まれる全てのレポジトリでのRunnerの稼働時間制限が適用されるため、稼働時間制限の不足が気になってきます。

そこで、まず最初に行ったこととしてGitHubが提供する、任意のマシンで動作可能なSelf-hosted Runnerの導入を検討しました。Actionsのワークフローの実行は、このSelf-hosted Runnerのみで動作可能です。しかし、単体でのエフェメラル(一時的な)運用が考慮されておらず、一度CIを実行し終えたRunnerで別のCIが実行されると、前の環境が残った状態で開始されてしまいます。複数のサービスを並行して複数人で開発しているため、この挙動によりCIが正しい結果を提供出来ない例が起きてしまいました。

手軽にGitHubが提供する公式のRunnerのようなエフェメラルな環境を提供できないかと、Docker Composeとコンテナを管理するサイドカーコンテナを用いて、やや強引ながらエフェメラルな環境を構築したり試行錯誤をしてみましたが、結論としては安定的な稼働を実現できませんでした。

難しかったポイントとして、Docker Composeの仕様的な限界がありました。Docker Composeはコンテナが異常終了したり、コンテナが起動していない場合にコンテナを立ち上げることができますが、「正常終了したら新しいコンテナを起動する」機能が存在しません。

そこで、docker in dockerのイメージをrunnerのコンテナのmanagerとして別に立てておき、そのコンテナ内で10秒おきにRunnerコンテナの数を確認して足りなければ起動するコマンドを実行するという回避策で対応を試みました。

services:
  manager:
    image: docker:latest
    environment:
      TZ: Asia/Tokyo
    command: >-
      sh -c '
        apk update && \
        apk add --no-cache curl tzdata && \
        while true; do
          if [ $(docker ps -a --filter "name=github-runner-runner" --format "{{.Names}}" | wc -l) -lt ${REPLICAS} ]; then
              echo "$(date +%Y-%m-%d\ %H:%M:%S) - コンテナ作成を行います。"
              docker compose up -d
          fi
          if [ $(docker ps -a --filter "name=github-runner-runner" --format "{{.Names}}" | wc -l) -eq 0 ]; then
              echo "$(date +%Y-%m-%d\ %H:%M:%S) - github-runner-runnerから始まるコンテナが1つもありません。コンテナ作成Webhookを実行します。"
              docker compose up -d
          fi
          sleep 10
        done
      '
    security_opt:
      # needed on SELinux systems to allow docker container to manage other docker containers
      - label:disable
    restart: unless-stopped
    volumes:
      - ./docker-compose.yaml:/docker-compose.yaml
      - '/var/run/docker.sock:/var/run/docker.sock'
  runner:
    image: myoung34/github-runner:latest
    environment:
      APP_ID: ${APP_ID}
      APP_LOGIN: ${ORG_NAME}
      APP_PRIVATE_KEY: ${APP_PRIVATE_KEY}
      RUNNER_NAME_PREFIX: self-hosted-runner
      RUNNER_GROUP: Default
      RUNNER_SCOPE: org
      ORG_NAME: ${ORG_NAME}
      EPHEMERAL: true
      LABELS: linux,x64,docker
    entrypoint: >-
      sh -c '
        echo "Current Container ID: $CONTAINER_ID" && \
        /entrypoint.sh && \
        ./bin/Runner.Listener run --startuptype service && \
        docker rm -f $CONTAINER_ID
      '
    security_opt:
      # needed on SELinux systems to allow docker container to manage other docker containers
      - label:disable
    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock'
    restart: always
    deploy:
      replicas: ${REPLICAS}

これによりエフェメラルな環境提供は実現できたものの、runnerが高負荷となった場合にmanagerが落ちてしまって正常に起動できなくなったり、常に最大個数のコンテナが待機する必要があったり、あまり使い勝手の良いものではありませんでした。

このDocker Composeを書いているときに、オートスケーリングとホストマシンに応じたコンテナ管理が可能なKubernetesの必要性を感じ、Kubernetes環境の構築に挑戦してみることにしました。

Kubernetesディストリビューションの選定

Kubernetes関連のソリューションは技術革新が大きく、一度構築した環境を保守するだけでも大変であるため何らかのKubernetesディストリビューションを利用してk8sの土台になるインフラの見通しを良くすることを検討しました。

そこで、K3sk0sRKEなどが候補に上がりました。K3sやk0sは手軽な分、Ingressやロードバランサー決め打ち状態になっていたので、今回の利用用途の場合、かえって複雑になりそうということで今回は断念しました。できるだけプレーンで使いやすいディストリビューションとして、RKEで一度構築してみましたが、歴史的経緯からDocker依存な部分が残っていたり、せっかく新しく構築するならRKE2が良さそうということで最終的にRKE2を選択しました。

RKE2はK3sのようなアプリケーションが同梱されているわけではありませんが、K3sの便利な機能がクセのない状態で入っているため、痒いところに手が届き、とても使いやすかったです。

RKE2の便利機能

  • クラスター構築時に、適用したいマニフェストを設置できるフォルダ
    /var/lib/rancher/rke2/server/manifests/ へ、kube-apiのロードバランサー(kube-vip)やCNIのコンフィグのようなクラスター構築と同時に立ち上げてほしいアプリケーションやコンフィグを設置すると、自動で反映してクラスターを構築してくれます。
  • HelmChartカスタムリソース
    RKE2のクラスターにはデフォルト状態でカスタムリソースがいくつか登録されており、そのうちの一つとしてHelmChartカスタムリソースがあります。このHelmChartカスタムリソースは、KubernetesのマニフェストとしてHelmのチャートを定義することができ、適用時に自動でjobが実行されて、Helmが展開されます。Helmをサーバーに別途インストールすることなく利用できるため、ArgoCDのデプロイ前に必要なアプリケーションのデプロイで利用しました。

できるだけIaCを使って再現性を高める

学園祭実施に向けた支援としてリソースを利用させて頂いている、さくらのクラウドを用いてKubernetes環境を作るにあたり、OSやディスク設定、ネットワーク設定、VPCルーター、ロードバランサーなどに様々な設定投入が必要となります。

RKE2を用いることでKubernetesそのものの環境構築の敷居は下がりますが、Kubernetesの真髄に当たる高可用性を持った構成にするにはネットワーク周りやハードウェア周りなど多岐にわたる知識が必要となります。

さくらのクラウドから公式のプロバイダーが提供されているTerraformを用いて、できるだけコードを追うだけで状況がわかることを目標にIaCを実践しました。

# RKE2ではcontrol planeノードをserverと呼び、workerノードをagentと呼ぶ。
# RKE2クラスターの各ノードのhostnameを決定する
locals {
  rke2_gateway_local_network = cidrhost("${var.rke2_gateway_local_ip}/${var.rke2_gateway_local_ip_cidr}", 0)

  # serverノードはxx.11から、agentノードはyy.1からIPアドレスを割り当てる
  # xx.1を各ノードのローカル用gatewayとして、xx.2をkube-apiのvipアドレスとして利用するため、念の為xx.3からxx.10の8個分予約している
  rke2_nodes_map = merge(
    {
      for idx in range(var.rke2_server_count) : "rke2-server-${idx}" => {
        agent   = false
        name    = "${var.name_prefix}-rke2-server-${idx}"
        address = cidrhost("${local.rke2_gateway_local_network}/${var.rke2_gateway_local_ip_cidr}", "${11 + idx}")
      }
    },
    {
      for idx in range(var.rke2_agent_count) : "rke2-agent-${idx}" => {
        agent   = true
        name    = "${var.name_prefix}-rke2-agent-${idx}"
        address = cidrhost("${local.rke2_gateway_local_network}/${var.rke2_gateway_local_ip_cidr}", "${257 + idx}")
      }
    }
  )
}

ここではそのコードの一例として、IPアドレスとホスト名の定義を紹介します。上記のコードのような形で環境変数で設定されたノード数に応じて、動的にローカルIPとホスト名を生成します。

こういった環境変数やローカル値による動的生成は便利な反面、コード全体を見直した際に汎用さを強く考えすぎてしまい、機能追加時に柔軟性に欠ける部分があったのが反省点でした。

今回のIaCでの構築を通じて、ソースコードでインフラを定義する関係上、一般的なプログラムのように最適化されたソースコードよりも、後々に何をしているのかすぐに追うことができるように多少の冗長さは容認したほうが効率的になるという学びを得ました。

さくらのクラウドの「ロードバランサ」と「Ingress(Traefik)」を併用して利用

Kubernetesの高可用性を実現するにはロードバランサーが必要となってきます。そこで今回は、さくらのクラウドにて提供されているサービスの一つ、「ロードバランサ」を採用してロードバランシングすることとしました。

使い方としては、下記の通りです。

  • VPCルーターの下にいるKubernetesノードと並べて「ロードバランサ」を設置
  • VPCルータのスタティックNAT設定を用いて、VPCルータがインターネットから受け取ったパケットを、ロードバランサが持っている仮想IPアドレスにフォワーディングするように設定
  • ロードバランサは実サーバーとしてAgent(Worker)ノードを設定

これでロードバランサが各Agent(Worker)ノードにパケットを転送するようになります。

普通の純粋なサーバーならこれだけで良いのですが、実際のサーバーアプリケーション(ここではIngressであるTraefik)がコンテナで運用されているので、少し細工をする必要があります。

RKE2は標準ではNginx(rke2-ingress-nginx)がIngressとして選択されますが、Traefikを利用するように変更しました。

下記のコンフィグは実際に利用したTraefikのHelmのValuesになります。

deployment:
  kind: DaemonSet
updateStrategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 1
    maxSurge: 0
tlsStore:
  default:
    defaultCertificate:
      secretName: wildcard-${rke2_cluster_domain}-tls
ingressRoute:
  dashboard:
    enabled: false
  healthcheck:
    enabled: true
    annotations:
      external-dns.alpha.kubernetes.io/hostname: "${rke2_cluster_domain}"
      external-dns.alpha.kubernetes.io/target: "${sakura_shared_ip}"
    matchRule: PathPrefix(`/ping`)
    entryPoints:
      - web
      - websecure
hostNetwork: true
additionalArguments:
  - "--providers.kubernetesingress.ingressendpoint.hostname=${rke2_cluster_domain}"
ports:
  web:
    port: 80
    redirectTo:
      port: websecure
  websecure:
    port: 443
    asDefault: true
securityContext:
  capabilities:
    drop: [ALL]
    add: [NET_BIND_SERVICE]
  readOnlyRootFilesystem: true
  runAsGroup: 0
  runAsNonRoot: false
  runAsUser: 0

ポイントとしては、Kubernetes側で外部接続の死活管理機能を使わず、全てのノードでTraefikのコンテナを動かし、さくらのクラウドのロードバランサを用いて外からの80番,443番ポートがそのままTraefikのコンテナに入るようにした部分です。

コンフィグの詳細としては、全てのノードでTraefikを動かすためにdeployment方法をDaemonSetにし、コンテナのエンドポイントをNATやKubernetesのサービスロードバランサーを利用せず80番,443番ポートをそのままコンテナで受け入れるように設定しています。

さくらのクラウドのロードバランサはポート変換をする機能がないため、上記のようにコンテナのセキュリティ設定を個別に指定する工程が必要です。

永続化ボリュームにはLonghornを採用

RKE2と同じ、Rancherが開発しているKubernetesベースの分散ストレージであるLonghornを利用して、永続化ボリュームを作成しました。

各ノードの追加ディスクとしてLonghorn用にブランクディスクを追加して、ノード立ち上げ時にマウントしています。

バックアップやリストアをオブジェクトストレージに設定できたり、レプリカ数を自由に決めることができて便利です。

デメリットとしては、各ノードに永続化ボリュームのレプリカを持たせる形となるため、ノードを増やす度に追加ディスクのコストが掛かります。速度や安定性がそこまで重要ではなく、永続化ボリュームのノード数や容量を多くしたい場合は、ロードバランサと同じように「NFSアプライアンス」を利用して永続化ボリュームを作成するという選択肢もあります。

ArgoCDとHashiCorp Vault

KubernetesのGitOpsはArgoCDで行いました。

ArgoCDだけではAPIキーやシークレット情報などのクレデンシャル情報を含めてGitのレポジトリに置く必要が出てくるため、HashiCorp Vaultと併用することで、Gitのレポジトリにはクレデンシャル情報を置かない運用にしました。

具体的にはクレデンシャル情報はKubernetesにセルフホストしたHashiCorp Vaultに設置し、Argo CD Vault Pluginを用いて、Gitレポジトリ内のクレデンシャル用に設定されているプレイスホルダーの部分を置換しつつ、デプロイする形になります。

ArgoCDからHashiCorp VaultへのログインにはKubernetes認証を用いてできるだけ安全にクレデンシャルの取得ができるようにしました。

Self-hosted GitHub Actions Runner

Actions RunnerはGitHubが公式でHelm Chartを提供しているため、そのHelm Chartをベースとして構築しました。

実際に使用したSelf-hosted GitHub Actions Runnerに関してはリンク先の記事で紹介しています。

Dex IdPを用いたOpenID Connectの複数サービス横断認証

Dex IdPを用いて、本来はOAuth 2.0として提供されるGitHub AuthをOIDCとして利用しました。これにより、組織内でセルフホストしたサービスごとに手動で個別のアカウント生成を行う必要がなくなりました。また、誰が何を実行したのかを残すこともできたため、ログの確認などでも重宝しました。

生配信視聴ページバックエンド

今年の学園祭生配信のバックエンドは、音声映像ストリーム部分にさくらインターネットのサービスの一つである「ImageFlux Live Streaming」を利用させていただきました。情報メディアシステム局では、生配信のフロントエンド部分と視聴者からのリアクション機能を独自に開発しました。視聴者からのリアクション処理を行うバックエンドサーバーのデプロイに今回構築したRKE2を利用しました。

ArgoCDによるGitOpsの自動デプロイや、トラフィックの負荷分散のシステムが既にある状態で開発が始められたため、従来のデプロイ方法に比べて、円滑に開発作業を行うことができました。

終わりに

本年度のKubernetes環境の利用は、GitHub Actionsと生配信のバックエンド部分での利用が主な利用方法でしたが、今後はより効率的かつ安定したKubernetes環境を整えて、年間を通じて稼働させるサービスなどもKubernetes環境上に構築する予定です。

さくらのクラウドの便利なアプライアンス機能やImageFlux Live Streamingなどをうまく組み合わせて利活用することで、Kubernetes環境やWebサービスを手軽に提供することができました。