🦾

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

2023/09/09に公開

この記事は ローカル環境に簡易 CI/CD 環境を構築して試す の続きになります。
記事内で使用するリソースは Github に置いてあります。

Motivation

前回の記事ではローカル CI/CD 環境を構築し、concourse を使ってコンテナイメージのビルド等の CI 処理を自動化していました。しかし、課題 で上げた git ブランチを分けて開発を進めていくことを考えた際に、現在の構成では処理を自動化することが難しい部分があります。というのも、現時点において concourse は webhook の payload の中身を見て処理を反映する機能が実装されていないためです。

Note that the request payload sent to this API endpoint is entirely ignored. You should configure the resource as if you're not using web hooks, as the resource config is still the "source of truth."

Github issue でも webhook payload に関する機能リエクストが上がっていますが、現時点では未対応となっています。

Gitlab の webhook に基づいて CI 処理を自動化することを考える場合、例えば以下のように payload の中身に基づいて処理を分けたい場面は多くあります。

  • push, merge 等のイベントに基づいてそれぞれ別の pipeline を実行する
  • payload の中身を見てイベントをフィルタリングする。
  • payload から値を抽出して pipeline 実行時のパラメータに設定する

よって、今回は webhook にも対応している tekton を使って既存の CI/CD 環境を改良していきます。
目標としては、concourse の部分を tekton で置き換え。 Gitlab の webhook イベントを処理して CI/CD によるビルドやデプロイの自動化を目指します。
また、実際にブランチを分けた開発を実践することで構築した tekton pipeline が正しく動作することを確認します。


今回目指す CI/CD 構成。concourse を tekton で置き換える。

tekton について

tekton は concourse と同じようにクラウドネイティブな CI を実現するためのプラットフォームです。tekton について検索すると既に概要や機能について詳しく解説している記事が出てくるので(例えば Tekton 徹底解説 など)、ここでは基本的な概要や手順の説明は割愛します。

tekton では concourse で行っていた処理をそのまま実装できる他、以下のようなメリット・デメリットがあります(私見を含む)。

  • メリット
    • 公式のドキュメントが充実しており、実際の運用に必要なことはドキュメントを読めばだいたいわかる。
    • webhook を含む機能が充実している。
    • tekton hub にユーザやコミュニティが作ったタスクが公開されており自由に使える。
    • task, pipeline が yaml で記述できる。
    • 開発が盛ん。
  • デメリット
    • concourse よりもコンポーネントが多く、はじめに覚えることが多い。
    • kubernetes の知識があることが前提。

concourse よりも機能は充実していますが、はじめの内はやや取っ掛かりづらさを感じるといったところでしょうか。

concourse からの移行

tekton としては以下のリソースをインストール済みとします。

task

ローカル CI/CD 環境で concourse から tekton に移行するにあたって、まずは concourse で行っていた以下の処理を tekton における Task オブジェクトで置き換えます。

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

tekton では Tekton Hub に様々なタスクが公開されており、上記の処理も Tekton Hub で公開されているタスクがほぼそのまま使用できます。

Git clone

git clone がそのまま使えます。

イメージのビルド・プッシュ

task 内でビルドを実行するため候補はいくつかありますが、kaniko を使ってイメージをビルド & プッシュするタスク Build and upload container image using Kaniko を使います。

argocd デプロイをトリガー

こちらも argocd があるので内容を少し修正して使用します。

task
apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: argocd-sync
  namespace: flask-app
  labels:
    app.kubernetes.io/version: "0.9"
  annotations:
    tekton.dev/pipelines.minVersion: "0.38.0"
    tekton.dev/categories: Deployment
    tekton.dev/tags: deploy
    tekton.dev/displayName: "argocd"
    tekton.dev/platforms: "linux/amd64"
spec:
  description: >-
    This task syncs (deploys) an Argo CD application and waits for it to be healthy.

    To do so, it requires the address of the Argo CD server and some form of
    authentication either a username/password or an authentication token.
  workspaces:
    - name: argocd-basic-auth
      description: |
        A workspace containing username and password to log in to argocd server.
  params:
    - name: applicationName
      description: name of the application to sync
      type: string
    - name: revision
      description: the revision to sync to
      default: HEAD
      type: string
    - name: flags
      default: --
      type: string
    - name: argocd-version
      default: v2.2.2
      type: string
    - name: server
      description: Argocd server name
      type: string
      default: argocd-dev.ops.com
    - name: timeout
      description: timeout until
      default: 60
    - name: imageName
      description: Image name
      type: string
      default: harbor.centre.com/k8s/argocd-cli
    - name: imageTag
      description: Image tag
      type: string
      default: latest
  steps:
    - name: sync
      image: $(params.imageName):$(params.imageTag)
      env:
        - name: ARGOCD_SERVER
          value: $(params.server)
        - name: ARGOCD_APPNAME
          value: $(params.applicationName)
        - name: ARGOCD_AUTH_PATH
          value: $(workspaces.argocd-basic-auth.path)
        - name: ARGOCD_TIMEOUT
          value: $(params.timeout)
      script: |
        #!/usr/bin/env sh
        export ARGOCD_USERNAME=$(cat ${ARGOCD_AUTH_PATH}/username)
        export ARGOCD_PASSWORD=$(cat ${ARGOCD_AUTH_PATH}/password)
        if [ -z "$ARGOCD_AUTH_TOKEN" ]; then
          yes | argocd login "$ARGOCD_SERVER" --username="${ARGOCD_USERNAME}" --password="${ARGOCD_PASSWORD}" --insecure;
        fi
        argocd app sync ${ARGOCD_APPNAME} --revision "$(params.revision)" "$(params.flags)" --replace

