こんにちは。ABEMA の CloudPlatform チームに所属しているエンジニアの koda (@ponkio_o) です。普段は Google Cloud を中心とした、ABEMA のプラットフォーム周りを手広く見ています。

今回は ABEMA で導入された Octo STS を用いて、Kubernetes 上から GitHub トークン発行を容易にするために構築した仕組みについて紹介します。

Octo STS とは

Octo STS は、Chainguard 社が開発している GitHub の短命なトークンを発行するためのサービスです。SaaS としても提供されていますが、セルフホストすることも可能で、ABEMA ではセルフホスト方式を選択しています。

Octo STS におけるトークン発行フロー
Octo STS における GitHub トークンの発行フロー (公式ドキュメントより)

GitHub の API を呼び出す際、Personal Access Token (PAT) が用いられることが多いですが、PAT では有効期限を無期限に設定できてしまい、セキュリティ上のリスクが高いことで知られています。

別の方法として、GitHub Apps を利用することで、短命なトークンを発行することができます。
この方法では GitHub Apps から発行されるスコープが絞られた有効期限付きのトークンを利用することになりますが、API を呼び出すワークロードが GitHub Apps の秘密鍵を持つことが多く、こちらもできれば避けたい選択肢です。

Octo STS を利用すると GitHub API を呼び出すワークロード上で PAT や GitHub Apps の秘密鍵といった無期限のクレデンシャルを持つ必要がなくなり、代わりに有効期限付きの短命なトークンを利用することができます。

Octo STS の仕組み

Octo STS は、OIDC トークンと Trust Policy と呼ばれる設定ファイルを用いて、スコープを絞った GitHub トークンを発行することができます。

下記は chainguard-dev/foo の main ブランチで実行される GitHub Actions の Job から、同リポジトリに対する contents: read / issues: write の権限を持つトークンを発行するための Trust Policy の例です。当然ですが、指定されたブランチやリポジトリ以外からのリクエストは拒否されます。

issuer: https://token.actions.githubusercontent.com
subject: repo:chainguard-dev/foo:ref:refs/heads/main

permissions:
  contents: read
  issues: write

別のリポジトリに対する権限を持つトークンを発行することも可能です。下記は chainguard-dev/foo リポジトリの main ブランチで実行される Job に bar / baz リポジトリに対する contents: read の権限を持つトークンを発行するための Trust Policy の例です。

issuer: https://token.actions.githubusercontent.com
subject: repo:chainguard-dev/foo:ref:refs/heads/main

permissions:
  contents: read

repositories:
  - bar
  - baz

GitHub Actions の Job からは次のようにして、トークンを発行し、利用することができます。

permissions:
  id-token: write # Needed to federate tokens.

steps:
  - uses: octo-sts/action@6177b4481c00308b3839969c3eca88c96a91775f # v1.0.0
    id: octo-sts
    with:
      scope: your-org/your-repo
      identity: foo

  - env:
      GITHUB_TOKEN: ${{ steps.octo-sts.outputs.token }}
    run: |
      gh repo list

GitHub Actions 以外から利用する

Trust Policy を見て察した方もいるかもしれませんが、Octo STS はその利用元を GitHub Actions に限定していません。OIDC に準拠したトークンさえあれば、GitHub Actions 以外のワークロードからも GitHub トークンを取得することができます。

下記は 公式でも紹介されている Google アカウントからトークンを発行する例です。

issuer: https://accounts.google.com
subject_pattern: "[0-9]+"
claim_pattern:
  email: '.*@chainguard\.dev'

permissions:
  contents: read

Google Cloud / AWS から利用する

Google Cloud や AWS といったパブリッククラウドでは、OIDC に準拠したトークンを取得するための仕組みが提供されています。

例えば Google Cloud の場合、GCE インスタンス上で下記のように HTTP リクエストを送ることで、OIDC トークンを取得することができます。

curl -H "Metadata-Flavor: Google" \
  'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=<audience>'

AWS の場合も、昨年発表された IAM Outbound ID Federation などを用いることで、OIDC トークンを取得することができます。

aws sts get-web-identity-token \
  --audience <audience>

