Jenkins + Docker 项目持续部署实践

前言

在博客Docker 入门 & CI/CD实践中,提及到了如何搭建Jenkins来进行持续集成和持续部署,并且跑了对大作业的web后端进行了一个简单的demo,使用自动触发抓取GitHub上项目代码进行持续集成测试,通过了mvn clean test的构建测试。本文将进一步通过Jenkins和docker对整个“订你所想”订票系统的Web Service Server、Web Page Server、Database server 等进行持续集成和持续部署。

PS: 本文的自动部署方法可能仍然存在一些问题,因为这系列的docker集群存在着一定的依赖关系,单独自动部署某个容器过程如果某个失败,可能会破坏其他容器的依赖关系,所以后续会再改用docker compose 对docker container集群进行部署的改进,使用docker compose可以实现集群依赖关系的先后部署和统一管理。

GitHub : https://github.com/SevenDwarfs

为什么是 Jenkins

相比于travis-ci,我选择使用自己搭建Jenkins服务的原因有以下几个:

  • 构建速度过慢:我们的项目每次构建时都要重新下载大量的maven插件和npm插件,尤其是下载maven插件的时间需要花费很多时间。
  • 持续部署存在困难:使用travis-ci之后,无法直接在宿主机上进行docker image的构建和容器的运行,只能将构建好的image发送到远程服务器如阿里云或者Docker Hub或者进行远程构建,需要部署的时候在重新docker pull下来。而一个image通常比较大,会花费大量时间。

以上原因主要都是时间花费过多,这就给开发过程带来了一定的困扰,可能一个项目得很久才能看到部署的结果,不利于开发的进行。所以我再搭建自己的jenkins服务器,对一些耗时的maven插件进行缓存,避免重复下载浪费时间,但是搭建自己的jenkins服务器也有几点是需要重新配置的。主要原因就是网络环境问题,国内使用国外maven、npm、docker的源速度太慢,而且经常下载失败,所以需要重新对这些源进行配置,经过重新配置之后,每个项目基本都能在五分钟之内完成构建或者部署。

但是如果是只要进行持续测试的话,travis-ci还是来的比较方便的,所以项目既保留了travis-ci的持续测试,也使用了jenkins进行持续部署。构建通过还有会有个building pass的图表,项目后端travis-ci地址: https://travis-ci.org/SevenDwarfs/WebService

使用国内镜像加速

国内使用国外maven、docker、npm源进行下载,会出现速度过慢和下载失败问题,下面是一些参考文章,可以用来修改相应的源。

建议:在使用docker之前改用阿里云的docker镜像加速器、在使用maven构建之前改用阿里云maven镜像、在使用npm之前改用淘宝的npm源镜像。下面文章的实践过程,默认已经改用了这些源来加速,便不再做具体的说明。有的可能是直接在宿主机上进行修改,有的在docker image构建的Dockerfile里就进行修改,根据具体需求来实现。

Jenkins 安装Tips

参考博客Docker 入门 & CI/CD实践,假设已经搭建好了jenkins的docker容器,但是在我们要进行持续部署之前还要进行一些调整。jenkins官方所提供的docker容器是不包含maven、npm等环境的,所以我们有两种解决方法,一是使用安装了这些环境的jenkins并且与宿主机使用volume进行maven安装位置的映射(用于保存插件缓存),还有docker.sock的映射(用于在宿主机上执行docker命令);二是直接将jenkins安装在宿主机上,并且给宿主机配置好相关环境。

这里我就接着上一次的内容,仍然是使用docker来安装jenkins,并且进行一些调整。这里使用了lw96/java8-jenkins-maven-git-vim镜像。该镜像带有maven、docker等环境。可以在其docker hub页面相关环境变量的位置。

1
docker run -d -p 0.0.0.0:8080:8080 -v /root/data:/jenkins \
2
-v /etc/localtime:/etc/localtime:ro \
3
-v /root/data/maven:/opt/maven \
4
-v /var/run/docker.sock:/var/run/docker.sock \
5
--name jenkins lw96/java8-jenkins-maven-git-vim
  • /var/run/docker.sock : 将宿主机的docker.sock映射到容器中,docker.sock是unix的socket通讯方式,可以将jenkins的docker命令传送到宿主机中进行执行。参考StackOverflow
  • /root/data:/jenkins : 与宿主机共享jenkins的工作目录,方便容器的迁移,保存之前jenkins所存留的数据
  • /opt/maven : 映射宿主机与jenkins的maven安装位置,使得jenkins可以使用maven插件的缓存

