基於k8s的CI/CD的實現
綜述
首先,本篇文章所介紹的內容,已經有完整的實現,可以參考這裡。
在微服務、DevOps和雲平台流行的當下,使用一個高效的持續集成工具也是一個非常重要的事情。雖然市面上目前已經存在了比較成熟的自動化構建工具,比如jekines,還有一些商業公司推出的自動化構建工具,但他們都不能夠很好的和雲環境相結合。那麼究竟該如何實現一個簡單、快速的基於雲環境的自動化構建系統呢?我們首先以一個Springboot應用為例來介紹一下整體的發布流程,然後再來看看具體如何實現。不發的步驟大體如下:
1.首先從程式碼倉庫下載程式碼,比如Gitlab、GitHub等;
2.接著是進行打包,比如使用Maven、Gradle等;
3.如果要使用k8s作為編排,還需要把步驟2產生的包製作成鏡像,比如用Docker等;
4.上傳步驟3的鏡像到遠程倉庫,比如Harhor、DockerHub等;
5.最後,下載鏡像並編寫Deployment文件部署到k8s集群;
如圖1所示:
圖1
從以上步驟可以看出,發布過程中需要的工具和環境至少包括:程式碼倉庫(Gitlab、GitHub等)、打包環境(Maven、Gradle等)、鏡像製作(Docker等)、鏡像倉庫(Harbor、DockerHub等)、k8s集群等;此外,還包括發布系統自身的數據存儲等。
可以看出,整個流程里依賴的環境很多,如果發布系統不能與這些環境解耦,那麼要想實現一個安裝簡單、功能快速的系統沒有那麼容易。那麼有沒有合理的解決方案來實現與這些環境的解耦呢?答案是有的,下面就分別介紹。
程式碼倉庫
操作程式碼倉庫,一般系統提供的都有對應Restful API,以GitLab系統提供的Java客戶端為例,如下程式碼:
<dependency>
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>4.17.0</version>
</dependency>
比如,我們想獲取某個項目的分支列表,如下程式碼所示:
public List<Branch> branchList(CodeRepo codeRepo, BranchListParam param) {
GitLabApi gitLabApi = gitLabApi(codeRepo);
List<Branch> list = null;
try {
list = gitLabApi.getRepositoryApi().getBranches(param.getProjectIdOrPath(), param.getBranchName());
} catch (GitLabApiException e) {
LogUtils.throwException(logger, e, MessageCodeEnum.PROJECT_BRANCH_PAGE_FAILURE);
} finally {
gitLabApi.close();
}
}
private GitLabApi gitLabApi(CodeRepo codeRepo) {
GitLabApi gitLabApi = new GitLabApi(codeRepo.getUrl(), codeRepo.getAuthToken());
gitLabApi.setRequestTimeout(1000, 5 * 1000);
try {
gitLabApi.getVersion();
}catch(GitLabApiException e) {
//如果token無效,則用帳號登錄
if(e.getHttpStatus() == 401 && !StringUtils.isBlank(codeRepo.getAuthUser())) {
gitLabApi = new GitLabApi(codeRepo.getUrl(), codeRepo.getAuthUser(), codeRepo.getAuthPassword());
gitLabApi.setRequestTimeout(1000, 5 * 1000);
}
}
return gitLabApi;
}
打包環境
我們以Maven為例進行說明,一般情況下,我們使用Maven打包時,需要首先安裝Maven環境,接著引入打包插件,然後使用mvn clean package命令就可以打包了。比如springboot自帶插件:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.5.6</version>
<configuration>
<classifier>execute</classifier>
<mainClass>com.test.Application</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
再比如,通用的打包插件:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.8.2</version>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/main/resources/assemble.xml</descriptor>
</descriptors>
<outputDirectory>../target</outputDirectory>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
等等。然後再通過運行mvn clean package
命令進行打包。那麼,在打包時如果要去除對maven環境的依賴,該如何實現呢?
可以使用嵌入式maven插件maven-embedder來實現。
具體可以這樣來做,首先在平台項目里引入依賴,如下:
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-embedder</artifactId>
<version>3.8.1</version>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-compat</artifactId>
<version>3.8.1</version>
</dependency>
<dependency>
<groupId>org.apache.maven.resolver</groupId>
<artifactId>maven-resolver-connector-basic</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>org.apache.maven.resolver</groupId>
<artifactId>maven-resolver-transport-http</artifactId>
<version>1.7.1</version>
</dependency>
運行如下程式碼,就可以對項目進行打包了:
String[] commands = new String[] { "clean", "package", "-Dmaven.test.skip" };
String pomPath = "D:/hello/pom.xml";
MavenCli cli = new MavenCli();
try {
cli.doMain(commands, pomPath, System.out, System.out);
} catch (Exception e) {
e.printStackTrace();
}
但是,一般情況下,我們通過maven的settings文件還會做一些配置,比如配置工作目錄、nexus私服地址、Jdk版本、編碼方式等等,如下:
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="//maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="//www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="//maven.apache.org/SETTINGS/1.0.0 //maven.apache.org/xsd/settings-1.0.0.xsd">
<localRepository>C:/m2/repository</localRepository>
<profiles>
<profile>
<id>myNexus</id>
<repositories>
<repository>
<id>nexus</id>
<name>nexus</name>
<url>//repo.maven.apache.org/maven2</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>nexus</id>
<name>nexus</name>
<url>//repo.maven.apache.org/maven2</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
<profile>
<id>java11</id>
<activation>
<activeByDefault>true</activeByDefault>
<jdk>11</jdk>
</activation>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.compilerVersion>11</maven.compiler.compilerVersion>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputEncoding>UTF-8</project.build.outputEncoding>
</properties>
</profile>
</profiles>
<activeProfiles>
<activeProfile>myNexus</activeProfile>
</activeProfiles>
</settings>
通過查看MavenCli類發現,doMain(CliRequest cliRequest)方法有比較豐富的參數,CliRequest的程式碼如下:
package org.apache.maven.cli;
public class CliRequest
{
String[] args;
CommandLine commandLine;
ClassWorld classWorld;
String workingDirectory;
File multiModuleProjectDirectory;
boolean debug;
boolean quiet;
boolean showErrors = true;
Properties userProperties = new Properties();
Properties systemProperties = new Properties();
MavenExecutionRequest request;
CliRequest( String[] args, ClassWorld classWorld )
{
this.args = args;
this.classWorld = classWorld;
this.request = new DefaultMavenExecutionRequest();
}
public String[] getArgs()
{
return args;
}
public CommandLine getCommandLine()
{
return commandLine;
}
public ClassWorld getClassWorld()
{
return classWorld;
}
public String getWorkingDirectory()
{
return workingDirectory;
}
public File getMultiModuleProjectDirectory()
{
return multiModuleProjectDirectory;
}
public boolean isDebug()
{
return debug;
}
public boolean isQuiet()
{
return quiet;
}
public boolean isShowErrors()
{
return showErrors;
}
public Properties getUserProperties()
{
return userProperties;
}
public Properties getSystemProperties()
{
return systemProperties;
}
public MavenExecutionRequest getRequest()
{
return request;
}
public void setUserProperties( Properties properties )
{
this.userProperties.putAll( properties );
}
}
可以看出,這些參數非常豐富,也許可以滿足我們的需求,但是CliRequest只有一個默認修飾符的構造方法,也就說只有位於org.apache.maven.cli包下的類才有訪問CliRequest構造方法的許可權,我們可以在平台項目里新建一個包org.apache.maven.cli,然後再創建一個類(如:DefaultCliRequest)繼承自CliRequest,然後實現一個public的構造方法,就可以在任何包里使用該類了,如下程式碼:
package org.apache.maven.cli;
import org.codehaus.plexus.classworlds.ClassWorld;
public class DefaultCliRequest extends CliRequest{
public DefaultCliRequest(String[] args, ClassWorld classWorld) {
super(args, classWorld);
}
public void setWorkingDirectory(String directory) {
this.workingDirectory = directory;
}
}
定義好參數類型DefaultCliRequest後,我們再來看看打包的程式碼:
public void doPackage() {
String[] commands = new String[] { "clean", "package", "-Dmaven.test.skip" };
DefaultCliRequest request = new DefaultCliRequest(commands, null);
request.setWorkingDirectory("D:/hello/pom.xml");
Repository repository = new Repository();
repository.setId("nexus");
repository.setName("nexus");
repository.setUrl("//repo.maven.apache.org/maven2");
RepositoryPolicy policy = new RepositoryPolicy();
policy.setEnabled(true);
policy.setUpdatePolicy("always");
policy.setChecksumPolicy("fail");
repository.setReleases(policy);
repository.setSnapshots(policy);
String javaVesion = "11";
Profile profile = new Profile();
profile.setId("java11");
Activation activation = new Activation();
activation.setActiveByDefault(true);
activation.setJdk(javaVesion);
profile.setActivation(activation);
profile.setRepositories(Arrays.asList(repository));
profile.setPluginRepositories(Arrays.asList(repository));
Properties properties = new Properties();
properties.put("java.home", "D:/java/jdk-11.0.16.2");
properties.put("java.version", javaVesion);
properties.put("maven.compiler.source", javaVesion);
properties.put("maven.compiler.target", javaVesion);
properties.put("maven.compiler.compilerVersion", javaVesion);
properties.put("project.build.sourceEncoding", "UTF-8");
properties.put("project.reporting.outputEncoding", "UTF-8");
profile.setProperties(properties);
MavenExecutionRequest executionRequest = request.getRequest();
executionRequest.setProfiles(Arrays.asList(profile));
MavenCli cli = new MavenCli();
try {
cli.doMain(request);
} catch (Exception e) {
e.printStackTrace();
}
}
如果需要設置其他參數,也可以通過以上參數自行添加。
鏡像製作
一般情況下,我們在Docker環境中通過Docker命令來製作鏡像,過程如下:
1.首先編寫Dockerfile文件;
2.通過docker build製作鏡像;
3.通過docker push上傳鏡像;
可以看出,如果要使用docker製作鏡像的話,必須要有docker環境,而且需要編寫Dockerfile文件。當然,也可以不用安裝docker環境,直接使用doker的遠程介面:post/build。但是,在遠程伺服器中仍然需要安裝doker環境和編寫Dockerfile。在不依賴Docker環境的情況下,仍然可以製作鏡像,下面就介紹一款工具Jib的用法。
Jib是Google開源的一套工具,github地址,它是一個無需Docker守護進程——也無需深入掌握Docker最佳實踐的情況下,為Java應用程式構建Docker和OCI鏡像, 它可以作為Maven和Gradle的插件,也可以作為Java庫。
比如,使用jib-maven-plugin插件構建鏡像的程式碼如下:
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<from>
<image>openjdk:13-jdk-alpine</image>
</from>
<to>
<image>gcr.io/dhorse/client</image>
<tags>
<tag>102</tag>
</tags>
<auth>
<!--連接鏡像倉庫的帳號和密碼 -->
<username>username</username>
<password>password</password>
</auth>
</to>
<container>
<ports>
<port>8080</port>
</ports>
</container>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
然後使用命令進行構建:
mvn compile jib:build
可以看出,無需docker環境就可以實現鏡像的構建。但是,要想通過平台類型的系統去為每個系統構建鏡像,顯然通過插件的方式,不太合適,因為需要每個被構建系統引入jib-maven-plugin插件才行,也就是需要改造每一個系統,這樣就會帶來一定的麻煩。那麼有沒有不需要改造系統的方式直接進行構建鏡像呢?答案是通過Jib-core就可以實現。
首先,在使用Jib-core的項目中引入依賴,maven如下:
<dependency>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-core</artifactId>
<version>0.22.0</version>
</dependency>
然後就可以直接使用Jib-core的API來進行製作鏡像,如下程式碼:
try {
JibContainerBuilder jibContainerBuilder = null;
if (StringUtils.isBlank(context.getProject().getBaseImage())) {
jibContainerBuilder = Jib.fromScratch();
} else {
jibContainerBuilder = Jib.from(context.getProject().getBaseImage());
}
//連接鏡像倉庫5秒超時
System.setProperty("jib.httpTimeout", "5000");
System.setProperty("sendCredentialsOverHttp", "true");
String fileNameWithExtension = targetFiles.get(0).toFile().getName();
List<String> entrypoint = Arrays.asList("java", "-jar", fileNameWithExtension);
RegistryImage registryImage = RegistryImage.named(context.getFullNameOfImage()).addCredential(
context.getGlobalConfigAgg().getImageRepo().getAuthUser(),
context.getGlobalConfigAgg().getImageRepo().getAuthPassword());
jibContainerBuilder.addLayer(targetFiles, "/")
.setEntrypoint(entrypoint)
.addVolume(AbsoluteUnixPath.fromPath(Paths.get("/etc/localtime")))
.containerize(Containerizer.to(registryImage)
.setAllowInsecureRegistries(true)
.addEventHandler(LogEvent.class, logEvent -> logger.info(logEvent.getMessage())));
} catch (Exception e) {
logger.error("Failed to build image", e);
return false;
}
其中,targetFiles是要構建鏡像的目標文件,比如springboot打包後的jar文件。
通過Jib-core,可以很輕鬆的實現鏡像構建,而不需要依賴任何其他環境,也不需要被構建系統做任何改造,非常方便。
鏡像倉庫
類似程式碼倉庫提供的Restful API,也可以通過Restful API來操作鏡像倉庫,以Harbor創建一個項目為例,程式碼如下:
public void createProject(ImageRepo imageRepo) {
String uri = "api/v2.0/projects";
if(!imageRepo.getUrl().endsWith("/")) {
uri = "/" + uri;
}
HttpPost httpPost = new HttpPost(imageRepo.getUrl() + uri);
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(5000)
.setConnectTimeout(5000)
.setSocketTimeout(5000)
.build();
httpPost.setConfig(requestConfig);
httpPost.setHeader("Content-Type", "application/json;charset=UTF-8");
httpPost.setHeader("Authorization", "Basic "+ Base64.getUrlEncoder().encodeToString((imageRepo.getAuthUser() + ":" + imageRepo.getAuthPassword()).getBytes()));
ObjectNode objectNode = JsonUtils.getObjectMapper().createObjectNode();
objectNode.put("project_name", "dhorse");
//1:公有類型
objectNode.put("public", 1);
httpPost.setEntity(new StringEntity(objectNode.toString(),"UTF-8"));
try (CloseableHttpResponse response = createHttpClient(imageRepo.getUrl()).execute(httpPost)){
if (response.getStatusLine().getStatusCode() != 201
&& response.getStatusLine().getStatusCode() != 409) {
LogUtils.throwException(logger, response.getStatusLine().getReasonPhrase(),
MessageCodeEnum.IMAGE_REPO_PROJECT_FAILURE);
}
} catch (IOException e) {
LogUtils.throwException(logger, e, MessageCodeEnum.IMAGE_REPO_PROJECT_FAILURE);
}
}
k8s集群
同樣,k8s也提供了Restful API。同時,官方也提供了各種語言的客戶端,下面以Java語言的客戶端為例,來創建一個deployment。
首先,引入Maven依賴:
<dependency>
<groupId>io.kubernetes</groupId>
<artifactId>client-java</artifactId>
<version>13.0.0</version>
</dependency>
然後,使用如下程式碼:
public boolean createDeployment(DeployContext context) {
V1Deployment deployment = new V1Deployment();
deployment.apiVersion("apps/v1");
deployment.setKind("Deployment");
deployment.setMetadata(deploymentMetaData(context.getDeploymentAppName()));
deployment.setSpec(deploymentSpec(context));
ApiClient apiClient = this.apiClient(context.getCluster().getClusterUrl(),
context.getCluster().getAuthToken(), 1000, 1000);
AppsV1Api api = new AppsV1Api(apiClient);
CoreV1Api coreApi = new CoreV1Api(apiClient);
String namespace = context.getProjectEnv().getNamespaceName();
String labelSelector = K8sUtils.getDeploymentLabelSelector(context.getDeploymentAppName());
try {
V1DeploymentList oldDeployment = api.listNamespacedDeployment(namespace, null, null, null, null,
labelSelector, null, null, null, null, null);
if (CollectionUtils.isEmpty(oldDeployment.getItems())) {
deployment = api.createNamespacedDeployment(namespace, deployment, null, null, null);
} else {
deployment = api.replaceNamespacedDeployment(context.getDeploymentAppName(), namespace, deployment, null, null,
null);
}
} catch (ApiException e) {
if (!StringUtils.isBlank(e.getMessage())) {
logger.error("Failed to create k8s deployment, message: {}", e.getMessage());
} else {
logger.error("Failed to create k8s deployment, message: {}", e.getResponseBody());
}
return false;
}
return true;
}
private ApiClient apiClient(String basePath, String accessToken, int connectTimeout, int readTimeout) {
ApiClient apiClient = new ClientBuilder().setBasePath(basePath).setVerifyingSsl(false)
.setAuthentication(new AccessTokenAuthentication(accessToken)).build();
apiClient.setConnectTimeout(connectTimeout);
apiClient.setReadTimeout(readTimeout);
return apiClient;
}
至此,關鍵的技術點已經介紹完了。
也可以參考其他文章:
《DHorse系列文章之操作手冊》