Octo STS の Trust Policy では、これらの issuer から受け入れる設定を記載することで、パブリッククラウド上のワークロードからも短命な GitHub トークンを取得することができます。

Kubernetes 上から利用する

ABEMA では GKE / EKS の両方を利用しており、クラスタ上では GitHub API を呼び出す社内向けの Bot がいくつか稼働しています。当然このような Bot にも GitHub のトークンが必要です。

以前までは、GitHub Apps の秘密鍵と bradleyfalzon/ghinstallation を用いて、Installation Token を取得していましたが、どうにかして Kubernetes 上のワークロードから Octo STS を利用して GitHub のトークンを取得できないかと考えました。

前述のように、各パブリッククラウドのトークン発行の仕組みを Kubernetes 上から利用することもできますが、GKE (Google Cloud) / EKS (AWS) といったプラットフォームの違いを意識する必要があります。

GKE も EKS もどちらも Kubernetes なので、統一された方法でトークンを取得したいところです。

Kubernetes の Projected Volumes を使う

先に述べたように Octo STS では、OIDC に準拠したトークンであれば issuer を問わず受け入れることができます。Kubernetes では Projected Volumes を利用することで、任意の audience の OIDC トークンを Pod にマウントすることができるため、これを利用します。

Octo STS で Projected Volume の OIDC トークンを使う例
Octo STS federation with Projected Volume

具体的には、Pod に次のような設定を記載することで、Pod に対して任意の audience を設定した OIDC トークンをマウントすることができます。

apiVersion: v1
kind: Pod
metadata:
  name: octo-sts-test
  namespace: octo-sts-test
spec:
  containers:
    - name: container-test
      image: busybox:1.28
      command: ["sleep", "3600"]
      volumeMounts:
        - name: octo-sts-token
          mountPath: "/var/run/octo-sts"
          readOnly: true
  serviceAccountName: octo-sts-test
  volumes:
    - name: octo-sts-token
      projected:
        sources:
          - serviceAccountToken:
              audience: <octo-sts-audience>
              expirationSeconds: 3600
              path: token

上記の例だと、発行されたトークンは /var/run/octo-sts/token にマウントされ、トークンの中身は次のようになっています。

{
  "aud": ["<octo-sts-audience>"],
  "exp": 1785156492,
  "iat": 1785152892,
  "iss": "https://container.googleapis.com/v1/projects/<project-id>/locations/asia-northeast1/clusters/<cluster-name>",
  "jti": "1e868b50-af0d-4cee-9f8f-2769395e65ff",
  "kubernetes.io": {
    "namespace": "octo-sts-test",
    "pod": {
      "name": "octo-sts-test",
      "uid": "7c2d1a94-3f6b-4e2a-9c1d-5b8e0f4a6d21"
    },
    "serviceaccount": {
      "name": "octo-sts-test",
      "uid": "b51e1e5a-b434-46b7-9596-0defa7bad4df"
    }
  },
  "nbf": 1785152892,
  "sub": "system:serviceaccount:octo-sts-test:octo-sts-test"
}

issuer は各クラウドによって異なりますが、GKE や EKS では次のような形式になっています。

  • GKE: https://container.googleapis.com/v1/projects/<project-id>/locations/<region>/clusters/<cluster-name>
  • EKS: https://oidc.eks.<region>.amazonaws.com/id/<eks-cluster-id>

Octo STS の Trust Policy では、次のように issuer と Kubernetes の namespace / ServiceAccount を指定します。

issuer: https://container.googleapis.com/v1/projects/<project-id>/locations/<region>/clusters/<cluster-name>
subject: system:serviceaccount:<namespace>:<serviceaccount>

permissions:
  issues: write

repositories:
  - your-repo

これで必要な設定が揃ったので、Kubernetes 上のワークロードから Octo STS を利用して GitHub のトークンを取得することができます。curl を用いてリクエストを送る場合は次のようになります。

$ curl -H "Authorization: Bearer $(cat /var/run/octo-sts/token)" \
  "https://<octo-sts-host>/sts/exchange?scope=<scope>&identity=<identity>"

