はじめに:Step 0 のゴール
前回は、Web 開発を始めた理由と、血液検査アプリまでの学習ロードマップを紹介しました。今回は最初のステップ、Step 0 です。
作ったのは、「Hello」という文字と、サーバーの現在時刻を表示するだけのツールです。機能としては、これ以上ないほど単純です。
それでも、このツールを外から見られるようにするまでには、Web の仕組みのほとんどの層を通ることになります。TypeScript で書いたプログラムをビルドし、Node.js で動かし、落ちても自動で立ち上がるように常駐させ、Web サーバーで中継し、認証をかけ、インターネット越しに確かめる。新しいマイコンでまず LED を点滅させ、開発環境から書き込み、クロック、GPIO までが一通り動くことを確かめる「Lチカ」と、まったく同じ発想です。
この回では、機能より経路を一本通すことを目標にした Step 0 の流れと、そこでつまずいたことを紹介します。
前提について:この回の常駐・中継・公開の手順は、常時動いている Linux サーバー(自宅の仮想マシン)を前提にしています。Windows の WSL は、Windows を閉じたりスリープしたりすると止まるため、公開用のサーバーには向きません。一方、プログラムを書いてビルドし、Node.js で動かして確かめるところまでは、WSL でも同じ手順で試せます。
全体の流れ
Step 0 は、次の8つの小さなステップに分けて進めました。1つのステップは、動作を確かめられる単位で区切っています。
| No | やったこと | 組み込みでいうと |
|---|---|---|
| 0-1 | Node.js と PM2 の整理 | 開発用ツールチェーンのバージョンをそろえる |
| 0-2 | 決定事項を書き、Git リポジトリを作る | 開発方針の文書化と構成管理の準備 |
| 0-3 | 要件定義書と設計書を書く | 仕様書と設計書 |
| 0-4 | TypeScript でサーバーを書き、ビルドして単体確認 | コンパイルし、デバッガをつないで動作確認 |
| 0-5 | PM2 で常駐させ、自動起動を設定 | ウォッチドッグと電源投入時の自動起動 |
| 0-6 | Apache で中継し、Basic 認証をかける | バスへの接続とアドレスの割り当て |
| 0-7 | 外部・スマホ・再起動後に確認 | 実機での受け入れ試験 |
| 0-8 | テスト項目と振り返りを記録 | 試験成績書と反省会 |
文字を表示するだけのツールに8ステップは大げさに見えますが、ここで一度通した手順は、次のツールからはほぼ同じ形で使い回せます。実際、Step 1 の単位換算ツールでは、0-4〜0-6 の設定ファイルをコピーして、名前と番号を変えるだけで済みました。
環境の整理と「決定事項」
最初にしたのは、プログラムを書くことではなく、サーバーの現状を調べることでした。以前、別の AI の提案に沿っていろいろ試していたため、サーバーの中が散らかっていたのです。
調べてみると、Node.js と、Node.js のアプリを常駐させる PM2 が、ユーザーごとに別の系統で入っていました。管理者(root)側には古い Node.js v20 と PM2、作業用ユーザー側には新しい Node.js v24 と、v20 で動く PM2、という状態です。組み込みでいえば、同じプロジェクトでコンパイラのバージョンが2種類混在していて、どちらでビルドしたかによって動きが変わるようなものです。
そこで、ツール開発では作業用ユーザーの Node.js v24 と、それに合わせて入れ直した PM2 だけを使うことにし、古いアプリはいったんすべて止めました。
あわせて、こうした方針を決定事項として文書にまとめました。主な内容は次のとおりです。
- 使う Node.js のバージョンと、常駐に使う PM2
- TypeScript は tsc でビルドしてから実行する
- 公開パスは
/apps/<ツール名>/、ポートは 5001 から順に1ツール1番号、待ち受けは自分自身(127.0.0.1)だけ - Apache の設定はツールごとに1ファイル
- 置き場所はツールごとに1フォルダで、Git もツールごとに管理する
AI に相談しながら進めていると、会話のたびに少しずつ違う提案が出てきて、気づくと構成がばらばらになりがちです。最初に方針を書いて AI にも読ませ、「変えるときは理由を示す」というルールにしたことで、その後の会話がぶれなくなりました。組み込みの開発で、最初にコーディング規約や設計方針を決めておくのと同じ効果です。
サーバーを書く:受信割り込みハンドラと同じ形
サーバーは、Node.js に最初から入っている node:http だけで書きました。追加の部品(フレームワーク)は使っていません。中心部分を抜き出すと、次のような形です。
import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 5001;
const BASE = "/apps/hello";
// リクエストが届くたびに呼ばれる処理
const server = createServer((req, res) => {
const path = new URL(req.url ?? "/", "http://localhost").pathname;
if (path === `${BASE}/`) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>Hello</h1>"); // 実際は時刻も埋め込む
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found\n");
});
server.listen(PORT, HOST); // 待ち受け開始最初は見慣れない書き方に戸惑いましたが、組み込みの言葉に置き換えると、よく知っている構造でした。
| コード | 組み込みでいうと |
|---|---|
createServer((req, res) => {…}) | 受信割り込みハンドラの登録 |
req.method、req.url | 受信フレームのコマンド部とアドレス部 |
res.writeHead(200, …) | 応答フレームのヘッダ部(ステータス) |
res.end(本文) | データを送って応答を完了する |
server.listen(PORT, HOST) | 割り込み許可、受信開始 |
驚いたのは、main() に相当する場所に無限ループがないことです。Node.js では、実行環境そのものがメインループ(イベントループ)を持っていて、リクエストが来ると、登録しておいたハンドラを呼んでくれます。私たちが書くのは、ハンドラの中身だけです。
ハンドラの中は、設計書に書いた「このパスなら 200、スラッシュなしなら 301、それ以外は 404、GET 以外は 405」という表を、そのまま if 文に並べたものです。設計書の1行が、コードの1か所とテスト項目の1つに対応している状態を作れたのは、V字モデルで進めた効果だと感じています。
TypeScript は tsc で JavaScript にコンパイルし、dist/server.js を Node.js で実行します。まず手で起動し、ログを目の前に流しながら curl で応答を確かめました。量産の書き込みの前に、デバッガをつないでシリアルログを見ながら動かす段階です。
常駐させる:PM2 はウォッチドッグ
手で起動したサーバーは、ターミナルを閉じれば止まり、エラーで落ちても誰も起こしてくれません。そこで PM2 というツールで常駐させます。PM2 の役割は、組み込みでいうウォッチドッグ付きの起動管理です。
- プロセスが落ちたら、自動で起動し直す
- サーバー(OS)の起動時に、登録したアプリをまとめて立ち上げる
- 標準出力をログファイルに残す
起動の設定は ecosystem.config.cjs というファイルに書きます。ポート番号などはここで環境変数として渡すので、プログラムを書き換えずに設定だけを変えられます。ファームは同じまま、ディップスイッチで動作を切り替えるような感覚です。
ウォッチドッグが本当に働くかは、わざとプロセスを強制終了して確かめました。手順は次のとおりです。
$ pm2 pid hello # ① 今のプロセス番号を確認
$ kill -9 $(pm2 pid hello) # ② そのプロセスを強制終了
$ sleep 3 # ③ 3秒待つ
$ pm2 list # ④ 状態を確認
$ pm2 pid hello # ⑤ プロセス番号をもう一度確認
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5001/apps/hello/ # ⑥ 応答を確認kill -9 は、プロセスに後片付けの猶予を与えずに止める命令です。組み込みでいえば、電源を瞬断するのに近い止め方です。$( … ) は、カッコの中のコマンドの結果(ここではプロセス番号)を、その場に埋め込む書き方です。
結果は次のとおりでした(pm2 list は一部の列を省略しています)。
$ pm2 pid hello
856682
$ kill -9 $(pm2 pid hello)
$ sleep 3
$ pm2 list
│ id │ name │ pid │ uptime │ ↺ │ status │
│ 0 │ hello │ 856887 │ 10s │ 1 │ online │
$ pm2 pid hello
856887
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5001/apps/hello/
200読み取れることは次の3つです。
| 確認 | 結果 | 意味 |
|---|---|---|
| プロセス番号 | 856682 → 856887 | 一度死んで、別のプロセスとして生き返った |
| 再起動回数(↺) | 1 | PM2 が1回、自動で再起動した |
| curl | 200 | 再起動後も、正しく応答している |
プロセス番号が変わっているのが、ウォッチドッグが働いた証拠です。番号が同じままなら、そもそも止まっていなかったことになります。
つまずいたこと:自動起動の設定に紛れ込んだ PATH
OS の起動時に PM2 を立ち上げる設定は、pm2 startup というコマンドで systemd(Linux の起動管理)に登録します。ここで一つ落とし穴がありました。
PATH は、コマンドを打ったときに「そのプログラムをどのフォルダから探すか」を並べた一覧です。pm2 startup は、実行したときのターミナルの PATH を、そのまま起動設定に書き込みます。
ところが、VS Code のターミナルには、VS Code 自身が使うフォルダが PATH に追加されています。そのため、登録された起動設定(/etc/systemd/system/pm2-devuser.service)を確認すると、次のようになっていました(長いので一部を省略しています)。
Environment=PATH=/home/devuser/.vscode-server/data/User/globalStorage/github.copilot-chat/…:/home/devuser/.vscode-server/cli/servers/Stable-07f8…/server/bin/remote-cli:/home/devuser/.nvm/versions/node/v24.19.0/bin:/home/devuser/.local/bin:/usr/local/bin:/usr/bin:….vscode-server で始まる部分が、VS Code が追加したフォルダです。この中にはバージョン番号が含まれていて、VS Code を更新すると存在しないフォルダになります。存在しないフォルダは無視されるだけなので、すぐに動かなくなるわけではありません。ただ、サーバーの起動設定が、開発に使っているエディタの環境に左右されるのは好ましくありません。組み込みでいえば、量産用のファームに、開発 PC の環境変数が焼き込まれてしまったような状態です。
そこで、必要なフォルダ(Node.js v24 のフォルダと、OS 標準のフォルダ)だけを env PATH=… で明示して、登録し直しました。
$ sudo env PATH=/home/devuser/.nvm/versions/node/v24.19.0/bin:/usr/local/bin:/usr/bin:/bin \
/home/devuser/.nvm/versions/node/v24.19.0/lib/node_modules/pm2/bin/pm2 startup systemd -u devuser --hp /home/devuser
$ sudo systemctl daemon-reload
$ grep "^Environment=PATH" /etc/systemd/system/pm2-devuser.service| 部分 | 意味 |
|---|---|
env PATH=… | このコマンドを実行するときだけ、PATH を指定した内容に差し替える |
pm2 startup systemd | PM2 を OS の起動管理(systemd)に登録する |
-u devuser --hp /home/devuser | どのユーザーとして、どのホームフォルダで動かすか |
systemctl daemon-reload | 書き換わった起動設定を、systemd に読み直させる |
行末の \ | コマンドが次の行に続くことを示す |
登録し直した後の起動設定は、指定したフォルダだけになりました。
Environment=PATH=/home/devuser/.nvm/versions/node/v24.19.0/bin:/usr/local/bin:/usr/bin:/binもう一つ、この起動設定には Node.js のバージョン番号入りのパスが書き込まれます。そのため、Node.js を更新したら pm2 startup をやり直す必要があります。忘れないように、環境の定義書に注意書きとして残しました。
最後に pm2 save で、「どのアプリを起動するか」の一覧を保存します。組み込みでいえば、起動構成を不揮発メモリに書き込む操作です。これを忘れると、再起動後にアプリが立ち上がりません。
ここまでの PM2 の働きを1枚にまとめると、次のようになります。

