見出し画像

【AWS Lambda ✕ Supabase】公式ドキュメント通りなのに動かない!サーバーレスAPI構築でブチ当たった2大エラーの解決策

Reactでフロントエンドを作り、AWS(API Gateway + Lambda)を経由して、Supabaseにデータを保存する。

セキュアでモダンなイメージのある構造なので実際作ってみようと意気込んだもののめっちゃエラー。

インフラ同士の隙間に存在する、OSの差異やネットワークプロトコルの壁がとにかく厚い…。
ネットで検索しても、ドンピシャな解決策が見つからずに時間を溶かしてしまうのは、サーバーレス個人開発における「あるある」です。

この記事では、上記3層構造を作ってみようとした時のトライアンドエラーを書いていきます。どなたかの参考になれば幸いです。

この記事を読んでわかること(ロードマップ)

この記事では、今回ぶち当たった以下の2つの壁との奮闘を書いていきます。

①【OSの壁】 Macで固めたパッケージZIPがLambdaで動かない問題の解決

Runtime.ImportModuleError: No module named 'pydantic_core._pydantic_core'  について
→Lambda環境内で自動的に依存関係を解決させる「/tmp領域 動的インストールハック」

②【ネットワークの壁】 環境変数もコードも正しいのにDBに繋がらない問題の解決

FATAL: connection failed: Cannot assign requested address について
→Supabase公式の「Connection Pooler(ポート6543)」を適用し、IPv4ネットワーク経由で安全に通信を通すインフラ設定と接続コード

動作環境
ローカル開発環境(Mac)
 OS: macOS Sequoia (Intel Core i3 / x64 アーキテクチャ)
 ランタイム: Node.js v24.18.0 LTS
 フロントエンド: Vite + React + TypeScript
 CSSフレームワーク: Tailwind CSS v4
バックエンド実行環境(AWS)
 AWS Lambda: Python 3.12 (アーキテクチャ: x86_64)
 API Gateway: HTTP API (認証なし・CORSオープン)
データベース環境(Supabase)
 PostgreSQL (接続プール: Connection Pooler / ポート 6543)


そもそもなぜフロントから直接DBを叩かず、AWSを挟むのか?

私たちが個人開発でWebアプリケーションを構築する際、フロントエンド(Reactなど)からデータベース(Supabase/PostgreSQL)を直接操作する「2層アーキテクチャ」は、非常に簡単で魅力的です。
実際、Supabaseが提供するJavaScriptクライアントを使えば、フロントエンドのコードだけでデータの読み書きが完結するんですよね。

なぜ、わざわざAWSという「中間業者」を挟むのでしょうか?

そこには、フルスタック開発において避けては通れないセキュリティ信頼性における、明確な設計思想があります。

【今回のデータフロー:バケツリレーの全体像】

理由①:セキュリティ。データベースの「マスターキー」をブラウザに渡さない

最大かつ絶対的な理由は「セキュリティ(シークレットキーの保護)」です。

ブラウザ上で動くReact(JavaScript)のコードは、ユーザーが開発者ツールを開けば誰でも100%閲覧・解析することができます。

API Gateway と Lambda を前段に置くことで、データベースのマスターキー(SUPABASE_KEY)は、AWSの安全な金庫(Lambdaの環境変数)の中に隠したままにすることができます。
React側で持っているのは「API GatewayのURL(エンドポイント)」だけであり、その裏側にあるデータベースへのアクセス鍵は、ブラウザから一切見えない安全なバックエンド環境に置くことができます。

理由②:データの信頼性。改ざん不可能な「サーバー時間」の担保

もう一つの重要な理由は、データおよびログの信頼性です。

例えばストップウォッチの「開始時間(start_time)」や「終了時間(end_time)」、および「作業時間(duration_minutes)」の計算を実装するとします。
ユーザーのPC(ブラウザ側)に依存してデータベースに直接保存する設計にした場合、以下のような欠陥が生じます。

  • ユーザーがPCの時計を意図的に(あるいは誤って)ずらしている場合、記録されるデータが滅茶苦茶になる。

  • ブラウザ側でデータを偽造して「1分しか作業していないのに、100分作業した」と不正なデータを直接データベースにインサートできてしまう。

