用 Docker 来运行和调试 PHP 网站(一)

前言

为什么要写这篇博文

因为,作为一个伪运维工程师,我已经被架设各种 Web 服务器环境折磨得体无完肤了。直到我发现了 Docker 这货,才有一种相见恨晚的赶脚!懂我的同学你们懂的,如果你不懂的话,你可以直接关闭这个页面了。

那么用 Docker 的好处是什么呢?最重要的,就是可以快速搭建统一的 PHP 开发和生产环境。你的开发环境就是你的生产环境,本地测试通过,代表着部署到服务器也可以完全正常运行。而且还可以部署多个测试环境,让一套代码同时跑在 PHP 5.2, 5.3, 5.4, 5.5, 5.6 系统上测试兼容性,而不需要把本地开发环境弄得一塌糊涂!

准备工作

要想体验 Docker 的好处以及更好的理解本文,你必须要做好以下准备工作:

  • 了解 Docker 的基本原理和操作,起码知道怎么拉镜像和创建实例吧?
  • 基本的 Linux 命令行操作能力
  • 熟悉 PHP 开发和相关的知识

基本环境架设

运行 MySQL

因为要经常升级 MySQL 到最新版本,所以,我们不想每升级一次数据库,就重新导出、导入一次数据,因为这样感觉实在是太土了,不够高大上。为了达到这个目的,我们在创建 MySQL 实例之前需要先创建一个 Volume 用于保存 MySQL 的数据。我个人喜欢用 busybox 作为 base image,当然,你也可以根据自己的喜好来做。

1
docker run --name=mysql_data -v /var/lib/mysql -d busybox echo MySQL Data

这个命令,会创建一个包含 /var/lib/mysql 的 volume,以便我们后续挂载到 MySQL 实例上。运行 docker ps -a 可以看到我们创建的 mysql_data 实例。

1
2
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
ee138116e544 busybox:latest "echo MySQL Data" 6 seconds ago Exited (0) 5 seconds ago mysql_data

接下来创建 MySQL 实例,这里我使用了自己制作的 MySQL 镜像 tommylau/mysql,你可以根据自己的使用情况进行调整,更多的信息可以参考 Docker Hub 上的说明:https://registry.hub.docker.com/u/tommylau/mysql/

1
docker run --name=mysql_server --volumes-from mysql_data -e MYSQL_ROOT_PASSWORD=Passw0rd -d tommylau/mysql

--volumes-from 命令表示挂载名为 mysql_data 的 volume 到即将创建的 mysql_server 实例,也就是说,在 mysql_server 实例中对 /var/lib/mysql 目录进行读写操作时,实际的文件是存储在 mysql_data 实例中的。是不是有点儿难理解?没关系,你只要知道 MySQL 的数据都保存在 mysql_data 实例中就可以了。

-e MYSQL_ROOT_PASSWORD 参数用于设置环境变量,实例初始化的时候会用这个环境变量来设置 root 用户的密码,我们这里使用的密码为 Passw0rd。如果是开发或者测试环境的话,你也可以使用 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 参数,这样的话 root 密码为空。

1
docker run --name=mysql_server --volumes-from mysql_data -e MYSQL_ALLOW_EMPTY_PASSWORD=1 -d tommylau/mysql

查看一下我们刚刚创建的 MySQL 实例(docker ps -a)。

1
2
3
CONTAINER ID        IMAGE                   COMMAND                CREATED             STATUS                      PORTS               NAMES
f0253a67d154 tommylau/mysql:latest "/entrypoint.sh mysq 9 seconds ago Up 8 seconds 3306/tcp mysql_server
ee138116e544 busybox:latest "echo MySQL Data" 33 seconds ago Exited (0) 33 seconds ago mysql_data

嗯,不错,没有报任何错误,而且也是正在运行的状态。基本上 MySQL 的配置到这里就结束了。

创建 wwwroot 存储

其实这步是可选的,如果你只运行很简单的站点的话,直接使用 Docker 自带的 -v 将本地路径映射到实例里面就可以了,比如:

1
docker run -v /path/to/web:/var/www/html -p 80:80 -d tommylau/apache

这样你访问 http://localhost 的时候,访问的就是你本地磁盘的 /path/to/web。但是本地保存的文件位置可能会有变化,为了以后更好地维护和管理,我们新建一个 wwwroot 的实例,用于映射实例里面的 /var/www/html 路径,因为 Nginx 和 PHP 需要同时访问到这些文件。

1
docker run --name=wwwroot -v /Users/tommy/www:/var/www/html -d busybox echo wwwroot

通过上面的命令我们就把我们的本地路径 /Users/tommy/www 映射到了 /var/www/html注意:请用你本机的实际地址替换相应的路径,不要照抄例子。至此,我们就完成了 wwwroot 的准备工作。可以再次运行 docker ps -a 检查一下:

1
2
3
4
CONTAINER ID        IMAGE                   COMMAND                CREATED             STATUS                     PORTS               NAMES
98b7d5a39152 busybox:latest "echo wwwroot" 3 minutes ago Exited (0) 3 minutes ago wwwroot
f0253a67d154 tommylau/mysql:latest "/entrypoint.sh mysq 5 minutes ago Up 5 minutes 3306/tcp mysql_server
ee138116e544 busybox:latest "echo MySQL Data" 5 minutes ago Exited (0) 5 minutes ago mysql_data

运行 PHP-FPM

好像讲了这么久,终于到正题了。现在就要用到我们之前所创建的 mysql_server 实例和 wwwroot 存储了。不管三七二十一,先来个命令嗨一下再说。

1
docker run --name=php-fpm --volumes-from wwwroot --link mysql_server:mysql -d tommylau/php

mysql_server 类似,这里再次使用了 --volumes-from 参数,这样在我们新建的 php-fpm 实例里面访问 /var/www/html 就会引用 wwwroot 实例,又因为 wwwroot 实例映射到了 /Users/tommy/www,所以实际访问地址是本机的 /Users/tommy/www。如果你还是无法理解的话,请再重温一下上述两个小节。

--link 参数表示连接另外一个实例,这里我们连接了之前创建的 mysql_server 实例,并将它命名为 mysql。注意,这里所谓的命名,你可以理解为一个主机名或者别名,在我们新创建的这个 php-fpm 实例中,如果你打开 /etc/hosts 会发现里面有一条域名记录,指向 mysql_server 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
775b56aa5f5e tommylau/php:latest "php-fpm" About a minute ago Up About a minute 9000/tcp php-fpm
f0253a67d154 tommylau/mysql:latest "/entrypoint.sh mysq 16 minutes ago Up 16 minutes 3306/tcp mysql_server

$ docker exec -ti php-fpm cat /etc/hosts
172.17.0.5 775b56aa5f5e
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.3 mysql

这次,我使用了 docker ps 命令,没有 -a,表示,只列出正在运行的实例。我们创建的 mysql_datawwwroot 都是完全不需要占用 CPU 的,它们只是负责存储而已。第二条命令表示在 php-fpm 实例中执行 cat /etc/hosts-ti-t, -i 的合体,分别表示伪 TTY 和命令行交互,想深入了解的请参考 Docker 官方文档,或运行 docker run --help

我们可以看到在 hosts 文件最后有一条记录 172.17.0.3 mysql,这个就是 mysql_server 实例在虚拟环境中的 IP 地址,我们在 php-fpm 实例中,就是通过 mysql 这个名字与 mysql_server 实例进行通信的,我们可以 ping 一下看看。

1
2
3
4
5
6
7
8
$ docker exec -ti php-fpm ping -c 3 mysql
PING mysql (172.17.0.3): 56 data bytes
64 bytes from 172.17.0.3: icmp_seq=0 ttl=64 time=0.066 ms
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.093 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.142 ms
--- mysql ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.066/0.100/0.142/0.031 ms

嗯,看起来一切正常,PHP-FPM 的配置就告一段落了。如果你已经看到想睡觉了,那么休息一会儿,喝杯咖啡什么的,回来再继续。

