对读者的要求

  • 后端开发基础知识
  • 掌握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不关心它们的来源:

compose docker 官方镜像 docker compose打包镜像_compose docker 官方镜像

下面我们一步步了解,为什么会形成这样的图,以及怎么打包以满足图中所示的架构。

概念

在谈论后端的镜像打包和镜像测试之前,首先要认识到Docker要用得好,需要满足两大特征:

  1. 无状态
  2. 单一职责

无状态是指容器不会保存任何东西,不论往容器里面写入任何东西,再次执行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-fpmnginx镜像只启动nginx。要做到将nginxphp-fpm都在同一个里容器里启动当然是可以的,但是它带来的代价往往比比好处更大。越多不同职责的功能塞到同一个容器里,代价就越大。
Laravel工程为例,有4个不同职责的进程:nginxphp-fpmphp队列cron定时任务。混在一起以后,第一个问题是:任何一块有更新,都要重新打包这个镜像,而通常这个镜像里面的内容比较多,通常会很大;第二个问题是职责的混合导致它很难与已有的系统结合,典型的是我们只需要php-fpmnginx由外部提供,如果打包到一起,nginx会产生干扰;另一个典型的场景是暂时不需要用到php队列cron定时任务,它们也必须开着;第三个则是日志信息的混乱,nginxphp-fpm的日志混在一起无法有效地提取。

后端应用要满足这2个特征,就带来2个直接的问题:

  • 后端访问的数据库、队列和存储放哪里?
  • nginx、php-fpm、php队列、定时任务怎么编排?

所有后端应用的配置都是比较敏感,比如数据库密码,是不能打包到镜像中的,于是就有了第3个问题:

  • 敏感信息怎么保护?

关键问题的分析

后端镜像的打包,重点在于思考清楚这3个问题。下面我们一个个问题的分析。

Q:后端要访问的数据库、缓存、队列和存储放哪里?
A:这三个需要与应用分开单独考虑,在生产环境下,一般使用一台独立的机器,或者直接使用云服务商提供的数据库、缓存等服务。这样不论应用迁移到哪里,都能使用同一份数据库、缓存和存储。

Q:nginx、php-fpm、php队列、php定时任务怎么编排?
A:单一职责在实践上是有争议的。如果是要求容器只启动一个进程的角度,将nginxlaravel php-fpmlaravel队列laravel定时任务独立成4个任务,如果要检查nginx证书是否过期,还得有certbot,共5个任务。一个简单的后台应用要有5个任务,听起来头就很大。

另外一种对单一职责的解释则是从就应用的范围去解释。nginxphp-fpm管的东西八竿子打不着,所以肯定是独立的两个镜像容器。但是上面的nginxcertbot要合在一起,而laravel php-fpmlaravel 队列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的许多扩展需要自己配置,很容易出现遗漏,性能调优也需要自己配置,cronsupervisord等用于支持任务调度和队列的包也需要自己安装,整个配置过程是非常繁琐的,因此这里我使用自己在生产环境验证过的php-fpm作为基础镜像,有做调整时就更新该包即可

其次是VOLUME /var/www/backend这一句,非常重要,它会开放一个匿名挂载供nginx使用,否则nginx容器里面将会是空的,没有任何应用源码。

最后是,Dockerfile中出现了start.shworker.confcrontab这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.confsupersivord的配置文件,用于确保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镜像打包》几乎一样, 这里就不再赘述。只简单提下不同的几个小点:

  1. Dockerfile不需要添加任何应用的源码;
  2. app.conf与前端不一样,完整代码见下面;
  3. 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"

镜像编排中有两个重点:

  1. 出现了app-backend.env这个文件,这个文件就是Laravel.env,这些敏感一般放在保密的对象存储上,或者通过云容器的环境配置、或者放在vault这样的密钥管理里面。
  2. nginxvolumes_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 logdocker-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应用本身也有许多问题(其他语言的后台应用也有类似的问题),会使镜像打包变得复杂:

  • 升级版本时,数据库迁移怎么做?
  • 日志放哪里?
  • 性能数据的采集放哪里?

这些问题在这里抛出由读者思考,以后有机会我们专门讲讲,本文镜像打包对其中的一些问题做了处理。