안녕하세요, SCG 31기 이진우입니다.
SCG는 성균관대학교 소프트웨어융합대학 소속 학생개발단체로, 2001년부터 25년째 교내 10여개의 서비스들을 개발 및 운영해오고 있는데요.
이번 포스팅에서는 정보통신대학 학부종합관리시스템을 기존 PM2기반 수동배포 방식에서 탈피해 쿠버네티스 클러스터에 배포하고, 개발자가 main 및 qa 브랜치에 변경이력을 push하기만 해도 argoCD가 이를 감지하여 자동으로 재배포해주는 gitOps 방식을 적용하기까지의 대장정을 기록해보려 합니다.
쿠버네티스 자체에 대한 이야기는 회장님께서 한 번 다뤄주신 적이 있습니다. SCG가 쿠버네티스를 어떻게 운영하고 있는지에 대한 정보는 해당 포스팅을 참고해주시면 감사하겠습니다. https://scg-skku.tistory.com/9
본격적인 글에 앞서, 이번 작업에 많은 도움을 주신 재원님, 현준님, 현우님, 민성님, 찬형님께 진심으로 감사의 인사를 전합니다.
SCG는 긴 선발과정을 거치는 단체인 만큼, 조직 내에 실력 좋은 분들이 많습니다. 팀원들의 도움 덕에 프로젝트를 완수할 수 있었고, 좋은 팀원과 함께할 수 있는가는 조직 선택에 있어 큰 부분을 차지한다는 이치도 배워가는 것 같습니다. 다시 한 번 팀원들에게 심심한 감사의 인사를 전합니다.
서론이 길었습니다. 이제 본론으로 들어가보겠습니다.
잘 돌아가던 서비스를 왜 굳이 쿠버네티스 클러스터로 옮기려 하는가?
정보통신대학 학부종합관리시스템은 성균관대학교 전자전기공학부, 반도체시스템공학과 학우들이 지도교수를 배정받고, 졸업논문을 제출하고 심사받는 시스템입니다. 2015년 SCG 선배님들께서 처음 개발해주신 이래로 지금까지 약 4,000여명의 선배 졸업생분들께서 이 서비스를 통해 졸업논문을 제출하고 사회로 나가셨습니다.
11년째 잘 돌아가던 서비스고, 매년 400여명 되는 실사용자가 꾸준히 발생하는 만큼, 굳이 왜 쿠버네티스 클러스터에 서비스를 올리려 하는가에 대한 충분한 고민이 선행되어야 했습니다.
SCG가 내린 해답은 편리함이었습니다. 조금 더 엄밀하게는, 개발자의 편리함이었습니다.
그렇다면, 기존에는 무엇이 불편했고, 쿠버네티스 환경으로 서비스를 이관하면 어떤 점이 편리해지는지 간단하게 이야기해보겠습니다.
행정실의 요청으로 새로운 기능이 추가되거나, UI가 변경되거나 오류가 수정되는 등의 변경이력이 발생하면, 개발자는 서비스를 재배포해야합니다.
k8s도입 전후의 재배포 과정을 표로 비교해보겠습니다.
| BEFORE (PM2기반 수동배포) | AFTER (gitOps) |
| 1. 변경사항 PR, main브랜치에 merge | |
| 2. 서비스가 배포된 서버에 ssh접속, 기존 pm2 정지 | 2. argoCD가 변경사항을 감지해 자동으로 pod 재배포 |
| 3. 서비스가 배포된 서버에서 리모트의 최신 코드 pull | |
| 4. pm2 restart | |
기존 서비스는 Node.js 런타임 위에서 express.js로 개발되어있었고, pm2를 통해 프로세스를 백그라운드에서 실행하는 구조였습니다.
위의 표만 보더라도, 코드베이스에 작은 변경사항 하나만 발생하더라도, 서버에 직접 접속해 잠시 실행중이던 프로세스를 멈추고, 깃헙의 최신 소스코드를 받아와 프로세스를 재시작해야하는 그 여정이 제법 피곤해보입니다.
개발자 입장에서 기존 PM2기반의 수동 배포방식은 번거로울 뿐 아니라, 큰 부담감을 안겨주기도 했습니다. 서버를 재시작하기 전까진 다운타임이 발생할 뿐더러, 서비스를 재시작했는데 예상치못한 오류를 마주한다면, 수정하기 전까지 사용자들은 서비스를 사용하지 못합니다. 물론 수동 배포 방식에서도 nginx와 같은 리버스 프록시를 배치해 블루그린, 롤링배포 등의 방식으로 무중단 배포를 구현할 수 있지만, k8s에서는 `replicas:n` 선언 한 줄로 서비스를 n개 복제할 수 있습니다. 추후 언급하겠지만, k8s의 Deployment yaml파일에 원하는 replicas수를 적어주기만 하면, 클러스터에 해당 서비스의 pod가 항상 n개로 유지됩니다. 수동배포 환경에 비해 훨씬 쉽게 무중단 배포로의 확장이 가능합니다.
k8s와 argoCD의 조합으로 구현된 gitOps환경에서는, 개발자가 변경사항을 push하기만 하면 모든 재배포가 끝납니다. 이후의 모든 과정은 자동화됩니다. 이제 더이상 SCG의 구성원들은, 해당 서비스의 소스코드를 변경한 뒤 서버에 접속해 기존 프로세스를 내릴 필요가 전혀 없어진 것입니다. 이 외에도 오토스케일링 등 k8s가 가져다주는 장점은 훨씬 많습니다. 이번 포스팅은 쿠버네티스 자체에 초점을 두고 있진 않으므로, k8s에 대한 자세한 이야기는 https://scg-skku.tistory.com/9 해당 포스팅을 참고해주시면 감사하겠습니다.
k8s 이관을 바로 할 순 없었습니다 - 11년된 레거시 맞네 맞아
바로 k8s config레포를 만들어 argoCD에 연결해주고 작업을 마무리하면 좋겠지만,
오래된 코드인만큼 선행되어야 할 밑작업들이 제법 많았습니다. 진행한 작업들을 표로 요약해보면 다음과 같습니다.
| BEFORE | AFTER | |
| 1. 환경변수(시크릿) | 모든 시크릿이 소스코드에 하드코딩 | 시크릿은 Hasicorp Vault를 통해 주입 |
| 2. 파일저장구조 변경 | 서버 파일시스템 | Minio 오브젝트 스토리지 |
| 3. 기존 미디어파일 백업 | rclone을 통해 서버의 파일을 minio 버킷으로 이관 | |
| 4. deprecated 파일 삭제 | 각 행정실 및 교내 시설의 ip주소가 json파일 형태로 하드코딩 / ssl인증서 및 dump파일의 존재 | ip주소는 DB에 적재, ssl은 k8s ingress에서 진행하므로 ssl폴더 및 dump파일 전체 삭제 |
| 5. DB things... | mysql, sequalize v2 | mysql2, sequalize v6 |
| 6. 개발환경 분리 | 프로덕션 DB만 존재 | 로컬, QA, 프로덕션 DB로 환경분리 |
크게 보면, 환경변수 분리, 파일시스템에서 오브젝트 스토리지로의 이관, 운영환경과 개발환경의 분리, deprecated된 라이브러리 및 패키지의 버전업데이트 정도로 볼 수 있겠습니다.
이중 5번 작업은 영욱님께서 90%이상 작업해두신 덕에 저는 바로 검증 및 자잘한 오류를 수정해 빠르게 진행이 가능했습니다.
이에 영욱님께 감사의 말씀을 전합니다.
이제, 각 작업들에 대해 자세히 알아보겠습니다.
왜 Vault를 써야하는가?
많은 외부 서비스들을 쓰게 되면, 신경써야 할 것들도 많아집니다.
.env 파일에 환경변수를 넣으면 될텐데 왜 굳이 vault라는 또 다른 서비스에 시크릿을 보관하는 것일까에 대한 충분한 고민과, 합리적인 선택의 이유가 있어야 합니다. SCG는 그 이유에 더욱 확실한 보안이라고 답했습니다.
.env파일 역시 결국 서버에 저장되는 하나의 파일인데, 문제는 암호화되지 않고 평문으로 올라간다는 것입니다. 학교 내부망과 서버가 다 뚫린다면, 더이상 시크릿이 아니게 되는 것입니다. 또, 해당 파일을 누가 조회했는지 로그가 남지도 않습니다.
Vault는 모든 시크릿을 암호화합니다. 악의적인 접근자는 평문을 확인할 수 없습니다.
이것이 SCG가 모든 시크릿을 Vault를 통해 주입받도록 결정한 이유입니다.
파일시스템에서 Minio로
해당 서비스에는 제안서, 중간논문, 최종논문, 서약서 등 학우분들의 수많은 파일들이 저장됩니다.
기존에는 이 파일들이 서버 파일시스템 자체에 저장됐습니다.
기존의 PM2 기반 배포방식에서는 문제가 되지 않았지만 k8s환경에서는 문제가 발생합니다.
k8s의 pod는 언제든지 삭제되고 새로운 pod로 교체될 수 있습니다.
또, pod가 3개 있고 로드밸런싱이 적용되었다면? 사용자 김성균의 서약서는 Pod A에 올라갔는데, 조회 요청시 로드밸런서가 김성균씨를 pod B로 보내버린다면, 해당 파일은 404 not found가 될 것이고, 김성균씨는 졸업을 할 수 없을 것입니다. 생각만 해도 아찔하군요
그래서, k8s환경에서는 pod의 가변적인 성질때문에 파일이나 사진과 같은 미디어들은 외부 스토리지에 저장해야 합니다.
SCG는 S3기반의 Minio라는 스토리지를 사용하고 있습니다.
Minio로의 이관을 결정하고, 다음과 같은 순서로 작업을 진행하였습니다.
1. minio bucket생성
2. 기존 서버 파일을 bucket으로 복제
3. 소스코드에서 파일I/O와 관련된 모든 코드를 찾아 minio bucket에 업로드하도록 수정
2번 작업의 경우, rclone이라는 오픈소스 툴을 사용해 대량의 파일을 빠르게 스토리지로 이관할 수 있었습니다.
해당 과정에 대한 이야기는 현준님의 포스팅 https://blog.scg.skku.ac.kr/3 에서 자세히 알아보실 수 있습니다.
3번 작업의 경우 교수 공지사항 페이지, 학생 공지사항 페이지, 서약서 제출, 논문 제출 등 수정해야할 코드가 상당히 많았는데요,
수정해야 할 파일의 범위를 파악하는데 LLM을 많이 활용했습니다. 레거시를 이번 프로젝트를 통해 처음 접해봤는데, 생각보다 작은 수정 하나에도 수십개의 파일을 수정해야 하는 등 수정소요가 많이 발생하는 것을 보고 LLM의 도움이 없었다면 정말 고된 작업이었겠다는 생각이 들었습니다.
본 게임으로 들어가보죠 - gitOps
아래는 형준님께서 작성해주신 다이어그램인데, gitOps의 전체적인 플로우가 잘 드러나 첨부해봅니다.

