GitOps 전환기 — Part 4. Tekton을 활용한 CI 구성하기

Tekton 이란

Tekton이란 클라우드 네이티브 CI/CD 도구입니다. Tekton의 주요 컴포넌트인 Tekton Pipelines, Trigger로 이뤄져 있으며 쿠버네티스 CR(Custom Resource)를 활용해 선언적으로 CI/CD를 구성할 수 있습니다.

Jenkins vs Tekton

Jenkins는 가장 많이 사용되고 있는 CI/CD 도구이며, 다양한 플러그인을 통해 기능을 확장할 수 있습니다. 반면 JVM 기반으로 실행되기 대문에 메모리를 크게 사용하며, 리소스 초과 사용 시 서버 전체가 다운될 수 있습니다.

Tekton은 클라우드 네이티브 CI/CD 도구이며, 쿠버네티스 클러스터 내부에서 독립적인 모듈 단위로 작업을 수행할 수 있습니다. 또한 Auto Scaling, Self-Healing, Serverless 기능을 가지고 있어 리소스 사용 효율성과 관리 측면에서 이점을 가지고 있습니다. 그러나 러닝 커브가 높은 편이고 자료가 많이 없다는 단점을 가지고 있습니다.

자세한 사항은 Jenkins vs GitHub Action vs Tekton 글을 참고하시길 바랍니다.

물론 GitHub Actions라는 선택지도 있었습니다. 그러나 프로젝트에서는 3개의 레포지토리를 활용하므로 하므로 GitHub Actions는 선택지에 제외되었습니다.

  1. 각 레포지토리마다 Git Actions를 설정해야 함 -> 중앙집중화된 관리 체계 필요
  2. Public만 무료로 제공 -> Public, Private 레포지토리에 동시에 접근 가능해야 함.

Tekton 선택 이유

이전에는 별도의 인스턴스에 Jenkins를 설치해서 운영하고 있었습니다. 인프라 운영 과정에서 메모리 공간을 확보하기 위해 인스턴스 정리가 필요했고, 젠킨스가 설치된 인스턴스를 삭제하기로 결정했습니다. 그 대신 쿠버네티스 내에 CI 도구를 활용하는 방향으로 결정했습니다.

Jenkins 또한 Kubernetes 내에서 배포할 수 있습니다. 그러나 파이프라인 구동 시에 리소스를 사용한다는 Serverless 특정 때문에 Tekton를 최종적으로 선택하게 되었습니다.

프로젝트에 적용하기

해당 게시글은 Tekton의 사용법이 아닌 프로젝트에 어떻게 활용했는지에 집중합니다. 자세한 사항은 공식 문서를 참고하시길 바랍니다.

Tekton은 크게 Triggers, Pipelines 컴포넌트로 이뤄져 있습니다. Triggers 컴포넌트는 EventListener 내부에 있는 모든 컴포넌트로 보시면 되고, Pipelines는 PipelineRun 내부 컴포넌트라고 보시면 됩니다.

Tekton Resource type 종류

Task 구성하기

개념

Task는 TaskRun이라는 CR를 통해 실행됩니다. 즉, Task는 작업할 흐름의 정의서로 보고 TaskRun은 Task의 실행 단위라고 보시면 됩니다.

단일 Task를 실행하기 위해서 TaskRun을 정의하여 사용할 수 있습니다. 그러나 프로젝트에서 CI를 구성할때는 Task의 상위 흐름 개념(Pipeline)을 활용하여 자동으로 TaskRun이 수행됩니다.

구성하기

GitOps 과정을 수행하기 위해서는 빌드를 마친 후에 Manifest를 변경시키는 작업이 필요합니다. Manifest 변경 작업은 우리는 이미지의 해시값을 kustomization.yaml에 업데이트하는 걸로 보시면 됩니다.

그러기 위해서는 3가지 작업을 수행해야 합니다.

  1. 특정 브랜치에 해당되는 레포지토리를 clone하여 가져오기
  2. 이미지 빌드하기
  3. 빌드한 이미지의 해시값을 config 레포지토리에 있는 kustomization.yaml에 업데이트하기

