Article - 깊게 탐구하기/시간 기록, 관리 서비스 Pinit

[Pinit] 핀잇 백엔드 마이크로서비스를 배포할 k8s 클러스터 구축하기

조금씩 차근차근 2025. 12. 23. 19:00

목차

  1. 인프라 세팅
    1. ARC 설치
    2. ARC가 사용할 ServiceAccount 정의
    3. pinit 네임스페이스에서 role 생성 & sa와 바인딩
  2. 워크플로우 작성
    1. 테스트/빌드
    2. 도커 이미지 업로드
    3. 러너가 해당 서비스 어카운트를 이용해 kubeconfig 생성
    4. kubectl을 이용해 해당 deployment 실행
  3. deployment 정의
    1. 파드에 secret 환경 변수 세팅하기
    2. 도커 이미지를 받아오고 해당 이미지를 배포
    3. Health Probe 시스템 구축

이 글은 쿠버네티스 클러스터 내에 Github Self-Hosted runner를 두고, 해당 러너에서 워크플로우를 트리거해 CI/CD를 진행하는 과정을 담은 가이드입니다.

진행하며 이해가 안되는 부분이 있을 경우, 댓글 남겨주시면 친절히 안내해드리도록 하겠습니다.


1. 인프라 세팅

우리가 hosted runner에게 바라는 동작은 hosted runner에서 이미지를 만들어, 쿠버네티스 클러스터 API를 통해 해당 이미지로 교체하고 롤링 전략 등을 사용해주는 것이다.

 

해당 배포를 진행하기 위한 방식에는 크게 두 가지 방식이 있다.

  • runner가 노드 안에 있지만, 클러스터 내부 파드로 관리되지 않는 경우
  • runner가 노드 안이자 CNI(Container Network Interface) 안에 파드 형태로 존재하는 경우

1번의 경우, 사실상 github의 hosted runner 와 연결 과정 상에 큰 차이가 없다.

 

클러스터 내부 Runner를 사용하면,

  • Pod에는 Projected ServiceAccount Token이 자동 마운트되고,
  • 이 토큰은 기본적으로 짧은 수명으로 자동 갱신(회전) 된다.
    그대신, runner를 Pod로 운영하기 위한 구성이 추가된다.

최대한 간단한 방식을 적용하려고 고민했는데, 이 방식이 좀 더 간단한 방식이라고 결론지었다.
그러므로 앞서 배운 helm을 이용해 클러스터 내부 컨트롤러를 구성해보자.

self-hosted runner 에 대한 충분한 지식이 없다면, private repository에서 실습해야 한다.

Actions Runner Controller 는 앞서 배운 helm을 이용해 다운받을 수 있다.

 

[쿠버네티스 튜토리얼] 6. Helm 설치 및 애플리케이션 관리

Kubernetes에서는 복잡한 애플리케이션 배포를 쉽게 하기 위해 Helm 패키지 매니저를 널리 사용한다.Helm은 차트(Chart)라는 단위로 Kubernetes 매니페스트들을 묶어 패키징하고, 템플릿화하여 재사용 가

dev.go-gradually.me

 


1) ARC(Actions Runner Controller) 설치

설치는 다음 명령어로 가능하다.

NAMESPACE="arc-systems"
helm install arc \
    --namespace "${NAMESPACE}" \
    --create-namespace \
    oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

이후 ARC가 GitHub API에 인증할 수 있도록 personal access token (classic) 을 생성해야 한다.

  • GITHUB_PAT 는 다음 스코프를 가진 GitHub PAT를 설정한다.
    • 리포지토리 러너: repo
    • 조직 러너: repo + admin:org
    • 엔터프라이즈 러너: manage_runners:enterprise

이제 이 토큰을 저장할 kubernetes secret을 만들어주자.

NAMESPACE="arc-runners"
sudo kubectl create namespace ${NAMESPACE}
sudo kubectl create secret generic pre-defined-secret \
   --namespace=${NAMESPACE} \
   --from-literal=github_token='YOUR_PAT'

Runner scale set 구성(Configuring a runner scale set)

ARC는 다음과 같이 동작한다.

  1. 위에서 정의한 Actions Runner Controller가 웹훅을 통해 CI/CD 요청이 들어옴을 트리거받는다.
  2. ARC는 러너가 실행될 네임스페이스에 pod를 생성하고, 해당 파드에 CI/CD의 작업을 위임한다.
  3. 파드는 해당 네임스페이스에 존재하는 service account를 이용해 우리의 배포 대상이 들어있는 namespacce에 접근하고, CI/CD작업을 실행한다.

이제 이 Runner가 실행될 네임스페이스를 정의하고, 해당 파드의 배포 방식을 결정하는 runner scale set을 구성해보자.

  1. 터미널에서 아래 명령을 실행해 runner scale set을 구성한다(ARC 구성에 맞게 값 변경).
INSTALLATION_NAME="arc-runner-set"
NAMESPACE="arc-runners"
GITHUB_CONFIG_URL="https://github.com/Pinit-Scheduler"
helm install "${INSTALLATION_NAME}" \
  --namespace "${NAMESPACE}" \
  --create-namespace \
  --set githubConfigUrl="${GITHUB_CONFIG_URL}" \
  --set githubConfigSecret=pre-defined-secret \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