개발자가 prod/dev 등 각 환경에 맞는 브랜치에 push하면, github actions에 정의된 CI스크립트가 동작합니다.
CI단계에서의 핵심 동작은 새로운 도커 이미지를 레지스트리에 push하고, config레포의 image태그를 업데이트 한다는 것입니다.
여기서 k8s와 argoCD의 환상적인 콜라보가 빛을 발하기 시작합니다.
argoCD에는 k8s config레포를 연결해두었기 때문에, argoCD는 계속해서 config레포를 감시합니다.
앞서 CI단계에서 github actions가 도커 이미지를 새로 빌드하고, config레포의 이미지 태그를 변경했습니다.
argoCD는 config레포와 현재 쿠버네티스 클러스터의 상태를 비교하고, 다른 지점이 생기면 자동으로 변경사항을 감지해 클러스터에 반영해줍니다.
즉, 개발자는 소스코드를 push하기만 하면, 이후의 재배포 과정은 더이상 개발자의 몫이 아니게 되는 것입니다.
이제 gitOps가 편리하다는 것을 어느정도 납득했으니, 위의 다이어그램을 정보통신대학 학부종합관리시스템에 적용시키기 위해 어떤 작업을 해야하는지 생각해보겠습니다. 번거로운 밑작업도 끝났겠다, 이제 정말 main dish를 즐길 시간입니다.
전체적인 작업은 아래와 같은 순서로 진행되었습니다.
1. Dockerfile작성
2. SCG Docker hub에 레포지토리 생성
3. github actions작성
4. k8s config 레포 작성
5. argoCD에 config레포 연결
이제, 각 작업에 대해 조금 더 자세히 살펴보겠습니다.
Dockerfile, Docker hub - pm2와의 공식적인 이별의 순간
FROM node:22-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY . .
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "./bin/www"]
흔하디 흔한 Dockerfile입니다만, pm2를 더이상 사용하지 않고 컨테이너화 하기 위한 첫 단추라는 점에서 의의가 있겠습니다.
위의 Dockerfile그 어디에도 pm2는 보이지 않습니다. 앞으로는 이 이미지로 서비스를 올리게 됩니다.
SCG의 도커허브에 레포지토리까지 만들어주면 1-2단계는 마무리됩니다.
github actions 작성
전체적인 workflow의 구조는 다음과 같습니다.
.github/
└── workflows/
├── cd.yaml
├── ci-prod.yaml
├── ci-qa.yaml
└── ci.yaml
QA환경과 프로덕션 환경 각각에 적용되는 ci-qa와 ci-prod는 base인 ci.yaml을 오버라이딩합니다.
base가 되는 ci.yaml의 전체적인 구조는 아래와 같습니다.
(CICD파이프라인은 31기 신입프로젝트의 파이프라인을 참고하여 작성하였습니다. )
name: CI
on:
workflow_call:
inputs:
image-name:
required: true
type: string
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5.5.1
with:
images: ${{ inputs.image-name }}
tags: |
type=raw,value=${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
type=raw,value=latest
flavor: |
latest=true
- name: Build and push Docker image
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64, linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
위 코드에서, 핵심이 되는 부분만 살펴보겠습니다.
먼저, gitOps에서는 CI가 새로운 이미지를 도커허브에 푸시해야 합니다.
이를 위해, 시크릿에 Dockerhub 인증정보가 주입되어야 합니다.
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
위와 같이 github actions가 해당 프로젝트의 도커 레지스트리에 대한 접근 권한을 획득하고,
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5.5.1
with:
images: ${{ inputs.image-name }}
tags: |
type=raw,value=${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
type=raw,value=latest
flavor: |
latest=true
위와 같이 이미지의 태그를 github의 sha키를 이용해 구분하게 됩니다.
마지막으로 아래와같이 이미지를 빌드하고 푸시해줍니다. 이때, 클러스터가 배포된 물리서버의 OS를 먼저 확인한 후, platforms값을 적어주어야 합니다. (처음에 별 생각 없이 amd64만 적어주어 불필요한 작업을 두 번 했습니다)
- name: Build and push Docker image
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64, linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
CI가 끝나면 CD 코드를 작성해주어야 합니다.
CD.yaml파일의 전체적인 구조는 다음과 같습니다.
name: CD
on:
workflow_call:
inputs:
environment:
required: true
type: string
image-name:
required: true
type: string
secrets:
CONFIG_REPO_TOKEN:
required: true
jobs:
update-config:
runs-on: ubuntu-latest
steps:
- name: Checkout config repository
uses: actions/checkout@v4
with:
repository: SystemConsultantGroup/iccsys-config
token: ${{ secrets.CONFIG_REPO_TOKEN }}
ref: main
path: config
- name: Set up Kustomize
uses: imranismail/setup-kustomize@v2.0.0
- name: Update image with Kustomize
env:
ENVIRONMENT: ${{ inputs.environment }}
IMAGE: ${{ inputs.image-name }}
TAG: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
run: |
case "$ENVIRONMENT:$IMAGE" in
prod:scgskku/iccsys-prod|qa:scgskku/iccsys-qa) ;;
*) echo "Unsupported deployment target"; exit 1 ;;
esac
cd "config/overlays/${ENVIRONMENT}"
kustomize edit set image "scgskku/iccsys=${IMAGE}:${TAG}"
cat kustomization.yaml
- name: Commit config change
env:
ENVIRONMENT: ${{ inputs.environment }}
IMAGE: ${{ inputs.image-name }}
TAG: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
working-directory: config
run: |
git config user.name "github-actions[bot]"
git config user.email "비밀+github-actions[bot]@users.noreply.github.com"
git add "overlays/${ENVIRONMENT}/kustomization.yaml"
git diff --cached --quiet && exit 0
git commit -m "chore(${ENVIRONMENT}): update ${IMAGE} to ${TAG}"
git push origin main
큰 흐름은 아래와 같이 정리해볼 수 있습니다.
config 레포 접근 > kustomize셋업 > kustomize로 이미지 업데이트 > config레포에 새로운 tag 커밋
여기서의 핵심은, 새로 생성된 이미지의 태그로 config 레포가 변경된다는 것입니다.
따라서, github actions가 해당 config레포에 write할 수 있는 PAT토큰 역시 시크릿으로 주입되고 있는 것을 확인할 수 있습니다.
k8s config 레포 작성
SCG는 현재 모든 ops 레포를 통합 관리하는 차기 config 레포를 준비중에 있지만,
아직까지는 프로젝트 단위로 config레포를 따로 두어 k8s셋업을 하고 있습니다.
k8s config레포의 전체적인 구조는 다음과 같습니다.
iccsys-config/
├── base/
│ ├── deployment.yaml
│ ├── kustomization.yaml
│ └── service.yaml
│
└── overlays/
├── prod/
│ ├── ingress.yaml
│ ├── kustomization.yaml
│ ├── namespace.yaml
│ └── secret.yaml
│
└── qa/
├── ingress.yaml
├── kustomization.yaml
├── namespace.yaml
└── secret.yaml
base에는 모든 환경에 공통적용되는 사항들을 작성하고, overlays는 prod(프로덕션 환경), qa(QA환경)에 맞게 매니페스트를 커스터마이징합니다. 위와 같이 운영환경별로 디렉터리를 구분하여 작성하면, 하나의 레포로 argoCD에 config레포를 환경별로 구분하여 연결할 수 있다는 장점이 있습니다.

