Skip to content

GitLab CI/CD를 활용한 EKS 배포 자동화

이 문서에서는 GitLab CI/CD 파이프라인을 이용해 Terraform(OpenTofu) 프로젝트로 EKS 클러스터를 구성하고 배포하는 과정을 다룬다.

이전 문서에서 공유했던 OOM 장애 사례에서도 등장했지만, 운영 환경에서는 GitLab과 EKS를 함께 사용하고 있었다.

CI/CD 러너들이 Gitaly에 부하를 집중시켜 장애가 발생했던 것처럼, 파이프라인 설계와 인프라 배포 자동화는 운영 안정성과 직결되는 부분이다.

이번 문서에서는 그 인프라를 어떻게 코드로 관리하고 GitLab 파이프라인을 통해 배포하는지 정리한다.


왜 GitLab CI/CD인가

인프라 자동화 도구는 여러 가지가 있다. ArgoCD, GitHub Actions, Jenkins, AWS CodePipeline 등 선택지가 많은 상황에서 GitLab CI/CD를 선택한 이유를 먼저 정리한다.

단일 플랫폼에서 모든 것이 해결된다

GitLab을 선택한 가장 큰 이유는 코드 저장소, CI/CD, 컨테이너 레지스트리, Terraform state 관리, 이슈 트래커가 하나의 플랫폼에 통합되어 있다는 점이다.

GitHub Actions도 CI/CD 기능이 있고 Jenkins도 오래된 생태계가 있지만, Terraform state 백엔드를 별도로 구성해야 하고 컨테이너 레지스트리도 따로 운영해야 한다. GitLab은 이 모든 것이 내장되어 있어서 초기 셋업 비용이 적다.

예를 들어 GitHub Actions로 같은 환경을 구성한다고 가정하면, Terraform state를 저장할 S3 버킷과 lock용 DynamoDB 테이블을 따로 만들어야 한다. 컨테이너 이미지를 푸시할 ECR이나 Docker Hub도 별도로 연동해야 한다. 이슈 관리는 Jira나 Linear 같은 외부 도구를 붙여야 하고, 그러면 자연스럽게 계정 관리 포인트가 늘어난다.

GitLab은 프로젝트 하나 만들면 이 모든 게 기본으로 따라온다. 소규모 팀에서 이건 꽤 큰 차이다.

Self-Hosted가 가능하다

GitLab은 Self-Hosted 설치가 가능하다. 이 점이 특히 중요한 환경이 있다. 보안 정책상 소스코드를 외부 SaaS에 올릴 수 없는 경우, 사내 서버나 프라이빗 클라우드 환경에 GitLab을 직접 설치해서 운영할 수 있다.

09번 문서에서도 언급했듯이 EKS 위에 GitLab 자체를 운영하고 있었다. GitLab Runner도 같은 클러스터나 별도 클러스터에 Kubernetes executor로 구성할 수 있어서, 전체 CI/CD 인프라를 자체적으로 통제할 수 있다.

GitHub Enterprise Server도 Self-Hosted가 되긴 하지만, 라이선스 비용이 상당하고 설치/운영 복잡도도 높다. Jenkins는 Self-Hosted가 기본이지만 플러그인 관리 지옥이 별도로 존재한다.

.gitlab-ci.yml 하나로 파이프라인을 정의한다

GitLab CI/CD는 프로젝트 루트에 .gitlab-ci.yml 파일 하나만 두면 파이프라인이 동작한다. 별도의 웹 UI에서 Job을 설정하거나 플러그인을 설치할 필요가 없다. 파이프라인 정의 자체가 코드로 관리되기 때문에, 파이프라인 변경 이력도 Git에 남는다.

Jenkins의 경우 Jenkinsfile로 파이프라인을 정의할 수 있지만, 실제로는 Jenkins 서버의 설정(플러그인 버전, 시스템 설정, credential 관리)에 의존하는 부분이 많아서 Jenkinsfile만으로는 동일한 환경을 재현하기 어렵다. 파이프라인이 실패했을 때 원인이 코드에 있는지 Jenkins 서버 설정에 있는지 구분하는 것 자체가 디버깅의 한 단계가 된다.

Merge Request 기반 워크플로와 자연스럽게 맞물린다