持续部署项目实践

GitHub 地址

GitHub 项目地址: https://github.com/SevenDwarfs

*GitHub 部署说明: *https://github.com/SevenDwarfs/Deployment

该项目主要使用Nginx作为静态资源服务器以及进行反向代理,使用docker来模拟节点运行。静态网页资源的npm build,web service server、database server都是运行在单独的docker容器上。其中Nginx直接运行在宿主机上,为了方便宿主机上一些docker的端口服务进行通讯,且考虑到Nginx配置一次即可,未再运行docker来持续部署Nginx,其他服务仅将端口暴露在内部或者宿主机的localhost上,避免外部直接访问端口,提升安全性。

节点配置

  • 阿里云华南节点:
    • CPU核数:1
    • 内存大小:1GB
    • 带宽:1Mbps

项目部署图

项目部署图

Demo

Demo Website: http://aliyun.kinpzz.com:8000

持续部署 (CD)

本文利用Jenkins与Docker实现项目的持续部署,通过Jenkins GitHub Plugin来实现GitHub hook,当GitHub的项目代码进行了更新或者合并,都会触发Jenkins执行指定的命令进行自动化的构建。然后通过Jenkins与宿主机的docker进行通讯,将构建好的项目通过docker镜像打包,再通过容器运行。一些容器之间存在着父子相应的依赖关系,本文尚未采用docker compose来支持这种依赖关系的先后构建顺序,而是采用当父容器发生改变之后,对依赖该父容器的子容器也进行重新构建、运行的方法来实现依赖关系,在后续的过程中可能会改用docker compose。例如该项目中的Database Server与Web Service Server就存在相应的依赖关系,后面会具体阐述做法,其中网页服务需要依赖数据库服务。

Web Page (Dockerfile)

使用下面的Dockerfile进行构建,主要就是执行从node环境进行npm包的安装和使用npm run build进行静态资源的生成。

1
FROM node:6.10.3
2
3
WORKDIR /web-server
4
5
COPY . .
6
7
RUN npm config set registry https://registry.npm.taobao.org \
8
    && npm config get registry
9
RUN npm install \
10
    && npm run build
11
12
CMD ["tail", "-f", "/var/log/faillog"]

在jenkins中执行下列shell指定,主要就是构建web page的镜像和运行容器,并且把镜像中生成的静态资源使用docker cp拷贝到jenkins的工作目录下,以便后面Nginx进行访问。

1
# for jenkins run shell
2
docker stop web-server
3
docker rm web-server
4
docker rmi web-server
5
docker build -t web-server .
6
docker run -d --name web-server web-server
7
# save to /home/kinpzz/webpage
8
docker cp web-server:/web-server/dist /jenkins/webpage

Nginx (Nginx.conf)

这里Nginx,我选择直接安装在宿主机上,而不是构建成docker镜像。主要是出于以下两个原因。

  • Nginx 需要进行反向代理,连接到只暴露到localhost的web service server端口。
  • Nginx 需要访问拷贝到宿主机上的静态资源。
  • Nginx 只需要修改一个配置文件,变动不大。

出于以上原因,若Nginx需要在docker的镜像里,则需要与Web Page构建的docker在同一个容器中,才能访问到相应的静态资源,且要访问web service server需要进行docker的link,加大了依赖关系。且Nginx配置文件变化不大,所以我就选择了直接简单地配置在宿主机上。

下面是对Nginx.conf主要进行的修改,在http标签下添加了:

1
...
2
server {
3
        listen 8000;
4
        server_name aliyun.kinpzz.com:8000;
5
        charset utf-8;
6
        # 反向代理RESTful server
7
        location /api/ {
8
                proxy_pass http://127.0.0.1:8082;
9
        }
10
        # 获取静态资源
11
        location / {
12
                # 静态资源位置
13
                root /root/data/webpage/dist;
14
                index index.html;
15
        }
16
}
17
...

