LWA(Lambda Web Adapter)でAurora DSQLに接続する際の認証トークン期限切れに注意

LWA(Lambda Web Adapter)でAurora DSQLに接続する際の認証トークン期限切れに注意

2026.03.25

はじめに

ブログサイトにVercel、サーバサイドにLambdaを使っています。Lambda Web Adapter(LWA)を使っていてRustのaxumをホスティングしています。ゆえに本記事では以下の構成という前提があります。

img

静的サイトでいいところをクライアント、サーバーで分けて作っており、サーバサイドでAurora DSQLからマークダウンを取得しHTMLに変換して、フロントエンドで描画しています。本ブログの構成の詳しい話はこちらをご覧ください。

https://shuntaka.dev/shuntaka/articles/20260108-shuntaka-blog-rearchitecture

元々はDynamoDBを使っており、最近Aurora DSQLにマイグレーションしました。その検証中たまに500エラーが発生しUIが表示されないことがありました。sqlx(RustのSQLツールキット)からAurora DSQLに接続した際に返却されたログ上のエラーメッセージは unable to accept connection, access denied で、調査をしたところAurora DSQLと接続している認証トークンの期限切れによるものでした。詳しくは後述しますが、たまに発生する理由は以下の通りだと思います。

  • トークンが切れても既存コネクションが生きている間は正常に動作するため、エラーの発生タイミングがトークン切れの時刻とズレる
  • ウォームインスタンスで idle_timeout(10分)/ max_lifetime(30分)によりコネクションが破棄され、かつ認証トークン(15分)も期限切れだった場合に再接続が access denied で失敗する。コールドスタートではトークンが新規生成されるため問題にならない

これらの条件が揃った場合にのみエラーが発生し、コールドスタートした新インスタンスに処理が移り自然復旧するためでした。例えば以下のようなケースです。実際に起きたのは後者でした。

  • 20分間クエリが来ず idle_timeout(10分)でコネクションが破棄される → 次のリクエストで再接続を試みるが、認証トークン(15分)も期限切れのため失敗
  • ウォームインスタンスが30分稼働し max_lifetime でコネクションが破棄される → 同様に認証トークンが期限切れで再接続に失敗

要素解説

sqlxのコネクション設定

Aurora DSQLへの接続には認証トークンをパスワードとして使用します。sqlxのコネクションプールには、コネクションの寿命を制御する重要な設定が2つあります。

これらのデフォルト値は PoolOptions::new() で以下のように定義されています。簡単に言えば、コネクションは作成から30分で削除されるか、10分間リクエストが来なくても削除されます。どちらか一方を満たした時点で削除されるor条件です。

設定 デフォルト値 目的 DSQLでの注意点
max_lifetime 30分 長時間使い続けたコネクションを定期的にリフレッシュし、メモリリークやコネクションの劣化を防ぐ 超過時にプールがコネクションを再作成する。再作成時に認証トークンが期限切れだと access denied が発生する
idle_timeout 10分 使われていないアイドルコネクションをプールから解放し、リソースを節約する 超過時にプールから削除され、次のクエリで再接続が発生する。同様にトークンが期限切れだとエラーになる

該当するソースコードは以下の箇所です。

コネクションプールのデフォルトの設定値

https://github.com/launchbadge/sqlx/blob/v0.8.6/sqlx-core/src/pool/options.rs#L142-L165

認証トークン設定

RustのAWS SDKはワークスペース構成になっており、aws_sdk_dsql というcrateがあります。こちらを利用して認証トークンを発行します。

use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_dsql::auth_token::{AuthTokenGenerator, Config as AuthConfig};

async fn generate_token(endpoint: &str, expires_in: u64) -> Result<String> {
    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
    let signer = AuthTokenGenerator::new(
        AuthConfig::builder()
            .hostname(endpoint)
            .expires_in(expires_in)
            .build()
            .map_err(|e| anyhow::anyhow!("Failed to build auth config: {e:?}"))?,
    );
    let token = signer
        .db_connect_admin_auth_token(&sdk_config)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to generate auth token: {e:?}"))?;
    Ok(token.to_string())
}

