App Engineのフレキシブル環境でGoogleドライブにアクセスする時のハマりポイント

5 min read読了の目安(約5200字

App Engineにデプロイしたサービスから何かファイルを出力したいとなったとき、普通は出力先としてCloud Storageを使うかと思います。しかし、今回は非エンジニアに出力ファイルを共有するというシチュエーションだったため、出力先としてGoogleドライブを選択しました。

その調査と実装をする中でハマったポイントと解決方法を紹介します。使用する言語はPythonです。もし間違った部分があればコメントでご指摘いただけると幸いです。

Google Drive APIの利用

google-api-python-clientというライブラリを使うか、これをラップしたPyDrive,PyDrive2というライブラリを使うことでGoogle Drive APIを簡単に利用できます。今回は前者を使いましたが、この後紹介するハマりポイントはライブラリの選択には依らない(ハズ)です。

エラー発生まで

App Engineのフレキシブル環境にアプリケーションをデプロイし、その中でGoogleドライブのある特定のフォルダにファイルを出力することを考えます。前提として、App Engineを動かすGCPプロジェクトのGoogle Drive APIを有効化すること、出力先Googleドライブフォルダの共有設定でApp Engineのデフォルトサービスアカウントのメールアドレスを追加すること、これらは済んでいます。

上記のどちらのAPIクライアントライブラリを使うにせよ、APIを利用するためには認証情報を取得する必要があります。google-authというライブラリを使うと、GCPがホスティングしている環境やGoogle Cloud SDKがインストールされている環境なら簡単に認証情報を取得できます。
そこで、このライブラリを使って以下のようなコードを実行すると、エラーが発生しました。

# 実行例
import google.auth
from googleapiclient import discovery

crds, prj = google.auth.default(scopes=["https://www.googleapis.com/auth/drive","https://www.googleapis.com/auth/cloud-platform"])
service = discovery.build("drive", "v3", credentials=crds)
service.files().list().execute()
# エラー内容
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/env/lib/python3.6/site-packages/googleapiclient/_helpers.py", line 134, in positional_wrapper
    return wrapped(*args, **kwargs)
  File "/env/lib/python3.6/site-packages/googleapiclient/http.py", line 915, in execute
    raise HttpError(resp, content, uri=self.uri)
googleapiclient.errors.HttpError: <HttpError 403 when requesting https://www.googleapis.com/drive/v3/files?alt=json returned "Insufficient Permission: Request had insufficient authentication scopes.". Details: "Insufficient Permission: Request had insufficient authentication scopes.">

エラー原因の特定

エラー内容はInsufficient Permission: Request had insufficient authentication scopes.つまり認証されたアクセススコープでは不十分というもの。しかし、scopes引数で指定しているのはGCPサービスとGoogleドライブにおけるほぼ完全なアクセススコープであり、十分なはず。何らかの原因で、取得したクレデンシャルがGoogleドライブへのアクセススコープを持っていないと考えられます。

ここで使用したgoogle.auth.default()という関数は、Application Default Credentials(ADC)という仕組みでクレデンシャルを取得します。この関数のドキュメントに記載されている通り、プログラムの実行環境に応じて次の順序でクレデンシャルの作成を試みます。

  1. 環境変数GOOGLE_APPLICATION_CREDENTIALSに設定されているパスがサービスアカウントのJSONキーファイルならそれを使う
  2. Google Cloud SDKがインストールされていてADCが設定済みならそれを使う
  3. App Engineのスタンダード環境ならApp Identity Serviceからクレデンシャルを得る
  4. Compute EngineかApp Engineのフレキシブル環境ならMetadata Serviceからクレデンシャルを得る

今回の実行環境はApp Engineのフレキシブル環境なので、環境変数GOOGLE_APPLICATION_CREDENTIALSが設定されていなければ4.の方法でクレデンシャルを得ます。他の方法で得られるクレデンシャルとは異なり、Metadata Serviceから得られるクレデンシャルはアクセススコープを変更できないという仕様となっていました。(ドキュメント

google-authライブラリのPR#633を見てみると以下の記述があります。

I've tested this on App Engine standard and Cloud Run, and using google.auth.default(scopes=..) works now. I tested on Compute Engine and requesting scopes has no effect, but does not raise an exception (which is expected).

App Engineのフレキシブル環境はCompute Engineと同じでMetadata Serviceからクレデンシャルを得るので、scopes引数は無効だが例外は発生しない、という仕様になっていることがわかります。

さらに、Compute Engineの場合はメタデータを編集した後、インスタンスを再起動することでアクセススコープを変更することができるようですが、App Engineの場合は仕組みが異なりインスタンスの停止・再起動といった操作はできないため、この方法を取ることができませんでした。(ドキュメント

解決方法

結論、App Engineのフレキシブル環境においてgoogle.auth.default()で取得できるクレデンシャル(=ADC)のアクセススコープを変更することはできません。

そこで、App EngineのデフォルトサービスアカウントのJSONキーファイルを作成し、それをSecret Manager経由でApp Engineにアップロードして認証情報を得るという方法を取りました。Metadata Serviceから得られるクレデンシャルも同じサービスアカウントを表すものですが、サービスアカウントキーから作成する場合はアクセススコープの変更が可能です。とはいえこのJSONキーファイルはいわば秘密鍵なので、そのままApp Engineにデプロイするわけにはいきません。そこで、作成したJSONキーファイルを一度ローカルからSecret Managerに置き、App Engine上のアプリケーションから取得させるようにしました。ADCはGCPサービスに対しては普通に有効なので、あらかじめサービスアカウントに「Secret Managerシークレットアクセサー」のようなロールを付与しておけばJSONキーファイルをダウンロードできます。サービスアカウントキーからクレデンシャルを得る場合はgoogle.auth.load_credentials_from_file()という関数を使います。

App Engineにデプロイしたアプリケーション内で実行するコードは以下のようになります。

import tempfile
import google.auth
from google.cloud import secretmanager
from googleapiclient import discovery

sm_client = secretmanager.SecretManagerServiceClient()
secret_name = sm_client.secret_version_path(project, secret_id, secret_version)
response = sm_client.access_secret_version(request={"name": secret_name})
tfile = tempfile.NamedTemporaryFile()
tfile.write(response.payload.data)
tfile.seek(0)
crds, _ = google.auth.load_credentials_from_file(tfile.name, scopes=scopes)

service = discovery.build("drive", "v3", credentials=crds)
service.files().list().execute()

最後に

当初はなるべく機密データをApp Engineにアップロードして認証を行わないようにと考えていたのですが、ここまでやって結局サービスアカウントキーファイルという機密データをSecret Manager経由でアップロードすることになったので、はじめからOAuthクライアントIDという別の認証方法を使った方がスムーズだったかもしれません。以下の記事によるとユーザーアカウントとしてアクセスするかサービスアカウントとしてアクセスするかという違いがあるらしいので、Googleドライブ側でサービスアカウントのメールアドレスに対して共有設定を行う手順も不要になりそうです。

あまり筋の良いやり方ではない気がしているので、そもそものアプローチが間違っているのかもしれません。より良いやり方をご存知の方がいましたらぜひコメントで教えていただきたいです。よろしくお願いします。

この記事に贈られたバッジ