위와 같이 argoCD애플리케이션 생성 화면에서 config레포를 연결해주고, path 항목에 qa를 넣어주면 QA환경으로, prod를 넣어주면 프로덕션 환경에 맞는 argoCD 대시보드가 생성됩니다.
본론으로 돌아와 k8s매니페스트의 가장 기본이 되는 Deployment는 아래와 같이 작성하였는데요,
apiVersion: apps/v1
kind: Deployment
metadata:
name: iccsys
spec:
selector:
matchLabels:
app.kubernetes.io/name: iccsys
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/name: iccsys
spec:
containers:
- image: scgskku/iccsys
imagePullPolicy: Always
name: iccsys
ports:
- containerPort: 비밀
livenessProbe:
httpGet:
path: /iccsys/health/liveness
port: 비밀
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /iccsys/health/readiness
port: 비밀
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
envFrom:
- secretRef:
name: iccsys-secret
현재로서는 replicas를 1로 지정해두었지만, 추후 2이상의 값으로 증가시킬 경우에 대비해야 하는 숙제가 남아있습니다.
현재 해당 서비스는 세션기반 인증을 하고 있는데, 아시다시피 세션은 stateful하기에 pod가 여러개로 늘어나는 환경을 고려하면 세션을 redis와 같은 외부 캐시에 두거나, 아예 stateless한 JWT기반의 인증방식으로 변경할 필요가 있습니다. 각 방식의 trade-off를 팀원들과 충분히 논의해본 후 변경할 예정입니다.
헬스체크 라우트도 만들어 연결해주었습니다. 서버 자체가 살아있는지를 확인하는 liveness probe, DB연동 등 모든 작업이 완료되어 앱이 실제로 사용자의 요청을 받을 준비가 되었는지를 확인하는 readiness probe를 작성해줍니다.
이 외의 ingress, secret등 k8s manifest의 구성요소에 대한 자세한 설명은 https://blog.scg.skku.ac.kr/9 포스팅을 참고해주시면 감사하겠습니다.
ArgoCD연동
이제 마지막 관문입니다. config레포를 argoCD에 연결해주면 아래와 같은 대시보드를 볼 수 있습니다.