Task를 직접 만들 수 있지만 Tekton Hub를 통해 간단한 파라미터 설정만으로도 Task를 수행할 수 있게 됩니다.

그러므로 1, 2번 절차인 git-clone, 이미지 빌드 부분은 Tekton Hub에 있는 것들을 재사용할 예정입니다.

  1. git clone -> https://hub.tekton.dev/tekton/task/git-clone
  2. 이미지 빌드 -> https://hub.tekton.dev/tekton/task/buildah

다만 3번 절차인 Manifest 변경은 직접 코드를 작성해줍니다.

Task는 Step이 순차적으로 이뤄져 있다고 말씀 드렸습니다. Manifest 변경 작업을 더 세분화해서 Step으로 생성하면 됩니다.

  1. Config Repository를 clone한다.
  2. Kustomization.yaml를 찾아서 images 값을 업데이트한다.
  3. Config Repository의 변경 사항을 반영한다.

params 값은 추후 Pipeline에서 값을 지정하게 됩니다. 임시로 값이 들어왔다는 전제로 파라미터를 활용한 코드를 작성해줍니다.

apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
annotations:
tekton.dev/pipelilnes.minVersion: "0.19.0"
tekton.dev/tags: git
name: git-update-deployment
labels:
operator.tekton.dev/provider_type: community
spec:

params:
- name: GIT_REPOSITORY
description: The URL of the Git repository to clone
type: string
default: ""
- name: GIT_REF
description: The Git revision to check out
type: string
default: "main"
- name: NEW_IMAGE
description: The name of the image to build and push
type: string
- name: NEW_DIGEST
type: string
- name: KUSTOMIZATION_PATH
description: The name of the kustomization path to update
type: string
results:
- name: GIT_COMMIT
description: The commit hash of the updated repository
workspaces:
- name: shared
description: The workspace to share between tasks
steps:
# 1. Config Repository를 clone한다.
- name: git-clone
image: alpine/git:latest
script: |
#!/bin/sh
set -ex
# 기존에 있는 레포지토리 파일 삭제
if [ -d "$(workspaces.shared.path)/repo" ] && [ "$(ls -A $(workspaces.shared.path)/repo)" ]; then
rm -rf "/workspace/shared/repo"
fi

# git clone을 수행하여 원하는 브랜치로 Checkout 하기
git clone $(params.GIT_REPOSITORY) -b $(params.GIT_REF) $(workspaces.shared.path)/repo
cd $(workspaces.shared.path)/repo
git checkout $(params.GIT_REF)
git rev-parse HEAD > /workspace/shared/GIT_COMMIT.txt

# 2. Kustomization.yaml를 찾아서 images 값을 업데이트한다.
- name: update-kustomization
image: smartive/kustomize:latest
script: |
#!/bin/sh
# Config Repostiory 내 kustomization.yaml를 찾는다.
cd $(workspaces.shared.path)/repo/$(params.KUSTOMIZATION_PATH)
echo "Current directory: $(pwd)"

# Image Hash를 업데이트한다.
kustomize edit set image $(params.NEW_IMAGE)@$(params.NEW_DIGEST)
cat kustomization.yaml

# 3. Config Repository의 변경 사항을 반영한다.
- name: commit-changes
image: alpine/git:latest
script: |
#!/bin/sh
set -e
cd $(workspaces.shared.path)/repo/$(params.KUSTOMIZATION_PATH)
git config user.name <github.username>
git config user.email <github.email>
git status
git add ./kustomization.yaml
git commit -m "Update image to $(params.NEW_IMAGE)"
git push
EXIT_CODE="$?"
if [ "$EXIT_CODE" -ne 0 ]; then
echo "Error: Failed to push changes to the repository."
exit 1
fi
echo "Changes pushed successfully.

Pipeline 구성하기

개념

Pipeline — PipelineRun과 Task — TaskRun은 거의 동일한 관계를 가진다고 보면 됩니다.

