AlmaLinuxで構築する3層クライアントサーバシステム
AlmaLinux を使った Hyper-V 上の 3 層アーキテクチャ構築ガイド
このガイドでは、Hyper-V 上に AlmaLinux 9 を使って、以下の 3 台の仮想マシンを構築し、3 層クライアントサーバアーキテクチャ(Web サーバ、アプリケーションサーバ、DB サーバ)を実現する手順を説明します。
構成概要
| サーバ名 | 役割 | ソフトウェア | ネットワーク |
|---|---|---|---|
| web01 | Web サーバ | Apache HTTP Server | 外部アクセス可 (NAT or 外部スイッチ) |
| app01 | アプリケーションサーバ | Apache Tomcat | 内部ネットワークのみ |
| db01 | データベースサーバ | MariaDB | 内部ネットワークのみ |
前提条件
- Hyper-V がインストールされた Windows 環境があること
- AlmaLinux ISO イメージの準備(https://almalinux.org/ja)
- 仮想スイッチ(外部用と内部用)の作成済み
- 以下のパッケージをインストール済み
dnf install vim-enhanced
⚠️❗ SELinux の注意点
AlmaLinux 9 では初期状態で SELinux が有効になっているため、以下コマンドを実行し、無効化する必要がある。
getenforce
# Enforcing or Disabled or Permissive
それぞれ、Enforcing の場合は有効、Disabled の場合は無効、Permissive の場合は有効ではあるが、ポリシールールが適応されずアクセス制限がされないという動作が設定されている。
ポリシーを無効化して動作させる場合は下記の設定をする。
/etc/selinux/config
SELINUX=enforcing
↓下記に変更
SELINUX=disabled
‼️ ただ、上記の設定では SELinux は完全に停止されるわけではなく、ポリシーを無効化した状態で動作するため、完全に停止させたい場合は下記のコマンドで SELinux を無効化する必要がある。
grubby --update-kernel ALL --args selinux=0
reboot
上記コマンドは grubby コマンドでブートローダの設定を行うので、下記コマンドで grubby がインストールされていることを確認しておく。
rpm -q grubby
# grubby-xxx (バージョンが表示される)
再起動後はgetenforceで動作モードを再度確認。
</br>
Java のバージョンに関する注意点
Tomcat を systemd 経由で起動する場合、Java のバージョンと JAVA_HOME の指定に注意が必要です。
✅ 推奨:Java 17 の使用
ガイドでは java-17-openjdk を使用しています。これは Tomcat との互換性が高く、systemd からの起動でもトラブルが少ないためです。
dnf install java-17-openjdk-devel -y
JAVA_HOME=/usr/lib/jvm/java-17-openjdk を指定することで安定動作します。
⚠️ Java 21 を使う場合の注意
-
dnf install java-21-openjdkだけでは JRE しか含まれず、Tomcat が JDK を要求して起動に失敗します。 - Java 21 を使うには
java-21-openjdk-develパッケージをインストールしてください。
dnf install java-21-openjdk-devel -y
そして JAVA_HOME は次のように JDK ディレクトリに設定してください:
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-<version>"
※ JAVA_HOME に /bin/java を含めないように注意してください。
✅ 例:実際の JAVA_HOME の設定例
readlink -f /usr/bin/java の出力が次のようになっていた場合:
/usr/lib/jvm/java-21-openjdk-21.0.6.0.7-1.el9.alma.1.x86_64/bin/java
このとき設定すべき JAVA_HOME は次のとおり:
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-21.0.6.0.7-1.el9.alma.1.x86_64"
(/bin/java は含めず、その親ディレクトリを指定)
共通初期設定(各 VM 共通)
1. 仮想マシンの作成(Hyper-V)
- OS: AlmaLinux 9
- メモリ: 2GB 以上(用途により調整)
- ネットワーク:
外部(web01)、内部(app01, db01)
2. AlmaLinux 9 のインストール
- 言語選択: 日本語
- ソフトウェアの選択: 最小限のインストール + 標準システムユーティリティ
- ネットワーク設定: ホスト名変更、IP 固定(後述)
- インストール後、
dnf update実行
3. IP アドレス固定化(例)
# 172.100.100.50がWebサーバ、172.100.100.100がAPサーバ、172.100.100.200がDBサーバ
nmcli con mod ens160 ipv4.addresses 172.100.100.50/24
nmcli con mod ens160 ipv4.gateway 172.100.100.1
nmcli con mod ens160 ipv4.dns 8.8.8.8
nmcli con mod ens160 ipv4.method manual
nmcli con up ens160
Web サーバ(web01): Apache HTTP Server のインストールと設定
dnf install httpd -y
firewall-cmd --permanent --add-service=http && firewall-cmd --reload
systemctl enable --now httpd
動作確認
- ブラウザで
http://[web01のIP]にアクセスし、Apache のテストページが表示されること
🙌(余談)HTML・CSS・JS でファイルを作成して/var/www/htmlに配置してもよい!
サンプルHTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Database Viewer</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
/>
</head>
<body class="d-flex flex-column vh-100">
<header>
<h1 class="sticky-top text-center mb-0 py-2 header-text">
データベーステーブル確認
</h1>
</header>
<main class="container mb-auto">
<!-- Button trigger modal -->
<div id="button-group" class="d-flex justify-content-evenly">
<button
type="button"
class="btn btn-info mt-3 px-5 py-2"
data-bs-toggle="modal"
data-bs-target="#addFormModal"
>
追加
</button>
</div>
<!-- Modal -->
<div
class="modal fade"
id="addFormModal"
tabindex="-1"
aria-labelledby="formModalLabel"
aria-hidden="true"
>
<!-- レコードの追加 -->
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addformModalLabel">
レコードの追加
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form id="addRecord" class="needs-validation" novalidate>
<div class="mb-3">
<label for="nameInput" class="form-label"
>名前<span class="badge text-danger">*</span></label
>
<input
type="text"
name="name"
class="form-control"
placeholder="Taro"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
<div class="my-3">
<label for="ageInput" class="form-label"
>年齢<span class="badge text-danger">*</span></label
>
<input
type="number"
name="age"
min="0"
step="1"
class="form-control"
placeholder="20"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
<div class="my-3">
<label for="birthplaceInput" class="form-label"
>出身地<span class="badge text-danger">*</span></label
>
<input
type="text"
name="birthplace"
class="form-control"
placeholder="東京都"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
</form>
</div>
<div class="modal-footer d-flex justify-content-center">
<button
type="submit"
class="btn btn-warning px-5 py-2"
form="addRecord"
>
送信
</button>
</div>
</div>
</div>
</div>
<!-- レコードの更新 -->
<div
class="modal fade"
id="updateFormModal"
tabindex="-1"
aria-labelledby="formModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="updateformModalLabel">
レコードの更新
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form id="updateRecord" class="needs-validation" novalidate>
<div class="mb-3">
<label for="idSelect" class="form-label"
>ID<span class="badge text-danger">*</span></label
>
<select id="selectID" name="id" class="form-select">
<option id="defaultSelected" selected>IDを選択</option>
</select>
<div class="invalid-feedback">入力必須です。</div>
</div>
<div class="mb-3">
<label for="nameInput" class="form-label"
>名前<span class="badge text-danger">*</span></label
>
<input
id="updateInputName"
type="text"
name="name"
class="form-control"
placeholder="Taro"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
<div class="my-3">
<label for="ageInput" class="form-label"
>年齢<span class="badge text-danger">*</span></label
>
<input
id="updateInputAge"
type="number"
name="age"
min="0"
step="1"
class="form-control"
placeholder="20"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
<div class="my-3">
<label for="birthplaceInput" class="form-label"
>出身地<span class="badge text-danger">*</span></label
>
<input
id="updateInputBirthplace"
type="text"
name="birthplace"
class="form-control"
placeholder="東京都"
aria-label="input-area"
aria-describedby="button-addon2"
required
/>
<div class="invalid-feedback">入力必須です。</div>
</div>
</form>
</div>
<div class="modal-footer d-flex justify-content-evenly">
<button
id="putButton"
type="submit"
class="btn btn-success px-5 py-2"
form="updateRecord"
name="action"
value="update"
>
送信
</button>
<button
id="deleteButton"
type="submit"
class="btn btn-danger px-5 py-2"
form="updateRecord"
name="action"
value="delete"
>
削除
</button>
</div>
</div>
</div>
</div>
<table id="membersTable" class="table table-striped my-4">
<thead>
<!-- <tr>
<th class="thead-color">ID</th>
<th class="thead-color">名前</th>
<th class="thead-color">年齢</th>
<th class="thead-color">出身地</th>
</tr> -->
</thead>
<tbody>
<!-- <tr>
<th scope="row">1</th>
<td>Mark</td>
<td>Otto</td>
<td>@mdo</td>
</tr>
<tr>
<th scope="row">2</th>
<td>Jacob</td>
<td>Thornton</td>
<td>@fat</td>
</tr>
<tr>
<th scope="row">3</th>
<td>Larry the Bird</td>
<td>Xbox</td>
<td>@twitter</td>
</tr> -->
</tbody>
</table>
</main>
<footer>
<p class="text-center mb-0 py-1 footer-text">©All right reserved.</p>
</footer>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
crossorigin="anonymous"
></script>
<script src="script.js"></script>
</body>
</html>
サンプルCSS
* {
margin: 0px;
padding: 0px;
box-sizing: border-box;
}
.header-text {
background-color: rgb(152, 78, 255);
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-bg-type: rgba(255, 88, 196, 0.3) !important;
}
.thead-color {
background-color: rgb(255, 88, 196) !important;
}
.footer-text {
background-color: rgb(56, 255, 79);
}
/* 以下はダークモード時 */
@media (prefers-color-scheme: dark) {
[data-bs-theme="dark"] {
--bs-body-color: #ffffff !important;
}
.header-text {
background-color: rgb(186, 137, 255);
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-bg-type: rgba(255, 133, 212, 0.507) !important;
}
.thead-color {
background-color: rgb(255, 57, 186) !important;
}
.footer-text {
background-color: rgb(85, 188, 97);
}
}
サンプルJS
"use strict";
const html = document.documentElement;
const isDark = window.matchMedia("(prefers-color-scheme: dark)");
if (isDark.matches) {
html.setAttribute("data-bs-theme", "dark");
}
document.addEventListener("DOMContentLoaded", () => {
const getSelector = document.querySelector("#selectID");
const defaultOption = document.getElementById("defaultSelected");
const updateInputName = document.getElementById("updateInputName");
const updateInputAge = document.getElementById("updateInputAge");
const updateInputBirthplace = document.getElementById(
"updateInputBirthplace"
);
const putButton = document.getElementById("putButton");
const deleteButton = document.getElementById("deleteButton");
// 🔽 ページ表示時にGETでデータ取得してテーブルに表示!
fetch("http://172.100.100.100:8080/sample/api/members")
.then((res) => res.json())
.then((data) => {
const tableHead = document.querySelector("#membersTable thead");
tableHead.innerHTML = "";
// データベースにデータがない場合は何も表示しない
if (data.length === 0) {
return;
}
const buttonGroup = document.querySelector("#button-group");
const updateButton = document.createElement("button");
updateButton.setAttribute("type", "button");
updateButton.setAttribute("data-bs-toggle", "modal");
updateButton.setAttribute("data-bs-target", "#updateFormModal");
updateButton.classList.add(
"btn",
"btn-secondary",
"mt-3",
"px-5",
"py-2"
);
updateButton.innerText = "更新";
buttonGroup.appendChild(updateButton);
const theadRow = document.createElement("tr");
theadRow.innerHTML = `
<th class="thead-color">ID</th>
<th class="thead-color">名前</th>
<th class="thead-color">年齢</th>
<th class="thead-color">出身地</th>
`;
tableHead.appendChild(theadRow);
const tableBody = document.querySelector("#membersTable tbody");
const formSelect = document.querySelector("#selectID");
data.forEach((member) => {
const tbodyRow = document.createElement("tr");
tbodyRow.innerHTML = `
<th scope="row">${member.id}</th>
<td>${member.name}</td>
<td>${member.age}</td>
<td>${member.birthplace}</td>
`;
tableBody.appendChild(tbodyRow);
const selectOptions = document.createElement("option");
selectOptions.setAttribute("value", `${member.id}`);
selectOptions.innerText = `${member.id}`;
formSelect.appendChild(selectOptions);
getSelector.addEventListener("change", (event) => {
if (event.target.value === `${member.id}`) {
updateInputName.setAttribute("value", `${member.name}`);
updateInputAge.setAttribute("value", `${member.age}`);
updateInputBirthplace.setAttribute("value", `${member.birthplace}`);
}
});
});
})
.catch((error) => {
const main = document.querySelector("main");
const h1 = document.createElement("h1");
h1.classList.add("text-center", "text-danger");
h1.innerHTML = `データベース接続エラー<br>${error}`;
main.appendChild(h1);
});
if (defaultOption.textContent === "IDを選択") {
updateInputName.setAttribute("disabled", true);
updateInputName.removeAttribute("placeholder");
updateInputAge.setAttribute("disabled", true);
updateInputAge.removeAttribute("placeholder");
updateInputBirthplace.setAttribute("disabled", true);
updateInputBirthplace.removeAttribute("placeholder");
putButton.setAttribute("disabled", true);
deleteButton.setAttribute("disabled", true);
}
getSelector.addEventListener("change", (event) => {
if (event.target.value === "IDを選択") {
updateInputName.setAttribute("disabled", true);
updateInputName.removeAttribute("placeholder");
updateInputName.removeAttribute("value");
updateInputAge.setAttribute("disabled", true);
updateInputAge.removeAttribute("placeholder");
updateInputAge.removeAttribute("value");
updateInputBirthplace.setAttribute("disabled", true);
updateInputBirthplace.removeAttribute("placeholder");
updateInputBirthplace.removeAttribute("value");
putButton.setAttribute("disabled", true);
deleteButton.setAttribute("disabled", true);
} else {
updateInputName.removeAttribute("disabled");
updateInputName.setAttribute("placeholder", "Taro");
updateInputAge.removeAttribute("disabled");
updateInputAge.setAttribute("placeholder", "20");
updateInputBirthplace.removeAttribute("disabled");
updateInputBirthplace.setAttribute("placeholder", "東京都");
putButton.removeAttribute("disabled");
deleteButton.removeAttribute("disabled");
}
});
// 🔽 POST時のバリデーション&登録
const submitButton = document.querySelector('button[form="addRecord"]');
const addForm = document.getElementById("addRecord");
submitButton.addEventListener("click", (event) => {
event.preventDefault();
if (!addForm.checkValidity()) {
// Bootstrapのスタイル表示用
addForm.classList.add("was-validated");
return;
}
const formData = new FormData(addForm);
const params = new URLSearchParams(formData);
fetch("http://172.100.100.100:8080/sample/api/members", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
.then((res) => res.json())
.then((data) => {
console.log("追加しました。", data);
// ✅ 追加後に再取得 or その場で1行追加もOK!
location.reload(); // ← 再読み込みが簡単!;
})
.catch((error) => {
console.log("追加失敗", error);
});
addForm.classList.add("was-validated");
});
// 🔽 PUT時のバリデーション&登録
const updateForm = document.getElementById("updateRecord");
putButton.addEventListener("click", (event) => {
event.preventDefault();
if (!updateForm.checkValidity()) {
// Bootstrapのスタイル表示用
updateForm.classList.add("was-validated");
return;
}
const formData = new FormData(updateForm);
const obj = {};
formData.forEach((value, key) => {
obj[key] = value;
});
// 更新ボタン押下時
fetch("http://172.100.100.100:8080/sample/api/members", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(obj),
})
.then((res) => res.json())
.then((data) => {
console.log("更新しました。", data);
// ✅ 更新後に再取得 or その場で1行追加もOK!
location.reload(); // ← 再読み込みが簡単!;
})
.catch((error) => {
console.log("更新失敗", error);
});
updateForm.classList.add("was-validated");
});
// Delete時
deleteButton.addEventListener("click", (event) => {
event.preventDefault();
const formData = new FormData(updateForm);
const id = formData.get("id"); // 後述するけど、select に `name="id"` が必要!
// 削除ボタン押下時
fetch(`http://172.100.100.100:8080/sample/api/members/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((data) => {
console.log("削除しました", data);
// ✅ 削除後に再取得 or その場で1行追加もOK!
location.reload(); // ← 再読み込みが簡単!;
})
.catch((error) => {
console.log("削除失敗", error);
});
});
});
// 58行目と75行目のaddForm.classList.add("was-validated");は共通化可能。
// 53~60行目を以下のように修正。
// addForm.addEventListener("submit", (event) => {
// event.preventDefault();
// const isValid = addForm.checkValidity();
// addForm.classList.add("was-validated"); // 共通で1回だけ!
// if (!isValid) return;
アプリケーションサーバ(app01): Apache Tomcat のインストールと設定
Java のインストール
# java-17-openjdkの場合
dnf install java-17-openjdk tar -y
# java-21-openjdkの場合
dnf install java-21-openjdk java-21-openjdk-devel tar -y
Tomcat のダウンロードと展開
useradd -r -m -U -d /opt/tomcat -s /bin/false tomcat
curl -O https://dlcdn.apache.org/tomcat/tomcat-11/v11.0.6/bin/apache-tomcat-11.0.6.tar.gz
tar -xzvf apache-tomcat-11.0.6.tar.gz -C /opt/tomcat --strip-components=1
chown -R tomcat: /opt/tomcat && chmod +x /opt/tomcat/bin/*.sh
systemd ユニット作成と起動
Tomcat を systemd 管理下で起動・停止できるようにするための設定を行います。以下の手順でユニットファイルを作成し、有効化・起動します。</br>
‼️java-21-openjdk の場合は冒頭の⚠️ Java 21 を使う場合の注意を参考。
/etc/systemd/system/tomcat.service
[Unit]
Description=Apache Tomcat
After=network.target
[Service]
Type=forking
User=tomcat
Group=tomcat
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
; java-17-openjdkの場合は、↓のJAVA_HOME=を消す。java-21-openjdkの場合は↑のJAVA_HOME=を消す。
Environment="JAVA_HOME=/usr/lib/jvm/java-21-openjdk-21.0.6.0.7-1.el9.alma.1.x86_64"
Environment="CATALINA_PID=/opt/tomcat/temp/tomcat.pid"
Environment="CATALINA_HOME=/opt/tomcat"
ExecStart=/opt/tomcat/bin/startup.sh
ExecStop=/opt/tomcat/bin/shutdown.sh
Restart=on-failure
[Install]
WantedBy=multi-user.target
systemd ユニットを起動
systemctl daemon-reexec
systemctl enable --now tomcat
DB サーバ(db01): MariaDB のインストールと初期設定
dnf install mariadb-server -y
systemctl enable --now mariadb
mysql_secure_installation
データベース作成例
CREATE DATABASE sampledb;
ALTER DATABASE sampledb CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
CREATE USER sampleuser@'%' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON sampledb.* TO sampleuser@'%';
FLUSH PRIVILEGES;
-- 以下は独自のアプリケーションを配置し、DBと接続する場合
USE sampledb;
CREATE TABLE members (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
age INT NOT NULL,
birthplace VARCHAR(100) NOT NULL
);
ALTER TABLE members CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SHOW TABLES;
DESCRIBE members;
外部アクセス許可(必要に応じて)
/etc/my.cnf.d/mariadb-server.cnf
bind-address=0.0.0.0
⚠️ DB サーバの IP アドレス 172.100.100.200 で待ち受ける(リクエストを受ける)といった場合は、以下のように修正。
- #bind-address=0.0.0.0
+ bind-address=172.100.100.200
systemctl restart mariadb
💥💥bind-address = <IP アドレス> は「そのサーバ自身(mariaDB)が <IP アドレス> という IP で待ち受ける(リクエストを受ける)」という意味であり、
接続だけを許可するという意味ではない!!!
Tomcat 側ポート開放(app01)
Tomcat に対して Web サーバやクライアントから接続するためには、以下のポート開放が必要です。
🔓 HTTP 用(ポート 8080)
firewall-cmd --add-port=8080/tcp --permanent && firewall-cmd --reload
🔓 AJP 用(ポート 8009)
firewall-cmd --add-port=8009/tcp --permanent && firewall-cmd --reload
Apache ⇔ Tomcat 連携手順
方法 1: HTTP リバースプロキシ(mod_proxy)
Apache 側設定(web01)
dnf install mod_proxy_html -y
/etc/httpd/conf.d/proxy.conf
<VirtualHost *:80>
ServerName web01
ProxyPreserveHost On
ProxyPass /tomcat http://172.100.100.100:8080/
ProxyPassReverse /tomcat http://172.100.100.100:8080/
ProxyPass /tomcat.css http://172.100.100.100:8080/tomcat.css
ProxyPassReverse /tomcat.css http://172.100.100.100:8080/tomcat.css
ProxyPass /tomcat.svg http://172.100.100.100:8080/tomcat.svg
ProxyPassReverse /tomcat.svg http://172.100.100.100:8080/tomcat.svg
</VirtualHost>
systemctl restart httpd
※ 192.168.100.20 は app01 の IP アドレスに置き換えてください。
方法 2: AJP プロトコル(mod_proxy_ajp)
Apache 側設定(web01)
dnf install mod_proxy_ajp -y
/etc/httpd/conf.d/ajp.conf
<VirtualHost *:80>
ServerName web01
ProxyPass /tomcat ajp://172.100.100.100:8009/
ProxyPassReverse /tomcat ajp://172.100.100.100:8009/
ProxyPass /tomcat.css ajp://172.100.100.100:8009/tomcat.css
ProxyPassReverse /tomcat.css ajp://172.100.100.100:8009/tomcat.css
ProxyPass /tomcat.svg ajp://172.100.100.100:8009/tomcat.svg
ProxyPassReverse /tomcat.svg ajp://172.100.100.100:8009/tomcat.svg
</VirtualHost>
systemctl restart httpd
Tomcat 側設定(app01)
Tomcat10 以降では server.xml に AJP コネクタがコメントアウトされています。以下のように有効化してください。
/opt/tomcat/conf/server.xml
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />
systemctl restart tomcat
各サーバ間の接続と確認
-
web01からapp01への HTTP または AJP によるリバースプロキシが動作すること -
app01からdb01:3306に JDBC で接続
Tomcat ⇔ MariaDB JDBC 接続設定
1. JDBC ドライバのダウンロードと配置(app01)
cd /opt/tomcat/lib
curl -O https://downloads.mariadb.com/Connectors/java/connector-java-3.5.3/mariadb-java-client-3.5.3.jar
chown tomcat: mariadb-java-client-3.5.3.jar
2. Tomcat の Web アプリ設定例(例:/opt/tomcat/webapps/sample/WEB-INF/web.xml)
<resource-ref>
<description>MariaDB Connection</description>
<res-ref-name>jdbc/sampledb</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
3. context.xml にリソース定義(例:/opt/tomcat/conf/context.xml)
<Context>
...
<Resource name="jdbc/sampledb" auth="Container"
type="javax.sql.DataSource"
maxTotal="100" maxIdle="30" maxWaitMillis="10000"
username="sampleuser" password="password"
driverClassName="org.mariadb.jdbc.Driver"
url="jdbc:mariadb://172.100.100.200:3306/sampledb"
/>
</Context>
4. Tomcat 再起動
systemctl restart tomcat
Tomcat ⇔ MariaDB 接続確認方法(JDBC テスト)
✅ ポート開放の確認と設定(前提)
Tomcat 側(app01)でポート 8080 を開放
firewall-cmd --add-port=8080/tcp --permanent && firewall-cmd --reload
MariaDB 側(db01)でポート 3306 を開放
firewall-cmd --add-port=3306/tcp --permanent && firewall-cmd --reload
Tomcat から MariaDB に JDBC で正しく接続できているか確認するには、以下のいずれかの方法でテストします。
✅ 方法 1:JSP ページで JDBC 接続テスト(接続成功確認済み)
- 次の内容で JSP ファイルを作成:
ファイル名: /opt/tomcat/webapps/ROOT/testdb.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.sql.*, javax.naming.*, javax.sql.*" %>
<%
String msg = "";
try {
Context initContext = new InitialContext();
Context envContext = (Context) initContext.lookup("java:/comp/env");
DataSource ds = (DataSource) envContext.lookup("jdbc/sampledb");
Connection conn = ds.getConnection();
msg = "✅ 接続成功!DB名: " + conn.getCatalog();
conn.close();
} catch (Exception e) {
msg = "❌ 接続失敗: " + e.getMessage();
}
%>
<h2><%= msg %></h2>
- ブラウザでアクセス:
http://[app01のIP]:8080/testdb.jsp
Apache 経由の場合:
http://[web01のIP]/tomcat/testdb.jsp
✅ 方法 2:Tomcat ログで接続エラー確認
Tomcat が起動時に JDBC 接続に失敗している場合、ログにエラーメッセージが出力されます。
cat /opt/tomcat/logs/catalina.out | grep -i error
または
journalctl -u tomcat
✅ 方法 3:WAR アプリケーションで確認
実際のアプリケーションが .war ファイルとしてデプロイされている場合、そのアプリ内の JDBC コードが正常に動作していれば接続確認となります。
-
/opt/tomcat/webapps/に.warファイルを配置すれば自動展開されます - ログや画面でエラーが出ないかを確認
✅ 接続成功時の例(画面表示)
以下のような表示があれば接続は成功です:
✅ 接続成功!DB名: systemdb
(※ 表示が文字化けしている場合は contentType の設定で UTF-8 を明示してください)
💡 文字化けを直したい場合(オプション)
testdb.jsp の先頭にこれを追加してみて:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
✅ 修正後の先頭はこうなる:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.sql.*, javax.naming.*, javax.sql.*" %>
🙌(余談)Tomcat に Java アプリケーションを配置してデータベースと接続することも可能!
1. コンパイル用、.jarライブラリ用、Java ファイル格納用のフォルダをそれぞれ作成
# コンパイル用 後ろのcom/exampleはjavaファイルの先頭のpackage文と同じにする。
# package com.example; だったら以下のようにフォルダを作成しよう
mkdir -p /opt/tomcat/webapps/sample/WEB-INF/classes/com/example
# .jarライブラリ用
mkdir -p /opt/tomcat/webapps/sample/WEB-INF/lib
# Javaファイル格納用 後ろのcom/exampleはjavaファイルの先頭のpackage文と同じにする。
# package com.example; だったら以下のようにフォルダを作成しよう
mkdir -p /opt/tomcat/webapps/sample/src/com/example
2. Java アプリケーション用の Java ファイルを保存
Java ファイルを/opt/tomcat/webapps/sample/src/com/exampleに格納。
サンプルファイル①
package com.example;
import java.io.IOException;
import java.sql.*;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.json.JSONObject;
import jakarta.servlet.ServletException;
public class BaseServlet extends HttpServlet {
// DB接続情報
// MySQLのときは↓こっち!
// protected static final String JDBC_URL =
// "jdbc:mysql://172.100.100.200:3306/sampledb?serverTimezone=UTC";
protected static final String JDBC_URL = "jdbc:mariadb://172.100.100.200:3306/sampledb?useUnicode=true&characterEncoding=utf8mb4";
protected static final String DB_USER = "sampleuser";
protected static final String DB_PASS = "password";
protected Connection getConnection() throws SQLException {
System.out.println("🛠 DB接続開始");
try {
return DriverManager.getConnection(JDBC_URL, DB_USER, DB_PASS);
} catch (Exception e) {
System.out.println("❌ DB接続失敗:" + e.getMessage());
e.printStackTrace();
throw e;
}
}
protected void prepareJsonResponse(HttpServletResponse response) {
response.setContentType("application/json; charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// ✅ CORS対応ヘッダー
response.setHeader("Access-Control-Allow-Origin", "http://172.100.100.50");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
protected void sendJsonError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json; charset=UTF-8");
JSONObject error = new JSONObject();
error.put("error", message);
response.getWriter().write(error.toString());
}
@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
prepareJsonResponse(response); // CORS用ヘッダーも含まれる
response.setStatus(HttpServletResponse.SC_OK);
}
}
サンプルファイル②
package com.example;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import jakarta.servlet.annotation.*;
import java.io.*;
import java.sql.*;
import org.json.JSONArray;
import org.json.JSONObject;
@WebServlet("/api/members/*")
public class MembersServlet extends BaseServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
prepareJsonResponse(response);
JSONArray jsonArray = new JSONArray();
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement("SELECT id, name, age, birthplace FROM members");
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
JSONObject member = new JSONObject();
member.put("id", rs.getInt("id"));
member.put("name", rs.getString("name"));
member.put("age", rs.getInt("age"));
member.put("birthplace", rs.getString("birthplace"));
jsonArray.put(member);
}
} catch (Exception e) {
e.printStackTrace();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
sendJsonError(response, 400, "データ取得エラー");
return;
}
response.getWriter().write(jsonArray.toString());
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
System.out.println("🔥 doPost reached!");
prepareJsonResponse(response);
String name = request.getParameter("name");
System.out.println("✅ name: " + name);
String ageStr = request.getParameter("age");
System.out.println("✅ ageStr: " + ageStr);
String birthplace = request.getParameter("birthplace");
System.out.println("✅ birthplace: " + birthplace);
// 入力チェック
if (name == null || name.trim().isEmpty() || ageStr == null || !ageStr.matches("\\d+") || birthplace == null
|| birthplace.trim().isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400 Bad Request
sendJsonError(response, 400, "名前と年齢、出身地は必須です(年齢は数字)\"}");
return;
}
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement("INSERT INTO members (name, age, birthplace) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
System.out.println("✅ DB接続成功");
stmt.setString(1, name);
stmt.setInt(2, Integer.parseInt(ageStr));
stmt.setString(3, birthplace);
stmt.executeUpdate();
ResultSet generatedKeys = stmt.getGeneratedKeys();
JSONObject result = new JSONObject();
if (generatedKeys.next()) {
result.put("id", generatedKeys.getInt(1));
}
result.put("name", name);
result.put("age", Integer.parseInt(ageStr));
result.put("birthplace", birthplace);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write(result.toString());
} catch (Exception e) {
System.out.println("💥 DB処理失敗!");
e.printStackTrace(); // ← ★絶対入れよう!!
response.setStatus(500);
sendJsonError(response, 500, "登録失敗");
}
}
@Override
protected void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
System.out.println("🔥 doPut reached!");
prepareJsonResponse(response);
BufferedReader reader = request.getReader();
StringBuilder jsonBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null)
jsonBuilder.append(line);
try {
JSONObject json = new JSONObject(jsonBuilder.toString());
// 🔽 バリデーション項目を取り出す
if (!json.has("id") || !json.has("name") || !json.has("age") ||
!json.has("birthplace")) {
sendJsonError(response, 400, "名前と年齢、出身地は必須です(年齢は数字)\\\"}");
return;
}
int id = json.getInt("id");
System.out.println("✅ id: " + id);
String name = json.getString("name");
System.out.println("✅ name: " + name);
int age = json.getInt("age");
System.out.println("✅ age: " + age);
String ageStr = Integer.toString(age);
System.out.println("✅ ageStr: " + ageStr);
String birthplace = json.getString("birthplace");
System.out.println("✅ birthplace: " + birthplace);
if (name.trim().isEmpty() || age < 0 || id <= 0 ||
birthplace.trim().isEmpty()) {
sendJsonError(response, 400, "不正なデータが含まれています");
return;
}
// 入力チェック
if (name == null || name.trim().isEmpty() || ageStr == null ||
!ageStr.matches("\\d+") || birthplace == null
|| birthplace.trim().isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400 Bad Request
sendJsonError(response, 400, "名前と年齢、出身地は必須です(年齢は数字)\"}");
return;
}
try (Connection conn = getConnection();
PreparedStatement stmt = conn
.prepareStatement("UPDATE members SET name = ?, age = ?, birthplace = ? WHERE id = ?")) {
System.out.println("✅ DB接続成功");
stmt.setString(1, name);
stmt.setInt(2, age);
stmt.setString(3, birthplace);
stmt.setInt(4, id);
int updated = stmt.executeUpdate();
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"updated\": " + updated + "}");
}
} catch (Exception e) {
System.out.println("💥 DB処理失敗!");
e.printStackTrace(); // ← ★絶対入れよう!!
response.setStatus(500);
sendJsonError(response, 500, "更新失敗");
}
}
@Override
protected void doDelete(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
System.out.println("🔥 doDelete reached!");
prepareJsonResponse(response);
String pathInfo = request.getPathInfo(); // 例: "/3"
System.out.println("✅ pathInfo: " + pathInfo);
if (pathInfo == null || pathInfo.length() <= 1) {
sendJsonError(response, 400, "IDが指定されていません");
return;
}
String idStr = pathInfo.substring(1); // "/3" → "3"
System.out.println("✅ id: " + idStr);
if (idStr == null || !idStr.matches("\\d+")) {
sendJsonError(response, 400, "IDが不正です");
return;
}
int id = Integer.parseInt(idStr);
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement("DELETE FROM members WHERE id = ?")) {
System.out.println("✅ DB接続成功");
stmt.setInt(1, id);
int deleted = stmt.executeUpdate();
response.getWriter().write("{\"deleted\": " + deleted + "}");
} catch (Exception e) {
System.out.println("💥 DB処理失敗!");
e.printStackTrace(); // ← ★絶対入れよう!!
response.setStatus(500);
sendJsonError(response, 500, "削除失敗");
}
}
}
3. .jarライブラリを集める
3-1. MariaDB JDBC ドライバ(Connector)
公式サイト → https://mariadb.com/kb/en/mariadb-connector-j/
または、https://downloads.mariadb.com/Connectors/javaから任意のバージョンをダウンロードするか、1. JDBC ドライバのダウンロードと配置(app01)で予めダウンロードしたファイルでもよい。
ファイル例:
mariadb-java-client-3.5.3.jar
</br>
3-2. JSON ライブラリ(org.json)
ここから任意のバージョンをダウンロード可能 → https://repo1.maven.org/maven2/org/json/json/20250107/json-20250107.jar
ファイル例:
json-20250107.jar
ファイルをダウンロードし、その所有権を変更。
cd /opt/tomcat/lib
curl -O https://repo1.maven.org/maven2/org/json/json/20250107/json-20250107.jar
chown tomcat: json-20250107.jar
3-3. MariaDB JDBC ドライバ(Connector)、 JSON ライブラリ(org.json)を/opt/tomcat/webapps/sample/WEB-INF/libに保存!
cp /opt/tomcat/lib/mariadb-java-client-3.5.3.jar /opt/tomcat/webapps/sample/WEB-INF/lib/ &&
cp /opt/tomcat/lib/json-20250107.jar /opt/tomcat/webapps/sample/WEB-INF/lib/
3-4. jakarta.servlet-api-6.0.x.jarをダウンロードして/opt/tomcat/webapps/sample/WEB-INF/libに保存!
cd /opt/tomcat/webapps/sample/WEB-INF/lib/
curl -O https://repo1.maven.org/maven2/jakarta/servlet/jakarta.servlet-api/6.0.0/jakarta.servlet-api-6.0.0.jar
⚠️ また、Maven を使用している場合は、/opt/tomcat/webapps/sample/pom.xmlに以下の依存関係を追加することで、ビルド時に自動的にダウンロードされる。</br>
(本手順書では Maven は使用していない)
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>sample</artifactId>
<version>1.0.0</version>
<packaging>war</packaging>
<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.3.2</version>
</plugin>
</plugins>
</build>
</project>
scopeをprovidedに設定することで、実行時にはサーブレットコンテナ(この場合は Tomcat)によって提供される
⚠️⚠️ ただし、Tomcat 11 には jakarta.servlet-api が同梱されていないため、WAR ファイルに同梱することをおすすめ
3-5. コンパイルする
cd /opt/tomcat/webapps/sample
javac -d WEB-INF/classes -classpath "WEB-INF/lib/*" src/com/example/*.java
4. war ファイルを作成する
jar -cvf ../sample.war *
<!-- Java ファイルを以下のコマンドでコンパイル
javac -encoding UTF-8 -cp ".:../WEB-INF/lib/*" -d ../WEB-INF/classes BaseServlet.java MembersServlet.java
これで、WEB-INF/classesに.classファイルができているか確認。 -->
<p style="color: red;"><b>💥 作成を確認出来たら再度<code>/opt/tomcat</code>以下の権限を tomcat ユーザーに付与!!忘れずに!!</b></p>
chown -R tomcat: /opt/tomcat
4-1. 作成したフォルダを削除(今回の場合 sample フォルダ)
cd /opt/tomcat/webapps
rm -rf sample
4-2. WAR アプリの自動展開設定
Tomcat のconf/server.xmlに以下の設定があると、webapps/sample/に自動で展開される
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
unpackWARs="true"で自動展開、autoDeploy="true"でホットデプロイ(起動中でも反映)できる。
5. web.xml を作成(アノテーション使わない場合)
/opt/tomcat/webapps/sample/WEB-INF/web.xml
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" version="5.0">
<servlet>
<servlet-name>MembersServlet</servlet-name>
<servlet-class>MembersServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>MembersServlet</servlet-name>
<url-pattern>/api/members</url-pattern>
</servlet-mapping>
</web-app>
‼️@WebServlet("/api/members")をMembersServletに書いていればweb.xml省略しても OK!(2. Java アプリケーション用の Java ファイルを保存でサンプルファイルを使用する場合は、省略 OK!)2. Tomcat の Web アプリ設定例でweb.xmlを作成して締まった場合は削除する。
6. Tomcat を再起動
systemctl restart tomcat
その後ブラウザで:
http:<TomcatのIP>:8080/tomcat/sample/api/members
もしくはリバースプロキシを使用している場合:
http:<WebサーバのIP>/tomcat/sample/api/members
レコード追加したのに反映しない場合
AP サーバで以下コマンドを実行し、トレースする。
tail -f /opt/tomcat/logs/catalina.out
Web サーバ側で POST してみる。
curl -X POST http://172.100.100.100:8080/sample/api/members -d "name=テスト&age=20&birthplace=東京"
Discussion