这属于docker与云原生的第二部分。

DevOps

DevOps是Development和Operation的组合词,这个属性位于开发工程师和运维测试工程师的交集。突出强调的是自动化流程,减少开发人员和运维的冲突,提高效率。

整个DevOps看是一个闭环:
devops.jpg
这个闭环从计划开始,然后编码,构建,测试,发布,部署,运维,监控,如果出了bug或者新的需求再继续走这一套流程。

具体到各型企业时,也会具体到各个环节选取的具体开源工具,比如:
devops1.jpg
以上只是一套工具链的适配,随着场景及越来越丰富,多种多样的工具链组合也不断迭代。
Devops2.jpg

CI/CD

CI是持续集成(Continuous Integration),CD是持续交付(Continuous Delivery)。

持续突出的是不间断的意思,两个异步线程不会太多干扰,又高度配合。

基本概念

属于敏捷开发,不断完善,然后灰度发布。

持续集成(Continuous Integration)

持续集成是指软件个人研发的部分向软件整体部分交付,频繁进行集成以便更快地发现 其中的错 误。“持续集成”源自于极限编程(XP),是 XP 最初的 12 种实践之一。

CI 需要具备这些:

  • 全面的自动化测试。这是实践持续集成&持续部署的基础,同时,选择合适的 自动化测试工具也极 其重要;
  • 灵活的基础设施。容器,虚拟机的存在让开发人员和 QA 人员不必再大费周 折;
  • 版本控制工具。如 Git,CVS,SVN 等;
  • 自动化的构建和软件发布流程的工具,如 Jenkins,flow.ci;
  • 反馈机制。如构建/测试的失败,可以快速地反馈到相关负责人,以尽快解决达到一个更稳定的版本。

持续交付(Continuous Delivery)

持续交付在持续集成的基础上,将集成后的代码部署到更贴近真实运行环境的「类生产环境」 (production-like environments)中。持续交付优先于整个产品生命周期的软件部署,建立在高水平自 动化持续集成之上。

持续交付和持续集成的优点非常相似:

  • 快速发布。能够应对业务需求,并更快地实现软件价值。
  • 编码->测试->上线->交付的频繁迭代周期缩短,同时获得迅速反馈;
  • 高质量的软件发布标准。整个交付过程标准化、可重复、可靠,
  • 整个交付过程进度可视化,方便团队人员了解项目成熟度;
  • 更先进的团队协作方式。从需求分析、产品的用户体验到交互 设计、开发、测试、运维等角色密 切协作,相比于传统的瀑布式软件团队,更少浪费。

持续部署(Continuous Deployment)

持续部署是指当交付的代码通过评审之后,自动部署到生产环境中。持续部署是持续交付的最高阶段。 这意味着,所有通过了一系列的自动化测试的改动都将自动部署到生产环境。它也可以被称 为“Continuous Release”。

“开发人员提交代码,持续集成服务器获取代码,执行单元测试,根据测试结果决定是否部署到预 演环境,如果成功部署到预演环境,进行整体验收测试,如果测试通过,自动部署到产品环境, 全程自动化高效运转。

持续部署主要好处是,可以相对独立地部署新的功能,并能快速地收集真实用户的反馈。

持续部署主要好处是,可以相对独立地部署新的功能,并能快速地收集真实用户的反馈。

最佳实践

许多企业是集成jira,k8s等架构的,我这里先从简单的做起,后面会应用整个k8s架构来应对多容器情况下Devops。

当然还有更简单的方法,比如IDEA连接远程docker部署的服务器,maven通过docker插件基于dockerfile直接打包成镜像一键部署。不仅如此IDEA还可以通过集成shell工具直接远程部署jar包,但是这些这个对我目前意义不大,不再详述。

docker官方给的最佳实战参考图:

image20210811092807537.png
可以看出有内循环和外循环。

对此图简单解析:

  • 内循环(开发要做的事情):编码、测试、运行、debug、提交
  • 代码推送到代码仓库(svn,git)【代码回滚】
  • 进行CI过程(持续集成),万物皆可容器化。打包成一个Docker镜像
  • 镜像推送到镜像仓库
  • 测试
  • 持续部署流程(CD),拿到之前的镜像,进行CD。怎么放到各种环境。uat、test、prod
  • 外循环()
    • 运行时监控
    • 生产环境的管理
    • 监控
    • 线上反馈到开发
  • 再次来到内循环