또한 SCG는 slack에 argoCD를 연동하여 해당 서비스에 문제가 생기면 slack을 통해 팀원 전체가 이슈를 인지하고 공유할 수 있는 환경을 구축해두었습니다. pm2기반의 수동배포 서비스가 클러스터에 합류해 argocd bot으로부터 sync되었다는 슬랙 알림을 받았을 때의 짜릿함이 아직도 생생하네요.

아직 끝이 아닙니다 - DNS와 TLS 설정
SCG의 k8s클러스터에 서비스가 잘 올라갔고, argoCD 연동도 잘 되었습니다.
이제, 서비스에 도메인을 적용해주어야 합니다. https통신을 위한 TLS인증서 처리도 해주어야 합니다.
프로덕션에 맞는 도메인은 성균관대학교 TLD를 사용해야 하므로, 정보통신처에 DNS신청을 보내두고,
승인이 날 때까지 테스트 목적으로 임시 도메인을 발급해 적용해봅니다.
아래와 같은 주소로 사용자가 입력했을때, k8s 클러스터에 배포된 서비스로 매핑되려면, DNS 서버에 A레코드를 추가해주어야 합니다.
iccsys-test.scg.skku.ac.kr
SCG는 자체 DNS서버를 보유하고 있으며, scg.skku형태의 TLD를 관리합니다.
인하우스 DNS서버에 접속하고, zone파일을 찾아 Ingress주소와 임시도메인을 매핑하는 A레코드를 추가해줍니다.
sudo rndc status 명령어를 입력하면 다음과 같은 결과가 출력됩니다.

