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.jsonAPI構成は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.tsDBは以下で初期化しておきます、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をそれぞれ書いてみます。Modelimport { 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: TodoInterfacekeyof 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: [] });
});
});
}
}Controllerimport { 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アプリだと書きやすので本当に例題として優れていますね。Webvite の 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.tsApp.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でのフロントエンド関連の話デプロイしてみるDockerfileとk8sのマニフェストを書いて、これまでに実装した API と Web をそれぞれpodで動かしてみます。今回はk8sを用いた開発やデプロイ簡略化のために、Oktetoというサービスを使っています。OktetoについてDockerfileHonoは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のルートに配置しておきます。ManifestsOktetoと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 --buildOkteto 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っぽいミドルウェアの話」 です!