使用Docker和GitLab构建一个CI/CD Pipeline

本文主要讲述了如何在GitLab上使用Docker镜像构建一个CI/CD的Pipeline。

现如今持续集成(CI)和持续交付(CD)大家已经不陌生了,它们是为了辅助你的产品/工程项目能够更快、更容易地运行最新版本。在这篇文章中,我将讲述如何使用Docker镜像和GitLab的CI/CD工具构建一个Pipeline,在一个VPS/KVM Linux服务器上进行部署。

前提要求

  • 对Linux、Docker以及CI/CD有基本的了解。
  • GitLab帐号(免费计划即可)。
  • 一台具备SSH访问权限的Linux服务器(非root用户即可)。我使用的是带有LAMP技术栈的Ubuntu 16.04 LTS系统。
  • 装有SSH和LFTP的轻量级Docker镜像。

在开始之前,你需要确保:

  • 你已经登录GitLab
  • 你是某个project/repository的拥有者
  • 你能够在本地机器通过Git访问这个repo进行pull和push操作

我用的是GitKraken,一个Git GUI工具,能够较为方面的进行Git操作。

关于GitLab的CI/CD

GitLab提供了一种通过Docker和Shared Runners处理CI/CD Pipeline的简单方法。每次运行Pipeline时,GitLab都会创建一个独立的虚拟机并构建一个Docker镜像。Pipeline可以使用YAML配置文件进行配置,一个Pipeline可以有多个job,但如果job太多,Pipeline的运行时间就较长。我们肯定不希望这样,因为使用免费计划,每月最多可以有2000分钟的构建时间。

“GitLab.com上的Shared Runners以自动缩放模式运行,由DigitalOcean提供支持。自动缩放意味着减少启动构建的等待时间,并为每个项目建立隔离虚拟机,从而最大限度地提高安全性。”
——来自GitLab文档中的描述

为GitLab的runner创建SSH密钥

注意:即使你的服务器上已有具备SSH访问方式,还是建议你为CI/CD创建一套新的密钥,同时为部署流程创建一个新的非root用户。

我们将在Docker容器中通过SSH连接我们的服务器,这就意味着我们不能输入用户密码(即非交互式登录),因此我们需要在本地计算机中创建无密码的SSH密钥对。通常我会创建一个2048字节的RSA密钥,因为这足够安全。

$ ssh-keygen rsa -b 2048

输入以上命令,跟随创建步骤,如果对创建步骤有疑问,使用man ssh-key。记住不要为密钥对设置密码。创建完成后,我们需要把私钥导入我们的服务器:

$ ssh-copy-id -i /path/to/key user@host

现在你可以尝试通过以下命令连接:

$ ssh -i /path/to/key user@host

连接过程应该不会让你输入密码。这个私钥我们后面会使用到。

选择Dockerfile

我使用Docker Hub来存放我的定制化Dockerfile,这个Dockerfile将基于Alpine构建一个安装有OpenSSH和LFTP的轻量级镜像(大约8Mb)。在GitLab的CI/CD中我们需要使用这个镜像来运行Pipeline的job和脚本,镜像越轻量意味着下载镜像的时间就越少。你可以用你自己的镜像或者用我的Dockerfile。

Pipleline的配置

在正式构建前,你需要在你repo的根目录创建一个”.gitlab-ci.yml”文件。接下来我将解释我使用的配置文件,如果有兴趣,你可以先到GitLab官网阅读配置文件格式以及所有可以使用的配置项。

我的配置文件如下:

image: jimmyadaro/gitlab-ci-cd:latest
Deploy:
stage: deploy
only:
— ‘master’
when: manual
allow_failure: false
before_script:
#Create .ssh directory
— mkdir -p ~/.ssh
#Save the SSH private key
— echo “$SSH_PRIVATE_KEY” > ~/.ssh/id_rsa
— chmod 700 ~/.ssh
— chmod 600 ~/.ssh/id_rsa
— eval $(ssh-agent -s)
— ssh-add ~/.ssh/id_rsa
script:
#Backup everything in /var/www/html/
— ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa $USERNAME@$HOST “zip -q -r /var/backups/www/01-Deploy-$(date +%F_%H-%M-%S).zip /var/www/html/”
#Deploy new files to /var/www/html
— lftp -d -u $USERNAME, -e ‘set sftp:auto-confirm true; set sftp:connect-program “ssh -a -x -i ~/.ssh/id_rsa”; mirror -Rnev ./ /var/www/html — ignore-time — exclude-glob .git* — exclude .git/; exit’ sftp://$HOST
— rm -f ~/.ssh/id_rsa
— ‘echo Deploy done: $(date “+%F %H:%M:%S”)’

让我们逐行看看配置文件的每一步都在做什么。

