この記事は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でフィーチャーフラグ」となります。