マルチアーキテクチャーイメージビルド（Tekton Pipelines編）

[前回の記事]("https://qiita.com/sonq/items/0f988f52f2813b09209e")では、各ツールによるマルチアーキテクチャーに対応したコンテナイメージのビルド方法について検証しました。
今回からはその応用として、マルチアーキテクチャービルドをCI/CDパイプラインに組み込んだ構成を試していきます。

本記事ではOpenShift Pipelines (Tekton Pipelines) を使用したCIパイプラインの構成について解説します。
GitOpsを実現するためのTekton TriggersやCDパイプラインの構成については別記事で追って解説していきます。


# 前提環境
コンテナ基盤はRed Hat OpenShift Service on AWS(ROSA) を使用します。
- Client Version: 4.10.12
- Server Version: 4.9.21
- Kubernetes Version: v1.22.3+fdba464

CIパイプラインは、
[OpenShift Pipelines]("https://access.redhat.com/documentation/ja-jp/openshift_container_platform/4.9/html/cicd/openshift-pipelines") (Tekton Pipelines) をOperatorでInstallしておきます。
- Client version: 0.23.1
- Pipeline version: v0.28.3


ソースコードリポジトリとマニフェストリポジトリは[GitLab]("https://gitlab.com/")でプロジェクトを分けて管理します。
ソースコードリポジトリに関しては、今回使用するJavaサンプルアプリの[Acme Airのリポジトリ]("https://github.com/blueperf/acmeair-mainservice-java")をフォークして作成します。

マニフェストリポジトリに関しては、任意の名前でプロジェクトを作成しておきます。

# CIパイプラインの構成

Tekton Pipelinesでは、それぞれの処理をTaskとして定義し、それを組み合わせることでPipelineを構成します。

今回のPipelineは、以下のようなTaskを順番に実行してきます。

1. ソースコードリポジトリとマニフェストリポジトリをそれぞれClone
1. ソースコードの静的解析
1. ソースコードビルド＆コンテナイメージビルド
1. ビルドしたイメージの脆弱性スキャン
1. アプリマニフェストの更新
1. アプリマニフェストのPush

重要なのは3のコンテナイメージビルドで、これをマルチアーキテクチャービルドとして実装することで、AMD64とs390xに対応したイメージを作成します。


# パイプライン作成手順

### プロジェクト作成
最初に検証用のプロジェクトを作成します。ここに各リソースを展開していきます。

```
$ oc new-project pipeline-work
Now using project "pipeline-work" on server ...
```

### Pipelineリソース作成


```pipeline.yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: pipeline
spec:
  workspaces:
    - name: shared-workspace
    - name: manifest-workspace
    - name: sonar-settings
  params:
    - name: SOURCE_URL
      type: string
    - name: MANIFEST_URL
      type: string
    - name: SONAR_HOST_URL
      type: string
    - name: SONAR_PROJECT_KEY
      type: string
    - name: REGISTRY_PATH
      type: string
    - name: MULTI_ARCH_IMAGE
      type: string
  tasks:
    - name: git-clone-source
      taskRef:
        kind: ClusterTask
        name: git-clone
      params:
        - name: url
          value: $(params.SOURCE_URL)
        - name: subdirectory
          value: ""
        - name: deleteExisting
          value: "true"
      workspaces:
        - name: output
          workspace: shared-workspace

    - name: git-clone-manifest
      taskRef:
        kind: ClusterTask
        name: git-clone
      params:
        - name: url
          value: $(params.MANIFEST_URL)
        - name: subdirectory
          value: ""
        - name: deleteExisting
          value: "true"
      workspaces:
        - name: output
          workspace: manifest-workspace

    - name: sonarqube-scanner
      taskRef:
        name: sonarqube-scanner
      runAfter:
        - git-clone-source
        - git-clone-manifest
      params:
        - name: SONAR_HOST_URL
          value: $(params.SONAR_HOST_URL)
        - name: SONAR_PROJECT_KEY
          value: $(params.SONAR_PROJECT_KEY)
      workspaces:
        - name: source-dir
          workspace: shared-workspace
        - name: sonar-settings
          workspace: sonar-settings

    - name: multi-arch-build
      taskRef:
        name: docker-buildx
      runAfter:
        - sonarqube-scanner
      params:
        - name: MULTI_ARCH_IMAGE
          value: $(params.MULTI_ARCH_IMAGE)
        - name: REGISTRY_PATH
          value: $(params.REGISTRY_PATH)
      workspaces:
        - name: source
          workspace: shared-workspace

    - name: image-scan
      taskRef:
        name: trivy-scanner
      runAfter:
        - multi-arch-build
      params:
        - name: REGISTRY_PATH
          value: $(params.REGISTRY_PATH)
        - name: MULTI_ARCH_IMAGE
          value: $(params.MULTI_ARCH_IMAGE)
      workspaces:
        - name: manifest-dir
          workspace: shared-workspace

    - name: update-manifest
      params:
        - name: MULTI_ARCH_IMAGE
          value: $(params.MULTI_ARCH_IMAGE)
      taskRef:
        kind: Task
        name: update-manifest
      runAfter:
      - image-scan
      workspaces:
        - name: manifest-dir
          workspace: manifest-workspace

    - name: push-manifest
      params:
        - name: MULTI_ARCH_IMAGE
          value: $(params.MULTI_ARCH_IMAGE)
      taskRef:
        kind: Task
        name: push-manifest
      runAfter:
      - update-manifest
      workspaces:
        - name: manifest-dir
          workspace: manifest-workspace
```
.spec.tasksで実行するTaskを指定し、.spec.paramsで定義した変数を渡します。（変数の値はPipelineRunというインスタンス定義で指定します）

