🌐

GAS・Colab からの通信を固定 IP にする: 単体では不可、中継を 1 段挟む

に公開

何が問題か

Google Apps Script(GAS)の UrlFetchApp で社内システムや SaaS の API を呼ぼうとすると、接続先が「送信元 IP で許可リスト制限している」ことがあります。この場合、GAS の送信元 IP を固定して許可してもらう必要があります。

GAS 単体では送信元 IP を固定できません。固定 IP を持つ中継サーバーを GAS と接続先の間に置けば実現できます。この記事では、できない理由、Cloud Run での中継構成、その中継を自社のアカウントだけに開ける方法、Colab から同じ中継を使う方法、費用をまとめます。

GAS 単体でできない理由

Apps Script のリファレンスには、UrlFetchApp のリクエストは「決まった IP 範囲のプール」から送信され、許可リストが必要なら Google の IP 範囲一覧を参照するよう書かれています。ところが Google Workspace のヘルプでは、その範囲は「動的に割り当てられ、頻繁に変わる」とされていて、Apps Script 専用の範囲でもありません。fetch() のオプションにもプロキシ指定や VPC 接続の項目はなく、GAS の通信を NAT に通す手段がありません。

一覧(goog.json)の範囲をまとめて許可すれば接続自体はできます。ただし goog.json から Google Cloud の範囲(cloud.json)を除いても IPv4 だけで 305 範囲・約 506 万アドレスあり(2026-09-24 時点)、他組織の GAS や Google の他サービスからの通信も通します。IP 制限が唯一の防御になっている接続先では、制限の意味がほぼなくなるため、ここでは採りません。

中継を 1 段挟む構成

GAS --(HTTPS + ID トークン)--> Cloud Run --(Cloud NAT の固定 IP)--> 接続先

Cloud Run は Direct VPC egress と Cloud NAT を組み合わせると、外向き通信の送信元を予約済みの静的 IP に固定できます。Cloud Run の公式ドキュメントに手順があり、Cloud NAT に予約済みの静的外部 IP を割り当てたうえで、Cloud Run のデプロイ時に --vpc-egress=all-traffic を指定してすべての外向き通信を VPC 経由にします。接続先には Cloud NAT の IP を 1 つ許可してもらえば済みます。

中継のコードは、転送先を 1 つに固定し、受け取ったリクエストをそこへ流します。認証は次の節の Cloud Run の IAM で行うので、コードには入れていません。

const http = require('node:http');
const TARGET = new URL(process.env.TARGET_BASE_URL);

http.createServer(async (req, res) => {
  const url = new URL(req.url, TARGET);
  if (url.origin !== TARGET.origin) return res.writeHead(400).end();

  const chunks = [];
  for await (const c of req) chunks.push(c);
  try {
    const upstream = await fetch(url, {
      method: req.method,
      headers: { 'content-type': req.headers['content-type'] ?? 'application/octet-stream' },
      body: ['GET', 'HEAD'].includes(req.method) ? undefined : Buffer.concat(chunks),
      redirect: 'manual',
    });
    res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'text/plain' });
    res.end(Buffer.from(await upstream.arrayBuffer()));
  } catch (e) {
    console.error(e);
    res.writeHead(502).end();
  }
}).listen(process.env.PORT || 8080);

転送先を固定するために、2 か所で手当てしています。new URL(req.url, TARGET) だけだと、パスが //evil.example/x のときに https://evil.example/x へ解決されるので、origin の一致を確認しています。また fetch は既定でリダイレクトをたどるため、接続先が別のオリジンへ 3xx を返すと固定 IP のままそちらへ接続してしまいます。redirect: 'manual' を付けて、3xx はたどらずにステータスコードだけを GAS に返すようにしています。Location ヘッダーは転送しないので、GAS 側からリダイレクト先は見えません。

