30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Dataproc クラスタで PySpark ジョブを実行して学ぶサービスアカウントの初歩

Dataproc は Google Cloud が提供しているサービスのひとつ。Dataproc を使うことで、Apache Spark や Hadoop を利用するためのインフラを簡単に用意することができる。
そして Python で Apache Spark を使うための仕組みが PySpark 。

Dataproc を利用する際には複数のサービスアカウントが必要になるのだが、それぞれに必要な権限を与えないと Dataproc を利用することはできない。
また、Dataproc API を有効にすると複数のサービスアカウントが自動的に作成される。サービスアカウントを適切に管理するためには、これらの内容や用途も理解しておく必要がある。

この記事では、「Dataproc クラスタで PySpark ジョブを実行する」ことを題材にして、サービスアカウントにはどのような種類があるのか、そしてそれはどのように使われるのか見ていく。

基本的に gcloud CLI を使って操作していく。
gcloud CLI の基本的な使い方については以下に書いた。

numb86-tech.hatenablog.com

また、作業の都合上、オーナー権限(roles/owner)を持ったユーザーアカウントで gcloud CLI の認証を行っている。
途中で認証するアカウントを変えるが、その際は明示する。

プロジェクトの準備

どのような操作をするとどのようなサービスアカウントが作られるのか分かりやすくするために、今回は Google Cloud プロジェクトを作るところから始める。
以下のコマンドでsample-pjプロジェクトを作成する。

$ gcloud projects create sample-pj

作成したプロジェクトを gcloud CLI のカレントプロジェクトに設定する。

$ gcloud config set core/project sample-pj

Dataproc クラスタを利用するためには請求先アカウントを設定する必要があるが、プロジェクト作成直後は設定されていない。

$ gcloud billing projects describe sample-pj
billingAccountName: ''
billingEnabled: false
name: projects/sample-pj/billingInfo
projectId: sample-pj

今回は予め用意しておいた請求先アカウントを使う。既存の請求先アカウントの ID は$ gcloud billing accounts listで確認できる。

$ gcloud billing accounts list
ACCOUNT_ID            NAME                OPEN   MASTER_ACCOUNT_ID
XXXXXX-XXXXXX-XXXXXX  請求先アカウント 1  True

以下のコマンドで請求先アカウントをプロジェクトに設定できる。

$ gcloud billing projects link sample-pj --billing-account=XXXXXX-XXXXXX-XXXXXX
billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX
billingEnabled: true
name: projects/sample-pj/billingInfo
projectId: sample-pj

sample-pjの IAM ポリシーで使われているサービスアカウントを以下のコマンドで調べてみる。

$ gcloud projects get-iam-policy sample-pj --flatten="bindings[].members" --filter="bindings.members:serviceAccount" --format="table(bindings.role, bindings.members)"

何も表示されないので、この時点ではまだ存在しないことが分かる。

自動生成されるサービスアカウント

Dataproc を使うために Dataproc API を有効にする。

$ gcloud services enable dataproc.googleapis.com

そうすると新たに以下の API が有効になる。

  • compute.googleapis.com
  • dataproc-control.googleapis.com
  • dataproc.googleapis.com
  • oslogin.googleapis.com

Compute Engine も有効になるのは、Dataproc クラスタは Compute Engine のインスタンス上に作られるため。
Google Kubernetes Engine のクラスタ上に Dataproc クラスタを作るパターンもあるが、この記事では扱わない。

そして Dataproc API を有効にしたこのタイミングで、複数のサービスアカウントが設定される。

$ gcloud projects get-iam-policy sample-pj --flatten="bindings[].members" --filter="bindings.members:serviceAccount" --format="table(bindings.role, bindings.members)"
ROLE                         MEMBERS
roles/compute.serviceAgent   serviceAccount:service-123456789@compute-system.iam.gserviceaccount.com
roles/dataproc.serviceAgent  serviceAccount:service-123456789@dataproc-accounts.iam.gserviceaccount.com
roles/editor                 serviceAccount:123456789-compute@developer.gserviceaccount.com
roles/editor                 serviceAccount:123456789@cloudservices.gserviceaccount.com