このように Projected Volume を利用することで、GKE / EKS といったプラットフォームの違いを意識する必要がなくなりました。
Trust Policy も namespace / ServiceAccount 単位で設定することができるので、GitHub のトークンを必要とするワークロードごとに細かく権限を分けることができて良さそうです。

しかし実際の運用を考えると、次のような理由からもう少し便利にしたい気持ちになりました。

  • Deployment に都度 Projected Volume の設定を記載するのは面倒な上に設定ミスも起きそう
  • アプリケーションでいい感じに GitHub トークンを取得したい

そこで Mutating Webhook を用いることにしました。

Mutating Webhook で設定する

Deployment に記載する設定は基本的に同じであるため、Kubernetes の Mutating Webhook を用いて Pod が作成されたタイミングで自動的に volume の設定を追加することにしました。

Webhook の実装はとてもシンプルで、Pod 作成のリクエストを受けた時に volume を追加するといったものです。一部省略していますが、イメージとしては下記のようなコードです。

func (i *PodTokenInjector) Handle(ctx context.Context, req admission.Request) admission.Response {
  pod := &corev1.Pod{}
  if err := i.Decoder.Decode(req, pod); err != nil {
    return admission.Errored(http.StatusBadRequest, err)
  }

  for _, v := range pod.Spec.Volumes {
    if v.Name == VolumeName {
      return admission.Allowed("volume already exists")
    }
  }

  audience := "<audience>"
  expirationSeconds := TokenExpirationSeconds

  pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
    Name: VolumeName,
    VolumeSource: corev1.VolumeSource{
      Projected: &corev1.ProjectedVolumeSource{
        Sources: []corev1.VolumeProjection{
          {
            ServiceAccountToken: &corev1.ServiceAccountTokenProjection{
              Audience:          audience,
              ExpirationSeconds: &expirationSeconds,
              Path:              TokenPath,
            },
          },
        },
      },
    },
  })

  mount := corev1.VolumeMount{
    Name:      VolumeName,
    MountPath: i.MountPath,
    ReadOnly:  true,
  }

  for idx := range pod.Spec.InitContainers {
    injectContainer(&pod.Spec.InitContainers[idx], mount)
  }
  for idx := range pod.Spec.Containers {
    injectContainer(&pod.Spec.Containers[idx], mount)
  }

  marshaled, err := json.Marshal(pod)
  if err != nil {
    return admission.Errored(http.StatusInternalServerError, err)
  }

  return admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
}

これで自動的に volume の設定を行うことができるようになりましたが、GitHub トークンを取得するためには、このトークンを利用して Octo STS にリクエストを送る必要があります。この時にアプリケーションからは次のような情報が必要です。

  • Octo STS のエンドポイント
  • Octo STS の Trust Policy 名
  • OIDC トークン

ここで思い出したのが Amazon EKS の IAM Role for ServiceAccounts (IRSA) です。

IRSA と Mutating Webhook

IRSA の詳細[1]はここでは割愛しますが、EKS 上の Pod から IAM Role を利用するための仕組みです。IRSA では、IAM OIDC Provider と適切な信頼ポリシーを設定した IAM Role を作成後、ServiceAccount に次のような annotation を付与し、この ServiceAccount を Pod に紐づけることで、紐づけた Pod から任意の IAM Role を利用することができるようになります。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: irsa-sa
  annotations:
    eks.amazonaws.com/role-arn: <IAM Role Arn>

内部的には aws/amazon-eks-pod-identity-webhook の Mutating Webhook が利用されており、AWS STS を audience とするトークンが格納された Projected Volume と AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ROLE_ARN といった AWS SDK から利用する環境変数が Pod に作成されます。

なので、これと同じ仕組みを作れば Octo STS でも同様の仕組みを実現できるのではないかと考えました。

IRSA-like に設定する

IRSA から着想を得て、Octo STS を利用したい Pod に紐づける ServiceAccount に annotation を付与することで、Mutating Webhook が Projected Volume の設定を追加するようにしました。