运行 Nginx

好了,我们的 Web 服务终于要跑起来了,把最后一个 Nginx 跑起来就算大功告成了。

运行 Nginx 可以使用 Dockerfile 生成一个新的镜像,也可以使用 -v 挂在一个配置文件映射到实例中。

Nginx 配置文件

在介绍两种方式之前,我们先准备好 Nginx 的配置文件 default.conf。随便找一个你自己喜欢的目录,并创建该文件,但是请记下该文件的路径,比如 /Users/tommy/default.conf。下面是参考的配置,你也可以根据自己的喜好进行调整。

default.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
listen 80;

root /var/www/html;
index index.html index.htm index.php;

server_name localhost;

location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
# Uncomment to enable naxsi on this location
# include /etc/nginx/naxsi.rules
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
}
}

这里有几点需要做适当调整的:

  • root 需要根据实际情况调整,如果你都是按照这个教程操作的话,可以不用修改
  • server_name 需要根据实际情况进行调整,如果只运行一个默认网站的话,可以不用修改
  • fastcgi_pass 后面的 php 是要连接的实例名称,稍后我们在命令行中会用到,先配置好

创建 Web 页面

上面我们曾经提到过,我的本地路径是 /Users/tommy/www,所以我在这个目录创建2个新文件,分别是 index.htmlphpinfo.php。用你喜欢的工具在你本地的路径创建这2个文件就好,或者是其他你想要写的内容,你可以任意发挥你的想象力。

index.html
1
2
3
4
5
6
7
8
9
<html>
<head>
<title>Test</title>
</head>

<body>
Hello world!
</body>
</html>
phpinfo.php
1
2
<?php
phpinfo();

Dockerfile 方式运行 Nginx

好消息是我们马上就可以看到我们的劳动成果了,坏消息是我们又要折腾 Dockerfile 了。

在刚才创建 default.conf 的目录内(/Users/tommy),创建一个文件名为 Dockerfile 的文件(Mac 和 Linux 下请注意首字母的大写),其内容如下:

Dockerfile
1
2
FROM tommylau/nginx
COPY default.conf /etc/nginx/conf.d/

打开终端或者命令行并进入到 Dockerfile 所在目录,运行 Docker build 命令来生成一个新的镜像。注意:本命令必须在 Dockerfiledefault.conf 所在目录执行,否则 Docker 会提示找不到 Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker build -t local/nginx .
Sending build context to Docker daemon 3.072 kB
Sending build context to Docker daemon
Step 0 : FROM tommylau/nginx
---> ee8df13c0397
Step 1 : COPY default.conf /etc/nginx/conf.d/
---> 05e151de7cef
Removing intermediate container 41eefaf73780
Successfully built 05e151de7cef

$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
local/nginx latest 05e151de7cef 27 seconds ago 92.66 MB

这个命令会生成一个新的名为 local/nginx 的镜像,当然你也可以按照你自己的喜好给它重新起个名字。不过你必须记住这个名字,因为稍后我们还要召唤它来提供 Web 服务。最后,整合我们之前启动的 PHP-FPM 实例 php-fpm

1
docker run --name=nginx --volumes-from wwwroot --link php-fpm:php -p 80:80 -d local/nginx

同样的,我们需要加载 wwwroot 实例,以便实例可以正确的访问 /var/www/html 目录。这里将实例 php-fpm 映射成别名 php,这里必须要与我们之前修改的 Nginx 配置文件 default.conf 中的名字相匹配(fastcgi_pass 后面的服务器名)。-p 80:80 表示将实例内的 80 端口暴露给 Host 主机。

这个时候,我们已经可以通过 http://localhost 来访问 Nginx 实例了。你会看到一个大大的 Hello world,当然我们也可以访问 http://localhost/phpinfo.php 来查看 PHP 版本信息。

