🤖

ローカル環境に簡易 CI/CD 環境を構築して試す

2023/08/23に公開

最近では CI/CD を意識した開発やアプリケーションをコンテナ化して kubernetes (以下 k8s) クラスタ上で運用していくのが主流となっています。
というわけで、今回はローカル環境に簡易的な CI/CD 環境を構築して最近の開発の流れを体験します。

環境

CI/CD コンポーネント

アプリケーションはコンテナ化してクラスタで動作させることを前提とします。そのため、アプリケーションのソースコード、コンテナイメージの管理を行うコンポーネントとして以下の OSS を使います。

  • ソースコード管理 : Gitlab
  • コンテナイメージ管理 (イメージレジストリ) : Harbor

CI を実行するプラットフォームはいくつか候補が考えられますが、せっかくなので前回の記事 OSS ワークフローツール Concourse を使ってみる で紹介した Concourse を使います。
CD 部分を担当するプラットフォームは、k8s 分野で広く普及している Argocd を使います。
これらを組み合わせて下図のようなローカル環境で動作する CI/CD 環境を構築します。

Cannot load image

今回は構築・インストール手順は割愛します。既にこの環境は構築済みとして 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 を設定しておきます。
また、以下をインストールしておくと作業に便利です。

  • kubectx: context の切り替えが容易になる。
  • kube-ps1: 現在の context が shell 上に表示される。

これにより 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 を返す。
main.py
#!/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")
Dockerfile
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 の定義となっています。

base/deployment.yml
---
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 にそれぞれ別の値を設定してデプロイするように設定します。

overlays/dev/deployment.yml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-k8s-example
spec:
  template:
    spec:
      containers:
        - name: flask-k8s-example
          env:
            - name: REGION
              value: "dev"
overlays/prod/deployment.yml
---
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) の以下レポジトリで管理。

アプリケーションの登録

デプロイを実行する前に、デプロイ対象のアプリケーションに関してソースの 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] となっています。

dev.yml
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
prod.yml
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 は事前に対象クラスタ上に展開済みとします。

dev.yml
---
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
prod.yml
-  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 でアドレスを設定します。

/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 における基本的なフローは以下のようになります。

  1. アプリケーションコードを開発
  2. 差分を github (gitlab) に push
  3. テスト等を実行
  4. コードからコンテナイメージをビルド、レジストリに push
  5. 検証環境 (クラスタ) にデプロイ

アプリケーションの準備で作成した flask アプリケーションを修正し、コンテナ化、クラスタにデプロイすることを目指します。コードが単純なのでテストの工程を省くと、今回の CI フローにおいてやるべきことは以下のようになります。

  1. コードに改修を加え、gitlab 上のアプリケーションレポジトリに push する。
  2. concourse で CI を実行
    • コンテナイメージのビルド・ harbor レジストリに push
    • argocd デプロイをトリガ
  3. ビルドしたイメージ使用した deployment をクラスタにデプロイ

pipeline の作成

Concourse では上記のうち 2, 3 つ目のステップを実装することができます。Concourse での実行単位にしたがって、やるべき動作を以下の 4 つの task に分割します。

  • gitlab からアプリケーションコードを pull
  • コンテナイメージのビルド
  • 作成したイメージを harbor レジストリに push
  • argocd デプロイをトリガー

Argocd のデプロイを開発フロー中のどこで実行するかという点はいくつか選択肢が考えられますが、今回はこの pipeline の中で argocd CLI を使ってトリガーするような構成にします。これを行うためには argocd app sync [app_name] コマンドが実行可能なコンテナイメージが必要となるため、argocd CLI をインストールしたイメージを作成します。

Dockerfile
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 等の外部に保存するのが良いと思います。

ci.yml
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 に指定した文字列。

今回の環境では 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 の文字列を入れる。
main.py
+ 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 に上げています。

https://github.com/git-ogawa/zenn_resource/tree/main/articles/cicd

Discussion