위의 출력결과에서, configuration file이 bind9이 설정파일로 사용중인 파일의 경로입니다.
SCG는 DNS로 bind9을 사용하고 있습니다. bind9을 기준으로, 도메인 추가를 위한 작업 순서는 다음과 같습니다.
1. rndc freeze
2. zone파일에 A레코드 추가
3. rndc thaw
이제 각 단계를 하나씩 살펴보겠습니다.
먼저, rndc freeze를 수행해줌으로써, 변경사항의 충돌 걱정 없이 zone파일을 수정할 수 있게 됩니다.
이 말의 의미를 조금 더 자세히 살펴보겠습니다.
SCG의 zone 설정 파일에는 다음과 같은 내용이 명시되어있습니다.
allow-update { key "rndc-key"; };
rndc-key를 가진 클라이언트는 DNS zone파일을 수정할 수 있게 하라는 명령어입니다.
민성님께서 개발하신 Bindizer가 그 예시인데요. Bindizer에 대한 자세한 이야기는 https://blog.scg.skku.ac.kr/7 포스팅에서 살펴보실 수 있습니다.
아무튼, 브라우저 단에서 zone파일을 수정할 수 있는 프로그램을 만들어주셨는데, 현재는 해당 서비스가 잠시 점검중에 있어 수동으로 DNS레코드를 추가해야 하는 상황입니다. 문제는, zone파일에 접근 가능한 클라이언트가 존재하면, 클라이언트(Bindizer와 같은 프로그램)와 서버에 접속한 접속자(현재의 상황에선 접니다) 둘이 동시에 zone파일을 수정하려 할 수 있고, 이때 변경 이력간 충돌이 발생할 수 있습니다. 이를 막고자 rndc freeze를 수행하면, rndc thaw를 수행하기 전까진 bindizer와 같은 외부 클라이언트는 zone파일을 수정하지 못합니다. (이제 이 DNS는 제겁니다)
sudo rndc freeze <zone이름>
sudo vim <zone파일 경로>
따라서, 위와 같은 순서로 zone파일에 진입합니다.
vim, nano등 취향에 맞는 에디터를 선택해 A레코드를 추가해주시면 됩니다. 원하는 host주소를 적고, k8s Ingress의 IP주소를 적어줍니다. (버전관리를 위해 SOA 값 역시 까먹지 않고 갱신해줍니다.)

