用 docker compose 建立本地 PHP + Nginx + MuSQL server

想要在本地開發 PHP 專案,但是又不想在本機安裝 MySQLNginx,或者是專案的 PHP 版本跟本機安裝的 PHP 版本不同時,可以考慮用 docker compose 在本機快速建立開發環境。

概念是建立三個 container:

Nginx 跟 PHP FPM 之間透過 fastcgi 協定在預設的 port 9000 連線。MySQL 的連線方式則跟一般 DB 連線沒有什麼不同。

以下是建立環境實際會用到的檔案。

php.dockerfile,由於會需要另外安裝 pdo_mysql,這裡不直接使用官方 image,而是自己建置安裝了 pdo_mysql 的 image。由於改動不多,需要升級版本時只要改 base image 的版本號就好,不會大費周章。

FROM php:8.1-fpm-alpine
RUN docker-php-ext-install pdo_mysql

docker-compose.yml,注意這裡把專案資料夾同時掛進 nginx 跟 php 兩個 container,確保兩邊都能看到機器上真正的專案檔案。另外這裡為求簡潔,MySQL 直接使用預設的 root 帳號以及空白密碼。

version: "3"
services:
  nginx:
    image: nginx:latest
    networks:
      - mynet
    ports:
      - 80:80
    volumes:
      - .:/app
      - ./nginx.conf:/etc/nginx/nginx.conf
  php:
    build:
      dockerfile: php.dockerfile
      context: .
    networks:
      - mynet
    volumes:
      - .:/app
  db:
    image: mysql:8.0.29-oracle
    environment:
      MYSQL_DATABASE: app
      MYSQL_ALLOW_EMPTY_PASSWORD: true
    networks:
      - mynet
networks:
  mynet:
    name: local-net

nginx.conf,非 PHP 檔案會直接回應,若是 PHP 檔案會轉由 php-fpm server 執行;找不到指定的檔案時會一率轉由 /app/public/index.php 負責回應。

events{
    worker_connections  4096;
}

http{
    include mime.types;

    log_format local '[$time_local] $request_time $request $status $upstream_response_time $bytes_sent ';

    upstream php-fcgi {
        server php:9000;
    }

    server {
        server_name php-docker.local;
        error_log  /var/log/nginx/error.log info;
        access_log /var/log/nginx/access.log local;
        root /app/public;

        index index.php index.html;
        location / {
            try_files $uri $uri/ /index.php$is_args$query_string;
        }

        location ~ \.php$ {
            include fastcgi_params;
            fastcgi_pass php-fcgi;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_param PATH_INFO $fastcgi_path_info;
        }
    }
}

public/index.php,印出一些訊息確保 PHP 有被執行,並執行 MySQL 連線驗證。

PHP is running...
<?php
$db = new PDO('mysql:host=db;dbname=app;charset=utf8mb4;', 'root');

$stmt = $db->query('SELECT now()');
$dbtime = $stmt->fetch()[0];

printf("| %-12s :  %-20s |\n", 'Request URI', $_SERVER['REQUEST_URI']);
printf("| %-12s :  %-20s |\n", 'PHP time', date("Y-m-d H:i:s"));
printf("| %-12s :  %-20s |\n", 'MySQL time', $dbtime);

然後放一個 public/text.txt 驗證檔案直出不過 PHP。

Some text

整個專案資料夾長這樣:

.
├── docker-compose.yml
├── nginx.conf
└── public
    ├── index.php
    └── text.txt

1 directory, 4 files

接下來就是實際啟動環境了。

首先透過 docker compose 建置執行會需要的 image。