image: jimmyadaro/gitlab-ci-cd:latest

这行将告诉runner从Docker Hub上拉取并运行最新版本的容器。你可以在这里设置你想要使用的镜像,但别忘了给镜像安装OpenSSH和LFTP。

Deploy:

这行设置了pipeline的job名字,创建一个job必须设置这行内容。

stage: deploy

这行设置了job的stage名字,如果你需要运行多个stage,例如“backup”、“build”、“deploy”等,stage名字将帮助你识别当前Pipeline处于什么状态。由于我不需要其他stage,所以我只用了一个job,并且这个job只有一个stage。对于job和stage的名字可以任意设置,例如你的job可以叫“ASDF”,stage可以叫“GHJK”,不过如果你有多个stage,你肯定需要鉴别不同的stage,因此我建议还是规范化这些名字。

only:
— ‘master’

这行表示Pipeline只有当你repo的master分支收到一个更新(例如git merge)时才会被触发。因此,我建议开发使用其他分支(例如development、wip等),然后使用master分支作为“产品分支”。

when: manual

这行表示你需要进入你的project的CI/CD配置中手动触发整个部署流程。当然,这一步是可以跳过的,只是我更喜欢手动触发Pipeline。如果去掉这行,你所选分支(本例中为master)的任何改动都会触发一次Pipeline。

allow_failure: false

这行表示如果你的Pipeline中有其他stage,当一个job中发生错误时,不允许继续执行剩余任务。这是一个可选配置。

before_script:
#Create .ssh directory
— mkdir -p ~/.ssh
#Save the SSH private key
— echo “$SSH_PRIVATE_KEY” > ~/.ssh/id_rsa
— chmod 700 ~/.ssh
— chmod 600 ~/.ssh/id_rsa
— eval $(ssh-agent -s)
— ssh-add ~/.ssh/id_rsa

before_script单元设置的所有命令都会在执行主单元(main script)之前执行。如你所见,每行shell命令需要用短横线(“-“)指定。上面的命令将把我们刚刚生成的SSH私钥保存到容器默认的SSH路径下,这样我们就可以免密连接我们的服务器。

刚刚生成的私钥将作为Protected变量保存在我的project的CI/CD配置中,在GitLab的web UI上,点击Settings > CI/CD > Variables将看到这个变量。同样,我将服务器地址和部署使用的用户名(非root用户)也使用Protected变量保存。

未分类

script:
#Backup everything in /var/www/html/
— ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa $USERNAME@$HOST “zip -q -r /var/backups/www/01-Deploy-$(date +%F_%H-%M-%S).zip /var/www/html/”
#Deploy new files to /var/www/html
— lftp -d -u $USERNAME, -e ‘set sftp:auto-confirm true; set sftp:connect-program “ssh -a -x -i ~/.ssh/id_rsa”; mirror -Rnev ./ /var/www/html — ignore-time — exclude-glob .git* — exclude .git/; exit’ sftp://$HOST
— rm -f ~/.ssh/id_rsa
— ‘echo Deploy done: $(date “+%F %H:%M:%S”)’

script下的内容就是GitLab的runner执行的主单元。首先,我会连接到我的服务器将所有内容备份到一个ZIP文件中,这个ZIP文件将使用当前时间(格式为yyyy-mm-dd_hh-mm-ss)进行命名:

— ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa $USERNAME@$HOST “zip -q -r /var/backups/www/01-Deploy-$(date +%F_%H-%M-%S).zip /var/www/html/”

注意:你需要在你的服务器上安装ZIP CLI。

在将/var/www/html备份后,使用LFTP连接到我的服务器并且上传最新的repo文件。这里我用的是SFTP,FTP配置有点不一样:

— lftp -d -u $USERNAME, -e ‘set sftp:auto-confirm true; set sftp:connect-program “ssh -a -x -i ~/.ssh/id_rsa”; mirror -Rnev ./ /var/www/html — ignore-time — exclude-glob .git* — exclude .git/; exit’ sftp://$HOST

使用mirror -Rnev ./ /var/www/html让LFTP上传./(我repo的根目录)下的所有文件到我服务器的/var/www/html路径下。上面部分参数的意思如下:

  • -u设置了我们sftp://$HOSTSSH用户名。
  • -e用于设置执行命令(使用单引号进行配置)。
  • -R用于设置reverse mirror。
  • -n表示只上传新的文件。
  • -e用于删除在我们源中不存在的文件。
  • -v用于配置verbose日志。
  • ignore-time将在决定是否下载时忽略时间。
  • exclude-glob .git*将会排除任何目录中匹配.git*的所有文件(例如.gitignore以及.gitkeep)。你可以在这里设置其他文件匹配方式。
  • exclude .git/这个配置将会保证不上传我们repo中的git文件。
  • exit将会停止LFTP和SSH执行。