Apache で中継し、認証をかける
Node.js のサーバーは、自分自身(127.0.0.1)からの接続しか受け付けないようにしてあります。外からの要求は、まず Web サーバーの Apache が受け取り、/apps/hello で始まるものだけを Node.js へ中継します。
設定は2段構えにしました。

| ファイル | 内容 | 組み込みでいうと |
|---|---|---|
| サイト全体の設定(1回だけ書く) | /apps/ の下すべてに Basic 認証をかけ、ツールごとの設定ファイルを読み込む | 共通のバスコントローラの設定 |
| ツールごとの設定(ツールが増えたら足す) | /apps/hello を 5001 番へ中継する | バスにぶら下がる各デバイスのアドレス割り当て |
ツールごとの設定は、次の2行だけです。
ProxyPass "/apps/hello" "http://127.0.0.1:5001/apps/hello"
ProxyPassReverse "/apps/hello" "http://127.0.0.1:5001/apps/hello"ProxyPass は「この住所で始まる要求を右側へ中継する」という、いわばルーティングテーブルの1行です。パスは書き換えずにそのまま渡す方針にしたので、Node.js 側にも /apps/hello/ がそのまま届きます。
つまずいたこと:中継するパスには .htaccess が効かない
サイトの別の部分では、.htaccess というファイルで Basic 認証をかけていました。ところが、.htaccess は実際にファイルがある場所にしか効きません。中継するパスには実ファイルがないので、同じ方法では認証がかからないのです。
そこで、サイト全体の設定ファイルの中に <Location "/apps/"> という形で認証を書き、URL のパスに対して認証をかけるようにしました。こうしておけば、今後ツールを増やしても、ツールごとの設定で認証を書き忘れる心配がありません。
設定を変えるときの手順
Apache の設定を間違えると、同じサーバーで動いているブログまで止まってしまいます。そこで、変更の手順を決めて毎回守ることにしました。
- 作業用のフォルダにコピーして編集する
diffで変更点を確認する- 本番の場所へ書き戻す
httpd -tで文法をチェックする(エラーなら、ここで止める)- Apache を止めずに設定だけを読み直す(reload)
- 自作のスクリプトで、各サイト・各パスの応答コードを一覧にして、変わっていないことを確かめる
4 と 5 は、次のコマンドです。
$ sudo httpd -t # 文法チェック
$ sudo systemctl reload httpd # 止めずに設定を読み直す文法に問題がなければ、httpd -t は次の1行だけを返します。
Syntax OKこれ以外が表示されたら reload せずに止まり、設定を見直します。restart(止めてから起動し直す)ではなく reload を使うのは、同じ Apache で動いているブログを一瞬でも止めないためです。
書き込み前のベリファイと、変更後の回帰テストを、手順として固定した形です。
外から確かめる:受け入れ確認
最後に、インターネット側から本当に使えるかを確かめました。要件定義書に書いた受け入れ条件に沿って、次のように確認しています。
| 確認 | 方法 | 結果 |
|---|---|---|
| 認証なしでは見られない | サーバーから curl で外部の URL に問い合わせる | 401(認証が必要) |
| 認証すれば見られる | 同じく、ユーザー名とパスワードを付けて問い合わせる | 200、Hello と時刻が返る |
| 応答がキャッシュされない | 応答ヘッダの cf-cache-status を見る | DYNAMIC(毎回サーバーまで届いている) |
| スマホから使える | Wi-Fi を切り、モバイル回線で開く | ログイン後に表示され、再読み込みで時刻が更新される |
| ログインを断ると見られない | プライベートモードで開き、ログイン画面でキャンセル | 表示されない |
| 再起動しても動く | OS を再起動した後に開く | PM2 が自動で立ち上げ、表示された |
表の上の3つは、サーバーの中から外部の URL に問い合わせて確かめました。外部の URL を使うので、要求は一度インターネットに出て、Cloudflare → Apache → Node.js という本番と同じ経路を通ります。手順は次のとおりです。
# 認証なし
$ curl -s -o /dev/null -w "%{http_code}\n" https://example.com/apps/hello/
# 認証あり(パスワードは画面に出さずに入力して変数に入れる)
$ read -s -p "Password: " P; echo
$ curl -s -i -u "devuser:$P" https://example.com/apps/hello/ | grep -iE "^HTTP|^cache-control|^cf-cache-status|時刻"
$ unset Pread -s は、入力した文字を画面に表示せずに変数へ読み込む命令です。パスワードをコマンドに直接書くと、コマンドの履歴に残ってしまうため、この形にしています。使い終わったら unset で変数を消します。
grep -iE は、応答の中から必要な行だけを抜き出す指定です。応答の見出し(ヘッダ)には多くの行があるので、確認したいものだけに絞っています。
結果は次のとおりでした。
401
HTTP/2 200
cache-control: no-store
cf-cache-status: DYNAMIC
<p>サーバー時刻: 2026/10/02 11:51:14</p>| 行 | 意味 |
|---|---|
401 | 認証なしでは中身を返さない(Basic 認証が効いている) |
HTTP/2 200 | 認証すれば、正常に中身が返る |
cache-control: no-store | サーバー(Node.js)が「保存しないで」と指示している |
cf-cache-status: DYNAMIC | Cloudflare がキャッシュせず、毎回サーバーまで取りに来ている |
サーバー時刻: … | 自分のプログラムが作った内容が、インターネット越しに届いている |
HTTP/2 と表示されているのは、Cloudflare とブラウザ(ここでは curl)の間が HTTP/2 という新しい方式で通信しているためです。Node.js 自体は従来の HTTP/1.1 で応答していますが、途中の Cloudflare が変換してくれています。
スマホでわざと Wi-Fi を切ったのは、自宅の LAN の中からではなく、本当に外のインターネットから届くことを確かめるためです。ボード単体のテストを終えて、実際の通信回線越しに対向機と通信できたことを確認する段階にあたります。
時刻の表示は単純ですが、確かめるうえでは便利でした。表示が古いままなら、どこかで古い応答が使い回されている(キャッシュされている)とすぐにわかるからです。
振り返りと次回予告
「Hello と時刻を出すだけ」のツールでしたが、ビルド、常駐、中継、認証、受け入れ確認まで、Web で何かを動かすための経路を一本通すことができました。Web 版の Lチカ成功です。
振り返ってみると、学んだことの多くは、プログラムそのものより、その周りの「動かし続ける仕組み」と「安全に変更する手順」でした。
- 環境は、まず現状を調べ、方針を文書にしてから触る
- 常駐は PM2 に任せ、落ちても再起動しても戻ることを、実際に落として確かめる
- 設定の変更は、コピー → 差分確認 → 文法チェック → 反映 → 回帰テスト、の順を崩さない
- 確認は、サーバーの中からだけでなく、外のネットワークからも行う
どれも組み込みの現場で当たり前にやってきたことでした。道具の名前は違っても、考え方はそのまま持ち込めると感じています。
もう一つ、Step 0 を終えた時点では、Git の記録がサーバーの中にしかありませんでした。仮想マシンが壊れればソースも文書も失われるので、その後、ツールごとに GitHub の非公開リポジトリへ送る運用に変えています。
次回からは Step 1 に入ります。ブラウザの中だけで動く「検査値の単位換算ツール」を題材に、小さなツールの要件定義書と設計書を、どのくらいの分量で、どう書いたかを紹介します。
この記事は、シリーズ「組み込みエンジニアの Web 開発入門」の1本です。ほかの回は、シリーズの目次からどうぞ。



コメント