天天看点

用Docker部署一个Web应用

本文将以个人(开发)的角度,讲述如何使用Docker技术在线上单机模式下部署一个Web应用,如有错误欢迎指出。

上次在这篇文章提到了Docker,这次打算把这个坑展开来讲。

首先,什么是Docker?根据官网描述,我们可以得知,Docker是一个软件/容器平台,使用了虚拟化技术(cgroups,namespaces)来实现操作系统的资源隔离和限制,对于开发人员来说,容器技术为应用的部署提供了沙盒环境,我们可以在独立的容器运行和管理应用程序进程,Docker提供的抽象层使得开发人员之间可以保持开发环境相对的一致,避免了冲突。

下面体验下Docker的使用:

使用下面的shell命令安装Docker

安装成功后,使用下面的命令应该能显示Docker的版本信息,说明Docker已经被安装了

接着我们使用Docker创建一个nginx的容器:

这条命令表示Docker基于nginx:alpine这个Docker镜像,创建一个名称为web的容器,并把容器内部的80端口与宿主机上的80端口做映射,使得通过宿主机80端口的流量转发到容器内部的80端口上。

使用docker ps命令,可以列出正在运行的容器,可以看到,刚才基于nginx镜像创建的容器已经处于运行状态了:

现在访问宿主机地址的80端口,看到nginx的欢迎页面。

用Docker部署一个Web应用

Docker容器本质上是一个运行的进程以及它需要的一些依赖,而Docker镜像则是定义这个容器的一个"模版"。

使用docker images能看到目前的镜像:

了解到这个事实之后,我们使用下面的命令进入刚才创建的容器内部

现在处于的是容器内部的根文件系统(rootfs),它跟宿主机以及其他容器的环境是隔离开的,看起来这个容器就是一个独立的操作系统环境一样。使用ps命令可以看到容器内正在运行的进程:

使用exit命令可以从容器中退出,回到宿主机的环境:

使用docker inspect命令我们可以看到关于这个容器的更多详细信息:

结果是用json格式表示的容器相关信息,拉到下面的Networks一列可以看到这个容器的网络环境信息:

内容显示了这个容器使用了bridge桥接的方式通信,它是docker容器默认使用的网络驱动(使用docker network ls可以看到所有的驱动),从上面可以看到这个容器的IP地址为172.17.0.2,网关地址为172.17.0.1。

现在回想刚才的例子,访问宿主机的80端口,宿主机是怎么跟容器打交道,实现转发通信的呢?

要解决这个问题,我们首先要知道,docker在启动的时候会在宿主机上创建一块名为docker0的网卡,可以用ifconfig查看:

这个网卡的ip地址为172.17.0.1,看到这里你是否想起了刚才我们创建的容器使用的网关地址即为172.17.0.1?我们是否可以大胆地猜测,docker容器就是通过这张名为docker0的网卡进行通信呢?确实如此,以单机环境为例,Docker Daemon启动时会创建一块名为docker0的虚拟网卡,在Docker初始化时系统会分配一个IP地址绑定在这个网卡上,docker0的角色就是一个宿主机与容器间的网桥,作为一个二层交换机,负责数据包的转发。当使用docker创建一个容器时,如果使用了bridge模式,docker会创建一个vet对,一端绑定到docker0上,而另一端则作为容器的eth0虚拟网卡。

使用ifconfig也可以看到这个veth对的存在:

我找了一张图,可以很好地表示veth对的存在方式:

用Docker部署一个Web应用

而真正实现端口转发的魔法的是nat规则。如果容器使用-p指定映射的端口时,docker会通过iptables创建一条nat规则,把宿主机打到映射端口的数据包通过转发到docker0的网关,docker0再通过广播找到对应ip的目标容器,把数据包转发到容器的端口上。反过来,如果docker要跟外部的网络进行通信,也是通过docker0和iptables的nat进行转发,再由宿主机的物理网卡进行处理,使得外部可以不知道容器的存在。

使用iptables -t nat命令可以看到添加的nat规则:

从上面的最后一行可以观察到流量转发到了172.17.0.2的80端口上,这个地址就是刚才创建容器使用的IP地址。

现在知道在刚才的例子中宿主机是怎么跟容器通信了吧,那么容器跟容器之间通信呢?类似地,也是通过这个docker0交换机进行广播和转发。

扯的有点多,开始进入正题,先写一个Web应用压压惊。

一般情况下,如果你要编写一个Web项目,你会做什么呢?反正对于我来说,如果我要写一个python web项目的话,我会先用virtualenv建立一个隔离环境,进入环境内,使用pip安装Django,最后用django-admin startproject创建一个项目,搞定。

但是如果用容器化的方式思考,我们大可直接借助于容器的隔离性优势,更好地控制环境和版本的隔离,通常情况下你都不需要再关心用pyenv,virtualenv这种方式来初始化python环境的了,一切交给docker来完成吧。

