709 lines
31 KiB
Markdown
709 lines
31 KiB
Markdown
|
# 28|SSH:如何生成发布系统让框架发布自动化?
|
|||
|
|
|||
|
你好,我是轩脉刃。
|
|||
|
|
|||
|
在前面的课程中,我们基本上已经完成了一个能同时生成前端和后端的框架hade,也能很方便对框架进行管理控制。下面两节课,我们来考虑框架的一些周边功能,比如部署自动化。
|
|||
|
|
|||
|
部署自动化其实不是一个框架的刚需,有很多方式可以将一个服务进行自动化部署,比如现在比较流行的Docker化或者CI/CD流程。
|
|||
|
|
|||
|
但是一些比较个人比较小的项目,比如一个博客、一个官网网站,**这些部署流程往往都太庞大了,更需要一个服务,能快速将在开发机器上写好、调试好的程序上传到目标服务器,并且更新应用程序**。这就是我们今天要实现的框架发布自动化。
|
|||
|
|
|||
|
所有的部署自动化工具,基本都依赖本地与远端服务器的连接,这个连接可以是FTP,可以是HTTP,但是更经常的连接是SSH连接。因为一旦我们购买了一个Web服务器,服务器提供商就会提供一个有SSH登录账号的服务器,我们可以通过这个账号登录到服务器上,来进行各种软件的安装,比如FTP、HTTP服务等。
|
|||
|
|
|||
|
基本上,SSH账号是我们拿到Web服务器的首要凭证,所以要设计的自动化发布系统也是依赖SSH的。
|
|||
|
|
|||
|
## SSH服务
|
|||
|
|
|||
|
那么在Golang中如何SSH连接远端的服务器呢?有一个[ssh](https://golang.org/x/crypto/ssh)库能完成SSH的远端连接。
|
|||
|
|
|||
|
这里介绍一个小知识,你可以看下这个ssh库的git:golang.org/x/crypto/ssh。它是在官网golang.org 下的,但是又不是官方的标准库,因为子目录是x。
|
|||
|
|
|||
|
这种库其实也是经过官方认证的,属于实验性的库,我们可以这么理解:**以golang.org/x/ 开头的库,都是官方认为这些库后续有可能成为标准库的一部份**,但是由于种种原因,现在还没有计划放进标准库中,需要更多时间打磨。但是这种库的维护者和开发者一般已经是Golang官方组的人员了。比如现在今年讨论热度很大的Golang泛型,据说也会先以实验库的形式出现。
|
|||
|
|
|||
|
不管怎么样,这种以golang.org/x/开头的库,成熟度已经非常高了,我们是可以放心使用的。来了解一下这个ssh库:
|
|||
|
|
|||
|
```go
|
|||
|
package main
|
|||
|
|
|||
|
import (
|
|||
|
"bytes"
|
|||
|
"fmt"
|
|||
|
"log"
|
|||
|
|
|||
|
"golang.org/x/crypto/ssh"
|
|||
|
)
|
|||
|
|
|||
|
func main() {
|
|||
|
var hostKey ssh.PublicKey
|
|||
|
|
|||
|
// ssh相关配置
|
|||
|
config := &ssh.ClientConfig{
|
|||
|
User: "username",
|
|||
|
Auth: []ssh.AuthMethod{
|
|||
|
ssh.Password("yourpassword"),
|
|||
|
},
|
|||
|
HostKeyCallback: ssh.FixedHostKey(hostKey),
|
|||
|
}
|
|||
|
// 创建client
|
|||
|
client, err := ssh.Dial("tcp", "yourserver.com:22", config)
|
|||
|
if err != nil {
|
|||
|
log.Fatal("Failed to dial: ", err)
|
|||
|
}
|
|||
|
defer client.Close()
|
|||
|
|
|||
|
// 使用client做各种操作
|
|||
|
|
|||
|
session, err := client.NewSession()
|
|||
|
if err != nil {
|
|||
|
log.Fatal("Failed to create session: ", err)
|
|||
|
}
|
|||
|
defer session.Close()
|
|||
|
|
|||
|
var b bytes.Buffer
|
|||
|
session.Stdout = &b
|
|||
|
if err := session.Run("/usr/bin/whoami"); err != nil {
|
|||
|
log.Fatal("Failed to run: " + err.Error())
|
|||
|
}
|
|||
|
fmt.Println(b.String())
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
在这个官方示例中,我们可以看到ssh库作为客户端连接,最重要的是创建ssh.Client这个数据结构,而这个数据结构使用ssh.Dail能进行创建,创建的时候依赖ssh.ClientConfig这么一个配置结构。
|
|||
|
|
|||
|
是不是非常熟悉?和前面的Gorm、Redis一样,将SSH的连接部分封装成为hade框架的SSH服务,这样我们就能很方便地初始化一个ssh.Client了。
|
|||
|
|
|||
|
经过前面几节课,相信你已经非常熟悉这种套路了,我们就简要说明下ssh service的封装和实现思路。这节课的重点在后面对自动化发布系统的实现上。
|
|||
|
|
|||
|
ssh service的封装一样有三个部分,服务协议、服务提供者、服务实现。
|
|||
|
|
|||
|
服务协议我们提供GetClient方法:
|
|||
|
|
|||
|
```go
|
|||
|
// SSHService 表示一个ssh服务
|
|||
|
type SSHService interface {
|
|||
|
// GetClient 获取ssh连接实例
|
|||
|
GetClient(option ...SSHOption) (*ssh.Client, error)
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
而其中的SSHOption作为更新SSHConfig的函数:
|
|||
|
|
|||
|
```go
|
|||
|
// SSHOption 代表初始化的时候的选项
|
|||
|
type SSHOption func(container framework.Container, config *SSHConfig) error
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
我们封装配置结构为 SSHConfig:
|
|||
|
|
|||
|
```go
|
|||
|
// SSHConfig 为hade定义的SSH配置结构
|
|||
|
type SSHConfig struct {
|
|||
|
NetWork string
|
|||
|
Host string
|
|||
|
Port string
|
|||
|
*ssh.ClientConfig
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
对应的配置文件如下 config/testing/ssh.yaml,你可以看看每个配置的说明:
|
|||
|
|
|||
|
```yaml
|
|||
|
timeout: 1s
|
|||
|
network: tcp
|
|||
|
web-01:
|
|||
|
host: 118.190.3.55 # ip地址
|
|||
|
port: 22 # 端口
|
|||
|
username: yejianfeng # 用户名
|
|||
|
password: "123456" # 密码
|
|||
|
web-02:
|
|||
|
network: tcp
|
|||
|
host: localhost # ip地址
|
|||
|
port: 3306 # 端口
|
|||
|
username: jianfengye # 用户名
|
|||
|
rsa_key: "/Users/user/.ssh/id_rsa"
|
|||
|
known_hosts: "/Users/user/.ssh/known_hosts"
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这里注意下,SSH的连接方式有两种,一种是直接使用用户名密码来连接远程服务器,还有一种是使用rsa key文件来连接远端服务器,所以这里的配置需要同时支持两种配置。**对于使用rsa key文件的方式,需要设置rsk\_key的私钥地址和负责安全验证的known\_hosts**。
|
|||
|
|
|||
|
定义好了SSH的服务协议,服务提供者和服务实现并没有什么特别,就不展示具体代码了,在GitHub上的[provider/ssh/provider.go](https://github.com/gohade/coredemo/blob/geekbang/28/framework/provider/ssh/provider.go) 和[provider/ssh/service.go](https://github.com/gohade/coredemo/blob/geekbang/28/framework/provider/ssh/service.go)中。我们简单说一下思路。
|
|||
|
|
|||
|
对于服务提供者,我们实现基本的五个函数Register/Boot/IsDefer/Param/Name。另外这个ssh服务并不是框架启动时候必要加载的,所以设置IsDefer为true,而Param我们就照例把服务容器container作为参数,传递给Register设定的实例化方法。
|
|||
|
|
|||
|
而SSH服务的具体实现,同样类似Redis,先配置更新,再查询是否已经实例化,若已经实例化,返回实例化对象;若没有实例化,实例化client,并且存在map中。
|
|||
|
|
|||
|
完成了SSH的服务协议、服务提供者、服务实例,我们就重点讨论下如何使用SSH的服务协议来实现自动化部署。
|
|||
|
|
|||
|
## 自动化部署
|
|||
|
|
|||
|
首先还是思考清楚自动化部署的命令设计。我们的hade框架是同时支持前后端的开发框架,所以自动化部署是需要同时支持前后端部署的,也就是说它的命令也需要支持前后端的部署,这里我们设计一个显示帮助信息的一级命令`./hade deploy` 和四个二级命令:
|
|||
|
|
|||
|
* `./hade deploy frontend` ,部署前端
|
|||
|
* `./hade deploy backend` ,部署后端
|
|||
|
* `./hade deploy all` ,同时部署前后端
|
|||
|
* `./hade deploy rollback` ,部署回滚
|
|||
|
|
|||
|
同时也设计一下部署配置文件。
|
|||
|
|
|||
|
首先,我们是需要知道部署在哪个或者哪几个服务器上的,所以需要有一个数组配置项connections来定义部署服务器。而部署服务器的具体用户名密码配置,在前面SSH的配置里是存在的,所以这里直接把SSH的配置路径放在我们的connections中就可以了。
|
|||
|
|
|||
|
其次,还要知道我们要部署的远端服务器的目标文件夹是什么?所以这里需要有一个remote\_folder配置项来配置远端文件夹。
|
|||
|
|
|||
|
然后就是前端部署的配置frontend了。我们知道,在本地编译之后,会直接编译成了dist目录下的HTML/JS/CSS文件,这些文件直接上传到远端文件夹就是可以使用的了。
|
|||
|
|
|||
|
但是,在上传前端编译文件之前和在远端服务器执行一些命令之后,是有可能要做一些操作的。比如上传前先清空远端文件夹、上传后更新nginx等。所以这里,**我们设计两个数组结构pre\_action和post\_action来分别存放部署的前置命令和部署的后置命令**。
|
|||
|
|
|||
|
最后就是后端部署的配置backend。同前端部署一样,我们也有部署的前置命令和后置命令。但是后端编译还有一个不同点。
|
|||
|
|
|||
|
因为后端是Golang编译的,而它的编译其实是分平台的,加上Go支持“交叉编译”。就是说,比如我的工作机器是Mac操作系统,Web服务器是Linux操作系统,那么我需要编译Linux操作系统的后端程序,但是我可以直接在Mac操作系统上使用GOOS 和 GOARCH 来编译Linux操作系统的程序:
|
|||
|
|
|||
|
```go
|
|||
|
GOOS=linux GOARCH=amd64 go build ./
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这样编译出来的文件就是可以在Linux运行的后端进程了。所以在后端部署的配置项里面,我们增加GOOS 和 GOARCH分别表示后端的交叉编译参数。
|
|||
|
|
|||
|
完整的配置文件在config/development/deploy.yaml中:
|
|||
|
|
|||
|
```yaml
|
|||
|
connections: # 要自动化部署的连接
|
|||
|
- ssh.web-01
|
|||
|
|
|||
|
remote_folder: "/home/yejianfeng/coredemo/" # 远端的部署文件夹
|
|||
|
|
|||
|
frontend: # 前端部署配置
|
|||
|
pre_action: # 部署前置命令
|
|||
|
- "pwd"
|
|||
|
post_action: # 部署后置命令
|
|||
|
- "pwd"
|
|||
|
|
|||
|
backend: # 后端部署配置
|
|||
|
goos: linux # 部署目标操作系统
|
|||
|
goarch: amd64 # 部署目标cpu架构
|
|||
|
pre_action: # 部署前置命令
|
|||
|
- "pwd"
|
|||
|
post_action: # 部署后置命令
|
|||
|
- "chmod 777 /home/yejianfeng/coredemo/hade"
|
|||
|
- "/home/yejianfeng/coredemo/hade app restart"
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
好,配置文件设计好了,下面我们开始实现对应的命令。
|
|||
|
|
|||
|
其实估计你对如何实现,已经大致心中有数了。一级命令 `./hade deploy` 还是并没有什么内容,只是将帮助信息打印出来,之前也做过很多次,就不描述了。二级命令按之前的套路,一般是先编译,再部署,最后上传到目标服务器。
|
|||
|
|
|||
|
### 部署前端
|
|||
|
|
|||
|
看二级命令 `./hade deploy frontend`。对于部署前端,我们分为三个步骤:
|
|||
|
|
|||
|
* 创建要部署的文件夹;
|
|||
|
* 编译前端文件到部署文件夹中;
|
|||
|
* 上传部署文件夹,并且执行对应的前置和后置的shell。
|
|||
|
|
|||
|
在framework/command/deploy.go中:
|
|||
|
|
|||
|
```go
|
|||
|
// deployFrontendCommand 部署前端
|
|||
|
var deployFrontendCommand = &cobra.Command{
|
|||
|
Use: "frontend",
|
|||
|
Short: "部署前端",
|
|||
|
RunE: func(c *cobra.Command, args []string) error {
|
|||
|
container := c.GetContainer()
|
|||
|
|
|||
|
// 创建部署文件夹
|
|||
|
deployFolder, err := createDeployFolder(container)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 编译前端到部署文件夹
|
|||
|
if err := deployBuildFrontend(c, deployFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 上传部署文件夹并执行对应的shell
|
|||
|
return deployUploadAction(deployFolder, container, "frontend")
|
|||
|
},
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这里可能你会有个疑惑,为什么要创建一个部署文件夹?我们直接将前端编译的dist目录上传到目标服务器不就行了么?来为你解答下。
|
|||
|
|
|||
|
部署服务是一个很小心的过程,因为它会影响现在的线上服务,而每次部署都是有可能失败的,也就很有可能需要进行回滚操作,就是我们前面定义的部署回滚操作命令 `./hade deploy rollback` 。**而回滚的时候,需要能找到某个特定版本的编译内容,这里就需要部署文件夹**。
|
|||
|
|
|||
|
这个部署文件夹我们定义为目录 deploy/xxxxxx,其中的xxxx直接设置为细化到秒的时间。对应的创建部署文件夹的函数如下:
|
|||
|
|
|||
|
```go
|
|||
|
// 创建部署的folder
|
|||
|
func createDeployFolder(c framework.Container) (string, error) {
|
|||
|
appService := c.MustMake(contract.AppKey).(contract.App)
|
|||
|
deployFolder := appService.DeployFolder()
|
|||
|
|
|||
|
// 部署文件夹的名称
|
|||
|
deployVersion := time.Now().Format("20060102150405")
|
|||
|
versionFolder := filepath.Join(deployFolder, deployVersion)
|
|||
|
if !util.Exists(versionFolder) {
|
|||
|
return versionFolder, os.Mkdir(versionFolder, os.ModePerm)
|
|||
|
}
|
|||
|
return versionFolder, nil
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这里的appService.DeployFolder() 是我们在appService下创建的一个新的目录deploy,在framework/contract/app.go中:
|
|||
|
|
|||
|
```go
|
|||
|
// App 定义接口
|
|||
|
type App interface {
|
|||
|
...
|
|||
|
// DeployFolder 存放部署的时候创建的文件夹
|
|||
|
DeployFolder() string
|
|||
|
...
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
有了这个部署文件夹,每次的发布都有“档案”存储了,这就为回滚命令提供了可能性。我们每次编译的文件,也都会先经过这个部署文件夹,再中转上传到目标服务器。
|
|||
|
|
|||
|
第一步创建部署文件夹实现了,我们再回头看下部署前端的第二个步骤,编译前端文件到部署文件夹。可以直接使用 buildFrontendCommand的RunE方法,它会将前端编译到dist目录下,然后我们再将dist目录文件拷贝到部署文件夹中:
|
|||
|
|
|||
|
```go
|
|||
|
func deployBuildFrontend(c *cobra.Command, deployFolder string) error {
|
|||
|
container := c.GetContainer()
|
|||
|
appService := container.MustMake(contract.AppKey).(contract.App)
|
|||
|
|
|||
|
// 编译前端
|
|||
|
if err := buildFrontendCommand.RunE(c, []string{}); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 复制前端文件到deploy文件夹
|
|||
|
frontendFolder := filepath.Join(deployFolder, "dist")
|
|||
|
if err := os.Mkdir(frontendFolder, os.ModePerm); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
buildFolder := filepath.Join(appService.BaseFolder(), "dist")
|
|||
|
if err := util.CopyFolder(buildFolder, frontendFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
第三步,上传部署文件夹,并且执行对应的前置和后置的shell。
|
|||
|
|
|||
|
这个步骤的实现是今天这节课的重点了。首先遍历配置文件中的deploy.connections,明确我们要在哪几个远端节点中进行部署;然后对每个远端服务创建一个ssh.Client,由于前面已经写好了SSH服务,所以直接使用GetClient方法就能为每个节点创建一个sshClient了:
|
|||
|
|
|||
|
```go
|
|||
|
for _, node := range deployNodes {
|
|||
|
sshClient, err := sshService.GetClient(ssh.WithConfigPath(node))
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
...
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
接下来就要执行命令了,那怎么执行前置或者后置命令呢?
|
|||
|
|
|||
|
我们需要为每个命令创建一个session,然后使用session.CombinedOut来输出这个命令的结果,把每个命令的结果都输出在控制台中。相关代码如下:
|
|||
|
|
|||
|
```go
|
|||
|
for _, action := range preActions {
|
|||
|
// 创建session
|
|||
|
session, err := sshClient.NewSession()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
// 执行命令,并且等待返回
|
|||
|
bts, err := session.CombinedOutput(action)
|
|||
|
if err != nil {
|
|||
|
session.Close()
|
|||
|
return err
|
|||
|
}
|
|||
|
session.Close()
|
|||
|
|
|||
|
// 执行前置命令成功
|
|||
|
logger.Info(context.Background(), "execute pre action", map[string]interface{}{
|
|||
|
"cmd": action,
|
|||
|
"connection": node,
|
|||
|
"out": strings.ReplaceAll(string(bts), "\n", ""),
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
执行了前置命令之后,下面就是要把部署文件夹中的文件上传到目标服务器了。如何通过SSH服务将文件上传到目标服务器呢?
|
|||
|
|
|||
|
这里需要使用到一个成熟的第三方库 [sftp](https://github.com/pkg/sftp) 了,目前已经有1.1k star,采用BSD-2的开源协议,允许修改商用,但是要保留申明。这个库就是封装SSH的,将SFTP文件传输协议封装了一下。SFTP是什么?它是基于SSH协议来进行文件传输的一个协议,功能与FTP相似,区别就是它的连接通道使用SSH。
|
|||
|
|
|||
|
SFTP的底层连接实际上就是SSH,只是把传输的文件内容进行了一下加密等工作,增加了传输的安全性。所以**SFTP本质就是“使用SSH连接来完成文件传输功能”**。这点可以从它的实例化看出,sftp.Client的唯一参数就是ssh.Client。
|
|||
|
|
|||
|
```go
|
|||
|
client, err := sftp.NewClient(sshClient)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
SFTP这个库,在初始化sftp.Client之后,会将这个client封装地和官方的本地操作文件OS库一样,你在使用sftp.Client的时候完全没有障碍。
|
|||
|
|
|||
|
比如,OS库创建一个文件是os.Create,在SFTP中就是使用client.Create;OS库获取一个文件信息的函数是os.Stat,在SFTP中就是client.Stat。但是注意下,这里完全是SFTP刻意将这个库函数设计的和OS库一样的,它们之间并没有什么嵌套关系。
|
|||
|
|
|||
|
我们使用ssh.Client初始化一个sftp.Client之后,写一个uploadFolderToSFTP的函数来实现将本地文件夹同步到远端文件夹:
|
|||
|
|
|||
|
```go
|
|||
|
// 上传部署文件夹
|
|||
|
func uploadFolderToSFTP(container framework.Container, localFolder, remoteFolder string, client *sftp.Client) error {
|
|||
|
logger := container.MustMake(contract.LogKey).(contract.Log)
|
|||
|
// 遍历本地文件
|
|||
|
return filepath.Walk(localFolder, func(path string, info os.FileInfo, err error) error {
|
|||
|
// 获取除了folder前缀的后续文件名称
|
|||
|
relPath := strings.Replace(path, localFolder, "", 1)
|
|||
|
if relPath == "" {
|
|||
|
return nil
|
|||
|
}
|
|||
|
// 如果是遍历到了一个目录
|
|||
|
if info.IsDir() {
|
|||
|
logger.Info(context.Background(), "mkdir: "+filepath.Join(remoteFolder, relPath), nil)
|
|||
|
// 创建这个目录
|
|||
|
return client.MkdirAll(filepath.Join(remoteFolder, relPath))
|
|||
|
}
|
|||
|
|
|||
|
// 打开本地的文件
|
|||
|
rf, err := os.Open(filepath.Join(localFolder, relPath))
|
|||
|
if err != nil {
|
|||
|
return errors.New("read file " + filepath.Join(localFolder, relPath) + " error:" + err.Error())
|
|||
|
}
|
|||
|
// 检查文件大小
|
|||
|
rfStat, err := rf.Stat()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
// 打开/创建远端文件
|
|||
|
f, err := client.Create(filepath.Join(remoteFolder, relPath))
|
|||
|
if err != nil {
|
|||
|
return errors.New("create file " + filepath.Join(remoteFolder, relPath) + " error:" + err.Error())
|
|||
|
}
|
|||
|
|
|||
|
// 大于2M的文件显示进度
|
|||
|
if rfStat.Size() > 2*1024*1024 {
|
|||
|
logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
|
|||
|
" to remote file: "+filepath.Join(remoteFolder, relPath)+" start", nil)
|
|||
|
// 开启一个goroutine来不断计算进度
|
|||
|
go func(localFile, remoteFile string) {
|
|||
|
// 每10s计算一次
|
|||
|
ticker := time.NewTicker(2 * time.Second)
|
|||
|
for range ticker.C {
|
|||
|
// 获取远端文件信息
|
|||
|
remoteFileInfo, err := client.Stat(remoteFile)
|
|||
|
if err != nil {
|
|||
|
logger.Error(context.Background(), "stat error", map[string]interface{}{
|
|||
|
"err": err,
|
|||
|
"remote_file": remoteFile,
|
|||
|
})
|
|||
|
continue
|
|||
|
}
|
|||
|
// 如果远端文件大小等于本地文件大小,说明已经结束了
|
|||
|
size := remoteFileInfo.Size()
|
|||
|
if size >= rfStat.Size() {
|
|||
|
break
|
|||
|
}
|
|||
|
// 计算进度并且打印进度
|
|||
|
percent := int(size * 100 / rfStat.Size())
|
|||
|
logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
|
|||
|
" to remote file: "+filepath.Join(remoteFolder, relPath)+fmt.Sprintf(" %v%% %v/%v", percent, size, rfStat.Size()), nil)
|
|||
|
}
|
|||
|
}(filepath.Join(localFolder, relPath), filepath.Join(remoteFolder, relPath))
|
|||
|
}
|
|||
|
|
|||
|
// 将本地文件并发读取到远端文件
|
|||
|
if _, err := f.ReadFromWithConcurrency(rf, 10); err != nil {
|
|||
|
return errors.New("Write file " + filepath.Join(remoteFolder, relPath) + " error:" + err.Error())
|
|||
|
}
|
|||
|
// 记录成功信息
|
|||
|
logger.Info(context.Background(), "upload local file: "+filepath.Join(localFolder, relPath)+
|
|||
|
" to remote file: "+filepath.Join(remoteFolder, relPath)+" finish", nil)
|
|||
|
return nil
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这段代码长一点。首先我们使用功能filePath.Walk来遍历本地文件夹中的所有文件,如果遍历到的是子文件夹,就创建子文件夹,否则的话,我们就将本地文件上传到远端。而上传远端的操作大致就是三步:打开本地文件、打开远端文件、将本地文件传输到远端文件。
|
|||
|
|
|||
|
在上述函数中大致是这几句代码:
|
|||
|
|
|||
|
```go
|
|||
|
// 打开本地的文件
|
|||
|
rf, err := os.Open(filepath.Join(localFolder, relPath))
|
|||
|
|
|||
|
// 打开/创建远端文件
|
|||
|
f, err := client.Create(filepath.Join(remoteFolder, relPath))
|
|||
|
|
|||
|
// 将本地文件并发读取到远端文件
|
|||
|
if _, err := f.ReadFromWithConcurrency(rf, 10); err != nil
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
SFTP提供了并发读取到远端文件ReadFromWithConcurrency的方法,我们可以使用这个并发读的方法提高上传效率。
|
|||
|
|
|||
|
但是即使是并发读,对于比较大的文件,还是需要等候比较长的时间。而这个等待时长,对于在控制台敲下部署命令的使用者来说是非常不友好的。我们希望能**每隔一段时间显示一下当前的部署进度**,这个怎么做呢?
|
|||
|
|
|||
|
这里我们设计大于2M的文件,执行这个操作。2M是我自己实验出来体验比较差的一个阈值。然后每2s就打印一下当前进度,所以使用了一个ticker,来计算时间。每次这个ticker结束的时候,计算一下远端文件的大小,再计算一下本地文件的大小。两者相除就是这个文件的上传进度,再使用日志打印就能打印出具体的进度了。
|
|||
|
|
|||
|
最后的效果如下:
|
|||
|
![](https://static001.geekbang.org/resource/image/08/4e/088379171caf4131231de7d635b6e34e.png?wh=1920x157)
|
|||
|
|
|||
|
到这里部署前端的代码就开发完成了。
|
|||
|
|
|||
|
### 部署后端
|
|||
|
|
|||
|
理解了如何部署前端,部署后端的对应方法基本如出一辙。唯一不同的地方就是编译。
|
|||
|
|
|||
|
编译Golang的后端需要指定对应的编译平台和编译CPU架构,就是前面说的GOOS和GOARCH。所以我们就不能直接使用build命令来编译后端了。改成定位go程序,来执行go build,并且需要修改输出文件路径,输出到部署文件夹中。
|
|||
|
|
|||
|
当然这个部署文件夹还是按照我们之前的设计为 deploy/xxxxxx,其中的xxxx直接设置为细化到秒的时间,继续在framework/command/deploy.go中写入:
|
|||
|
|
|||
|
```go
|
|||
|
// 编译后端
|
|||
|
path, err := exec.LookPath("go")
|
|||
|
if err != nil {
|
|||
|
log.Fatalln("hade go: 请在Path路径中先安装go")
|
|||
|
}
|
|||
|
// 组装命令
|
|||
|
deployBinFile := filepath.Join(deployFolder, binFile)
|
|||
|
cmd := exec.Command(path, "build", "-o", deployBinFile, "./")
|
|||
|
cmd.Env = os.Environ()
|
|||
|
// 设置GOOS和GOARCH
|
|||
|
if configService.GetString("deploy.backend.goos") != "" {
|
|||
|
cmd.Env = append(cmd.Env, "GOOS="+configService.GetString("deploy.backend.goos"))
|
|||
|
}
|
|||
|
if configService.GetString("deploy.backend.goarch") != "" {
|
|||
|
cmd.Env = append(cmd.Env, "GOARCH="+configService.GetString("deploy.backend.goarch"))
|
|||
|
}
|
|||
|
// 执行命令
|
|||
|
ctx := context.Background()
|
|||
|
out, err := cmd.CombinedOutput()
|
|||
|
if err != nil {
|
|||
|
logger.Error(ctx, "go build err", map[string]interface{}{
|
|||
|
"err": err,
|
|||
|
"out": string(out),
|
|||
|
})
|
|||
|
return err
|
|||
|
}
|
|||
|
logger.Info(ctx, "编译成功", nil)
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
同时除了生成二进制文件,还要记得把.env文件(如果有的话)、config目标文件传递到本地的部署目录:
|
|||
|
|
|||
|
```go
|
|||
|
// 复制.env
|
|||
|
if util.Exists(filepath.Join(appService.BaseFolder(), ".env")) {
|
|||
|
if err := util.CopyFile(filepath.Join(appService.BaseFolder(), ".env"), filepath.Join(deployFolder, ".env")); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 复制config文件
|
|||
|
deployConfigFolder := filepath.Join(deployFolder, "config", env)
|
|||
|
if !util.Exists(deployConfigFolder) {
|
|||
|
if err := os.MkdirAll(deployConfigFolder, os.ModePerm); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
}
|
|||
|
if err := util.CopyFolder(filepath.Join(appService.ConfigFolder(), env), deployConfigFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
自动化部署后端的命令,除了以上的编译文件到部署目录之外,其他部分都和自动化部署前端的命令一致:
|
|||
|
|
|||
|
```go
|
|||
|
// deployBackendCommand 部署后端
|
|||
|
var deployBackendCommand = &cobra.Command{
|
|||
|
Use: "backend",
|
|||
|
Short: "部署后端",
|
|||
|
RunE: func(c *cobra.Command, args []string) error {
|
|||
|
container := c.GetContainer()
|
|||
|
|
|||
|
// 创建部署文件夹
|
|||
|
deployFolder, err := createDeployFolder(container)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 编译后端到部署文件夹
|
|||
|
if err := deployBuildBackend(c, deployFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 上传部署文件夹并执行对应的shell
|
|||
|
return deployUploadAction(deployFolder, container, "backend")
|
|||
|
},
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
### 部署全部
|
|||
|
|
|||
|
而对于同时部署前后端命令,其实就是在编译阶段,把前端和后端同时进行编译,并且最终上传部署文件夹。同样放在framework/command/deploy.go:
|
|||
|
|
|||
|
```go
|
|||
|
var deployAllCommand = &cobra.Command{
|
|||
|
Use: "all",
|
|||
|
Short: "全部部署",
|
|||
|
RunE: func(c *cobra.Command, args []string) error {
|
|||
|
container := c.GetContainer()
|
|||
|
|
|||
|
deployFolder, err := createDeployFolder(container)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 编译前端
|
|||
|
if err := deployBuildFrontend(c, deployFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 编译后端
|
|||
|
if err := deployBuildBackend(c, deployFolder); err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 上传前端+后端,并执行对应的shell
|
|||
|
return deployUploadAction(deployFolder, container, "all")
|
|||
|
},
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
### 部署回滚
|
|||
|
|
|||
|
最后就是部署回滚操作,主要明确一下需要传递的参数:
|
|||
|
|
|||
|
一个是回滚版本号。这个版本号就是我们的部署目录的名称,前面说过部署目录为deploy/xxxxxx,xxxx设置为细化到秒的时间。比如20211110233354,表示是我们2021年11月10日23点33分54秒创建的版本。
|
|||
|
|
|||
|
另外一个就是标记希望回滚前端,还是后端,还是全部回滚。这里主要涉及执行前端的回滚命令,还是执行后端的回滚命令。
|
|||
|
|
|||
|
这两个参数我们直接以参数形式,跟在deploy rollback命令之后,如下:
|
|||
|
|
|||
|
```go
|
|||
|
./hade deploy rollback 20211110233354 backend
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
明确了参数,它的具体实现就很简单了,因为它没有任何的编译过程,我们只需要把回滚版本所在目录的编译结果,上传到目标服务器就可以了,同样,我们把这个命令放在framework/command/deploy.go中:
|
|||
|
|
|||
|
```go
|
|||
|
// deployRollbackCommand 部署回滚
|
|||
|
var deployRollbackCommand = &cobra.Command{
|
|||
|
Use: "rollback",
|
|||
|
Short: "部署回滚",
|
|||
|
RunE: func(c *cobra.Command, args []string) error {
|
|||
|
container := c.GetContainer()
|
|||
|
|
|||
|
if len(args) != 2 {
|
|||
|
return errors.New("参数错误,请按照参数进行回滚 ./hade deploy rollback [version] [frontend/backend/all]")
|
|||
|
}
|
|||
|
|
|||
|
version := args[0]
|
|||
|
end := args[1]
|
|||
|
|
|||
|
// 获取版本信息
|
|||
|
appService := container.MustMake(contract.AppKey).(contract.App)
|
|||
|
deployFolder := filepath.Join(appService.DeployFolder(), version)
|
|||
|
|
|||
|
// 上传部署文件夹并执行对应的shell
|
|||
|
return deployUploadAction(deployFolder, container, end)
|
|||
|
},
|
|||
|
}
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
到这里四个自动化部署命令就都开发完成。我们来验证一下。
|
|||
|
|
|||
|
## 验证
|
|||
|
|
|||
|
要验证部署命令,我们当然需要有一个目标部署服务器,这是我设置的web-01服务器配置,在config/development/ssh.yaml中:
|
|||
|
|
|||
|
```yaml
|
|||
|
timeout: 3s
|
|||
|
network: tcp
|
|||
|
web-01:
|
|||
|
host: 111.222.333.444 # ip地址
|
|||
|
port: 22 # 端口
|
|||
|
username: yejianfeng # 用户名
|
|||
|
password: "123456" # 密码
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
而在config/development/deploy.yaml中我的配置如下:
|
|||
|
|
|||
|
```yaml
|
|||
|
connections: # 要自动化部署的连接
|
|||
|
- ssh.web-01
|
|||
|
|
|||
|
remote_folder: "/home/yejianfeng/coredemo/" # 远端的部署文件夹
|
|||
|
|
|||
|
frontend: # 前端部署配置
|
|||
|
pre_action: # 部署前置命令
|
|||
|
- "pwd"
|
|||
|
post_action: # 部署后置命令
|
|||
|
- "pwd"
|
|||
|
|
|||
|
backend: # 后端部署配置
|
|||
|
goos: linux # 部署目标操作系统
|
|||
|
goarch: amd64 # 部署目标cpu架构
|
|||
|
pre_action: # 部署前置命令
|
|||
|
- "rm /home/yejianfeng/coredemo/hade"
|
|||
|
post_action: # 部署后置命令
|
|||
|
- "chmod 777 /home/yejianfeng/coredemo/hade"
|
|||
|
- "/home/yejianfeng/coredemo/hade app restart"
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
重点看后端部署配置。在部署后端之前,我们先运行一个rm 命令来将旧的hade二进制进程删除,然后部署后端文件,其中包括这个二进制进程。最后执行了两个命令,一个是chmod命令,保证上传上去的二进制进程命令可以执行;第二个就是./hade app restart命令,能将远端的命令启动。
|
|||
|
|
|||
|
这里就演示下部署后端服务 `./hade deploy backend` ,输出结果如下:
|
|||
|
![](https://static001.geekbang.org/resource/image/14/ac/14915cb398646c747875c4860e01b6ac.png?wh=1920x440)
|
|||
|
![](https://static001.geekbang.org/resource/image/2e/bb/2e70470a5e55e864aeea7e920e1eaabb.png?wh=1920x283)
|
|||
|
|
|||
|
我们看到,它成功地编译后端服务,到目标文件夹deploy/20211110233533, 并且上传了编译的hade命令,在远端启动了进程。
|
|||
|
|
|||
|
接着验证下回滚命令。在之前已经发布过版本 20211110233354 了。所以这里直接运行命令 `./hade deploy rollback 20211110233354 backend` 将版本回滚到 20211110233354。
|
|||
|
![](https://static001.geekbang.org/resource/image/fc/78/fc3280cfd8813169970f8d91c11f6578.png?wh=1920x403)
|
|||
|
![](https://static001.geekbang.org/resource/image/89/90/89390e826834862981bb94344dcfb090.png?wh=1920x297)
|
|||
|
|
|||
|
验证成功!
|
|||
|
|
|||
|
本节课我们对framework下的provider、contract、command目录都有修改。目录截图如下,供你对比查看,所有代码都已经上传到[geekbang/28](https://github.com/gohade/coredemo/tree/geekbang/28)分支了。
|
|||
|
![](https://static001.geekbang.org/resource/image/d8/0b/d89d6aaf4f25fab54dec747ef0f4700b.jpg?wh=2187x1292)
|
|||
|
|
|||
|
## 小结
|
|||
|
|
|||
|
今天我们实现了将代码自动化部署到Web服务器的机制。为了实现这个自动化部署,先实现了一个SSH服务,然后定制了一套自动化部署命令,包括部署前端、部署后端、部署全部和部署回滚。
|
|||
|
|
|||
|
虽然说这个由框架负责的自动化部署机制在大项目中可能用不上,毕竟现在大项目都采用Docker化和k8s部署了。不过对于小型项目,这种部署机制还是有其便利性的。所以我们的hade框架还是决定提供这个机制。
|
|||
|
|
|||
|
在实现这个机制的过程中,要做到熟练掌握Golang对于SSH、SFTP等库的操作。基本上这两个库的操作你熟悉了,就能在一个程序中同时自动化操作多个服务器了。在实际工作中,如果遇到类似的需求,可以按照这节课所展示的技术来自动化你的需求。
|
|||
|
|
|||
|
### 思考题
|
|||
|
|
|||
|
其实今天的内容涉及自动化运维的范畴了,我们就布置一个课外研究吧。自动化运维范畴中有一个很出名的自动化运维配置框架ansible,你可以去浏览下[Ansible中文权威指南](https://ansible-tran.readthedocs.io/en/latest)网站,学习一下ansible有哪些功能,分享一下你的学习心得。
|
|||
|
|
|||
|
欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见。
|
|||
|
|