デフォルト値は900秒(15分)です。

https://github.com/awslabs/aws-sdk-rust/blob/42488c8123e370b3b325a51fa060c7cbce1d47c1/sdk/dsql/src/auth_token.rs#L101

実際の事象と原因

事象

実際のサイトを閲覧中、一定時間ブログが表示されず、その後何度かリロードすると閲覧できるようになりました。

Lambdaのエラー時間帯のログを分析した結果、3つの Lambda インスタンス(ログストリーム)が関与していた。各インスタンスの特定は、CloudWatch Logs のログストリーム名と起動ログ(Listening on 0.0.0.0:8080)を突き合わせることで行った。

時刻 (JST) インスタンス 起動からの経過 ステータス レイテンシ 内容
10:04:10 A 0秒 コールドスタート(トークン新規発行)
10:35:54 A 31分44秒 200 55ms 既存のプール接続で成功(最後の正常レスポンス)
10:36:44 A 32分34秒 500 567ms access denied — 新規接続がトークン期限切れで失敗
10:37:01 A 32分51秒 500 577ms 記事一覧 API でも同様のエラー
10:37:37 A 33分27秒 500 212ms 以降、全リクエストが失敗
... A ... 500 121-988ms 同一エラーが継続(計21件)
10:39:53 B 0秒 コールドスタート(トークン新規発行)
10:40:03 B 10秒 200 9,289ms 初回リクエスト成功(コールドスタート含む)
10:40:37 C 0秒 コールドスタート(トークン新規発行)
10:40:40 A 36分30秒 500 911ms インスタンス A 最後のエラー
10:40:43 B 50秒 200 342ms 正常処理
10:40:44 C 7秒 200 7,320ms 初回リクエスト成功(コールドスタート含む)
10:40:46 B 53秒 200 213ms 以降、正常に処理
10:40:49 C 12秒 200 39ms 以降、正常に処理

全エラーはインスタンス A(起動から32分経過)からのみ発生。インスタンス B, C はフレッシュなトークンで起動し、エラーなく処理を開始した。

原因

インスタンス A は起動時に認証トークン(有効期限15分)を発行し、そのトークンでコネクションプールを作成していた。起動から31分が経過した10:35:54時点では、既存のコネクションがプール内に残っていたため正常に応答できていた。しかし max_lifetime(30分)の超過によりコネクションが破棄され、プールが新規接続を試みた際に、すでに期限切れの認証トークンが使われたため access denied で失敗した。

コールドスタートではトークンが新規発行されるため、新たに起動したインスタンス B, C はエラーなく処理を開始し、自然復旧した。

最小限コードで試してみる

以下のLambdaのコードは環境変数 MODE で動作モードを切り替えます。

環境変数 説明
MODE bug トークンリフレッシュなし。起動時に発行したトークンをそのまま使い続けるため、有効期限が切れると access denied で失敗する
MODE fix トークンリフレッシュあり。有効期限の80%が経過した時点でバックグラウンドタスクが新しいトークンを発行し、コネクションプールごと差し替える
Lambdaのコード
use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_dsql::auth_token::{AuthTokenGenerator, Config as AuthConfig};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::get;
use axum::Router;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
use sqlx::PgPool;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::RwLock;

/// トークンの有効期限(秒)
/// ローカル再現時は短くする(60秒)、Lambda 上では本番同等(900秒=15分)
const TOKEN_EXPIRES_IN_SECS: u64 = 60;

/// トークンリフレッシュ間隔(秒)— 有効期限の80%で切り下げ
const TOKEN_REFRESH_INTERVAL_SECS: u64 = TOKEN_EXPIRES_IN_SECS * 8 / 10;

// -------------------------------------------------------------------------
// DSQL 接続ヘルパー
// -------------------------------------------------------------------------

