ローカル環境に簡易 CI/CD 環境を構築して試す
最近では CI/CD を意識した開発やアプリケーションをコンテナ化して kubernetes (以下 k8s) クラスタ上で運用していくのが主流となっています。
というわけで、今回はローカル環境に簡易的な CI/CD 環境を構築して最近の開発の流れを体験します。
環境
CI/CD コンポーネント
アプリケーションはコンテナ化してクラスタで動作させることを前提とします。そのため、アプリケーションのソースコード、コンテナイメージの管理を行うコンポーネントとして以下の OSS を使います。
- ソースコード管理 : Gitlab
- コンテナイメージ管理 (イメージレジストリ) : Harbor
CI を実行するプラットフォームはいくつか候補が考えられますが、せっかくなので前回の記事 OSS ワークフローツール Concourse を使ってみる で紹介した Concourse を使います。
CD 部分を担当するプラットフォームは、k8s 分野で広く普及している Argocd を使います。
これらを組み合わせて下図のようなローカル環境で動作する CI/CD 環境を構築します。
今回は構築・インストール手順は割愛します。既にこの環境は構築済みとして CI/CD 部分の動作に注目します。
ノードの詳細
CI/CD 環境で実際に開発することを意識して、アプリケーションを検証するための環境を模擬した 検証、本番 k8s クラスタ 2 つを用意します。また、Argocd を稼働するためのクラスタも用意します。各クラスタは以下のクラスタ名を付けて区別します。
- 検証環境クラスタ: k8s-dev
- 本番環境クラスタ: k8s-prod
- Argocd 用クラスタ: k8s-argocd
各クラスタを構成するノードの情報は以下になります。
k8s-prod
クラスタ
ノード名 | IP アドレス | 役割 |
---|---|---|
k8s-prod-master | 192.168.3.204 | control plane |
k8s-prod-worker | 192.168.3.205 | worker node |
k8s-dev
クラスタ
ノード名 | IP アドレス | 役割 |
---|---|---|
k8s-dev-master | 192.168.3.206 | control plane |
k8s-dev-worker | 192.168.3.207 | worker node |
k8s-argocd
クラスタ
- ノード数節約のため 1 ノードのみで構成。taint して control plane に pod が乗るようにする。
ノード名 | IP アドレス | 役割 |
---|---|---|
k8s-argo | 192.168.3.208 | control plane |
クラスタを図示すると以下のようになります。
また、CI/CD 関連で以下のコンポーネントのノードも立てておきます。利便性のためローカル環境で動作するドメイン名も適当に付けておきます。
ドメイン名 | IP アドレス | 用途 |
---|---|---|
gitlab.centre.com | 192.168.3.21 | Gitlab サーバ |
harbor.centre.com | 192.168.3.51 | Harbor イメージレジストリ |
concourse.centre.com | 192.168.3.53 | Concourse CI |
Argocd による CD
Argocd でアプリケーションをデプロイするための設定をします。
Argocd は helm を使ってインストールした直後の何も設定されていない状態を想定しています。また、以降の作業は k8s-argo ノード上で行います。
context の設定
いちいち他のノードに ssh して変更を加えるのは面倒なので、k8s-argo ノードから対象のクラスタに直接アクセスできるよう context を設定しておきます。
また、以下をインストールしておくと作業に便利です。
これにより kubectl ctx [context_name]
でクラスタを切り替えて作業できます。
(⎈|k8s-argocd:argocd) $ kubectl ctx
k8s-argocd
k8s-dev
k8s-prod
クラスタの追加
リソースのデプロイ先クラスタを Argocd に登録します。ドキュメント ではマニフェストに Secret として記述することで宣言的に登録できると記載されていますが、手元環境だとエラーで登録できなかったのでひとまず CLI から登録します。
$ argocd cluster add k8s-prod --insecure --yes
$ argocd cluster add k8s-dev --insecure --yes
登録に成功すると、検証・本番環境および元から合った argocd 稼働クラスタの 3 つが登録済みとなります。
$ argocd cluster list
SERVER NAME VERSION STATUS MESSAGE PROJECT
https://10.0.0.14:6443 k8s-dev 1.26 Successful
https://10.0.0.12:6443 k8s-prod 1.26 Successful
https://kubernetes.default.svc in-cluster 1.26 Successful
アプリケーションの準備
CD の動作を確認するため、クラスタ上へデプロイするアプリケーションを準備します。アプリケーションは何でも良いですが、今回はごく単純な flask アプリケーションを用意します。
- port 5000 にアクセスすると 環境変数
REGION
に設定されている値を用いてThis is {region}
というメッセージを返す。 - 何も設定されていない場合は
This is default
を返す。
#!/usr/bin/env python
import os
from flask import Flask, jsonify
app = Flask(__name__)
region = os.getenv("REGION", "default")
@app.route("/")
def hello_world():
ret = {"message": f"This is {region}"}
return jsonify(ret)
if __name__ == "__main__":
app.run(host="0.0.0.0")
FROM python:3.11-slim-bookworm
COPY main.py /
RUN pip install flask
ENTRYPOINT [ "python" ]
CMD ["/main.py"]
あとで Deployment で対象クラスタにデプロイするので、コンテナイメージ flask-k8s-example
としてビルドし、コンテナレジストリ harbor (ドメイン名: harbor.centre.com) に push しておきます。
docker built -t harbor.centre.com/k8s/flask-k8s-example:latest .
docker push harbor.centre.com/k8s/flask-k8s-example:latest
次にこのアプリケーションを k8s 上にデプロイするためのマニフェストを用意します。
今回は検証・本番環境の 2 つのクラスタを用意したので、それぞれに環境別の設定を適用してみます。
環境別に異なる設定でデプロイするには kustomize を使ったマニフェストを作成します。
.
├── base
│ ├── deployment.yml
│ ├── kustomization.yaml
│ └── svc.yml
└── overlays
├── dev
│ ├── deployment.yml
│ └── kustomization.yaml
└── prod
├── deployment.yml
└── kustomization.yaml
base/deployment.yml
は先ほど作成したコンテナイメージを使用したアプリケーションを動作させる deployment の定義となっています。
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-k8s-example
spec:
selector:
matchLabels:
app: flask-k8s-example
replicas: 2
template:
metadata:
labels:
app: flask-k8s-example
spec:
containers:
- name: flask-k8s-example
image: harbor.centre.com/k8s/flask-k8s-example:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
dev, prod ではコンテナの環境変数 REGION
にそれぞれ別の値を設定してデプロイするように設定します。
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-k8s-example
spec:
template:
spec:
containers:
- name: flask-k8s-example
env:
- name: REGION
value: "dev"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-k8s-example
spec:
template:
spec:
containers:
- name: flask-k8s-example
env:
- name: REGION
value: "prod"
Argocd Best Practices に基づいてアプリケーションのソースコードとマニフェストはそれぞれ別レポジトリで管理します。ローカルに立てた gitlab (ドメイン名: gitlab.centre.com) の以下レポジトリで管理。
- ソースコード: https://gitlab.centre.com/gitlab/flask-k8s-app.git
- マニフェスト: https://gitlab.centre.com/gitlab/flask-k8s-manifest.git
アプリケーションの登録
デプロイを実行する前に、デプロイ対象のアプリケーションに関してソースの git repo URL, パス、及び展開先のクラスタを定義する Application リソースを argocd に登録する必要があります。こちらは Argocd Applications に沿ってマニフェストを作ることで宣言的に作成できます。
dev.yml は k8s-dev クラスタ、prod.yml は k8s-prod クラスタにデプロイする設定のため、server
にそれぞれのクラスタのエンドポイントを設定します。今回は control plane が 1 台だけなので [control plane ノードの IP アドレス]:[api server port]
となっています。
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: flask-dev
namespace: argocd
spec:
project: default
source:
repoURL: https://gitlab.centre.com/gitlab/flask-k8s-manifest.git
targetRevision: HEAD
path: overlays/dev
destination:
server: https://10.0.0.14:6443
namespace: default
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: flask-prod
namespace: argocd
spec:
project: default
source:
repoURL: https://gitlab.centre.com/gitlab/flask-k8s-manifest.git
targetRevision: HEAD
path: overlays/prod
destination:
server: https://10.0.0.12:6443
namespace: default
kubectl apply
で登録。
$ kubectl apply -f dev.yml -f prod.yml
argocd app list
でそれぞれ登録されていれば ok.
$ argocd app list
NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY CONDITIONS REPO PATH TARGET
argocd/flask-dev https://10.0.0.14:6443 default default Synced Healthy <none> <none> https://gitlab.centre.com/gitlan/flask-k8s-manifest.git overlays/dev HEAD
argocd/flask-prod https://10.0.0.12:6443 default default Synced Healthy <none> <none> https://gitlab.centre.com/gitlan/flask-k8s-manifest.git overlays/prod HEAD
デプロイ
これから実際に Argocd を使ってデプロイの動作を確認していきます。
まずは argocd app sync [app_name]
で手動でデプロイを実行します。
$ argocd app sync flask-dev
$ argocd app sync flask-prod
デプロイが成功したかどうか確認するため対象クラスタ上のリソースを見ても良いですが、今後の確認作業を簡単にするため、外部からクラスタ上にデプロイしたアプリケーションにアクセスできるように ingress を作成します。なお、nginx ingress controller などの ingress controller は事前に対象クラスタ上に展開済みとします。
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flask-dev
namespace: default
spec:
ingressClassName: nginx
rules:
- host: flask-dev.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flask-k8s-example
port:
number: 5000
- name: flask-dev
+ name: flask-prod
...
- - host: flask-dev.com
+ - host: flask-prod.com
それぞれの ingress を各クラスタに適用。
(⎈|k8s-dev:default) $ kubectl apply -f dev.yml
(⎈|k8s-dev:default) $ kubectl ctx k8s-prod
(⎈|k8s-prod:default) $ kubectl apply -f prod.yml
今回は nginx ingress controller を使用しているので、pod が動作しているノードの IP アドレス(つまり worker node の IP アドレス)に向かうように /etc/hosts
でアドレスを設定します。
192.168.3.205 flask-prod.com
192.168.3.207 flask-dev.com
これでクラスタ外部にある k8s-argo ノードからドメイン名でアプリケーションにアクセスできるようになったので、curl
でアプリケーションのレスポンスを確認します。
# Check app in dev environment.
$ curl -k https://flask-dev.com
{"message":"This is dev"}
# Check app in prod environment.
$ curl -k https://flask-prod.com
{"message":"This is prod"}
それぞれのクラスタ上の application にアクセスした際に別々の値が返ってきているので、argocd + kustomize によって各クラスタに環境別の設定を適用してデプロイできていることが確認できました。
CI との組み合わせ
ここまででとりあえず argocd を使って CD を実現することができたので、次は CI の方を考えていきます。
コンテナ化したアプリケーションを k8s で運用することを想定した場合、CI における基本的なフローは以下のようになります。
- アプリケーションコードを開発
- 差分を github (gitlab) に push
- テスト等を実行
- コードからコンテナイメージをビルド、レジストリに push
- 検証環境 (クラスタ) にデプロイ
アプリケーションの準備で作成した flask アプリケーションを修正し、コンテナ化、クラスタにデプロイすることを目指します。コードが単純なのでテストの工程を省くと、今回の CI フローにおいてやるべきことは以下のようになります。
- コードに改修を加え、gitlab 上のアプリケーションレポジトリに push する。
- concourse で CI を実行
- コンテナイメージのビルド・ harbor レジストリに push
- argocd デプロイをトリガ
- ビルドしたイメージ使用した deployment をクラスタにデプロイ
pipeline の作成
Concourse では上記のうち 2, 3 つ目のステップを実装することができます。Concourse での実行単位にしたがって、やるべき動作を以下の 4 つの task に分割します。
- gitlab からアプリケーションコードを pull
- コンテナイメージのビルド
- 作成したイメージを harbor レジストリに push
- argocd デプロイをトリガー
Argocd のデプロイを開発フロー中のどこで実行するかという点はいくつか選択肢が考えられますが、今回はこの pipeline の中で argocd CLI を使ってトリガーするような構成にします。これを行うためには argocd app sync [app_name]
コマンドが実行可能なコンテナイメージが必要となるため、argocd CLI をインストールしたイメージを作成します。
FROM ubuntu:latest
RUN apt-get update && apt-get install -y curl && \
curl -L https://github.com/argoproj/argo-cd/releases/download/v2.8.0/argocd-linux-amd64 -o argocd && \
chmod +x argocd && \
mv argocd /usr/local/bin && \
apt-get clean && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["argocd"]
これを harbor.centre.com/k8s/argocd-cli
としてビルドし、レジストリに push しておきます。
次に pipeline を定義する yaml を作成します。
Concourse ドキュメントでコンテナイメージをbuild & push する例として記載されている The Entire Pipeline をベースに以下のような変更を加えます。
- git repository, image registry を今回の環境で置き換え
- argocd のデプロイを実行する step を追加。
ローカル環境なので認証情報などはそのまま書いていますが、本格的な運用を意識する場合は vault 等の外部に保存するのが良いと思います。
resources:
- name: input-git
type: git
check_every: never
webhook_token: testtoken
public: true
icon: git
source:
uri: https://gitlab.centre.com/gitlab/flask-k8s-example.git
branch: main
username: xxx
password: xxx
skip_ssl_verification: true
- name: flask-k8s-example
type: registry-image
icon: docker
source:
repository: harbor.centre.com/k8s/flask-k8s-example
tag: latest
username: xxx
password: xxx
ca_certs:
- |
xxxxxxxxxxxxxxxxxxxxxxxxx
...
jobs:
- name: build-and-push
plan:
- get: input-git
trigger: true
- task: build
privileged: true
config:
platform: linux
image_resource:
type: registry-image
source:
repository: concourse/oci-build-task
inputs:
- name: input-git
outputs:
- name: image
params:
CONTEXT: input-git
run:
path: build
- put: flask-k8s-example
params:
image: image/image.tar
- task: deploy
privileged: true
config:
platform: linux
image_resource:
type: registry-image
source:
repository: harbor.centre.com/k8s/argocd-cli
tag: latest
username: xxx
password: xxx
ca_certs:
- |
xxxxxxxxxxxxxxxxxxxxxxxxx
...
run:
path: sh
args:
- -c
- |
argocd login argocd-dev.com --username admin --password xxx --insecure
argocd app sync flask-dev --force --replace
argocd app sync flask-prod --force --replace
concourse への登録
$ fly -t tutorial set-pipeline -p git-webhook -c ci.yml
$ fly -t tutorial unpause-pipeline -p git-webhook
webhook の登録
CI フローで gitlab にコードの変更が push されたタイミングで concourse pipeline を実行するには webhook を利用します。concourse では Resources/webhook_token にあるように、以下の url に POST リクエストを送信することで webhook 経由で pipeline を実行できます。
-
/api/v1/teams/[team_name]/pipelines/[pipeline_name]/resources/[resource_name]/check/webhook?webhook_token=[token_value]
- team_name : pipeline が存在するチーム名。デフォルトでは
main
. - pipeline_name : pipeline 名。今回は
git-webhook
として登録。 - resource_name : pipeline の中で trigger としてセットされたリソース名。今回は
input-git
という名前の git リソース。 - token_value : 上記のリソースで
webhook_token
に指定した文字列。
- team_name : pipeline が存在するチーム名。デフォルトでは
今回の環境では IP アドレス 192.168.3.52
, port 8080 のノードに concourse コンテナを立てているので、例えば curl を使ってトリガする場合は curl -XPOST 'http://192.168.3.52:8080/api/v1/teams/main/pipelines/git-webhook/resources/input-git/check/webhook?webhook_token=testtoken'
という url になります。
gitlab にアプリケーションコードの変更点が push された際に pipeline を実行するには、gitlab webhook を使ってこの url に webhook を飛ばすようにします。
アプリケーションコードの変更
CI の準備は完了したので flask アプリケーションの更新に取り掛かります。
変更点は何でも良いのですが、例えばアプリケーションにアクセスした際のレスポンスに新しいフィールドを追加するようにな変更を加えるとします。
- レスポンスに
cluster
フィールドを追加。 - value は環境変数
CLUSTER
を取得し、マニフェスト側で値を設定できるようにする。変数が未定義の場合はundefined
の文字列を入れる。
+ cluster = os.getenv("CLUSTER", "undefined")
@app.route("/")
def hello_world():
- ret = {"message": f"This is {region}"}
+ ret = {"message": f"This is {region}", "cluster": cluster}
CI/CD の実行
では、このコードを gitlab に push して CI/CD フローを開始します。
通常では main ブランチとは別のブランチに push するべきですが、今回はお試しでもあるのでそのまま main に push します。concourse pipeline が開始するとビルドやデプロイは自動で行われるので、pipeline が成功し argocd のデプロイが完了するのを待ちます。
argocd のデプロイまで完了したのを確認した後、実際にアプリケーションの変更点が対象クラスタ上にデプロイされていることを確認します。デプロイでの作業と同様、クラスタ上のアプリケーションにアクセスしてレスポンスを確認します。
$ curl https://flask-dev.com -k
{"cluster":"undefined","message":"This is dev"}
$ curl https://flask-prod.com -k
{"cluster":"undefined","message":"This is prod"}
レスポンスに cluster
が追加されているので、新しいイメージを使用した deployment が両クラスタにデプロイされたことがわかります。しかしアプリケーションのマニフェスト側は更新していないため、cluster
の値は環境変数が未定義の際の undefined
がセットされています。
アプリケーションの変更のみが必要なケースではアプリケーションコードの変更を gitlab に push するだけで良いですが、今回のようにマニフェスト側にも更新が必要なケースではそちらにも変更を適用する必要があります。
この点については実開発でどのような CI/CD 戦略をとるか等に左右されると思いますが、今回はマニフェスト側を手動で書き換える方法で対応します。マニフェストに以下の環境変数を追加して git push します。
diff --git a/overlays/dev/deployment.yml b/overlays/dev/deployment.yml
index 962901e..a3ac555 100644
--- a/overlays/dev/deployment.yml
+++ b/overlays/dev/deployment.yml
@@ -11,3 +11,5 @@ spec:
env:
- name: REGION
value: "dev"
+ - name: CLUSTER
+ value: "k8s-dev"
diff --git a/overlays/prod/deployment.yml b/overlays/prod/deployment.yml
index b10cf60..754aab0 100644
--- a/overlays/prod/deployment.yml
+++ b/overlays/prod/deployment.yml
@@ -11,3 +11,5 @@ spec:
env:
- name: REGION
value: prod
+ - name: CLUSTER
+ value: "k8s-prod"
これで無事に対象クラスタによって cluster
の値が変わるようになりました。
$ curl https://flask-prod.com -k
{"cluster":"k8s-prod","message":"This is prod"}
$ curl https://flask-dev.com -k
{"cluster":"k8s-dev","message":"This is dev"}
課題について
このように CI/CD 環境を組むことでビルド・テストやデプロイが自動で行えるようになり、いちいち手動で行う煩わしさから開放されたように思えます。しかし、実際の CI/CD 運用を想定するとまだまだ改善すべき課題があります。
- 開発ブランチにコードを push した際は開発クラスタのみにデプロイし、 main ブランチに merge したタイミングで prod 環境へのデプロイを行いたい。
- デプロイ後にアプリケーションに不具合が発覚した場合に環境を元のバージョンに戻したい(ロールバック処理)
- 機密情報はハードコーディングせず別で管理したい。
- organization や team での管理。
- etc...
この辺りは既存コンポーネントのまだ使っていない機能で工夫したり、それぞれの機能に特化した別のコンポーネントを導入することで改善できることが期待されます。
機会があれば試してみようと思います。
まとめ
ローカルに CI/CD 環境を作成して、アプリケーションの更新、イメージのビルド、デプロイの自動化を試しました。
必要な環境を構築するのがやや大変ですが、いったん作成してしまえば CI/CD の作業を自動化出来るので楽になるかと思います。
また、実際に CI/CD 環境を動かしてみることで、本格的な運用を考える際にはどのような事に気を使わなければならないのかという点がより理解できると思いました。
今回の範囲で使用したコードは Github に上げています。
Discussion