Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

GCPのサービスアカウントキーなしでgcloudやbqコマンドをAWSから利用してみた

本記事は Akatsuki Advent Calendar 2021の9日目です。
前回は ぐんそう さんの 非エンジニアのためのGit/GitHub講座 でした。

概要

この記事では以下の内容について書きます。

  • GCPのWorkload Identity連携を利用して、AWSのEC2インスタンスからサービスアカウントキーを使わずにGCPを利用する方法
    • EC2インスタンスロールとGCPのサービスアカウントを紐付ける方法
  • gcloud CLIツールでWorkload Identity連携を利用する方法
  • gsutilコマンドやbqコマンドを使っていたのだけれどどうすれば良いの?

特にgcloud CLI周りについて、これまでAWS上でサービスアカウントキーを利用してシェルスクリプトによる自動化を行っていたが、サービスアカウントキーの使用をやめてセキュリティ向上を図りつつクレデンシャル管理の手間をなくしたいというニーズがあり、今回 Workload Identity 連携が利用できないか調べてみました。

最初にお断りしておきますが、Cloud Storageにアクセスするgsutilコマンドや、BigQueryにアクセスするbqコマンドついては現時点では正式な手順がなく、(パッチを当てたりして)無理やり動かしてみたという内容です。またその理由・将来的にどうなるかの予想についても書いてみます。

AWSとGCPのWorkload Identity連携のセットアップ

Workload Identity 連携は、GCP以外の認証情報(例えばAWSのIAM)をGCPのサービスアカウントに紐付けることで、永続するキーなしでGCPへのアクセスを認可するものです。技術的にはOpenID Connectを利用しています。
AWSであれば例えばEC2インスタンスにIAMロールをアタッチすることで、AWS・GCP共にキー管理を不要にできます。

最初にこの連携をセットアップしていきます。ここでは簡単のためにWebコンソールでセットアップする手順を書きます。(実際にはTerraform等のIaCを利用することが多いでしょう。)

まずは 「IAMと管理 > Workload Identity 連携」から「プールを追加」。

f:id:NeoCat:20211206161357p:plain
プールを追加

適当にプールの名前やID、説明を入力します。なおこのうちIDは後で変更できません。

プールの作成

次にプールにAWSをプロバイダとしてを追加します。AWSは他のOIDCプロバイダと違って専用の設定が用意されているので、アカウントIDとプロバイダ名(識別用の適当なID)を入力するだけでOKです。

プールにプロバイダを追加

AWSを選択した場合、次のステップでAWS用のマッピングが自動的に設定されます。

プロバイダの属性の設定

具体的には attribute.aws_role という属性でAWSのロールを使いやすく変換したものをマッピングするようになっています。長いCEL式でちょっと読みにくいですね。IAMロールをアタッチしたEC2インスタンス上で

aws sts get-caller-identity
を実行すると、 "Arn" として
"arn:aws:sts::...:assumed-role/<ロール名>/<EC2インスタンスID>"
という文字列が返ってくるはずです。先のCEL式は、 "assumed-role" が含まれる場合は、最後の "/<EC2インスタンスID>" 部分を削る、ということをしています。含まれない場合はArnそのままです。
これを利用して、例えば上記スクリーンショットの下部ようにCEL式を記述することで、EC2インスタンスに特定のIAMロール role-name がアタッチされている時のみWorkload Identity認証を許可するといった制御ができます。

最後に、このプールにサービスアカウントを紐付けて許可します。あらかじめ利用させたい権限*1を持つサービスアカウントを作成しておきます。

f:id:NeoCat:20211206163435p:plain
アクセスを許可
「アクセスを許可」で、そのサービスアカウントを設定します。また、ここでもロールとサービスアカウントの紐付けが可能です。こちらであれば、複数のロールとサービスアカウントの対応付けが可能です。
サービスアカウントとの紐付け

設定が終わると、構成ファイルと呼ばれるJSONをダウンロードすることができます。これをアプリケーションに渡すことで、Workload Identityによる認証が可能になります。サービスアカウントキーと違ってこれは機密情報を含まずセキュアに管理する必要がありませんので、気軽に取り扱うことができます。

構成JSONファイルのダウンロード

GOOGLE_APPLICATION_CREDENTIALS で利用する

Google Cloud SDKを利用して開発されたPythonやGo等のアプリケーションを利用する場合、標準的な方法で認証を行なっていれば、EC2インスタンス上にこのファイルを配置してGOOGLE_APPLICATION_CREDENTIALS 環境変数にこのファイルのパスを渡すだけでOKなはずです。

gcloud CLIで利用する