pipeline

concourse では pipeline のリソース内に個々のタスク処理を直接書いていましたが、tekton では一連の処理を行うために独立した pipeline オブジェクトを作成し、その中で個々の task を実行するように記述します。pipeline の中身は後で作成する際に書きます。

webhook の対応

tekton では eventlistener, triggers というオブジェクトを用いて webhook に対応することができます。こちらも後半で実際に使う際に詳細を書きます。

準備

tekton を使えば webhook を処理して pipeline を実行したりすることができるので、実際に前回記事で作成したアプリケーションの開発に活用してみます。

やりたいこと

前回の記事ではアプリケーションやマニフェストを修正する際 main ブランチに直接 push していましたが、今回は本格的な運用を想定し、以下のように本番用・開発用でブランチを分けます。

  • 開発中のコード変更は開発中ブランチ dev に push する。
  • 本番用の変更は dev -> main ブランチに PR を立てる。
  • PR で承認を受けた後 main マージし、新しいバージョンタグを追加する。
  • 新しいバージョンに対応するコンテナイメージをビルドし、本番環境用のクラスタにデプロイする

前回の記事で作成したアプリケーションのバージョンを v1.0.0とします。
今回の記事で機能を追加したアプリケーションをバージョン v1.1.0 として作成し、最終的に本番環境クラスタにデプロイすることを目標とします。

これらを踏まえると開発の流れは以下のようになります。

  1. マニフェストレポジトリに develop ブランチを作成
  2. アプリケーションレポジトリに develop ブランチを作成
  3. アプリケーションレポジトリ develop ブランチに機能追加したコードを push
  4. tekton pipeline でイメージビルド、開発用クラスタにデプロイ
  5. アプリケーションレポジトリで Pull Request を作成
  6. PR をマージ
  7. マージしたアプリケーションのバージョンを更新してタグ登録
  8. tekton pipeline でイメージビルド
  9. マニフェストレポジトリ develop ブランチにコードを push
  10. アプリケーションレポジトリで Pull Request を作成
  11. PR をマージ
  12. tekton pipeline で本番用クラスタにデプロイ

環境の定義

次に今回の開発に向けて環境の整理を行います。
Gitlab や k8s クラスタなど開発に必要なリソースは 前回の環境構築で作成したもの をそのまま使いまわします。
ただし今回は本番用、開発用で gitlab のブランチを分けるため、ブランチやイメージ、用語の設定を以下のように定義します。

アプリケーションレポジトリ

  • git レポジトリ名: https://gitlab.centre.com/gitlab/flask-k8s-app.git
  • ブランチ: 以下の 2 ブランチを用意
    • main: 本番環境用のコードを保管する。
    • dev: 開発環境用のコードを保管する。

マニフェストレポジトリ

  • git レポジトリ名: https://gitlab.centre.com/gitlab/flask-k8s-manifest.git
  • ブランチ: 以下の 2 ブランチを用意
    • main: 本番環境用のコードを保管する。
    • dev: 開発環境用のコードを保管する。

Git タグ

  • 前回作成したアプリケーションのバージョンを git tag v1.0.0 とする。
  • 今回作成するバージョンを v1.1.0 とする。

イメージ

  • 本番環境と開発環境でイメージ名、タグを以下のように分ける。
  • 本番用: harbor.centre.com/k8s/flask-k8s-example:1.0.0
    • イメージタグはアプリケーションのバージョンに一致させる。ただし v は除く
    • 今回作成するバージョンに対応するイメージタグは 1.1.0 となる。
  • 開発用: harbor.centre.com/k8s/flask-k8s-example-dev:latest
    • イメージ名に -dev をつけて本番用と区別する。
    • 開発用イメージのタグは常に latest とする。

k8s クラスタ

  • flask-prod: 本番用クラスタ
  • flask-dev: 開発用クラスタ
  • kube-argo: argocd, tekton インストール済みの作業用クラスタ

pipeline の起動タイミングと処理

今回も前回と同様に Gitlab の webhook を起点として tekton pipeline をトリガーする構成にします。
また、pipeline で行う処理について以下の様な要求があるとします。

  1. develop ブランチでは何度もコードの更新やビルド・テストを繰り返すことが想定されるので、アプリケーションコードが push されたタイミングでコードの更新・ビルド・クラスタの展開まで自動でやりたい。
  2. 本番用のアプリケーションは develop -> main のマージが完了し、新しいバージョンのタグが完了したタイミングでイメージビルドしたい。
  3. 本番環境へのアプリケーションの展開はマニフェストのタグが変更されたタイミングで行いたい。

これを実現するためには、pipeline の起動タイミングと pipeline 内で処理する内容を以下のように作れば良いことがわかります。