このコードは content-type しか転送しません。接続先が API キーや Authorization ヘッダーを必要とする場合は、中継側で Secret Manager などから取り出して付けてください。GAS から届く Authorization は中継の認証に使う ID トークンなので、そのまま接続先に渡してはいけません。Cookie も他のヘッダーも往復とも転送しないので、ログインセッションを持つシステムには使えません。この中継はステートレスな API 向けです。

転送先の固定は origin 単位で、パス単位ではありません。req.url は / で始まるため、TARGET_BASE_URL にパス(/v1/ など)を含めても消え、パスは GAS 側で指定します。裏返すと、中継を呼べる人は接続先ホストのすべてのパスに固定 IP で届きます。接続先が同じホストで複数のサービスを提供している場合は、中継でパスの接頭辞も照合してください。

中継を自社のアカウントだけに開ける

中継の入口はインターネットに公開されるので、認証がないと誰でも固定 IP を経由して接続先に届きます。GAS の ID トークンと Cloud Run の IAM 認証を組み合わせると、自社ドメインのアカウントだけに絞れました。

GAS のマニフェスト(appsscript.json)の oauthScopes に openid と https://www.googleapis.com/auth/userinfo.email を追加すると、ScriptApp.getIdentityToken() で ID トークンを取れます。このトークンの aud は Cloud Run の URL ではなく OAuth クライアント ID になっているので、その値を Cloud Run のカスタムオーディエンスに登録します。そのうえで Cloud Run の呼び出し権限(roles/run.invoker)を domain:<自社ドメイン> に付け、allUsers には付けません。なお、プロジェクトの Owner と Editor の基本ロールは呼び出し権限(run.routes.invoke)を含むので、これらを持つアカウントはサービス側の設定に関係なく呼べます。

gcloud run services update gas-relay --region=asia-northeast1 --add-custom-audiences=<GAS の aud>
gcloud run services add-iam-policy-binding gas-relay --region=asia-northeast1 \
  --member=domain:<自社ドメイン> --role=roles/run.invoker

GAS からは ID トークンを Authorization ヘッダーに付けて呼びます。

const res = UrlFetchApp.fetch(RELAY_URL, {
  headers: { Authorization: 'Bearer ' + ScriptApp.getIdentityToken() },
});

aud は GAS プロジェクトごとに異なり、同じ GAS なら実行する人が変わっても同じでした。中継を使う GAS を増やすときは、その aud を 1 回登録します。登録を 1 つにまとめたい場合は、後述の「Colab から使う場合」の変換用 GAS を GAS からも経由します。Cloud Run に登録する aud は変換用 GAS の 1 つで済みますが、受け付ける資格情報の範囲は広がります(同節の注意点を参照)。

ただし、aud の登録で使える GAS を限定できるわけではありません。Cloud Run は gcloud auth print-identity-token で取ったユーザーの ID トークンも受け付けるとドキュメントに書かれていて、実際に自社ドメインのアカウントなら GAS を通さず手元の curl から中継を呼べました。絞れるのは「自社ドメインのアカウント」までです。呼べる人をさらに減らしたい場合は、domain: の代わりに Google グループに権限を付けます。

利点と注意点

接続先に伝える IP が 1 つで済み、変わりません。認証は Cloud Run の IAM に任せられるので、共有シークレットを配ったり管理したりする必要もありません。

一方で、接続先からは全員が同じ IP に見えるので、誰が呼んだかは中継の手前でしか分かりません。Cloud Run のリクエストログに呼び出し元のアカウントは残らず、Cloud Run の監査ログもサービスの呼び出しは対象外です。IAM 認証を通った Authorization ヘッダーの ID トークンはそのままコンテナに届くので、中継で email を取り出してログに出すか、X-Caller のようなヘッダーで接続先に渡してください。上の中継コードには入れていないので、使うときは足します。

const idToken = req.headers.authorization?.replace(/^Bearer /, '');
const caller = idToken ? JSON.parse(Buffer.from(idToken.split('.')[1], 'base64url')).email : null;
console.log(JSON.stringify({ caller, method: req.method, path: url.pathname }));

署名の検証は Cloud Run の IAM が済ませているので、ここでは payload を読むだけにしています。