async fn generate_token(endpoint: &str, expires_in: u64) -> Result<String> {
    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;
    let signer = AuthTokenGenerator::new(
        AuthConfig::builder()
            .hostname(endpoint)
            .expires_in(expires_in)
            .build()
            .map_err(|e| anyhow::anyhow!("Failed to build auth config: {e:?}"))?,
    );
    let token = signer
        .db_connect_admin_auth_token(&sdk_config)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to generate auth token: {e:?}"))?;
    Ok(token.to_string())
}

async fn create_pool(endpoint: &str, token: &str) -> Result<PgPool> {
    let connect_options = PgConnectOptions::new()
        .host(endpoint)
        .port(5432)
        .database("postgres")
        .username("admin")
        .password(token)
        .ssl_mode(PgSslMode::Require);

    let pool = PgPoolOptions::new()
        .max_connections(2)
        // 接続の最大生存時間。トークン有効期限と同じにすることで、
        // 期限切れ前後に接続がリサイクルされるようにする。
        .max_lifetime(Duration::from_secs(TOKEN_EXPIRES_IN_SECS))
        // アイドル接続のタイムアウト。有効期限の半分で設定。
        .idle_timeout(Duration::from_secs(TOKEN_EXPIRES_IN_SECS / 2))
        .connect_with(connect_options)
        .await?;
    Ok(pool)
}

// -------------------------------------------------------------------------
// RefreshablePool(修正版で使用)
// -------------------------------------------------------------------------

#[derive(Clone)]
struct RefreshablePool {
    pool: Arc<RwLock<PgPool>>,
}

impl RefreshablePool {
    async fn new(endpoint: &str, token: &str) -> Result<Self> {
        let pool = create_pool(endpoint, token).await?;
        Ok(Self {
            pool: Arc::new(RwLock::new(pool)),
        })
    }

    async fn get(&self) -> PgPool {
        self.pool.read().await.clone()
    }

    async fn refresh(&self, endpoint: &str) -> Result<()> {
        // 1. 新しいトークンで新しいプールを作成(max_lifetime/idle_timeout は無効化)
        let token = generate_token(endpoint, TOKEN_EXPIRES_IN_SECS).await?;
        let connect_options = PgConnectOptions::new()
            .host(endpoint)
            .port(5432)
            .database("postgres")
            .username("admin")
            .password(&token)
            .ssl_mode(PgSslMode::Require);
        let new_pool = PgPoolOptions::new()
            .max_connections(2)
            .max_lifetime(None)
            .idle_timeout(None)
            .connect_with(connect_options)
            .await?;

        // 2. 書き込みロックを取得し、古いプールを新しいプールに差し替える
        //    ブロック {} でスコープを限定し、ロック保持時間を最小にしている
        //    ブロックを抜けると guard がドロップされてロックが解放される
        let old_pool = {
            let mut guard = self.pool.write().await;
            std::mem::replace(&mut *guard, new_pool)
        };

        // 3. ロック解放後に古いプールを閉じる(既存の実行中クエリは完了を待つ)
        old_pool.close().await;
        tracing::info!("pool refreshed");
        Ok(())
    }
}

// -------------------------------------------------------------------------
// HTTP サーバー
// -------------------------------------------------------------------------

#[derive(Clone)]
struct AppState {
    pool: RefreshablePool,
}

async fn health_handler(headers: HeaderMap, State(state): State<AppState>) -> (StatusCode, String) {
    let request_id = headers
        .get("x-amzn-lambda-context")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
        .and_then(|v| v.get("request_id")?.as_str().map(String::from))
        .unwrap_or_else(|| "-".to_string());
    let pool = state.pool.get().await;
    match sqlx::query("SELECT 1").fetch_one(&pool).await {
        Ok(_) => {
            tracing::info!(request_id, "SELECT 1 => OK");
            (StatusCode::OK, "OK".to_string())
        }
        Err(e) => {
            tracing::error!(request_id, error = %e, "SELECT 1 => ERROR");
            (StatusCode::INTERNAL_SERVER_ERROR, format!("ERROR: {e}"))
        }
    }
}