위와 같이 에디터로 zone파일에 원하는 origin, ttl, sub domain, record type, ip 주소를 적어주면 됩니다.
이후, 아래의 명령어로 변경된 zone파일에 문법 오류가 없는지 확인해줍니다.
sudo named-checkzone <zone이름> <zone파일 절대경로>
수행 결과 OK라는 문구가 나오면, zone파일 갱신에 성공한 것입니다.
이후, 아래의 명령어를 수행해 다시 rndc-key를 가진 클라이언트들이 zone파일에 접근할 수 있도록 해줍니다
sudo rndc thaw <zone이름>
위 명령어가 아래와같이 정상 수행된 것을 확인할 수 있습니다.

DNS설정이 끝나면, k8s config레포의 `overlays/qa/ingress.yaml` 에도 아래와 같이 host를 맞춰주어야 합니다.
Ingress에는 외부의 http요청을 어떻게 처리할 것인지만 선언적으로 작성하고, 실제 처리는 nginx가 처리하도록 작성합니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: iccsys
annotations:
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-clusterissuer"
nginx.ingress.kubernetes.io/proxy-body-size: "2g"
spec:
tls:
- hosts:
- qa.iccsys.scg.skku.ac.kr
secretName: gitops-qa-tls
rules:
- host: qa.icssys.scg.skku.ac.kr
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: iccsys
port:
number: 비밀
테스트 목적은 자체 서비스에서 진행하고, 프로덕션은 skku.ac.kr 형태의 TLD를 사용해야 하는데, 해당 authroization DNS에는 SCG측에서 접근권한이 없어 정보통신처에 A레코드 등록신청을 해야합니다. 지정된 문서를 작성하는 등의 일련의 절차를 거쳐 성균관대학교 도메인을 서비스에 적용합니다.
이제, 성균관대학교 학우분들이 iccsys.skku.ac.kr이라는 주소를 입력하면, SCG 클러스터 내의 iccsys ingress가(정확히는 ingress controller인 Nginx가) 해당 요청을 처리하게 되는 것입니다. (아직 정보통신처 승인이 나지 않아 실제로는 접속이 되지 않습니다. DNS승인이 나는대로 바로 접속이 가능해집니다.)
잘 되는지 직접 확인해봅시다 (feat. TLS인증서 오류 트러블슈팅)
굉장히 긴 여정이었습니다.
지금까지의 작업이 잘 마무리되었는지, 우리의 서비스는 k8s환경에서 잘 동작하는지, gitOps는 잘 적용되었는지 확인해보겠습니다.
아래와 같이 3가지 검증 프로세스를 거쳐보겠습니다.
1. nslookup (혹은 dig)
2. 임시 도메인으로 실제 서비스 접근
3. 변경사항을 qa브랜치에 push하면 bot이 자동으로 config레포에 commit을 남겨주는가? slack에 알림이 잘 오는가?
가장 먼저, nslookup <방금 생성한 임시도메인> 요청을 날려보니, 응답이 잘 오는 것을 확인해 DNS 등록이 잘 되었음을 확인합니다.
이후, 브라우저에 실제 도메인 주소를 입력해 접속이 잘 되는지 확인해봅니다.