ServiceAccount の設定は次のようになります。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-service
  annotations:
    octo-sts.abema.tv/inject: "true" # Projected Volume をマウントするかどうか
    octo-sts.abema.tv/identity: my-service # Trust Policy 名

octo-sts.abema.tv/identity は Octo STS へのリクエスト時に利用する Trust Policy の名前です。この値は Deployment の設定には不要ですが、Octo STS にリクエストを送る際に必要になる情報であるため記載するようにしました。
なぜ Pod Template に関係のない設定を annotation に書くかというと、これらの情報にアクセスしやすいように環境変数も同時に設定したかったからです。

volume の設定に加えて、次のような環境変数も設定されます。

  • OCTO_STS_DOMAIN: Octo STS のドメイン
  • OCTO_STS_SCOPE: Scope (ABEMA では Org Scope を強制しているため固定値)
  • OCTO_STS_IDENTITY: Trust Policy の名前
  • OCTO_STS_TOKEN_FILE: OIDC トークンのパス

こうすることで Pod からは次のような形でアクセスすることができます。

$ curl -H "Authorization: Bearer $(cat $OCTO_STS_TOKEN_FILE)" \
  "$OCTO_STS_DOMAIN/sts/exchange?scope=$OCTO_STS_SCOPE&identity=$OCTO_STS_IDENTITY"

決まった環境変数だけ指定すればよく、トークンの場所や Trust Policy の名前を個別に持たせる必要がなくなりました。

Go 向けのライブラリを作る

Mutating Webhook を用いて、Octo STS で利用するトークンをいい感じにマウントするところまではできました。GitHub の API を呼び出す直前までもう少しいい感じにできそうです。

ABEMA では開発に Go をメインで利用しており、GitHub API を呼び出すような Bot もほとんど Go で書かれています。そして Go から GitHub API を扱う際には google/go-github を使うことが多いため、このライブラリから Octo STS を利用できるようにするためのライブラリを作ることにしました。

README の Authentication に記載されているように、go-github では http.Client を渡すことで、GitHub API の呼び出し時に利用する HTTP クライアントを差し替えることができます。

To support more advanced use cases; you can use the github.WithTransport option to provide a custom http.RoundTripper that handles authentication for you, or the github.WithHTTPClient option to provide a custom http.Client. As an example; you can use the oauth2.Transport from the golang.org/x/oauth2 package to handle OAuth token refreshing for you.

これを利用して、Octo STS からトークンを取得し、GitHub API を呼び出すための http.Client を返すライブラリを作りました。

次のように、octosts.NewHTTPClient を呼び出すだけで、必要な http.Client を取得することができ、github.NewClient に渡すことで、GitHub API を呼び出すことができます。

func main() {
  ctx := context.Background()

  hc, err := octosts.NewHTTPClient(ctx)
  if err != nil {
    log.Fatal(err)
  }
  gh := github.NewClient(hc)

  repo, _, err := gh.Repositories.Get(ctx, "my-org", "my-repo")
  // ...
}

今回のような仕組みで Octo STS を Kubernetes 上から利用する場合、次のような事項を考慮する必要がありますが、ライブラリを作ったことでこれらへの対応が内包され、利用者は意識する必要がなくなりました。

  • GitHub Apps の Installation Token は 1 時間で期限切れになること
  • Projected Volume にマウントされる OIDC トークンにも有効期限が存在し自動で更新されること

さいごに

Octo STS と Mutating Webhook を組み合わせて Kubernetes 上のワークロードから GitHub の短命なトークンを取得する方法を紹介しました。この仕組みによって管理する GitHub Apps の数も減らすことができ、より安全に GitHub API を呼び出すことができるようになりました。

Installation トークンの有効期限をもっと短くしたい、権限設定をもっと細かくしたいなど、まだ改善できるところがありそうなので、今後の GitHub 側のアップデートに期待したいですね。


[1] Diving into IAM Roles for Service Accounts

アバター画像
2025年中途入社のエンジニア。株式会社AbemaTVにて『ABEMA』のクラウドプラットフォーム領域の開発・運用に携わっています。組織の規模やフェーズに合わせたプラットフォームの設計と運用に関心があります。趣味はコーヒーです。