甚至把安装django这个步骤也省了,直接通过一句命令来拉取一个安装了django的Python环境的镜像。

现在通过这个镜像运行django容器,同时进入容器Shell环境:

在/usr/src这个目录下新建一个app目录,然后用django-admin命令新建一个django项目:

然后使用下面的命令,在容器8000端口上运行这个应用:

由于之前已经将容器的8000端口与宿主机的8080端口做了映射,因此我们可以通过访问宿主机的8080端口访问这个应用。

注意了,对这个容器的所有修改仅仅只对这个容器有效,不会影响到镜像和基于镜像创建的其他容器,当这个容器被销毁之后,所做的修改也就随之销毁。

下面新建一个应用ping,作用是统计该应用的访问次数,每次访问页面将次数累加1,返回响应次数给前端页面,并把访问次数存到数据库中。

使用redis作为ping的数据库,与之前类似,拉取redis的镜像,运行容器。

由于django容器需要与redis容器通信的话首先要知道它的ip地址,但是像刚才那样,每次都手工获取容器的ip地址显然是一件繁琐的事情,于是我们需要修改容器的启动方式,加入—link参数,建立django容器与redis容器之间的联系。

删除掉之前的容器,现在重新修改django容器的启动方式:

这次加入了两个参数:

-v /code:/usr/src/app 表示把宿主机上的/code目录挂载到容器内的/usr/src/app目录,可以通过直接管理宿主机上的挂载目录来管理容器内部的挂载目录。

--link=redis:db 表示把redis容器以db别名与该容器建立关系,在该容器内以db作为主机名表示了redis容器的主机地址。

现在进入到django容器,通过ping命令确认django容器能访问到redis容器:

像之前一样,建立一个项目,接着使用django-admin新建一个应用:

编写ping的视图,添加到项目的urls.py:

别忘了安装redis的python驱动:

运行django应用,访问应用的根地址,如无意外便能看到随着页面刷新累加的数字。

你或许会想,每次创建一个容器都要手工做这么多操作,好麻烦,有没有更方便的方式地来构建容器,不需要做那么多额外的环境和依赖安装呢?

仔细一想,其实我们创建的容器都是建立在基础镜像上的,那么有没有办法,把修改好的容器作为基础镜像,以后需要创建容器的时候都使用这个新的镜像呢?当然可以,使用docker commit [CONTAINER]的方式可以将改动的容器导出为一个Docker镜像。

当然,更灵活的方式是编写一个Dockerfile来构建镜像,正如Docker镜像是定义Docker容器的模版,Dockerfile则是定义Docker镜像的文件。下面我们来编写一个Dockerfile,以定义出刚才我们进行改动后的容器导出的镜像。

下面加入supervisor和gunicorn以更好地监控和部署应用进程:

gunicorn的配置文件:

supervisord的配置文件:

以supervisord作为web应用容器的启动进程,supervisord来管理gunicorn的进程。这里说明一下的是,由于使用docker logs命令来打印容器的日志时默认是从启动进程(supervisord)的stdout和stderr里收集的,而gunicorn又作为supervisord的派生进程存在,因此要正确配置gunicorn和supervisord的日志选项,才能从docker logs中看到有用的信息。

把上面所做的修改混杂在一起,终于得出了第一个Dockerfile:

上面的Dockerfile的说明如下:

FROM指令制定了该镜像的基础镜像为django:latest。

三行COPY指令分别将宿主机的代码文件和配置文件复制到容器环境的对应位置。

接着两行RUN指令,一条指令安装supervisor,另一条指令安装python的依赖以及初始化django应用。

最后运行supervisord,配置为刚才复制的supervisor的配置文件。

上面每一条指令都会由docker容器执行然后提交为一个镜像,叠在原来的镜像层的上方,最后得到一个拥有许多镜像层叠加的最终镜像。

完成Dockerfile的编写后,只需要用docker build命令就能构建出一个新的镜像:

接着就可以根据这个镜像来创建和运行容器了:

目前为止,项目的应用结构图如下:

用Docker部署一个Web应用

现在,如果Redis这个节点出现故障的话会怎么样?

答案是,整个服务都会不可用了,更糟糕的是,数据备份和恢复同步成为了更棘手的问题。

很明显,我们不能只依赖一个节点,还要通过建立主从节点防止数据的丢失。再创建两个redis容器,通过slaveof指令为Redis建立两个副本。

现在写入到Redis主节点的数据都会在从节点上备份一份数据。

用Docker部署一个Web应用

现在看起来好多了,然而当Redis master挂掉之后,服务仍然会变的不可用,所以当master宕机时还需要通过选举的方式把新的master节点推上去(故障迁移),Redis Sentinel正是一个合适的方式,我们建立Sentinel集群来监控Redis master节点,当master节点不可用了,再由Sentinel集群根据投票选举出slave节点作为新的master。