番号 起動タイミング pipeline の処理
1 アプリケーション dev ブランチへの push 時 git clone -> build -> argocd デプロイ
2 アプリケーション main ブランチへの tag push 時 git clone -> build
3 マニフェスト main ブランチへの merge 時 argocd デプロイ

リソースの作成

やりたいことが決まったので、次にこれを実現するための k8s および tekton リソースを作成していきます。

namespace

今回は作成するリソースが多いので flask-app という namespace に必要なリソースを作っていきます。

kubectl create namespace flask-app

Secret

tekton pipeline 内で git clone の実行、harbor レジストリへのイメージ push, argocd へのログインを行うための認証情報をそれぞれ secret として作成します。
ここでは簡単のためいずれも basic auth としていますが、セキュリティ面を考慮すると他の認証方法でも良いでしょう。

apiVersion: v1
kind: Secret
metadata:
  name: argocd-basic-auth
  namespace: flask-app
type: kubernetes.io/basic-auth
data:
  username: xxxx
  password: xxxx

---
kind: Secret
apiVersion: v1
metadata:
  name: gitlab-basic-auth
  namespace: flask-app
type: Opaque
stringData:
  .gitconfig: |
    [credential "https://<hostname>"]
      helper = store
  .git-credentials: |
    https://gitlab:password@gitlab.centre.com

---
apiVersion: v1
kind: Secret
metadata:
  name: harbor-basic-auth
  namespace: flask-app
data:
  config.json: |
    xxxx

serviceaccount

tekton pipeline を実行するための serviceaccount を作成します。この serviceaccount には eventlistener や trigger を参照するための権限、gitlab からの webhook を受け取って pipeline を実行するために pipelinerun, taskrun の作成権限を付加します。
権限は clusterrole として作成し、clusterrolebinding で serviceaccount に紐づけます。

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: webhook
  namespace: flask-app

---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: gitlab-webhook
  namespace: flask-app
rules:
  - apiGroups:
      - "triggers.tekton.dev"
    resources:
      - "clustertriggerbindings"
      - "clusterinterceptors"
      - "eventlisteners"
      - "triggerbindings"
      - "triggertemplates"
      - "triggers"
      - "interceptors"
    verbs:
      - "get"
      - "list"
      - "watch"
  - apiGroups:
      - "tekton.dev"
    resources:
      - "pipelineruns"
      - "taskruns"
    verbs:
      - "create"

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: gitlab-webhook
  namespace: flask-app
subjects:
  - kind: ServiceAccount
    name: webhook
    namespace: flask-app
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: gitlab-webhook

webhook の対応

pipeline の起動タイミングと処理 で決めたように、今回の範囲では 3 つの異なるタイミング発生する webhook をもとに pipeline をトリガーする必要があります。eventlistener の構成によっては複数の webhook を処理するようにできないこともないですが、ここでは素直にそれぞれの webhook イベントを処理する eventlistener を 1 つずつ作成します。

eventlistener

まず 1 つめの webhook に対応する eventlistener を作るためのマニフェストを準備します。
eventlistener では interceptor を使用することで event のフィルタリングや検証が可能となっています。interceptor にもいくつか種類がありユーザ独自の interceptor も作成可能ですが、今回は CEL interceptor を利用して webhook event のフィルタリングと payload の処理を行います。
フィルタリングでは、payload 内の特定のフィールドの値を確認し、条件に合ったときのみ指定した pipeline を実行する等の処理が記述できます。gitlab webhook が発生した時に payload 内にどのような値が設定されるかはGitlab ドキュメントの webhook events に一覧が表示されているので、これを確認しながらフィルタリング条件を決めていきます。

commit が特定のブランチに push された時に発生するイベントは Push event であるため、アプリケーションの dev ブランチへの push イベントを一意に定めるために以下のような条件でフィルタリングします 。

  • commit が push された時のイベントを補足するため、body.event_name == 'push' とする。
  • main ブランチ以外 (dev ブランチ) への push のみを補足するため、body.ref != 'refs/heads/main' とする。
  • アプリケーションレポジトリのみの push イベントを補足するため、body.project.path_with_namespace == 'gitlab/flask-k8s-example' とする。
interceptors:
  - ref:
      name: cel
    params:
      - name: "filter"
        value: body.ref != 'refs/heads/main'
      - name: "filter"
        value: body.project.path_with_namespace == 'gitlab/flask-k8s-example'
      - name: "filter"
        value: body.event_name == 'push'

CEL interceptors では overlays を使うことで payload に追加のフィールドを設定して後続の処理に渡すことができます。
ここでは pipeline 内で以下の情報を使いたいので文字列を payload に追加します。

  • branchName
    • pipeline 内で git clone を実行する際の対象レポジトリのブランチ名
  • imageName
    • pipeline 内でイメージをビルドする際のイメージ名
  • imageTag
    • 上記のイメージタグ名
  • argocdApplicationName
    • pipeline 内で argocd sync を実行する際の Application 名
interceptors:
  - ref:
      name: cel
    params:
      ...
      - key: branchName
        expression: "string('dev')"
      - key: imageName
        expression: "string('harbor.centre.com/k8s/flask-k8s-example-dev')"
      - key: imageTag
        expression: "string('latest')"
      - key: argocdApplicationName
        expression: "string('flask-dev')"

