[DevOps] 利用 Jenkins 與 Ansible 搭建 CI/CD Pipeline
簡易 CI/CD Pipeline 設計原則
- 遵循 GitHub Flow,
master
分支永遠是經過驗證且可佈署的 - 建立新
Pull Request
時,自動 deploy 到 Staging 環境並執行Smoke Testing
,等團隊其他成員或主管完成Code Review
以及測試後才能Merge
至master
- 只有被
Tag
的 commit,才能 deploy 到 Production 環境(手動) - 為了方便測試,可以手動 deploy 其他 branch 到 Staging 環境
Pipeline 設定
Jenkinsfile 說明
- 全域環境變數設定
1
2
3
4
5
6
7
8environment {
# 登入 Docker Hub 的 credential
DOCKER_HUB_CREDS = credentials('dockerhub-downager')
# 設定 Staging 和 Production 環境 Postgresql UserName & Password
FLASK_POSTGRES_CREDS = credentials('flask-app-postgres-user-pass')
# 決定本次佈署是否要執行 db_migrate.sh 這隻腳本
DB_MIGRATION = false
} - 在 Jenkins Node 上 build 出 UnitTest 專用的 docker image,並進行測試
1
2
3
4
5
6stage('Test') {
steps {
sh 'docker build -t flask-realworld-example-app:UnitTest -f ./UnitTest_Dockerfile .'
sh 'docker run flask-realworld-example-app:UnitTest'
}
} - Build 出 Production 專用的 Image,並推至 Private Docker Registry,並用
COMMIT_ID
當作 Image Tag1
2
3
4
5
6
7stage('Build') {
steps {
sh 'docker login -u $DOCKER_HUB_CREDS_USR -p $DOCKER_HUB_CREDS_PSW'
sh 'docker build -t downager/flask-realworld-example-app:$GIT_COMMIT .'
sh 'docker push downager/flask-realworld-example-app:$GIT_COMMIT'
}
} - 每次觸發 Jenkins Job 時都會佈署至 Staging 環境,並執行 Smoke Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33stage('Deploy - Staging') {
environment {
# 設定本次 Ansible Playbook 要執行的 Host Group
HOST_GROUP = 'Staging'
# 設定本次 docker-compose 的 image tag,Staging 環境是 COMMIT_ID
IMAGE_TAG = sh(returnStdout: true, script: 'echo $GIT_COMMIT')
}
steps {
echo 'Deploy to staging servers'
ansiColor(colorMapName: 'xterm') {
ansiblePlaybook(
disableHostKeyChecking: true,
# 設定登入 Staging 環境的 SSH KEY
credentialsId: 'devops-ssh-key',
playbook: 'ansible/playbook-deploy-flask-app.yml',
inventory: 'ansible/hosts',
colorized: true)
}
echo 'Make a smoke testing on staging servers (MAX_RETRIES = 3)'
# 進行 Smoke Testing,如果連續三次都失敗才報錯退出
retry(3) {
ansiColor(colorMapName: 'xterm') {
ansiblePlaybook(
disableHostKeyChecking: true,
# 設定登入 Staging 環境的 SSH KEY
credentialsId: 'devops-ssh-key',
playbook: 'ansible/playbook-smoke-testing.yml',
inventory: 'ansible/hosts',
colorized: true)
}
}
}
} - 佈署至 Production 環境,只有觸發帶
Tag
的 Job 才會執行此階段1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40stage('Deploy - Production') {
when {
tag '*'
}
environment {
# 設定本次 Ansible Playbook 要執行的 Host Group
HOST_GROUP = 'Production'
# 設定本次 docker-compose 的 image tag,Production 環境是 Git tag
IMAGE_TAG = sh(returnStdout: true, script: 'echo $TAG_NAME')
}
steps {
echo 'Deploying only because this commit is tagged'
# 將本次 commit 的 image 打上 Git Tag,並推上 docker registry
sh 'docker tag downager/flask-realworld-example-app:$GIT_COMMIT downager/flask-realworld-example-app:$TAG_NAME'
sh 'docker push downager/flask-realworld-example-app:$TAG_NAME'
echo 'Deploy to production servers'
ansiColor(colorMapName: 'xterm') {
ansiblePlaybook(
disableHostKeyChecking: true,
# 設定登入 Production 環境的 SSH KEY
credentialsId: 'devops-ssh-key',
playbook: 'ansible/playbook-deploy-flask-app.yml',
inventory: 'ansible/hosts',
colorized: true)
}
echo 'Make a smoke testing on production servers (MAX_RETRIES = 3)'
# 進行 Smoke Testing,如果連續三次都失敗才報錯退出
retry(3) {
ansiColor(colorMapName: 'xterm') {
ansiblePlaybook(
disableHostKeyChecking: true,
# 設定登入 Production 環境的 SSH KEY
credentialsId: 'devops-ssh-key',
playbook: 'ansible/playbook-smoke-testing.yml',
inventory: 'ansible/hosts',
colorized: true)
}
}
}
}
Ansible Playbook 說明
playbook-deploy-flask-app.yml
- 建立資料夾以及設定權限
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23# 取得 Jenkinsfile 設定的 HOST_GROUP 環境變數,決定要 deploy 到 Staging 或 Production 環境
- hosts: "{{ lookup('env','HOST_GROUP') }}"
remote_user: devops
vars:
# 取得 DB_MIGRATION 環境變數,決定是否執行 DB migrate 步驟
DB_MIGRATION: "{{ lookup('env','DB_MIGRATION') }}"
tasks:
- name: Change ownership of /opt directory
file:
path: /opt
state: directory
owner: devops
group: devops
mode: "0775"
become: yes
- name: Create flask-realworld-example-app directory
file:
path: /opt/flask-realworld-example-app
state: directory
owner: devops
group: devops
mode: "0775" - 複製
docker-compose.yaml
,以及將 container 所需環境變數寫入.env
內1
2
3
4
5
6
7
8
9
10
11
12- name: Copy docker-compose file
copy:
src: docker-compose.yaml
dest: /opt/flask-realworld-example-app
- name: Create .env file
copy:
dest: /opt/flask-realworld-example-app/.env
content: |
TAG={{ lookup('env','IMAGE_TAG') }}
POSTGRES_USER={{ lookup('env','FLASK_POSTGRES_CREDS_USR') }}
POSTGRES_PASSWORD={{ lookup('env','FLASK_POSTGRES_CREDS_PSW') }} - 登入 Private Docker Registry,以及使用 docker-compose 管理容器編排
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18- name: Log into DockerHub
docker_login:
username: "{{ lookup('env','DOCKER_HUB_CREDS_USR') }}"
password: "{{ lookup('env','DOCKER_HUB_CREDS_PSW') }}"
- name: docker-compose down 'flask-realworld-example-app'
docker_compose:
project_src: /opt/flask-realworld-example-app
state: absent
register: output
- name: docker-compose up 'flask-realworld-example-app'
docker_compose:
project_src: /opt/flask-realworld-example-app
state: present
# 強迫重新拉取 image
pull: yes
register: output - 根據環境變數設定決定是否執行
./db_migrate.sh
腳本1
2
3- name: DB migrate - flask-realworld-example-app
command: docker exec flask-realworld-example-app bash -c './db_migrate.sh'
when: DB_MIGRATION == true
playbook-smoke-testing.yml
- 登入到主機中,進行簡易的本機測試,只要回傳的不是 HTTP 200 就會報錯,退出 Jenkins Job
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17# 取得 Jenkinsfile 設定的 HOST_GROUP 環境變數,決定要測試 Staging 或 Production 環境
- hosts: "{{ lookup('env','HOST_GROUP') }}"
remote_user: devops
vars:
HOST_NAME: "http://localhost:8080"
tasks:
- name: GET '/api/tags'
uri:
url: "{{ HOST_NAME }}/api/tags"
method: GET
status_code: 200
- name: GET '/api/articles'
uri:
url: "{{ HOST_NAME }}/api/articles"
method: GET
status_code: 200
docker-compose 說明
docker-compose.yaml
上的環境變數設定,都是由 Ansible Playbook 寫入.env
檔案中,如果需要人工介入重啟 docker-compose 時才不會缺少環境變數1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34version: '3'
services:
web:
container_name: flask-realworld-example-app
image: downager/flask-realworld-example-app:${TAG}
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/flask-app
volumes:
- ./app/migrations:/app/migrations
ports:
- 8080:80
restart: always
networks:
- web
db:
container_name: postgres
image: postgres:12.1
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRES_DB=flask-app
volumes:
- pgdata:/var/lib/postgresql/data/
ports:
- 5432:5432
restart: always
networks:
- web
networks:
web:
driver: bridge
volumes:
pgdata:
driver: local
Jenkins & GitHub 設定
請先參考本篇進行初步設定:
GitHub Repository 有變動時自動通知 Jenkins 2 進行編譯建置 (GitHub Plugin)調整 GitHub Webhook 設定,只勾選
Branch or tag creation / deletion
、Pull requests
、Pushes
回到 Jenkins,設定
Discover tags
設定預設行為
Suppress automatic SCM triggering
,只有master
和Pull Requests
觸發 Jenkins Job
成果截圖
當新的 Pull Request 建立時,會自動 Trigger Jenkins 執行測試,以及回報結果至 GitHub PR 上
只有帶
Tag
的事件才會進入Deploy - Production
階段Ansible Playbook 執行截圖
Jenkins BlueOcean 截圖
Jenkins Stage View
參考資料
- Using a Jenkinsfile - Handling credentials
- When using tags in Jenkins Pipeline
- Triggering a Jenkins Pipeline on ‘git push’
- Integrating Ansible with Jenkins in a CI/CD process
- How to disable automatic build from scm change in Jenkinsfile
- Ansible Playbook with Jenkins Pipeline
- Fix unique constraint on userprofile.user_id #29
- GitHub Flow 及 Git Flow 流程使用時機