Nuxt 3 기반의 여수 빅데이터 포털 프로젝트에 대해 CI/CD 환경을 구성했습니다.
CI는 Jenkins, CD는 Docker + Kubernetes를 활용하여 자동화하였으며, 배포 흐름은 아래와 같습니다
개발자가 dev 브랜치에 push → Jenkins가 도커 이미지 빌드 및 푸시 → Kubernetes에 배포
Nuxt 3에서는 다음과 같이 빌드 및 실행할 수 있습니다.
pnpm build
실행 파일은 .output 디렉토리에 생성되며, 아래 명령어로 실행해볼 수 있습니다.
node .output/server/index.mjs
Dockerfile은 아래와 같이 multi-stage 방식으로 구성했습니다.
# ================================================
# Base 스테이지: 공통 베이스 이미지 정의
# ================================================
FROM node:22 AS base
# pnpm 설치
RUN npm install -g pnpm
# ================================================
# Deps 스테이지: 전체 의존성 설치
# ================================================
FROM base AS deps
WORKDIR /app
# package.json과 pnpm-lock.yaml 복사 (캐시 활용)
COPY package.json pnpm-lock.yaml ./
# 의존성 설치, 명시된 버전으로만 설치 허용
RUN pnpm install --frozen-lockfile
# ================================================
# Builder 스테이지: 전체 소스코드 복사 후 빌드 수행
# ================================================
FROM base AS builder
WORKDIR /app
# deps 스테이지에서 설치한 모듈 복사 (캐시 활용)
COPY --from=deps /app/node_modules ./node_modules
# 전체 소스 복사
COPY . .
# 빌드 수행 (.output 폴더에 빌드 결과물 생성)
RUN pnpm build
# ================================================
# Runner 스테이지: 빌드 결과물을 이용해 프로덕션 이미지 생성
# ================================================
FROM base AS runner
WORKDIR /app
# 프로덕션 환경 변수 설정
ENV NODE_ENV=production
ENV NUXT_APP_BASE_URL=/management
# builder 스테이지에서 생성된 빌드 결과물 복사
COPY --from=builder /app/.output ./
# Nuxt 3 SSR 서버 기본 포트 노출
EXPOSE 3000
# 비루트 사용자로 실행
USER 1001
# Nitro 기반 Nuxt 3 SSR 서버 실행
CMD [ "node", "server/index.mjs" ]
Nuxt는 SSR 기반이기 때문에 ClusterIP 타입으로 클러스터 내부에서 접근할 수 있도록 구성했습니다.
---
# Management ClusterIP Service (내부 클러스터용)
apiVersion: v1
kind: Service
metadata:
name: name
namespace: namespace
spec:
type: ClusterIP
selector:
app: name
ports:
- port: 3000
targetPort: 3000
---
# Management Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: name
namespace: namespace
spec:
replicas: 1
selector:
matchLabels:
app: name
template:
metadata:
labels:
app: name
spec:
containers:
- name: portal
image: abc.def.ghi/abc/name:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: false
runAsNonRoot: true
env:
- name: NUXT_APP_BASE_URL
value: /management
Jenkins에서는 GitHub에서 dev 브랜치를 기준으로 코드를 받아 빌드 후, 이미지 푸시 및 K8s 배포까지 자동화하였습니다.
import java.time.LocalDate
import java.time.format.DateTimeFormatter
pipeline {
agent any
environment {
WORK_DIR = 'frontend/management'
KUBE_CONFIG = credentials('KUBECONFIG')
IMAGE_REPO = 'abc.def.ghi/abc'
IMAGE_NAME = 'name'
DEPLOYMENT_NAME = 'name'
NAMESPACE = 'namespace'
}
stages {
stage('Set Variables') {
steps {
script {
def today = LocalDate.now().format(DateTimeFormatter.ofPattern('yyyyMMdd'))
env.IMAGE_TAG = "${today}-${env.BUILD_NUMBER}"
}
}
}
stage('Checkout') {
steps {
checkout([$class: 'GitSCM',
branches: [[name: 'dev']],
userRemoteConfigs: [[url: 'https://github.com/abc/def.git', credentialsId: 'github_credential']]
])
}
}
stage('Build') {
steps {
dir(env.WORK_DIR) {
script {
withCredentials([usernamePassword(credentialsId: 'harbor_credential', passwordVariable: 'HARBOR_PASSWORD', usernameVariable: 'HARBOR_USERNAME')]) {
sh """
echo ${HARBOR_PASSWORD} | podman login -u ${HARBOR_USERNAME} --password-stdin ${IMAGE_REPO}
podman build --platform=linux/amd64 -t ${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG} .
podman push ${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}
"""
}
}
}
}
}
stage('Deploy to Kubernetes') {
steps {
dir(env.WORK_DIR) {
withCredentials([file(credentialsId: 'KUBECONFIG', variable: 'KUBECONFIG_FILE')]) {
sh """
export KUBECONFIG=${KUBECONFIG_FILE}
sed -i 's|image: ${IMAGE_REPO}/${IMAGE_NAME}:.*|image: ${IMAGE_REPO}/${IMAGE_NAME}:${IMAGE_TAG}|' deployment.yml
kubectl apply -f deployment.yml --namespace=${env.NAMESPACE}
kubectl rollout status deployment/${env.DEPLOYMENT_NAME} --namespace=${env.NAMESPACE}
"""
}
}
}
}
}
post {
success {
slackSend(
channel: '#젠킨스',
color: '#00FF00',
message: """
FE 배포 성공: Job ${env.JOB_NAME} [${env.BUILD_NUMBER}]
"""
)
}
failure {
slackSend(
channel: '#젠킨스',
color: '#FF0000',
message: """
FE 배포 실패: Job ${env.JOB_NAME} [${env.BUILD_NUMBER}]
"""
)
}
}
}