명령을 실행할 때 다음을 유의해야 한다.

  • INSTALLATION_NAME 값은 신중히 설정하자. 워크플로에서 runs-on 값으로 이 설치 이름을 사용한다.
  • NAMESPACE 는 runner 파드가 생성될 위치로 설정한다.
  • GITHUB_CONFIG_URL 은 러너가 소속될 대상의 URL(리포지토리/조직/엔터프라이즈)을 설정한다.
  • 위 예시는 최신 버전 차트를 설치한다. 특정 버전 설치 시 --version 사용이 가능하다.
  • runner 파드는 오퍼레이터 파드가 있는 네임스페이스와 다른 네임스페이스 에 생성하는 것을 권장한다.

토큰이 아닌 미리 secret를 만들어뒀기 때문에, githubConfigSecret.token 형태가 아닌 githubConfigSecret을 사용했음에 유의하자.

  1. 설치 확인을 위해 다음을 실행한다.
    helm list -A

출력 예시는 다음과 유사하게 표시된다.

NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                                       APP VERSION
arc             arc-systems     1               2023-04-12 11:45:59.152090536 +0000 UTC deployed        gha-runner-scale-set-controller-0.4.0       0.4.0
arc-runner-set  arc-runners     1               2023-04-12 11:46:13.451041354 +0000 UTC deployed        gha-runner-scale-set-0.4.0                  0.4.0
  1. 매니저(manager) 파드를 확인하려면 다음을 실행한다.
kubectl get pods -n arc-systems

정상 설치된 경우 파드 상태가 Running으로 표시되게 된다.

NAME                                                   READY   STATUS    RESTARTS   AGE
arc-gha-runner-scale-set-controller-594cdc976f-m7cjs   1/1     Running   0          64s
arc-runner-set-754b578d-listener                       1/1     Running   0          12s

다음과 같이 해당 깃허브 리포지토리의 actions runner 탭을 확인해보면 여기에도 runner가 생성된다.


Runner scale sets 사용(Using runner scale sets)

정상적으로 설치되었는지 확인해야 뒤가 두렵지 않은 것은 개발자들의 특징일 것이다.

코드 한줄 짜고 테스트 돌리는 느낌으로.

이제 runner scale set 러너를 사용하는 간단한 테스트 워크플로를 생성하고 실행하자.

  1. 리포지토리에 아래와 유사한 워크플로를 생성한다. runs-on 값은 autoscaling runner set 설치 시 사용한 Helm 설치 이름과 일치해야 한다.
name: Actions Runner Controller Demo
on:
  workflow_dispatch:

jobs:
  Explore-GitHub-Actions:

    runs-on: arc-runner-set
    steps:
    - run: echo "🎉 This job uses runner scale set runners!"
  1. 워크플로를 리포지토리에 추가한 뒤, 수동으로 워크플로를 트리거한다.

성공 시 출력은 다음과 유사하다.


2) ARC가 사용할 ServiceAccount 정의

지금까지 "self-hosted" runner를 사용하기 위한 기본 세팅을 완료했다.
이제 쿠버네티스와 runner 간의 관점으로 이동해, runner가 우리 클러스터에 접근해서 CI/CD를 진행할 수 있게 하기 위한 작업들을 시작해보자.

 

CI/CD를 러너가 진행하기 위해선, runner 에서 쿠버네티스 클러스터 API에 접근이 가능해야 한다.

 

하지만 우리는 이미 hosted runner가 쿠버네티스 클러스터 API의 한 종류인 kubectl을 사용할 수 있는 상태이다.

이걸 위해서 self-hosted runner를 사용한 것이다.

만약 github hosted runner 와 같은 외부 hosted runner 에서 접속하고 싶다면, OIDC를 따로 적용해야 하는데, 이러면 주제와 맞지 않아져 Self-hosted Runner 를 선택했다.

우리는 OIDC와 kubeconfig을 사용하는 방식 중 후자인 kubeconfig을 사용하는 방식을 사용할 것이다.

kubeconfig이란?

kubectl이 “어느 클러스터에 어떤 자격으로 접속할지”를 담는 YAML 파일이다.

핵심 구성은 다음 4가지다.

  1. clusters
    • server: Kubernetes API Server URL (예: https://xxx:6443)
    • certificate-authority-data: API Server TLS를 검증할 CA(PEM) 데이터를 base64로 인코딩한 값
      (사설 CA 포함)
  2. users
    • token: 서비스어카운트 토큰(Bearer Token)
      또는 클라이언트 인증서 방식(이번 범위는 토큰 기준)
  3. contexts
    • 어떤 cluster + 어떤 user 조합을 사용할지
    • 기본 namespace를 넣어두면 매번 -n 안 써도 됨
  4. current-context
    • 기본으로 사용할 context 이름

자신의 config을 보고 싶다면, 다음 명령어를 사용해보자.

sudo cat $HOME/.kube/config

우리는 kubeconfig 방식으로 배포를 진행할 것이다.
kubeconfig 방식은 보통 서비스어카운트(ServiceAccount) + RBAC + 토큰 조합으로 이루어진다.

  • ServiceAccount: “이 배포 작업을 수행하는 주체”
  • RBAC(Role/RoleBinding): “무엇을 할 수 있는지(권한 범위)”
  • Token: “그 주체임을 증명하는 인증 수단”

설명이 길었는데, 다음과 같이 서비스 어카운트를 생성해주자.

서비스 어카운트 생성

kubectl -n arc-runners create serviceaccount gha-deployer

3) pinit 네임스페이스에서 role 생성 & sa와 바인딩