上記のサービスアカウントを全て使う必要はない。バックグラウンドで Google Cloud が使うためのものであるため、ユーザーは特に意識しなくてもよいサービスアカウントもある。
そのことを理解するためにはまず、「Dataproc を使用する際に必要なサービスアカウントは何か、それはどのように使われるか」を先に理解したほうがよいので、それを見ていく。

サービスアカウントを使用する 3 つの要素

Dataproc クラスタを利用する際、3 つの要素でサービスアカウントが使われる。

  • Dataproc API ユーザー
    • Dataproc サービスを呼び出して、クラスタの作成やジョブの送信などを行う
  • コントロールプレーン
    • Compute Engine リソースの作成、Cloud Storage のバケットの作成とそれに対する読み書き、などを行う
  • データプレーン
    • 以下を行う
      • コントロールパネルと通信
      • Cloud Storage のバケットへの読み書き
      • BigQuery などとのやり取りが必要なら、それもデータプレーンが行う

公式ドキュメントに掲載されている図も載せておく。

https://cloud.google.com/dataproc/docs/concepts/iam/dataproc-principals?hl=ja

Dataproc クラスタを利用するためには、この 3 つそれぞれに対して必要な権限を持ったサービスアカウント(またはユーザーアカウント)を接続する必要がある。

コントロールプレーンについては、意識しなくてよい。
コントロールプレーンのために必要なサービスアカウントは Google Cloud が作成し管理するからだ。
このような「Google Cloud が作成し管理する」サービスアカウントはサービスエージェントと呼ばれる。

先ほど見た 4 つのサービスアカウントのうち以下の 3 つがサービスエージェントに該当する。

  • 123456789@cloudservices.gserviceaccount.com
    • 公式ドキュメントによれば「お客様に代わって内部の Google Cloud プロセスを実行」するのが、このサービスエージェントの役割
    • 恐らく特定のサービスに紐づくわけではない処理を担当しているのだと思われる
  • service-123456789@compute-system.iam.gserviceaccount.com
    • Compute Engine のサービスエージェント
    • 既に述べたように Dataproc は Compute Engine を利用するので、このサービスエージェントも作られる
  • service-123456789@dataproc-accounts.iam.gserviceaccount.com
    • Dataproc のサービスエージェント
    • これが自動的にコントロールプレーンに接続される
      • そのためユーザーは特に意識しなくてよい

3 つの要素のひとつである「Dataproc API ユーザー」についてはこの記事の最後で説明するので、一旦スキップする。

データプレーンが使用するサービスアカウントは、 Dataproc クラスタを作成するときに指定することができる。
「指定する」ではなく「指定することができる」と書いたのは、指定は必須ではないため。
指定しなかった場合は、「デフォルトのサービスアカウント」が Dataproc クラスタに接続される。
デフォルトのサービスアカウントは、特定の Google Cloud サービスを有効にするとき、または使用するときに Google Cloud によって自動的に作成される。

以下のサービスが、デフォルトのサービスアカウントを作成する。

サービス サービスアカウント名 メールアドレス
App Engine、および App Engine を使用する Google Cloud サービス App Engine default service account <PROJECT_ID>@appspot.gserviceaccount.com
Compute Engine、および Compute Engine を使用する Google Cloud サービス Compute Engine default service account <PROJECT_NUMBER>-compute@developer.gserviceaccount.com

既に説明したように Dataproc は「Compute Engine を使用する Google Cloud サービス」であるため、123456789-compute@developer.gserviceaccount.comが作られた。

デフォルトのサービスアカウントはこのように自動的に作成されるが、サービスエージェントとは異なり管理はユーザーが行う必要がある。

今回はデフォルトのサービスアカウントは使わず、データプレーンが使用するためのサービスアカウントを自分で作成することにする。