bindings, template ではこの eventlistener に紐付ける triggerbinding, triggertemplate のリソース名を指定します。これらは次で作成します。

      bindings:
        - ref: build-dev-image
      template:
        ref: build-dev-image

まとめると eventlistener マニフェストの中身は以下のようになります。

eventlistener
---
apiVersion: triggers.tekton.dev/v1alpha1
kind: EventListener
metadata:
  name: flask-app-dev
  namespace: flask-app
spec:
  serviceAccountName: webhook
  triggers:
    - name: event-dev-branch
      interceptors:
        - ref:
            name: cel
          params:
            - name: "filter"
              value: body.ref != 'refs/heads/main'
            - name: "filter"
              value: body.project.path_with_namespace == 'python/flask-k8s-example'
            - name: "filter"
              value: body.event_name == 'push'
            - name: "overlays"
              value:
                - key: branchName
                  expression: "string('dev')"
                - key: imageName
                  expression: "string('harbor.centre.com/k8s/flask-k8s-example-dev')"
                - key: imageTag
                  expression: "string('latest')"
                - key: argocdApplicationName
                  expression: "string('flask-dev')"
      bindings:
        - ref: build-dev-image
      template:
        ref: build-dev-image

pipeline

pipeline リソースでは push イベントを受け取って実際に実行する tekton pipeline を定義します。
以下 3 つのタスクを順次実行するように記述します。

git-clone

アプリケーションレポジトリからコードを clone するための git-clone タスクを実行するように指定します。
tekton workspace としては以下を指定。

  • 後続のタスクで clone したコードを共有するための persistentVolume を shared-data で指定。
  • git clone するための git 認証情報を git-credentials で指定。

git clone するレポジトリの URL, ブランチはパラメータとして外部から与える形式にします。

tasks:
  - name: git-clone
    taskRef:
      name: git-clone
    workspaces:
      - name: output
        workspace: shared-data
      - name: basic-auth
        workspace: git-credentials
    params:
      - name: url
        value: $(params.repositoryUrl)
      - name: revision
        value: $(params.branch)
build

次に clone したアプリケーションをビルドするタスクを定義します。
tekton workspace としては以下を指定。

  • clone したコードを共有するための persistentVolume を shared-data で指定。
  • ビルドしたイメージを harbor レジストリに push する際の認証情報を harbor-credentials で指定。

git clone した後にこのタスクが実行されるように runAfter で前のタスクを指定。

- name: build
  taskRef:
    name: kaniko
  runAfter:
    - git-clone
  workspaces:
    - name: source
      workspace: shared-data
    - name: dockerconfig
      workspace: harbor-credentials
  params:
    - name: IMAGE
      value: $(params.imageName):$(params.imageTag)
    - name: CONTEXT
      value: $(params.context)
deploy

最後にアプリケーションをクラスタにデプロイするタスクを実行するように指定します。
tekton workspace としては以下を指定。

  • argocd にログインし、argocd sync を実行するための認証情報を argocd-credentials で指定。
  • このタスクでは clone したコードを共有する必要はないため、shared-data の指定は不要。

デプロイ先のクラスタ(開発用 or 本番用)はパラメータとして外部から指定するようにします。

- name: deploy
  taskRef:
    name: argocd-sync
  runAfter:
    - git-clone
    - build
  workspaces:
    - name: argocd-basic-auth
      workspace: argocd-credentials
  params:
    - name: applicationName
      value: $(params.argocdApplicationName)

これらを spec.tasks で定義することで一連の task を pipeline として実行できるようになります。また、各タスクで使用するパラメータと workspace の定義はそれぞれ spec.params, spec.workspaces で行います。pipeline が実行される際にこれらの値をどのように設定するかはこのリソース内ではなく pipelineRun リソースで指定します。

最終的な pipeline リソースのマニフェストは以下のようになります。この pipeline は build-dev-image という名前を付けます。

pipeline
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: build-dev-image
  namespace: flask-app
spec:
  description: >-
    This pipeline run the sequence of tasks as a pipeline.
    1. Clone the application source from a git repository.
    2. Build an image from Dockerfile and push it to a registry.
    3. Deploy application by argocd sync.
  params:
    - name: repositoryUrl
      type: string
      description: The git repository URL to clone the source.
    - name: branch
      type: string
      description: The branch of the repository.
    - name: context
      description: The path to the build context, used by Kaniko - within the workspace
      default: "./"
    - name: imageName
      description: Image name
    - name: imageTag
      description: Image tag
      default: "latest"
    - name: argocdApplicationName
      description: The name of Argocd application to be synced.
  workspaces:
    - name: shared-data
      description: >-
        This workspace contains the cloned repo files, so they can be read by the
        next task.
    - name: git-credentials
      description: Name of the secret to log in to gitlab.
    - name: harbor-credentials
      description: Name of the secret to log in to harbor.
    - name: argocd-credentials
      description: Name of the secret to log in to argocd.
  tasks:
    - name: git-clone
      taskRef:
        name: git-clone
      workspaces:
        - name: output
          workspace: shared-data
        - name: basic-auth
          workspace: git-credentials
      params:
        - name: url
          value: $(params.repositoryUrl)
        - name: revision
          value: $(params.branch)
        - name: sslVerify
          value: "false"
    - name: build
      taskRef:
        name: kaniko
      runAfter:
        - git-clone
      workspaces:
        - name: source
          workspace: shared-data
        - name: dockerconfig
          workspace: harbor-credentials
      params:
        - name: IMAGE
          value: $(params.imageName):$(params.imageTag)
        - name: CONTEXT
          value: $(params.context)
    - name: deploy
      taskRef:
        name: argocd-sync
      runAfter:
        - git-clone
        - build
      workspaces:
        - name: argocd-basic-auth
          workspace: argocd-credentials
      params:
        - name: applicationName
          value: $(params.argocdApplicationName)