async fn serve(pool: RefreshablePool) -> Result<()> {
    let state = AppState { pool };
    let app = Router::new()
        .route("/health", get(health_handler))
        .with_state(state);

    let addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 8080);
    let listener = TcpListener::bind(&addr).await?;
    tracing::info!("Listening on {addr}");
    axum::serve(listener, app).await?;
    Ok(())
}

/// トークンリフレッシュなし — 期限切れ後にエラーになる
async fn run_bug(endpoint: &str) -> Result<()> {
    tracing::info!("=== bug mode: no token refresh ===");

    let token = generate_token(endpoint, TOKEN_EXPIRES_IN_SECS).await?;
    let pool = RefreshablePool::new(endpoint, &token).await?;

    serve(pool).await
}

/// トークンリフレッシュあり — 期限切れ前にプールを差し替える
/// プール丸ごと差し替えで対応するため、max_lifetime/idle_timeout は無効化する。
/// これらを設定すると、リフレッシュ前にプール内部で接続リサイクルが走り、
/// 期限切れトークンで再接続してエラーになる可能性がある。
async fn run_fix(endpoint: &str) -> Result<()> {
    tracing::info!("=== fix mode: with token refresh ===");

    let token = generate_token(endpoint, TOKEN_EXPIRES_IN_SECS).await?;
    let connect_options = PgConnectOptions::new()
        .host(endpoint)
        .port(5432)
        .database("postgres")
        .username("admin")
        .password(&token)
        .ssl_mode(PgSslMode::Require);

    let pg_pool = PgPoolOptions::new()
        .max_connections(2)
        .max_lifetime(None)
        .idle_timeout(None)
        .connect_with(connect_options)
        .await?;

    let pool = RefreshablePool {
        pool: Arc::new(RwLock::new(pg_pool)),
    };

    let refresh_pool = pool.clone();
    let refresh_endpoint = endpoint.to_string();
    tokio::spawn(async move {
        loop {
            tokio::time::sleep(Duration::from_secs(TOKEN_REFRESH_INTERVAL_SECS)).await;
            if let Err(e) = refresh_pool.refresh(&refresh_endpoint).await {
                tracing::error!("refresh failed: {e}");
            }
        }
    });

    serve(pool).await
}

// -------------------------------------------------------------------------
// main
// -------------------------------------------------------------------------

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .json()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
        )
        .init();

    let endpoint = std::env::var("DSQL_CLUSTER_ENDPOINT")
        .expect("DSQL_CLUSTER_ENDPOINT 環境変数を設定してください");

    let mode = std::env::var("MODE").unwrap_or_else(|_| "bug".to_string());

    match mode.as_str() {
        "bug" => run_bug(&endpoint).await?,
        "fix" => run_fix(&endpoint).await?,
        _ => {
            eprintln!("MODE=bug|fix を指定してください");
            std::process::exit(1);
        }
    }

    Ok(())
}
CDKのコード
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as cdk from 'aws-cdk-lib';
import * as dsql from 'aws-cdk-lib/aws-dsql';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export class DsqlSampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DSQL Cluster
    const dsqlCluster = new dsql.CfnCluster(this, 'DsqlCluster', {
      deletionProtectionEnabled: false,
    });

    const dsqlClusterEndpoint = cdk.Fn.join('', [
      dsqlCluster.attrIdentifier,
      '.dsql.',
      cdk.Aws.REGION,
      '.on.aws',
    ]);

    const dsqlClusterArn = cdk.Fn.join('', [
      'arn:aws:dsql:',
      cdk.Aws.REGION,
      ':',
      cdk.Aws.ACCOUNT_ID,
      ':cluster/',
      dsqlCluster.attrIdentifier,
    ]);

    // Docker Lambda with LWA
    const fn = new lambda.DockerImageFunction(this, 'SampleLambda', {
      functionName: 'dsql-sample',
      code: lambda.DockerImageCode.fromImageAsset(
        path.resolve(__dirname, '../..'),
      ),
      memorySize: 1024,
      timeout: cdk.Duration.seconds(30),
      architecture: lambda.Architecture.ARM_64,
      loggingFormat: lambda.LoggingFormat.JSON,
      logGroup: new logs.LogGroup(this, 'SampleLogGroup', {
        logGroupName: '/aws/lambda/dsql-sample',
        retention: logs.RetentionDays.ONE_WEEK,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
      environment: {
        AWS_LWA_INVOKE_MODE: 'response_stream',
        DSQL_CLUSTER_ENDPOINT: dsqlClusterEndpoint,
        MODE: 'fix',
      },
    });

    // DSQL 接続用 IAM ポリシー
    fn.addToRolePolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['dsql:DbConnectAdmin'],
        resources: [dsqlClusterArn],
      }),
    );

    // Function URL(API Gateway なしで簡易的にアクセス)
    const fnUrl = fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });

    new cdk.CfnOutput(this, 'FunctionUrl', {
      value: fnUrl.url,
    });

    new cdk.CfnOutput(this, 'DsqlClusterEndpoint', {
      value: dsqlClusterEndpoint,
    });
  }
}
Dockerfile
# syntax=docker/dockerfile:1

