Hono app with Docker, Kubernetes
こちらは Hono Advent Calendar 2023 3日目の記事です。
はじめに
Honoと聞くと、Cloudflareなどのサーバーレスなサービスでアプリを動かす印象が強いですが、コンテナ系のサービスでも全然開発出来るよーというユースケースの紹介です。
アプリを実装する
HonoでAPIを、Reactでクライアントを実装し、簡単なTodoアプリを作ってみます。
全体は以下のようなモノレポになっています。
.
├── README.md
├── apps
│ ├── api/
│ └── web/
├── okteto.yaml
├── package-lock.json
└── package.json
API
構成はMVCです。
app/
├── Dockerfile
├── dist/
├── k8s_api.yaml
├── package.json
├── src
│ ├── controller
│ │ ├── index.ts
│ │ └── todo-controller.ts
│ ├── index.tsx
│ ├── infrastructure
│ │ ├── index.ts
│ │ ├── todo-database.ts
│ │ └── todo.db
│ ├── lib
│ │ ├── index.ts
│ │ ├── result.ts
│ │ └── sqlite3.ts
│ └── model
│ ├── index.ts
│ └── todo-model.ts
├── tsconfig.json
└── tsup.config.ts
DBは以下で初期化しておきます、
import { sqlite3 } from "@/lib";
import { Database } from "sqlite3";
import path from "path";
export const TODO_DATABASE_PATH = path.join(__dirname, "todo.db");
export interface TodoInterface {
uid: string;
title: string;
completed: 0 | 1;
}
export class TodoDatabase {
db: Database;
constructor() {
this.db = new sqlite3.Database(TODO_DATABASE_PATH);
this.#creataTable();
}
close() {
this.db.close();
}
#creataTable() {
const sql = `
CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT,
title TEXT,
completed INTEGER
)`;
this.db.run(sql);
}
}
CRUDをそれぞれ書いてみます。
Model
import { TodoInterface, TodoDatabase } from "@/infrastructure";
import { Result } from "@/lib";
export class TodoModel {
#todo: TodoDatabase;
constructor() {
this.#todo = new TodoDatabase();
}
read({ uid, title, completed }: Partial<TodoInterface>) {
return new Promise<Result<TodoInterface[]>>((resolve) => {
let sql = "SELECT * FROM todo";
const params: TodoInterface[keyof TodoInterface][] = [];
if (uid) {
sql += " WHERE uid = ?";
params.push(uid);
}
if (title) {
sql += " WHERE title = ?";
params.push(title);
}
if (completed) {
sql += " WHERE completed = ?";
params.push(completed);
}
this.#todo.db.all<TodoInterface>(sql, params, (error, rows) => {
if (error) resolve({ status: "error", error });
resolve({ status: "ok", data: rows });
});
});
}
create({ uid, title }: Pick<TodoInterface, "title" | "uid">) {
return new Promise<Result<TodoInterface[]>>((resolve) => {
const sql = "INSERT INTO todo (uid, title, completed) VALUES (?, ?, ?)";
this.#todo.db.run(sql, [uid, title, 0], (error) => {
if (error) resolve({ status: "error", error });
resolve({ status: "ok", data: [{ uid, title, completed: 0 }] });
});
});
}
update({ uid, title, completed }: TodoInterface) {
return new Promise<Result<TodoInterface[]>>((resolve) => {
const sql = "UPDATE todo SET title = ?, completed = ? WHERE uid = ?";
this.#todo.db.run(sql, [title, completed, uid], (error) => {
if (error) resolve({ status: "error", error });
resolve({ status: "ok", data: [{ uid, title, completed }] });
});
});
}
delete({ uid }: Pick<TodoInterface, "uid">) {
return new Promise<Result<TodoInterface[]>>((resolve) => {
const sql = "DELETE FROM todo WHERE uid = ?";
this.#todo.db.run(sql, [uid], (error) => {
if (error) resolve({ status: "error", error });
resolve({ status: "ok", data: [] });
});
});
}
}
Controller
import { TodoModel } from "@/model";
export class TodoController {
#todo: TodoModel;
constructor() {
this.#todo = new TodoModel();
}
async read(params: Parameters<TodoModel["read"]>[0]) {
return await this.#todo.read(params);
}
async create(params: Parameters<TodoModel["create"]>[0]) {
return await this.#todo.create(params);
}
async update(param: Parameters<TodoModel["update"]>[0]) {
return await this.#todo.update(param);
}
async delete(param: Parameters<TodoModel["delete"]>[0]) {
return await this.#todo.delete(param);
}
}
export const todoController = new TodoController();
今回、HonoはAPIサーバーとして、JSONを返すものとします。ブラウザからAPIを叩くのでHonoのCORS Middlewareも入れておきます。
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { todoController } from "@/controller/todo-controller";
const app = new Hono();
app.use(
"/api/*",
cors({
origin: ["http://localhost:8080", "https://web-shuta13.cloud.okteto.net"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
allowHeaders: ["Content-Type", "Authorization"],
})
);
app.get("/", (c) => {
return c.text("Health OK");
});
app.get("/api/todo", async (c) => {
const todos = await todoController.read({});
if (todos.status === "ok") {
return c.json(todos.data);
} else {
c.status(500);
return c.json(todos.error);
}
});
app.post("/api/todo", async (c) => {
const data = await c.req.json();
const uid = crypto.randomUUID();
const result = await todoController.create({
uid,
...data,
});
if (result.status === "ok") {
return c.json(result.data);
} else {
c.status(500);
return c.json(result.error);
}
});
app.put("/api/todo/:uid", async (c) => {
const uid = c.req.param("uid");
const data = await c.req.json();
const result = await todoController.update({ uid, ...data });
if (result.status === "ok") {
return c.json(result.data);
} else {
c.status(500);
return c.json(result.error);
}
});
app.delete("/api/todo/:uid", async (c) => {
const uid = c.req.param("uid");
const result = await todoController.delete({ uid });
if (result.status === "ok") {
return c.json(result.data);
} else {
c.status(500);
return c.json(result.error);
}
});
serve(app, (info) => {
console.log(`Api server is running on ${info.port}`);
});
こういうライブラリでCRUD書いて試すときに、Todoアプリだと書きやすので本当に例題として優れていますね。
Web
vite の react-ts のテンプレから生成したものをほぼそのまま使います。
web/
├── Dockerfile
├── dist/
├── index.html
├── k8s_web.yaml
├── package.json
├── public/
├── server.js
├── src
│ ├── components
│ │ └── App.tsx
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
App.tsx にTodoリストと、Todo作成用のフォームを追加します。
import { useState, useEffect } from "react";
import { clsx } from "clsx";
interface Todo {
uid: string;
title: string;
completed: 0 | 1;
}
const API_URL = import.meta.env.PROD
? "https://api-shuta13.cloud.okteto.net"
: "http://localhost:3000";
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState("");
useEffect(() => {
fetch(API_URL + "/api/todo")
.then((res) => res.json())
.then((data) => setTodos(data));
}, []);
return (
<div className="space-y-4 mx-auto max-w-[768px] px-4">
<h1 className="text-2xl font-bold">hono-docker-k8s-example</h1>
<div className="space-y-2">
<h2 className="text-xl font-bold">Todo</h2>
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo.uid}
className="flex place-items-center place-content-between bg-gray-100 p-2 rounded-md"
>
<div className="flex gap-x-2">
<input
type="checkbox"
checked={todo.completed === 1}
onChange={async () => {
await fetch(API_URL + "/api/todo/" + todo.uid, {
method: "PUT",
body: JSON.stringify({
title: todo.title,
completed: todo.completed === 1 ? 0 : 1,
}),
});
const res = await fetch(API_URL + "/api/todo");
const data = await res.json();
setTodos(data);
}}
/>
<span
className={clsx({
"line-through": todo.completed === 1,
})}
>
{todo.title}
</span>
</div>
<button
type="button"
onClick={async () => {
await fetch(API_URL + "/api/todo/" + todo.uid, {
method: "DELETE",
});
const res = await fetch(API_URL + "/api/todo");
const data = await res.json();
setTodos(data);
}}
>
Delete
</button>
</li>
))}
</ul>
</div>
<div className="space-y-2">
<h2 className="text-xl font-bold">Add a new task</h2>
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
await fetch(API_URL + "/api/todo", {
method: "POST",
body: JSON.stringify({
title,
completed: 0,
}),
});
setTitle("");
const res = await fetch(API_URL + "/api/todo");
const data = await res.json();
setTodos(data);
}}
>
<label htmlFor="title">Type a title</label>
<input
name="title"
type="text"
className="border w-full px-2"
onChange={(e) => {
setTitle(e.target.value);
}}
value={title}
/>
<div className="flex place-content-end">
<button type="submit" className="bg-green-200 px-4 py-1">
Add
</button>
</div>
</form>
</div>
</div>
);
}
export default App;
ちなみに Web の production server は Hono で立ち上げています。 server.js がこれの実装です。
"use strict";
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
const app = new Hono();
app.use("/assets/*", serveStatic({ root: "./dist" }));
app.use("/vite.svg", serveStatic({ path: "./dist/vite.svg" }));
app.use("/", serveStatic({ path: "./dist/index.html" }));
serve({ fetch: app.fetch, port: 8080 }, (info) => {
console.log(`Web server is running on ${info.port}`);
});
Honoでのフロントエンド関連の話
今回、ReactでSSRはしていません。全部CSRです。
ただ、Reactだと事前にビルドする手間があるので、Hono x htmxの事例を見つつ検討してみたり、esm.sh を使って script に埋め込む方法も検討しました。
が、結局 vite でバンドルする形に落ち着いてます。
Hono使うにあたってここらへんは何が最適なんでしょうかね…
k8sでwebとapiのpod分けず、honoから直接 HTML 返すのはアリだと思いますが、フロントの規模がデカくなると大変になりそうです。
JSX Middlewareは Hydration までカバーしないので、そのままReactを使っていくのは少し手間がかかりそうです。
なのでHonoでReactのSSRする場合、Next.jsでも良いかなーとは思います。サンプルもあって良さそうです。
デプロイしてみる
Dockerfileとk8sのマニフェストを書いて、これまでに実装した API と Web をそれぞれpodで動かしてみます。
今回はk8sを用いた開発やデプロイ簡略化のために、Oktetoというサービスを使っています。
Oktetoについて
Oktetoについて軽く紹介します(別に回し者ではないです)
OktetoとはSaaS、あるいはKaaS(Kubernetes as a Service)の一種です。
k8sにあまり慣れてなくても扱いやすく、無料枠の制限がそこまで厳しくないので個人開発や零細なサービス開発には向いているような気がします。
…と、まあ個人サービス開発でk8s使いたいってなる場面が正直あまり想像出来ません。
k8sの素振りで使う程度にはちょうど良いかなと思います。
ただ、リクエストをしないとPodが起動しないので、初回のレスポンスは遅くなりがちです。
また、 okteto up でリモートへのファイルのSyncが動かないことが多く、変更は手動かDeploy Pipelineで反映する形が安定しそうです。
SyncはIOの監視の上限を上げればどうにかなるそうですが、筆者の環境では解消しませんでした(manifestのマウントの from:to も間違ってなかったので謎)
詳細はドキュメントの他、日本語だと https://zenn.dev/aoi/articles/9ff83fe3c2e58d によくまとまっていたので、参考にしてみてください。
Dockerfile
HonoはNode.jsの環境があれば動くので、こんな感じで書けば良いです。
FROM okteto.dev/node:18
COPY package.json ./
COPY package-lock.json ./
COPY apps/api ./apps/api
RUN npm ci .
COPY . .
RUN npm run build --prefix apps/api
ENV PORT 3000
EXPOSE 3000
ENTRYPOINT ["npm", "run", "--prefix", "apps/api"]
CMD ["start"]
なお、nodeのイメージはoktetoのレジストリに上がっているものを使うので、ローカルで動かす際は適当なものに書き換えてください。
Reactの向けのものは以下のようになります。ほぼ一緒です。
FROM okteto.dev/node:18
COPY package.json ./
COPY package-lock.json ./
COPY apps/web ./apps/web
RUN npm ci .
COPY . .
RUN npm run build --prefix apps/web
ENV PORT 8080
EXPOSE 8080
ENTRYPOINT ["npm", "run", "--prefix", "apps/web"]
CMD ["start"]
それぞれ、モノレポのWorkspaceのルートに配置しておきます。
Manifests
Oktetoとk8sのmanifestを書いてみます。
今回は、API向けの k8s_api.yaml とWeb向けの k8s_web.yaml に分けて、Okteto CLIでそれぞれを kubectl apply -f で実行してデプロイします。
Web 向けの k8s manifest は以下のようになります。
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- image: okteto.dev/node:18
name: web
command: ["npm", "run", "--prefix", "apps/web", "start"]
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP
ports:
- protocol: TCP
name: "web"
port: 8080
selector:
app: web
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
dev.okteto.com/generate-host: web
spec:
rules:
- http:
paths:
- backend:
service:
name: web
port:
number: 8080
path: /
pathType: ImplementationSpecific
ちなみにIngressは別に書かなくても Okteto 側で適当なものを割り当て られます。
API 向けの k8s manifest は以下のようになります。
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- image: okteto.dev/node:18
name: api
command: ["npm", "run", "--prefix", "apps/api", "start"]
---
apiVersion: v1
kind: Service
metadata:
name: api
spec:
type: ClusterIP
ports:
- protocol: TCP
name: "api"
port: 3000
selector:
app: api
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
annotations:
dev.okteto.com/generate-host: api
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "https://web-shuta13.cloud.okteto.net"
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type,Authorization"
spec:
rules:
- http:
paths:
- backend:
service:
name: api
port:
number: 3000
path: /
pathType: ImplementationSpecific
ほぼWebと変わりませんが、Ingressの部分でCORS向けの設定を追加しています。
Hono側でCORSは設定しているのですが、これを追記しないと web の k8s のサービス名のオリジン以外は全部弾かれます。
次にOkteto manifestです。
build:
api:
image: okteto.dev/node:18
context: .
dockerfile: apps/api/Dockerfile
web:
image: okteto.dev/node:18
context: .
dockerfile: apps/web/Dockerfile
deploy:
- kubectl apply -f ./apps/web/k8s_web.yaml
- kubectl apply -f ./apps/api/k8s_api.yaml
dev:
api:
command: bash
forward:
- 3000:3000
sync:
- ./apps/api:/apps/api
web:
command: bash
forward:
- 8080:8080
sync:
- ./apps/web:/apps/web
一応 dev も書いてます。
(これでローカルでpod間の通信を見たいときはデプロイ後に okteto up <サービス名> で、podを立ち上げて確認できます)
では、イメージをビルドし、Okteto CloudにデプロイしてPod上でアプリを動かしてみます。
Okteto CLI をインストールし、以下でOkteto Cloudにデプロイします。
okteto login
okteto deploy --build
Okteto CloudにAPIとWebでそれぞれpodが起動しています。
Web の URL を開くと、アプリも動いてていい感じですね
おわりに
Dockerとk8s、KaaSを使用してHono製のアプリを開発してみました。
あまりHonoには触れたことがなかったんですが、Expressを書く感覚で書け、エコシステムやサンプルも充実しており、ビギナーにも優しい印象でした。
基本Honoを使う際は、サーバーレスで事足りるとは思いますが、コンテナやk8sが必要になった場面で何かの参考になれば幸いです。
今回の記事に登場したソースコードはこちらからご覧になれます:https://github.com/shuta13/hono-docker-k8s-example
ご精読ありがとうございました。
次回は4日目 @watany さんの「helmetっぽいミドルウェアの話」 です!