以上是官方推荐的最佳实践,相当于是一种规范,具体落实看公司。

实践流程

image20210811093549566.png
当遇到新功能需求或者bug时,走上面一套流程。

  1. 创建相应分支来做这个事情(开发功能)
  2. 提交响应了需求的分支代码
  3. 进入持续集成流程
    • 当前分支代码功能性自动化构建和测试
    • 自动工具推送这次提交
    • 自动化集成测试
    • 管理员确认此次功能是否发布到生产环境
  4. 代码合并。
  5. 进入持续部署流程
    • 构建、测试、发布......

CICD LandSpace

整个工具链工具一览:20210305114042422.jpeg

第一个阶段collaborate一般是收集需求进行开发,常用的jira或者禅道,沟通交流用钉钉微信,知识分享用confluence wiki等。

build阶段,版本控制工具就是git,开发集成工具等。

test阶段一般是gatling或者jmeter,selenium,junit等。

deploy阶段 vagrant dockerhub nexus等都很常用

run阶段选择熟悉的就好。

为了让这些工具配合起来,就需要Jenkins来统一起来。

Jenkins

image20210813133609323.png
Jenkins本质就是java编写的程序。

我们安装Jenkins基于docker,所以确保docker的安装步骤不错。

Jenkins中午安装文档参考:

https://www.jenkins.io/zh/doc/book/installing/

文件已经十分详细了

docker run \
  -u root \ 
  -d \ 
  -p 9090:8080 \ 
  -p 50000:50000 \ 
  -v jenkins-data:/var/jenkins_home \ 
  -v /etc/localtime:/etc/localtime:ro \
  -v /var/run/docker.sock:/var/run/docker.sock \ 
  --restart=always \
  jenkinsci/blueocean

--rm:

关闭时自动删除Docker容器。如果您需要退出Jenkins,这可以保持整洁。

-p 50000:50000

集群端口号。

/var/jenkins_home

jenkins的核心文件,所有差异化文件都在这里以文件的方式存储。

-v /var/run/docker.sock:/var/run/docker.sock:

是为了Jenkins容器内可以操作docker进程及相关命令。

表示Docker守护程序通过其监听的基于Unix的套接字。 该映射允许 jenkinsci/blueocean 容器与Docker守护进程通信, 如果 jenkinsci/blueocean 容器需要实例化其他Docker容器,则该守护进程是必需的。 如果运行声明式管道,其语法包含agent部分用 docker;例 如, agent { docker } 此选项是必需的。

jenkinsci/blueocean

安装的版本是jenkinsci,带blueocean插件,jenkinsci/jenkins是没有插件的。

-v /etc/localtime:/etc/localtime:ro:

容器的运行时间很可能是UTC 0时区,需要run的时候我们设置好。

**启动
image20210816104734974.png
管理员密码在这个目录也可以通过docker命令查看日志:

docker logs 容器id

image20210816105026848.png

然后选择安装推荐的插件,jenkins最大的特色就是集成了上千插件形成了庞大的生态。

安装完成登录进入工作台:

image20210816111203794.png
如图,构建新项目就从新建项目开始,可以在系统管理中配置用户相关信息,插件和查看日志等,主要就是blue ocean查看我们构建的流程。

jenkins应用

目标:

代码在本地修改----提交到远程git----触发jenkins整个自动化构建流程(打包,测试,发布,部署)。

步骤:

  1. idea创建Spring Boot项目
  2. VCS - 创建git 仓库
  3. gitee创建一个空仓库(gitlap是内网,Jenkins是外网,这种情况gitlab要暴露一个公网端口)
  4. idea提交内容到gitee
  5. 开发项目基本功能,并在项目中创建一个Jenkinsfile文件
  6. 创建一个名为 cicdtest的流水线项目,使用项目自己的流水线