一方で、中継が止まると GAS 側も止まり、Cloud Run と Cloud NAT の運用と費用が増えます。コールドスタートについては、Direct VPC egress のドキュメントに、Cloud NAT と組み合わせると起動に 30 秒以上かかることがあり、その対策として Serverless VPC Access コネクタを推奨すると書かれています。今回の検証では 2 回とも 3 秒以内でしたが、遅延が問題になる場合は最小インスタンス数を 1 にするか、コネクタを使ってください。IAM の変更の反映にも時間がかかります。ドキュメントでは通常 2 分、7 分以上かかることもあるとされ、今回は allUsers を外してから拒否されるまで約 3 分かかりました。その間は認証なしでも通ります。これは検証で先に allUsers を付けてから外したために生じたものです。最初から --no-allow-unauthenticated でデプロイすると、デプロイ完了直後からトークンなしは 403 でした。権限を付けた側の反映にも 1 分強かかり、その間は正しいトークンでも 403 が返りますが、認証なしで通る時間帯は生じません。

GAS の送信元 IP を api.ipify.org で調べると誤ることがある

検証中に気づいた点です。GAS を手動で実行して api.ipify.org を呼ぶと、Google 以外のネットワークの IP(毎回同じ 1 つ)が返りました。httpbin.org/get で確かめると、返ってきた origin はその IP と Google の範囲内の IP の 2 つを並べたものでした。httpbin は X-Forwarded-For ヘッダーの値と接続元を並べて返すので、GAS がこのヘッダーを付けて送り、api.ipify.org はその値を送信元として返していたと見られます。付いている IP の正体は確認できていません。実行した人のブラウザの送信元 IP とは異なっていました。

このヘッダーは毎回付くわけではありませんでした。手動実行と、1 回だけ動かした時間主導トリガーでは付いていましたが、1 時間ごとの時間主導トリガーでは約 270 回すべてで付いていませんでした。付く条件は分かっていません。

GAS の送信元 IP を調べるときは、接続元 IP と X-Forwarded-For を分けて返すサービスを使ってください。また、X-Forwarded-For は送信側が自由に書けるヘッダーなので、接続先の IP 制限の根拠には使えません。

Colab から使う場合

Google Colab からも同じ中継を使えます。ただし Colab と Cloud Run の間に、アクセストークンを ID トークンに取り替える GAS を 1 つ挟みます。

Cloud Run の IAM が受け付けるのは ID トークンで、アクセストークンでは通りません。通常の Colab で google.colab.auth.authenticate_user() を実行して得られるのはアクセストークンで、ID トークンは取れませんでした。gcloud auth print-identity-token は「ID トークンを取得できない」というエラーになり、google.oauth2.id_token.fetch_id_token も Colab の実行環境が対応しておらず失敗しました。

そこで、アクセストークンを本人の ID トークンに取り替える GAS(以下、変換用 GAS)を Web App として置きます。社内で同じ仕組みを使っている例があり、それを再現しました。

Colab --(アクセストークン)--> 変換用 GAS --(本人の ID トークン)--> Cloud Run --(Cloud NAT の固定 IP)--> 接続先

変換用 GAS の設定とコード

変換用 GAS は、実行ユーザーを「ウェブアプリケーションにアクセスしているユーザー」、アクセスできるユーザーを自社ドメイン内の全員にしてデプロイします。アクセストークンを Authorization ヘッダーに付けて /exec を呼ぶと、Apps Script はトークンの持ち主としてスクリプトを実行します。その中で getIdentityToken() を呼べば本人の ID トークンが取れるので、Cloud Run から見た呼び出し元は Colab を使っている本人です。Cloud Run の URL は、スクリプトプロパティ RELAY_URL に入れておきます。