Pipeline만 실행시키고 싶다면 PipelineRun을 정의하여 실행시킬 수 있습니다. 저희 프로젝트에서는 GitHub의 event에 맞춰 발생한 Trigger를 통해 자동으로 PipelineRun을 생성하도록 구성할 것입니다.

구성하기
지금까지 아래와 같은 Task를 생성했습니다.

  1. 특정 브랜치에 해당되는 레포지토리를 clone하여 가져오기
  2. 이미지 빌드하기
  3. 빌드한 이미지의 해시값을 config 레포지토리에 있는 kustomization.yaml에 업데이트하기

Task에서는 파라미터가 선언되어 있는데요. 해당 파라미터를 지정하여 모든 Task들을 하나의 Pipeline으로 생성합니다.

apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
name: challenge-api-pipeline
namespace: ci
spec:
params:
- name: repo-url
type: string
- name: revision
type: string
default: "main"
- name: image-name
type: string
- name: context-dir
type: string
default: "."
- name: config_git_repo
type: string
- name: config_git_ref
type: string
default: "main"

workspaces:
- name: shared # 모든 태스크가 공유하는 워크스페이스

tasks:
# 1. 특정 브랜치에 해당되는 레포지토리를 clone하여 가져오기
- name: git-clone
taskRef:
name: git-clone
params:
- name: url
value: $(params.repo-url)
- name: revision
value: $(params.revision)
- name: deleteExisting
value: "true"
workspaces:
- name: output
workspace: shared

# 2. 이미지 빌드하기
- name: buildah-build
taskRef:
name: buildah
runAfter: ["git-clone"]
params:
- name: IMAGE
value: $(params.image-name)
- name: CONTEXT
value: "$(workspaces.source.path)/$(params.context-dir)"
- name: TLSVERIFY
value: "false"
workspaces:
- name: source
workspace: shared
# 3. 빌드한 이미지의 해시값을 config 레포지토리에 있는 kustomization.yaml에 업데이트하기
- name: git-update-deployment
taskRef:
name: git-update-deployment
runAfter: ["buildah-build"]
params:
- name: GIT_REPOSITORY
value: $(params.config_git_repo)
- name: GIT_REF
value: $(params.config_git_ref)
- name: NEW_IMAGE
value: $(params.image-name)
- name: NEW_DIGEST
value: $(tasks.buildah-build.results.IMAGE_DIGEST)
- name: KUSTOMIZATION_PATH
value: "challenge-api/overlays/dev"
workspaces:
- name: shared
workspace: shared

실행하면 아래와 같이 pod를 확인할 수 있습니다.

kubectl get pod 결과

Trigger 구성하기

개념

출처 — https://tekton.dev/vault/triggers-main/

구성하기

지금가지 새로 생성한 이미지의 정보(Manifest)를 kustomization.yaml 에 업데이트하는 Pipeline를 정의했습니다.

이제부터는 GitHub의 변경 이벤트에 따라 CI가 진행될 수 있도록 구성해야 합니다. 그러기 위해서는 들어오는 GitHub 이벤트에 맞춰서 Trigger가 실행될 수 있도록 TriggerTemplate, TriggerBinding을 정의해야 합니다.

이벤트로부터 레포지토리 url, revision 정보를 받아왔습니다. 해당 정보를 TriggerBinding을 통해 받고 TriggerTemplate에 적용할 수 있도록 구성했습니다.

프로젝트에서는 main으로 merge하는 이벤트로만 실행할 수 있도록 설계했습니다. 그렇기 때문에 TriggerBinding을 통해 revision을 가져오는 것이 무의하다고 생각할 수 있을 것 같습니다. 추후 브랜치 전략을 체계적으로 짜게 된다면 이벤트로부터 받아들이는 값들이 유의미하게 적용될 수 있을 것 같습니다.

apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
name: challenge-api-triggerbinding
namespace: ci
spec:

# GitHub Event json 구조를 참고하셔서 body 파라미터를 정의하셔야 합니다.
params:
- name: repo-url
value: $(body.repository.clone_url)
- name: revision
value: $(body.after)
- name: image-name
value: <image name>
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
name: challenge-api-triggertemplate
spec:
params:
- name: repo-url
description: The URL of the Git repository to clone
default: <default git url>
- name: revision
description: The Git revision to check out
default: "main"
- name: image-name
description: The name of the image to build and push
default: <default image name>

# PipelineRun 정의
resourcetemplates:
- apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
labels:
tekton.dev/pipeline: challenge-api-pipeline
name: challenge-api-$(uid)
spec:
pipelineRef:
name: challenge-api-pipeline
serviceAccountName: tekton-trigger-sa

# TriggerTemplate은 tt.params를 참조합니다.
params:
- name: repo-url
value: $(tt.params.repo-url)
- name: revision
value: $(tt.params.revision)
- name: image-name
value: $(tt.params.image-name)
workspaces:
- name: shared
persistentVolumeClaim:
claimName: challenge-api-pvc # PVC 이름

EventListener 구성하기

개념

자세히 알고 싶다면 Red Hat Blog — Filtering Tekton trigger operations를 확인하시길 바랍니다.

구성하기

저는 단일 EvenetListener를 정의하여 들어오는 레포지토리에 따라 서로 다른 Trigger를 수행하도록 정의할 것입니다. 왜냐하면 단일 EvenetListener를 정의함으로 관리하기 편하기 때문입니다.

현재 온프로미스 환경에서 쿠버네티스 서버를 구동 중이기 때문에 NodePort를 통해 포트를 노출했습니다. GitHub에서 Webhook을 설정할때는 주소와 노출한 포트로 URL을 설정하면 됩니다.

프로젝트에서는 크게 3개의 레포지토리를 빌드해야 합니다. 그렇게 때문에 각 레포지토리마다 main으로 merged된 이벤트 발생 시 Trigger가 발생할 수 있도록 설정했습니다.

기본적으로 제공되는 GitHub ClusterInterceptor를 통해 Interceptor를 구현할 수 있습니다. 그러나 저는 기본적인 CEL Interceptor를 활용하여 필터링을 수행해보겠습니다.

CEL은 구글에서 제작한 빠르고 이동 가능하며 안전하게 실행하도록 설계된 범용 표현식 언어입니다.
출처 — https://cel.dev/overview/cel-overview?hl=ko

Trigger가 발생하는 조건은 아래와 같이 정했습니다.

  1. 레포지토리가 helloworld/challenge-api인 경우
  2. main에 PR(Pull Request)를 수행한 경우
  3. Merge가 된 경우 -> PR이 자동으로 closed 상태로 변함

실제로 GitHub에 접속하셔서 Webhook>Recent Deliveries에 접속하셔서 필터링하고자 하는 필드의 위치를 찾아 입력합니다.

Recent Delivieries 확인
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
name: hexactf-listener
spec:
resources:
kubernetesResource:
serviceType: NodePort
serviceAccountName: tekton-trigger-sa
triggers:
- name: challenge-api-trigger
interceptors:
- ref:
name: cel
params:
- name: filter
value: "body.repository.full_name == 'helloworld/challenge-api' && body.pull_request.base.ref =='main' && body.action == 'closed' && body.pull_request.merged==true"
bindings:
- ref: challenge-api-triggerbinding
template:
ref: challenge-api-triggertemplate

위와 동일하게 프로젝트 이름(body.repository.full_name)에 따라 서로 다른 Trigger를 실행할 수 있도록 설정했습니다. 이를 통해 하나의 Listener만을 가지고 다양한 Trigger를 실행할 수 있도록 구현했습니다.

Tekton 실행하기

GitHub 에서 PR을 수행하면 아래와 같은 변경사항을 확인할 수 있습니다.

tekton dashboard
config repository 변경사항

Tekton에 Slack 메세지 보내기