Jenkins的工作流程

  1. 先定义一个流水线项目,指定项目的git位置
    image20210817091202172.png
    然后配置项目来源为SCM Git,添加URL后,Jenkins会自动拉取远程代码进行流水线构建。(实际我使用的是Generic Webhook,这个会监听所有分支提交然后正则匹配拉取分支部署,所谓的Jenkins拉取代码实际是一次post请求,里面包含了我们所熟悉的各种json格式信息,git信息)
    image20210817091901308.png
    流水线启动后流程:

  2. 先去git位置自动拉取代码

  3. 解析拉取代码里面的Jenkinsfile文件

  4. 按照Jenkinsfile指定的流水线开始加工项目

目前还没有Jenkinsfile,需要在项目根目录新建一个Jenkinsfile。Jenkinsfile有声明式和脚本式,主流还是声明式,用的是groovy的语法。

参考文档:https://www.jenkins.io/zh/doc/book/pipeline/

文档里给了示例:
image20210817093056109.png
一个pipeline中分了很多stage,每个stage中分step去做相应的事。

至于每个阶段可以做什么事情,Jenkins可以自动帮我们生成相应片段:
image20210817093447151.png
所有可以简单应用编写下我们的Jenkinsfile:

pipeline{

    //任何一个代理可用即可执行
    agent any
    //定义一些环境信息
    environment {
      hello = "mella"
    }

    //定义流水线的加工流程
    stages {
        //
        stage('代码编译'){
            steps {
               echo "编译...."
               echo "$hello"
               sh 'pwd && ls -alh'
               sh 'printenv'
               sh 'echo ${GIT_BRANCH}'
            }
        }
        stage('测试'){
            steps {
              echo "测试...."
            }
        }
    }
}

打开blue ocean查看:
image20210817095224195.png
可以看到我们打印出的环境信息:
image20210817095704761.png
WORKSPACE=/var/jenkins_home/workspace/cicdtest

  1. 每个新建的项目都有一个workspace

BUILD_NUMBER=5

  1. 第五次构建

WORKSPACE_TMP=/var/jenkins_home/workspace/cicdtest@tmp

  1. 临时工作目录

还有其他很多环境信息。

Jenkins环境和自动触发

现在流水线问题解决了,还有的问题是,如果我们的springboot项目启动没有相应的环境要怎么办?如何自动触发Jenkins拉取git项目?

gitee为例子,远程构建触发:
从Jenkins主页面选择构建历史进入我们之前构建的项目,然后配置我们的构建触发器。
image20210817102638397.png
身份验证令牌随便写。

需要在gitee上配置钩子,这样当你提交代码后会触发gitee的钩子,这个钩子配置的就是Jenkins对应的地址,等于说提交代码到gitee后gitee会通知Jenkins,jenkins收到通知就会去gitee拉取代码。(gitee如果是私库,需要配置Jenkins的credential,一般也就是gitee的账号密码)
image20210817102909033.png
钩子填写遵循官方要求:

Use the following URL to trigger build remotely: JENKINS_URL/job/cicdtest/build?token=TOKEN_NAME 或者 /buildWithParameters?token=TOKEN_NAME
Optionally append &cause=Cause+Text to provide text that will be included in the recorded build cause.
Generic Webhook和这个也不一样,注意参照提示要求。

image20210817104123354.png
效果如上,选择的是push才会触发钩子,也可自行适配。

可以看到URL有点奇怪,其实是 用户名:API Token

关于用户名和token,在jenkins的系统设置-->管理用户-->设置中生成:
image20210817104404800.png
token只显示一次,及时保存。
image20210817104526820.png
显示表明远程触发成功。

这时候,当我们代码一提交就会显示触发成功。

环境问题

解决环境问题,要么是直接打包一个大docker里面包含了所有的运行环境,要么在jenkins中集成配置环境,还有一种方式是使用docker临时环境。

官方文档给了示例:

pipeline {
    //如果为none,下面每个stage都必须指定具体的
    agent none
    stages {
        stage('Back-end') {
            agent {
                docker { image 'maven:3-alpine' }
            }
            steps {
                sh 'mvn --version'
            }
        }
        stage('Front-end') {
            agent {
                docker { image 'node:7-alpine' }
            }
            steps {
                sh 'node --version'
            }
        }
    }
}

当使用自定义执行环境时,jenkins需要安装docker pinpline插件:
image20210817105814743.png
至于插件安装,我们挂载的目录/var/lib/docker/volumes/jenkins-data/_data 下有plugins目录就是我们所有的插件,也可以下载插件包手动导入jenkins插件管理。