これを回避するため、ストップウォッチの「開始」「停止」ボタンを押した瞬間の時刻計算などの機能は、ユーザーのPCの時計ではなく、改ざん不可能なAWSのサーバー時間(信頼できる第三者機関の時間)を基準に行います
ブラウザからは単に「スタートした」「ストップした」という無色透明なシグナルだけを送り、バックエンドのLambda(Python)側で datetime.now(timezone.utc) を生成してDBに直接インサートする。

これにより、システムのデータ整合性と信頼性を高目ることができます。

今回のデータフローまとめ:4つの主要コンポーネントの役割分担(バケツリレー)

この3層アーキテクチャにおける、各コンポーネントの具体的な役割分担は以下の通りです。

データ連携ステップ図

【エラー①:OSの壁】Runtime.ImportModuleError:Macローカルで固めたパッケージZIPがLambdaで動かない!

API GatewayとLambdaを作成し、いよいよバックエンドからSupabaseへと接続するフェーズ。
ローカルのMac環境で pip install supabase を実行して必要なモジュールをダウンロードし、フォルダをZIPに固めて意気揚々とAWS Lambdaのレイヤーとしてデプロイ。

そしてAPIエンドポイントをブラウザで叩いた瞬間、画面に冷酷な {"message":"Internal Server Error"} が表示されます。

もう本当に見飽きた!
いつもバージョンに悩まされる…。

ということでCloudWatch Logsから確認。最初のありがちな、でもめんどくさいエラーログです。

2-1. 実際のエラーログ