Cloud Run 側では、変換用 GAS の aud をカスタムオーディエンスに登録します。変換用 GAS を標準の GCP プロジェクトに紐づける必要はありません。最初の検証では紐づけていましたが、紐づけない状態でも通ることを後から確かめました。紐づける場合は aud が変わるので、aud の登録は紐づけた後に行ってください。一方、スクリプト自体をドメイン内に閲覧者として共有することは必要です。Web App のアクセス設定を「ドメイン内の全員」にしただけでは、所有者以外のアカウントがアクセストークン付きで /exec を呼ぶと承認済みでも 403 になり、閲覧共有を追加すると通りました。入口がトークンの持ち主の Drive の権限(スクリプトを閲覧できるか)で検証しているためと見られ、後述の Drive 系スコープの要件とも整合します。

{
  "timeZone": "Asia/Tokyo",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "openid",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/script.external_request"
  ],
  "webapp": { "executeAs": "USER_ACCESSING", "access": "DOMAIN" }
}
// 利用者がブラウザで /exec を開いたときに、全スコープの承認を求めて結果を返す
function doGet() {
  ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
  const info = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
  return ContentService.createTextOutput(JSON.stringify({
    caller: Session.getActiveUser().getEmail(),
    status: String(info.getAuthorizationStatus()),
    scopes: info.getAuthorizedScopes(),
  })).setMimeType(ContentService.MimeType.JSON);
}

function doPost() {
  const relayUrl = PropertiesService.getScriptProperties().getProperty('RELAY_URL');
  const res = UrlFetchApp.fetch(relayUrl + '/', {
    headers: { Authorization: 'Bearer ' + ScriptApp.getIdentityToken() },
    muteHttpExceptions: true,
  });
  return ContentService.createTextOutput(JSON.stringify({
    caller: Session.getActiveUser().getEmail(),
    relayStatus: res.getResponseCode(),
    relayBody: res.getContentText().slice(0, 200),
  })).setMimeType(ContentService.MimeType.JSON);
}

doGet は、利用者が最初に承認するための入口です(後述)。doPost は、決まったパスに GET を 1 回送り、応答の先頭 200 文字を返す疎通確認用の最小例です。実際に使うときは、doPost(e) の e.postData から本文を取り出して UrlFetchApp.fetch に渡すなどの拡張が要ります。その際も、転送先のパスやホストをリクエストから受け取らないようにしてください。たとえばクエリの値をそのまま URL につなぐと、@evil.example/ のような値で宛先ホストを差し替えられ、本人の ID トークンを外部に送らせることができてしまいます。

Colab 側のコード

authenticate_user() の後に取ったアクセストークンを付けて、/exec に POST します。

from google.colab import auth
import google.auth, google.auth.transport.requests, requests

BRIDGE_URL = 'https://script.google.com/a/macros/<自社ドメイン>/s/<デプロイ ID>/exec'

auth.authenticate_user()
creds, _ = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())

r = requests.post(BRIDGE_URL, headers={'Authorization': f'Bearer {creds.token}'})
print(r.json())  # {'caller': '...', 'relayStatus': 200, 'relayBody': '...'}

doPost は最初の POST の時点で実行され、/exec はその結果を置いた別のホストへリダイレクトします。requests は既定でこれを GET でたどり、ホストが変わるので Authorization ヘッダーも外します。リダイレクト先は結果を返すだけなので、これで問題ありません。トークンの有効期限は 1 時間で、長く使うノートブックでは呼ぶ前に creds.refresh() を挟みます。

検証では、トークンに Drive 系のスコープ(drive.readonly か drive)が無いと、/exec の入口で 401 のログイン画面が返りました。公式ドキュメントには、この要件の記載が見当たりませんでした。Colab のトークンには drive が入っていたので、そのまま通りました。GAS から変換用 GAS を呼ぶ場合は、呼ぶ側のマニフェストに https://www.googleapis.com/auth/drive.readonly などを入れます。

利用者に案内すること