推荐插件安装

上面提到了插件安装docker pipeline,下面列举推荐的插件:

Docker Pipeline && Docker

安装Docker Pipeline会自动安装docker相关的 这个允许我们自定义agent使用docker环境

Git Parameter

解析git参数,允许我们选择分支进行构建

Active Choices

可以做到参数的级联选择

Generic Webhook Trigger

通用的webhook触发器,构建更强大的webhook功能

Role-based Authorization Strategy

RBAC权限指定,给一个用户精确指定权限

List Git Branches Parameter

列出分支参数

Build With Parameters

基于自定义参数构建

自定义环境原理

image20210817124920344.png
这里自定义maven仓库和配置文件通过-v挂载的方式实现,实现该方式的手段有两种,一是挂载linux的文件,二是直接使用jenkins的home目录的文件。要选择直接使用jenkins的home目录,这样方便jenkins整体移植。

所以可以在jenkins的挂载目录jenkins-data/_data外创建配置文件进行相应配置。
image20210817122025413.png
以root用户进入到挂载的文件夹
image20210817122256523.png
然后复制粘贴如下配置:

<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
    <!-- localRepository
    | The path to the local repository maven will use to store artifacts.
    |
    | Default: ${user.home}/.m2/repository
    <localRepository>H:\Devsoft\apache-maven-3.6.1\repository</localRepository>
    用户目录下的.m2是所有jar包的地方; maven容器内jar包的位置
    -->
    <localRepository>/root/.m2</localRepository>
    <pluginGroups>
    </pluginGroups>
    <proxies>
    </proxies>
    <servers>
    </servers>
    <mirrors>
        <mirror>
            <id>nexus-aliyun</id>
            <mirrorOf>central</mirrorOf>
            <name>Nexus aliyun</name>
            <url>http://maven.aliyun.com/nexus/content/groups/public</url>
        </mirror>
    </mirrors>
    <profiles>
        <profile>
            <id>jdk-1.8</id>
            <activation>
                <activeByDefault>true</activeByDefault>
                <jdk>1.8</jdk>
            </activation>
            <properties>
                <maven.compiler.source>1.8</maven.compiler.source>
                <maven.compiler.target>1.8</maven.compiler.target>
                <maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
            </properties>
        </profile>
    </profiles>
</settings>

流水线可以这样写:

stage('maven编译'){
            agent {
                docker {
                     image 'maven:3-alpine'
                     args '-v /var/jenkins_home/appconfig/maven/.m2:/root/.m2'
            //       args  '-v /a/settings.xml:/app/settings.xml'
            //       docker run -v /a/settings.xml:/app/settings.xml
                         }
              }
            steps {
             //git下载来的代码目录下
               sh 'pwd && ls -alh'
               sh 'mvn -v'
             //打包,jar.。默认是从maven中央仓库下载。 jenkins目录+容器目录;-s指定容器内位置
             //只要jenkins迁移,不会对我们产生任何影响
             //workdir
             //每一行指令都是基于当前环境信息。和上下指令无关
               sh 'mvn clean package -s "/var/jenkins_home/appconfig/maven/settings.xml"  -Dmaven.test.skip=true '
             //jar包推送给maven repo ,nexus
              }
//原理: jenkins在解析流水线期间,可以任意访问jenkins家目录的位置和相关环境信息

args '-v /var/jenkins_home/appconfig/maven/.m2:/root/.m2':

maven依赖的jar包挂载到对应位置,这样多次构建同一个项目无需再次重复下载jar包。

但是这样依然会报错,这涉及到了临时容器的问题

临时容器问题

临时容器导致的问题

  1. 第一次检出代码,默认在 /var/jenkins_home/workspace/cicdtest (这个才是实际的工作空间,也就是我们项目存储在jenkins的地址)
  2. 但是当使用docker临时agent的时候,每一个临时容器运行又分配临时目录 /var/jenkins_home/workspace/cicdtest@1;这里面放到就是上面workspace/cicdtest的内容
  3. 在临时容器里面运行的mvn package命令时,实际所有maven命令操作都会在这个/var/jenkins_home/workspace/cicdtest@1临时目录进行操作
  4. package包实际是打到了 /var/jenkins_home/workspace/cicdtest@1 位置 ,最大的问题是临时目录用完就删除了,我们打的包实际也消失了。
  5. 当我们走到下一步进行打包镜像时,实际又是在 /var/jenkins_home/workspace/cicdtest这个实际工作目录
  6. 但是这个位置没有运行过 mvn clean package ,没有生成target。

总结就是我们执行maven命令的工作地址是个临时目录,执行完命令相当于什么都没有做所以会报错。

为了解决这个问题,就不要在临时空间进行操作,直接cd到真实的工作目录执行命令。

pipeline{

    //任何一个代理可用即可执行
    agent any
    //定义一些环境信息
    environment {
      hello = "mella"
      WS = "${WORKSPACE}"
    }

    //定义流水线的加工流程
    stages {
        //
        stage('环境检查'){
            steps {
               echo "$hello"
               sh 'pwd && ls -alh'
               sh 'printenv'
               sh 'echo ${GIT_BRANCH}'
            }
        }
        stage('maven编译'){
            agent {
                docker {
                     image 'maven:3-alpine'
                     args '-v /var/jenkins_home/appconfig/maven/.m2:/root/.m2'
            //                     args  '-v /a/settings.xml:/app/settings.xml'
                                //docker run -v /a/settings.xml:/app/settings.xml
                         }
              }
            steps {
             //git下载来的代码目录下
               sh 'pwd && ls -alh'
               sh 'mvn -v'
             //打包,jar.。默认是从maven中央仓库下载。 jenkins目录+容器目录;-s指定容器内位置
             //只要jenkins迁移,不会对我们产生任何影响
               sh "echo 默认的工作目录:${WS}"
             //                sh 'cd ${WS}'
             //workdir
             //每一行指令都是基于当前环境信息。和上下指令无关
               sh 'cd ${WS} && mvn clean package -s "/var/jenkins_home/appconfig/maven/settings.xml"  -Dmaven.test.skip=true '
             //jar包推送给maven repo ,nexus
             //如何让他适用阿里云镜像源
              }
        }
        stage('生成镜像'){
            steps {
                echo "打包..."
                //检查Jenkins的docker命令是否能运行
                sh 'docker version'
                sh 'pwd && ls -alh'
                sh 'docker build -t mella-test .'
                //镜像就可以进行保存
               }
        }
        stage('部署'){
            steps {
                echo "部署..."
                sh 'docker rm -f java-devops-demo-dev'
                sh 'docker run -d -p 8888:8888 --name mellaservertest mella-test'
            }
        }
    }
        post {
              failure {
                echo "这个阶段 完蛋了.... $currentBuild.result"
              }
              success {
                echo "这个阶段 成了.... $currentBuild.result"
              }
            }

}

要注意 开始定义了一个全局变量WS = "$",这个是取出实际环境变量的地址赋值给WS,然后在下面 操作,如果直接在下面使用 cd "$" 进入的仍然是临时目录,而且进入目录和相关操作必须写在一行,不然进入的还是临时目录。

打包镜像就需要用的我们之前编写的Dockerfile,直接copy就可以用了,然后就是部署,首先强制删除以前的镜像再部署新镜像。

post是后置处理,当任何一个阶段出错都会报错,也可以单独配置到某一个阶段。

目前为止简单的CICD已经完成了:

image20210817153420491.png

其他功能植入

发送邮件通知

1、首先要在系统配置中加上系统管理员地址,由于现在访问不了gmail,就先用qq代替。
image20210817164335958.png
2、然后qq邮箱开启pop3服务:
image20210817164652994.png
会获得一串授权码,记得复制,当账户密码用。

3、账号密码各种空能填的都填上
image20210817165857806.png
4、邮件模板

emailext body: '''<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>${ENV, var="JOB_NAME"}-第${BUILD_NUMBER}次构建日志</title>
</head>
<body leftmargin="8" marginwidth="0" topmargin="8" marginheight="4"
      offset="0">
<table width="95%" cellpadding="0" cellspacing="0" style="font-size: 11pt;
font-family: Tahoma, Arial, Helvetica, sans-serif">
    <h3>本邮件由系统自动发出,请勿回复!</h3>
    <tr>
        <br/>
        以下为${PROJECT_NAME }项目构建信息</br>
        <td><font color="#CC0000">构建结果 - ${BUILD_STATUS}</font></td>
    </tr>
    <tr>
        <td><br/>
            <b><font color="#0B610B">构建信息</font></b>
            <hr size="2" width="100%" align="center"/>
        </td>
    </tr>
    <tr>
        <td>
            <ul>
                <li>项目名称 : ${PROJECT_NAME}</li>
                <li>构建编号 : 第${BUILD_NUMBER}次构建</li>
                <li>触发原因: ${CAUSE}</li>
                <li>构建状态: ${BUILD_STATUS}</li>
                <li>构建日志: <a
                        href="${BUILD_URL}console">${BUILD_URL}console</a></li>
                <li>构建 Url : <a href="${BUILD_URL}">${BUILD_URL}</a></li>
                <li>工作目录 : <a
                        href="${PROJECT_URL}ws">${PROJECT_URL}ws</a></li>
                <li>项目 Url : <a href="${PROJECT_URL}">${PROJECT_URL}</a>
                </li>
            </ul>
            <h4><font color="#0B610B">最近提交</font></h4>
            <ul>
                <hr size="2" width="100%"/>
                ${CHANGES_SINCE_LAST_SUCCESS, reverse=true, format="%c", changesFormat="
                <li>%d [%a] %m
                </li>
                "}
            </ul>
            详细提交: <a href="${PROJECT_URL}changes">${PROJECT_URL}changes</a><br/>
        </td>
    </tr>
</table>
</body>
</html>''', subject: '${ENV, var="JOB_NAME"}-第${BUILD_NUMBER}次构建日志', to: 'flitsneak@gmail.com'

一般来说通知要通知到jira,禅道或者钉钉等。

发布版本到镜像仓库

发布镜像到镜像仓库,发布版本。
阿里云举例,实际应用的是aws的ECR,但是ECR操作太简单了,所以以阿里云的ACR为例。

首先是开通个人镜像仓库:
image20210819205822534.png

然后设置登录镜像仓库的密码,接着创建一个镜像仓库。
image20210819210217439.png

可以看到支持很多仓库自动构建镜像,我们不选择这种方式而是选择本地仓库手动确认生成镜像。

然后会看到阿里云推送镜像的指令:

#登录阿里云
docker login --username=flits**** registry.cn-beijing.aliyuncs.com
#推送镜像
docker tag [ImageId] registry.cn-beijing.aliyuncs.com/flitsneak/mellaserver:[镜像版本号]
docker push registry.cn-beijing.aliyuncs.com/flitsneak/mellaserver:[镜像版本号]

实际写法:

docker login -u ${ALIYUN_USR} -p ${ALIYUN_PSW}   registry.cn-beijing.aliyuncs.com

docker tag cicdtset registry.cn-beijing.aliyuncs.com/flitsneak/mellaserver:${APP_VER}

docker push registry.cn-beijing.aliyuncs.com/flitsneak/mellaserver:${APP_VER}

注意到我们引用了变量代替了账户密码,这个其实是系统的credentials,除了配置系统变量还可以配置阶段变量,先看系统变量如何配置:

首先进入jenkins,选择系统管理———》Manage Credentials
image20210820062705576.png

可以看到我们添加的全局凭据,我们之前设置过gitee私有仓库。

现在添加阿里云镜像仓库:
image20210820064046253.png

添加账户密码就可以了,还要命名一个ID方便取引用变量,这样在Jenkinsfile中声明变量:

environment {
      //引用Jenkins配置的全局秘钥信息
      ALITUN=credentials("flistsneakrepo")
    }

取用相应的账户密码就是加对应下划线即可。

至于stage里定义局部credentials使用片段生成器即可。

image20210820064950850.png

然后选择separated,给账户密码分别起个引用别名,用的时候直接引用即可。

这样推送镜像就OK了。

还有参数化构建,手动选择input等等太简单就不搞了。

通过脚本及Generic Webhook,可以实现选择部署,这个是个大工程以后待完善。

监听拉取固定分支

Generic Webhook 动态部署。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议