この記事はWano Group Advent Calendar 2024の5日目の記事となります。
ALB -> Lambda -> WebApp?
アプリケーションサーバーをAWS Lambdaでホストする、というのを仕事でも個人的にも最近たまにやっています。このへんでいつもお世話になっているのが aws-lambda-web-adpter ですね。
aws-lambda-web-adapterを仕込んだDocker Image を作ってデプロイ、Cloudfront からLambda Function URL や API Gateway へ...みたいなのが王道パターンかと思います。
実はこれだけでなく、ALBからもLambdaってルーティングできるらしいのです。
普通に aws-lambda-web-adapter で作ったLambdaアプリもアプリケーションサーバーとして起動できそうな気がしますが、個人的にやったことがありませんでした。
ということで、今回はそれをやってみた記事となります。
既存のWebサイトの特定パス以下にアプリをデプロイしたい
このアプリ専用でドメインを切るのではなく、既存のWebサイトの特定ディレクトリをルートとして使う想定でプロジェクトを作ることとします。
このおかげで本筋の ALB + Lambda とは別のルーティングノウハウへの言及が多くなってしまいました。Remix アプリ事例そのものとしてもなんらかの参考になるかと思うので、そのへんはご容赦ください。
Remixアプリの用意
通常、npx create-remix@latestで初期化すると、以下のような構成になります。
(デプロイ用スクリプトを格納するbuild_deployディレクトリを追加しています)
README.md
app
build_deploy // デプロイ
node_modules
package-lock.json
package.json
postcss.config.js
public
server.js // デプロイ用expressサーバー
tailwind.config.ts
tsconfig.json
vite.config.ts
インデックス
app/routes/
├── _index.tsx
├── sample_site1._index
│   └── index.tsx
export default function Index() {
  return (
    <div>
      <h1>sample_site1</h1>
      <p>このディレクトリがアプリルート</p>
    </div>
  )
}
今回は /sample_site1/へのリクエストを想定したWebページを実装します。
express設定
server.js はexpressサーバーとなっており、実際の稼働時はRemixはexpressのプラグインとして動作させます。
Remixは公式にExpressとの連携例が提供されているため、本実装でもExpressをWebサーバーとして使用しています。なお、最近ではHonoとの連携も容易になっているため、それも選択肢の一つとなるでしょう。
今回は既存Webサイトのサブディレクトリとしてアプリケーションをデプロイするため、URL /sample_site1/ 以下をアプリケーションのルートとして設定します。
(このアプリで専用のドメイン作るのであれば、SITE_PREFIX_DIRあたりの記載は無視してOKです。)
import { createRequestHandler } from "@remix-run/express";
import express from "express";
import * as build from "./build/server/index.js";
import path from 'path';
import { fileURLToPath } from 'url';
const SITE_PREFIX_DIR = "/sample_site1";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// eslint-disable-next-line no-undef
const _PORT = process.env.PORT || 7000;
// eslint-disable-next-line no-undef
const BUILD_VERSION = process.env.BUILD_VERSION || "0.0.0";
const app = express();
// 静的ファイルの提供
app.use(`${SITE_PREFIX_DIR}/assets`, express.static(path.join(__dirname, "build", "client", "assets"), {
  maxAge: 100000, // 100秒
  setHeaders: (res, /*path*/) => {
    res.setHeader('Cache-Control', 'public, max-age=100');
  }
}));
app.use(express.static(path.join(__dirname, `${SITE_PREFIX_DIR}/public`), {
  maxAge: 100000, // 100秒 
  setHeaders: (res,/* path*/) => {
    res.setHeader('Cache-Control', 'public, max-age=600');
  }
}));
// /img パスを build/client/images にマッピング
app.use(`${SITE_PREFIX_DIR}/images`, express.static(path.join(__dirname, "build", "client", "images"), {
  maxAge: 100000, // 100秒 
  setHeaders: (res) => {
    res.setHeader('Cache-Control', 'public, max-age=600');
  }
}));
// デバッグ用のログ
//app.use((req, res, next) => {
//  console.log(`Requested URL: ${req.url} をserver.jsで受信`);
//  next();
//});
// ALBのヘルスチェック用エンドポイント
let healthCheck = 0;
const healthCheckPrintMax = 5;
app.get(`${SITE_PREFIX_DIR}/health`, async (req, res) => {
  console.log(`ヘルスチェックを受信`)
  if (healthCheck < healthCheckPrintMax) {
    healthCheck++;
    console.log("Health check OK", BUILD_VERSION , `:` , healthCheck);
  }
  res.send("OK");
});
// Remixのリクエストハンドラ
app.all("*", createRequestHandler({ build }));
app.listen(_PORT, () => {
  console.log(`Server started on http://localhost:${_PORT}`);
});
console.log(`@@@@@@@ AWS Lambda COLD START @@@@@@@@@@@`);
ALBのヘルスチェックは /sample_site1/health エンドポイントで受け付けます。
Viteのアセットパス設定
サブディレクトリ運用のアセットパスを正しく設定するため、Viteの設定でベースパスを指定します。
このままでは /asset/index.jsとかにファイルを取りに行ってしまいますので,/sample_site1/asset/index.jsを見てもらうためです。
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
declare module "@remix-run/node" {
  interface Future {
    v3_singleFetch: true;
  }
}
export default defineConfig({
  base: '/sample_site1/', // ここだけ追加
  plugins: [
    remix({
      future: {
        // React Router v7にアップデートするために必要なフラグ群
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
        v3_singleFetch: true,
        v3_lazyRouteDiscovery: false, // https://github.com/remix-run/remix/issues/10124
      },
    }),
    tsconfigPaths(),
  ],
});
注意点として、202412時点で上記のv3_lazyRouteDiscoveryにはバグが有ります。
これを true にすると、ルーティング時にmanifestファイルというものをまずRemixが探しに行くようになりますが、これが Vite の baseもRemixのbasenameも尊重してくれません。
/sample_site1/ではなく/にルーティングマニフェストファイルを要求してしまい、404エラーが発生します。
次期 Remix である React Router v7のリリースまでにこの問題が解決されることが期待されます。
Docker Image /Lambdaのデプロイ
build_deploy以下は
build_deploy
└── site1
    ├── Dockerfile
    └── Makefile