TriggerBinding

TriggerBinding にはこの eventlistener からパラメータを受け取って triggertemplate に渡すパラメータを定義します。

  • repositoryUrl
    • git clone するレポジトリの URL
    • push イベントの payload では元から body.project.http_url に設定されているため、その値を抽出する。

それ以外の値は eventlistener の cel interceptor payload に追加した値を抽出して変数にセットしています。

---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: build-dev-image
  namespace: flask-app
spec:
  params:
    - name: repositoryUrl
      value: $(body.project.http_url)
    - name: branch
      value: $(extensions.branchName)
    - name: imageName
      value: $(extensions.imageName)
    - name: imageTag
      value: $(extensions.imageTag)
    - name: argocdApplicationName
      value: $(extensions.argocdApplicationName)

triggertemplate

TriggerTemplate は templateBinding からパラメータを受け取って pipeline を実行する部分を定義します。pipeline は pipelineRun リソースを作成することでトリガーされるため、spec.resourcetemplates 以下で pipelineRun の中身を定義するような構成になっています。

まず、pipelineRef に実行する pipeline のリソース名を指定します。ここでは先ほど作成した pipeline 名を指定します。

spec:
  pipelineRef:
    name: build-dev-image

podTemplate には pipeline 実行用の pod の設定を書きます。
pipeline の中で使っている git-clone task では実行ユーザの指定があるため、securityContext で指定してます。

spec:
  podTemplate:
    securityContext:
      fsGroup: 65532
    imagePullSecrets:
      - name: harbor-basic-auth

workspaces には各 task で使用する workspace の定義を行います。

  • clone したコードを各タスクで共有するための shared-data には persistentVolume を使用するため、ここで PVC の定義を書いています。PV は事前に作成しておくか、openebs などの dynamic provisioner を使って動的に作成します。
  • 残りの credentials 系は事前に作成した secret のリソース名を指定します。
spec:
  workspaces:
    - name: shared-data
      volumeClaimTemplate:
        spec:
          storageClassName: openebs-hostpath
          accessModes:
            - ReadWriteOnce
          resources:
            requests:
              storage: 1Gi
    - name: git-credentials
      secret:
        secretName: gitlab-basic-auth
    - name: harbor-credentials
      secret:
        secretName: harbor-basic-auth
    - name: argocd-credentials
      secret:
        secretName: argocd-basic-auth

タスクに渡すパラメータは params に設定します。まず spec.params でパラメータの定義を行い、pipeline に渡すパラメータは spec.resourcetemplates[].spec.params で記述します。
実際のパラメータの値は payload -> eventlistener -> triggertemplate の順で処理され、spec.params で定義した変数名に代入されています。格納されたパラメータは $(tt.params.[変数名]) で取得できます。

spec:
  params:
    - name: repositoryUrl
      description: The git repo URL to clone from.
    - name: branch
      description: git branch to be pulled from.
    - name: imageName
      description: Image name including repository
    - name: imageTag
      description: Image tag
    - name: argocdApplicationName
      description: applicationName
  resourcetemplates:
    - apiVersion: tekton.dev/v1beta1
      spec:
        params:
          - name: repositoryUrl
            value: $(tt.params.repositoryUrl)
          - name: branch
            value: $(tt.params.branch)
          - name: imageName
            value: $(tt.params.imageName)
          - name: imageTag
            value: $(tt.params.imageTag)
          - name: argocdApplicationName
            value: $(tt.params.argocdApplicationName)

まとめると、triggertemplate のマニフェストは以下のようになります。

triggertemplate
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: build-dev-image
  namespace: flask-app
spec:
  params:
    - name: repositoryUrl
      description: The git repo URL to clone from.
    - name: branch
      description: git branch to be pulled from.
    - name: imageName
      description: Image name including repository
    - name: imageTag
      description: Image tag
    - name: argocdApplicationName
      description: applicationName
  resourcetemplates:
    - apiVersion: tekton.dev/v1beta1
      kind: PipelineRun
      metadata:
        generateName: build-dev-image-
      spec:
        pipelineRef:
          name: build-dev-image
        podTemplate:
          securityContext:
            fsGroup: 65532
          imagePullSecrets:
            - name: harbor-basic-auth
        workspaces:
          - name: shared-data
            volumeClaimTemplate:
              spec:
                storageClassName: openebs-hostpath
                accessModes:
                  - ReadWriteOnce
                resources:
                  requests:
                    storage: 1Gi
          - name: git-credentials
            secret:
              secretName: gitlab-basic-auth
          - name: harbor-credentials
            secret:
              secretName: harbor-basic-auth
          - name: argocd-credentials
            secret:
              secretName: argocd-basic-auth
        params:
          - name: repositoryUrl
            value: $(tt.params.repositoryUrl)
          - name: branch
            value: $(tt.params.branch)
          - name: imageName
            value: $(tt.params.imageName)
          - name: imageTag
            value: $(tt.params.imageTag)
          - name: argocdApplicationName
            value: $(tt.params.argocdApplicationName)