下面为Sentinel编写Dockerfile,在redis镜像的基础上作改动:

Sentinel的配置文件:

run-sentinel.sh:

构建出Sentinel的镜像文件,容器运行的方式类似于redis:

这下Sentinel的容器也搭建起来了,应用的结构图如下:

用Docker部署一个Web应用

简单验证一下当redis主节点挂掉后sentinel怎么处理:

修改代码用Sentinel获取redis实例:

下面再来考虑这种情况:

假设我们对django_app容器进行伸缩,扩展出三个一模一样的django应用容器,这时候怎么办,该访问哪个?显然,这时候需要一个负载均衡的工具作为web应用的前端,做反向代理。

nginx是一个非常流行的web服务器,用它完成这个当然没问题,这里不说了。

下面说一说个人尝试过的两种选择:

LVS(Linux Virtual Server)作为最外层的服务,负责对系统到来的请求做负载均衡,转发到后端的服务器(Real Server)上,DR(Direct Route)算法是指对请求报文的数据链路层进行修改mac地址的方式,转发到后端的一台服务器上,后端的服务器集群只需要配置和负载均衡服务器一样的虚拟IP(VIP),请求就会落到对应mac地址的服务器上,跟NAT模式相比,DR模式不需要修改目的IP地址,因此在返回响应时,服务器可以直接将报文发送给客户端,而无须转发回负载均衡服务器,因此这种模式也叫做三角传输模式。

用Docker部署一个Web应用

Haproxy是一个基于TCP/HTTP的负载均衡工具,在负载均衡上有许多精细的控制。下面简单地使用Haproxy来完成上面的负载均衡和转发。

首先把haproxy的官方镜像下载下来:

这类的镜像的Dockerfile都可以在Docker Hub上找到。

这次同样选择编写Dockerfile的方式构建自定的haproxy镜像:

暂时只需要把配置文件复制到配置目录就可以了,因为通过看haproxy的Dockerfile可以看到最后有这么一行,于是乎偷个懒~

haproxy的配置文件如下:

这里的app即web应用容器的主机名,运行haproxy容器时用link连接三个web应用容器,绑定到宿主机的80端口。

这时候访问宿主机的80端口后,haproxy就会接管请求,用roundrobin方式轮询代理到后端的三个容器上,实现健康检测和负载均衡。

用Docker部署一个Web应用

现在又有一个问题了,每次我们想增加或者减少web应用的数量时,都要修改haproxy的配置并重启haproxy,十分的不方便。

理想的方式是haproxy能自动检测到后端服务器的运行状况并相应调整配置,好在这种方式不难,我们可以使用etcd作为后端服务器的服务发现工具,把买二手QQ地图服务器的信息写入到etcd的数据库中,再由confd来间隔一段时间去访问etcd的api,将服务器的信息写入到模版配置中,并更新haproxy的文件以及重启haproxy进程。

按官方的说法,etcd是一个可靠的分布式的KV存储系统,而confd则是使用模版和数据管理应用配置的一个工具,关于他俩我还没太多了解,所以不多说,下面把他们集成到上面的应用中。

创建一个etcd的容器:

confd的处理比较简单,把confd的二进制文件和配置文件集成到之前haproxy的Dockerfile中:

通过之前haproxy的配置文件创建出新的模版文件,修改backend的配置,加入模版指令,表示confd从etcd的前缀为/app/servers的所有key中获取键值对,作为server的key的value,逐条追加到配置文件中去:

下面是confd的配置文件:

confd会把数据填入上面的模版文件,并把配置更新到haproxy配置的目标路径,再使用reload_cmd指定的命令重启haproxy。

修改后的haproxy镜像最后通过boot.sh启动进程:

watcher.sh启动了confd间隔一段时间去访问etcd的地址,检查是否有更新:

启动haproxy时建立与etcd容器间的连接:

下面通过调用etcd的api在/app/servers上新建一个服务器节点:

观察haproxy容器的日志,可以看到配置被更新了:

最终的应用结构图如下:

用Docker部署一个Web应用

运行在机器上的服务时刻有可能有意外发生,因此我们需要一个服务来监控机器的运行情况和容器的资源占用。netdata是服务器的一个实时监测工具,利用它可以直观简洁地了解到服务器的运行情况。

用Docker部署一个Web应用

当docker镜像和容器数量增多的情况下,手工去运行和定义docker容器以及其相关依赖无疑是非常繁琐和易错的工作。Docker Compose是由Docker官方提供的一个容器编排和部署工具,我们只需要定义好docker容器的配置文件,用compose的一条命令即可自动分析出容器的启动顺序和依赖,快速的部署和启动容器。

下面编写好compose的文件:

继续阅读