📓 memotty

Cloud Runの外部IPアドレスを固定する

概要 #

Cloud Run単体では固定の外部IPアドレスは存在せず、デプロイごと・インスタンスごとにIPアドレスが動的に変更される。
外部APIで特定のIPアドレスからのみリクエストを許可している場合、デフォルトのCloud Runだと要件を満たせずリクエストができない状態になってしまう。

全体像 #

flowchart LR subgraph GCP[Google Cloud(同一リージョン)] subgraph CR[Cloud Run サービス] i1[インスタンス #1] i2[インスタンス #2] iN[インスタンス #N] end i1 -- "Direct VPC egress(すべてのトラフィック)" --> VPC[(VPC サブネット)] i2 -- "Direct VPC egress(すべてのトラフィック)" --> VPC iN -- "Direct VPC egress(すべてのトラフィック)" --> VPC VPC --> Router[Cloud Router] Router --> NAT[Cloud NAT] NAT -- "固定送信元IPで外部へ" --> IPs[(静的 外部 IPv4(1..n))] IPs --> Internet[インターネット] end Internet --> Partner[相手先サービス(IP許可リスト:静的IP群)]
  • Cloud Run -> VPC (Direct VPC egress) -> Cloud NAT (静的外部IP) -> 外部API
  • 外部API側で許可してもらうのはCloud NATに手動割り当てした静的 外部IPv4(リージョン)

事前確認 #

  • リージョン: Cloud Run, サブネット, Cloud Router/NATを同一リージョンにする
    • 今回は asia-northeast1
  • VPC/サブネット: どのネットワーク経由で出すか決めておく
    • defaultを使用する
  • 権限: compute.networkUserが必要

手順 #

VPCの設定 #

  • 外部静的IPを予約する
    • VPCネットワーク→IPアドレス→IPアドレスを予約
    • 種別: 外部/IPv4
    • スコープ: リージョン(Cloud NATと同じリージョン、asia-northeast1)
  • Cloud Routerを作成
    • ハイブリッド接続→Cloud Router→作成
    • ネットワーク、リージョンを上記と合わせる
  • Cloud NATを作成
    • ネットワークサービス→Cloud NAT→作成
    • NATタイプ:公開
    • Cloud Routerの選択
      • ネットワーク・リージョン: 上記と合わせる
      • クラウドルーター: 上記で作成したものを指定
    • Cloud NATマッピング
      • VM インスタンス、GKE ノード、サーバーレス
      • ソースIPバージョン: IPv4サブネット範囲
        • 「すべてのサブネットのプライマリとセカンダリの範囲」を選択
      • Cloud NAT IP アドレス: 手動を選択
        • 上記で予約した静的IPを割り当て

Cloud Runの設定 #

vpc経由にする必要があるのでCloud Runサービス設定を変更

  • 対象のCloud Runを開き「新しいリビジョンの編集とデプロイ」へ
    • ネットワーキングタブを開く
      • アウトバウンド トラフィック用のVPCに接続するをチェック
        • ネットワーク・サブネットを上記と同じものへ
        • トラフィックルーティング: 全てのトラフィックをVPCにルーティングする(Direct VPC egress)を設定
    • このリビジョンをすぐに利用するにチェックを行いデプロイ

確認方法 #

Cloud Runでテスト用のエンドポイントを作成し、そのエンドポイント上でhttps://curlmyip.orgへリクエストしレスポンスをチェックするのが楽

func (h *HealthHandler) TestExternalAPI(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(os.Stderr, "DEBUG: Getting external IP address\n")

	resp, err := http.Get("https://curlmyip.org")
	if err != nil {
		fmt.Fprintf(os.Stderr, "DEBUG: Failed to get IP: %v\n", err)
		http.Error(w, fmt.Sprintf("Failed to get external IP: %v", err), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Fprintf(os.Stderr, "DEBUG: Failed to read response: %v\n", err)
		http.Error(w, fmt.Sprintf("Failed to read response: %v", err), http.StatusInternalServerError)
		return
	}

	externalIP := strings.TrimSpace(string(body))
	fmt.Fprintf(os.Stderr, "DEBUG: External IP is: %s\n", externalIP)

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(fmt.Sprintf("Cloud Run external IP: %s", externalIP)))
}

これをhttp.HandleFuncにわたせばOkey

注意点 #

  • stagingとproductionのCloud Runを分けている場合、VPC/サブネットを別にしないといけない
    • サブネットだけ分離すれば同一VPCで行うことは可能
    • 結論としてめんどくさいからVPCごと分離したほうが分かりやすい
      • ただし作業量は増える→terraform化すれば問題なしだがね