올해 초 Kubernetes 전환을 마친 뒤에도 여러 가지로 환경을 개선하기 위해 노력하고 있습니다.
이 과정에서 넘어야 할 큰 산이 있었는데, 바로 개발 환경이었습니다. 정말 사용 빈도가 높았지만 K8s로 운영 서버가 넘어간 시점에도 EC2 기반으로 운영되고 있어 괴리가 있었고 개선이 필요했습니다.
이를 위해 Feature 환경을 K8s + Argo CD PR Generator 기반으로 재구축하는 작업을 진행했습니다.
Feature 환경을 만들며 수많은 시행착오를 거쳤고, 이 구조를 기반으로 Staging 환경도 구축하면서, 동시에 적용하지 못하고 있었던 GitOps까지 적용하기 위한 초안을 제시하게 되었습니다.
또한 이 과정에서 불필요하게 중복되어 배포되는 환경을 통합하는 작업도 진행했습니다.
이 글에서는 개발 환경 구축 과정, 그리고 그 과정에서 마주쳤던 여러 문제들을 정리해 보려고 합니다.
왜 개발 환경을 K8s로 전환했는가
기존 개발 (Feature + Staging) 환경은 EC2 인스턴스 기반으로 운영되고 있었습니다. 개발자가 특정 브랜치를 테스트하고 싶을 때 워크플로우를 트리거하면 EC2 인스턴스를 띄우고, SSH로 접속하여 Docker Compose로 앱을 배포하는 방식이었습니다. 원래대로라면 운영 환경보다 개발 환경이 먼저 K8s로 전환되고 이후 운영 전환이 이루어지는 게 일반적이지만, 검증에 대한 시간이 많이 주어지지 않고 빠르게 운영 환경을 K8s로 전환해야 했기 때문에 이런 상황이 발생하게 되었습니다.
기존 개발 환경은 몇 가지 문제가 있었습니다:
- 자원 낭비가 심했습니다.
- 사용하지 않는 시간에도 모든 인스턴스와 관련 리소스가 계속 실행되고 있었습니다.
- 또한, 모든 Feature 환경과 EC2에 MongoDB, Elasticsearch 등 무거운 DB 리소스가 개별적으로 배포되고 있었습니다.
- GPU 관련 리소스도 예외는 없었고, 운영 환경의 리소스를 테스트 용도로 사용하는 경우도 있었습니다.
- 운영 환경과의 차이가 있었습니다.
- 위에서 설명한 것처럼 운영 환경이 먼저 K8s로 전환되었고, 개발 환경은 EC2 기반이기 때문에 발생하는 괴리가 있었습니다.
- Production과 다른 배포 방식으로 인해 “내 환경에서는 됐는데…”라는 문제가 발생하기도 했습니다.
- 관리와 디버깅이 어려웠습니다.
- 환경 생성/삭제는 워크플로우를 트리거하여 수동으로 이루어지고 있었습니다.
- 또한, 로그를 확인하거나 디버깅을 위해서 EC2에 직접 SSH 접속을 하여 리눅스 명령어를 사용하고 있었습니다.
이전에도 Staging 환경 개선에 대한 논의가 이루어졌지만, 매번 흐지부지되는 경우가 많았습니다.
문제가 계속 누적되면서 여러 가지 방면에서 개선이 필요했고, 결국 직접 K8s 전환을 주도하기로 하고 회사 내부에서 동의를 구했습니다.
공통 DB와 GPU 자원
가장 먼저 진행한 것은 공통 DB와 GPU 자원을 구축하는 것이었습니다. 아주 단순하게 중복을 줄이면, 그만큼 비용과 효율 문제를 해결할 수 있기 때문입니다.
MongoDB, Elasticsearch 등 무거운 DB와 GPU 자원은 리소스 소비도 크고, 개발 환경 특성상 사용량이 많지 않기 때문에 개별적으로 띄울 이유가 없었습니다.
따라서 개발 환경용으로 구축한 EKS에 단일 리소스만 배포하고, 이를 모든 Feature 환경에서 사용하도록 했습니다.
공통 사용을 위해 MongoDB의 경우 database, Elasticsearch의 경우 index prefix를 다르게 하는 등 구분이 필요했고, 백엔드에 관련 설정이 이미 존재해서 쉽게 해결할 수 있었습니다.
GPU 자원 역시 단일 리소스만 배포하고, 모든 개발 환경에서 사용 가능하도록 방화벽을 설정했습니다. 또한 GPU 자원은 비용이 많이 들고 항상 사용하지는 않기 때문에, Argo CD auto-sync를 비활성화하고 일과 시간이 끝난 뒤에 Deployment를 종료하는 CronJob을 설정해 두었습니다. 그리고 엔지니어들에게 Argo CD sync 권한을 주어 필요할 때 수동으로 간편하게 배포할 수 있도록 하고 전체 조직에 가이드를 전달하였습니다. 그 결과 불필요한 자원 사용 없이 일과 시간에 필요할 때만 사용할 수 있게 개선되었습니다.
Redis와 MinIO의 경우에는 상대적으로 가볍고, 개별적으로 필요한 경우도 있어서 따로 ApplicationSet을 구성하여 Feature 환경별로 배포하도록 했습니다.
Feature 환경 구축하기
Feature 환경의 가장 큰 특징은 엔지니어의 요구에 따라 여러 개의 환경을 배포할 수 있어야 하고, 그 개수가 동적으로 변할 수 있다는 것입니다. 또한 Feature 환경을 구분할 방법도 이슈 트래커 티켓 이름을 기반으로 생성된 브랜치뿐이었습니다.
이를 해결하기 위해서 Generator 패턴이 필요하다고 판단했고, 이 중에서도 Argo CD ApplicationSet의 PR Generator를 선택했습니다. PR Generator를 선택한 이유는 개발자들에게 PR을 생성하고 라벨을 추가하는 것이 익숙하기도 하고, PR을 열고 닫음으로써 PR의 생명주기에 따라 환경을 자동으로 생성하고 정리할 수 있기 때문이었습니다. 처음에는 SCM Provider Generator 를 사용해 구현해 보았지만 환경 생성 속도에 딜레이가 있었고, 그에 비해 PR Generator는 반영 시간도 빨랐습니다.
ApplicationSet의 동작
실제로는 백엔드, 프론트엔드, Redis, MinIO를 모두 비슷한 방식으로 배포했지만, 대표적인 1가지 경우만 설명하도록 하겠습니다.
- 개발자가
myapp저장소에서 PR을 생성하고deploy-feature-env라벨을 추가합니다. - Argo CD가 60초 주기로 GitHub PR을 확인합니다.
- 라벨이 있는 PR에 대해 자동으로 Application과 Namespace가 생성됩니다.
- PR이 머지되거나 라벨이 제거되면 환경이 자동으로 정리됩니다.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: myapp-features-applicationset
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- pullRequest:
github:
owner: mycompany
repo: myapp
labels:
- deploy-feature-env
tokenRef:
secretName: github-token
key: token
requeueAfterSeconds: 60
template:
metadata:
name: "myapp-{{.branch | lower}}"
spec:
project: dev
destination:
name: dev-eks
namespace: "myapp-{{.branch | lower}}"
sources:
- repoURL: https://github.com/mycompany/helm-charts
targetRevision: main
path: charts/myapp
helm:
releaseName: "myapp-{{.branch | lower}}"
valueFiles:
- "values/feature.yaml"
parameters:
- name: "global.ticketName"
value: "{{.branch}}"
- name: "web.image.tag"
value: "{{.branch}}"
# ... 기타 이미지 태그들
Chart 변경 테스트: 라벨 기반 분기
Helm Chart 자체를 수정하며 테스트해야 하는 경우도 있습니다. 이를 위해 chart-changes 라벨이 있으면 helm-charts 저장소의 동일한 브랜치를 참조하도록 했습니다.
sources:
- repoURL: https://github.com/mycompany/helm-charts
targetRevision: '{{- if has "chart-changes" .labels -}}
{{.branch}}
{{- else -}}
main
{{- end -}}'
PreSync Hook을 활용한 환경 초기화
백엔드 환경을 배포하기 위해서는 ConfigMap과 Secret이 필요합니다. 그리고 추가적으로 정상적인 백엔드 구동을 위해서는 DB 초기화와 Migration script 실행이 필수였습니다. 또한 Feature 환경은 모두 다를 수 있기 때문에 개별적으로 이 작업들이 이루어져야 했습니다.
이를 해결하기 위해 Argo CD의 PreSync Hook을 활용하여 배포 이전에 실행하고, 실행 순서도 중요하기 때문에 Sync Wave를 활용하여 순차적으로 처리했습니다. 대략적인 순서는 다음과 같습니다.
- PreSync Hook 실행을 위한 Secret 생성
- PreSync Hook은 별도의 Pod에서 실행되어야 하고, 특히 저희는 Git 저장소 내에 환경변수가 git-secret을 사용하여 암호화되어 있었기 때문에 복호화 등에 필요한 Secret을 별도로 생성해야 했습니다.
- 변수 복호화 + AWS Secret Manager 업로드 (Wave -4)
- ExternalSecret 생성 (Wave -3)
- Secret Manager의 값을 ESO (External Secrets Operator)를 통해 불러와 K8s Secret으로 동기화하였습니다.
- DB 초기화 (Wave -2)
- 초기 구동에 필요한 초기화 스크립트를 실행하였습니다.
- Migration 실행 (Wave -1)
- Feature 환경마다 필요한 변경이 다를 수 있기 때문에, Git 저장소 내에 Migration 스크립트를 저장할 수 있도록 하고 해당 스크립트를 실행하도록 하였습니다.
이 과정에서 스크립트가 매번 실행되는 것을 방지해야 했는데, 그것은 ConfigMap에 timestamp나 identifier를 추가하여 스크립트가 실행된 후에 ConfigMap을 업데이트하여 스크립트가 다시 실행되지 않도록 하는 방법을 사용하였습니다.
PostSync Job을 통한 Pod 업데이트
일반적인 경우라면 Argo CD는 GitOps 기반으로 동작하고, 이미지가 변경되면 다른 태그로 변경하여 Deployment를 업데이트하게 됩니다. 하지만 Feature 환경은 이미지 태그가 고정되어 있었고, Argo CD가 Deployment를 업데이트하지 않았습니다.
궁극적으로는 Feature 환경도 GitOps 패턴을 적용하고 태그 또는 Digest를 변경하여 Deployment를 업데이트해야 하지만, 이미 Feature 환경 전환만으로도 공수가 크기 때문에 동시에 진행할 수는 없었습니다. 그래서 CI 파이프라인과 태그 규칙은 유지하는 대신, imagePullPolicy를 Always로 변경하여 매번 이미지를 다시 끌어오도록 하고, PostSync Job을 통해 Deployment Rollout을 실행하여 이미지 업데이트를 적용하였습니다.
추가 기능 구현
기존에 Feature 환경을 사용하던 방식을 개선하면서도, 최대한 비슷한 경험을 제공하기 위해 추가적인 작업이 필요했습니다.
- 많은 개발자들이 MongoDB를 MongoDB Compass 등의 GUI로 직접 보고 관리하는 것을 원했습니다. 따라서 MongoDB의 서비스를 VPN 내부망에서 Ingress로 노출하여 접근할 수 있도록 하였습니다.
- 기존 환경에서는 개발자들이 디버깅을 위해서 EC2에 직접 SSH 접속을 하여 리눅스 명령어와 Django 내장 명령어 등을 사용하고 있었습니다. 하지만 현재 환경은 EC2가 아니었고, EKS 접근 권한을 부여하는 방법도 좋은 방법이 아니라고 생각했습니다. 개발자들에게 피드백을 받은 결과 원하는 요구사항은 라이브 코딩 수준의 디버깅이었고, 이것이 영구적으로 유지될 필요는 없다는 의견을 들었습니다.
고민 끝에 Feature 환경에서는 Code-server를 Sidecar로 실행하여 브라우저에서 VS Code에 접근하여 컨테이너 내부 파일에 접근하고 명령어를 실행할 수 있도록 하였습니다. 모든 환경과 실행 결과가 동일하게 반영되어야 했기 때문에 공유 볼륨을 사용하여 동기화를 수행했습니다.
Staging 환경 구현하기
Feature 환경 구현이 안정화된 후, 이를 기반으로 Staging 환경도 K8s로 전환하게 되었습니다.
Staging 환경을 간단하게 생각하면 Base branch가 master, main, dev 등으로 고정된 Feature 환경이라고 생각할 수 있습니다. 따라서 기존 구조를 바탕으로 약간의 변형만 수행하면 되었습니다.
또한 위에서 적용한 원칙들도 최대한 적용하고, 특히 Kafka의 경우 AWS MSK 대신 가벼운 인스턴스로 대체하여 비용 절감을 노렸습니다.
하지만 Staging 환경을 구현하면서 1가지 더 해결할 일이 있었는데, 바로 GitOps였습니다.
갑자기 GitOps…?
물론 Argo CD를 사용하고는 있었지만, 현재 배포 방식은 Full GitOps와는 거리가 멀었습니다.
Helm chart 템플릿이나 오픈소스는 Git에서 관리하지만, 서비스의 이미지 태그는 CI 파이프라인이 끝나면 Argo CD API를 통해 임시로 Patch하는 방식이었습니다. 즉, GitOps의 원칙인 코드를 통해 변경사항을 관리하는 것이 아닌 부분이 있었습니다.
당연히 이것은 권장되는 방식이 아니고, 결국에는 임시로 보유하고 있는 값이기 때문에 Argo CD에 변화가 생기거나 다른 이유로 그 값이 초기화되는 일이 발생한다면 의도치 않은 롤백이나 배포 변경이 발생할 수 있었습니다. 사전 조사를 하면서 이런 문제를 알고 있었고 의견과 해결 방법도 전달했지만, 조직 내에서 받아들여지지 않았습니다.
하지만 K8s의 비중이 늘어나고 실제로 관련된 문제가 발생하면서, 조직 내에서도 GitOps의 필요성에 대해 논의하게 되었고, Staging 환경을 진행하면서 GitOps 적용 방식을 확정하고 이를 Production 환경으로도 확장하는 것이 좋겠다고 판단하였습니다.
Staging 환경 설계: Git File Generator
Staging 환경은 PR 라이프사이클이 아닌 지속적으로 운영되는 환경이므로, PR Generator 대신 Git File Generator를 사용했습니다.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: myapp-staging-applicationset
spec:
goTemplate: true
goTemplateOptions: ["missingkey=default"]
generators:
- git:
repoURL: https://github.com/mycompany/helm-charts
revision: main
files:
- path: "argocd/deploy-info.yaml"
template:
# ...
핵심은 deploy-info.yaml 파일을 참조한다는 건데, 이것은 아래에서 추가로 설명하겠습니다.
태그 정보 관리하기
deploy-info.yaml 파일의 형식은 다음과 같습니다.
staging:
web:
repository: mycompany/myapp-web
tag: master
digest: sha256:f04d027dc1711c201592bbd76483a8b39605f8f56ff20229c7d1cb7b31124daf
foo-service:
repository: mycompany/foo-service
tag: "20251013175113"
# ... 기타 컴포넌트들
ApplicationSet에서는 Go Template을 활용하여 이 값들을 각 컴포넌트에 주입합니다:
parameters:
- name: "web.image.tag"
value: "{{ .staging.web.tag }}"
- name: "foo-service.image.tag"
value: '{{ (index .staging "foo-service").tag }}'
기존 환경은 이미 이전 블로그에서 설명했듯이, 환경별로 values.yaml 파일이 따로 관리되고 있었습니다.
하지만 이번에 deploy-info.yaml 파일을 활용하여 환경별로 이미지 태그를 하나의 파일에 관리하도록 변경하였습니다.
이렇게 한 이유는 DevOps 팀이 아닌 일반 개발자는 대부분 배포된 이미지 태그 정도만 필요로 하고, 특히 여러 클러스터와 환경이 존재하는 회사 상황을 생각했을 때 한 번에 배포 정보 확인이 필요하다는 요구가 있었기 때문이었습니다.
이렇게 deploy-info.yaml 파일을 두고, 태그와 Digest 정보를 받아 업데이트하는 워크플로우를 GitHub Actions로 구현하였습니다.
name: Update Image Tag in deploy-info.yaml
on:
workflow_dispatch:
inputs:
environment:
description: "환경 (dev, staging, production 등)"
type: choice
options: [dev, staging, production]
updates:
description: 'JSON 배열 (예: [{"image":"web","tag":"master","digest":"sha256:..."}])'
type: string
target_branch:
description: "타겟 브랜치 (기본: main)"
default: "main"
concurrency:
group: deploy-info-${{ inputs.environment }}
cancel-in-progress: false
jobs:
update-deploy-info:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.target_branch }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install yq and jq
run: # yq, jq 설치 ...
- name: Update deploy-info.yaml
run: |
./scripts/update-deploy-info.sh \
--environment "${{ inputs.environment }}" \
--updates '${{ inputs.updates }}'
- name: Check for changes
id: check_changes
run: # git diff로 변경사항 확인 ...
# dev/staging: 직접 커밋
- name: Commit and push (dev/staging)
if: steps.check_changes.outputs.changed == 'true' && inputs.environment != 'production'
run: |
IMAGES=$(echo '${{ inputs.updates }}' | jq -r '.[].image' | paste -sd ',' -)
git add argocd/deploy-info.yaml
git commit -m "Update ${{ inputs.environment }} images: $IMAGES"
git push
# production: PR 생성
- name: Create Pull Request (production)
if: steps.check_changes.outputs.changed == 'true' && inputs.environment == 'production'
run: |
IMAGES=$(echo '${{ inputs.updates }}' | jq -r '.[].image' | paste -sd ',' -)
# 브랜치 생성 후 PR 생성 ...
gh pr create --title "[Production] Update images: $IMAGES" \
--body "## Image Update Request
**Environment**: \`${{ inputs.environment }}\`
**Images**: \`$IMAGES\`
..."
# 이하 PR body 생성, 결과 확인은 생략
GitHub Action의 동작 확인은 로컬 환경에서 act를 사용하여 수행하였습니다. 실무에서 act를 제대로 사용한 것은 처음이었는데, 확실히 이러한 상황에서 불필요한 공수를 줄이고 효율적인 작업을 할 수 있었습니다.
어려웠던 점들
레거시 CI 파이프라인과 설정
현재 서비스 구조와 CI 파이프라인이 일반적인 구조와 많이 다르고, 이를 K8s에 맞게 변경하는 과정에서 어려움이 많았습니다.
설정이 코드 내에 하드코딩이 되어 있어 빌드 파이프라인을 새로 구성하거나 설정을 떼어내야 하는 것도 있었고, 기존 Feature 환경에 대한 가이드라인도 마련되어 있지 않아 직접 알아보면서 진행해야 하는 일이 많았습니다.
폴더와 파일 구조
기존 Helm chart repository는 Production 환경을 먼저 이전하기도 했고, 조직적으로 K8s 활용을 시작한 지 오래 되지 않아 최대한 단순하게 구성하였습니다.
하지만 이번에 개발 환경을 추가하면서 환경별로 서로 영향을 받지 않으면서도, 어떻게 개발자들이 쉽게 설정을 파악하고 유지보수할 수 있는지에 대한 고민이 있었고 지금도 고민 중입니다.
혼선이 오지 않도록 최소한의 배치를 해 두긴 했지만, 구현 쪽의 우선순위가 더 높았기 때문에 해당 작업이 마무리되면 다시 정리해 나가야 할 것 같습니다.
단독 진행의 어려움
대부분의 작업을 단독으로 진행하면서, 레거시 빌드 과정과 소스코드를 직접 분석하며 진행해야 했습니다. 이 과정에서 전체 시스템에 대한 이해도를 높일 수 있었고, 문서화되지 않았던 부분들을 정리하는 계기가 되기도 했습니다.
마치며
다행히도 8월 말부터 시작한 계획은 여러 시행착오를 거쳐 안정화되었고, Feature 환경은 11월부터 K8s로 운영되고 있습니다. 기존 EC2 기반 환경은 배포를 막았고, 기존에 이미 배포된 환경이 정리되면 파이프라인까지 완전히 정리할 계획입니다.
Staging 환경도 역시 1차적인 검증이 모두 끝났고 최종 파이프라인 연결과 Production 환경의 GitOps 적용을 준비하고 있습니다.
IT 회사에서 빠른 개발 사이클이 이루어지기 위해서는 개발 환경과 같은 DX가 매우 중요하지만, 반대로 많은 회사에서 크게 중요하게 생각하지 않는 것도 개발 환경과 테스트이기도 합니다. 그래도 이러한 환경을 개선해 나가는 것이 DevOps Engineer의 역할이고, 앞으로도 계속해서 노력해 보려 합니다.