우리는 지금 위 그림에서 두개의 네임스페이스, arc-systemarc-runners에 들어가는 ARC와 Actions-Runner를 만들고, 해당 러너가 사용할 서비스 어카운트까지 설정을 완료했다.

 

이제 우리가 배포할 네임스페이스인 pinit에 Role을 만들고, 이를 arc-runners 의 sa에 바인딩하자.

만약 여러분들이 직접 사용하고 싶은 네임스페이스가 있다면, 해당 이름으로 변경해서 사용하자.

Role 생성

# app-deploy-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: gha-deploy-role
  namespace: pinit # 각자의 네임스페이스에 생성하자.
rules:
  - apiGroups: ["apps"]
    resources: ["deployments","replicasets","statefulsets","daemonsets"]
    verbs: ["get","list","watch","create","update","patch","delete"]
  - apiGroups: [""]
    resources: ["services","configmaps","endpoints"]
    verbs: ["get","list","watch","create","update","patch","delete"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get","list","watch","create","update","patch","delete"]
  - apiGroups: ["batch"]
    resources: ["jobs","cronjobs"]
    verbs: ["get","list","watch","create","update","patch","delete"]
  - apiGroups: [""]
    resources: ["pods","pods/log","events"]
    verbs: ["get","list","watch"]

Role과 RoleBinding의 경우, 실제로 해당 네임스페이스를 조작할 권한이 있는 곳에 생성한다.

 

여기서 시크릿 변경 기능은 넣지 않았는데, 우리의 경우 Secret은 사람이/별도 시스템이 미리 만들어두고, 배포는 참조만 하는 경우이다.

#deployment.yaml
envFrom:
  - secretRef:
      name: app-secret

이 경우 파드가 Secret을 읽는 것이지, 배포 계정이 Secret을 읽는 게 아니다.
배포 계정은 보통 Secret 권한 없이도 Deployment를 적용할 수 있다.

Rolebinding 생성

“arc-runners의 SA”를 “pinit”에 바인딩하자.

# app-deploy-rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: gha-deploy-binding
  namespace: pinit # 각자의 네임스페이스로 바꿔 사용하시길 바랍니다.
subjects:
  - kind: ServiceAccount
    name: gha-deployer
    namespace: arc-runners
roleRef:
  kind: Role
  name: gha-deploy-role
  apiGroup: rbac.authorization.k8s.io

위 매니페스트를 보면, subject로 arc-runners 네임스페이스에 존재하는 ServiceAccount "gha-deployer"를 지정하고, 해당 서비스 어카운트는 방금 위에서 생성한 roleRef gha-deploy-role과 바인딩했음을 알 수 있다.


2. 워크플로우 작성

시작하기 전에, 쿠버네티스 클러스터 내에서의 배포를 위한 워크플로우의 구성을 간단한 예제를 참고해보며 이해해보자.

앞서 진행했던 ci의 깃허브 UI와 코드를 함께 보면 이해하기 쉽다.

name: deploy
on:
  push:
    branches: ["main"]

jobs:
  deploy:
    runs-on: arc-runner-set
    steps:
      - uses: actions/checkout@v4

      - name: Install kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: "latest"

      - name: Build kubeconfig from in-cluster ServiceAccount
        shell: bash
        run: |
          mkdir -p ~/.kube
          cat > ~/.kube/config <<'EOF'
          apiVersion: v1
          kind: Config
          clusters:
          - name: in-cluster
            cluster:
              server: https://kubernetes.default.svc
              certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
          users:
          - name: sa
            user:
              tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
          contexts:
          - name: ctx
            context:
              cluster: in-cluster
              user: sa
              namespace: pinit # 각자의 네임스페이스 맞춰주세요.
          current-context: ctx
          EOF
          chmod 600 ~/.kube/config

      - name: Apply manifests
        run: |
          kubectl -n my-namespace apply -f k8s/
          kubectl -n my-namespace rollout status deploy/my-app --timeout=180s

1. 워크플로우 메타데이터

name: deploy

  • GitHub Actions UI(워크플로우 목록/실행 이력)에서 표시될 워크플로우 이름deploy로 지정한다.

우리가 테스트를 위해 방금 실행한 CI/CD의 탭을 Github Actions UI로 보면 다음과 같이 매칭될 것이다.

on:

  • 이 워크플로우를 언제 실행할지(트리거)를 정의한다.

push:

  • GitHub 저장소에 push 이벤트가 발생하면 실행 대상이 된다.

branches: ["main"]

  • push 이벤트 중에서도 대상 브랜치가 main일 때만 실행되게 한다.
  • 즉, main으로 푸시되거나 main으로 머지되어 push가 발생할 때 Job이 시작하게 된다.

2. Jobs 정의

jobs:

  • 이 워크플로우에서 수행할 Job들의 집합이다.

deploy:

  • Job의 ID(키)이다. UI에서는 Job 이름으로도 보인다.
  • 워크플로우 내에서 “deploy job”이라는 하나의 실행 단위를 정의한다.


3. Runner 선택

runs-on: arc-runner-set

  • 이 Job을 실행할 러너 환경을 선택한다.
  • 여기서는 GitHub Hosted Runner가 아니라, ARC(Actions Runner Controller)가 Kubernetes에 생성한 runner 그룹(=scale set)인 arc-runner-set에서 실행되게 한다.
  • 동작 흐름
  1. GitHub가 “arc-runner-set 라벨을 가진 idle runner”를 찾음
  2. 없으면 ARC가 runner Pod를 생성(스케일 아웃)
  3. 생성된 runner Pod가 Job을 받아서 step들을 순차 실행

4. Steps 실행(순서대로)

steps:

  • Job 내부에서 순차 실행할 단계(steps) 목록이다.
  • 기본적으로 각 step은 이전 step이 성공해야 다음 step 실행으로 진행된다(에러 시 job 실패).
  • 즉, 우리가 ci 문서를 작성할 때 심도있게 작성해야 하는 부분이다.
  • 이 부분을 하나씩 뜯어보며 커맨드 라인을 입력할 준비를 해보자.

Step 4-1) - uses: actions/checkout@v4

  • 리포지토리 코드를 runner 작업 디렉터리로 체크아웃(clone/fetch) 한다.
    • 이는 github 에서 공식적으로 제공하는 checkout 액션이다.
  • 결과
    • 이후 step에서 k8s/ 디렉터리 같은 리포 파일 경로를 그대로 사용할 수 있게 된다.
  • 내부적으로는 GitHub가 제공하는 토큰으로 해당 커밋/브랜치 소스를 다운받는다.

