---
# System prepended metadata

title: 整合PKS CI/CD與Spring Boot APP
tags: [CI/CD, Java, 操作流程, Tomcat, Maven, SonarQube, GitLab, Demo, Jenkins, Spring Boot, PKS, PostgreSQL, DevOps]

---

---
title: '整合PKS CI/CD與Spring Boot APP'
disqus: hackmd
---

整合PKS CI/CD與Spring Boot APP
===
本文件主要說明如何佈署最新一版的PKS CI/CD環境，以及和之前版本的差異，並以Spring Boot APP為例，執行於建置完的CI/CD流程

[TOC]

## 流程架構與先前版本的差異
![](https://i.imgur.com/ARyHyMY.png)
藍色框：PKS Cluster
紫色框：Jenkins-Slave Pod
紅色框：Tomcat Image
1. 開發者Push Code到Gitlab
2. Gitlab觸發Webhook告知Jenkins發生Push Event
3. Jenkins在PKS上建立臨時的Jenkins-Slave Pod處理CI/CD
4. Jenkins-Slave下載CI/CD流程所須的Images，以及Gitlab上的Code
5. Jenkins-Slave利用Maven將Source Code包裝產生WAR檔
6. Jenkins-Slave將War檔及相關檔案利用Maven傳到SonarQube作分析
7. Jenkins-Slave以Tomcat為Base Image，利用Docker將第5步產生的WAR檔包裝進新的Tomcat Image
8. 將第7步Build完的Tomcat Image Push到Harbor
9. Jenkins-Slave透過K8S RESTful API佈署Tomcat Deployment
10. Tomcat Deployment Pull Harbor上對應的Tomcat Image並完成佈署

#### 版本差異

[第1版](https://drive.google.com/open?id=1M_w9Z1H-fWiijIQieCzV0ZRa_YoK0mOd) 
流程沒什麼不一樣，較大差異在Maven內建於Jenkins Pod，因此為了要讓Jenkins內的WAR檔可被Jenkins-Slave共用，需要讓Jenkins-Slave掛跟Jenkins一樣的PV，但是vSphere的PV僅支援ReadWriteOnce，因此需要額外佈署NFS Server來提供ReadWriteMany的PV

[第2版](https://drive.google.com/open?id=1l66xhqM9Sgr7MuQUPbdB5JnDL6wTa3su)
將Maven移出Jenkins，因此環境不需額外佈署NFS並能支援較多Job同時執行

加入GPG加密，加密敏感性資料，但Jenkins log一樣能看到明碼

CD部份將原本使用CLI的方式改為透過K8S RESTful API來執行，減少懂K8s的開發人員可透過Jenkinsfile操作PKS Cluster的風險

[第3版](https://drive.google.com/drive/folders/1QE-Y2bjHdPJTnkTWhqA9Ukg6mBbFZhMK?usp=sharing)
將前兩版的Static PV改為Dynamic PV，方便環境佈署。

前兩版的所有服務並無考慮TLS，所有服務都是跑http，但一般環境都是跑https，因此第3版將所有服務都加上TLS進行整合

由於GPG對客戶的使用體驗太麻煩，因此將GPG拿掉，改由在Jenkins設定敏感性資料(不可視)，並使Jenkins log顯示遮閉後的結果

在了解Maven的Lifecycle後，拿掉前兩版多餘的步驟，減少CI/CD時間

加入JaCoCo，使SonarQube可顯示單元測試後的覆蓋率

#### Jenkinsfile
可從Jenkinsfile對照上述的流程與差異說明
```gherkin=
def label = "mypod-${UUID.randomUUID().toString()}"
podTemplate(label: label, cloud: 'kubernetes',
  containers: [
    containerTemplate(name: 'maven', image: 'argonhiisi/cicd-mvn-openjdk8', ttyEnabled: true, command: 'cat'),
    containerTemplate(name: 'docker', image: 'docker:18.06', ttyEnabled: true),
    containerTemplate(name: 'curl', image: 'argonhiisi/curl-jq', ttyEnabled: true, command: 'cat')
  ],
  volumes: [
    hostPathVolume(hostPath: '/var/vcap/sys/run/docker/docker.sock', mountPath: '/var/run/docker.sock'),
    hostPathVolume(hostPath: '/etc/hosts', mountPath: '/etc/hosts')
  ]
) {
  node(label) {
    checkout scm
    def commitID = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()

    stage('Maven project package') {
      container('maven') {
        sh "mvn org.jacoco:jacoco-maven-plugin:prepare-agent clean  package  -Dautoconfig.skip=true   -Dmaven.test.skip=false  -Dmaven.test.failure.ignore=true"
      }
    }

    stage('SonarQube') {
      container('maven') {
        wrap([$class: 'MaskPasswordsBuildWrapper', varPasswordPairs: [[password: '${SONAR_TOKEN}', var: 'MASK_SONAR'],[password: '${SONAR_URL}', var: 'WORKER']]]){
        sh "mvn -U sonar:sonar -Dsonar.branch.name=master -Dsonar.host.url=${SONAR_URL} -Dsonar.login=${SONAR_TOKEN}"}
      }
    }


    stage('Build Image and Push to Harbor') {
      container('docker'){
        wrap([$class: 'MaskPasswordsBuildWrapper', varPasswordPairs: [[password: '${HARBOR_PASS}', var: 'HARBOR']]]){
        sh """
        mkdir -p /etc/war/test && cp $WORKSPACE/target/postgres.war /etc/war/test && cp $WORKSPACE/server.xml /etc/war/test && cp $WORKSPACE/Dockerfile /etc/war
        cd /etc/war && docker build -t harbor.inwinstack.com/cicd/tomcatapp:${commitID} -f /etc/war/Dockerfile .
        docker login harbor.inwinstack.com -u admin -p ${HARBOR_PASS}
        docker push harbor.inwinstack.com/cicd/tomcatapp:${commitID}
        """}
      }
    }

    stage('Depoloy yaml to PKS') {
      container('curl'){
        sh "sed -i 's/version/${commitID}/g' $WORKSPACE/k8s/yaml/web-app.yaml"

        def deploymentName = "myweb"
        wrap([$class: 'MaskPasswordsBuildWrapper', varPasswordPairs: [[password: '${PKS_TOKEN}', var: 'TOKEN']]]){
        def WORKLOAD_EXIST = sh (
          script: """
          curl -X GET \
               -H "Authorization: Bearer ${PKS_TOKEN}" \
               --cacert secret/ca.crt \
               https://my-cluster2:8443/apis/apps/v1/namespaces/default/deployments/${deploymentName} | jq -r '.status'
          """,
          returnStdout: true
        ).trim()

        sh """
        sed -i 's/deploymentName/${deploymentName}/g' $WORKSPACE/k8s/api/update.sh
        if [ "${WORKLOAD_EXIST}" = "Failure" ]
        then
          echo "Workload not exist, create the workload"
          cd $WORKSPACE/k8s && sh api/create.sh ${PKS_TOKEN}
        else
          echo "Workload is exist, update workload"
          cd $WORKSPACE/k8s && sh api/update.sh ${PKS_TOKEN}
        fi
        """}

      }
    }
  }
}
```

環境佈署
---

#### 事前準備
佈署Gitlab、PKS、Harbor（在此使用公司內部DT2環境）

#### Image Build
下載[第3版](https://drive.google.com/drive/folders/1QE-Y2bjHdPJTnkTWhqA9Ukg6mBbFZhMK?usp=sharing)內的image.tar並解包

```bash
tar xvf image.tar
```

資料夾對應範例Image


| File | Image 
| -------- | -------- 
| curl-base     | appropriate/curl:latest 
|curl-jq|argonhiisi/curl-jq
|jenkins|argonhiisi/jenkins-cert
|jnlp|argonhiisi/jnlp-slave
|maven|argonhiisi/cicd-mvn-openjdk8

其中jenkins和jnlp需要加入Gitlab的憑證，以下指令取得gitlab.example.com的憑證並覆蓋原資料夾下的mycert.crt
```bash
echo -n | openssl s_client -showcerts -connect gitlab.inwinstack.com:443 2>/dev/null| sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > mycert.crt
```

各Image有重新加入新憑證的需要重新Build Image並上傳至需要的映像倉庫

#### 佈署流程說明
下載[第3版](https://drive.google.com/drive/folders/1QE-Y2bjHdPJTnkTWhqA9Ukg6mBbFZhMK?usp=sharing)內的deploy.tar並解包
```bash
tar xvf deploy.tar
```

首先佈署Nginx Ingress
```bash
kubectl apply -f deploy/nginx-ingress/mandatory.yaml

kubectl apply -f deploy/nginx-ingress/lb-svc.yaml
```

準備SonarQube的憑證（若有需要Jenkins憑證，則同以下方式創建，在此創建環境為ubuntu）

執行以下指令創建工作環境
```bash
mkdir -m 0755 /etc/pki/mypbxCA

cd /etc/pki/mypbxCA

mkdir -m 0755 private/ certs/ newcerts/ crl/ 
```
建立 openssl 設定檔

```bash
cp /etc/ssl/openssl.cnf /etc/pki/mypbxCA/openssl.my.cnf

chmod 0600 /etc/pki/mypbxCA/openssl.my.cnf

touch /etc/pki/mypbxCA/index.txt

echo '01' > /etc/pki/mypbxCA/serial 
```

修改 openssl 設定，在 openssl.my.cnf 內增加以下設定(v3_ca是原本就有的)

```bash
[ v3_ca ]
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer
basicConstraints = CA:true
subjectAltName = @alt_names

[alt_names]
DNS.1 = sonar.inwinstack.com
```

執行以下指令產生CA的憑證和私鑰
> 執行後會需要輸入CA資訊，重點在Common Name (eg, your name or your server's hostname)部份不要輸入要簽署的網站FQDN，在此個人輸入是Inwinstack CA
```bash
openssl req -config openssl.my.cnf -new -x509 -extensions v3_ca -keyout private/CA_ROOT.key -out certs/CA_ROOT.crt -days 36500
```

修改 openssl 設定
> 主要修改dir、certificate、private_key路徑和檔名，這是最後一個簽發步驟會用到的，在此順便先改
```bash
[ CA_default ]

dir             = .             # Where everything is kept
certs           = $dir/certs            # Where the issued certs are kept
crl_dir         = $dir/crl              # Where the issued crl are kept
database        = $dir/index.txt        # database index file.
#unique_subject = no                    # Set to 'no' to allow creation of
                                        # several ctificates with same subject.
new_certs_dir   = $dir/newcerts         # default place for new certs.

certificate     = $dir/certs/CA_ROOT.crt        # The CA certificate
serial          = $dir/serial           # The current serial number
crlnumber       = $dir/crlnumber        # the current crl number
                                        # must be commented out to leave a V1 CRL
crl             = $dir/crl.pem          # The current CRL
private_key     = $dir/private/CA_ROOT.key # The private key
RANDFILE        = $dir/private/.rand    # private random number file

x509_extensions = usr_cert              # The extentions to add to the cert

# Comment out the following two lines for the "traditional"
# (and highly broken) format.
name_opt        = ca_default            # Subject Name options
cert_opt        = ca_default            # Certificate field options

```
在 openssl.my.cnf 內增加以下設定（少的補上，原本就有的不用理他）

```bash
[ req ]
req_extetions = v3_req

[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = sonar.inwinstack.com
```

建立sonarqube的私鑰和憑證簽署要求檔(CSR)
> 1. Country, State, Locality, Organization <這些基本資訊必須與 CARoot 憑證檔相同
> 2. Common Name：<輸入要簽發的網站名稱，必須是正確的 FQDN>
> 3. A challenge password []: <這裡不要輸入密碼，否則服務會無法自動重啟>

```bash
openssl req -config openssl.my.cnf -new -nodes -keyout private/sonar.key -out sonar.csr
```

簽署包含subjectAltName的sonarqube憑證
> 1. 簽署時，會要求輸入 CARoot 的金鑰密碼。
> 2. 建立 CSR 檔時所輸入的基本資訊必須與 CARoot 憑證的基本資訊相同，例如 Country, State, Locality,......，如果有一項不同，會無法簽署。
> 3. Certificate is to be certified until Jul 29 03:40:03 2013 GMT (365 days)
Sign the certificate? [y/n]: <輸入y，如果沒看此項，表示簽署不成功>
> 4. 1 out of 1 certificate requests certified, commit? [y/n] <輸入 y>
```bash
openssl ca -config openssl.my.cnf -in sonar.csr -out certs/sonar.crt -extensions v3_req
```
檢驗完成後的 Server 憑證檔
```bash
openssl x509 -in certs/sonar.crt -noout -text
```
結果如下（確認要有Subject Alternative Name）

```bash
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 1 (0x1)
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=TW, ST=Taiwan, L=Taipei, O=Inwinstack, OU=DT2, CN=Inwinstack CA/emailAddress=argon.l@inwinstack.com
        Validity
            Not Before: Jul 12 08:22:27 2019 GMT
            Not After : Jul 11 08:22:27 2020 GMT
        Subject: C=TW, ST=Taiwan, O=Inwinstack, OU=DT2, CN=sonar.inwinstack.com/emailAddress=argon.l@inwinstack.com
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:d1:2b:6e:5f:ea:0a:e0:6e:8e:7a:dc:7f:ce:60:
                    df:ec:f9:cd:ce:8d:4a:8d:f5:fa:dd:a7:b5:05:87:
                    f4:da:6c:e2:cb:22:c3:77:b1:e0:4f:30:ba:cc:4c:
                    09:5a:52:44:c5:46:a3:45:e2:29:3c:f5:cd:97:3c:
                    6f:99:4b:d7:6d:a5:3d:b9:a4:70:b9:c2:be:2c:5d:
                    74:f8:75:67:97:27:1b:2b:2a:80:cc:47:c9:55:7b:
                    ed:33:de:cf:65:c7:fb:cc:e7:8f:da:0a:87:52:d7:
                    fe:1b:3c:4e:0d:f6:4a:dc:bb:5b:27:88:a9:d9:3f:
                    de:83:40:e7:17:9a:84:dc:7a:de:eb:84:29:67:5a:
                    62:ea:a7:25:07:bd:62:0e:b1:8a:29:eb:5a:6e:ee:
                    16:43:ea:67:77:89:c1:0c:93:23:80:69:9f:ad:05:
                    39:de:d1:a0:74:14:a2:d6:0e:2d:f6:ce:b0:da:34:
                    82:b5:a5:d8:2e:60:78:65:ce:1c:a3:2b:b0:31:a1:
                    71:c9:54:f6:6a:c6:d3:a2:98:77:1b:0f:67:73:c3:
                    33:08:d4:12:ad:58:60:7e:89:26:86:a4:26:72:93:
                    d8:d3:0e:d9:5b:d1:a7:0c:9a:ec:84:46:3f:4c:f2:
                    df:92:6f:7f:6c:bf:44:8e:6a:95:14:f3:0e:9c:63:
                    e5:e1
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Basic Constraints: 
                CA:FALSE
            X509v3 Key Usage: 
                Digital Signature, Non Repudiation, Key Encipherment
            X509v3 Subject Alternative Name: 
                DNS:sonar.inwinstack.com
    Signature Algorithm: sha256WithRSAEncryption
         82:12:5e:c6:e9:de:2e:41:b5:aa:a9:94:2a:55:a3:a5:19:b3:
         86:cf:d6:e1:62:97:cb:07:d2:5c:eb:7b:5f:16:9a:8a:77:03:
         ca:1d:7b:06:3b:b9:5c:6a:4b:64:15:07:05:97:ac:53:f9:ab:
         2e:b0:e3:77:56:00:d0:a0:90:d6:4c:75:38:e4:30:85:3d:3f:
         4e:3c:69:67:23:86:93:11:1c:64:1c:bf:12:22:16:7a:45:41:
         7f:42:38:db:fb:d6:e5:10:57:19:75:12:b2:83:b5:44:ab:a7:
         5e:36:c0:44:0a:18:3c:ae:46:14:e2:0a:eb:88:44:b7:35:b0:
         08:08:62:75:bd:8b:a5:bb:17:fe:f1:c4:a9:fc:ae:dc:cc:c3:
         88:9b:fc:eb:ed:cd:65:08:34:a7:9a:f8:13:16:30:98:32:bc:
         ca:f0:f0:aa:bf:d2:25:6b:0e:95:ba:a9:c9:5f:64:fa:f5:8e:
         31:5a:76:cf:4c:91:8e:8a:32:1f:76:ab:86:87:87:4a:95:76:
         65:2a:5b:3e:40:35:2a:59:e2:df:55:18:d4:c0:a8:84:23:d5:
         e2:42:e4:23:3f:f8:be:c4:10:a8:db:a1:25:70:66:08:40:0c:
         cd:c0:f0:48:85:18:3e:6c:21:85:2f:a9:55:61:7b:99:38:d2:
         3f:a0:83:14

```


建立憑證Secret
```bash
kubectl create secret tls jenkins-ssl-secret --key=deploy/jenkins-pks/ssl/server.key --cert=deploy/jenkins-pks/ssl/server.crt

kubectl create secret tls sonar-ssl-secret --key=deploy/sonar-pks/ssl/server.key --cert=deploy/sonar-pks/ssl/server.crt
```

佈署SonarQube
```bash
kubectl apply -f deploy/sonar-pks/pv

kubectl apply -f deploy/sonar-pks/k8s-yaml/postgres

kubectl apply -f deploy/sonar-pks/k8s-yaml/sonar

kubectl apply -f deploy/sonar-pks/ingress
```

完成後進去sonarqube pod（以下pod名稱自行修改），將branch plug-in移到extensions/plugins內
```bash
kubectl exec -it sonarqube-6ff66d767b-rdpn6 bash

mv branch-2.0.0.jar extensions/plugins/

exit

kubectl delete po sonarqube-6ff66d767b-rdpn6
```

完成後開啟SonarQube UI建立Token、User和Password，點選 *Administration* 確定有以下畫面，不然Pipeline會無法正確執行check SCM
![](https://i.imgur.com/JhLeMfs.png)


佈署Jenkins
> 記得要先修改deploy/jenkins-pks/k8s-yaml/jenkins-deployment.yaml內的hostAliases為正確的對應
```bash
kubectl apply -f deploy/jenkins-pks/pv

kubectl apply -f deploy/jenkins-pks/k8s-yaml

kubectl apply -f deploy/jenkins-pks/ingress
```

完成後開啟UI，內部URL最後要設定為pks_worker_ip:NodePort（或是進入設定系統內修改Jenkins URL）
![](https://i.imgur.com/epo6m0H.png)

Jenkins須額外下載3個plug-in，下載方法之前的文件都有，在此不另外說明

1. Gitlab
2. Kubernetes
3. Mask Passwords Plugin

點選 *管理 Jenkins* 在 *設定全域安全性* 內取消勾選 *防範 CSRF 入侵*

點選 *管理 Jenkins* 進入 *設定系統* 在雲的部份新增k8s連線資訊，Name對應到Jenkinsfile的cloud名稱，可自行修改，設定如下(要加入SSL憑證請另外[參考](https://docs.google.com/document/d/1ZzIqkjbqxyKHO98AiW8AcVdqPLiZuivNCF-f2F-HAM0/edit?usp=sharing))

![](https://i.imgur.com/yuBZLDL.png)
![](https://i.imgur.com/VMyD0Ex.png)

點選 *新增作業* 選取 *Pipeline*

![](https://i.imgur.com/I45T5hK.png)

勾選 *參數化建置* 點選 *新增參數* 選取 *密碼參數*


|  參數 | 說明 | 
| -------- | -------- | 
|  SONAR_TOKEN    | SonarQube產生的Token     |
|SONAR_URL|http://pks_worker_ip:31002
|HARBOR_PASS | harbor的密碼
|PKS_TOKEN|PKS的Token，取得方法請[參考](https://docs.google.com/document/d/1VJakRVg0bn1Kf1H6Vp6GY3i2YiYC2q2L4l1YTvJey_M/edit?usp=sharing)

![](https://i.imgur.com/lqeVGL3.png)

最後請參考該說明內的[Jenkins Configuration](https://hackmd.io/OCumh2jsTD-C7pdMSpJP6g?view#Jenkins-Configuration)在Jenkins產生Token讓Gitlab連接即可

範例說明
---
下載[第3版](https://drive.google.com/drive/folders/1QE-Y2bjHdPJTnkTWhqA9Ukg6mBbFZhMK?usp=sharing)內的mvntest.tar並解包

```bash
tar xvf mvntest.tar
```
詳細範例說明請[參考](https://docs.google.com/document/d/1cLiIYsS7BGaSjrhaTupda3Vw-e3aP8ubA-ah48imOWk/edit?usp=sharing)

檔案結構，紅框部份是在原始範例上新增的檔案

![](https://i.imgur.com/9t2lXy6.png)




| 檔案 | 說明 |
| -------- | -------- |
| Dockerfile|Tomcat Image的Dockerfile| 
|Jenkinsfile|Jenkins-slave執行的Pipeline Script
|create.sh|kubectl create的RESTful API
|update.sh|kubectl replace的RESTful API
|web-app.yaml|要佈署Tomcat Image的Deployment yaml
|web-svc.yaml|Tomcat deployment的service yaml
|ca.crt|從PKS取得的ca.crt，取得方法同樣[參考](https://docs.google.com/document/d/1VJakRVg0bn1Kf1H6Vp6GY3i2YiYC2q2L4l1YTvJey_M/edit?usp=sharing)
|server.xml|Tomcat的設定檔

> 一般的Spring Boot範例在佈署於Tomcat後無法正常顯示，主因在於mvn package後會將網頁及設定包在WEB-INF下，但Tomcat無法直接讀取WEB-INF內的資料，需修改專案初始產生的主程式(有@SpringBootApplication的檔案)，以範例程式為例，該檔案為PostgresPcfApplication.java

原檔案如下
```code=
package com.inwinstack;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PostgresPcfApplication {

	public static void main(String[] args) {
		SpringApplication.run(PostgresPcfApplication.class, args);
	}

}
```

修改後如下，主要繼承SpringBootServletInitializer，另外覆寫 configure方法，加入java configuration，更多細節可[參考](https://coffee0127.github.io/blog/2017/04/26/convert-spring-boot-jar-to-war/)

```code=
package com.inwinstack;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
public class PostgresPcfApplication extends SpringBootServletInitializer {
  
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(PostgresPcfApplication.class);
    }
    
	public static void main(String[] args) {
		SpringApplication.run(PostgresPcfApplication.class, args);
	}

}
```

範例佈署測試結果
---
全流程請參考[測試影片](https://drive.google.com/file/d/1wf4YdbCopEwh6MVj0bu38SpZsdJJWr6e/view?usp=sharing)

確認最終Jenkins log有遮閉重要資料
![](https://i.imgur.com/U6ClZOV.png)

> 以下有兩個警告，第1個警告是建議密碼存在檔案內再Load進，不然有明碼洩漏問題，但由於在此我們使用Mask password plugin，因此沒有明碼問題，第2個警告由於該jenkins slave是暫時的pod且沒有掛PV，在Job完成後就會刪除，因此明碼洩漏問題風險很低 



![](https://i.imgur.com/R7q9Khk.png)



###### tags: `DevOps` `PKS` `CI/CD` `GitLab` `Jenkins` `Tomcat` `Demo` `操作流程` `Spring Boot` `SonarQube` `PostgreSQL` `Maven` `Java`