さて、gcloud コマンドでシェルスクリプトなどを書いている場合、どうすれば良いでしょうか?

gcloud コマンドは、gcloud auth login でWeb認証したり、 gcloud auth activate でサービスアカウントキーを登録し、それをデフォルトアカウントとして利用します。

Workload Identity を利用する場合、gcloud auth activate に構成ファイルを渡しても失敗します。正解は、以下のように auth login の --cred-file オプションに構成ファイルを渡すというものです。

gcloud auth login --cred-file=clientLibraryConfig.json

これで、以降はデフォルトでgcloudコマンドが Workload Identity を利用してくれます!

gsutilコマンドやbqコマンドでWorkload Identity認証するには…?

これがある意味でこの記事の本題かもしれません。

上記の方法で gcloud コマンドは利用可能になりました。が、それと同じデフォルトアカウントを利用するgsutilコマンドやbqコマンドも使いたいですよね。
しかし実際にやってみると、

$ gsutil ls
ServiceException: 401 Anonymous caller does not have storage.buckets.list access to the Google Cloud project.

$ bq ls
ERROR: (bq) Your current active account [...] does not have any valid credentials

と認証エラーと思しきメッセージが出て失敗してしまいます。何か回避策はないでしょうか? *2

gsutil コマンドの代替

まず gsutil ですが、alphaステージではありますが代替となるgcloud alpha storageコマンドが用意されていました。こちらであればきちんと認証が行えます。(gcloudコマンドの一部ですからね)*3
下記のようにcp, ls, rmといったサブコマンドがあります。

$ gcloud alpha storage
ERROR: (gcloud.alpha.storage) Command name argument expected.

Available groups for gcloud alpha storage:

Available commands for gcloud alpha storage:

      cp                      *(ALPHA)*  Upload, download, and copy Cloud
                              Storage objects.
      ls                      *(ALPHA)*  List Cloud Storage buckets and objects.
      rm                      *(ALPHA)*  Delete objects and buckets.

ところで、実は gsutil コマンドはアクセス先のプラグインに対応しており、デフォルトでS3などにもアクセスできます。
gcloud alpha storageもこれを継承しており、ちゃんとS3にもアクセスできます。

gcloud alpha storage ls s3://  # バケット一覧
gcloud alpha storage cp s3://bucket-name/file gs://bucket-name/

ということで、将来的に gsutil はこちらに移行していくことになると思われるので、これを待つか先行して試用することになります*4

bq コマンドは…?

では bq コマンドはどうでしょうか? gcloud alpha bq というコマンドはあり、データセットやテーブルのCRUD操作はできるようですが、 bq query に相当するクエリを実行するようなコマンドはなさそうでした。

そもそもなぜGoogle Cloud SDKの一部であるはずのbqコマンドはエラーを出してしまうのでしょう?
SDKはPythonで書かれていますので、直接ソースコードを読むことができます。調べてみましょう。

まず bq コマンドの実体はgoogle-cloud-sdk内の bin/bq ですが、これは適切なPythonを探してbin/bootstrapping/bq.py を実行します。
このbootstrappingは、まずデフォルトの認証情報を取得しています。

    store.Load()  # Checks if there are active credentials

その後、認証情報にはauth login、GKEやGCEのメタデータ、サービスアカウント、Application Default Credentialなど様々な認証方法がありますが、この種類ごとに分岐しています。

なんだか雲行きが怪しくなってきました。

分岐先では種類に応じたコマンドラインオプションを生成し、それを platform/bq/bq.pyrun_mainに渡しています。
その先でさらに渡されたオプションを元に認証を再度やり直しますが、そこで使われている認証ライブラリはthird_party/oauth2client_4_0 で、Google Cloud SDKの認証系とは別物*5であり、Workload Identity認証にも非対応です。

つまるところ、bqコマンドはSDKに入ってはいるものの、内部は完全に別の作りになっていて、起動部分で強引に認証方法の辻褄合わせをしているために、引き渡せないでいたわけです。起動と言ってもただのPythonの関数呼び出しなのでCLIオプション形式にする必要すらないはずなのですが、bqの中身は意地でも触りたくなかったのでしょうか…?

皮肉なことに、上記の

    store.Load()  # Checks if there are active credentials

を実行した時点で、実はWorkload Identityの認証は内部的に解決されており、返り値としてAPI呼び出しに必要なトークンはすでに取得できています。

ならばあとは引き渡してやれば良いのだろう? ということで、簡単なパッチを書いてみました(本記事の末尾に置いておきます)。
やっていることは、上記 store.Load() で取れていたトークンをオブションとしてそのまま bq.py に渡し、API呼び出し時に使うというだけです。
これで bq コマンドが Workload Identity 対応になってしまいました(笑)。

