Jenkins CI/CD 파이프라인 완전 구축 및 가이드 개선

주요 작업:
- Kustomize 기반 환경별 배포 구조 완성 (dev/staging/prod)
- deployment-patch.yaml 개선: replicas + resources 통합 관리
- Strategic Merge Patch 형식으로 변경하여 가독성 및 유지보수성 향상
- 환경별 차등 리소스 할당 정책 적용
- Jenkins 파이프라인 스크립트 및 수동 배포 스크립트 완성
- 상세한 체크리스트 및 실수 방지 가이드 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hiondal
2025-09-12 12:09:54 +09:00
parent 892f30ba44
commit 042198deb0
63 changed files with 1156 additions and 1615 deletions
+133 -409
View File
@@ -1,425 +1,149 @@
#!/usr/bin/env groovy
def PIPELINE_ID = "${env.BUILD_NUMBER}"
/**
* Jenkins Pipeline for phonebill Microservices
* Supports multi-environment deployment (dev, staging, prod)
* Services: api-gateway, user-service, bill-service, product-service, kos-mock
*/
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
metadata:
labels:
jenkins: agent
spec:
serviceAccountName: jenkins-agent
containers:
- name: gradle
image: gradle:8.5-jdk17
command:
- cat
tty: true
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1"
volumeMounts:
- name: gradle-cache
mountPath: /home/gradle/.gradle
- name: podman
image: quay.io/podman/stable:latest
command:
- cat
tty: true
securityContext:
privileged: true
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1"
- name: azure-cli
image: mcr.microsoft.com/azure-cli:latest
command:
- cat
tty: true
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
- name: kubectl
image: bitnami/kubectl:latest
command:
- cat
tty: true
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "250m"
volumes:
- name: gradle-cache
persistentVolumeClaim:
claimName: jenkins-gradle-cache
'''
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def environment = params.ENVIRONMENT ?: 'dev'
def services = ['api-gateway', 'user-service', 'bill-service', 'product-service', 'kos-mock']
stage("Get Source") {
checkout scm
props = readProperties file: "deployment/cicd/config/deploy_env_vars_${environment}"
}
}
parameters {
choice(
name: 'ENVIRONMENT',
choices: ['dev', 'staging', 'prod'],
description: 'Target deployment environment'
)
choice(
name: 'SERVICES_TO_BUILD',
choices: ['all', 'api-gateway', 'user-service', 'bill-service', 'product-service', 'kos-mock'],
description: 'Services to build and deploy'
)
booleanParam(
name: 'SKIP_TESTS',
defaultValue: false,
description: 'Skip unit tests during build'
)
booleanParam(
name: 'SKIP_SONAR',
defaultValue: false,
description: 'Skip SonarQube analysis'
)
booleanParam(
name: 'FORCE_DEPLOY',
defaultValue: false,
description: 'Force deployment even if no changes detected'
)
}
environment {
// Load environment-specific variables
CONFIG_FILE = "deployment/cicd/config/deploy_env_vars_${params.ENVIRONMENT}"
// Build configuration
GRADLE_USER_HOME = '/home/gradle/.gradle'
GRADLE_OPTS = '-Xmx2048m -XX:MaxPermSize=512m'
// Azure credentials
AZURE_CREDENTIALS = credentials('azure-service-principal')
ACR_CREDENTIALS = credentials('acr-credentials')
// SonarQube
SONAR_TOKEN = credentials('sonarqube-token')
// Slack notification
SLACK_TOKEN = credentials('slack-token')
}
stages {
stage('Initialize') {
steps {
script {
echo "🚀 Starting phonebill pipeline for ${params.ENVIRONMENT} environment"
// Load environment-specific configuration
if (fileExists(env.CONFIG_FILE)) {
def props = readProperties file: env.CONFIG_FILE
props.each { key, value ->
env[key] = value
}
echo "✅ Loaded configuration from ${env.CONFIG_FILE}"
} else {
error "❌ Configuration file not found: ${env.CONFIG_FILE}"
}
// Set services to build
if (params.SERVICES_TO_BUILD == 'all') {
env.SERVICES_LIST = env.SERVICES
} else {
env.SERVICES_LIST = params.SERVICES_TO_BUILD
}
echo "🎯 Services to build: ${env.SERVICES_LIST}"
echo "📦 Target namespace: ${env.AKS_NAMESPACE}"
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az aks get-credentials --resource-group \${props.resource_group} --name \${props.cluster_name} --overwrite-existing
kubectl create namespace phonebill-\${environment} --dry-run=client -o yaml | kubectl apply -f -
"""
}
}
}
stage('Checkout & Prepare') {
steps {
checkout scm
script {
env.BUILD_TIMESTAMP = sh(
script: 'date +%Y%m%d-%H%M%S',
returnStdout: true
).trim()
env.IMAGE_TAG = "${env.BUILD_NUMBER}-${params.ENVIRONMENT}-${env.BUILD_TIMESTAMP}"
echo "🏷️ Image tag: ${env.IMAGE_TAG}"
}
}
}
stage('Build & Test') {
parallel {
stage('Gradle Build') {
steps {
container('gradle') {
script {
def services = env.SERVICES_LIST.split(',')
for (service in services) {
echo "🔨 Building ${service}..."
if (!params.SKIP_TESTS) {
sh """
./gradlew ${service}:clean ${service}:test ${service}:build \
--no-daemon \
--parallel \
--build-cache
"""
} else {
sh """
./gradlew ${service}:clean ${service}:build -x test \
--no-daemon \
--parallel \
--build-cache
"""
}
}
}
}
}
post {
always {
// Publish test results
script {
def services = env.SERVICES_LIST.split(',')
for (service in services) {
if (fileExists("${service}/build/test-results/test/*.xml")) {
publishTestResults(
testResultsPattern: "${service}/build/test-results/test/*.xml",
allowEmptyResults: true
)
}
}
}
}
}
}
}
}
stage('SonarQube Analysis') {
when {
not { params.SKIP_SONAR }
}
steps {
container('gradle') {
withSonarQubeEnv('SonarQube') {
sh '''
./gradlew sonarqube \
-Dsonar.projectKey=${SONAR_PROJECT_KEY} \
-Dsonar.sources=${SONAR_SOURCES} \
-Dsonar.exclusions="${SONAR_EXCLUSIONS}" \
--no-daemon
'''
}
}
}
}
stage('Quality Gate') {
when {
not { params.SKIP_SONAR }
}
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
stage('Container Build & Push') {
parallel {
stage('Build Images') {
steps {
container('podman') {
script {
// Login to ACR
sh """
echo "${ACR_CREDENTIALS_PSW}" | podman login \
--username "${ACR_CREDENTIALS_USR}" \
--password-stdin \
${REGISTRY_URL}
"""
def services = env.SERVICES_LIST.split(',')
for (service in services) {
echo "🐳 Building container image for ${service}..."
sh """
cd ${service}
podman build \
--tag ${REGISTRY_URL}/phonebill/${service}:${IMAGE_TAG} \
--tag ${REGISTRY_URL}/phonebill/${service}:latest-${params.ENVIRONMENT} \
--file Dockerfile \
.
podman push ${REGISTRY_URL}/phonebill/${service}:${IMAGE_TAG}
podman push ${REGISTRY_URL}/phonebill/${service}:latest-${params.ENVIRONMENT}
"""
}
}
}
}
}
}
}
stage('Deploy to Kubernetes') {
steps {
container('kubectl') {
script {
// Login to Azure and get AKS credentials
sh """
az login --service-principal \
--username "${AZURE_CREDENTIALS_USR}" \
--password "${AZURE_CREDENTIALS_PSW}" \
--tenant "${AZURE_CREDENTIALS_TENANT_ID}"
az aks get-credentials \
--resource-group ${AZURE_RESOURCE_GROUP} \
--name ${AKS_CLUSTER_NAME} \
--overwrite-existing
"""
stage('Build & SonarQube Analysis') {
container('gradle') {
withSonarQubeEnv('SonarQube') {
sh """
chmod +x gradlew
./gradlew build -x test
// Deploy services using kustomize
def services = env.SERVICES_LIST.split(',')
for (service in services) {
echo "🚀 Deploying ${service} to ${params.ENVIRONMENT}..."
# 각 서비스별 테스트 및 분석
./gradlew :api-gateway:test :api-gateway:jacocoTestReport :api-gateway:sonar \\
-Dsonar.projectKey=phonebill-api-gateway-\${environment} \\
-Dsonar.projectName=phonebill-api-gateway
./gradlew :user-service:test :user-service:jacocoTestReport :user-service:sonar \\
-Dsonar.projectKey=phonebill-user-service-\${environment} \\
-Dsonar.projectName=phonebill-user-service
sh """
cd ${KUSTOMIZE_BASE}/${service}
# Update image tag in kustomization.yaml
sed -i 's|newTag:.*|newTag: ${IMAGE_TAG}|' ${KUSTOMIZE_OVERLAY}/kustomization.yaml
# Apply deployment
kubectl apply -k ${KUSTOMIZE_OVERLAY} -n ${AKS_NAMESPACE}
# Wait for rollout
kubectl rollout status deployment/${service} -n ${AKS_NAMESPACE} --timeout=300s
"""
}
./gradlew :bill-service:test :bill-service:jacocoTestReport :bill-service:sonar \\
-Dsonar.projectKey=phonebill-bill-service-\${environment} \\
-Dsonar.projectName=phonebill-bill-service
./gradlew :product-service:test :product-service:jacocoTestReport :product-service:sonar \\
-Dsonar.projectKey=phonebill-product-service-\${environment} \\
-Dsonar.projectName=phonebill-product-service
./gradlew :kos-mock:test :kos-mock:jacocoTestReport :kos-mock:sonar \\
-Dsonar.projectKey=phonebill-kos-mock-\${environment} \\
-Dsonar.projectName=phonebill-kos-mock
"""
}
}
}
stage('Quality Gate') {
timeout(time: 10, unit: 'MINUTES') {
def qg = waitForQualityGate()
if (qg.status != 'OK') {
error "Pipeline aborted due to quality gate failure: \${qg.status}"
}
}
}
stage('Build & Push Images') {
container('podman') {
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'USERNAME',
passwordVariable: 'PASSWORD'
)]) {
sh "podman login acrdigitalgarage01.azurecr.io --username \$USERNAME --password \$PASSWORD"
services.each { service ->
sh """
podman build \\
--build-arg BUILD_LIB_DIR="\${service}/build/libs" \\
--build-arg ARTIFACTORY_FILE="\${service}.jar" \\
-f deployment/container/Dockerfile \\
-t acrdigitalgarage01.azurecr.io/phonebill/\${service}:\${environment}-\${imageTag} .
podman push acrdigitalgarage01.azurecr.io/phonebill/\${service}:\${environment}-\${imageTag}
"""
}
}
}
}
stage('Update Kustomize & Deploy') {
container('azure-cli') {
sh """
# Kustomize 설치
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/
# 환경별 디렉토리로 이동
cd deployment/cicd/kustomize/overlays/\${environment}
# 이미지 태그 업데이트
kustomize edit set image acrdigitalgarage01.azurecr.io/phonebill/api-gateway:\${environment}-\${imageTag}
kustomize edit set image acrdigitalgarage01.azurecr.io/phonebill/user-service:\${environment}-\${imageTag}
kustomize edit set image acrdigitalgarage01.azurecr.io/phonebill/bill-service:\${environment}-\${imageTag}
kustomize edit set image acrdigitalgarage01.azurecr.io/phonebill/product-service:\${environment}-\${imageTag}
kustomize edit set image acrdigitalgarage01.azurecr.io/phonebill/kos-mock:\${environment}-\${imageTag}
# 매니페스트 적용
kubectl apply -k .
echo "Waiting for deployments to be ready..."
kubectl -n phonebill-\${environment} wait --for=condition=available deployment/\${environment}-api-gateway --timeout=300s
kubectl -n phonebill-\${environment} wait --for=condition=available deployment/\${environment}-user-service --timeout=300s
kubectl -n phonebill-\${environment} wait --for=condition=available deployment/\${environment}-bill-service --timeout=300s
kubectl -n phonebill-\${environment} wait --for=condition=available deployment/\${environment}-product-service --timeout=300s
kubectl -n phonebill-\${environment} wait --for=condition=available deployment/\${environment}-kos-mock --timeout=300s
"""
}
}
stage('Health Check') {
steps {
container('kubectl') {
script {
def services = env.SERVICES_LIST.split(',')
for (service in services) {
echo "🏥 Health checking ${service}..."
retry(count: env.HEALTH_CHECK_RETRY as Integer) {
sh """
kubectl get deployment ${service} -n ${AKS_NAMESPACE} -o json | \
jq -e '.status.readyReplicas == .status.replicas and .status.replicas > 0'
"""
sleep time: 30, unit: 'SECONDS'
}
echo "✅ ${service} is healthy"
}
}
}
}
}
}
post {
always {
script {
// Archive build artifacts
archiveArtifacts(
artifacts: '**/build/libs/*.jar',
allowEmptyArchive: true,
fingerprint: true
)
// Clean workspace
cleanWs()
}
}
success {
script {
def message = """
✅ *phonebill Deployment Successful*
• Environment: `${params.ENVIRONMENT}`
• Services: `${env.SERVICES_LIST}`
• Image Tag: `${env.IMAGE_TAG}`
• Build: `${env.BUILD_NUMBER}`
• Duration: `${currentBuild.durationString}`
""".stripIndent()
// Send Slack notification
slackSend(
channel: env.SLACK_CHANNEL,
color: 'good',
message: message,
token: env.SLACK_TOKEN
)
// Send email notification
emailext(
subject: "✅ phonebill Deployment Success - ${params.ENVIRONMENT}",
body: message,
to: env.EMAIL_RECIPIENTS
)
}
}
failure {
script {
def message = """
❌ *phonebill Deployment Failed*
• Environment: `${params.ENVIRONMENT}`
• Services: `${env.SERVICES_LIST}`
• Build: `${env.BUILD_NUMBER}`
• Error: `${currentBuild.result}`
• Console: ${env.BUILD_URL}console
""".stripIndent()
// Send Slack notification
slackSend(
channel: env.SLACK_CHANNEL,
color: 'danger',
message: message,
token: env.SLACK_TOKEN
)
// Send email notification
emailext(
subject: "❌ phonebill Deployment Failed - ${params.ENVIRONMENT}",
body: message,
to: env.EMAIL_RECIPIENTS
)
container('azure-cli') {
sh """
echo "🔍 Health Check starting..."
# API Gateway Health Check
GATEWAY_POD=\$(kubectl get pod -n phonebill-\${environment} -l app=api-gateway -o jsonpath='{.items[0].metadata.name}')
kubectl -n phonebill-\${environment} exec \$GATEWAY_POD -- curl -f http://localhost:8080/actuator/health || exit 1
echo "✅ All services are healthy!"
"""
}
}
}