利用者は最初に一度、変換用 GAS の /exec をブラウザで開いて承認します。Web Apps のガイドによると、アクセスしているユーザーとして動く Web App では、スコープを個別に許可・拒否できる承認画面が表示されます。そのため、サービスを呼ぶ前に ScriptApp.getAuthorizationInfo で必要なスコープが揃っているか確かめることが推奨されています。検証でも、承認画面をそのまま許可しただけでは openid と userinfo.email しか許可されず、UrlFetchApp に要る script.external_request が抜けていました。この状態でもブラウザでは doGet が正常に表示されますが、Colab から呼ぶと「Authorization needed」の承認ページが HTML で返ってきます。スクリプトの編集権限を持たない別のアカウントでも同じでした。

上の doGet の先頭にある ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL) が、その対処です。足りないスコープがあると、ブラウザで開いたときに承認画面がもう一度出ます。リファレンスでは、このメソッドが動く場面の例に Apps Script のエディタが挙がっていて、Web App には触れていません。今回、Web App の doGet でも承認画面が再表示されることを確かめました。許可すると status が NOT_REQUIRED になり、scopes に 3 つとも並びます。この状態にしてから、別のアカウントの Colab からも 200 が返りました。

利用者には、/exec を開いて承認したうえで、status が NOT_REQUIRED になっていることと、caller が Colab で使うアカウントと同じであることを確かめてもらいます。ブラウザで複数のアカウントにログインしていると、意図しないアカウントで承認してしまうことがあるためです。

注意点

変換用 GAS は、Drive 系のスコープを持つアクセストークンであれば、発行したアプリを問わず本人として通します。検証でも、Colab が発行したトークンと、別の GAS プロジェクトが ScriptApp.getOAuthToken() で得たトークンの両方で通りました。Cloud Run を直接呼ぶ経路は aud を登録した ID トークンに限られるので、それに比べて受け付ける資格情報の範囲が広がります。自社ドメインの誰かのアクセストークンが漏れると、そのトークンで本人として中継を呼べ、中継で呼び出し元を記録していても本人の呼び出しとして残ります。

また、この方法は authenticate_user() のポップアップで人が承認することが前提なので、無人の定期実行には使えません。無人で動かす場合は別経路になり、変換用 GAS を通さずにサービスアカウントの ID トークンで Cloud Run を直接呼びます。サービスアカウントに roles/run.invoker を付けるだけで、aud はサービスの URL なので登録は要りません。検証では、カスタムオーディエンスを登録済みのサービスでもこの aud で通りました。中継のログに呼び出し元を残すには、ID トークンに email クレームを含めて発行します(gcloud auth print-identity-token なら --include-email)。誰の代わりに動いているかは中継では分からないので、必要なら呼び出し側で明示します。

そもそも GAS や Colab をやめる選択肢

処理が GAS や Colab である必要がないなら、処理ごと Cloud Run(ジョブや Cloud Scheduler からの定期実行)に移すほうが構成は単純になります。中継を置くのは、スプレッドシートのトリガーなど GAS でしか書きにくい部分が残る場合や、Colab で人が対話的に分析する場合です。

接続先が AWS 上にある場合は、処理を AWS 側に置いて完結させるほうがさらに単純になることがあります。接続先と同じ VPC か、つながった VPC の中から呼べば、インターネット越しの IP 許可リストに頼らず、セキュリティグループなど AWS の中の仕組みでアクセスを絞れます。GAS や Colab、Cloud Run、Cloud NAT をまたぐ構成に比べて、運用する環境も 1 つで済みます。

費用

中継の費用の中心は Cloud NAT です。単価は次のとおりです(Cloud Billing Catalog API の円建て価格、2026-09-24 時点。料金ページの米ドル価格も併記)。

項目 単価
NAT が使う外部 IP アドレス 0.7969 円/時($0.005)
NAT ゲートウェイ 0.2231 円/時 × NAT を使うインスタンス数($0.0014、32 台分で上限)
NAT のデータ処理 7.1719 円/GiB($0.045)

外部 IP 1 つで月(720 時間)約 574 円、ゲートウェイはインスタンス 1 台分で月約 161 円です。これに Cloud Run の利用料とデータ転送料が加わります。