ARG RUST_VERSION=1.94

FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
    --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
    cargo chef cook --release --recipe-path recipe.json

COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
    --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
    --mount=type=cache,target=/app/target,sharing=locked \
    cargo build --release && \
    cp ./target/release/rust-dsql-connect-error-sample /bin/server

FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.9.0 AS aws-lambda-adapter

FROM gcr.io/distroless/cc-debian13:nonroot
COPY --from=aws-lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter

WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /bin/server /app
USER nonroot

EXPOSE 8080
CMD ["/app/server"]

トークンリフレッシュなし

zshから実行したコマンド
URL="https://qdfa6iavhhfduvileclkeon2ye0jlfeh.lambda-url.ap-northeast-1.on.aws/health"
curl -s -w " [HTTP %{http_code}]\n" "$URL"
for i in $(seq 1 20); do
  sleep 5
  curl -s -w " [HTTP %{http_code}]\n" "$URL"
done
実行結果
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]
 [HTTP 500]

CloudWatch Logs(主要なログのみ抜粋)

{"timestamp":"2026-03-25T00:09:55.030061Z","level":"INFO","fields":{"message":"=== bug mode: no token refresh ==="}}
{"timestamp":"2026-03-25T00:09:56.214885Z","level":"INFO","fields":{"message":"Listening on 0.0.0.0:8080"}}
{"timestamp":"2026-03-25T00:09:56.240932Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
{"timestamp":"2026-03-25T00:10:01.498344Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
// ... 00:10:57 まですべて OK
{"timestamp":"2026-03-25T00:10:57.489894Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
{"timestamp":"2026-03-25T00:11:03.016435Z","level":"ERROR","fields":{"message":"SELECT 1 => ERROR","error":"error returned from database: unable to accept connection, access denied"}}
{"timestamp":"2026-03-25T00:11:08.844325Z","level":"ERROR","fields":{"message":"SELECT 1 => ERROR","error":"error returned from database: unable to accept connection, access denied"}}
// ... 以降すべて同一エラーが継続
{"timestamp":"2026-03-25T00:11:43.345384Z","level":"ERROR","fields":{"message":"SELECT 1 => ERROR","error":"error returned from database: unable to accept connection, access denied"}}
時刻 起動からの経過 結果 解説
00:09:55 0秒 起動。トークン発行(有効期限60秒)
00:09:56 1秒 OK 初回リクエスト。新規コネクションで成功
00:10:01〜00:10:52 6〜57秒 OK 既存コネクションを再利用。トークンは期限内
00:10:57 62秒 OK トークンは期限切れだが、既存コネクションには影響なし
00:11:03 68秒 ERROR max_lifetime(60秒)超過でコネクション破棄 → 再接続時に期限切れトークンが使われ access denied
00:11:08〜 73秒〜 ERROR 以降すべて同一エラー。トークンリフレッシュがないため復旧しない

認証トークンのリフレッシュがついている場合(fix)

zshから実行したコマンド
URL="https://qdfa6iavhhfduvileclkeon2ye0jlfeh.lambda-url.ap-northeast-1.on.aws/health"
curl -s -w " [HTTP %{http_code}]\n" "$URL"
for i in $(seq 1 20); do
  sleep 5
  curl -s -w " [HTTP %{http_code}]\n" "$URL"
done
実行結果
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]
 [HTTP 200]

CloudWatch Logs(主要なログのみ抜粋)

{"timestamp":"2026-03-25T00:13:49.825109Z","level":"INFO","fields":{"message":"=== fix mode: with token refresh ==="}}
{"timestamp":"2026-03-25T00:13:50.046550Z","level":"INFO","fields":{"message":"Listening on 0.0.0.0:8080"}}
{"timestamp":"2026-03-25T00:13:50.069600Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
{"timestamp":"2026-03-25T00:13:55.273293Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
// ... 00:14:41 まですべて OK
{"timestamp":"2026-03-25T00:14:41.024192Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
{"timestamp":"2026-03-25T00:14:46.102802Z","level":"WARN","fields":{"message":"acquired connection, but time to acquire exceeded slow threshold","aquired_after_secs":5.083752837,"slow_acquire_threshold_secs":2.0}}
{"timestamp":"2026-03-25T00:14:46.103741Z","level":"INFO","fields":{"message":"pool refreshed"}}
{"timestamp":"2026-03-25T00:14:46.109962Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
// ... 以降すべて OK
{"timestamp":"2026-03-25T00:15:16.653103Z","level":"INFO","fields":{"message":"SELECT 1 => OK"}}
時刻 起動からの経過 結果 解説
00:13:49 0秒 起動。トークン発行(有効期限60秒)。リフレッシュ間隔は48秒(60秒 × 80%)
00:13:50 1秒 OK 初回リクエスト。新規コネクションで成功
00:13:55〜00:14:41 6〜52秒 OK 既存コネクションを再利用。トークンは期限内
00:14:46 57秒 OK リフレッシュタスクが48秒経過時点で起動し、新トークン発行 → 新プール作成 → 古いプールの close().await で既存コネクションの完了を待機。sqlxが slow threshold(2秒)を超えた旨のWARNを出力。pool refreshed で差し替え完了。リクエストは新プールで成功
00:14:51〜 62秒〜 OK 以降すべて正常。新トークンのプールで処理が継続。bug版では68秒で access denied が発生していたが、fix版ではリフレッシュ済みのため問題なし

本コードでわかることは、まだレースコンディションの課題があることです。例えばトークン60秒・リフレッシュ48秒の場合、Lambdaが50秒間フリーズした場合以下のような挙動になります。

トークンA生成(60秒有効)、プールA作成

Lambdaフリーズ(リクエストが来ない)

リクエストが来てLambda復帰
├─ リフレッシュタスクも同時に起き上がる
│  → トークンB生成 → プールB作成中...(数秒かかる)

└─ リクエスト処理開始
   → まだプールAを使う(プールBは作成中)
   → プールAの接続はトークンA(あと10秒で期限切れ)
   → DSQL側が既に接続を切っていたら access denied

ただしトークンの期限が15分(900秒)の場合、Lambdaのフリーズが10分以上続くケースはサンドボックス自体が破棄されてコールドスタートになるので、実質的には問題になりにくいです。

ただこの挙動は保証がないため、リクエスト時にエラーが出たらリトライする、あるいはリクエストハンドラ内でトークンの残り時間をチェックしてからクエリを投げる、といった対策が必要です。

さいごに

LWAは「本来長時間動くサーバープロセス」をLambdaの実行モデルに載せる仕組みであり、今回の問題はその構造的なギャップが浮き彫りになった事例でした。通常のサーバーであればプロセスが生き続けるためバックグラウンドでのトークンリフレッシュは確実に動作しますが、Lambdaではフリーズ・サンドボックス破棄といった独自のライフサイクルがあるため、一筋縄ではいきません。

もうちょっと筋の良いやり方を見つけたいところですが、一旦これで簡易に解消できたかなと思います!もう少し試して良いやり方が見つかったらまた書こうと思います!

この記事をシェアする

FacebookHatena blogX

関連記事