今回は以下のフローを実行する定義となっています。
![Screen Shot 2022-06-29 at 14.42.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182110/979e70f1-e7f3-efbf-128f-2dc09a888aa3.png)


### Taskリソース作成

続いてPipelineで実行されるTaskを作成します。
git-clone-sourceとgit-clone-manifestについては[Tekton Hubで提供されているTask]("https://hub.tekton.dev/tekton/task/git-clone")を使用しますが、他の処理は適切なものがないので自分で作成します。

#### ソースコード静的解析
ツールはSonarQubeを使用します。

```sonarqube-scanner.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: sonarqube-scanner
  labels:
    app.kubernetes.io/version: "0.1"
  annotations:
    tekton.dev/pipelines.minVersion: "0.12.1"
    tekton.dev/categories: Security
    tekton.dev/tags: security
    tekton.dev/displayName: "sonarqube scanner"
    tekton.dev/platforms: "linux/amd64"
spec:
  description: >-
    The following task can be used to perform static analysis on the source code
    provided the SonarQube server is hosted

    SonarQube is the leading tool for continuously inspecting the Code Quality and Security
    of your codebases, all while empowering development teams. Analyze over 25 popular
    programming languages including C#, VB.Net, JavaScript, TypeScript and C++. It detects
    bugs, vulnerabilities and code smells across project branches and pull requests.

  workspaces:
    - name: source-dir
    - name: sonar-settings
  params:
    - name: SONAR_HOST_URL
      description: Host URL where the sonarqube server is running
      default: ""
    - name: SONAR_PROJECT_KEY
      description: Project's unique key
      default: ""
  steps:
    - name: sonar-properties-create
      image: registry.access.redhat.com/ubi8/ubi-minimal:8.2
      workingDir: $(workspaces.source-dir.path)
      script: |
        #!/usr/bin/env bash

        replaceValues() {
          filename=$1
          thekey=$2
          newvalue=$3

          if ! grep -R "^[#]*\s*${thekey}=.*" $filename >/dev/null; then
            echo "APPENDING because '${thekey}' not found"
            echo "" >>$filename
            echo "$thekey=$newvalue" >>$filename
          else
            echo "SETTING because '${thekey}' found already"
            sed -ir "s|^[#]*\s*${thekey}=.*|$thekey=$newvalue|" $filename
          fi
        }

        if [[ -f $(workspaces.sonar-settings.path)/sonar-project.properties ]]; then
          echo "using user provided sonar-project.properties file"
          cp -RL $(workspaces.sonar-settings.path)/sonar-project.properties $(workspaces.source-dir.path)/sonar-project.properties
        fi

        if [[ -f $(workspaces.source-dir.path)/sonar-project.properties ]]; then
          if [[ -n "$(params.SONAR_HOST_URL)" ]]; then
            replaceValues $(workspaces.source-dir.path)/sonar-project.properties sonar.host.url $(params.SONAR_HOST_URL)
          fi
          if [[ -n "$(params.SONAR_PROJECT_KEY)" ]]; then
            replaceValues $(workspaces.source-dir.path)/sonar-project.properties sonar.projectKey $(params.SONAR_PROJECT_KEY)
          fi
        else
          touch sonar-project.properties
          echo "sonar.projectKey=$(params.SONAR_PROJECT_KEY)" >> sonar-project.properties
          echo "sonar.host.url=$(params.SONAR_HOST_URL)" >> sonar-project.properties
          echo "sonar.sources=." >> sonar-project.properties
        fi

        echo "---------------------------"
        cat $(workspaces.source-dir.path)/sonar-project.properties

    - name: sonar-scan
      image: docker.io/sonarsource/sonar-scanner-cli:4.5@sha256:b8c95a37025f3c13162118cd55761ea0b2a13d1837f9deec51b7b6d82c52040a #tag: 4.5
      workingDir: $(workspaces.source-dir.path)
      command:
        - sonar-scanner
        - -Dsonar.login=xxx
        - -Dsonar.password=xxx
```