としています。
アプリケーションのビルドとaws-lambda-web-adapterの設定を含むDockerfileを実装します。
FROM arm64v8/node:22.3-alpine3.19 AS build-deps
WORKDIR "/var/task"
COPY package.json package-lock.json ./
RUN npm ci
# Build stage
FROM build-deps AS build
COPY . .
RUN npm run build
# Production dependencies stage
FROM arm64v8/node:22.3-alpine3.19 AS prod-deps
WORKDIR "/var/task"
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Final stage
FROM arm64v8/node:22.3-alpine3.19 AS runner
WORKDIR "/var/task"
RUN apk update && apk add ca-certificates tzdata && rm -rf /var/cache/apk/*
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=7000
# Copy only necessary files from previous stages
COPY --from=prod-deps /var/task/node_modules ./node_modules
COPY --from=build /var/task/build ./build
COPY --from=build /var/task/public ./public
COPY --from=build /var/task/package.json ./package.json
COPY --from=build /var/task/package-lock.json ./package-lock.json
COPY server.js ./server.js
ENV NODE_OPTIONS=--enable-source-maps
ENV VITE_ENV=production
CMD ["node", "server.js"]
MakefileでDocker Imageの作成、ECRへのプッシュ、Lambdaの更新を自動化します。
LambdaとECRは事前にsample_site1という名前で作成されている前提です。
LAMBDA_FUNC_NAME = sample_site1
ECR_COM = xxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com
REPOSITORY_URI_LAMBDA=${ECR_COM}/sample_site1
LOCAL_DOCKER_IMAGE=vkjp/sample_site1:latest
build:
	mkdir -p .build
	docker build \
		-f ./Dockerfile \
		--platform linux/arm64 \
		--progress=plain \
		-t ${LOCAL_DOCKER_IMAGE} ../..
	rm -rf .build
deploy_frontend:
	export AWS_PAGER="" && \
	aws ecr get-login-password | docker login --username AWS --password-stdin ${ECR_COM} && \
	docker tag ${LOCAL_DOCKER_IMAGE} ${REPOSITORY_URI_LAMBDA}:latest && \
	docker push ${REPOSITORY_URI_LAMBDA}:latest
	# UPDATE
	@echo "Lambdaを更新して伝播を待機中...."
	aws lambda update-function-code \
		--function-name ${LAMBDA_FUNC_NAME} \
		--image-uri "${REPOSITORY_URI_LAMBDA}:latest" \
		--publish
	@echo "更新の伝播を待機中...."
	@while [ "$$(aws lambda get-function --function-name ${LAMBDA_FUNC_NAME} --query 'Configuration.LastUpdateStatus' --output text)" = "InProgress" ]; do \
		echo "更新中... 待機しています"; \
		sleep 5; \
	done
このディレクトリでmake build、make deploy_frontendを実行することでデプロイが完了します。
ALBターゲットグループ/ALBリスナールール作成
ここまでの実装はAPI Gateway + LambdaやLambda Function URL + LambdaでのRemixアプリケーションのデプロイと同様です。以下がALB特有の実験となります。
ALBのターゲットグループ、「samplesite1」を作り、先程のLambdaを指定します。
今回はあまりALBのヘルスチェック設定する意味もないのですが、server.jsで設定したヘルスチェック関数にルーティングしておきます。
あとはALBリスナールールに特定ホスト、特定パス以下の挙動を先程のターゲットグループ 「samplesite1」に当てれば完成です。
動作確認
/sample_site1にアクセスしてみます。
/sample_site1にアクセスすると、CSSとJavaScriptが正しく読み込まれ、アプリケーションが正常に動作することが確認できます。'ちょっとわからない見た目ですね...)
考察
ALB + Lambda(aws-lambda-web-adapter)でも問題なくサイト構築ができることがわかりました。
ここまでALBでやってきてなんですが、キャッシュのことなどを考えると普通に 前段のCloudfront 側で Lambda Function URLにルーティングするかな... という気はします。ALBからルーティングされているからといってこれが自動的に VPC Lambda になるってわけでもないですからね。
ALB Lambda、用途がすぐには思い浮かばない...。
いろいろ試せましたということで、本稿を終わりたいと思います。
明日は @kotobuki5991 さんの「OpenFeature x AWS AppConfigでフィーチャーフラグ」となります。