worker-for-data-planeという名前のサービスアカウントを作る。

$ gcloud iam service-accounts create worker-for-data-plane

この時点では何のロールも付与されていないので付与する。今回はroles/dataproc.workerを付与する。

$ gcloud projects add-iam-policy-binding sample-pj --member="serviceAccount:worker-for-data-plane@sample-pj.iam.gserviceaccount.com" --role="roles/dataproc.worker"

worker-for-data-plane@sample-pj.iam.gserviceaccount.comroles/dataproc.workerで追加されている。

$ gcloud projects get-iam-policy sample-pj --flatten="bindings[].members" --filter="bindings.members:serviceAccount" --format="table(bindings.role, bindings.members)"
ROLE                         MEMBERS
roles/compute.serviceAgent   serviceAccount:service-123456789@compute-system.iam.gserviceaccount.com
roles/dataproc.serviceAgent  serviceAccount:service-123456789@dataproc-accounts.iam.gserviceaccount.com
roles/dataproc.worker        serviceAccount:worker-for-data-plane@sample-pj.iam.gserviceaccount.com
roles/editor                 serviceAccount:123456789-compute@developer.gserviceaccount.com
roles/editor                 serviceAccount:123456789@cloudservices.gserviceaccount.com

Dataproc クラスタの作成

以下のコマンドでmy-clusterという名前の Dataproc クラスタを作成できる。
データプレーンで使用するサービスアカウントは--service-accountで指定できる。省略した場合、既に説明したように<PROJECT_NUMBER>-compute@developer.gserviceaccount.comが使われる。

$ gcloud dataproc clusters create my-cluster --service-account=worker-for-data-plane@sample-pj.iam.gserviceaccount.com --region=asia-northeast1 --single-node --master-machine-type=e2-standard-2

以下のエラーが出る。

ERROR: (gcloud.dataproc.clusters.create) INVALID_ARGUMENT: Subnetwork 'default' does not support Private Google Access which is required for Dataproc clusters when 'internal_ip_only' is set to 'true'. Enable Private Google Access on subnetwork 'default' or set 'internal_ip_only' to 'false'.

Compute Engine のインスタンス上に Dataproc クラスタが作られるわけだが、その Dataproc クラスタは Dataproc API を利用する。しかし Compute Engine インスタンスが内部 IP アドレスしか持たないため API にアクセスできず、エラーになってしまった。

ネットワークの設定を変えて「限定公開の Google アクセス」と呼ばれる設定を有効にすることで、このエラーを解消できる。
この設定を有効にすると、内部 IP アドレスしか持たないインスタンスでも Google Cloud の API にアクセスできるようになるためだ。

現在このプロジェクトにはdefaultというネットワークがある。

$ gcloud compute networks list
NAME     SUBNET_MODE  BGP_ROUTING_MODE  IPV4_RANGE  GATEWAY_IPV4
default  AUTO         REGIONAL

これはプロジェクトに最初から存在するネットワークで、Dataproc クラスタ作成時にネットワークを明示しない場合はこれが使われる。
今回はこのネットワークを使うので、このネットワークの設定を変える。

まずは現在の設定を確認する。

$ gcloud compute networks subnets describe default --region=asia-northeast1 | grep 'privateIpGoogleAccess'
privateIpGoogleAccess: false

privateIpGoogleAccessが無効になっているので有効にする。

$ gcloud compute networks subnets update default --region=asia-northeast1 --enable-private-ip-google-access

有効になっている。

$ gcloud compute networks subnets describe default --region=asia-northeast1 | grep 'privateIpGoogleAccess'
privateIpGoogleAccess: true

これで Dataproc クラスタを作成できるようになるので、改めて以下のコマンドを実行する。このとき--network--subnetを使うことで、使用するネットワークを指定できる。今回は指定していないので、先述したようにdefaultネットワークが使われる。

$ gcloud dataproc clusters create my-cluster --service-account=worker-for-data-plane@sample-pj.iam.gserviceaccount.com --region=asia-northeast1 --single-node --master-machine-type=e2-standard-2