안타깝게 위와 같은 경고창이 뜨며 접속이 되지 않고 있습니다. https에 빨간 취소선이 그려진 것으로 보아 인증서 문제로 추정되는데, argoCD의 대시보드를 살펴볼 필요가 있겠습니다.

argoCD 대시보드를 살펴보니, 실제로 ingress는 요청을 잘 처리했지만, TLS인증서 발급이 pending상태입니다.
인증서 발급이 제대로 되지 않고 있다는 것을 확인했습니다.
kubectl get order,challenge -n iccsys-qa
위의 명령어를 통해 현재 인증서의 발급상태가 PENDING(대기상태)임을 아래와 같이 확인하였습니다.

kubectl describe명령어로 자세한 로그를 분석해보니, host 가 존재하지 않아 인증서가 발급되지 못하고 있었습니다.
확인해보니, DNS에 추가해둔 임시 도메인과, k8s ingress의 host이름간 불일치가 있었습니다.
작은 실수를 수정해주고 다시 접속해보면, 아래와 같이 https://qa.iccsys.scg.skku.ac.kr로 정상 접속되는 것을 확인할 수 있습니다.

마지막입니다. 원본 소스코드 레포지토리의 qa브랜치에 새로운 push가 발생하면, config레포에 bot이 커밋을 남기고, 그걸 argoCD가 감지해 AutoSync를 수행하고, scg slack에 sync결과 알림을 전송해주는 전 과정이 잘 동작하는지 확인해야 합니다. 이는 애플리케이션이 gitOps의 철학을 완전히 구현하였는지를 최종 확인하는 단계입니다.

다음과 같이 변경의 규모는 작지만 큰 간절함이 담긴 수정사항을 레포지토리의 qa브랜치에 push해보겠습니다.

github actions가 정상 수행된 것을 확인하였습니다.
이후 config레포로 이동해보니, github-actions bot이 새로운 커밋을 생성해둔 것을 확인할 수 있습니다.


