laravel后端项目的Docker镜像打包
对读者的要求
- 后端开发基础知识
- 掌握Docker基础用法以及docker-compose用法(有laradock使用经验为佳)
- Laravel基础
简介
在第一篇文章《纯前端项目的Docker镜像打包》中,提到后端项目的镜像打包方面比较复杂,需要独立一篇。我们的目标仍然是执行一条命令就要能完成应用的启动。纯前端项目使用的是docker run
,后端项目需要用到镜像编排(docker-comopse)
,启动命令变成:
docker-compose up -d --renew-anon-volumes
docker-compose
使用最新版本,否则--renew-anon-volumes
参数可能不支持
docker-compose
的内部结构如下图的“应用”部分所示,里面编排了4个任务;而后端需要的数据库
、缓存
、队列
、对象存储
以及安全配置
则由外部提供,docker-compose
不关心它们的来源:
下面我们一步步了解,为什么会形成这样的图,以及怎么打包以满足图中所示的架构。
概念
在谈论后端的镜像打包和镜像测试之前,首先要认识到Docker
要用得好,需要满足两大特征:
- 无状态
- 单一职责
无状态是指容器不会保存任何东西,不论往容器里面写入任何东西,再次执行docker-compose up -d
,启动后的容器是找不到原来写入的东西的。有些场景如果需要保存数据,比如数据库和存储,它们需要通过挂载保证写入的数据不因为重启而丢失,这类容器称为有状态容器。
要保证应用的水平扩展和快速迁移,所有的应用在启动时必须是无状态的。试想一下,你的应用原本在A机器上执行一句docker-comopse up -d
就行了,现在要改到B机器上运行,就在B机器上执行一句docker-compose up -d
。
如果它是有状态的(比如A在启动应用的时候,将存储挂载到主机,以便用户上传的图片和应用产生的日志可以永久保留),那么迁移到B机器时,它的日志和图片仍然在A机器上,快速迁移受到了限制。
另一个更常见的场景是,A,B两台机器同时启动以便负载均衡,如果A和B是有状态的,就会出现跟迁移一样的问题,A和B都会遗漏用户上传的图片,这就限制了水平扩展。因此,应用必须是无状态的。
单一职责简单说就是只干一件事,反映到Docker上,就是最好只启动一个进程(取决于对职责的解释,也可能启动多个进程)。比如php-fpm
镜像只启动php-fpm
,nginx
镜像只启动nginx
。要做到将nginx
,php-fpm
都在同一个里容器里启动当然是可以的,但是它带来的代价往往比比好处更大。越多不同职责的功能塞到同一个容器里,代价就越大。
以Laravel
工程为例,有4个不同职责的进程:nginx
,php-fpm
,php队列
,cron定时任务
。混在一起以后,第一个问题是:任何一块有更新,都要重新打包这个镜像,而通常这个镜像里面的内容比较多,通常会很大;第二个问题是职责的混合导致它很难与已有的系统结合,典型的是我们只需要php-fpm
,nginx
由外部提供,如果打包到一起,nginx
会产生干扰;另一个典型的场景是暂时不需要用到php队列
和cron定时任务
,它们也必须开着;第三个则是日志信息的混乱,nginx
和php-fpm
的日志混在一起无法有效地提取。
后端应用要满足这2个特征,就带来2个直接的问题:
- 后端访问的数据库、队列和存储放哪里?
- nginx、php-fpm、php队列、定时任务怎么编排?
所有后端应用的配置都是比较敏感,比如数据库密码,是不能打包到镜像中的,于是就有了第3个问题:
- 敏感信息怎么保护?
关键问题的分析
后端镜像的打包,重点在于思考清楚这3个问题。下面我们一个个问题的分析。
Q:后端要访问的数据库、缓存、队列和存储放哪里?
A:这三个需要与应用分开单独考虑,在生产环境下,一般使用一台独立的机器,或者直接使用云服务商提供的数据库、缓存等服务。这样不论应用迁移到哪里,都能使用同一份数据库、缓存和存储。
Q:nginx、php-fpm、php队列、php定时任务怎么编排?
A:单一职责
在实践上是有争议的。如果是要求容器只启动一个进程的角度,将nginx
、laravel php-fpm
和laravel队列
和laravel定时任务
独立成4个任务,如果要检查nginx
证书是否过期,还得有certbot
,共5个任务。一个简单的后台应用要有5个任务,听起来头就很大。
另外一种对单一职责
的解释则是从就应用的范围去解释。nginx
和php-fpm
管的东西八竿子打不着,所以肯定是独立的两个镜像容器。但是上面的nginx
和certbot
要合在一起,而laravel php-fpm
和laravel 队列
和laravel 定时任务
也要合在一起,镜像编排时只启动2个容器。
这两种解释目前我们都接受,应用小的时候可以采用后一种方式,应用变大时要过度到第一种方式。
Q:敏感信息怎么保护?
A:通过环境变量的方式注入。云容器有提供配置环境变量的地方。如果自己启动docker-compose
,环境变量可以通过直接读取独立的环境变量文件,或者从vault
等管理密钥的平台读取。
解决这3个问题以后,最后形成的镜像结构就如“简介”中的图所示。
目录结构
脚本文件比较多,并不适合直接放到后端代码里面。要么将后端与脚本独立成2个项目,要么按如下调整项目的总体目录结构:
backend/
- Dockerfile
- scripts/
- start.sh
- crontab
- worker.conf
- build.sh
- push.sh
- ...其他Laravel文件直接忽略
scripts/
- nginx/ # nginx 镜像制作
- Dockerfile
- app.conf
- nginx.conf
- build.sh
- push.sh
- prod/ # 生产环境的镜像编排测试
- docker-compose.yml
- update.sh
- app-backend.env # 不能放git仓库
- prod-local/ # 本地镜像编排测试
- docker-compose.yml
- update.sh
- app-backend.env # 不能放git仓库
步骤
制作php-fpm镜像配置
先直接看Dockerfile
,然后对这个文件做详细解释:
FROM pheye/php-fpm:latest
MAINTAINER LIUWENCAN <phenye@gmail.com>
# 将源码拷到镜像中
COPY . /var/www/backend
# 确保没有将.env打包进去
RUN if [ -e .env ] ; then rm .env; fi
# 启动脚本,除了php-fpm还有一些额外的配置
COPY scripts/start.sh /start.sh
RUN chmod +x /start.sh
# 用于任务调度的任务
COPY scripts/crontab /etc/cron.d/www
# 用于支持worker的启动
ADD ./scripts/worker.conf /etc/supervisor/conf.d/worker.conf
# 修改属主,确保与php-fpm的用户一致
RUN chown -R www /var/www/backend
VOLUME /var/www/backend
CMD ["/start.sh"]
关于这个文件,首先要了解php-fpm
的基础镜像,官方有提供,不过php-fpm
的许多扩展需要自己配置,很容易出现遗漏,性能调优也需要自己配置,cron
和supervisord
等用于支持任务调度和队列的包也需要自己安装,整个配置过程是非常繁琐的,因此这里我使用自己在生产环境验证过的php-fpm
作为基础镜像,有做调整时就更新该包即可
其次是VOLUME /var/www/backend
这一句,非常重要,它会开放一个匿名挂载供nginx
使用,否则nginx
容器里面将会是空的,没有任何应用源码。
最后是,Dockerfile
中出现了start.sh
,worker.conf
、crontab
这3个文件,下面要针对这3个文件做个详细解释。
start.sh
是启动脚本,正常来讲,只需要启动php-fpm
就能工作,但是默认情况没有考虑到迁移文件的需求,该脚本可以做更多必要的工作。
#!/bin/sh
# 用于启动性能采集
# nohup tideways-daemon &
# 执行migration
cd /var/www/backend
php artisan migrate --force
if [ ! -f "public/storage" ] ; then php artisan storage:link; fi
# 下面这2个被注释的命令有助于提高性能,但是可能导致应用不可用,根据需要自己启动
# php artisan optimize
# php artisan api:cache
if [ $? -eq 0 ] ; then
# 启动php-fpm
php-fpm
else
exit 1
fi
worker.conf
是supersivord
的配置文件,用于确保php队列
的可靠启动和对队列的进程数量做精确控制
[program:worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/backend/artisan queue:work --sleep=3 --tries=3 --daemon
user=www
autostart=true
autorestart=true
numprocs=2
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
crontab
用于执行任务调度
* * * * * www php /var/www/backend/artisan schedule:run > /dev/null 2>&1
构建php-fpm镜像
docker build -t app-backend:latest .
制作nginx镜像配置
nginx
的制作与《培训-纯前端项目的Docker镜像打包》几乎一样, 这里就不再赘述。只简单提下不同的几个小点:
Dockerfile
不需要添加任何应用的源码;app.conf
与前端不一样,完整代码见下面;app.conf
中的fastcgi_pass backend:9000;
需要特别注意,backend
是实际启动的php-fpm
的容器名。
Dockerfile
FROM nginx:alpine
MAINTAINER LIUWENCAN <phenye@gmail.com>
RUN adduser -D -H -u 5000 -s /bin/sh www
RUN rm /etc/nginx/conf.d/default.conf
ADD nginx.conf /etc/nginx/
ADD backend.conf /etc/nginx/sites-available/
VOLUME /var/www
CMD ["nginx"]
区别只在于app.conf
的内容不一样:
server {
listen 80;
listen [::]:80;
server_tokens off;
server_name demo-app.store.codefriend.top;
root /var/www/backend/public;
index index.php index.html index.htm;
location ~* .*\.(gif|jpg|jpeg|png|bmp|swf|js|css)$ {
expires 30d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
internal;
try_files $uri /index.php =404;
fastcgi_pass app-backend:9000; # 这个名字需要注意
fastcgi_index index.php;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt/;
log_not_found off;
}
}
构建nginx镜像
docker build -t app-nginx:latest .
本地镜像编排
本地镜像编排docker-comopse.yml
version: '2'
services:
backend:
image: app-backend:latest
env_file: "./app-backend.env"
cron:
image: app-backend:latest
env_file: "./app-backend.env"
command: ['cron', '-f']
worker:
image: app-backend:latest
env_file: "./app-backend.env"
command: ['/usr/bin/supervisord', '-n', '-c', '/etc/supervisor/supervisord.conf']
nginx:
image: app-nginx:latest
volumes_from:
- backend
depends_on:
- backend
ports:
- "8888:80"
镜像编排中有两个重点:
- 出现了
app-backend.env
这个文件,这个文件就是Laravel
的.env
,这些敏感一般放在保密的对象存储上,或者通过云容器的环境配置、或者放在vault
这样的密钥管理里面。 nginx
的volumes_from
来自backend
,这一句非常重要,它确保nginx
可以直接读取静态文件,没有这一句的话,nginx
没有任何应用的文件,读取静态文件时将直接报错。
测试镜像
docker-compose up -d --renew-anon-volumes
--renew-anon-volumes
这个参数用于更新匿名挂载,非常重要。因为nginx
里面本身没源码,源码是跟着php-fpm
,如果没有这个参数,当php-fpm
那边的代码更新以后,nginx
这边仍然是旧代码。
docker-compose ps
看下各个任务是否正常启动,如果已经正常启动。直接访问http://localhost:8888
应该能够正常进入。
如果不能正常进入,结合docker-compose log
,docker-compose top
以及通过docker-compose exec
进到容器内部排查。
进阶配置1-版本控制
前面制作的镜像,版本都是latest
,这种玩法在生产环境是有问题的,要是制作的镜像不能用,一启动起来就是人间惨剧。因此制作的镜像都需要指定版本(一般通过CI/CD自动生成),布署出问题的时候就回滚。下面是怎么方便指定版本的脚本
php-fpm构建支持指定版本的生产镜像
php-fpm
部分,增加backend/scripts/build.sh
:
#!/bin/sh
if [ $# -gt 1 ] ; then
docker build -t app-backend:$1 -t app-backend:latest .
else
docker build -t app-backend:latest .
fi
要制作镜像时就进入backend
目录,指定版本构建,如果不指定,就是latest
版本:
./scripts/build.sh v1.0.0
nginx构建支持指定版本的生产镜像
nginx
部分,与前面的类似,增加scripts/nginx/build.sh
:
#!/bin/sh
if [ $# -gt 1 ] ; then
docker build -t app-nginx:$1 -t app-nginx:latest .
else
docker build -t app-nginx:latest .
fi
要制作镜像时就进入scripts/nginx
目录,指定版本构建,如果不指定,就是latest
版本:
./build.sh v1.0.0
进阶配置2-推送镜像与编排测试
本地构建的镜像在本地测试,只能说它没有问题。但是要实际使用,需要推送到公共的镜像仓库以供其他人获取。https://hub.docker.com/
是官方提供的免费镜像仓库,放公开的镜像是极好的。
公司的应用,一般都要放在私有镜像仓库。私有镜像仓库,国内阿里云,国外AWS都有提供,自己要搭建的话,可以使用harbor
。本文以阿里云作为例子,演示私有镜像仓库的推送和拉取。
php-fpm的推送镜像脚本
创建backend/scripts/push.sh
:
#!/bin/bash
pwd=${ALIYUN_REGISTRY_PASSWORD}
docker login --username=phenye -p $pwd registry.cn-hangzhou.aliyuncs.com
docker tag app-backend:latest registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:latest
docker push registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:latest
if [ $# -gt 0 ] ; then
tag=$1
docker tag app-backend:latest registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:${tag}
docker push registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:${tag}
fi
进入backend
,推送指定版本,不指定则总是推成(latest
):
./scripts/push.sh v1.0.0
nginx的推送镜像脚本
创建scripts/nginx/push.sh
:
#!/bin/sh
pwd=${ALIYUN_REGISTRY_PASSWORD}
docker login --username=phenye -p $pwd registry.cn-hangzhou.aliyuncs.com
docker tag app-nginx:latest registry.cn-hangzhou.aliyuncs.com/phenye/app-nginx:latest
docker push registry.cn-hangzhou.aliyuncs.com/phenye/app-nginx:latest
if [ $# -gt 0 ] ; then
tag=$1
docker tag app-nginx:latest registry.cn-hangzhou.aliyuncs.com/phenye/app-nginx:${tag}
docker push registry.cn-hangzhou.aliyuncs.com/phenye/app-nginx:${tag}
fi
进入scripts/nginx
,推送指定脚本,不指定总是推送latest
:
./push.sh v1.0.0
镜像编排测试
对推送的镜像做编排测试:
version: '2'
services:
backend:
image: registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:latest
env_file: "./baas.env"
cron:
image: registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:latest
env_file: "./baas.env"
command: ['cron', '-f']
worker:
image: registry.cn-hangzhou.aliyuncs.com/phenye/app-backend:latest
env_file: "./baas.env"
command: ['/usr/bin/supervisord', '-n', '-c', '/etc/supervisor/supervisord.conf']
nginx:
image: registry.cn-hangzhou.aliyuncs.com/phenye/baas-nginx:latest
volumes_from:
- backend
depends_on:
- backend
ports:
- "8080:80"
docker-compose up -d --renew-anon-volumes
优化镜像编排的升级
前面的镜像编排都存在一个问题,都是使用latest
版本,指定版本并不方便,因此创建update.sh
脚本,方便快速升级:
#!/bin/bash
# ./update.sh <version>
if [ $# -gt 0 ] ; then
hash=$1
else
hash=latest
fi
echo "version: $hash"
sed -i.bak "/app-backend:/s/\(app-backend:\)\([^\"]*\)/\1${hash}/" docker-compose.yml
sed -i.bak "/app-nginx:/s/\(app-nginx:\)\([^\"]*\)/\1${hash}/" docker-compose.yml
docker-compose pull
# 暂不支持蓝绿布署
docker-compose up -d --renew-anon-volumes
要启动或者更换成某个版本(不指定版本就是latest
):
./udpate.sh v1.0.0
一般来说,通过蓝绿布署做到零秒停机的升级是必要的,但是我们这一节的目标主要是讲镜像打包,而不是讲布署;第二个我们的布署基本推荐在K8S或者云容器上,基于docker-compose
的布署场景变得很少,没有专门写优化的脚本。
附录
Laravel自身的问题
本文谈的镜像打包的问题主要由Docker
的特征导致,但是Laravel
应用本身也有许多问题(其他语言的后台应用也有类似的问题),会使镜像打包变得复杂:
- 升级版本时,数据库迁移怎么做?
- 日志放哪里?
- 性能数据的采集放哪里?
这些问题在这里抛出由读者思考,以后有机会我们专门讲讲,本文镜像打包对其中的一些问题做了处理。