Dataproc クラスタ一覧を見ると、確かにmy-clusterが作られている。

$ gcloud dataproc clusters list --region=asia-northeast1
NAME        PLATFORM  PRIMARY_WORKER_COUNT  SECONDARY_WORKER_COUNT  STATUS   ZONE               SCHEDULED_DELETE
my-cluster  GCE                                                     RUNNING  asia-northeast1-b

そしてmy-clusterにはworker-for-data-plane@sample-pj.iam.gserviceaccount.comが接続されている。

$ gcloud dataproc clusters describe my-cluster --region=asia-northeast1 --format="get(config.gce_cluster_config.service_account)"
worker-for-data-plane@sample-pj.iam.gserviceaccount.com

Compute Engine インスタンスが作られていることも確認しておく。

$ gcloud compute instances list
NAME          ZONE               MACHINE_TYPE   PREEMPTIBLE  INTERNAL_IP  EXTERNAL_IP  STATUS
my-cluster-m  asia-northeast1-b  e2-standard-2               10.146.0.2                RUNNING

そしてインスタンスmy-cluster-mにもworker-for-data-plane@sample-pj.iam.gserviceaccount.comが接続されている。

$ gcloud compute instances describe my-cluster-m --zone=asia-northeast1-b --format="get(serviceAccounts.email)"
worker-for-data-plane@sample-pj.iam.gserviceaccount.com

ジョブの送信

Dataproc クラスタを作成できたので、次はいよいよジョブを送信する。

まずは Dataproc クラスタに処理してもらいたいスクリプトを用意する。
以下の内容のexample.pyを用意した。

#!/usr/bin/python
import pyspark

# SparkContext を作成
sc = pyspark.SparkContext()

data = [('Alice', 34), ('Bob', 45), ('Cathy', 29), ('David', 40), ('Eve', 22), ('Frank', 38)]

# データを RDD に変換
rdd = sc.parallelize(data)

# 年齢が30以上の人をフィルタリング
filtered_sorted_rdd = rdd.filter(lambda x: x[1] >= 30).sortBy(lambda x: x[0])

# 結果を収集して出力
result = filtered_sorted_rdd.collect()
print(result)

以下のコマンドで、このスクリプトを処理するジョブを送信できる。

$ gcloud dataproc jobs submit pyspark ./example.py --cluster=my-cluster --region=asia-northeast1

送信すると様々なログが流れるが、そのなかに[('Alice', 34), ('Bob', 45), ('David', 40), ('Frank', 38)]というログが混じっているはず。
正しくフィルタリングされており、ジョブが実行されたことがわかる。

ここで、Dataproc クラスタに接続しているサービスアカウントworker-for-data-plane@sample-pj.iam.gserviceaccount.comのロールを変えてみる。
現在roles/dataproc.workerが付与されているので、これを外して代わりにroles/logging.viewerを付与する。

$ gcloud projects remove-iam-policy-binding sample-pj --member="serviceAccount:worker-for-data-plane@sample-pj.iam.gserviceaccount.com" --role="roles/dataproc.worker"

$ gcloud projects add-iam-policy-binding sample-pj --member="serviceAccount:worker-for-data-plane@sample-pj.iam.gserviceaccount.com" --role="roles/logging.viewer"

この状態でジョブを送信するとエラーになる。

$ gcloud dataproc jobs submit pyspark ./example.py --cluster=my-cluster --region=asia-northeast1
WARNING: Job terminated, but output did not finish streaming.
   ERROR: (gcloud.dataproc.jobs.submit.pyspark) Job [ed6de677d45a467c9e912e5ac78b263f] failed with error:
   Task was not acquired

このように、接続しているサービスアカウントに適切な権限が付与されていないと Dataproc クラスタにジョブを処理させることはできない。

roles/logging.viewerは外し再びroles/dataproc.workerを付与しておく。