Send message to Slack Channel, send-to-channel-slack 과 같이 Slack에 메세지를 보내는 Task가 있습니다. 그러나 ArgoCD처럼 상태에 따라 메세지를 다르게 출력해주는 Task는 없는 것 같습니다.

기본적으로 상태에 따라 박스 색과 메세지 내용을 다르게 출력하도록 Task를 만들었습니다.

Task 구성 시 필요 변수가 기본적으로 제공되는지 공식 문서를 꼭 확인하시길 바랍니다.
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: send-to-channel-slack
labels:
app.kubernetes.io/version: "0.1"
annotations:
tekton.dev/pipelines.minVersion: "0.12.1"
tekton.dev/categories: Messaging
tekton.dev/tags: messaging
tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le"
spec:
description: |
이 태스크는 Slack 채널에 Tekton Bot 결과 메시지를 컬러 블록과 함께 전송합니다.
성공 시 초록색, 실패 시 빨간색 컬러 바가 표시됩니다.
params:
- name: token-secret
type: string
description: secret name of the slack app access token (key는 token)
default: token-secret
- name: channel
type: string
description: channel id
- name: status
type: string
description: Succeeded/Failed 등 파이프라인 상태
- name: pipeline-name
type: string
description: 파이프라인 이름
- name: pipelinerun-name
type: string
- name: username
type: string
description: 슬랙 메시지 발신자 이름
default: "Tekton Bot"

steps:
- name: post
image: curlimages/curl:7.70.0
env:
- name: TOKEN
valueFrom:
secretKeyRef:
name: $(params.token-secret)
key: token
script: |
#!/bin/sh
if [ "$(params.status)" = "Succeeded" ]; then
COLOR="#4BB543"
EMOJI=":white_check_mark:"
STATUS_TEXT="*성공*"
else
COLOR="#FF0000"
EMOJI=":x:"
STATUS_TEXT="*실패*"
fi

cat <<EOF > /tmp/slack_message.json
{
"channel": "$(params.channel)",
"username": "$(params.username)",
"attachments": [
{
"color": "$COLOR",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "$EMOJI Tekton CI 결과"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*상태*: $STATUS_TEXT\n*파이프라인*: \`$(params.pipeline-name)\`"
}
}
]
}
]
}
EOF

/usr/bin/curl -X POST \
-H 'Content-type: application/json' \
-H "Authorization: Bearer $TOKEN" \
--data @/tmp/slack_message.json \
https://slack.com/api/chat.postMessage

pipeline에서는 무조건 실행되어야 하므로 finally 에서 Task를 정의해줍니다. 상태값은 Pipeline이 아닌 Task 단위로 확인해야 하므로 tasks.status 변수를 통해 Task의 상태를 가져옵니다.

  finally:
- name: notify-tasks
taskRef:
name: send-to-channel-slack
params:
- name: token-secret
value: token-secret
- name: channel
value: <channel_id>
- name: status
value: "$(tasks.status)"
- name: pipeline-name
value: "$(context.pipeline.name)"
- name: pipelinerun-name
value: "$(context.pipelineRun.name)"
- name: username
value: "Tekton Bot"

이를 통해 슬랙에서 Tasks들의 상태에 따라서 성공과 실패 메세지를 확인할 수 있습니다.

Tekton 성공 Slack Message

다만 위의 내용 만으로 어떤 Task에서 발생했는지 구체적인 정보를 확인할 수 없습니다. 그러므로 추후 Tekton의 성격을 살려서 깔끔하고 정확한 슬랙 메세지를 작성하고 싶습니다.’

기타

Tekton 파일을 보시면 리소스의 재사용성을 고려하지 않고 작성했다는 것을 확인할 수 있습니다. 특히 글을 쓰는 과정에서 잘 작성하지 못했다는 것을 더욱 느낀 것 같습니다. 앞으로 시간이 있으면 지금까지 작성한 스크립트를 정리해야 겠습니다.

References

<hr><p>GitOps 전환기 — Part 4. Tekton을 활용한 CI 구성하기 was originally published in S0okJu Technology Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>