Step 4-2) kubectl 설치

- name: Install kubectl

  • step의 표시 이름이다(로그에 이 이름으로 표시).

uses: azure/setup-kubectl@v4

  • kubectl 바이너리를 runner 환경에 설치/설정해주는 GitHub Action을 실행한다.
    • 아까 위 actions/checkout@v4와 유사한 데에서 알 수 있듯이, azure 에서 제공하는 것이다.
  • 결과
    • 이후 step에서 kubectl 명령이 PATH에 잡혀 실행 가능해진다.

with: version: "latest"

  • 설치할 kubectl 버전을 지정한다.
  • 운영 관점에서는 “클러스터 버전과의 호환성”을 위해 버전을 고정하는 경우도 많다.

Step 4-3) in-cluster ServiceAccount 기반 kubeconfig 구성

- name: Build kubeconfig from in-cluster ServiceAccount

  • step 표시 이름.

shell: bash

  • 이 step의 스크립트는 bash로 실행된다.

run: |

  • 아래 여러 줄 스크립트를 한 번에 실행한다.

mkdir -p ~/.kube

  • runner 사용자 홈 디렉터리 아래에 ~/.kube 디렉터리를 생성한다.
  • -p라서 이미 존재해도 에러가 나지 않는다.

cat > ~/.kube/config <<'EOF' ... EOF

  • heredoc을 이용해 ~/.kube/config 파일을 생성/덮어쓰기 한다.
  • <<'EOF'(따옴표 있는 heredoc)라서, 내부 문자열은 변수 치환 없이 그대로 기록된다.

이 heredoc 안의 YAML이 “kubectl이 사용할 kubeconfig”이다. 각 항목의 의미는 다음과 같다.

 

apiVersion: v1, kind: Config

  • kubeconfig 파일의 형식임을 나타낸다.

clusters: ...

  • kubectl이 접속할 대상 “클러스터” 정의이다.

- name: in-cluster

  • 클러스터 엔트리의 이름을 in-cluster로 지정한다.
  • 임의 이름으로, 동일 kubeconfig 내에서 일관되기만 하면 된다.

server: https://kubernetes.default.svc

  • Kubernetes 클러스터 내부에서 사용 가능한 내장 DNS 서비스명이다.
  • runner Pod가 클러스터 내부에 있으므로, API Server에 이 주소로 접근할 수 있다.

certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt

  • TLS 서버 인증서 검증에 사용할 CA 인증서를 지정한다.
  • Kubernetes는 Pod에 ServiceAccount 토큰을 마운트할 때, 기본적으로 CA 인증서 파일도 같은 경로에 함께 제공하고 있다.
  • 이 값을 통해 kubectl이 API Server의 TLS를 정상 검증한다.

users: ...

  • 인증 주체(자격증명)를 정의한다.

- name: sa

  • 사용자 엔트리 이름을 sa로 지정했다.
  • 그동안 name 변수를 다뤄봐서 알듯이, 일관된 임의 이름으로 지정하면 된다.

tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token

  • Bearer 토큰을 파일에서 읽도록 지정한다.
  • 이 토큰 파일은 Pod가 사용하는 ServiceAccount의 토큰(대개 projected token)이며, 만료/회전 메커니즘을 따른다.
  • 즉, 워크플로우가 별도로 토큰을 “발급”하지 않아도, Pod가 가진 SA 신원으로 API를 호출한다.

contexts: ...

  • “어떤 cluster + 어떤 user 조합으로 실행할지”를 정의한다.

- name: ctx

  • 컨텍스트 이름.

cluster: in-cluster

  • 위에서 정의한 clustersin-cluster를 사용.

user: sa

  • 위에서 정의한 userssa를 사용.

