Workload-identityでGKEのPodに権限を与える

従来のGKEでの認証情報の渡し方

既存のGKEクラスタでは使用したリソースへのアクセス・編集権限を持ったGCPのサービスアカウントを発行し、サービスアカウントのjsonキーをkubernetesリソースであるSecretに登録し、マウントすることでアプリケーションから他のGCPリソースへのアクセス権限を渡していると思います。

しかし、この手法ではSecretをどの様に管理するかによってセキュリティリスクが大きく異なります。KMSなどのKey Management Serviceを利用することでセキュリティリスクを削減することも出来ますが、それでもjsonファイル一つで認証情報がすべてわかってしまう為、キーが漏洩する危険性は常に付きまとっています。

Workload Identity

Workload IdentityとはGKE上で動作しているPodがGCSなどの他のGCPリソースを使用する際の認証情報を渡す為の手法です。

Workload IdentiyではKubernetes上のServiceAccountとGCP上のServiceAccountを紐づけることでサービスアカウントの認証情報を直接Podに渡すことなく認証を行うことができる手法です。 (このブログでは両方が”ServiceAccount”では説明がややこしくなってしまうので、公式ドキュメントになぞってKubernetesのリソースであるServiceAccountをKSA、GCPの認証用アカウントであるServiceAccountをGSAとしています)

Google Cloud Nextで登場したスライドの図がとてもわかりやすくなっています。最後に発表のリンクを貼っておきます f:id:tomiokasyogo:20190916232504p:plain

KSAとGSAを紐づけることで、図の様にMetadata Serverからアクセストークンを取得し利用することが出来ます。

実際に使ってみる

それでは実際に使ってみたいと思います

準備

workload-identityを有効にしたGKEクラスタを作成します

$ gcloud beta container clusters create ${cluster-name} \
  --cluster-version=1.12 \
  --identity-namespace=${project-id}.svc.id.goog \
  --zone=asia-northeast1-c

次にGSAを作成します。この時点ではなんの権限も付与していません

$ gcloud iam service-accounts create workload-identify-sa-k8s

次にクラスタ内に新しくechoというnamespaceと、echonamespace内に今回使用するKSAを作成します

  • namespaceの作成
$ kubectl create namespace echo
  • KSAの作成
$ kubectl create sa -n echo workload-identify-sa

次に2つのGSAとKSA間にCloud IAMポリシーバインディングを作成して、KubernetesサービスアカウントがGoogleサービスアカウントを使用できるようにします。このバインドにより、KubernetesサービスアカウントがGoogleサービスアカウントとして機能できるようになります。

$ gcloud iam service-accounts add-iam-policy-binding \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:${project-id}.svc.id.goog[echo/workload-identity-sa-k8s]" \
workload-identify-sa@${project-id}.iam.gserviceaccount.com

GCP側の設定が完了したので、次にKSA側にGSAの情報を付与します。 付与する時にはKSAにGKA名が入ったアノテーション を追加することで付与することができます

$ kubectl annotate sa \
--namespace echo \
workload-identity-sa-k8s  \
iam.gke.io/gcp-service-account=workload-identify-sa@${project-id}.iam.gserviveaccount.com

$ kubectl describe sa -n echo workload-identity-sa-k8s
Name:                workload-identity-sa-k8s
Namespace:           echo
Labels:              <none>
Annotations:         iam.gke.io/gcp-service-account: workload-identify-sa@${project-id}.iam.gserviveaccount.com
Image pull secrets:  <none>
Mountable secrets:   workload-identity-sa-k8s-token-6qhd4
Tokens:              workload-identity-sa-k8s-token-6qhd4
Events:              <none>

KSAではSAに紐づくTokenがSecretとして同時に作成されます。 ServiceAccountをDeployment等で指定すると同時にSecretが/var/run/secrets/kubernetes.io/serviceaccountにマウントされてKSAのTokenや証明書の情報を参照することができます。

実際にKSAとGSAが紐づいているかを確認する

google/cloud-sdkのイメージを利用して実際にKSAとGSAが紐づいているのかを確認します。 下記のコマンドで一時的なPodをデプロイします

