概要
Dockerを使ってApache+PHP+MySQLな環境構築を行う。ついでにphpMyAdminも使えるようにする。動作確認で簡単な掲示板を作る。対象はDockerって何?触ったことないよ?くらいの人。
前提
以下のものがインストールされている必要があります
- Git
- Docker
- https://matsuand.github.io/docs.docker.jp.onthefly/get-docker/
- 私はWindows+WSL2な環境なので、Docker Desktop for Windowsを利用しています。
まずはこいつを見てくれ
このリポジトリをクローンして、リポジトリのルートディレクトリ配下で以下を叩いてください。
$ docker-compose up
するとなにやら英語がたくさん出てきます(アホみたいな感想)。
初回は結構時間がかかるので気長に待っててください。
だいたいこの画面になったあたりで止まります。
それではhttp://localhost にアクセスしてみましょう。
下のようなショボい掲示板の画面が出てくれば成功です。
http://localhost:8080 にアクセスしてみましょう。
DBの管理画面が開きます。
ブラウザからGUIでレコードを追加したりテーブルを削除したりできます。
解説
ではこれを一から作っていきましょう。
全体像
.
├── docker-compose.yaml
├── mysql
│ ├── config
│ │ └── my.cnf
│ │ └── my.conf
│ ├── Dockerfile
│ └── mysql
├── phpmyadmin
│ └── Dockerfile
└── web
├── config
│ └── php.ini
├── Dockerfile
└── html
├── index.php
└── phpinfo.php
※ディレクトリ構成に関してはこれで本当に大丈夫なのかよくわかっていません。
変だなと思ったら自分でいい感じに改善してください。
Dockerってなに?
Dockerとは、要するに今流行りのコンテナです。コンピュータ上に独立した環境を作成することで、アプリケーション開発を効率化してくれるすごいやつです。
コンテナは、仮想マシンに似た概念なのですが、ゲストOSを利用しないという点で違いがあります。
仮想マシンでは実際にコンピュータをエミュレートしてゲストOSを動かすことで、独立した環境を作成しています。しかしコンテナの場合コンテナエンジンというやつがうまいこと立ち回ることによって、ホストOSの機能のみを利用してライブラリやミドルウェアを動作させます。
ここにUbuntuのコンテナイメージがあります。これでUbuntuコンテナを立ち上げると、コンテナの中のUbuntuは自分のことをUbuntuだと思っています。しかし実際に各機能を動かしているのはWindowsなりMacOSの力なのです。
なんかよくわかんねえな?
とりあえず作っていきましょう。
docker-compose.yamlを書く
適当にディレクトリを作成して、docker-compose.yamlを書きます。
version: "3.9"
services:
web:
build:
context: ./web #DockerfileをBuildする時のパスの起点が変わる
dockerfile: ./Dockerfile #Dockerfileのあるパスを指定する
volumes:
- ./web/html:/var/www/html #パスをマウント <HostPath>:<Container Path>
ports:
- ${WEB_PORT}:80 #ポートを公開 <Host IP>:<Host Port>:<Container Port>
depends_on:
- mysql
mysql:
build:
context: ./mysql
dockerfile: ./Dockerfile
volumes:
- ./mysql/mysql:/var/lib/mysql
- ./mysql/config/my.cnf:/etc/mysql/conf.d/my.cnf
environment:
- MYSQL_ROOT_PASSWORD=root #適切に変更
- MYSQL_DATABASE=test_db #適切に変更
- MYSQL_USER=test_user #適切に変更
- MYSQL_PASSWORD=test_password #適切に変更
ports:
- ${MYSQL_PORT}:3306
phpmyadmin:
build:
context: ./phpmyadmin
dockerfile: ./Dockerfile
environment:
PMA_HOST: "mysql"
PMA_USER: "test_user" #適切に変更
PMA_PASSWORD: "test_password" #適切に変更
ports:
- ${PMA_PORT}:80
自分で書いてみながら知らないところを調べるのが一番勉強になると思います。
簡単な説明
-
version:
composeファイルのバージョンを指定します。公式を見るとサンプルに3.9と書いてあったので3.9にしました。 -
services:
アプリケーションを構成する要素をこの下に記述します。具体的にはweb:
とかmysql:
とかphpmyadmin:
とか書いてあるのが一つ一つの要素に該当し、今回の例ではこれの数だけコンテナが立ち上がります。名前は何でも良くて、web:
という名前でDBが立ち上がっていても動作には問題ありません。 -
build:
今回の例では各サービスにDockerfileを用意しています(イメージから直接立ち上げる方法もある)。Dockerファイルの在処などをここで指定します。 -
volumes:
ホスト側のファイルシステムとコンテナ内のファイルシステムをつなげます。web:
の例だと、(docker-compose.yamlがあるディレクトリから見て)./web/html
に置いたファイルを、コンテナ内では/var/www/html
にあるものとして認識します。 -
ports:
コンテナ内のポートとホストのポートをつなげます。${WEB_PORT}
などと書いてありますが、これは後述する.env
ファイルの中に持つ環境変数です。yamlに直接書かないで環境変数に持つようにすることによって、同じファイルを開発環境、テスト環境、本番環境で流用できるようになるというメリットがあるようです。
Dockerfileを書く
Dockerfileとは新規にDockerイメージを作成するための設計図のようなものです。
FROM mysql:8
FROM phpmyadmin/phpmyadmin:latest
ARG UID=1000
RUN useradd -m -u ${UID} docker
USER ${UID}
FROM php:8.1-apache
RUN apt-get update && apt-get install -y \
libonig-dev \
&& docker-php-ext-install pdo_mysql mysqli
COPY ./config/php.ini /usr/local/etc/php/
#COPY等で親ディレクトリは参照できない
#と思ったらyamlの方のcontextで参照できるようになるらしい
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ARG UID=1000
RUN useradd -m -u ${UID} docker
USER ${UID}
UIDに1000を渡すくだりは、前に書いた記事の参考文献に書いてあったのでなんとなくそうしていますが、実はよくわかっていません。
下記の記事あたりを見ると、コンテナは初期状態でroot権限で動くため、乗っ取られたときにセキュリティ的に危険などの話が書いてあります。
ただし、mysqlサーバは特定のフォルダに特別な権限を付与するようで、同様にUID=1000を渡すとうまく動作しなかったのでとりあえず何もしないであります。
設定ファイルを作る
[Date]
date.timezone = "Asia/Tokyo"
[mysqld]
default_autentication_plugin=mysql_native_password
この辺は適当なのでちゃんと調べて書いてください。
.envファイルを書く
COMPOSE_PROJECT_NAME=lmap-php8
COMPOSE_FILE=docker-compose.yaml
COMPOSE_PATH_SEPARATOR=:
WEB_PORT=80
MYSQL_PORT=3306
PMA_PORT=8080
PHPファイルを作る
<?php phpinfo();?>
とりあえず動作確認用に作ります。
動かしてみよう
docker-compose.yamlがあるディレクトリで以下を叩きます。
$ docker-compose up
Dockerfileのビルドやイメージの取得からdocker run
までをまとめて行います。
初回はDockerfileをビルドしたり、イメージをローカルに引っ張ってきたりするので時間がかかります。
Ctrl+C
で終了できるようにあえて-d
オプションを付けずに起動しています。バックグラウンドで動かしたいときは以下を叩きます。
docker-compose up -d
2回目からはキャッシュを利用するのですぐ起動するようになります。
Dockerは一度ビルドするとキャッシュというのが作成されます。ご存知グーグルクロームとかのブラウザにも同じ機能がありますね。キャッシュがあると2回目以降にビルドするときに、速やかに処理をすることができるわけです。
ただ、キャッシュがあると不便なときもあって、それが例えばDockerfileを更新したときなどです。上に紹介したように --no-cache オプションを付けないと、Dockerはキャッシュを使ってimageを構築してしまうので、更新したDockerfileを見てくれず新しいimageが作られません。
docker-compose up
とか build
とか start
とかの違いを理解できていなかったのでまとめてみた
では、http://localhost/phpinfo.php にアクセスしてみましょう。
なんか出ましたね。これでLAMP環境の構築ができました。おめでとうございます。
簡単な掲示板を作ってみる
index.pnpの中身を表示する
<?php
/**
* 入力が空白でないか確認する
*
* @param string $str
* @return bool
*/
function is_not_space(?string $str): bool {
$str = preg_replace("/( | )/", "", $str);
if ($str == "") {
return FALSE;
} else {
return TRUE;
}
}
/**
* DBに接続する
*
* @return PDO
*/
function connectDB() {
try {
$dsn = 'mysql:host=mysql;dbname=test_db;charset=utf8';
$pdo = new PDO($dsn, 'test_user', 'test_password');
return $pdo;
} catch (PDOException $e) {
echo $e->getMessage();
return null;
}
}
/**
* テーブルがなければ作成する
*
* @param PDO $db
* @param string $TABLE_NAME
* @return bool
*/
function createTableForBBS(PDO $pdo, $TABLE_NAME): bool {
try {
//ダブルクオーテーションで囲うと変数が自動的に展開される
$sql = "CREATE TABLE IF NOT EXISTS $TABLE_NAME
(
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(32),
comment TEXT,
date DATETIME,
password VARCHAR(32)
);";
$stmt = $pdo->query($sql);
return true;
} catch (PDOExeption $e) {
echo $e->getMessage();
return false;
}
}
/**
* 書き込みを読み込んで表示
*
* @param PDO $db
* @param string $TABLE_NAME
* @return void
*/
function fetchPosts(PDO $pdo, $TABLE_NAME){
$sql = "SELECT * FROM $TABLE_NAME";
$stmt = $pdo->query($sql);
$results = $stmt->fetchAll();
foreach ($results as $row) {
echo $row['id'] . ' ';
echo "名前:" . $row['name'] . " ";
echo "日時:" . $row['date'] . '<br>';
echo $row['comment'] . '<br>';
echo "<hr>";
}
}
/**
* 書き込みを投稿してエラーメッセージを返却する
*
* @param PDO $db
* @param string $TABLE_NAME
* @param array $POST
* @return string
*/
function post(PDO $pdo, string $TABLE_NAME, $name, $password, $comment): string {
//submitされたがコメントが空のときの処理
if (!is_not_space($comment)) {
return "コメントを入力してください";
}
//変数に格納
if (!is_not_space(($name))) {
$name = "名無しさん";
}
if (!is_not_space($password)) {
$password = "0000";
}
$date = date("Y-m-d H:i:s");
//新規投稿
$sql = $pdo->prepare
("INSERT INTO $TABLE_NAME (name, comment, date, password)
VALUES (:name, :comment, :date, :password)");
$sql->bindParam(':name', $name, PDO::PARAM_STR);
$sql->bindParam(':comment', $comment, PDO::PARAM_STR);
$sql->bindParam(':date', $date, PDO::PARAM_STR);
$sql->bindParam(':password', $password, PDO::PARAM_STR);
$sql->execute();
return "書き込み完了";
}
function updatePost(PDO $pdo, string $TABLE_NAME, $name, $password, $comment, $id): string {
//書き換え用の処理
if (!is_not_space($comment)) {
return "コメントを入力してください";
}
//変数に格納
if (!is_not_space(($name))) {
$name = "名無しさん";
}
if (!is_not_space($password)) {
$password = "0000";
}
$sql = $pdo->prepare
("UPDATE $TABLE_NAME
SET name=:name,comment=:comment,password=:password WHERE id=:id");
$sql->bindParam(':name', $name, PDO::PARAM_STR);
$sql->bindParam(':comment', $comment, PDO::PARAM_STR);
$sql->bindParam(':password', $password, PDO::PARAM_STR);
$sql->bindParam(':id', $id, PDO::PARAM_STR);
$sql->execute();
return "編集完了";
}
/**
* 編集状態にする
*
* @param PDO $pdo
* @param string $TABLE_NAME
* @param array $POST
* @return void
*/
function preEdit(PDO $pdo, string $TABLE_NAME, $id, $password) {
//指定idの書き込みを取得
$sql = "SELECT id, name, comment, password FROM $TABLE_NAME WHERE id=:id";
$stmt = $pdo->prepare($sql);
$stmt->bindparam(':id', $id, PDO::PARAM_STR);
$stmt->execute();
$results = $stmt->fetch();
if (!$results) {
return ["mes" => "その番号の書き込みは存在しません。"];
}
if ($results["password"] != $password) {
return ["mes" => "パスワードが不正です。"];
}
return["mes" => "編集可能です",
"comment_edit" => $results["comment"],
"name_edit" => $results["name"],
"id_edit" => $id];
}
/**
* 投稿を削除する
*
* @param PDO $pdo
* @param string $TABLE_NAME
* @param $id
* @param $password
* @return string
*/
function deletePost(PDO $pdo, string $TABLE_NAME, $id, $password): string{
//パスワードが一致するか確かめる
$sql = "SELECT id, password FROM $TABLE_NAME WHERE id=:id";
$stmt = $pdo->prepare($sql);
$stmt->bindparam(':id', $id, PDO::PARAM_STR);
$stmt->execute();
$results = $stmt->fetch();
if (!$results) {
return "その番号の書き込みは存在しません";
}
if ($results["password"] != $password) {
return "パスワードが不正です";
}
//パスワードがあっていれば削除
$sql = "delete from $TABLE_NAME where id=:id";
$stmt = $pdo->prepare($sql);
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();
return "削除しました";
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TestBBS</title>
</head>
<body>
<h1>掲示板</h1>
<?php
$errMsg = "";
$mes = "";
$TABLE_NAME = "test_bbs";
$isEdit = FALSE;
$db = connectDB();
createTableForBBS($db, $TABLE_NAME);
if (isset($_POST["submit"]) && !isset($_POST["id"])) {
$errMsg = post($db, $TABLE_NAME, $_POST["name"], $_POST["password"], $_POST["comment"]);
}
if (isset($_POST["submit"]) && isset($_POST["id"])) {
$errMsg = updatePost($db, $TABLE_NAME, $_POST["name"], $_POST["password"], $_POST["comment"], $_POST["id"]);
}
if (isset($_POST["delete"])) {
$errMsg = deletePost($db, $TABLE_NAME, $_POST["id"], $_POST["password"]);
}
if (isset($_POST["edit"])) {
$res = preEdit($db, $TABLE_NAME, $_POST["id"], $_POST["password"]);
$mes = $res["mes"];
if($mes == "編集可能です") {
$isEdit = TRUE;
$comment_edit = $res["comment_edit"];
$name_edit = $res["name_edit"];
$id_edit = $res["id_edit"];
}
$password_edit = $_POST["password"];
}
echo "<span style='color:red'><p>$errMsg</p></span>";
fetchPosts($db, $TABLE_NAME);
?>
<?php if($isEdit): ?>
<div>
<form action="" method="post">
<p>パスワードを指定しない場合「0000」に設定されます</p>
<p>名前:<input type="text" name="name" value="<?= $name_edit ?>">
パスワード:<input type="text" name="password" value="<?= $password_edit ?>"></p>
<p>コメント:</p>
<textarea name="comment" rows="8" cols="40"><?= $comment_edit ?></textarea>
<input type="hidden" name="id" value="<?= $id_edit ?>">
<input type="submit" name="submit">
</form>
</div>
<?php else: ?>
<div>
<form action="" method="post">
<p>パスワードを指定しない場合「0000」に設定されます</p>
<p>名前:<input type="text" name="name">
パスワード:<input type="text" name="password"></p>
<p>コメント:</p>
<textarea name="comment" rows="8" cols="40"></textarea>
<input type="submit" name="submit">
</form>
</div>
<?php endif; ?>
<div>
<form action="" method="post">
<p>番号:<input type="number" name="id">
パスワード:<input type="text" name="password">
<input type="submit" name="edit" value="編集">
<input type="submit" name="delete" value="削除"></p>
</form>
<span style="color:red"><?php echo "<p>$mes</p>" ?></span>
</div>
</body>
</html>
これは昔TE◯H-B◯SE(テッ◯ベース)とかいう微妙なプログラミングインターンで作成したクソコードをリファクタリングしたものです。(前のひどいコード)
一つのphpファイルにぐちゃぐちゃと書いてしまっていますがファイルは分割すべきでしょう。
一応CRUDのすべての操作を行います。。
これをweb/html/配下に置きます。
http://localhostにアクセスしてみましょう。
webのポートに80番を指定しているので、ポート番号を指定しなくても接続できます。
index.phpファイルにはファイル名指定なしでアクセスすることができるので、掲示板が開くはずです。
おめでとうございます!
補足説明
- Laravelなどのフレームワークを使いたい場合はDockerfile等を修正するなりが必要です。調べてみましょう。
- Apacheの設定など書き換えたいときはvolumeでマウントするなりDockerfileでCOPYするなりするといいんじゃないですかね?
- DBをコンテナで立ち上げていますが、本番環境でDBを使用する場合コンテナとは相性が悪いです(コンテナが落ちると状態を引き継がないため)。今回はホスト側の環境をマウントしデータを永続化していますが、開発・テスト用の利用に留めたほうが良いでしょう。(Webサービスを作るときはちゃんとしたところに情報を保存する。)
- .envファイルは本来GitHub等に公開してはいけないファイルです。 クローンしてすぐ動くようにあえて公開してあります。ここには開発環境と本番環境で分けたい情報を記載します。${hoge}で参照できるようになります。接続するDBの名前とかユーザー名やパスワードなどを書いたりもします(今回はyamlに直接書いてしまっている)。更に進んだ話をすると、パスワードを平文で.envファイルに持つとセキュリティ的に良くないので別の場所に持とうとか暗号化しようとかいう話もあります。
- 上に載せたindex.phpはこのままだとXSS(クロスサイトスクリプティング)が行えてしまう ので、書き込みの際にちゃんとエスケープするように修正すべきです。
参考記事
DockerでApache+PHP+MySQLの環境を構築してみる
DockerでPHPの開発環境を構築してみよう!
docker-compose.ymlの書き方を整理する
phpMyAdminでDockerで建てたMySQLにアクセスする
PHP8のLAMP環境をDockerで作ってみる
Docker ComposeでDockerfileをビルドする際に親ディレクトリのファイルをコピーする
Docker Compose - docker-compose.yml リファレンス
docker-compose公式ドキュメント
PHP公式イメージ
MySQL公式イメージ
ふ~ん
なるほどね?
※間違い等ありましたらご指摘のほどよろしくお願いします。