GitLab CI/CD는 Merge Request와 파이프라인이 긴밀하게 연결되어 있다. MR을 생성하면 자동으로 파이프라인이 실행되고, plan 결과가 MR 화면에 바로 표시된다. 리뷰어는 코드 변경사항과 plan 결과를 같은 화면에서 확인할 수 있다.

Terraform 인프라 코드의 특성상 코드만 봐서는 실제로 어떤 리소스가 변경되는지 파악하기 어려운 경우가 많다. 변수 하나를 바꿨는데 노드 그룹 전체가 교체될 수도 있다. plan 결과가 MR에 자동으로 붙으면 리뷰어가 이런 부분을 쉽게 확인할 수 있고, 실수를 머지 전에 잡을 수 있다.

GitLab CI/CD의 장단점

운영하면서 느낀 장단점을 정리하자면..

장점

첫번째, Terraform State 백엔드가 내장되어 있다. 앞에서도 언급했지만 이 부분이 실무에서 가장 편리했다. S3 + DynamoDB 조합으로 백엔드를 구성하면 버킷 생성, DynamoDB 테이블 생성, IAM 권한 설정, 암호화 설정 등을 해야 하는데, GitLab HTTP Backend는 프로젝트를 만드는 순간 바로 사용할 수 있다. state locking도 자동으로 지원된다.

두번째, CI/CD 변수 관리가 직관적이다. 프로젝트, 그룹, 인스턴스 레벨로 변수를 계층적으로 관리할 수 있다. 예를 들어 AWS 자격증명은 그룹 레벨에 두고, 프로젝트별로 다른 도메인이나 리전은 프로젝트 레벨에 두는 식이다. Protected, Masked, Environment scope 같은 옵션도 있어서 변수의 노출 범위를 세밀하게 제어할 수 있다.

세번째, 파이프라인 시각화가 잘 되어 있다. 스테이지별 진행 상황, 각 Job의 로그, 실패 원인 등을 웹 UI에서 바로 확인할 수 있다. 특히 manual 트리거가 필요한 Job(apply, destroy)은 버튼 형태로 표시되어서, 비개발 직군도 배포 상태를 한눈에 파악할 수 있다.

네번째, 환경(Environment) 기능으로 배포 이력을 추적할 수 있다. 어떤 커밋이 어떤 환경에 언제 배포되었는지, 현재 어떤 버전이 활성 상태인지를 GitLab UI에서 확인할 수 있다.

단점

첫번째, Runner 관리가 번거롭다. GitLab.com SaaS를 사용하면 공유 Runner가 제공되지만, Self-Hosted 환경에서는 Runner를 직접 설치하고 관리해야 한다. Runner의 버전 업그레이드, 캐시 관리, 디스크 정리, executor 설정 등을 신경 써야 한다. 09번 문서의 장애도 결국 Runner들이 동시에 부하를 발생시킨 것이 원인이었다. Runner 자체의 리소스 관리와 동시 실행 수 제한을 적절히 설정하지 않으면 비슷한 문제가 반복될 수 있다.

두번째, 복잡한 파이프라인은 YAML 지옥이 된다. .gitlab-ci.yml이 단순할 때는 깔끔하지만, 환경별 분기, 조건부 실행, 매트릭스 빌드 등이 추가되면 YAML 파일이 수백 줄로 늘어난다. includeextends로 모듈화할 수 있지만, 디버깅이 어려워진다. 특히 rules 조건이 복잡해지면 "이 Job이 왜 실행됐지?" 또는 "왜 안 됐지?"를 파악하는 데 시간이 걸린다.

세번째, GitLab 자체의 리소스 소비가 크다. Self-Hosted GitLab을 운영해본 사람이면 공감할 텐데, GitLab은 Sidekiq, Gitaly, PostgreSQL, Redis 등 여러 컴포넌트로 구성되어 있어서 메모리를 상당히 많이 사용한다. 09번 문서에서 Gitaly가 메모리를 무제한으로 소비해서 OOM이 발생한 것도 이런 구조적 특성과 관련이 있다. GitLab 자체를 운영하는 데 드는 인프라 비용과 관리 공수를 미리 고려해야 한다.

전체 흐름

GitLab CI/CD로 EKS를 배포하는 가장 기본적인 전체 흐름은 아래와 같다.