yyyy-MM-ddThh:mm:ss.064Z [ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': No module named 'pydantic_core._pydantic_core'
Traceback (most recent call last):
yyyy-MM-ddThh:mm:ss.087Z INIT_REPORT Init Duration: 307.52 ms Phase: init Status: error Error Type: Runtime.ImportModuleError

(CloudWatchログより抜粋)

Runtime.ImportModuleError

プログラムのコードは間違っていないはずなのに、そもそもモジュールをインポートする前段階(INIT フェーズ)でシステムが異常終了してしまっています。

「Pythonのバージョン指定を間違えたのだろうか?」と首を傾げたくなりますが、実はそう簡単な話でもない深い罠が潜んでいるそう。

2-2. 原因:ローカル(Mac/Darwin)とAWS Lambda(Linux)のバイナリ不整合

このエラーの真犯人は、Supabaseライブラリが内部で依存している pydantic_core や、PostgreSQLへの接続を司る psycopg2 などのライブラリの特性にあります。

手元のMacで pip install を実行した際、pipは「Mac(Darwinカーネル)で最速で動くバイナリ」をコンパイルして配置します。 しかし、AWS Lambdaの実行環境の実体は「Linux(Amazon Linux)」です。

【コンパイルのミスマッチ構造】
・Macローカルでビルド: Mac(Darwin)用のパーツが完成
・AWS Lambdaが求めるパーツ: Linux用のパーツ

結果: レイヤーとしてZIPを送りつけても、Lambdaは「OSが違うので読み込めません!」と拒絶。

C言語でコンパイルされたバイナリパーツ(拡張子が .so のファイルなど)は、異なるOS間での互換性がありません。
そのため、Mac環境で固めたZIPファイルをどれだけ丁寧にAWSに適用し直しても、この ImportModuleError(モジュールが見つからない)が出続け、システムは沈黙してしまいます。

🔗 関連するAWS公式ドキュメン

【対策コード:OSの壁】Docker不要!Lambda内で完結させる「/tmp動的インストールハック」

第2章で解説した通り、pydantic_corepsycopg2 などをZIPで固めてLambdaにデプロイすると、OSのアーキテクチャの違いによって高確率でインポートエラーを引き起こします。

このクロスコンパイル問題を解決する定石は「Dockerコンテナを使ってAmazon Linux互換環境をローカルに立ち上げ、そこでビルドしたものをパッケージングする」という方法です。

しかし、個人開発やプロトタイピングの段階で、毎回コンテナを起動してZIPをビルドし、AWSにアップロードし直すのは煩雑かつ開発のテンポを著しく損ないます。

そこで、今回は「AWS環境(Linux)に適合するライブラリを、Lambda関数自身に実行時インストールさせる」という、シンプル+頑丈やり方を適用します。

解決策:Lambda起動時に /tmp 領域で直接 pip を動かす

AWS Lambdaの実行環境には、関数実行時に読み書きが可能な一時ストレージ領域である /tmp ディレクトリ が用意されています。
この領域を利用し、Lambda関数が起動した直後にPythonの subprocess モジュールを使って pip install を実行します。

Lambda環境自身が直接Pythonパッケージをダウンロード・コンパイルするため、OSもPythonのバージョンも100%動作環境と完全一致したバイナリがその場で確実に生成されます

以下は、この動的インストールを実装した、Lambdaコード(Python 3.12)はこちら

/tmp動的インストールを組み込んだLambda接続コード

import os
import sys
import subprocess

# 1. Lambda内の書き込み可能領域である /tmp にライブラリ用のパスを通す
lib_path = "/tmp/python/lib"
if lib_path not in sys.path:
    sys.path.append(lib_path)

# 2. /tmp 内にライブラリが存在しない場合のみ、pip install を動的実行する
if not os.path.exists(os.path.join(lib_path, "supabase")):
    print("Installing supabase library and dependencies to /tmp...")
    try:
        # Lambda環境のPythonインタープリタを直接使用してpipを実行
        subprocess.check_call([
            sys.executable, "-m", "pip", "install",
            "--target", lib_path,
            "supabase", "psycopg2-binary",
            "--no-cache-dir"
        ])
        print("Successfully installed dependencies!")
    except Exception as e:
        print(f"Installation failed: {str(e)}")
        raise e

# 3. インストール完了後に初めてモジュールをインポートする
from supabase import create_client

def lambda_handler(event, context):
    # 環境変数から接続情報を安全に取得
    supabase_url = os.environ.get("SUPABASE_URL")
    supabase_key = os.environ.get("SUPABASE_KEY")
    
    if not supabase_url or not supabase_key:
        return {
            "statusCode": 500,
            "body": "Missing Supabase configuration in environment variables."
        }
        
    # クライアントの初期化
    supabase = create_client(supabase_url, supabase_key)
    
    # テスト用ダミーレスポンス
    return {
        "statusCode": 200,
        "headers": {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Headers": "content-type",
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
        },
        "body": "Connection established successfully!"
    }

<sys.path.appendによるパスの追加>
Pythonがインポート対象を探す検索パス(sys.path)に、一時領域の /tmp/python/lib を事前に追加しておくことで、動的にインストールしたモジュールを後続のコードで通常通りインポートできるようになります。

<インストール判定の最適化>
if not os.path.exists(...) でフォルダの存在チェックを入れています。これにより、Lambdaが「ウォームスタート(コンテナが再利用される起動)」する2回目以降の実行時にはダウンロード処理がスキップされ、高速に処理が実行されます。

注意点:デフォルトのタイムアウト制限は必ず伸ばしておくこと

このハックを導入する際、絶対に忘れてはならないAWSコンソール側の必須設定があります。それが 「タイムアウト時間の設定変更」 です。

AWS Lambda関数を作成した初期状態では、実行制限時間(タイムアウト値)は「3秒」 に設定されています。

初回アクセス時は、インターネット経由で数十MBのパッケージ群をダウンロードして /tmp に配置し、コンパイルを走らせるため、実行完了までにどうしても数十秒程度の時間を要します。

設定をデフォルトの3秒のままにしておくと、ライブラリのインストール中にAWS側から強制終了させられ、CloudWatchに "Task timed out after 3.00 seconds"のエラーが刻まれることになります。

<タイムアウト延長の設定手順>

  1. AWS Lambdaの管理画面から、該当する関数を開きます。

  2. 画面中段のタブメニューから 「設定」 タブをクリックします。

  3. 左側の縦メニューから 「一般設定」 を選択し、右側の 「編集」 ボタンをクリックします。

  4. タイムアウト設定を、デフォルトの 0分3秒 から、余裕を持たせた 1分0秒 (またはそれ以上)に変更します。

  5. 設定内容を確認し「保存」 ボタンをクリックします。

Lambda操作画面

🔗 関連するAWS公式ドキュメント

【本音の技術考察】このハックは「実際の商用サービス」で使えるか?

ここまで「/tmp 動的インストールハック」の圧倒的な手軽さを紹介してきましたが、ここで一応冷静な技術的ツッコミを挟んでおきます。

初回アクセス数十秒のレイテンシー(待ち時間)が発生する構成は、実際の商用サービスで実用に耐えうるのか?

これに関しては極めて懐疑的で、通常の商用サービスではほぼ採用不可だと考えてま。あくまで「個人開発やプロトタイピングだからこそ許される、最強の力技」だと割り切る必要があります。

なぜ、商用サービスではこのアプローチがNGとなるのか、考えられる理由は3点書いておきます。

1. コールドスタート時の致命的なUX低下
初回アクセスや、同時実行数が増えて新しいコンテナが立ち上がるたびに「数十秒のインストール待ち」が発生します。基本的にユーザーを10秒以上待たせることは致命的な離脱に直結します。

2. Lambdaの二重課金リスク
AWS Lambdaは「実行時間」に対して課金されます。動的インストールにかかる数十秒間もLambdaは起動し続けているため、アクセス数に比例してインフラコストが跳ね上がることになります。

3. 外部リポジトリ(PyPI)への依存
APIが起動するたびに外部の `pip` リポジトリ(PyPI)へパッケージをダウンロードしに行くため、PyPI側のサーバーが一時的に落ちていたり、ネットワークに瞬断が発生しただけで、自社のAPIごと道連れでシステムダウンするリスクを孕んでいます。

本番リリース時には、おとなしくGitHub Actionsなどを用いたCI/CDパイプラインを構築してLinux用バイナリをデプロイパッケージにビルドする設計へ移行し、「開発初期は/tmpで爆速構築、本番はCI/CDで堅牢デプロイ」という段階的な設計アプローチを踏むのが、賢い戦略だと思います。

【エラー②:ネットワークの壁】Cannot assign requested address:すべての設定が正しいはずなのにデータベースに繋がらない!

モジュールのインポートエラー克服!
これでデータベースと通信できる!と思いAPIエンドポイントを叩く。

しかし、ブラウザの画面に返ってきたのは、非情にも 500 (Internal Server Error) の文字です。

ため息。いや、そんなことを言っていても仕方がない。

「コードのタイポもない、環境変数(URLとKey)も間違いなく設定した、ライブラリも正常にインポートされている。それなのに一体なぜ…?」

ということでブラウザのデベロッパーツール(Networkタブ)のレスポンスを開いたときに記録されていたのがこちら、二番目の致命的なエラーログです。

4-1. 実際に遭遇したリアルなエラーログ

{
    "error": "Database Error: connection to server at \"db.xxxxxx.supabase.co\" (2406:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx), port 5432 failed: Cannot assign requested address\nIs the server running on that host and accepting TCP/IP connections?\n"
}

(Networkレスポンスログより抜粋)

Cannot assign requested address
データベースが動いていないかのように見えるこのログですが、ここに、AWSとSupabaseという異なるクラウドサービスを連携させる際に発生する「次世代ネットワーク規格(IPv6)の罠」がありました。

原因:Supabaseホスト名の「IPv6解決」と、AWS Lambdaの「IPv4通信」のミスマッチ

このエラーの根本原因は、「Supabase(PostgreSQL)が提供するデフォルト接続」と「AWS Lambdaのデフォルト仕様」における、通信プロトコル(IPアドレス規格)のズレにあるとのこと。

エラーログの中に記載されている (2406:xxxx:xxxx:...)IPv6(Internet Protocol Version 6) のアドレスです。
Supabaseのデフォルトの直接接続用ホスト名(ポート5432)は、ネットワーク上で名前解決(DNS)を行うと、IPv6アドレスとして解決される仕様になっています。

一方、プログラムが動いている AWS Lambdaは、デフォルト状態(VPCに属さず、外のインターネットへアウトバウンド通信を行う標準状態)において、IPv4での通信しかサポートしていません

【ネットワーク規格のミスマッチ構造】
・AWS Lambda(VPC外・標準)→ IPv4 192.168...などの規格
・Supabase(ポート5432直)→IPv6(2406:xxx...などの規格

結果としてLambdaが「IPv6の宛先(5432)」へ通信しにいこうとしても、
Lambda自身の口からIPv6パケットを送り出すことができないため、
「Cannot assign requested address(そんなアドレスへ繋ぐ手段はありません)」となっていました。

🔗 関連するAWS公式ドキュメント

【対策・設定:ネットワーク&DBの壁】公式Connection Pooler(ポート6543)の適用とスキーマ不整合の罠

第4章で解剖した「IPv6解決による通信拒絶(Cannot assign requested address)」解決のためにSupabaseが標準提供している「Connection Pooler(コネクションプーラー)」を導入しました。

解決策:Connection Pooler(ポート6543)の取得と環境変数へのセット

AWS Lambdaから、IPv6アドレスで解決されるSupabaseの通常接続。このプロトコルの壁を乗り越えるため、IPv4アドレスに対応したプロキシとして機能するConnection Pooler(ポート6543)を利用します。

<コネクションプーリング用URI(接続文字列)の取得手順>

1.Supabaseダッシュボードにログインし、対象のプロジェクトを開きます。

2.画面の最上部、中央付近にある 「< > Connect」 ボタンをクリックします

ブックマークバー直下

3.ポップアップ画面が表示されたら、メニューから 「ORM」 または 「Direct Connection string」 を選択します。

Directを選び下にスクロール

4.ポート番号が 6543(ホスト名に pooler.supabase.com が含まれているもの)の接続URI(postgresql://...)をコピーします。

表示されているURIをコピー

5.コピーしたURL内の [YOUR-PASSWORD] 部分をデータベースのパスワードに書き換えます。

※パスワードに@など記号を入れず「英数字のみ」にするとパースエラーを防止できます。

【パスワード書き換えのURI構造例】

・書き換え前: postgresql://postgres.xulwhqjpzzrzdpujwmvt:[YOUR-PASSWORD]@aws-0-ap-northeast-1...:6543/postgres
・書き換え後: postgresql://postgres.xulwhqjpzzrzdpujwmvt:DemoSecretPass@aws-0-ap-northeast-1...:6543/postgres

<AWS Lambdaへの登録>

1.AWS Lambdaの 「設定」タブ > 「環境変数」 を開きます。
2.キー名を SUPABASE_DB_URL(例)とし、上記で作成したパスワード入りのURIを丸ごと値(Value)に貼り付けて保存します。

Connection Poolerを使用した、psycopg2によるセキュアなDB接続コード(Python)

Lambdaでは、アクセスがあるたびに新規コンテナが立ち上がってDB接続を行うため、プールせずに直結させるとあっという間にデータベース側の最大接続数上限(Connection Limit)を使い果たしてシステムダウンします。

Connection Pooler(ポート6543)を仲介させることで、データベースの手前で接続を安全に再利用・管理できるようになります。

以下は、psycopg2 ライブラリを使用して、環境変数からURIを読み込み、安全に接続を管理・パースする今回実装したPythonコード例です。

import os
import sys
import json
import psycopg2
from psycopg2.extras import RealDictCursor

# 1. Lambda内の書き込み可能領域である /tmp にパスを通す
lib_path = "/tmp/python/lib"
if lib_path not in sys.path:
    sys.path.append(lib_path)

# 2. 実行時(コールドスタート時)に psycopg2-binary を自動ダウンロード
if not os.path.exists(os.path.join(lib_path, "psycopg2")):
    import subprocess
    try:
        subprocess.check_call([
            sys.executable, "-m", "pip", "install",
            "--target", lib_path,
            "psycopg2-binary",
            "--no-cache-dir"
        ])
    except Exception as e:
        print(f"Dependency installation failed: {str(e)}")

def get_db_connection():
    """
    環境変数からConnection PoolerのURIを取得し、PostgreSQLセッションを確立する
    """
    db_url = os.environ.get("SUPABASE_DB_URL")
    if not db_url:
        raise ValueError("Environment variable 'SUPABASE_DB_URL' is missing.")
    
    # 接続文字列(URI)を psycopg2.connect() にそのまま渡して接続
    # pooler.supabase.com:6543 経由なので、VPC外のLambdaからIPv4通信で確実に疎通可能
    return psycopg2.connect(db_url)

def lambda_handler(event, context):
    conn = None
    try:
        conn = get_db_connection()
        # 辞書型(キー名: 値)でカラムデータを取り出すために RealDictCursor を使用
        cursor = conn.cursor(cursor_factory=RealDictCursor)
        
        # SQLクエリの実行(例:categoriesテーブルからのデータ取得)
        cursor.execute("SELECT id, user_id, name, parent_id FROM categories;")
        rows = cursor.fetchall()
        
        cursor.close()
        
        # RealDictRow型を安全に標準のPython辞書型にキャストしてJSON化
        serialized_data = [dict(row) for row in rows]
        
        return {
            "statusCode": 200,
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Headers": "content-type",
                "Access-Control-Allow-Methods": "GET, POST, OPTIONS"
            },
            "body": json.dumps(serialized_data)
        }
    except Exception as e:
        return {
            "statusCode": 500,
            "body": json.dumps({"error": f"Database Error: {str(e)}"})
        }
    finally:
        if conn:
            conn.close() # サーバーレス環境の行儀作法として、コネクションは確実にクローズ

🔗 関連する公式ドキュメント

総括:インフラ同士の「隙間」で起きるエラーこそ、フルスタックの血肉になる

ReactからAPI Gateway、Lambda、そしてSupabase(PostgreSQL)にいたる、サーバーレス3層アーキテクチャの構築。

一見するとドキュメント通りに繋ぐだけの作業に見えても実際は数多くの「インフラ同士の隙間」に潜む罠があります。

しかし、これらのエラーと格闘し、CloudWatchのログを一行ずつ読み解いて解決した経験こそが、開発者としての視座を一段引き上げてくれる…はず。

完璧なドキュメントの通りにいかないからこそ、システム構造の理解が深まる

技術のチュートリアルに載っている「Hello World」の多くは、あらかじめ用意された平坦な道です。

しかし、実際の開発、特にバックエンドとインフラが絡み合うフルスタック開発においては、コンポーネントを組み合わせた瞬間に生じる、接続面のバグをいかにしてロジカルに解決できるかが本質的な実力になると思います。

今回のトラブルシューティングを経て完成したデータ連携は、単なる「動くコード」ではなく、システムの裏側の動きをちゃんと把握した上で制御された、プロダクトです。

対話型AIを「最強のペアプログラマ」にして、一歩ずつ進む楽しさ

この複雑なインフラの絡み合いを一人でデバッグし、解決に導くのは容易ではありません。しかし現代の開発において、私たちには対話型AIという最強の相棒がいます。

今回のエラー対応を経て改めて感じた主導権を自分が握りながらAIと協調する開発スタイルについて書いておきます。

  • フライトレコーダー(生ログ)をそのまま投げる
    「動きません」ではなく、CloudWatch Logsやブラウザのデベロッパーツールに出ている「生のエラーログ(Traceback)」をそのままAIに見せる。機密情報が含まれていない場合は個人的な判断で省かずちゃんと投げるのが吉です

  • 一歩ずつ手順を提示してもらう
    AIに一気にすべてのファイルを書き換えさせるのではなく、「まず1つの手順を示してもらい、完了したら次に進む」というステップ・バイ・ステップの約束をする。

  • エラーの意味をロジカルに教えてもらう
    なぜそのエラーが起きたのか、OSやネットワークの仕組みといった「根本原因」を丁寧に解説してもらい、自分の理解をアップデートする。

今回の記事がAWS LambdaやAPI Gatewayを使うかたの何かの参考になればなと思います!

あわせて読みたい:他にも過去に個人開発をした時の記録を記事にしています!あわせて以下の記事も覗いてみてください!

Xでも毎朝発信しています!

元教師からITエンジニアへ転身した私が、日々の開発の中で磨いた「学びのハック」「対話型AIをペアプログラマにするプロンプト術」「健やかに働くメンタル習慣」をリアルタイムで発信しています。

ぜひXでも繋がっていただけたらとても嬉しいです!


いいなと思ったら応援しよう!