这里让Nginx负责监听8000端口,由于服务器没有备案,所以无法使用域名加80端口进行访问,但是效果是一样的。以/api开头的请求就转送到宿主机的8082端口,由web service server来进行处理,其他静态资源则有Nginx服务器直接返回。

PS: 这里要注意的是Nginx用户 www-data(配置文件开头所设置),对一些root文件夹是没有访问权限的,需要手动使用chmod命令进行权限的授予,也可以选择放在一个普通权限用户可以访问的目录,由于静态资源在上一步中存储在了jenkins的工作目录下,所以这里我就需要手动的授予读权限,注意每层目录都要授予相应读权限才可以,否则进不了当前一层的目录仍是访问不了下一层的目录的。当然也可以将用户修改成有root权限的用户,出于安全因素,这种做法通常不推荐。

Database Server (Dockerfile)

使用下面的Dockerfile来进行数据库的构建,主要是在mysql的环境下创建一个数据库用户并授予相关权限、数据库以及指定该数据库的source。

1
FROM mysql:5.7
2
3
WORKDIR /db-server
4
5
COPY ./sql/init_data.sql .
6
COPY ./init.sh .
7
8
CMD ["sh", "init.sh"]

下列的init.sh就是实现数据库相关操作的过程,具体操作可以参考我的另外一篇博文介绍: 连接mysql远程服务器。其中,mysql的官方docker镜像已经将bind-address=127.0.0.1给注释掉允许外网进行访问了,所以这里可以不用再进行另外进行操作了。注意的一点是,docker容器如果没有在前台执行的任务是会自动结束容器运行的,所以数据库的服务器只在后台运行是不行的,这里再用了tail在前台不断输出mysql的错误日志,使得docker容器能够一直执行。

init.sh

1
service mysql start
2
mysql << EOF
3
CREATE DATABASE movie;
4
use movie
5
source init_data.sql
6
CREATE USER 'movie_database'@'%' IDENTIFIED BY 'movie_database';
7
GRANT ALL ON movie.* TO 'movie_database'@'%';
8
EOF
9
service mysql restart
10
tail -f /var/log/mysql/error.log

在Jenkins执行下列的run.sh脚本来构建docker镜像以及运行容器。这里要注意的是,web service server是依赖于database server的,所以重新构建database server之前需要停止web service server,构建完之后也要重新构建web service server的镜像再运行。因为通过docker --link的标签会产生一个依赖于database server的容器,database server的镜像发生了改变,自然web service server也要依赖于新的镜像进行新的容器的运行了。

1
docker stop restful-server
2
docker stop db
3
docker rm db
4
docker rmi db-server
5
docker build -t db-server .
6
docker run -d --name db db-server
7
8
docker rm restful-server
9
docker rmi kinpzz/restful-server
10
cd ../WebService
11
git pull origin
12
docker build -t kinpzz/restful-server .
13
docker run -d -p 127.0.0.1:8082:8082 --name restful-server --link db:db-server kinpzz/restful-server

** PS: 使用link的作用: **

link 是在两个contain之间建立一种父子关系,父container中的web,可以得到子container db上的信息。
通过link的方式创建容器,我们可以使用被Link容器的别名进行访问,而不是通过IP,解除了对IP的依赖。

所以我们后端服务器在连接数据库的时候就不需要通过ip地址,通过db这个别名即可。实现机理就关系到了docker的网络架构是通过linux的bridge来实现的,能够获取到兄弟节点的ip就可以相互访问,而link通过将ip与别名写入了/etc/hosts来解除对ip的依赖。

参考:

Web Service Server (Dockerfile)

这里Dockerfile主要执行的就是运行好jenkins打包好的war包即可运行好服务器。

1
FROM java:8
2
VOLUME /tmp
3
COPY target/movie-booking.war .
4
CMD ["java", "-jar", "movie-booking.war"]

在jenkins中执行run.sh, 这里直接在jenkins环境下进行mvn package的命令将maven项目进行打包成可以执行的war包。之所以没有选择在docker中进行包的构建,是为了缓存一些使用过的mvn插件,来减少因重复下载包所花费的时间。

