基于Git WebHook+Docker的自动化部署

826 阅读6分钟

前言

很久之前,有个云服务器,上面挂着自己的 Blog,每次发布代码的时候,都需要手动到服务器上 pull 代码,很是麻烦。

于是,折腾了一下 GiteeHook,实现了自动部署的功能。

一些答复

为什么不用比较成熟的工具?比如:Jenkins、CI/CD。

1、资源问题,本身就一台小服务器,没那么多资源搞这个。

2、自己搞个简单的,免费,香(就是搞事情)。

是否会停服?

会有短暂停服,如果不像停服,需要改造一下策略。

是否支持多副本?

不支持,因为作者的需求比较简单,未实现多副本的方案。

环境

  • Docker
  • PHP
  • Nginx(需要支持Lua模块)
  • Lua

示例代码

talk-lucky/git-docker-deploy

具体流程图

image.png

具体实现

项目结构改造

这里的目的是将项目结构都改造成一致,方便后续脚本的统一。

.
├── src // 项目目录
└── version.txt // 项目版本

宿主机安装Git

因为需要拉取云端代码,需要 git

配置自动化项目

这个项目可以放到代码仓库里。

本文将项目放在了 /src 目录下。

具体结构和释义如下:

/src
├── dockers
│   ├── docker-compose.yml // 使用Docker compose编排容器
│   ├── mysql // 存放mysql数据的目录
│   ├── redis // 存放redis数据的目录
│   ├── lua
│   │   └── hook.lua // 这是Nginx接收到webhook之后,执行的lua脚本
│   ├── projects
│   │   ├── api.demo.com
│   │   │   ├── Dockerfile // 生成镜像和容器的Dockerfile
│   │   │   └── publish.sh // 发布脚本,会拉取代码,根据Dockerfile创建镜像和容器
│   │   └── admin.demo.com
│   │       ├── Dockerfile
│   │       └── publish.sh
│   └── nginx
│       ├── nginx.conf // 重写了nginx.conf,用于支持后续的项目配置,这个会在一开始替换 /etc/nginx/nginx.conf
│       ├── webhook.conf // webhook的nginx conf,里面会指定需要执行的lua脚本
│       └── api.demo.com.d
│           └── api.demo.com.conf // api.demo.com的nginx conf
├── api.demo.com // 具体的项目目录
│   ├── src // 代码目录
│   └── version.txt // 项目版本,构建镜像时会使用
└── admin.demo.com // 具体的项目目录
    ├── src // 代码目录
    └── version.txt // 项目版本,构建镜像时会使用

宿主机安装Nginx

因为需要构建容器,所以直接在宿主机上安装了 Nginx,用于转发请求。

需要安装 lua 模块。

安装好之后,将自动化配置中的 nginx.conf 替换默认的 nginx.conf

宿主机安装Docker和Docker compose

这个毋庸置疑了。

安装好之后,在 /src/dockers 目录下,运行 docker compose up -d

配置Gitee

在项目的 管理→WebHooks 下添加WebHook。

image.png

编写Nginx Lua脚本

为了能够适应多个项目,这里会通过路由来辨别不同的项目。

大致结构是:http://webhook.demo.com/项目别名

同时,每个项目都有回调时都有自己的密钥。

所以我们需要先定义项目和域名的映射表,项目和密钥的映射表。

-- 需要修改域名和对应别名
local mapping = {
    api = "api.demo.com",
    admin = "admin.demo.com"
}
-- 每个项目的密钥
local password = {
    api = "E491B741843DA372DE61FCF8EFBB5252",
    admin = "E491B741843DA372DE61FCF8EFBB5252"
}

接下来是判断请求和项目、密钥是否匹配。

可以先参考一下 Gitee 的请求头信息,如果是其它平台,则需要自行研究。

Request URL: <http://webhook.demo.com/api>
Request Method: POST
Content-Type: application/json
User-Agent: git-oschina-hook
X-Gitee-Token: E491B741843DA372DE61FCF8EFBB5252
X-Gitee-Timestamp: 1591321794260
X-Gitee-Ping: false
X-Gitee-Event: Push Hook
X-Git-Oschina-Event: Push Hook

可以看出,需要判断路由对应的项目是否存在,密钥是否存在。

local project = ngx.var.request_uri
project = string.gsub(project, "/", "")
if (not mapping[project]) then
    ngx.say('error project')
    ngx.log(ngx.ERR, 'webhook: error project')
    return 0
end
if (not password[project]) then
    ngx.say('error project')
    ngx.log(ngx.ERR, 'webhook: error project')
    return 0
end

当项目和密钥都存在之后,就需要判断请求体信息是否正确。

  • 是否是 master 分支变更
  • 密钥是否正确

请求体大致结构如下(原文太长了,这里精简了一下):

{
  "ref": "refs/heads/master",
  "before": "5f6477bb191fd11fb98988d96c84c1070a76e771",
  "after": "7826fb922fb2583705441ec6f77d4ccde24b3d14",
  "total_commits_count": 1,
  "commits_more_than_ten": false,
  "created": false,
  "deleted": false,
  "compare": "",
  "commits": [],
  "head_commit": {},
  "repository": {},
  "project": {},
  "user_id": 0,
  "user_name": "",
  "user": {},
  "pusher": {},
  "sender": {},
  "enterprise": null,
  "hook_name": "push_hooks",
  "hook_id": 373558,
  "hook_url": "",
  "password": "E491B741843DA372DE61FCF8EFBB5252",
  "timestamp": "1591321794260",
  "sign": ""
}

从上面的请求体可以看出,只需要判断是否存在 refs/heads/master 和密钥。

具体实现如下:

-- 请求头没问题之后,判断一下请求体是不是master分支
ngx.req.read_body()
local requestBody = ngx.req.get_body_data()
if (not requestBody) then
    ngx.say('error requestBody')
    ngx.log(ngx.ERR, 'webhook: error requestBody')
    return 0
end
if (not string.find(requestBody, 'refs/heads/master')) then
    ngx.say('error branch')
    ngx.log(ngx.ERR, 'webhook: error branch')
    return 0
end
-- 请求体中也有密钥,做第二次校验
if (not string.find(requestBody, password[project])) then
    ngx.say('error password')
    ngx.log(ngx.ERR, 'webhook: error password')
    return 0
end

接下来就是执行 Shell 脚本,创建容器了。

目前,项目都放在了 /src 目录下,同时每个项目都拥有自己的发布脚本,地址是 /src/docker/project/项目/publish.sh

-- 查找项目的版本
-- 目前项目都放在 src 目录下,具体结构如下:
-- /src/域名/项目代码
local filePath = "/src/" .. mapping[project] .. "/version.txt"
local file = io.open(filePath, "r")
local version = "v1"
if (file) then
    io.input(file)
    tmpVersion = io.read()
    if (not tmpVersion) then
        version = tmpVersion
    end
end

-- 执行Shell脚本,创建容器
os.execute("nohup bash /src/docker/project/" .. mapping[project] .. "/publish.sh " .. version .. " > /var/log/nohup/nohup.log 2>&1 &")

最后,给以正确的响应。

ngx.say('success')

具体脚本如下:

-- 需要修改域名和对应别名
local mapping = {
    api = "api.demo.com",
    admin = "admin.demo.com"
}
-- 每个项目的密钥
local password = {
    api = "E491B741843DA372DE61FCF8EFBB5252",
    admin = "E491B741843DA372DE61FCF8EFBB5252"
}

-- 请求的结构是 <http://webhook.demo.com/项目别名>
-- 获取项目别名和对应的密钥,进行匹配
-- 请求头如下:
-- Request URL: <http://webhook.demo.com/api>
-- Request Method: POST
-- Content-Type: application/json
-- User-Agent: git-oschina-hook
-- X-Gitee-Token: E491B741843DA372DE61FCF8EFBB5252
-- X-Gitee-Timestamp: 1591321794260
-- X-Gitee-Ping: false
-- X-Gitee-Event: Push Hook
-- X-Git-Oschina-Event: Push Hook
local project = ngx.var.request_uri
project = string.gsub(project, "/", "")
if (not mapping[project]) then
    ngx.say('error project')
    ngx.log(ngx.ERR, 'webhook: error project')
    return 0
end
if (not password[project]) then
    ngx.say('error project')
    ngx.log(ngx.ERR, 'webhook: error project')
    return 0
end

-- 请求头没问题之后,判断一下请求体是不是master分支
ngx.req.read_body()
local requestBody = ngx.req.get_body_data()
if (not requestBody) then
    ngx.say('error requestBody')
    ngx.log(ngx.ERR, 'webhook: error requestBody')
    return 0
end
if (not string.find(requestBody, 'refs/heads/master')) then
    ngx.say('error branch')
    ngx.log(ngx.ERR, 'webhook: error branch')
    return 0
end
-- 请求体中也有密钥,做第二次校验
if (not string.find(requestBody, password[project])) then
    ngx.say('error password')
    ngx.log(ngx.ERR, 'webhook: error password')
    return 0
end

-- 查找项目的版本
-- 目前项目都放在 src 目录下,具体结构如下:
-- /src/域名/项目代码
local filePath = "/src/" .. mapping[project] .. "/version.txt"
local file = io.open(filePath, "r")
local version = "v1"
if (file) then
    io.input(file)
    tmpVersion = io.read()
    if (not tmpVersion) then
        version = tmpVersion
    end
end

-- 执行Shell脚本,创建容器
os.execute("nohup bash /src/docker/project/" .. mapping[project] .. "/publish.sh " .. version .. " > /var/log/nohup/nohup.log 2>&1 &")

ngx.say('success')

编写Dockerfile

位置:/src/dockers/project/你的项目/Dockerfile

这里会直接将项目的代码拷贝到容器中,并且执行 composer install --no-dev

FROM imjcw/php-fpm:7.3.18-fpm-alpine

COPY src /var/www/html/
WORKDIR /var/www/html

RUN set -e; \
    apk update; \
    apk add git; \
    cd /var/www/html; \
    rm -rf .git; \
    composer install --no-dev; \
    apk del git; \
    rm -rf /var/cache/apk/*

编写publish.sh

这里面指定了容器开放的端口,是预留给 Nginx 使用的。

大致流程是:

  • 进入项目
  • 拉取代码
  • 构建镜像
  • 停止之前的容器
  • 删除之前的容器
  • 构建新的容器
  • 删除不用的容器和镜像
#!/bin/bash

PROJECT="api.demo.com"

VERSION=""
if [ -n "$1" ]; then
    VERSION="$1"
else
    VERSION="v1"
fi

PROJECT_DIR="/src/${PROJECT}"
if [ ! -d "${PROJECT_DIR}" ]; then
    echo "项目目录不存在"
    exit -2
fi

# pull code
cd ${PROJECT_DIR}
git pull origin master

# build image
docker build -t imjcw/${PROJECT}:${VERSION} ./

# stop container
docker stop ${PROJECT}
docker rm ${PROJECT}

# run container
docker run -d --name ${PROJECT} --link mysql --link redis --net dockers_default -p 9000:9000 imjcw/${PROJECT}:${VERSION}

# rm <none> images
if test ! -z $(docker images -f "dangling=true" -q); then
    docker rmi $(docker images -f "dangling=true" -q)
fi

最后

为了能够实现,特地学了一下 LUA,感觉还不错。