#### ソースコードビルド ＆ マルチアーキテクチャービルド

Dockerfileのマルチステージビルドを利用し、Mavenでソースコードをビルドした上で、WebSphere Libertyのイメージとしてビルドします。

事前にフォークした[Acme Airのリポジトリ]("https://github.com/blueperf/acmeair-mainservice-java")のDockerfileを以下のように編集しておきます。

```
FROM maven:3.8.4-jdk-11-slim AS build-stage
COPY . /project
WORKDIR /project/
RUN mvn clean install

FROM ibmcom/websphere-liberty:kernel-java8-ibmjava-ubi
COPY --chown=1001:0 --from=build-stage /project/src/main/liberty/config/server.xml /config/server.xml
COPY --chown=1001:0 --from=build-stage /project/src/main/liberty/config/server.env /config/server.env
COPY --chown=1001:0 --from=build-stage /project/src/main/liberty/config/jvm.options /config/jvm.options
COPY --chown=1001:0 --from=build-stage /project/target/acmeair-mainservice-java-5.0.war /config/apps/
RUN configure.sh
```

続いてTaskです。
マルチアーキテクチャービルドにはDocker Buildxを使用するため、Docker DaemonをSidecarで稼働させています。（[こちらを参考]("https://vikaspogu.dev/posts/tekton-multiarch-buildx/")）
またBuildxはビルドしたタイミングでそのままイメージをレジストリにPushする仕様となっています。
```multiarch-build.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  annotations:
    tekton.dev/displayName: multiarch-build
    tekton.dev/pipelines.minVersion: 0.12.1
    tekton.dev/tags: docker, build-image, push-image, dind, buildx, multiarch-build
  generation: 5
  labels:
    app.kubernetes.io/version: "0.1"
  name: multi-arch build
spec:
  params:
  - description: ""
    name: REGISTRY_PATH
    type: string
  - description: ""
    name: MULTI_ARCH_IMAGE
    type: string
  - default: docker.io/library/docker:20.10.12
    description: ""
    name: BUILDER_IMAGE
    type: string
  - default: ./Dockerfile
    description: ""
    name: DOCKERFILE
    type: string
  - default: .
    description: ""
    name: CONTEXT
    type: string
  - default: --platform linux/amd64,linux/s390x --no-cache
    description: ""
    name: BUILD_EXTRA_ARGS
    type: string
  - default: --push
    description: ""
    name: PUSH_EXTRA_ARGS
    type: string
  results:
  - description: ""
    name: IMAGE_DIGEST
  sidecars:
  - args:
    - --storage-driver=vfs
    - --userland-proxy=false
    - --debug
    env:
    - name: DOCKER_TLS_CERTDIR
      value: /certs
    image: docker:20.10.12-dind
    name: server
    readinessProbe:
      exec:
        command:
        - ls
        - /certs/client/ca.pem
      periodSeconds: 1
    resources: {}
    securityContext:
      privileged: true
    volumeMounts:
    - mountPath: /certs/client
      name: dind-certs
  steps:
  - env:
    - name: DOCKER_HOST
      value: tcp://localhost:2376
    - name: DOCKER_TLS_VERIFY
      value: "1"
    - name: DOCKER_CERT_PATH
      value: /certs/client
    image: $(params.BUILDER_IMAGE)
    name: build
    resources: {}
    script: 
     "# install depends\n
        apk add curl jq\n\n
      # enable experimental buildx features\n
        export DOCKER_BUILDKIT=1\n
        export DOCKER_CLI_EXPERIMENTAL=enabled\n\n
      # Download latest buildx bin from github\n
        mkdir -p ~/.docker/cli-plugins/\n
        BUILDX_LATEST_BIN_URI=$(curl -s -L https://github.com/docker/buildx/releases/latest | grep 'linux-amd64'
      | grep 'href' | sed 's/.*href=\"/https:\\/\\/github.com/g; s/amd64\".*/amd64/g')\n
        curl -s -L ${BUILDX_LATEST_BIN_URI} -o ~/.docker/cli-plugins/docker-buildx\n
        chmod a+x ~/.docker/cli-plugins/docker-buildx\n\n
      # Get and run the latest docker/binfmt tag to use its qemu parts\n
        BINFMT_IMAGE_TAG=$(curl -s https://registry.hub.docker.com/v2/repositories/docker/binfmt/tags
      | jq '.results | sort_by(.last_updated)[-1].name' -r)\n
        docker run --rm --privileged docker/binfmt:${BINFMT_IMAGE_TAG}\n\n
        docker context create tls-environment\n
      # create the multibuilder\ndocker buildx create --name multibuilder --use tls-environment\n
        docker buildx use multibuilder\n\n
      # login to a registry\n
      # build the containers and push them to the registry then display the images\n
        docker buildx build $(params.BUILD_EXTRA_ARGS) -f $(params.DOCKERFILE) -t $(params.REGISTRY_PATH)$(params.MULTI_ARCH_IMAGE) $(params.CONTEXT) $(params.PUSH_EXTRA_ARGS)\n"
    volumeMounts:
    - mountPath: /certs/client
      name: dind-certs
    workingDir: $(workspaces.source.path)
  volumes:
  - emptyDir: {}
    name: dind-certs
  workspaces:
  - name: source
```