$ bq ls
       datasetId
 ---------------------
...

カスタムパッチを実運用に投入するのは微妙なので、これも将来的にgcloud bqに統合されていくのを待つことになるのでしょうが、こんなに簡単なら暫定で bq コマンドでも対応してくれないかな…という気持ちになりました。

まとめ

本記事では、Workload Identityによる認証を利用して、AWS上でgcloud CLIを使う方法を書きました。
AWS以外のOIDCプロバイダでも利用可能なはずです(GitHub Actions等の利用例は多く公開されていますね)。
現状、gsutilやbqについては残念な状況ですが、alphaコマンドを使ったり簡単なパッチ適用で半ば無理やり動かすことはできました。
簡単かつセキュアになるので、ぜひ早くもっと利用しやすくなればなと期待しています。

bqコマンドをWorkload Identity対応にするパッチ

diff -ur google-cloud-sdk/bin/bootstrapping/bq.py google-cloud-sdk-patched/bin/bootstrapping/bq.py
--- google-cloud-sdk/bin/bootstrapping/bq.py    1980-01-01 08:00:00.000000000 +0000
+++ google-cloud-sdk-patched/bin/bootstrapping/bq.py    2021-11-12 17:06:00.747650228 +0000
@@ -38,7 +38,7 @@
   args = []
   if cmd_args and cmd_args[0] not in ('version', 'help'):
     # Check for credentials only if they are needed.
-    store.Load()  # Checks if there are active credentials
+    cred = store.Load()  # Checks if there are active credentials

     project, account = bootstrapping.GetActiveProjectAndAccount()
     adc_path = config.Paths().LegacyCredentialsAdcPath(account)
@@ -57,8 +57,11 @@
                 '--service_account_credential_file', single_store_path,
                 '--service_account_private_key_file', p12_key_path]
       else:
-        # Don't have any credentials we can pass.
-        raise store.NoCredentialsForAccountException(account)
+        if len(cred.token) > 0:
+          args = ['--access_token', cred.token]
+        else:
+          # Don't have any credentials we can pass.
+          raise store.NoCredentialsForAccountException(account)

     _MaybeAddOption(args, 'project_id', project)

diff -ur google-cloud-sdk/platform/bq/bq_auth_flags.py google-cloud-sdk-patched/platform/bq/bq_auth_flags.py
--- google-cloud-sdk/platform/bq/bq_auth_flags.py       1980-01-01 08:00:00.000000000 +0000
+++ google-cloud-sdk-patched/platform/bq/bq_auth_flags.py       2021-11-12 16:26:43.453653046 +0000
@@ -44,3 +44,7 @@
     'service_account_credential_file', None,
     'Only for the gcloud wrapper use.'
 )
+flags.DEFINE_string(
+    'access_token', '',
+    'Only for the gcloud wrapper use.'
+)
diff -ur google-cloud-sdk/platform/bq/credential_loader.py google-cloud-sdk-patched/platform/bq/credential_loader.py
--- google-cloud-sdk/platform/bq/credential_loader.py   1980-01-01 08:00:00.000000000 +0000
+++ google-cloud-sdk-patched/platform/bq/credential_loader.py   2021-11-12 16:29:54.002440206 +0000
@@ -61,6 +61,16 @@
     raise NotImplementedError()


+class AccessTokenCredentialLoader(CredentialLoader):
+  """Use the specified access token."""
+
+  def __init__(self, access_token):
+    self._access_token = access_token
+
+  def _Load(self):
+    return oauth2client_4_0.client.AccessTokenCredentials(self._access_token, _CLIENT_USER_AGENT)
+
+
 class CachedCredentialLoader(CredentialLoader):
   """Base class to add cache capability to credential loader.

@@ -276,6 +286,10 @@
         credential_cache_file=FLAGS.credential_file,
         read_cache_first=True,
         credential_file=FLAGS.application_default_credential_file)
+
+  if FLAGS.access_token:
+    return AccessTokenCredentialLoader(FLAGS.access_token)
+
   raise app.UsageError(
       'bq.py should not be invoked. Use bq command instead.')

*1:セキュリティ向上のために最小限に絞りましょう。

*2:自明な解決策は、スクリプトをPython等で書き直してApplication Default Credentialを使うことです。規模が大きいと大変ですけど。。

*3:余談ですが、Python3がインストールされていないとエラーで動作しません。

*4:alphaですから、いきなりインターフェイスが変わったりするかもしれないので注意しましょう、念の為。

*5:SDK内部に持ってはいるんですが。。。