その他の webhook 対応

以上のように、以下のリソースを作成することで pipeline の起動タイミングと処理 の 1 番目の pipeline を実行するためのリソースが準備できました。

  • pipeline
  • eventlistener
  • triggerbinding
  • triggertemplate

残りの 2, 3 番目に対応するリソースも同様の手順で作成していきます。作り方は大体同じなので必要な部分だけ説明します。

2 番目の webhook 対応

2 番目の pipeline は tag の push イベント を受け取るための eventlistener を作成します。push イベントにある v1.1.0 の数字を抽出してイメージタグに設定するため、cel intercept で以下のような表現を使います。

- key: imageTag
  expression: "body.ref.split('/')[2].replace('v', '')"

これで "ref": "refs/tags/v1.1.0" から 1.1.0 の数字部分のみを抽出できます。

eventlistener
---
apiVersion: triggers.tekton.dev/v1alpha1
kind: EventListener
metadata:
  name: flask-app-prod
  namespace: flask-app
spec:
  serviceAccountName: webhook
  triggers:
    - name: event-tag-push
      interceptors:
        - ref:
            name: cel
          params:
            - name: "filter"
              value: body.event_name == "tag_push"
            - name: "filter"
              value: body.project.path_with_namespace == 'gitlab/flask-k8s-example'
            - name: "overlays"
              value:
                - key: branchName
                  expression: "string('main')"
                - key: imageName
                  expression: "string('harbor.centre.com/k8s/flask-k8s-example')"
                - key: imageTag
                  expression: "body.ref.split('/')[2].replace('v', '')"
      bindings:
        - ref: build-prod-image
      template:
        ref: build-prod-image

この pipeline ではイメージの build, push を行いますが、本番用クラスタへのデプロイはマニフェスト変更後に行います。そのため、pipeline リソースでは argocd sync 以外の 2 タスクを実行するように記述します。

pipeline
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: build-prod-image
  namespace: flask-app
spec:
  description: >-
    This pipeline run the sequence of tasks as a pipeline.
    1. Clone the application source from a git repository.
    2. Build an image from Dockerfile and push it to a registry.
  params:
    - name: repositoryUrl
      type: string
      description: The git repository URL to clone the source.
    - name: branch
      type: string
      description: The branch of the repository.
    - name: context
      description: The path to the build context, used by Kaniko - within the workspace
      default: "./"
    - name: imageName
      description: Image name
    - name: imageTag
      description: Image tag
      default: "latest"
  workspaces:
    - name: shared-data
      description: >-
        This workspace contains the cloned repo files, so they can be read by the
        next task.
    - name: git-credentials
      description: Name of the secret to log in to gitlab.
    - name: harbor-credentials
      description: Name of the secret to log in to harbor.
  tasks:
    - name: git-clone
      taskRef:
        name: git-clone
      workspaces:
        - name: output
          workspace: shared-data
        - name: basic-auth
          workspace: git-credentials
      params:
        - name: url
          value: $(params.repositoryUrl)
        - name: revision
          value: $(params.branch)
        - name: sslVerify
          value: "false"
    - name: build
      taskRef:
        name: kaniko
      runAfter:
        - git-clone
      workspaces:
        - name: source
          workspace: shared-data
        - name: dockerconfig
          workspace: harbor-credentials
      params:
        - name: IMAGE
          value: $(params.imageName):$(params.imageTag)
        - name: CONTEXT
          value: $(params.context)

3 番目の webhook 対応

最後の pipeline では マニフェストの変更を main ブランチにマージした際に実行するように設定します。
gitlab では PR に関するイベントは Merge request events となっており、PR がマージされた際のイベントは object_attributes.action = merge が設定されるのでそれをフィルタリング条件に指定します。

eventlistener
---
apiVersion: triggers.tekton.dev/v1alpha1
kind: EventListener
metadata:
  name: merge-manifest
  namespace: flask-app
spec:
  serviceAccountName: webhook
  triggers:
    - name: deploy-prod-manifest
      interceptors:
        - ref:
            name: cel
          params:
            - name: "filter"
              value: body.event_type == 'merge_request'
            - name: "filter"
              value: body.project.path_with_namespace == 'gitlab/flask-k8s-manifest'
            - name: "filter"
              value: body.object_attributes.target_branch == 'main'
            - name: "filter"
              value: body.object_attributes.action == 'merge'
            - name: "overlays"
              value:
                - key: argocdApplicationName
                  expression: "string('flask-prod')"
      bindings:
        - ref: deploy-prod-manifest
      template:
        ref: deploy-prod-manifest

この pipeline では本番用クラスタのデプロイのみを行うため、argocd sync タスクだけを実行すれば良く clone や build は必要ありません。
そのため新規に pipeline は作成せず、triggertemplate では argocd sync タスクを起動するように pipelineRun の代わりに taskRun を記述します。

