【Docker初歩の初歩】DockerでApache+PHP+MySQLなLAMP環境構築をする(ついでに簡単な掲示板を作って動かす)

# 概要
Dockerを使ってApache+PHP+MySQLな環境構築を行う。ついでにphpMyAdminも使えるようにする。動作確認で簡単な掲示板を作る。対象はDockerって何？触ったことないよ？くらいの人。

# 前提
以下のものがインストールされている必要があります
- Git
    - https://git-scm.com/
- Docker
    - https://matsuand.github.io/docs.docker.jp.onthefly/get-docker/
    - 私はWindows+WSL2な環境なので、Docker Desktop for Windowsを利用しています。

# まずはこいつを見てくれ
https://github.com/miya-38-a/docker-apache-php-mysql

このリポジトリをクローンして、リポジトリのルートディレクトリ配下で以下を叩いてください。
```
$ docker-compose up
```

するとなにやら英語がたくさん出てきます(アホみたいな感想)。
初回は結構時間がかかるので気長に待っててください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/0d7470de-ff8a-6c6e-dd40-bf4579c521f4.png)
だいたいこの画面になったあたりで止まります。

それではhttp://localhost にアクセスしてみましょう。
下のようなショボい掲示板の画面が出てくれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/b9647eac-57c3-90bd-2e91-e8526d2d86ab.png)

試しに書き込んでみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/c82d7284-e7bc-9950-6df7-1d91bec4cfc0.png)

書き込めました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/fabc462a-ae07-6e75-ea9c-6d3da81bc7f6.png)

http://localhost:8080 にアクセスしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/e740c3fd-4c15-f6be-bd1e-81e14827414b.png)

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ってなに？
https://knowledge.sakura.ad.jp/13265/

Dockerとは、要するに今流行りのコンテナです。コンピュータ上に独立した環境を作成することで、アプリケーション開発を効率化してくれるすごいやつです。
コンテナは、仮想マシンに似た概念なのですが、ゲストOSを利用しないという点で違いがあります。
仮想マシンでは実際にコンピュータをエミュレートしてゲストOSを動かすことで、独立した環境を作成しています。しかしコンテナの場合コンテナエンジンというやつがうまいこと立ち回ることによって、ホストOSの機能のみを利用してライブラリやミドルウェアを動作させます。

https://hub.docker.com/_/ubuntu

ここにUbuntuのコンテナイメージがあります。これでUbuntuコンテナを立ち上げると、コンテナの中のUbuntuは自分のことをUbuntuだと思っています。しかし実際に各機能を動かしているのはWindowsなりMacOSの力なのです。

## なんかよくわかんねえな？
とりあえず作っていきましょう。

## docker-compose.yamlを書く
適当にディレクトリを作成して、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イメージを作成するための設計図のようなものです。
```Dockerfile: mysql/Dockerfile
FROM mysql:8
```
```Dockerfile: phpmyadmin/Dockerfile
FROM phpmyadmin/phpmyadmin:latest

ARG UID=1000
RUN useradd -m -u ${UID} docker

USER ${UID}
```
```Dockerfile: web/Dockerfile
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を渡すくだりは、前に書いた記事の参考文献に書いてあったのでなんとなくそうしていますが、実はよくわかっていません。

https://qiita.com/are-38-a/items/ce7f76a8955c0ccfaaf9

下記の記事あたりを見ると、コンテナは初期状態でroot権限で動くため、乗っ取られたときにセキュリティ的に危険などの話が書いてあります。

https://qiita.com/yitakura731/items/36a2ba117ccbc8792aa7

ただし、mysqlサーバは特定のフォルダに特別な権限を付与するようで、同様にUID=1000を渡すとうまく動作しなかったのでとりあえず何もしないであります。

## 設定ファイルを作る
```web/config/php.ini
[Date]
date.timezone = "Asia/Tokyo"
```
```mysql/config/my.cnf/my.conf
[mysqld]
default_autentication_plugin=mysql_native_password
```
この辺は適当なのでちゃんと調べて書いてください。

## .envファイルを書く
```.env
COMPOSE_PROJECT_NAME=lmap-php8
COMPOSE_FILE=docker-compose.yaml
COMPOSE_PATH_SEPARATOR=:

WEB_PORT=80
MYSQL_PORT=3306
PMA_PORT=8080
```

## PHPファイルを作る
```web/html/phpinfo.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` とかの違いを理解できていなかったのでまとめてみた](https://qiita.com/tegnike/items/bcdcee0320e11a928d46)

では、http://localhost/phpinfo.php にアクセスしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882988/9f18be3b-738c-12fa-c52a-f960e3b87162.png)

なんか出ましたね。これでLAMP環境の構築ができました。おめでとうございます。

## 簡単な掲示板を作ってみる
<details><summary>index.pnpの中身を表示する</summary><div>

```web/html/index.php
<?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>
```
</div></details>

これは昔TE◯H-B◯SE(テッ◯ベース)とかいう微妙なプログラミングインターンで作成したクソコードをリファクタリングしたものです。([前のひどいコード](https://github.com/miya-38-a/TECH-BASE_internship/blob/main/mission_5-1.php))
一つのphpファイルにぐちゃぐちゃと書いてしまっていますがファイルは分割すべきでしょう。

一応CRUDのすべての操作を行います。。

これをweb/html/配下に置きます。
http://localhostにアクセスしてみましょう。
webのポートに80番を指定しているので、ポート番号を指定しなくても接続できます。
index.phpファイルにはファイル名指定なしでアクセスすることができるので、掲示板が開くはずです。

**おめでとうございます！**

# 補足説明
- Laravelなどのフレームワークを使いたい場合はDockerfile等を修正するなりが必要です。調べてみましょう。
- Apacheの設定など書き換えたいときはvolumeでマウントするなりDockerfileでCOPYするなりするといいんじゃないですかね?
- DBをコンテナで立ち上げていますが、**本番環境でDBを使用する場合コンテナとは相性が悪いです**(コンテナが落ちると状態を引き継がないため)。今回はホスト側の環境をマウントしデータを永続化していますが、開発・テスト用の利用に留めたほうが良いでしょう。(Webサービスを作るときはちゃんとしたところに情報を保存する。)

https://yuru-infra-se.hatenablog.com/entry/2020/10/24/215352
- **.envファイルは本来GitHub等に公開してはいけないファイルです。** クローンしてすぐ動くようにあえて公開してあります。ここには開発環境と本番環境で分けたい情報を記載します。${hoge}で参照できるようになります。接続するDBの名前とかユーザー名やパスワードなどを書いたりもします(今回はyamlに直接書いてしまっている)。更に進んだ話をすると、パスワードを平文で.envファイルに持つとセキュリティ的に良くないので別の場所に持とうとか暗号化しようとかいう話もあります。
- **上に載せたindex.phpはこのままだとXSS(クロスサイトスクリプティング)が行えてしまう** ので、書き込みの際にちゃんとエスケープするように修正すべきです。

# 参考記事
[DockerでApache+PHP+MySQLの環境を構築してみる](https://qiita.com/me-654393/items/1ed212cb33eafe734835)
[DockerでPHPの開発環境を構築してみよう！](https://idealump.com/service/lab/95)
[docker-compose.ymlの書き方を整理する](https://zenn.dev/o2z/articles/zenn-20210615_docker-compose)
[phpMyAdminでDockerで建てたMySQLにアクセスする](https://gihyo.jp/dev/serial/01/mysql-road-construction-news/0125)
[PHP8のLAMP環境をDockerで作ってみる](https://tech.fusic.co.jp/posts/2021-08-12-php8-lamp-on-docker/)
[Docker ComposeでDockerfileをビルドする際に親ディレクトリのファイルをコピーする](https://zukucode.com/2020/08/docker-compose-parent-directory.html)
[Docker Compose - docker-compose.yml リファレンス](https://qiita.com/zembutsu/items/9e9d80e05e36e882caaa)
[docker-compose公式ドキュメント](https://docs.docker.com/compose/)
[PHP公式イメージ](https://hub.docker.com/_/php)
[MySQL公式イメージ](https://hub.docker.com/_/mysql)

# ふ～ん
なるほどね？

<font color=red>※間違い等ありましたらご指摘のほどよろしくお願いします。</font>