$ kubectl run -it \
  --generator=run-pod/v1 \
  --image google/cloud-sdk \
  --serviceaccount workload-identity-sa-k8s \
  --namespace echo \
  workload-identity-test

デプロイが完了するとターミナルがPod内でコマンドを実行することができるので

$ gcloud auth list

とタイプしてみましょう。そうすると先ほど指定したGSAのリストがActivateされていることが確認できると思います。

実際にアプリケーションから利用する

実際にアプリケーションから非公開のGCSバケットにアクセスしてデータを取得してみようと思います。 以下のコマンドでバケットを作成し、適当なファイルをおきます

$ gsutil mb gs://workload-identity
$ gsutil cp aa.txt gs://workload-identity/aagsutil cp aa.txt gs://workload-identity/aa.txt

次にバケットの読み取り権限を付与します

$ gsutil acl ch -u gcs workload-identify-sa@${project-id}.iam.gserviveaccount.com:READER gs://workload-identity/aa.txt

あとは以下の様なコードを書くことでGCSのオブジェクトを読み込むことができます

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

func main() {
    http.HandleFunc("/", helloWorld)
    http.ListenAndServe(":8080", nil)
}

func helloWorld(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()
    ts, err := google.DefaultTokenSource(ctx)
    if err != nil {
        fmt.Fprintf(w, "Can't read object!,%s", err)
        return
    }
    gcs := oauth2.NewClient(ctx, ts)
    resp, err := gcs.Get(fmt.Sprintf("https://storage.cloud.google.com/%s/%s", "workload-identity", "aa"))
    if err != nil {
        fmt.Fprintf(w, "Can't read object!,%s", err)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}

DefaultTokenSourceのコメントに書いてありますが DefaultTokenSourceは以下の様な順番でクレデンシャル情報を探します

// It looks for credentials in the following places,
// preferring the first location found:
//
//   1. A JSON file whose path is specified by the
//      GOOGLE_APPLICATION_CREDENTIALS environment variable.
//   2. A JSON file in a location known to the gcloud command-line tool.
//      On Windows, this is %APPDATA%/gcloud/application_default_credentials.json.
//      On other systems, $HOME/.config/gcloud/application_default_credentials.json.
//   3. On Google App Engine standard first generation runtimes (<= Go 1.9) it uses
//      the appengine.AccessToken function.
//   4. On Google Compute Engine, Google App Engine standard second generation runtimes
//      (>= Go 1.11), and Google App Engine flexible environment, it fetches
//      credentials from the metadata server.

今回の場合、4番にある様にGKEのMetadata Serviceに問い合わせ、IAMからTokenを取得することでアクセスが可能になっています。 コードだとこの辺りでしょうか

https://github.com/golang/oauth2/blob/master/google/default.go#L107-L113

 // Fourth, if we're on Google Compute Engine, an App Engine standard second generation runtime,
    // or App Engine flexible, use the metadata server.
    if metadata.OnGCE() {
        id, _ := metadata.ProjectID()
        return &DefaultCredentials{
            ProjectID:   id,
            TokenSource: ComputeTokenSource("", scopes...),
        }, nil
    }

運用を考えて

実際にWorkload-identityを使用する場合、現状のGSAをSecretで渡す形式より良いと感じる部分は以下の点です

  • KSAと紐づけられるのでキー漏洩の心配が無くなる
  • KSAはnamespaceで区切れる=namespaceごとに開発チームを分けることで、管理がしやすくなる

ただ、使用する際にはマニフェストのAnnotationに書くだけなのでnamespaceのKSAを指定しておけば紐づくGSAも使えてしまうというのは少し気になります。(Secretを利用した場合でも同じですが) ちゃんとやるなら開発チームやサービスごとにnamespaceを分けるのは必須の様な気がします。

総合的にみるとGCP内で認証プロセスが完結し、開発者が認証情報を直接触らなくていいだけでかなりメリットが大きい機能だと思います。 認証プロセスだけ少し複雑なのでちゃんと理解する必要がありますが、ぜひ使っていきたい機能ですね。

参考サイト、発表

https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity

www.youtube.com