TriggerBinding,TriggerTemplate
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: deploy-prod-manifest
  namespace: flask-app
spec:
  params:
    - name: argocdApplicationName
      value: $(extensions.argocdApplicationName)

---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: deploy-prod-manifest
  namespace: flask-app
spec:
  params:
    - name: argocdApplicationName
      description: Argocd application name to be deployed
  resourcetemplates:
    - apiVersion: tekton.dev/v1beta1
      kind: TaskRun
      metadata:
        generateName: deploy-prod-manifest-
      spec:
        taskRef:
          name: argocd-sync
        workspaces:
          - name: argocd-basic-auth
            secret:
              secretName: argocd-basic-auth
        params:
          - name: applicationName
            value: $(tt.params.argocdApplicationName)

Ingress

いままで説明しませんでしたが、eventlistener を作成すると event を処理するための pod と service が作成されます。この service に対して webhook を飛ばすことでイベントを処理できます。

$ kubectl get pod -l app.kubernetes.io/managed-by=EventListener
NAME                                 READY   STATUS    RESTARTS   AGE
el-flask-app-dev-68f6b74978-dnxvt    1/1     Running   0          12d
el-flask-app-prod-cdcb5d64b-jndnf    1/1     Running   0          12d
el-merge-manifest-749cbf9985-qx78w   1/1     Running   0          12d
$ kubectl get svc
NAME                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE
el-flask-app-dev    ClusterIP   10.104.27.131    <none>        8080/TCP,9000/TCP   12d
el-flask-app-prod   ClusterIP   10.100.124.225   <none>        8080/TCP,9000/TCP   12d
el-merge-manifest   ClusterIP   10.98.62.105     <none>        8080/TCP,9000/TCP   12d

ここで作成した service は clusterIP であるため、クラスタ外部にある gitlab から service に webhook を送信できるように ingress リソースを作成します。
ingress では path prefix を使い、リクエスト URL のパスに基づいてそれぞれ別の service にルーティングするように設定します。

ingress
spec:
  ingressClassName: nginx
  rules:
    - host: tekton-webhook.ops.com
      http:
        paths:
          - path: /dev
            pathType: Prefix
            backend:
              service:
                name: el-flask-app-dev
                port:
                  number: 8080
          - path: /prod
            pathType: Prefix
            backend:
              service:
                name: el-flask-app-prod
                port:
                  number: 8080
          - path: /manifest
            pathType: Prefix
            backend:
              service:
                name: el-merge-manifest
                port:
                  number: 8080

実践編

pipeline の作成と webhook の処理を準備したので、ここからは実際にアプリケーションの変更を行い、gitlab webhook に基づいて pipeline が実行される様子を見ていきます。

初期状態の確認

はじめに最初のクラスタ上のアプリケーションを確認しておきます。
確認方法は前回記事と同様、各クラスタ上で稼働しているアプリケーションに対して curl を実行した際のレスポンスを見て確認します。

各クラスタには既に ingress を展開しているため、kube-argo クラスタ上で以下の URL にリクエストを送信することで対象のアプリケーションにアクセスできます。

  • 本番用クラスタ: flask-prod.ops.com
  • 開発用クラスタ: flask-dev.ops.com
$ curl https://flask-dev.ops.com
{"cluster":"k8s-dev","message":"This is dev"}

$ curl https://flask-prod.ops.com
{"cluster":"k8s-prod","message":"This is prod"}

この時点では前回記事で作成したアプリケーション v1.0.0 が本番・開発用クラスタにデプロイされています。また、マニフェストで本番環境・開発環境で異なる環境変数を設定しているため、message の内容がそれぞれ異なっています。

開発用クラスタへのデプロイ

アプリケーションの v1.1.0 を作成するために既存のコードを改修していきます。
変更箇所は前回と同様なんでも良いですが、今回もレスポンスに対して新しいフィールドを追加していきます。
環境変数 VALUE1 を読み取ってレスポンスに value1 として追加します。

main.py
+ value1 = os.getenv("VALUE1", "undefined")

@app.route("/")
def hello_world():
-    ret = {"message": f"This is {region}", "cluster": cluster}
+    ret = {"message": f"This is {region}", "cluster": cluster, "value1": value1}

これをアプリケーションレポジトリ gitlab/flask-k8s-app.git の dev ブランチに push します。
dev ブランチに push したことによって webhook イベントが発動し、イメージのビルド、開発用クラスタへのデプロイを実行する tekton pipeline がトリガーされます。

pipeline の実行状況は tkn pipelinerun list で確認できます。

$ tkn pipelinerun list
NAME                     STARTED      DURATION   STATUS
build-dev-image-zf6k4    1 week ago   51s        Succeeded

pipeline が完了すると、変更したアプリケーションが開発用クラスタにデプロイされたためレスポンスが変更されています。ただしこの時点ではマニフェスト側に環境変数を設定していないため、value1 の値は undefined となっています。

$ curl https://flask-dev.ops.com
{"cluster":"k8s-dev","message":"This is dev","value1":"undefined"}

本番用クラスタに変更は適用されないためレスポンスは変化していません。