flowchart LR
    A[Software Engineer] -->|git push| B[GitLab Repository]
    B -->|트리거| C[GitLab CI/CD Pipeline]
    C --> D[tofu init/tofu validate/tofu plan]
    D -->|MR 리뷰| E{승인 여부}
    E -->|승인| F[tofu apply]
    E -->|반려| G[수정 후 재푸시]
    F --> H[EKS Cluster]

개발자가 코드를 푸시하면 GitLab Runner가 파이프라인을 실행하고, plan 결과를 Merge Request에서 확인한 뒤 승인하면 apply가 실행되는 구조로 볼 수 있다.

위 과정 이외에도 사실 자잘한 검증들(포멧팅, 스크립트 검증)등을 추가하면 더욱 좋은 결과물을 만들 수 있다.

사전 준비

GitLab Runner 구성

GitLab CI/CD를 실행하려면 Runner가 필요하다. Runner는 파이프라인의 각 Job을 실제로 수행하는 에이전트다.

Runner를 등록하는 방법은 여러 가지가 있지만, EKS 환경에서는 Kubernetes executor를 사용하는 것이 일반적이다.

Runner 자체를 Kubernetes Pod로 띄우고, 각 Job마다 별도 Pod를 생성해서 격리된 환경에서 실행한다.

다만 이 문서에서 다루는 Terraform 프로젝트는 EKS 클러스터 자체를 생성하는 코드이기 때문에, 클러스터가 아직 없는 초기 단계에서는 별도의 서버나 Docker executor를 가진 Runner가 필요하다.

클러스터가 한번 구성된 이후에는 Kubernetes executor로 전환해도 된다.

CI/CD 변수 설정

GitLab 프로젝트의 Settings -> CI/CD -> Variables에서 아래 변수들을 등록해야 한다.

  • AWS_ACCESS_KEY_ID: AWS 접근 키
  • AWS_SECRET_ACCESS_KEY: AWS 시크릿 키
  • AWS_DEFAULT_REGION: eu-west-2 (또는 사용하는 리전)
  • TF_VAR_domain_name: 사용할 도메인 (예: testing.leopark.me)

AWS 자격증명은 Masked와 Protected 옵션을 둘 다 켜두는 것이 좋다. Masked를 켜면 Job 로그에 값이 노출되지 않고, Protected를 켜면 보호된 브랜치에서만 해당 변수를 사용할 수 있다.

SSO를 사용하는 환경이라면 Access Key 대신 OIDC를 통한 Assume Role 방식을 고려해야 한다. 장기 자격증명을 CI 환경에 저장하는 것은 보안상 좋지 않기 때문이다.

Terraform Backend 구성

여러 사람이 같은 인프라를 관리하거나, CI/CD에서 자동으로 배포하려면 Terraform state 파일을 원격 저장소에 보관해야 한다. 로컬에 state를 두면 다른 환경에서 apply할 때 state 불일치로 인해 인프라가 꼬일 수 있다.

GitLab에는 Terraform HTTP Backend가 내장되어 있어서 별도의 S3 버킷 없이도 state를 관리할 수 있다. versions.tofu 파일에 backend 설정을 추가한다.

terraform {
  backend "http" {
  }
}

실제 backend 주소와 인증 정보는 .gitlab-ci.yml에서 환경변수로 주입하는 방식이 깔끔하다. 이렇게 하면 코드에 민감 정보가 포함되지 않는다.

.gitlab-ci.yml 작성

이제 핵심인 파이프라인 정의 파일을 작성한다. 전체 파이프라인은 4개 스테이지로 구성했다.

stages:
  - validate
  - plan
  - apply
  - destroy

image:
  name: ghcr.io/opentofu/opentofu:latest
  entrypoint: [""]

variables:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"

# GitLab Managed Terraform State 사용
.terraform-init: &terraform-init
  - export TF_HTTP_ADDRESS="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${CI_ENVIRONMENT_NAME}"
  - export TF_HTTP_LOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
  - export TF_HTTP_UNLOCK_ADDRESS="${TF_HTTP_ADDRESS}/lock"
  - export TF_HTTP_USERNAME="gitlab-ci-token"
  - export TF_HTTP_PASSWORD="${CI_JOB_TOKEN}"
  - tofu init