1
export MAVEN_HOME=/opt/maven
2
export JAVA_HOME=/opt/java/jdk1.8.0_112
3
export JENKINS_HOME=/jenkins
4
5
mvn package
6
docker stop restful-server
7
docker rm restful-server
8
docker rmi kinpzz/restful-server
9
docker build -t kinpzz/restful-server .
10
docker run -d -p 127.0.0.1:8082:8082 --name restful-server --link db:db-server kinpzz/restful-server

数据库连接

这里使用了docker -link来进行两个容器的连接,原本想把数据库容器的端口暴露在127.0.0.1:3306,但是暴露在宿主机的127.0.0.1上之后,这里的web service server仍然是无法访问到的(因为docker的网络是隔离的),所以这里就使用了docker -link这个操作,可以让docker之间通过bridge的方式进行连接。更多关于docker link的知识可以参考: Docker 中如何连接多个 Container 协同工作

通过bash查看相连的数据库docker容器的ip

1
$ docker exec -it restful-server bash
  • -i: 指的是interactive 交互式
  • -t: 指的是terminal 终端形式
  • restful-server: 指的是所指定的容器的名字
  • bash: 在这个容器中执行 bash指令

我们通过查看/etc/host文件来查看数据库的ip为192.168.0.3,在bash中执行ping也是可以ping通的

所以在web service server生产环境配置application-prod.properties,我们将JDBC数据库连接的地址设置为192.168.0.3:3306。这样子就可以避免直接向外部暴露数据库的ip地址了,也只有通过docker连接的方式才能访问到我们的数据库服务器。

1
...
2
spring.datasource.jdbcUrl=jdbc:mysql://192.168.0.3:3306/movie?useSSL=false\
3
  &serverTimezone=UTC\
4
  &useUnicode=true\
5
  &characterEncoding=utf8&autoReconnect=true\
6
  &failOverReadOnly=false
7
...

Jenkins 配置

在配置好上述的Dockerfile和jenkins下run的shell命令之后,还要配置好jenkins中的项目。写好了jenkins应该执行的指令之后,配置就比较简单的了。

首先要先安装好jenkins的GitHub plugin,如何安装插件上一篇博文Docker 入门 & CI/CD实践已经介绍过了,具体可以再看看如何操作。主要步骤为:

  1. 对每个仓库的新建一个风格自由的jenkins项目
  2. 设置好Github项目地址,相关访问权限
  3. 设置好GitHub项目上的jenkins地址、还有jenkins构建触发器
  4. 在构建中执行run.sh

总结

缺点与改进

配置好持续部署方案之后,极大地提高了开发到部署的进程,有新版本的代码更新之后,马上就可以在线上看到部署的结果了。但是同时也存在着一些缺点:

  • docker之间的依赖关系实现的不是很好,(待改进:)可以改进使用docker compose来支持docker集群之间的以来关系。但是有时候删除原有容器的时候偶尔会发生错误,只能手动进行删除,这点需要改进。看到了一套博客的CD方案也可以参考一下:binsite 3.2

  • 如果开发人员提交了一个错误版本的代码,那么可能会使得整个部署系统崩溃,所以这一套自动部署方案应该还是比较适合作为一个模拟实际的测试环境,要正式上线的项目应该经过更严格的审核和测试才能进行上线。

体会与收获

实践过程中踩了许多坑,主要还是对docker了解不够充分,比如Dockerfile中WORKDIR的作用,每一条RUN都是独立的环境下执行的,如果要更换目录应该采用WORKDIR语句(若没有该文件夹会自动创建并进入)。使用cd命令来切换工作目录是无效的,因为每一条CMD和RUN语句的执行环境都是独立的,这是很多新手容易犯的一个错误。体会到了老师所说的“在云时代,部署也是一种编程”,更进一步了解了docker技术,感受到了它的强大和魅力,也感觉到还有许多可以学习的地方,相信在将来还会有许多业务场景会使用上这一项技术,也将做进一步的学习、记录和分享!

参考

参考中有前面关于docker相关的命令解释和概念,docker的开发者应该都要熟悉的。

您的支持将鼓励我继续创作!