$ curl https://flask-prod.ops.com
{"cluster":"k8s-prod","message":"This is prod"}

アプリケーションにもう一つ変更を加えてみます。先程と同様にレスポンスに value2 というフィールドを追加します。

main.py
+ value2 = os.getenv("VALUE2", "undefined")

@app.route("/")
def hello_world():
-    ret = {"message": f"This is {region}", "cluster": cluster, "value1": value1}
+    ret = {"message": f"This is {region}", "cluster": cluster, "value1": value1, "value2": value2}

今回はフィールドに値を設定するためマニフェストの方に環境変数をセットします。

overlays/dev/deployment.yml
spec:
  template:
    spec:
      containers:
        - name: flask-k8s-example
          env:
+            - name: VALUE1
+              value: "dev-1"
+            - name: VALUE2
+              value: "dev-2"

変更をマニフェストレポジトリ gitlab/flask-k8s-manifest.git dev ブランチに push した後、アプリケーションの変更点を push します。

pipeline 完了後にレスポンスを確認すると、value2 のフィールドと環境変数に設定した値が追加されていることが確認できます。
もちろん本番環境の方は何も更新されていないためレスポンスは変化していません。

$ curl https://flask-dev.ops.com
{"cluster":"k8s-dev","message":"This is dev","value1":"dev-1","value2":"dev-2"}

$ curl https://flask-prod.ops.com
{"cluster":"k8s-prod","message":"This is prod"}

本番用イメージのビルド

アプリケーションの変更点が問題なく動作することが確認できたので、次はこの変更点をアプリケーションの新しいバージョン v1.1.0 としてデプロイするための準備を行います。
まず、変更点を main ブランチにマージするために gitlab 上で PR を作成します。
実際の運用ではこの段階でコードレビュー等を受けますが、今回はこのまま merge します。

これで main ブランチに変更点が適用されたので、これをバージョン v1.1.0 として公開するため tag を追加します。

$ git tag  v1.1.0
$ git push origin v1.1.0
warning: redirecting to https://gitlab.centre.com/gitlab/flask-k8s-example.git/
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0
To https://gitlab.centre.com/gitlab/flask-k8s-example
 * [new tag]         v1.1.0 -> v1.1.0

tag を push すると gitlab の tag push イベントが webhook として送信され、イメージをビルドする pipeline がトリガーされます。
これにより アプリケーション v1.1.0 に対応するコンテナイメージがタグ 1.1.0 としてビルドされ、harbor レジストリに push されます。
harbor の docker registry API を使って harbor 上のイメージタグを確認してみます。

$ curl -Ssu "admin:xxxx" https://harbor.centre.com/v2/k8s/flask-k8s-example/tags/list | jq
{
  "name": "k8s/flask-k8s-example",
  "tags": [
    "1.0.0",
    "1.1.0"
  ]
}

もともとあったイメージタグ 1.0.0 に加えて pipeline によって作成された 1.1.0 が追加されていることが確認できます。

本番用クラスタへのデプロイ

最後に、1.1.0 として作成したイメージを使ったアプリケーションを本番用クラスタにデプロイします。
マニフェスト内で使用するイメージタグを更新し、環境変数もセットしておきます。

overlays/prod/deployment.yml
     spec:
       containers:
         - name: flask-k8s-example
-          image: harbor.centre.com/k8s/flask-k8s-example:1.0.0
+          image: harbor.centre.com/k8s/flask-k8s-example:1.1.0
           env:
             - name: REGION
               value: prod
             - name: CLUSTER
               value: "k8s-prod"
+            - name: VALUE1
+              value: "prod-1"
+            - name: VALUE2
+              value: "prod-2"

これをマニフェストレポジトリの dev ブランチに push し、dev -> main のマージを行う PR を作成します。
先程と同様にマージを完了させると、PR のマージに対応する webhook イベントが発生し、最後の pipeline がトリガーされます。この pipeline では上記マニフェストを本番用クラスタに適用します。

アプリケーションが本番用クラスタに適用されたことを確認するためレスポンスを見てみます。

$ curl https://flask-dev.ops.com
{"cluster":"k8s-dev","message":"This is dev","value1":"dev-1","value2":"dev-2"}

$ curl https://flask-prod.ops.com
{"cluster":"k8s-prod","message":"This is prod","value1":"prod-1","value2":"prod-2"}

本番用クラスタでも無事にアプリケーションの新しいバージョンが適用され、マニフェストに設定した環境変数がセットされていることが確認できました。

まとめ

tekton を使うことで gitlab webhook のイベントを区別し、CI 処理を自動化することが達成できました。
始めはやや取っ掛かりづらさを感じますが、機能が豊富であり本番環境での動作のも十分であることから、tekton は CI 実行プラットフォームとしては有力な選択肢となり得ると感じました。

おまけ

tekton pipeline で pipeline の成功/失敗を slack 等で通知できる機能があればいいなと思ったのですが、現時点ではなさそうでした。
機能リクエストとしては https://github.com/tektoncd/pipeline/issues/1740 があり、2021 年の roadmap に記載されています。
また、pipeline の定義で全タスクが終了した後に実行される finally というのがあるので、こちらで代用はできそうです。

Discussion