### ビルドしたイメージの脆弱性スキャン
脆弱性スキャンにはTrivyを使用します。
```trivy-scanner.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: trivy-scanner
  labels:
    app.kubernetes.io/version: "0.1"
  annotations:
    tekton.dev/pipelines.minVersion: "0.12.1"
    tekton.dev/categories: Security
    tekton.dev/tags: CLI, trivy
    tekton.dev/displayName: "trivy scanner"
    tekton.dev/platforms: "linux/amd64"
spec:
  description: >-
    Trivy is a simple and comprehensive scanner for
    vulnerabilities in container images,file systems
    ,and Git repositories, as well as for configuration issues.

    This task can be used to scan for vulnenrabilities on the source code
    in stand alone mode.
  workspaces:
    - name: manifest-dir
  params:
    - name: TRIVY_IMAGE
      default: docker.io/aquasec/trivy@sha256:dea76d4b50c75125cada676a87ac23de2b7ba4374752c6f908253c3b839201d9
      description: Trivy scanner image to be used
    - name: REGISTRY_PATH
      description: Path to be scanned by trivy.
      type: string
    - name: MULTI_ARCH_IMAGE
      description: Image to be scanned by trivy.
      type: string
  steps:
    - name: trivy-scan
      image: $(params.TRIVY_IMAGE)
      workingDir: $(workspaces.manifest-dir.path)
      script: |
        #!/usr/bin/env sh
          cmd="trivy $* $(params.REGISTRY_PATH)$(params.MULTI_ARCH_IMAGE)"
          echo "Running trivy task with command below"
          echo "$cmd"
          eval "$cmd"
```

### アプリマニフェストの更新
アプリは以下のDeploymentリソースでデプロイします。
```
apiVersion: apps/v1
kind: Deployment
metadata:
  name: acmeair-mainservice
spec:
  replicas: 1
  selector:
    matchLabels:
      name: acmeair-main-deployment
  template:
    metadata:
      labels:
        name: acmeair-main-deployment
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9080"
    spec:
      imagePullSecrets:
      - name: gitlab-token
      containers:
      - name: acmeair-mainservice-java
        image: registry.gitlab.com/hoge/acmeair-manifests/acmeair:run-jb98k
        imagePullPolicy: Always
        ports:
        - containerPort: 9080
        - containerPort: 9443
```
ただアプリイメージはビルドする度にtagを変更するため、マニフェストに対してもそれを反映する必要があります。
その処理を実行するTaskは以下です。

```update-manifest.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: update-manifest
spec:
  workspaces:
    - name: manifest-dir
  params:
  - name: MULTI_ARCH_IMAGE
    type: string
  steps:
  - name: modify-image-tag
    image: docker.io/redhat/ubi8
    workingDir: $(workspaces.manifest-dir.path)
    script: |
      sed -iE "s/image:.*$/image: registry.gitlab.com\/hoge\/acmeair-manifests\/$(params.MULTI_ARCH_IMAGE)/g" manifests-openshift/deploy-acmeair-mainservice-java.yaml && cat manifests-openshift/deploy-acmeair-mainservice-java.yaml |grep image:
```

### マニフェストのPush
最後に更新したマニフェストファイルをリポジトリにPushします。
```push-manifest.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: push-manifest
spec:
  workspaces:
    - name: manifest-dir
  params:
  - name: MULTI_ARCH_IMAGE
    type: string
  steps:
  - name: modify-image-tag
    image: docker.io/bitnami/git:2.36.1
    workingDir: $(workspaces.manifest-dir.path)
    script: |
      git checkout -b dev
      git config --global user.email "you@example.com"
      git add manifests-openshift/deploy-acmeair-mainservice-java.yaml
      git commit -m "Change the app image tag to $(params.MULTI_ARCH_IMAGE)"
      git push origin dev -o merge_request.create -o merge_request.target=main
```

# まとめ
ここまでPipelineとそれぞれのTaskを定義しました。次回以降Tekton TriggersとArgo CDを利用したGitOpsの実装までを実施します。