RemixアプリをALB Lambdaとしてデプロイする

この記事は[Wano Group Advent Calendar 2024](https://qiita.com/advent-calendar/2024/wano-group)の5日目の記事となります。

## ALB -> Lambda -> WebApp?


アプリケーションサーバーをAWS Lambdaでホストする、というのを仕事でも個人的にも最近たまにやっています。このへんでいつもお世話になっているのが [aws-lambda-web-adpter](https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/) ですね。
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
```
```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です。)  

```javascript:server.js
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`を見てもらうためです。


```TypeScript:vite.config.ts
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`には[バグ](https://github.com/remix-run/remix/issues/10124)が有ります。

これを true にすると、ルーティング時にmanifestファイルというものをまずRemixが探しに行くようになりますが、これが Vite の baseもRemixの[basename]( https://remix.run/blog/remix-vite-stable#basename-support)も尊重してくれません。

`/sample_site1/`ではなく`/`にルーティングマニフェストファイルを要求してしまい、404エラーが発生します。

次期 Remix である React Router v7のリリースまでにこの問題が解決されることが期待されます。


## Docker Image /Lambdaのデプロイ

build_deploy以下は
```
build_deploy
└── site1
    ├── Dockerfile
    └── Makefile
```
としています。


アプリケーションのビルドとaws-lambda-web-adapterの設定を含むDockerfileを実装します。

```Dockerfile: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`という名前で作成されている前提です。

```Makefile:Makefile
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を指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85375/cbb2a095-60c2-0632-570b-7f55f0a41d55.png)

今回はあまりALBのヘルスチェック設定する意味もないのですが、`server.js`で設定したヘルスチェック関数にルーティングしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85375/b04f5260-fa41-ba26-fea9-4ae690d25761.png)


あとはALBリスナールールに特定ホスト、特定パス以下の挙動を先程のターゲットグループ 「samplesite1」に当てれば完成です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85375/5be9ccac-ecc2-dfa3-472e-f41e9e7c3b8e.png)

## 動作確認
`/sample_site1`にアクセスしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85375/bbbb7163-7ddc-7d51-2765-ab8610b5d974.png)

`/sample_site1`にアクセスすると、CSSとJavaScriptが正しく読み込まれ、アプリケーションが正常に動作することが確認できます。'ちょっとわからない見た目ですね...)

## 考察
ALB + Lambda(aws-lambda-web-adapter)でも問題なくサイト構築ができることがわかりました。  

ここまでALBでやってきてなんですが、**キャッシュのことなどを考えると普通に 前段のCloudfront 側で Lambda Function URLにルーティングするかな...** という気はします。ALBからルーティングされているからといってこれが自動的に VPC Lambda になるってわけでもないですからね。

ALB Lambda、用途がすぐには思い浮かばない...。

いろいろ試せましたということで、本稿を終わりたいと思います。

明日は [@kotobuki5991](https://qiita.com/kotobuki5991) さんの「OpenFeature x AWS AppConfigでフィーチャーフラグ」となります。