注意:使用 boot2docker 的用户,需要将 localhost 修改为 Guest 的 IP 地址。如果你的 VirtualBox 只有 boot2docker 一个虚拟机的话,则 boot2docker 默认的 IP 地址是:192.168.59.103。所以,你可以尝试一下访问:http://192.168.59.103 。如果不行的话,你可以运行 boot2docker ip 来获得 guest 的 IP 地址。

1
2
$ boot2docker ip
192.168.59.103

挂载方式运行 Nginx

挂载配置文件方式启动 Nginx 跟使用 Dockerfile 方式类似,不过更简单一点,这种方式比较适合配置简单的情况。

记得我们之前保存 default.conf 的路径么?我的是 /Users/tommy/default.conf。实现同上述同样的启动效果,我们可以使用下面的命令。

1
docker run --name=nginx --volumes-from wwwroot --link php-fpm:php -v /Users/tommy/default.conf:/etc/nginx/conf.d/default.conf -p 80:80 -d tommylau/nginx

如果运行上述命令,提示如下错误,我们可以使用 docker rm -f nginx 命令来删除旧的实例,再重新执行该命令便可。

1
FATA[0000] Error response from daemon: Conflict. The name "nginx" is already in use by container 6f021cff90ef. You have to delete (or rename) that container to be able to reuse that name.

打开网页检查一下,是不是和使用 Dockerfile 的方式一样?:)

还有一种情况是,我们有多个配置文件,如果一一指定的话会变得相当的痛苦,这个时候我们就会使用目录映射的方式来替代文件映射。我们先来创建一个包含配置的文件夹 conf.d 并把 default.conf 移动到该目录中。

1
2
3
$ cd /Users/tommy
$ mkdir conf.d
$ mv default.conf conf.d

然后使用如下命令,就可以把整个配置文件的目录映射到实例中。

1
docker run --name=nginx --volumes-from wwwroot --link php-fpm:php -v /Users/tommy/conf.d:/etc/nginx/conf.d -p 80:80 -d tommylau/nginx

再打开浏览器检查一下,依然可以正常运行。

测试 MySQL 连接

在我们的 wwwroot 目录(/Users/tommy/www)中新建一个 mysql.php 的文件:

mysql.php
1
2
3
4
5
6
7
8
9
<?php
$connect = mysql_connect("mysql", "root", "Passw0rd") or die("Unable to connect to MySQL.");
mysql_select_db("mysql") or die("Could not open the database.");
$showtablequery = "SHOW TABLES FROM mysql;";
$query_result = mysql_query($showtablequery);

while ($row = mysql_fetch_array($query_result)) {
echo $row[0] . "<br />\n";
}

访问:http://localhost/mysql.php ,应该会打印出类似如下的 mysql 数据库表名列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
columns_priv
db
event
func
general_log
help_category
help_keyword
help_relation
help_topic
innodb_index_stats
innodb_table_stats
ndb_binlog_index
plugin
proc
procs_priv
proxies_priv
servers
slave_master_info
slave_relay_log_info
slave_worker_info
slow_log
tables_priv
time_zone
time_zone_leap_second
time_zone_name
time_zone_transition
time_zone_transition_type
user

总结

总的来说,架设的步骤如下

  1. 架设 MySQL 数据库,因为 PHP 需要连接到 MySQL
  2. 架设 PHP 服务器,同时连接到 MySQL Server
  3. 架设 Nginx 服务器,连接 PHP 服务器

因为 PHP 访问数据库,所以 Nginx 是不需要连接 MySQL 的,Nginx 只需要做代理服务器,当有 PHP 请求的时候转发给 PHP 服务器处理就好了。

这里的架设范例都是基于单个主机进行的,实例之间会自动存在于一个虚拟的 docker0 的交换机内。所以实例之间就好像在同一个局域网一样,如果你要跨主机进行数据的访问,那么你可能需要暴露部分服务的端口。比如 MySQL3306 端口,PHP9000 端口等。更多的网络设置可以参考 Docker 官方的 Network Configuration

至此,PHP 服务器的架设就告一段落了,稍后会更新如何搭建基于 Apache 和 Xdebug 的 PHP 调试环境。