namespace: my-namespace

  • 기본 네임스페이스를 my-namespace로 설정한다.
    • 본인이 사용하고 있는 배포할 네임스페이스로 지정하자.
  • 이후 kubectl get pods처럼 -n을 생략하면 기본으로 이 네임스페이스를 대상으로 한다.
  • 다만 지금 보고 있는 워크플로우는 다음 step에서 -n my-namespace를 명시하고 있어, 이 설정은 “안전 장치/일관성” 역할이 크다.

current-context: ctx

  • 기본으로 사용할 컨텍스트를 ctx로 지정한다.
  • 이후 kubectl 실행 시 별도로 --context를 주지 않으면 ctx로 동작한다.

chmod 600 ~/.kube/config

  • kubeconfig 파일을 소유자만 읽기/쓰기 가능하도록 권한을 제한한다.
  • 토큰 참조가 들어가므로, 불필요한 노출을 막는 운영상 기본 조치이다.

Step 4-4) 매니페스트 적용 및 롤아웃 확인

- name: Apply manifests

  • step 표시 이름.

run: |

  • 아래 명령들을 순서대로 실행한다.

kubectl -n my-namespace apply -f k8s/

  • k8s/ 디렉터리 내 Kubernetes 매니페스트(YAML)들을 읽어서 클러스터에 생성/갱신(apply) 한다.
  • “선언적 적용”이라 기존 리소스가 있으면 patch/update되고, 없으면 create된다.
  • 권한은 runner Pod의 ServiceAccount(gha-deployer 등)에 부여된 RBAC 범위 내에서만 가능하다.
  • -n my-namespace는 이 명령이 적용될 네임스페이스를 명시한다.
    • 매니페스트에 metadata.namespace가 명시된 리소스는 그 값이 우선될 수 있으니 주의하자.
    • 자신이 사용할 namespace를 적용하면 된다.
  • 만약 k8s/ 내에 있는 매니페스트들에 적용 순서가 중요하다면,
    • step을 나눠서 설계하거나,
    • 애초에 배포 과정에 들어갈 매니페스트가 아닌 것들이 k8s 폴더 내에 포함된 건 아닌지 확인해보자.

kubectl -n my-namespace rollout status deploy/my-app --timeout=180s

  • my-namespace의 Deployment my-app에 대해 롤아웃 진행 상태를 감시한다.
  • --timeout=180s 동안 새 ReplicaSet/Pod가 정상 Ready 상태가 되기를 기다리며,
    • 시간이 초과되거나 실패하면 명령이 실패(exit code != 0)하여 Job 전체가 실패로 종료된다.
  • 이 단계는 “apply는 성공했지만 실제로는 새 Pod가 뜨지 않는” 상황을 CI에서 즉시 감지하기 위한 표준 패턴이다.

요약하면 다음과 같다.

  1. main 브랜치에 push가 오면,
  2. ARC가 제공하는 Kubernetes 내부 runner Pod에서 코드를 체크아웃하고
  3. kubectl을 설치한 뒤,
  4. runner Pod에 자동 마운트된 ServiceAccount 토큰/CA를 기반으로 kubeconfig를 만들어
  5. k8s/ 매니페스트를 적용하고
  6. Deployment 롤아웃 완료까지 확인한다.

이제 이 내용을 기반으로 CI/CD 워크플로우를 작성해보자.

jobs를 중점적으로 설명할 예정이다.
on, permission 관련 설정은 본인의 상황에 맞게 설정해주길 바란다.


0) 환경 변수 세팅

jobs:
  build-test-push-deploy:
    runs-on: [ arc-runner-set ]

    env:
      IMAGE_REPO: ghcr.io/pinit-scheduler/pinit-task/app
      NAMESPACE: pinit
      DEPLOYMENT_NAME: pinit-task
      CONTAINER_NAME: app
      MANIFEST_PATH: k8s/deployment.yaml

우선, 위에서 생성한 arc-runner-set을 사용해준다고 지정하고, 배포할 이미지를 저장할 repo와 네임스페이스, 배포 이름, 컨테이너 이름, 해당 deployment 매니페스트가 존재하는 경로를 환경변수로 세팅해뒀다.


1) 테스트/빌드

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup JDK
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: gradle

      - name: Build & Test
        run: ./gradlew clean generateProto test build

여기까지는 평범한 CI/CD 과정과 같다.
여기서 나의 경우, gRPC 서버/클라이언트 코드를 생성해야 했기 때문에, generateProto 명령어 또한 추가해주었다.

queryDsl을 써본 분들은 알겠지만, 해당 인프라를 사용하기 위해선 자동 생성된 코드를 한번 만들어두기 위해 의도적으로 ./gradlew build를 한번 수행하고 코드를 작성한다.
이와 비슷한 방식이라고 생각하면 된다.
(나의 경우, 저걸 안하면 컴파일이 안된다.)


2) 도커 이미지 업로드

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build & Push Image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE_REPO }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

이후, 빌드후 생성된 jar을 이미지화하고, 해당 이미지를 ghcr에 푸시하는 작업을 수행했다.

쿠버네티스의 deployment는 배포할 jar 파일을 컨테이너 이미지로 알아야 하는데, 이를 건네줄 방법으로 ghcr을 선택한 것이다.

해당 이미지를 생성할 때 사용한 도커파일은 다음과 같다.

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jre-jammy

WORKDIR /app

# 비루트 실행(쿠버네티스 securityContext와도 정합성 좋음)
RUN useradd -r -u 10001 -g root appuser \
  && mkdir -p /app \
  && chown -R 10001:0 /app