「Dataproc API ユーザー」について

冒頭で述べたように gcloud CLI にはオーナー権限(roles/owner)を持ったユーザーアカウントで認証しており、その状態で gcloud CLI でジョブを送信したので、このユーザーアカウントが「Dataproc API ユーザー」にあたる。
このユーザーアカウントで Dataproc クラスタを作成し、このユーザーアカウントでジョブを送信した。

だが実際の開発現場では、セキュリティ的な観点から、オーナー権限を持ったプリンシパルで操作を行うことは稀なはず。
適切な権限を持たせたプリンシパルを用意してそれを使うことが多いと思う。

先ほど示したスクリプトの処理を Dataproc クラスタに実行させるためには、以下の 2 つのロールが付与されていればよい。

  • roles/dataproc.editor
  • roles/storage.objectUser

そのことを確認するために、上記のロールが付与されているサービスアカウントと、何のロールも付与されていないサービスアカウントを用意して、動作検証してみる。

まず、ロールが付与されていないサービスアカウントempty-for-api-userとそのキーを用意する。

$ gcloud iam service-accounts create empty-for-api-user
Created service account [empty-for-api-user].

$ gcloud iam service-accounts keys create empty-for-api-user-key.json --iam-account=empty-for-api-user@sample-pj.iam.gserviceaccount.com

次に、必要なロールが付与されたサービスアカウントviable-for-api-userとそのキーを作る。

$ gcloud iam service-accounts create viable-for-api-user
Created service account [viable-for-api-user].

$ gcloud iam service-accounts keys create viable-for-api-user-key.json --iam-account=viable-for-api-user@sample-pj.iam.gserviceaccount.com

$ gcloud projects add-iam-policy-binding sample-pj --member="serviceAccount:viable-for-api-user@sample-pj.iam.gserviceaccount.com" --role="roles/dataproc.editor"

$ gcloud projects add-iam-policy-binding sample-pj --member="serviceAccount:viable-for-api-user@sample-pj.iam.gserviceaccount.com" --role="roles/storage.objectUser"

まずはempty-for-api-userを使ってジョブを送信してみる。

$ gcloud auth login --cred-file=empty-for-api-user-key.json

$ gcloud dataproc jobs submit pyspark ./example.py --cluster=my-cluster --region=asia-northeast1
ERROR: (gcloud.dataproc.jobs.submit.pyspark) PERMISSION_DENIED: Permission 'dataproc.clusters.get' denied on resource '//dataproc.googleapis.com/projects/sample-pj/regions/asia-northeast1/clusters/my-cluster' (or it may not exist). This command is authenticated as empty-for-api-user@sample-pj.iam.gserviceaccount.com which is the active account specified by the [core/account] property.
- '@type': type.googleapis.com/google.rpc.ErrorInfo
  domain: dataproc.googleapis.com
  metadata:
    permission: dataproc.clusters.get
    resource: projects/sample-pj/regions/asia-northeast1/clusters/my-cluster
  reason: IAM_PERMISSION_DENIED

予想通り権限不足でエラーになった。

次にviable-for-api-userでジョブを送信すると上手くいくため、このサービスアカウントは必要な権限を持っていることを確認できた。

$ gcloud auth login --cred-file=viable-for-api-user-key.json

$ gcloud dataproc jobs submit pyspark ./example.py --cluster=my-cluster --region=asia-northeast1

このように、コントロールプレーン、データプレーン、Dataproc API ユーザーという 3 つの要素全てに対して適切な権限が割り当てられていないと、Dataproc クラスタを利用することはできないのである。

Dataproc クラスタを放置し続けるとその分だけ費用が発生するので、削除しておく。

$ gcloud dataproc clusters delete my-cluster --region=asia-northeast1

Dataproc クラスタや Compute Engine インスタンスが削除されていることを確認する。

$ gcloud dataproc clusters list --region=asia-northeast1
Listed 0 items.

$ gcloud compute instances list
Listed 0 items.

参考資料