🎃

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>

引用:RHEL9 系(AlmaLinux9)で SELinux を無効化する方法


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 接続テスト(接続成功確認済み)

  1. 次の内容で 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>
  1. ブラウザでアクセス:
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>

scopeprovidedに設定することで、実行時にはサーブレットコンテナ(この場合は 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