はじめに
こんにちは、技術本部ML・データ部MLOpsブロックの鹿山(@Ash_Kayamin)です。MLOpsブロックではバッチ実行環境としてVertex AI Pipelinesを用いています。Vertex AI PipelinesはGCPマネージドなKubeflow Pipelinesを提供するサービスで、コンテナ化した処理に依存関係をもたせたパイプラインを定義し実行できます。この記事ではVertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントへ通信するために、NATを利用して通信元IPアドレスを固定した方法と実装のはまりどころについてご紹介します。
Vertex AI Pipelinesの利用例については過去の記事で紹介していますので、併せてご覧ください。
目次
- はじめに
- 目次
- 課題:Vertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントと通信したい
- 解決策:ピアリングしたVPCにNATインスタンスを作成し、NATインスタンス経由で外部通信させる
- 通信元IPアドレスを固定するための最小構成
- NATインスタンスを冗長化した構成
- 終わりに
課題:Vertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントと通信したい
先日、Vertex AI Pipelinesで実行するバッチの中で、IPアドレス制限を課しているエンドポイントと通信することが必要になりました。しかしながら、Vertex AI Pipelinesで起動するノードからの通信の通信元IPアドレスは固定されず、またパイプラインの実行パラメータ等で指定もできません。そのため、デフォルトではIPアドレス制限を課しているエンドポイントと通信できません。通信先のIPアドレス制限に対応するため、通信元IPアドレスを固定する方法を検討する必要がありました。
解決策:ピアリングしたVPCにNATインスタンスを作成し、NATインスタンス経由で外部通信させる
GCP公式ブログでVertex AI Pipelinesを様々なネットワーク構成で利用する方法が紹介されています。今回作成した構成はこちらを参考にさせていただきました。公式ブログで紹介されている構成のおおまかな流れは以下になります。
- ユーザーが管理するVPCとGoogleの共有VPCをピアリングする。
- ユーザーが管理するVPCにNATインスタンスを作成する。
- Googleの共有VPCで起動するノードからの特定の宛先への外部通信がNATインスタンスを経由するようにカスタムルートを作成する。
- Vertex AI pipelinesのパイプラインパラメータ
VPC network
に、ピアリングしたユーザー管理のVPCを指定してパイプラインを実行する。
パイプラインパラメータVPC network
にピアリングしたユーザー管理のVPCを指定することで、ピアリング先のGoogleの共有VPCでノードを起動できます。このGoogleの共有VPC内で起動したノードから特定の宛先へ外部通信する際にNATインスタンスを経由させることで、通信元IPアドレスをNATインスタンスに付与したIPアドレスに固定します。
ネットワーク構成の概略図は以下になります。こちらの詳細については後ほどご説明します。
公式ブログの構成例ではNATインスタンスの耐障害性が考慮されていません。そこで、Managed Insntance Group(MIG)とCloud NATを利用することでNATインスタンスが担う機能に冗長性を持たせました。MIGはインスタンスの設定を記載したテンプレートからインスタンスを起動した上で、インスタンスのヘルスチェック・再起動・負荷に応じたスケーリング等を自動的に行ってくれます。リージョンMIGを使用すると、インスタンスを複数のゾーンに配置できます。また、Cloud NATはGCPマネージドなNATを提供するサービスになります。まず、Vertex AI Pipelinesで起動したノードからの外部通信は、MIGで起動したNATインスタンスを経由するようにしました。加えて、NATインスタンスからの外部通信はCloud NATに付与したIPアドレスを通信元IPアドレスとするようにCloud NATを設定しました。MIGとCloud NATを合わせて利用することで、NATインスタンスに冗長性を持たせた通信元IPアドレスの固定を実現しました。
ネットワーク構成の概略図は以下になります。こちらの詳細についても後ほどご説明します。
通信元IPアドレスを固定するための最小構成
この章では、NATインスタンスを用いて通信元IPアドレス固定を実現する最小構成の構築手順と利用方法についてご説明します。
図の例では、Vertex AI Pipelinesで起動したノードから、カスタムルートを設定しているIPアドレスx.x.x.x
へ通信した場合、最終的にNATインスタンスから宛先アドレスx.x.x.x
へ通信元アドレスy.y.y.y
で通信が行われます。ルーティングの例を以下に示します。
手順1. プライベートサービスアクセスを用いてユーザー管理のVPCとGoogleの共有VPCをピアリングする
まずVPC Aを作成し、公式ドキュメントの手順に従ってGoogleの共有VPCとピアリングします。ピアリングをすることで、VPC間で内部通信できるようになります。ピアリングにあたっては、CIDRを指定する必要があります。このCIDRはユーザー管理のVPC内の他のサブネットで利用しているCIDRと重複してはいけません。Vertex AI Pipelinesでパイプラインを実行する際のパラメータVPC network
にピアリング済みのVPC Aを指定すると、パイプラインで利用するノードのIPアドレスはここで指定するCIDRから割り当てられるようになります。
Googleの共有VPCとピアリングした際のGCPマネジメントコンソールでのVPC Peeringの表示は以下のようになります。
ここで指定するCIDRのレンジが小さいと、同じネットワークを指定して複数のパイプラインを実行した際に、IPアドレスが枯渇してパイプラインを実行できなくなる可能性があります。そのため十分な大きさのレンジを割り当てるように注意します。Vertex AI Pipelinesで実行するパイプラインに含まれる各コンポーネントの処理はVertex AI TrainingのCustom Jobとして実行されます。割り当てるレンジと、Custom Jobで起動できるノードの数の関係は公式ドキュメントに記載があります。例えば/16を割り当てると最大63ジョブ(1ジョブ当たり8ノードと仮定)を並列実行できます。つまり、並列で63個のコンポーネントを同時に実行できますが、これ以上のコンポーネントを実行しようとするとIPアドレス不足でエラーが発生します。また、公式ドキュメントの通り、同じGCPプロジェクト内で特定のネットワークを指定して実行しているパイプラインがある場合、そのパイプラインを実行中は別のネットワークを指定したパイプラインは実行できないことにも注意が必要です。
加えて、Vertex AI Pipelinesで起動するノードから、VPC A内のサブネットに作成するNATインスタンスへの通信を許可するため、ここで指定するCIDRからの通信を許可するファイアウォールルールをVPC Aに作成します。
手順2. 外部通信用VPC、サブネットおよびNATインスタンスを作成する
次にVPC AのサブネットA、VPC BならびにVPC BのサブネットBを作成します。そして、サブネットA、Bそれぞれに接続した2つのネットワークインタフェースを持つCompute Engineインスタンスを作成します。サブネットBに接続するネットワークインタフェースにはパブリックIPを割り当てます。Compute EngineのインスタンスをNATとして機能させるため、以下コマンドをインスタンスの起動時に実行されるスクリプトとして設定します。
# サブネットAに10.95.0.0/16, サブネットBに10.97.0.0/16を割り当てている前提で # それぞれのサブネットに接続しているネットワークインタフェース名を取得する private_interface=$(ifconfig | grep 10.95 -B 1 | head -n 1 | awk -F: {'print $1'}) public_interface=$(ifconfig | grep 10.97 -B 1 | head -n 1 | awk -F: {'print $1'}) # フォワーディングとマスカレードを有効化 sysctl -w net.ipv4.ip_forward=1 sudo iptables -t nat -A POSTROUTING -o "$public_interface" -j MASQUERADE # サブネットBのデフォルトゲートウェイをネクストホップとするデフォルトルートを作成 sudo ip route add default via 10.97.0.1 dev "$public_interface" # Googleの共有VPCとのピアリングで10.98.0.0/21を割り当てている前提で # Vertex AI Pipelinesで起動したノードへの戻り通信(=宛先が10.98.0.0/21)の場合はサブネットAのデフォルトゲートウェイをネクストホップとする sudo ip route add 10.98.0.0/21 via 10.95.0.1 dev "$private_interface" # Cloud IAPの戻り通信(=宛先が35.235.240.0/20)の場合はサブネットAのデフォルトゲートウェイをネクストホップとする. インスタンスへIAPを用いてSSH接続したい場合に必要 sudo ip route add 35.235.240.0/20 via 10.95.0.1 dev "$private_interface"
このスクリプトではサブネットAに属するネットワークインタフェースで受けた通信を、サブネットBに属するネットワークインタフェースからVPC Bのデフォルトゲートウェイへ送るデフォルトルートを作成します。IPマスカレードの設定とこのデフォルトルートにより、NATインスタンスのサブネットAのネットワークインタフェースで受けた通信は、サブネットBのネットワークインタフェースに割り当てたパブリックIPアドレスを通信元IPアドレスとして、サブネットBのデフォルトゲートウェイ経由で外部と通信します。
また、Vertex AI Pipelinesで起動したノードへの戻り通信を考慮したルートを追加していることに注意してください。戻り通信に対しては、行きの通信を受けたサブネットAのデフォルトゲートウェイをネクストホップとしています。このルートがないと、デフォルトルートによって戻り通信がサブネットBのデフォルトゲートウェイにルーティングされてしまい、Vertex AI Pipelinesで起動したノードと通信ができません。
手順3. カスタムルートを作成してGoogleの共有VPCにエクスポートする
最後にNATインスタンス経由で通信させたい外部IPアドレスを定め、このIPアドレスへの通信を手順2で作成したNATインタンスへルーティングするカスタムルートをVPC Aに作成します。そしてVPC Peeringの設定でカスタムルートのエクスポートを有効にし、このルートをVertex AI Pipelinesのノードが起動するGoogleの共有VPCへエクスポートします。
カスタムルートのエクスポートを有効にした際の、GCPマネジメントコンソールでのVPC Peeringの表示は以下のようになります。
ここで注意して欲しいのが、VPC Peeringではデフォルトルートならびに、通信元のIPアドレスをベースにしたルーティングを行うルートのエクスポートはサポートされていないことです。デフォルトルート(宛先IPアドレス0.0.0.0/0
に対するカスタムルート)のエクスポートはサポートされていないので、Googleの共有VPCからの全ての通信をNATインスタンス経由にするようなルートを作成しても反映されません。デフォルトルートを作成するとGCPマネジメントコンソール上はピアリング先へのルートのエクスポートが成功した表示になります。しかしながら、実際にはこのルートは適用されないので注意してください。また、2023年1月には通信元のIPアドレスをベースにしたルーティング(Policy-based routes)がプレビュー機能として提供され始めましたが、こちらはピアリング先にはエクスポートされません。したがって、NATインスタンス経由にしたい宛先ごとにカスタムルートを作成してエクスポートする必要があります。
今回の構成において2つのVPCを作成しているのはこの理由からです。VPC Aに、通信したい特定の宛先への通信をNATインスタンスにルーティングするルートを作成するため、VPC A内からは通信したい特定の宛先への外部通信はできません。NATインスタンスから出た通信が再びNATインスタンスにルーティングされて戻ってきてしまうためです。この問題を解消するためにVPC Bを作成しVPC Aで受けた通信をVPC Bから外部通信するようにしています。
NATインスタンスを冗長化した構成
ここまで説明した最小構成ではNATインスタンスの部分が冗長化されていません。そのためNATインスタンスに問題が発生した場合、Vertex AI Pipelinesで起動したノードからカスタムルートを設定している特定の宛先への外部通信が一切行えなくなってしまいます。そこでNATインスタンスを冗長化した構成を作成しました。最小構成との差分をこの章で解説します。
再掲ですが、構築した冗長構成を以下の図に示します。
図の例では、Vertex AI Pipelinesで起動したノードから、カスタムルートを設定しているIPアドレスx.x.x.x
へ通信をした場合、最終的にCloud NATから宛先アドレスx.x.x.x
へ通信元アドレスy.y.y.y
で通信が行われます。ルーティングの例を以下に示します。
差分1. Managed Instance Groupを用いてNATインスタンスを2台作成する
まず、リージョンMIGを用いて2つの異なるゾーンでインスタンスを起動するように変更しました。MIGはインスタンスの設定を記載したテンプレートからインスタンスを起動した上で、インスタンスのヘルスチェック、再起動ならびに負荷に応じたスケーリング等を自動で行います。MIGからのヘルスチェックに対応するため、起動時に実行するスクリプトの末尾に以下のコマンドを追加しました。
# MIGからのヘルスチェックの戻り通信(=宛先が35.191.0.0/16、 130.211.0.0/22) の場合はサブネットAのデフォルトゲートウェイをネクストホップとする. MIGのヘルスチェックでの通信に必要 sudo ip route add 35.191.0.0/16 via 10.95.0.1 dev "$private_interface" sudo ip route add 130.211.0.0/22 via 10.95.0.1 dev "$private_interface" # 外部への接続が可能なことを確認するヘルスチェックエンドポイントをPythonで作成 cat <<EOF > /usr/local/sbin/health-check-server.py #!/usr/bin/env python from http.server import BaseHTTPRequestHandler、 HTTPServer import subprocess PORT_NUMBER = 8080 PING_HOST = "example.com" def connectivityCheck(): try: subprocess.check_call(["ping", "-c", "1", PING_HOST]) return True except subprocess.CalledProcessError as e: return False class MyHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/health-check': if connectivityCheck(): self.send_response(200) self.end_headers() self.wfile.write(b"OK") else: self.send_response(503) else: self.send_response(404) try: server = HTTPServer(("", PORT_NUMBER), MyHandler) print(f"Started httpserver on port {PORT_NUMBER}") #Wait forever for incoming http requests server.serve_forever() except KeyboardInterrupt: print("^C received, shutting down the web server") server.socket.close() EOF # ヘルスチェックエンドポイントを起動 nohup sudo python3 /usr/local/sbin/health-check-server.py >/dev/null 2>&1 &
加えて、MIGからのヘルスチェックの通信を許可するファイアウォールルールをVPC Aに追加しました。ヘルスチェックエンドポイントではリクエストを受けると外部通信し、通信に成功した場合には200 OK、失敗した場合には503 Service Unavailableを返します。こうすることで、何らかの問題が発生してインスタンスから外部通信が行えなくなりNATインスタンスとしての役割を果たせなくなった際に、MIGで障害を検出して自動的にインスタンスを再起動できます。
差分2. ロードバランサーのバックエンドにManaged Instance Groupを設定し、ロードバランサー経由でNATインスタンスに接続する
次に、内部ロードバランサーを作成し、ロードバランサーのバックエンドに差分1で作成したMIGを設定します。そして、この内部ロードバランサーをカスタムルートでのルーティング先に変更します。ロードバランサーのヘルスチェック機能で、MIGのいずれかのインスタンスで障害が発生した場合、障害が発生したインスタンスはロードバランサーのルーティング対象から自動的に除外されます。NAT経由にしたい通信をLBを介してNATインスタンスにルーティングすることで、特定のNATインスタンスに障害が発生した場合でも、他の正常なNATインスタンスを利用して外部通信を継続できます。
差分3. Cloud NATを用いて、NATインスタンスからの外部通信の通信元IPアドレスを固定する
MIG管理のインスタンスに、事前に作成した固定のパブリックIPアドレスを付与する場合、インスタンステンプレートのネットワークインタフェース設定に固定のパブリックIPアドレスを記載することになります。この場合、同じパブリックIPアドレスは1つのインスタンスにしか付与できないので、同じテンプレートを利用するMIGでは1台しかインスタンスを起動できないという問題が発生します。この問題を解決するため、サブネットBからの外部通信は全てCloud NAT経由で通信をするように変更し、NATインスタンスのネットワークインタフェースに付与していたパブリックIPアドレスは削除します。こうすることで、MIGで作成する個別のインスタンスに固定のパブリックIPアドレスを付与する必要がなくなり、MIGで複数のインスタンスを作成できるようになります。
実際に冗長構成で通信する例
冗長構成を実際に構築して通信をする例を示します。Vertex AI Pipelinesでパイプラインを実行する際のパラメータVPC network
に手順1で作成したVPC Aを指定します。手順2で予めカスタムルートを作成した宛先へ外部通信する処理をパイプライン内で行うと、Clound NAT経由での外部通信になります。
実際に通信した際のカスタムルートの設定、Cloud NATの設定、Vertex AI Pipelinesのコンテナログを以下に示します。
Vertex AI Pipelinesのコンテナログに出力されている通り、Vertex AI Pipelinesが起動したノードで実行したコンテナからifconfig.ioへcurlしています。curlの出力をみると、カスタムルートを設定しているIPアドレス172.64.110.32
への通信をしています。そしてcurlのレスポンスを見ると、通信の通信元IPアドレスがCloudNATへ割り当てたパブリックIPアドレス34.37.88.80
になっていることがわかります。
終わりに
今回はNATを利用し、Vertex AI Pipelinesで起動するノードから通信する際の通信元IPアドレスの固定を実現しました。これにより、Vertex AI Pipelinesで起動したノードからIPアドレス制限があるエンドポイントへ通信できるようになりました。
今後の活用方針としては例えば、複数の異なるGCPプロジェクトで実行するパイプラインから利用する共通機能を作成する際の活用が考えられます。1つのGCPプロジェクトに作成した共通機能のエンドポイントにIPアドレス制限を設けることで、異なるGCPプロジェクトで実行する通信元IPアドレスを固定したパイプラインからの外部通信は許可しつつ、他の管理されていない環境からの通信を弾くことができます。
なお複数のGCPプロジェクト間のネットワークをつなぐサービスとしてShared VPCがありますが、Vertex AI Pipelinesではその恩恵にあずかれないので注意してください。Shared VPCを用いてGCPプロジェクト間で通信可能なネットワークを構築していても、Vertex AI Pipelinesで起動するノードから他のGCPプロジェクトのリソースへは内部通信はできません。公式ドキュメントに記載の通り、VPC Peeringでは推移的なルーティングはサポートされていません。そのため、Googleの共有VPCとShared VPCの1つのVPCをピアリングしても、直接ピアリングしたVPC間でしか通信できず、Shared VPCに含まれる他のVPCとGoogleの共有VPCは通信できません。またVertex AI Pipelinesで必要とするCIDRは比較的大きいので、Shared VPCに属するVPCをピアリング対象とするとShared VPC全体のCIDRを逼迫してしまう可能性もありおすすめできません。
今後は今回作成した仕組みを用いて、より運用負荷とセキュリティリスクを低減したMLバッチの実行環境を構築していく予定です! ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!