그림과 같이 새로운 tag값이 갱신된 것을 확인할 수 있습니다.


slack에도 정상적으로 알림이 오는 것을 확인했습니다.
이제 정말 k8s위에서 우아하게 돌아가는 저의 서비스가, gitOps의 철학까지 구현한 것을 입증한 순간입니다.
진짜 QA의 시간 - 꼭꼭 숨어라 버그 보일라
당연한 이야기지만, 서비스가 잘 올라갔다 하더라도, 파일 업로드가 버킷으로 잘 올라가지 않는 등의 자잘한 이슈들이 생길 수 있습니다.
개발자인 저도, 팀원들도 꾸준히 서비스의 배포 이후 직접 서비스의 모든 flow를 직접 돌아보며 문제사항들을 찾아내야 합니다.
이는 기술적인 성장을 추구하는 소프트웨어 엔지니어들에게 다소 지루한 시간으로 여겨지지만,
실사용자가 마주할 수 있는 문제사항을 사전에 발견하는 프로세스이므로, 결코 등한시 해서는 안됩니다.
실제로 저는 현재 test도메인에 로그인만 하였더니 로컬에선 문제없던 템플릿엔진이 오류를 뱉어내는 비극의 현장을 목격하였습니다.
이것이 프로덕션 환경과 개발환경을 분리하는 이유이기도 합니다. (저는 오늘부터 해당 문제를 해결해야만 합니다...)
또한, 사전에 발견하지 못한 오류가 프로덕션 환경에서 발견된다면, 저를 포함한 SCG팀원들은 장애에 대응해야 합니다.
쿠버네티스에서 돌아가는 서비스의 디버깅은 어떻게 할 것인가?
로컬에서 애플리케이션을 실행하면, 터미널에서 오류 메시지를 확인하고 디버깅 하면 됐습니다.
이제 서비스는 로컬에 없습니다. 쿠버네티스 클러스터에 있습니다. 이제부터 디버깅은 어떤 과정을 거쳐 이뤄져야 할까요?
쿠버네티스 환경에서는 아래의 명령어로 로컬 터미널에서 확인하던 출력문들을 확인할 수 있습니다.
kubectl get pods -n <namespace>
kubectl logs <pod이름> -n <namespace>
먼저 pod의 이름을 확인한 후, 해당 pod의 log를 조회하는 방식입니다.
로그를 조회해 템플릿엔진에서 system이라는 테이블에 접근할 때, 접근하려는 값이 존재하지 않아 템플릿엔진이 터진 것임을 확인했습니다. QA용 DB를 덤프뜰 때 데이터가 온전히 넘어오지 못했을 것이라는 의심이 들었습니다. 확인해보니, QA디비의 system 테이블이 텅 비어있었습니다.

mysqldump를 재수행해주어 테이블의 값을 채워주었습니다.
마무리하며
본 프로젝트는 아직 많은 개선점과 숙제를 남겨두고 있습니다.
가장 큰 것으로는, 오래된 라이브러리를 최신의 것으로 교체하는 작업들,
인증 등 모든 로직에서 서버를 stateless하게 만드는 작업 등이 있겠습니다.
SCG는 계속해서 학내 구성원들의 빠르고 편리한 서비스 이용 경험을 위해 SCG가 가진 자원과 문제상황에 맞는 기술을 발굴해 도입하고,
보안취약점을 발견해 학우분들의 안전한 서비스 이용이 가능해지도록 노력하고 있습니다.
SCG와 함께 성장하고 싶으신 분들의 많은 관심 기다리고 있겠습니다.
부족한 글 읽어주셔서 감사합니다.
좋은 하루 되세요.
-SCG 31기 Student Software Engineer 이진우-
'Infra' 카테고리의 다른 글
| DNS 관리 도구를 만들다가, 결국 DNS 서버를 만든 이야기 (2) (0) | 2026.07.29 |
|---|---|
| SCG 홈페이지 k8s에 날려보내기 (0) | 2026.07.24 |
| DNS 관리 도구를 만들다가, 결국 DNS 서버를 만든 이야기 (1) (0) | 2026.05.15 |
| Postfix에서 메일을 Slack으로 포워딩하는 방법 (0) | 2026.05.10 |
| 웍실 네트워크 개편 (1) | 2026.04.03 |