validate:
  stage: validate
  environment:
    name: production
  before_script:
    - *terraform-init
  script:
    - tofu validate
    - tofu fmt -check -recursive
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

plan:
  stage: plan
  environment:
    name: production
  before_script:
    - *terraform-init
  script:
    - tofu plan -out=plan.cache
    - tofu show -no-color plan.cache > plan.txt
  artifacts:
    paths:
      - plan.cache
      - plan.txt
    reports:
      terraform: plan.json
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  stage: apply
  environment:
    name: production
  before_script:
    - *terraform-init
  script:
    - tofu apply plan.cache
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  allow_failure: false

destroy:
  stage: destroy
  environment:
    name: production
  before_script:
    - *terraform-init
  script:
    - tofu destroy -auto-approve
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  allow_failure: false

각 스테이지가 하는 일은 다음과 같다.

  • validate: tofu validate로 문법 오류를 검사하고, tofu fmt -check로 포맷팅이 맞는지 확인한다. MR이 올라오거나 기본 브랜치에 푸시될 때 실행된다.

  • plan: tofu plan을 실행해서 어떤 리소스가 생성/변경/삭제되는지 미리 확인한다. plan 결과를 plan.cache 파일로 저장하고 이걸 apply 단계에서 그대로 사용한다. plan 시점과 apply 시점 사이에 인프라 상태가 바뀌더라도 plan에서 확인한 그 변경사항만 적용되도록 보장하는 것이다.

  • apply: plan 단계에서 만든 plan.cache를 가지고 실제 인프라를 배포한다. when: manual로 설정해서 사람이 직접 버튼을 눌러야 실행된다. 실수로 자동 배포되는 것을 방지하기 위함이다.

  • destroy: 클러스터를 완전히 제거할 때 사용한다. 이것도 manual 트리거다. 학습이나 테스트 환경에서는 비용 절감을 위해 사용이 끝나면 destroy를 실행하는 것이 좋다.

운영 시 고려사항

state lock 충돌

두 사람이 동시에 apply를 실행하면 state lock 충돌이 발생한다. GitLab HTTP Backend는 자체적으로 locking을 지원하므로, 한쪽이 실행 중이면 다른 쪽은 lock 해제까지 대기하거나 실패한다. 이건 정상 동작이고, 동시 변경으로 인한 인프라 꼬임을 방지해준다.

plan과 apply 사이의 시차

plan 결과를 리뷰하는 동안 누군가 콘솔에서 직접 리소스를 수정하면 apply 시점에 drift가 발생할 수 있다. 이를 방지하려면 인프라 변경은 반드시 코드를 통해서만 하도록 팀 내 규칙을 정하는 것이 중요하다.

destroy 순서 문제

클러스터를 destroy할 때도 순서가 중요하다. Ingress(ALB)가 먼저 삭제되지 않으면 VPC 삭제 시 ENI가 남아서 에러가 발생한다. depends_on 설정이 올바르게 되어 있으면 Terraform이 역순으로 삭제해주지만, 수동으로 일부만 삭제한 경우에는 꼬일 수 있다.

파이프라인이 코드 리뷰를 강제하는 구조이기 때문에, limits 누락 같은 실수를 머지 전에 발견할 수 있다는 것이 GitLab CI/CD의 장점 중 하나다.

마무리

GitLab CI/CD와 Terraform을 조합하면 EKS 클러스터의 전체 라이프사이클을 코드로 관리할 수 있다. 클러스터 생성부터 모니터링 스택 배포, Ingress 설정까지 하나의 파이프라인으로 처리되고, 모든 변경사항이 Git 이력에 남는다.

이 방식의 가장 큰 장점은 재현성이다. 같은 코드를 다른 리전이나 계정에 적용하면 동일한 환경이 만들어진다. 09번 문서에서 겪었던 장애 이후 환경을 재구성할 때도, 코드가 있었기 때문에 처음부터 다시 만들 필요 없이 apply 한번으로 복구할 수 있었다.

GitLab CI/CD가 모든 상황에서 최선은 아니다. 하지만 Self-Hosted가 필요하고, 코드 저장소부터 CI/CD, state 관리까지 단일 플랫폼에서 해결하고 싶으며, 팀 규모가 크지 않아서 도구 관리에 인력을 많이 투입하기 어려운 환경이라면 충분히 합리적인 선택이다.