Docker在PHP項(xiàng)目開(kāi)發(fā)環(huán)境中的應(yīng)用
環(huán)境部署是所有團(tuán)隊(duì)都必須面對(duì)的問(wèn)題,隨著系統(tǒng)越來(lái)越大,依賴的服務(wù)也越來(lái)越多,比如我們目前的一個(gè)項(xiàng)目就會(huì)用到:
- Web服務(wù)器:Nginx
- Web程序:PHP + Node
- 數(shù)據(jù)庫(kù):MySQL
- 搜索引擎:ElasticSearch
- 隊(duì)列服務(wù):Gearman
- 緩存服務(wù):Redis + Memcache
- 前端構(gòu)建工具:npm + bower + gulp
- PHP CLI工具:Composer + PHPUnit
因此團(tuán)隊(duì)的開(kāi)發(fā)環(huán)境部署隨之暴露出若干問(wèn)題:
- 依賴服務(wù)很多,本地搭建一套環(huán)境成本越來(lái)越高,初級(jí)人員很難解決環(huán)境部署中的一些問(wèn)題
- 服務(wù)的版本差異及OS的差異都可能導(dǎo)致線上環(huán)境BUG
- 項(xiàng)目引入新的服務(wù)時(shí)所有人的環(huán)境需要重新配置
對(duì)于問(wèn)題1,可以用Vagrant這樣的基于虛擬機(jī)的項(xiàng)目來(lái)解決,團(tuán)隊(duì)成員共享一套開(kāi)發(fā)環(huán)境鏡像。對(duì)于問(wèn)題2,可以引入類似PHPBrew這樣的多版本PHP管理工具來(lái)解決。但兩者都不能很好地解決問(wèn)題3,因?yàn)樘摂M機(jī)鏡像沒(méi)有版本管理的概念,當(dāng)多人維護(hù)一個(gè)鏡像時(shí),很容易出現(xiàn)配置遺漏或者沖突,一個(gè)很大的鏡像傳輸起來(lái)也不方便。
Docker的出現(xiàn)讓上面的問(wèn)題有了更好的解決方案,雖然個(gè)人對(duì)于Docker大規(guī)模應(yīng)用到生產(chǎn)環(huán)境還持謹(jǐn)慎態(tài)度,但如果僅僅考慮測(cè)試及開(kāi)發(fā),私以為Docker的容器化理念已經(jīng)是能真正解決環(huán)境部署問(wèn)題的銀彈了。
下面介紹Docker構(gòu)建PHP項(xiàng)目開(kāi)發(fā)環(huán)境過(guò)程中的演進(jìn),本文中假設(shè)你的操作系統(tǒng)為L(zhǎng)inux,已經(jīng)安裝了Docker,并且已經(jīng)了解Docker是什么,以及Docker命令行的基礎(chǔ)使用,如果沒(méi)有這些背景知識(shí)建議先自行了解。
Hello World
首先還是從一個(gè)PHP在Docker容器下的Hello World實(shí)例開(kāi)始。我們準(zhǔn)備這樣一個(gè)PHP文件index.php:
- echo "PHP in Docker";
然后在同目錄下創(chuàng)建文本文件并命名為Dockerfile,內(nèi)容為:
- # 從官方PHP鏡像構(gòu)建
- FROM php
- # 將index.php復(fù)制到容器內(nèi)的/var/www目錄下
- ADD index.php /var/www
- # 對(duì)外暴露8080端口
- EXPOSE 8080
- # 設(shè)置容器默認(rèn)工作目錄為/var/www
- WORKDIR /var/www
- # 容器運(yùn)行后默認(rèn)執(zhí)行的指令
- ENTRYPOINT ["php", "-S", "0.0.0.0:8080"]
構(gòu)建這個(gè)容器:
- docker build -t allovince/php-helloworld .
運(yùn)行這個(gè)容器
- docker run -d -p 8080:8080 allovince/php-helloworld
查看結(jié)果:
- curl localhost:8080
- PHP in Docker
這樣我們就創(chuàng)建了一個(gè)用于演示PHP程序的Docker容器,任何安裝過(guò)Docker的機(jī)器都可以運(yùn)行這個(gè)容器獲得同樣的結(jié)果。而任何有上面的php文件和Dockerfile的人都可以構(gòu)建出相同的容器,從而完全消除了不同環(huán)境,不同版本可能引起的各種問(wèn)題。
想象一下程序進(jìn)一步復(fù)雜,我們應(yīng)該如何擴(kuò)展呢,很直接的想法是繼續(xù)在容器內(nèi)安裝其他用到的服務(wù),并將所有服務(wù)運(yùn)行起來(lái),那么我們的Dockerfile很可能發(fā)展成這個(gè)樣子:
- FROM php
- ADD index.php /var/www
- # 安裝更多服務(wù)
- RUN apt-get install -y \
- mysql-server \
- nginx \
- php5-fpm \
- php5-mysql
- # 編寫(xiě)一個(gè)啟動(dòng)腳本啟動(dòng)所有服務(wù)
- ENTRYPOINT ["/opt/bin/php-nginx-mysql-start.sh"]
雖然我們通過(guò)Docker構(gòu)建了一個(gè)開(kāi)發(fā)環(huán)境,但覺(jué)不覺(jué)得有些似曾相識(shí)呢。沒(méi)錯(cuò),其實(shí)這種做法和制作一個(gè)虛擬機(jī)鏡像是差不多的,這種方式存在幾個(gè)問(wèn)題:
- 如果需要驗(yàn)證某個(gè)服務(wù)的不同版本,比如測(cè)試PHP5.3/5.4/5.5/5.6,就必須準(zhǔn)備4個(gè)鏡像,但其實(shí)每個(gè)鏡像只有很小的差異。
- 如果開(kāi)始新的項(xiàng)目,那么容器內(nèi)安裝的服務(wù)會(huì)不斷膨脹,最終無(wú)法弄清楚哪個(gè)服務(wù)是屬于哪個(gè)項(xiàng)目的。
#p#
使用單一進(jìn)程容器
上面這種將所有服務(wù)放在一個(gè)容器內(nèi)的模式有個(gè)形象的非官方稱呼:Fat Container。與之相對(duì)的是將服務(wù)分拆到容器的模式。從Docker的設(shè)計(jì)可以看到,構(gòu)建鏡像的過(guò)程中可以指定唯一一個(gè)容器啟動(dòng)的指令,因此 Docker天然適合一個(gè)容器只運(yùn)行一種服務(wù),而這也是官方更推崇的。
分拆服務(wù)遇到的第一個(gè)問(wèn)題就是,我們每一個(gè)服務(wù)的基礎(chǔ)鏡像從哪里來(lái)?這里有兩個(gè)選項(xiàng):
選項(xiàng)一、 統(tǒng)一從標(biāo)準(zhǔn)的OS鏡像擴(kuò)展,比如下面分別是Nginx和MySQL鏡像
FROM ubuntu:14.04 RUN apt-get update -y && apt-get install -y nginx
- FROM ubuntu:14.04
- RUN apt-get update -y && apt-get install -y mysql
這種方式的優(yōu)點(diǎn)在于所有服務(wù)可以有一個(gè)統(tǒng)一的基礎(chǔ)鏡像,對(duì)鏡像進(jìn)行擴(kuò)展和修改時(shí)可以使用同樣的方式,比如選擇了ubuntu,就可以使用apt-get指令安裝服務(wù)。
問(wèn)題在于大量的服務(wù)需要自己維護(hù),特別是有時(shí)候需要某個(gè)服務(wù)的不同版本時(shí),往往需要直接編譯源碼,調(diào)試維護(hù)成本都很高。
選項(xiàng)二、 直接從Docker Hub繼承官方鏡像,下面同樣是Nginx和MySQL鏡像
FROM nginx:1.9.0
- FROM mysql:5.6
Docker Hub可以看做是Docker的Github,Docker官方已經(jīng)準(zhǔn)備好了大量常用服務(wù)的鏡像,同時(shí)也有非常多第三方提交的鏡像。甚至可以基于Docker-Registry項(xiàng)目在短時(shí)間內(nèi)自己搭建一個(gè)私有的Docker Hub。
基于某個(gè)服務(wù)的官方鏡像去構(gòu)建鏡像,有非常豐富的選擇,并且可以以很小的代價(jià)切換服務(wù)的版本。這種方式的問(wèn)題在于官方鏡像的構(gòu)建方式多種多樣,進(jìn)行擴(kuò)展時(shí)需要先了解原鏡像的Dockerfile。
出于讓服務(wù)搭建更靈活的考慮,我們選擇后者構(gòu)建鏡像。
為了分拆服務(wù),現(xiàn)在我們的目錄變?yōu)槿缦滤窘Y(jié)構(gòu):
- ~/Dockerfiles
- ├── mysql
- │ └── Dockerfile
- ├── nginx
- │ ├── Dockerfile
- │ ├── nginx.conf
- │ └── sites-enabled
- │ ├── default.conf
- │ └── evaengine.conf
- ├── php
- │ ├── Dockerfile
- │ ├── composer.phar
- │ ├── php-fpm.conf
- │ ├── php.ini
- │ ├── redis.tgz
- └── redis
- └── Dockerfile
即為每個(gè)服務(wù)創(chuàng)建單獨(dú)文件夾,并在每個(gè)服務(wù)文件夾下放一個(gè)Dockerfile。
MySQL容器
MySQL繼承自官方的MySQL5.6鏡像,Dockerfile僅有一行,無(wú)需做任何額外處理,因?yàn)槠胀ㄐ枨蠊俜蕉家呀?jīng)在鏡像中實(shí)現(xiàn)了,因此Dockerfile的內(nèi)容為:
FROM mysql:5.6
在項(xiàng)目根目錄下運(yùn)行
- docker build -t eva/mysql ./mysql
會(huì)自動(dòng)下載并構(gòu)建鏡像,這里我們將其命名為eva/mysql。
由于容器運(yùn)行結(jié)束時(shí)會(huì)丟棄所有數(shù)據(jù)庫(kù)數(shù)據(jù),為了不用每次都要導(dǎo)入數(shù)據(jù),我們將采用掛載的方式持久化MySQL數(shù)據(jù)庫(kù),官方鏡像默認(rèn)將數(shù)據(jù)庫(kù)存放在/var/lib/mysql,同時(shí)要求容器運(yùn)行時(shí)必須通過(guò)環(huán)境變量設(shè)置一個(gè)管理員密碼,因此可以使用以下指令運(yùn)行容器:
- docker run -p 3306:3306 -v ~/opt/data/mysql:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=123456 -it eva/mysql
通過(guò)上面的指令,我們將本地的3306端口綁定到容器的3306端口,將容器內(nèi)的數(shù)據(jù)庫(kù)持久化到本地的~/opt/data/mysql,并且為MySQL設(shè)置了一個(gè)root密碼123456
Nginx容器
Nginx目錄下提前準(zhǔn)備了Nginx配置文件nginx.conf以及項(xiàng)目的配置文件default.conf等。Dockerfile內(nèi)容為:
FROM nginx:1.9 ADD nginx.conf /etc/nginx/nginx.conf ADD sites-enabled/* /etc/nginx/conf.d/ RUN mkdir /opt/htdocs && mkdir /opt/log && mkdir /opt/log/nginx RUN chown -R www-data.www-data /opt/htdocs /opt/log VOLUME ["/opt"]
由于官方的Nginx1.9是基于Debian Jessie的,因此首先將準(zhǔn)備好的配置文件復(fù)制到指定位置,替換鏡像內(nèi)的配置,這里按照個(gè)人習(xí)慣,約定/opt/htdocs目錄為Web服務(wù)器根目錄,/opt/log/nginx目錄為Nginx的Log目錄。
同樣構(gòu)建一下鏡像
docker build -t eva/nginx ./nginx
并運(yùn)行容器
docker run -p 80:80 -v ~/opt:/opt -it eva/nginx
注意我們將本地的80端口綁定到容器的80端口,并將本地的~/opt目錄掛載到容器的/opt目錄,這樣就可以將項(xiàng)目源代碼放在~/opt目錄下并通過(guò)容器訪問(wèn)了。
#p#
PHP容器
PHP容器是最復(fù)雜的一個(gè),因?yàn)樵趯?shí)際項(xiàng)目中,我們很可能需要單獨(dú)安裝一些PHP擴(kuò)展,并用到一些命令行工具,這里我們以 Redis擴(kuò)展以及Composer來(lái)舉例。首先將項(xiàng)目需要的擴(kuò)展等文件提前下載到php目錄下,這樣構(gòu)建時(shí)就可以從本地復(fù)制而無(wú)需每次通過(guò)網(wǎng)絡(luò)下載,大大加快鏡像構(gòu)建的速度:
- wget https://getcomposer.org/composer.phar -O php/composer.phar
- wget https://pecl.php.net/get/redis-2.2.7.tgz -O php/redis.tgz
php目錄下還準(zhǔn)備好了php配置文件php.ini以及php-fpm.conf,基礎(chǔ)鏡像我們選擇的是PHP 5.6-FPM,這同樣是一個(gè)Debian Jessie鏡像。官方比較親切的在鏡像內(nèi)部準(zhǔn)備了一個(gè)docker-php-ext-install指令,可以快速安裝如GD、PDO等常用擴(kuò)展。所有支持的擴(kuò)展名稱可以通過(guò)在容器內(nèi)運(yùn)行docker-php-ext-install獲得。
來(lái)看一下Dockerfile:
- FROM php:5.6-fpm
- ADD php.ini /usr/local/etc/php/php.ini
- ADD php-fpm.conf /usr/local/etc/php-fpm.conf
- COPY redis.tgz /home/redis.tgz
- RUN docker-php-ext-install gd \
- && docker-php-ext-install pdo_mysql \
- && pecl install /home/redis.tgz && echo "extension=redis.so" > /usr/local/etc/php/conf.d/redis.ini
- ADD composer.phar /usr/local/bin/composer
- RUN chmod 755 /usr/local/bin/composer
- WORKDIR /opt
- RUN usermod -u 1000 www-data
- VOLUME ["/opt"]
在構(gòu)建過(guò)程中做了這樣一些事情:
- 復(fù)制php和php-fpm配置文件到相應(yīng)目錄
- 復(fù)制redis擴(kuò)展源代碼到/home
- 通過(guò)docker-php-ext-install安裝GD和PDO擴(kuò)展
- 通過(guò)pecl安裝Redis擴(kuò)展
- 復(fù)制composer到鏡像作為全局指令
按照個(gè)人習(xí)慣,仍然設(shè)置/opt目錄作為工作目錄。
這里有一個(gè)細(xì)節(jié),在復(fù)制tar包文件時(shí),使用的Docker指令是COPY而不是ADD,這是由于ADD指令會(huì)自動(dòng)解壓tar文件。
現(xiàn)在終于可以構(gòu)建+運(yùn)行了:
- docker build -t eva/php ./php
- docker run -p 9000:9000 -v ~/opt:/opt -it eva/php
在大多數(shù)情況下,Nginx和PHP所讀取的項(xiàng)目源代碼都是同一份,因此這里同樣掛載本地的~/opt目錄,并且綁定9000端口。
PHP-CLI的實(shí)現(xiàn)
php容器除了運(yùn)行php-fpm外,還應(yīng)該作為項(xiàng)目的php cli使用,這樣才能保證php版本、擴(kuò)展以及配置文件保持一致。
例如在容器內(nèi)運(yùn)行Composer,可以通過(guò)下面的指令實(shí)現(xiàn):
- docker run -v $(pwd -P):/opt -it eva/php composer install --dev -vvv
這樣在任意目錄下運(yùn)行這行指令,等于動(dòng)態(tài)將當(dāng)前目錄掛載到容器的默認(rèn)工作目錄并運(yùn)行,這也是PHP容器指定工作目錄為/opt的原因。
同理還可以實(shí)現(xiàn)phpunit、npm、gulp等命令行工具在容器內(nèi)運(yùn)行。
Redis容器
為了方便演示,Redis僅僅作為緩存使用,沒(méi)有持久化需求,因此Dockerfile僅有一行
FROM redis:3.0
容器的連接
上面已經(jīng)將原本在一個(gè)容器中運(yùn)行的服務(wù)分拆到多個(gè)容器,每個(gè)容器只運(yùn)行單一服務(wù)。這樣一來(lái)容器之間需要能互相通信。Docker容器間通訊的方法有兩種,一種是像上文這樣將容器端口綁定到一個(gè)本地端口,通過(guò)端口通訊。另一種則是通過(guò)Docker提供的Linking功能,在開(kāi)發(fā)環(huán)境下,通過(guò)Linking通信更加靈活,也能避免端口占用引起的一些問(wèn)題,比如可以通過(guò)下面的方式將Nginx和PHP鏈接起來(lái):
- docker run -p 9000:9000 -v ~/opt:/opt --name php -it eva/php
- docker run -p 80:80 -v ~/opt:/opt -it --link php:php eva/nginx
在一般的PHP項(xiàng)目中,Nginx需要鏈接PHP,而PHP又需要鏈接MySQL,Redis等。為了讓容器間互相鏈接更加容易管理,Docker官方推薦使用Docker-Compose完成這些操作。
用一行指令完成安裝
- pip install -U docker-compose
然后在Docker項(xiàng)目的根目錄下準(zhǔn)備一個(gè)docker-compose.yml文件,內(nèi)容為:
- nginx:
- build: ./nginx
- ports:
- - "80:80"
- links:
- - "php"
- volumes:
- - ~/opt:/opt
- php:
- build: ./php
- ports:
- - "9000:9000"
- links:
- - "mysql"
- - "redis"
- volumes:
- - ~/opt:/opt
- mysql:
- build: ./mysql
- ports:
- - "3306:3306"
- volumes:
- - ~/opt/data/mysql:/var/lib/mysql
- environment:
- MYSQL_ROOT_PASSWORD: 123456
- redis:
- build: ./redis
- ports:
- - "6379:6379"
然后運(yùn)行docker-compose up,就完成了所有的端口綁定、掛載、鏈接操作。
更復(fù)雜的實(shí)例
上面是一個(gè)標(biāo)準(zhǔn)PHP項(xiàng)目在Docker環(huán)境下的演進(jìn)過(guò)程,實(shí)際項(xiàng)目中一般會(huì)集成更多更復(fù)雜的服務(wù),但上述基本步驟仍然可以通用。比如EvaEngine/Dockerfiles是為了運(yùn)行我的開(kāi)源項(xiàng)目EvaEngine準(zhǔn)備的基于Docker的開(kāi)發(fā)環(huán)境,EvaEngine依賴了隊(duì)列服務(wù)Gearman,緩存服務(wù)Memcache、Redis,前端構(gòu)建工具Gulp、Bower,后端Cli工具Composer、PHPUnit等。具體實(shí)現(xiàn)方式可以自行閱讀代碼。