Docker(App Runner)+Honoなら、BunでもDenoでもNode.jsでも動く
モチベーション
- Edge環境やLambda周りでの利用事例はあるが、意外と Hono on Dockerな事例が見つからなかったので試してみる。
- App RunnerがSSE(Server-Sent Events)に対応していると聞いて
App Runner
Webサービスを簡単に立てられるAWSのサービス。DockerImageをカジュアルにホスティングする。
ちなみに組み込みランタイムはNode v16までしか対応していない(しっかりしてくれ!)ので割愛。
Docker + Hono
それではDockerで試してみよう。
1. Bun
以下のコマンドでbun用のフォーマットを選び、hono-bun
という名前のWorkspaceを作る。
npm create hono@latest hono-bun
Code
Hono v3.8から採用されたSSEで、時刻が返ってくる。
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
let id = 0;
app.get('/', (c) => c.text('Hono on Bun'));
app.get("/sse", async (c) => {
return streamSSE(c, async (stream) => {
while (true) {
const message = `It is ${new Date().toISOString()}`;
await stream.writeSSE({
data: message,
event: "time-update",
id: String(id++),
});
await stream.sleep(1000);
}
});
});
export default app;
Dockerfile
distrolessを選ぶと200MBが100MB程度に下がるので、頑張ってマルチステージビルドする。
# build
FROM oven/bun:latest AS build
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --production
# runtime
FROM oven/bun:distroless AS runtime
COPY /app/node_modules /app/node_modules
COPY src ./src
USER nonroot
EXPOSE 3000
CMD ["run", "--hot", "src/index.ts"]
2. Deno
以下のコマンドでDeno用のフォーマットを選び、hono-deno
という名前のWorkspaceを作る。
npm create hono@latest hono-deno
Code
Healthcheck相当の/
を除くと、import文とエントリポイントを変えれば同じコードで動く。
import { Hono } from "https://deno.land/x/hono@v3.8.1/mod.ts";
import { streamSSE } from "https://deno.land/x/hono@v3.8.1/helper.ts";
const app = new Hono();
let id = 0;
app.get('/', (c) => c.text('Hono on Deno'));
app.get("/sse", async (c) => {
return streamSSE(c, async (stream) => {
while (true) {
const message = `It is ${new Date().toISOString()}`;
await stream.writeSSE({
data: message,
event: "time-update",
id: String(id++),
});
await stream.sleep(1000);
}
});
});
Deno.serve(app.fetch);
Dockerfile
nonrootの権限周りでハマってちょっと汚い。
# build
FROM denoland/deno:debian AS build
WORKDIR /app
COPY hello.ts .
RUN mkdir -p /deno-dir
RUN deno cache hello.ts
# runtime
FROM denoland/deno:distroless AS runtime
COPY /app /app
COPY /deno-dir /deno-dir
ENV DENO_DIR=/deno-dir
USER nonroot
EXPOSE 8000
CMD ["run", "--allow-net", "app/hello.ts"]
3. Node
以下のコマンドでNodejs用のフォーマットを選び、hono-node
という名前のWorkspaceを作る。
npm create hono@latest hono-node
Code
こちらもimportとエントリポイント(と/
)を変更する。node-serverを追加するのがポイント。
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
let id = 0;
app.get('/', (c) => c.text('Hono on Nodejs'));
app.get("/sse", async (c) => {
return streamSSE(c, async (stream) => {
while (true) {
const message = `It is ${new Date().toISOString()}`;
await stream.writeSSE({
data: message,
event: "time-update",
id: String(id++),
});
await stream.sleep(1000);
}
});
});
serve(app);
Config
honoのstarterだと足りないものがあったので、いろいろ追加する。
{
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"@hono/node-server": "^1.2.0",
"hono": "^3.8.1"
},
"devDependencies": {
"tsc": "^2.0.4",
"tsx": "^3.14.0",
"typescript": "^5.2.2"
}
}
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"**/*.spec.ts"
]
}
Dockerfile
# build
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json tsconfig.json ./
COPY src ./src
RUN npm ci --only=production
RUN npm install typescript --save-dev \
npx tsc
RUN npm prune --production
# runtime
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
COPY /app/node_modules /app/node_modules
COPY /app/src /app/src
EXPOSE 3000
CMD ["/app/src/index.js"]
App Runnerでの構築
App Runnerの欠点としてDeploy待ちが面倒だったりする。Imageを差し替える時間が無駄なので景気よく、3多重で作ってしまおう。CDKなら簡単。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets';
import * as ecrDeploy from 'cdk-ecr-deployment';
import * as apprunner from '@aws-cdk/aws-apprunner-alpha';
export class CdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const repo = new ecr.Repository(this, "app-runner-api-repo", {
repositoryName: 'app-runner-api-repo',
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteImages: true
});
const images = [
{ id: 'Node', directory: '../hono-node', port: 3000, tag: 'node' },
{ id: 'Deno', directory: '../hono-deno', port: 8000, tag: 'deno' },
{ id: 'Bun', directory: '../hono-bun', port: 3000, tag: 'bun' },
];
images.forEach(image => {
const ecrAsset = new ecrAssets.DockerImageAsset(this, `CDKAppRunner${image.id}Image`, {
directory: image.directory,
});
new ecrDeploy.ECRDeployment(this, `Deploy${image.id}ECRImage`, {
src: new ecrDeploy.DockerImageName(ecrAsset.imageUri),
dest: new ecrDeploy.DockerImageName(`${repo.repositoryUri}:${image.tag}`),
});
const apprunnerName = new apprunner.Service(this, `${image.id.toLowerCase()}Service`, {
source: apprunner.Source.fromEcr({
imageConfiguration: { port: image.port },
repository: repo,
tagOrDigest: image.tag,
}),
});
new cdk.CfnOutput(this, `App-Runner-Uri-${image.id.toLowerCase()}`, {
value: apprunnerName.serviceUrl,
});
});
}
}
作ったURLにアクセスすると、いずれもいい感じにSSEイベントが流れてくることがわかる。
まとめ
改めてHonoがマルチランタイムであることを確認できた。Hono + Dockerfileの作り方は意外とWebにないので、今後動かすときのサンプルにできそうだ。
あくまで参考値だが、Docker Imageのサイズはこんな感じ。Bunは作りやすいし軽いので良い。
Discussion
ありがとうございます!
上記に関して、エントリーとなるファイルを
bun build
もしくはesbuild
等で Bundle + Minify してしまえば、イメージサイズを更に減らせますかね...?デメリットとしては
辺りでしょうか。上記以外のデメリット等あればお伺いしたいです!
普通に詳しくないだけでした!手元で試して動いたら更新します。
ありがとうございます!