$ docker compose build
[+] Building 0.1s (6/6) FINISHED
 => [internal] load build definition from php.dockerfile                                               0.0s
 => => transferring dockerfile: 82B                                                                    0.0s
 => [internal] load .dockerignore                                                                      0.0s
 => => transferring context: 2B                                                                        0.0s
 => [internal] load metadata for docker.io/library/php:8.1-fpm-alpine                                  0.0s
 => [1/2] FROM docker.io/library/php:8.1-fpm-alpine                                                    0.0s
 => CACHED [2/2] RUN docker-php-ext-install pdo_mysql                                                  0.0s
 => exporting to image                                                                                 0.0s
 => => exporting layers                                                                                0.0s
 => => writing image sha256:ec765448eff2df4b05ba952f969120957d47bd5692fe33f2b55a011917d52f89           0.0s
 => => naming to docker.io/library/test-d_php                                                          0.0s

Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them

接著用 docker compose 啟動 container。MySQL 的啟動訊息較長,僅保留頭尾。

$ docker compose up
[+] Running 4/1
 ⠿ Network local-net         Created                                                                   0.0s
 ⠿ Container test-d-nginx-1  Created                                                                   0.1s
 ⠿ Container test-d-db-1     Created                                                                   0.1s
 ⠿ Container test-d-php-1    Created                                                                   0.1s
Attaching to test-d-db-1, test-d-nginx-1, test-d-php-1
test-d-db-1     | 2022-06-26 13:59:32+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.29-1.el8 started.
test-d-nginx-1  | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
test-d-nginx-1  | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
test-d-nginx-1  | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
test-d-db-1     | 2022-06-26 13:59:32+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
test-d-nginx-1  | 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
test-d-db-1     | 2022-06-26 13:59:32+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.29-1.el8 started.
test-d-php-1    | [26-Jun-2022 13:59:32] NOTICE: fpm is running, pid 1
test-d-php-1    | [26-Jun-2022 13:59:32] NOTICE: ready to handle connections
test-d-nginx-1  | 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
test-d-nginx-1  | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
test-d-nginx-1  | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
test-d-nginx-1  | /docker-entrypoint.sh: Configuration complete; ready for start up
test-d-db-1     | 2022-06-26 13:59:32+00:00 [Note] [Entrypoint]: Initializing database files
test-d-db-1     | 2022-06-26T13:59:32.987566Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.29) initializing of server in progress as process 42
.....
test-d-db-1     | 2022-06-26T13:59:39.107115Z 0 [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
test-d-db-1     | 2022-06-26T13:59:39.107138Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.0.29'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server - GPL.

驗證是否正確啟動

# 根目錄
PHP is running...
| Request URI  :  /                    |
| PHP time     :  2022-06-26 14:01:39  |
| MySQL time   :  2022-06-26 14:01:39  |

# 非根目錄
$ curl localhost/other/path
PHP is running...
| Request URI  :  /other/path          |
| PHP time     :  2022-06-26 14:01:59  |
| MySQL time   :  2022-06-26 14:01:59  |

# 靜態檔案
$ curl localhost/text.txt
Some text

完成後關閉 container

# 關閉 container 不移除資料
$ docker compose stop
[+] Running 3/3
 ⠿ Container test-d-nginx-1  Stopped                                                                   0.2s
 ⠿ Container test-d-php-1    Stopped                                                                   0.2s
 ⠿ Container test-d-db-1     Stopped                                                                   1.4s

# 或者,關閉並完全移除 container
$ docker compose down
[+] Running 4/4
 ⠿ Container test-d-nginx-1  Removed                                                                   0.0s
 ⠿ Container test-d-php-1    Removed                                                                   0.0s
 ⠿ Container test-d-db-1     Removed                                                                   0.0s
 ⠿ Network local-net         Removed                                                                   0.1s

這裡的程式只是範例。現實生活專案會有許多小地方要注意例如 index.php 開頭應該要用 require __DIR__ . '/../vendor/autoload.php' 載入 composer 安裝好的套件。並且應該包含 front-end controller 的必要邏輯,與 SlimLaravelpublic/index.php 相同作用。

相對於 build 一個裡面把 PHP / MySQL / nginx 全部裝在一起的 container,分成多個 container 的最大的好處是升級容易,上游有修改,或安全性更新,或專案需求要使用不同版本時,只要更改 image 版本號就好。而且也可以省去每次小修小改就要重新 build image 的時間。