料金ページには、ゲートウェイ料金は「NAT の割り当てを受けた VM インスタンスの数」で決まるとだけ書かれていて、Cloud Run のインスタンスがどう数えられるかは書かれていません。検証環境の請求レポート(2026-09-24〜27、9/28 朝の時点で反映済みの分)では、外部 IP(Networking Cloud NAT IP Usage)が 76.08 時間で 61 円、ゲートウェイ(Networking Cloud Nat Gateway Uptime)が 6.19 時間で 1 円、データ処理は 0.01 GiB で 0 円でした。Cloud Run の CPU・メモリ・リクエストは無料枠に収まり 0 円で、合計 62 円です。ゲートウェイの課金時間が外部 IP よりずっと短いのは、最小インスタンス数 0 で Cloud Run のインスタンスが動いていた時間だけが数えられたためと見られます。Cloud Run はリクエストを処理した後もインスタンスを最大 15 分残すことがあり、41 回のリクエストで 6.19 時間という値はこれと矛盾しません。この構成では、固定費はほぼ外部 IP の月約 574 円になります。

参考までに、AWS で同じように NAT ゲートウェイと固定 IP を東京リージョンに置く場合と比べます。AWS の Price List API(2026-09-01 適用の価格)では、NAT ゲートウェイが 1 時間 $0.062、データ処理が 1 GB あたり $0.062、パブリック IPv4 アドレスが 1 時間 $0.005 です。固定費は月 720 時間で約 $48 になり、Cloud NAT のインスタンス 1 台分と外部 IP 1 つの合計(約 $4.6)のおよそ 10 倍です。AWS はゲートウェイ 1 つごとの時間課金で、Cloud NAT は NAT を使うインスタンスの数で決まるので、Cloud NAT のゲートウェイ料金が上限(32 台を超えると 1 時間 $0.044)に近づくと差は縮まります。データ処理は、AWS が 1 GB あたり $0.062、Cloud NAT が 1 GiB あたり $0.045 です。

検証してみた結果

東京リージョン(asia-northeast1)で構築し、2026-09-24〜27 に確認しました。レビューを受けて 2026-09-28 に追加で確認した項目も含みます。

確認項目 結果
GAS から直接呼んだときの接続元 IP Google の範囲内で毎回変わる。約 13 時間で 4 つの範囲(35.187.134.96〜.110 など)にまたがった
中継経由での送信元 IP 毎回 Cloud NAT の固定 IP
自社ドメインのアカウントの GAS から ID トークン付き 200
ID トークンなし 403
自社ドメイン外のアカウントの GAS から ID トークン付き(aud は登録済み) 403
aud を登録していない GAS プロジェクトの ID トークン 401
自社ドメインのアカウントの gcloud の ID トークン(GAS を通さない) 200
プロジェクトに権限のない自社ドメインのアカウントの ID トークン(domain: の付与のみ) 200。domain: を外すと約 100 秒後に 403
サービスアカウントの ID トークン(aud はサービス URL、invoker 付与) 200。aud の登録なしで通る
新規デプロイ直後にトークンなし(--no-allow-unauthenticated) 403
Colab から変換用 GAS 経由(アクセストークン付き) 200。送信元は Cloud NAT の固定 IP
所有者以外のアカウントの Colab から変換用 GAS 経由(スクリプト未共有、承認済み) 403。ドメインに閲覧共有すると 200
Colab から変換用 GAS(トークンなし) 401
編集権限のない別アカウントの Colab から変換用 GAS 経由(requireAllScopes で承認後) 200。送信元は Cloud NAT の固定 IP
コールドスタート(最小インスタンス数 0) 2 回とも 3 秒以内

自社ドメイン外のアカウントで GAS を初めて実行したときは、「このアプリは Google で確認されていません」という警告が出ました。自社ドメインのアカウントでは出ませんでした。

残る課題は、中継が 1 か所で止まるとすべて止まる点です。今回は Cloud Run も Cloud NAT も東京リージョンに 1 つずつで、冗長化はしていません。

参考

株式会社スプリックス IT戦略部・SPRIX Enginieering Lab

Discussion