# GitHub Actions에서 ./gradlew clean generateProto test build 로 생성된 산출물 사용
# (주의) build/libs 에 plain.jar 와 bootJar가 같이 생길 수 있어 bootJar를 선택하도록 처리
COPY build/libs/*.jar /app/

RUN set -eux; \
  JAR="$(ls /app/*.jar | grep -v -- '-plain\.jar$' | head -n 1)"; \
  mv "$JAR" /app/app.jar; \
  rm -f /app/*-plain.jar || true; \
  chown 10001:0 /app/app.jar

USER 10001

EXPOSE 8080
EXPOSE 9090

# JVM 옵션은 Kubernetes 매니페스트에서 JAVA_TOOL_OPTIONS로 주입 권장
ENTRYPOINT ["java","-jar","/app/app.jar"]

9090의 경우, gRPC 기본 포트이다. 무시해도 좋다.


3) 러너가 해당 서비스 어카운트를 이용해 kubeconfig 생성

      - name: Install kubectl (if needed)
        uses: azure/setup-kubectl@v4
        with:
          version: v1.33.6

      - name: Create kubeconfig from in-cluster ServiceAccount
        shell: bash
        run: |
          TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
          CA_PATH="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"

          cat > kubeconfig <<EOF
          apiVersion: v1
          kind: Config
          clusters:
          - name: in-cluster
            cluster:
              server: https://kubernetes.default.svc
              certificate-authority: ${CA_PATH}
          contexts:
          - name: in-cluster
            context:
              cluster: in-cluster
              namespace: ${NAMESPACE}
              user: sa
          current-context: in-cluster
          users:
          - name: sa
            user:
              token: ${TOKEN}
          EOF

          echo "KUBECONFIG=$PWD/kubeconfig" >> $GITHUB_ENV

위에서 설명한 간단한 예제처럼 kubeconfig을 생성했다.

이를 통해 runner가 kubectl API를 사용할 수 있게 되었다.


4) kubectl을 이용해 해당 deployment를 실행

      - name: Install envsubst
        run: sudo apt-get update && sudo apt-get install -y gettext-base

      - name: Deploy (apply manifest with GITHUB_SHA substitution)
        shell: bash
        run: |
          command -v envsubst >/dev/null 2>&1 || (echo "envsubst not found" && exit 1)

          export GITHUB_SHA="${{ github.sha }}"
          envsubst < "${MANIFEST_PATH}" | kubectl apply -f -

      - name: Rollout status
        run: kubectl rollout status deployment/${{ env.DEPLOYMENT_NAME }} -n ${{ env.NAMESPACE }} --timeout=180s

환경변수를 주입하고, 정의한(아직 정의 안했지만, 다음 장에서 정의할 예정이다) deployment를 배포한다.


3. deployment 정의

이제 앞서 배운 deployment 작성 방법을 토대로 deployment를 작성할 차례이다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pinit-task
  namespace: pinit
  labels:
    app: pinit-task
spec:
  replicas: 2
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: pinit-task

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

  template:
    metadata:
      labels:
        app: pinit-task
    spec:
      imagePullSecrets:
        - name: ghcr-pull-secret
      terminationGracePeriodSeconds: 30
      volumes:
        - name: keys
          secret:
            secretName: pinit-keys
            defaultMode: 0444
      containers:
        - name: app
          image: ghcr.io/pinit-scheduler/pinit-task/app:${GITHUB_SHA} # GITHUB_SHA 환경변수는 GitHub Actions에서 설정되며 envsubst로 치환된다.
          imagePullPolicy: IfNotPresent

          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod

          volumeMounts:
            - mountPath: /etc/keys
              name: keys
              readOnly: true


          ports:
            - name: http
              containerPort: 8080
            - name: grpc
              containerPort: 9090

          envFrom:
            - secretRef:
                name: pinit-task-secret

          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 10
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 6

          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3

          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

          lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 5" ]

deployment를 배포하는 과정에선 다양한 버그가 발생할 수 있다.
따라서, 애플리케이션이 실행될 때 로그를 잘 확인하기 위한 쉘 스크립트와 애플리케이션 로딩 시 로그를 적극적으로 확인할 마음의 준비를 해둬야 한다.
나의 경우, 현재 배포하고 있는 pinit-task 애플리케이션에 대한 로그를 확인하는 커맨드(sudo kubectl -n pinit logs pinit-task-f4fd69bbd-sx6sk --previous)를 쉘 스크립트로 두고, 수시로 확인하는 과정을 거쳤다.


1) 기본 메타데이터와 셀렉터

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pinit-task
  namespace: pinit
  labels:
    app: pinit-task
spec:
  selector:
    matchLabels:
      app: pinit-task
  • apiVersion / kind: apps/v1, Deployment
  • namespace: pinit
    → 리소스가 pinit 네임스페이스에 생성된다.
  • labels: app: pinit-task
  • selector.matchLabels: app: pinit-task
    → Deployment가 관리할 Pod를 이 라벨로 식별한다.
  • template.metadata.labels: 동일하게 app: pinit-task
    → selector와 template label을 일치시켜야 한다.
    • 추후 Service가 이 라벨을 사용해 트래픽을 라우팅하게 된다(일반적인 패턴).

2) 레플리카 / 리비전 히스토리

spec:
  replicas: 2
  revisionHistoryLimit: 3
  • replicas: 2
    → 항상 2개의 Pod를 유지하고 있다.
  • revisionHistoryLimit: 3
    → 롤백용 ReplicaSet을 최대 3개까지 보관한다.
  • 롤백 대비는 가능하나, 더 많은 히스토리가 필요하면 상향할 수 있다(운영 정책에 따라).

3) 배포 전략: RollingUpdate (무중단 지향)

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0
  • maxUnavailable: 0: 업데이트 중에도 “사용 불가 Pod 수 0” 유지
  • maxSurge: 1: 기존 2개 + 최대 1개까지 임시로 더 띄워서 교체
    • surge란, 급등하는 것들을 의미하는 단어로, 최대 1개까지 더 생성될 수 있다는 의미이다.

해당 세팅은 다음과 같은 결과를 의미한다.

  • 업데이트 시점에 최대 3개 Pod가 잠깐 존재할 수 있고,
  • 준비 완료(readiness)된 새 Pod가 뜬 뒤 기존 Pod를 내리므로 가용성 우선 전략이다.
  • 클러스터 리소스가 타이트하면 Surge로 인해 스케줄 실패 가능성이 있다(특히 노드 여유가 적을 때).
    • 각자 노드 환경에 맞춰 surge 값을 결정하자.

4) 이미지 / 풀 시크릿 / 이미지 태깅 방식

    spec:
      imagePullSecrets:
        - name: ghcr-pull-secret
      terminationGracePeriodSeconds: 30
      volumes:
        - name: keys
          secret:
            secretName: pinit-keys
            defaultMode: 0444
      containers:
        - name: app
          image: ghcr.io/pinit-scheduler/pinit-task/app:${GITHUB_SHA} # GITHUB_SHA 환경변수는 GitHub Actions에서 설정되며 envsubst로 치환된다.
          imagePullPolicy: IfNotPresent
  • imagePullSecrets: ghcr-pull-secret
    → GHCR(private registry)에서 이미지를 당기기 위한 Secret 사용.
  • image: ghcr.io/.../app:${GITHUB_SHA}
    → GitHub Actions에서 GITHUB_SHAenvsubst로 치환해 커밋 SHA 기반 태그로 배포하는 구조.
  • imagePullPolicy: IfNotPresent
    • 노드에 동일 태그 이미지가 이미 있으면 재다운로드하지 않는다.
  • 커밋 SHA 태그는 보통 “불변(immutable)” 태그로 운영하므로 IfNotPresent와 궁합이 좋다.
  • 다만, 동일 SHA 태그를 재푸시하는 정책(비권장)이면 노드 캐시 때문에 갱신이 반영되지 않을 수 있다.
  • 혹시 배포가 이루어지지 않았다면, 동일 태그이진 않은지 확인해보자. 현재 우리의 제작 과정에서는 커밋 메시지에 담긴 태그를 이용했기 때문에, 웬만해선 같은 태그일 가능성은 없다.

5) 종료 처리: terminationGracePeriod + preStop

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30
      container:
        lifecycle:
            preStop:
              exec:
                command: [ "sh", "-c", "sleep 5" ]
  • terminationGracePeriodSeconds: 30
  • lifecycle.preStop: sleep 5
  • Pod 종료(SIGTERM) 시점에 5초를 먼저 대기하고,
  • 전체적으로는 최대 30초까지 정상 종료를 기다린다.
  • 이는 Graceful shutdown 을 위한 세팅이다.
  • sleep 5는 종종 서비스 엔드포인트에서 제외(Ready=false) 되는 시간을 벌기 위한 용도이다.
  • 다만 “애플리케이션의 graceful shutdown이 실제로 30초 내에 완료되는지”가 핵심이다.
    • Spring Boot는 기본적으로 SIGTERM 시 graceful shutdown이 가능하지만, 실제 종료 시간은 작업량/연결 상태에 따라 달라질 수 있다.

6) 볼륨: Secret 마운트

volumes:
  - name: keys
    secret:
      secretName: pinit-keys
      defaultMode: 0444
volumeMounts:
  - mountPath: /etc/keys
    name: keys
    readOnly: true
  • pinit-keys Secret의 키/파일들을 /etc/keys에 파일로 마운트하고 있다.
  • 권한은 0444(읽기 전용) 로 설정.
  • Secret 변경 시 마운트된 파일이 자동으로 갱신될 수 있지만(쿠버네티스 동작), 애플리케이션이 이를 런타임에 재로딩하는지는 별개이다.
  • 키 파일을 애플리케이션에서 어떻게 참조하는지(경로/파일명)와 일치해야 한다.

여기서 secret의 Mode가 0444로 되어있는데, 이는 다음과 같은 기술적 판단의 수행이었다.

  • 저 해당 키 값들은 서버의 인증을 수행하기 위한 jwt 공개 키와 비밀 키, 그리고 firebase 콘솔을 사용하기 위한 비밀 키들이다.
  • 그런데, 400으로 해당 볼륨을 마운트하면 컨테이너가 해당 파일을 생성했다고 판단하고, 읽기 권한을 만들어둘 수 있을 줄 알았다.
  • 하지만, 400으로 설정하니, 로그 상으로 해당 파일을 읽을 권한이 없어 키 로딩 없이 애플리케이션 컨텍스트가 로드되고 있었고, 정상 빈 주입이 실패하고 있었다.
  • 따라서 임시 방편으로 0444를 사용했다.
    • 하지만, 이는 다른 파드에서 해당 키 값들을 읽을 수 있게 되므로, 다른 파드에서 보안적으로 문제가 생겨도 해당 파드의 키 값들이 유출될 가능성이 있따.
  • 하지만, 현재 설계 상 모든 마이크로 서비스들이 같은 키를 공유하고 있었기 때문에, 다음과 같은 보안적으로 허술한 도구를 채택하게 도었다.

즉, 여러분들에게 여유와 리소스가 있다면, 좀 더 좋은 방법을 생각해보길 바란다.


7) 환경변수 구성

          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
          envFrom:
            - secretRef:
                name: pinit-task-secret
  • 개별 env
    • SPRING_PROFILES_ACTIVE=prod
  • envFrom
    • secretRef: pinit-task-secret
  • Spring profile은 prod로 고정했다.
  • 추가 설정(예: DB, API key 등)은 pinit-task-secret의 key/value가 환경변수로 주입된다.

이미지를 이용한 애플리케이션 배포에는 크게 다음 세 가지 구성요소가 포함되어야 한다.

  1. 배포 애플리케이션 .jar
  2. 네임스페이스 내에 이미 존재하는 secret
  3. 네임스페이스 내에 이미 존재하는 configMap

우리는 이미지를 배포할 때 secret과 같은 것들을 포함시키지 않고 빌드 후 ghcr에 푸쉬했다.
이후, deployment에서 해당 secret을 실제로 환경 변수로 세팅해주는 작업을 진행하고 있다.

secret 값들이 빌드 이미지에 들어가면 안된다. secret 값들은 실제 실행하는 환경(pod)만 알 수 있게 해야 한다.

우리가 애플리케이션을 배포할 때는 appication.yml 내에 중요 값들이 포함되게 된다.
일반적으로 Spring Boot의 application.yml에 있던 값을 Kubernetes의 ConfigMap(비민감)Secret 으로 분리해서 넣고, Pod에는 환경변수 또는 파일 마운트 방식으로 주입하게 된다.

기본적으로 application.yml은 환경 변수 주입 방식을 잘 지원한다.

  • Secret 값 변경 후 기존 Pod는 자동으로 env가 갱신되지 않으므로 일반적으로 롤아웃 재시작이 필요하다(운영 절차로 관리).

8) 포트

          ports:
            - name: http
              containerPort: 8080
            - name: grpc
              containerPort: 9090
  • http: 8080
  • grpc: 9090
  • 컨테이너 내부에서 HTTP(Actuator 포함)와 gRPC를 동시에 노출했다.
  • 아래에서 설정한 readiness/liveness probe가 port: http(이름 참조)로 되어 있어 포트 이름 일치가 중요하다. 현재는 일치하고 있다.

9) Health Probe 설계

Readiness Probe

          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 10
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 6
  • GET /actuator/health/readiness
  • initialDelay: 10s, period: 5s, timeout: 2s, failureThreshold: 6

의미/효과

  • 기동 후 10초부터 체크 시작
  • 연속 실패 6회면 NotReady로 판단(대략 30초 이상 실패 시)
  • Ready가 되기 전에는 Service 엔드포인트에 포함되지 않으므로 무중단 롤링 업데이트에 필수 요소이다.

Liveness Probe

          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
  • GET /actuator/health/liveness
  • initialDelay: 30s, period: 10s, timeout: 2s, failureThreshold: 3

의미/효과

  • 기동 후 30초부터 체크
  • 연속 실패 3회면 컨테이너 재시작(대략 30초 내 3번 실패 시)
  • Spring Boot Actuator의 liveness/readiness 엔드포인트를 쓰는 구성이다.
  • 해당 2초의 체크는 임의로 설정한 것으로, 이 빈도가 지나치게 짧으면 서버 자체에 부하가 강해지게 된다.
  • 보통은 “헬스 엔드포인트의 최악 응답시간”을 기준으로 조정하는 것이 안전하다.

10) 리소스 Requests/Limits

          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
  • requests: cpu 100m, memory 256Mi
  • limits: cpu 500m, memory 512Mi
  • 스케줄링 보장은 100m/256Mi 기준이다.
  • CPU는 burst 가능(최대 500m).
  • 메모리는 512Mi 초과 시 OOMKill 가능.
  • 실제 안정 운영을 위해서는
    • 실제 Pod 메모리 사용량(peak)과 OOM 여부
    • JVM 옵션(예: -XX:MaxRAMPercentage) 적용 여부
  • 를 함께 점검하자.

이상으로 CI/CD 배포를 마쳤다.

이제 각자 원하는 대로 서비스와 인그레스를 등록하고, https를 붙여주면서 사용할 수 있다.

 

[쿠버네티스 튜토리얼] 3. 서비스 노출: ClusterIP, NodePort, Ingress

Kubernetes에서 Service(서비스) 객체는 Pod들의 논리적인 집합에 단일 접근 포인트(IP 및 DNS 이름)을 제공하는 역할을 한다.앞서 Deployment로 Nginx Pod 여러 개를 띄웠다면, 클라이언트가 그중 어떤 Pod에

dev.go-gradually.me