注意:所有在我们服务上但是不在我们repository中的文件将被删除,记住上面所述的’源’指的就是我们GitLab的repository。

最终,脚本会在shared runner的容器中删除我们的私钥(这是一个安全措施),并且输出带有当前时间的结束语句。

— rm -f ~/.ssh/id_rsa
— ‘echo Deploy done: $(date “+%F %H:%M:%S”)’

以上部分就是我配置文件的所有内容。在GitLab中一个成功的Pipeline执行流程如下图所示:

未分类

运行Docker镜像

未分类

Pipeline的最终状态

结论

我尝试了一些其他的方式,例如使用rsync替代LFTP、使用多阶段以及缓存依赖(我能够重用SSH密钥)的Jobs、使用Docker的ENTRYPOINT和CMD等等,但我发现上面描述的方式对我来说是最快和最容易的。

Jenkins pipeline 并行执行任务流

笔者在 https://www.cnblogs.com/sparkdev/p/7617765.html 一文中介绍了如何在声明式 pipeline 中执行并行的任务。前一段时间,Jenkins 发布了 1.3 版的声明式 pipeline(declarative pipeline),这个版本继续增强了并行执行任务的能力:并行执行的任务可以是个任务流。官方称这一功能为 “sequential stages”,本文将解释 “sequential stages”,并通过 demo 演示其用法。

之前的任务并行方式

就是笔者在 https://www.cnblogs.com/sparkdev/p/7617765.html 一文中介绍的方式,我们在一个 stage 中设置多个子 stage 并行执行:

stages {
    stage('Stage1') {
        ...
    }
    stage('并行执行的 Stage') {
        parallel {
            stage('Stage2.1') {
                agent { label "test1" }
                steps {
                    echo "在 agent test1 上执行的并行任务 1."
                }
            }
            stage('Stage2.2') {
                agent { label "test2" }
                steps {
                    echo "在 agent test2 上执行的并行任务 2."
                }
            }
        }
    }
    stage('Stage3') {
        ...
    }
}

上面代码中任务的执行过程如下图所示:

未分类

任务 2.1 和任务 2.2 并行执行。

并行执行任务流

过去并行执行的任务都是单个的,但实际情况中我们还需要任务流级别的并行能力,如下图所示:

未分类

上图中显示有两条任务流在并行的执行,我们可以通过下面的代码来实现:

pipeline {
    agent none

    stages {
        stage('Stage1') {
            agent { label "master" }
            steps {
                timestamps {
                    echo '这是第一个被执行的 stage.'
                    sleep 5
                }
            }
        }
        stage("build, deploy and test on Windows and Linux") {
            parallel {
                stage("windows") {
                    agent {
                        label "master"
                    }
                    stages {
                        stage("build") {
                            steps {
                                timestamps {
                                    echo "build on windows."
                                }
                            }
                        }
                        stage("deploy") {
                            steps {
                                timestamps {
                                    echo "deploy on windows."
                                }
                            }
                        }
                        stage("test") {
                            steps {
                                timestamps {
                                    echo "test on windows."
                                    sleep 5
                                }
                            }
                        }
                    }
                }

                stage("linux") {
                    agent {
                        label "worker1"
                    }
                    stages {
                        stage("build") {
                            steps {
                                timestamps {
                                    echo "build on linux."
                                }
                            }
                        }
                        stage("deploy") {
                             steps {
                                 timestamps {
                                     echo "deploy on linux."
                                 }
                            }
                        }
                        stage("test") {
                            steps {
                                timestamps {
                                    echo "test on linux."
                                    sleep 5
                                }
                            }
                        }
                    }
                }
            }
        }
        stage('Stage3') {
            agent { label "worker1" }
            steps {
                timestamps {
                    echo '这是最后一个被执行的 stage.'
                }
            }
        }
    }
}

为了显示任务的执行时间,笔者使用了 timestamper 插件。下图显示了笔者精简后的运行日志:

未分类

红框中的内容说明我们的两个任务流是完全并行执行的。这就是 1.3 版的声明式 pipeline 中增加的 “sequential stages” 功能。

总结

如今 jenkins 对声明式 pipeline 中并行任务的执行支持的非常给力(虽然经历了一个稍显漫长的过程)。笔者在 2017 年初调研时发现声明式 pipeline 无法支持并行的任务,后来开始支持比较初级的并行任务,笔者在 https://www.cnblogs.com/sparkdev/p/7617765.html 一文中进行了介绍。到今年(2018) 7 月份声明式 pipeline 发布了版本 1.3,这个版本中开始支持本文介绍的任务流级别的并行。至此笔者认为 jenkins 声明式 pipeline 中任务的并行执行功能已经比较完善了。