Showing Posts From

Jenkins

Jenkins 파이프라인으로 CI/CD 자동화 완성하기

Jenkins 파이프라인으로 CI/CD 자동화 완성하기

Jenkins 파이프라인으로 CI/CD 자동화 완성하기 파이프라인 처음 만들던 날 3년 전이었다. Jenkins 설치하고 첫 파이프라인 돌렸다. pipeline { agent any stages { stage('Test') { steps { sh 'pytest' } } } }10줄짜리 코드. 테스트만 돌렸다. 그게 시작이었다. 지금은 300줄이다. 빌드, 테스트, 배포, 알림, 롤백까지. 복잡하다. 하지만 이제 안다. 어떻게 관리하는지.오늘 우리 파이프라인 이야기한다. 실전이다. 선언형 파이프라인 기본 구조 선언형을 쓴다. 스크립트형 말고. 이유는 간단하다. 읽기 쉽다. 유지보수 쉽다. pipeline { agent { docker { image 'python:3.9' args '-v /var/run/docker.sock:/var/run/docker.sock' } } environment { TEST_ENV = 'staging' SLACK_CHANNEL = '#qa-alerts' COVERAGE_THRESHOLD = '80' } options { timestamps() timeout(time: 1, unit: 'HOURS') buildDiscarder(logRotator(numToKeepStr: '30')) disableConcurrentBuilds() } stages { stage('Setup') { steps { sh 'pip install -r requirements.txt' } } stage('Lint') { steps { sh 'flake8 tests/' sh 'pylint tests/' } } stage('Test') { steps { sh 'pytest --junitxml=report.xml' } } } post { always { junit 'report.xml' } } }이게 기본이다. agent는 어디서 돌릴지. environment는 환경변수. options는 파이프라인 설정. stages는 단계들. post는 후처리. 처음엔 agent any 썼다. 아무 노드나. 그런데 환경이 달라서 문제였다. 파이썬 버전, 라이브러리 버전. 다 달랐다. 도커로 바꿨다. 환경 통일됐다. 테스트 결과도 일관됐다. environment는 하드코딩 없애려고. 설정 값들 여기 모았다. 수정할 때 한 곳만 보면 된다. options는 필수다. timeout 없으면 무한 대기한다. 한번 그랬다. 테스트 하나 멈췄는데 8시간 돌았다. 그 후로 무조건 넣는다. disableConcurrentBuilds도 중요하다. 동시 빌드 막는다. DB 테스트할 때 충돌 났었다. 같은 데이터 동시 접근. 이것도 배웠다. 병렬 테스트 실행 구조 테스트가 많다. 1500개. 순차 실행하면 40분. 너무 길다. 병렬로 돌린다. 10분으로 줄었다. stage('Parallel Tests') { parallel { stage('Unit Tests') { steps { sh 'pytest tests/unit -n 4' } } stage('Integration Tests') { steps { sh 'pytest tests/integration -n 2' } } stage('API Tests') { steps { sh 'pytest tests/api --dist loadgroup' } } stage('UI Tests - Chrome') { steps { sh 'pytest tests/ui -k chrome --dist loadscope' } } stage('UI Tests - Firefox') { steps { sh 'pytest tests/ui -k firefox --dist loadscope' } } } }parallel 블록이다. 동시에 돌린다. 처음엔 전부 -n 4로 했다. 4개씩 병렬. 그런데 UI 테스트가 문제였다. 브라우저가 4개 뜨면 메모리 터진다. 노드가 죽었다. 그래서 분리했다. Unit은 빠르니까 4개. Integration은 무거워서 2개. UI는 loadscope로 클래스 단위 분산. 실패율도 줄었다. UI 테스트가 원래 flaky했다. 동시 실행하면 더 불안정했다. 브라우저별로 나눠서 격리했다.메트릭도 본다. 어느 단계가 오래 걸리는지. stage('Performance Check') { steps { script { def startTime = System.currentTimeMillis() sh 'pytest tests/performance' def duration = System.currentTimeMillis() - startTime if (duration > 300000) { // 5분 초과 echo "Warning: Performance tests took ${duration}ms" currentBuild.result = 'UNSTABLE' } } } }5분 넘으면 경고. UNSTABLE로 마킹. 실패는 아니다. 주의만 준다. 이거 넣고 병목 찾았다. API 테스트 중 하나가 2분 걸렸다. DB 초기화 때문이었다. 픽스처 개선했다. 20초로 줄었다. 실패 알림 자동화 시스템 테스트 실패하면 알아야 한다. 빨리. 정확하게. 슬랙 연동했다. post { failure { script { def failedTests = sh( script: "grep -o 'FAILED.*' report.xml | wc -l", returnStdout: true ).trim() def testReport = """ *Build Failed* :x: *Branch:* ${env.GIT_BRANCH} *Build:* ${env.BUILD_NUMBER} *Failed Tests:* ${failedTests} *Duration:* ${currentBuild.durationString} *Triggered by:* ${env.BUILD_USER} *View:* ${env.BUILD_URL} """ slackSend( channel: '#qa-alerts', color: 'danger', message: testReport ) } } success { slackSend( channel: '#qa-success', color: 'good', message: "*Build Success* :white_check_mark: Branch: ${env.GIT_BRANCH}" ) } unstable { slackSend( channel: '#qa-alerts', color: 'warning', message: "*Build Unstable* :warning: Some tests are flaky. Branch: ${env.GIT_BRANCH}" ) } }실패하면 #qa-alerts로. 성공하면 #qa-success로. 채널 분리했다. 노이즈 줄이려고. 처음엔 전부 한 채널이었다. 성공 알림이 너무 많았다. 실패 알림을 못 봤다. 분리하고 나서 놓치는 게 없다. 실패 알림은 상세하게. 어느 브랜치. 몇 개 실패. 누가 트리거. URL까지. def getFailedTestDetails() { def details = sh( script: """ grep 'FAILED' report.xml | \ sed 's/.*FAILED \\(.*\\)::.*/\\1/' | \ sort | uniq | head -5 """, returnStdout: true ).trim() return details }post { failure { script { def failedList = getFailedTestDetails() slackSend( channel: '#qa-alerts', message: """ *Failed Tests:* ``` ${failedList} ``` """ ) } } }실패한 테스트 이름도 보낸다. 최대 5개. 뭐가 깨졌는지 바로 안다. 이메일도 보낸다. 중요한 빌드는. post { failure { emailext( to: 'qa-team@company.com', subject: "Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}", body: """ <h2>Build Failed</h2> <p><b>Branch:</b> ${env.GIT_BRANCH}</p> <p><b>Build URL:</b> <a href="${env.BUILD_URL}">${env.BUILD_URL}</a></p> <p><b>Console Output:</b> <a href="${env.BUILD_URL}console">View</a></p> """, mimeType: 'text/html' ) } }HTML 형식이다. 보기 좋다. 링크 클릭하면 바로 Jenkins로. Jira 티켓도 자동 생성한다. 반복 실패하는 테스트는. def createJiraTicket(testName, buildUrl) { def issue = [ fields: [ project: [key: 'QA'], summary: "Flaky Test: ${testName}", description: "Test failed in build: ${buildUrl}", issuetype: [name: 'Bug'], priority: [name: 'Medium'], labels: ['flaky-test', 'auto-created'] ] ] def response = httpRequest( url: 'https://jira.company.com/rest/api/2/issue', httpMode: 'POST', contentType: 'APPLICATION_JSON', requestBody: groovy.json.JsonOutput.toJson(issue), authentication: 'jira-credentials' ) }post { failure { script { def failureCount = sh( script: "grep '${testName}' failure_log.txt | wc -l", returnStdout: true ).trim() as Integer if (failureCount >= 3) { createJiraTicket(testName, env.BUILD_URL) } } } }3번 연속 실패하면 티켓 생성. 자동으로. QA 백로그에 쌓인다. 주간 회의 때 리뷰한다.복잡한 파이프라인 관리 전략 파이프라인이 커진다. 300줄 넘어가면 관리가 어렵다. 첫 번째 전략. 공유 라이브러리. // vars/testPipeline.groovy def call(Map config) { pipeline { agent any stages { stage('Setup') { steps { script { setupTestEnvironment(config.testEnv) } } } stage('Test') { steps { script { runTests(config.testType) } } } } } }def setupTestEnvironment(env) { sh "pip install -r requirements-${env}.txt" }def runTests(type) { sh "pytest tests/${type} --junitxml=report.xml" }공통 로직을 라이브러리로 뺐다. Jenkinsfile은 간단해진다. @Library('my-shared-library') _testPipeline( testEnv: 'staging', testType: 'integration' )10줄이다. 읽기 쉽다. 두 번째 전략. 파라미터화. pipeline { parameters { choice( name: 'TEST_SUITE', choices: ['all', 'smoke', 'regression', 'api', 'ui'], description: 'Which test suite to run' ) string( name: 'TEST_ENV', defaultValue: 'staging', description: 'Test environment' ) booleanParam( name: 'SKIP_UI', defaultValue: false, description: 'Skip UI tests' ) choice( name: 'BROWSER', choices: ['chrome', 'firefox', 'both'], description: 'Browser for UI tests' ) } stages { stage('Test') { steps { script { if (params.TEST_SUITE == 'all') { sh 'pytest tests/' } else { sh "pytest tests/${params.TEST_SUITE}" } } } } stage('UI Test') { when { expression { !params.SKIP_UI } } steps { script { if (params.BROWSER == 'both') { parallel( chrome: { sh 'pytest tests/ui -k chrome' }, firefox: { sh 'pytest tests/ui -k firefox' } ) } else { sh "pytest tests/ui -k ${params.BROWSER}" } } } } } }유연하다. 상황마다 다르게 돌린다. 전체 테스트는 밤에. 스모크는 PR마다. 세 번째 전략. 단계별 분리. // Jenkinsfile pipeline { stages { stage('Build') { steps { build job: 'build-job', parameters: [string(name: 'BRANCH', value: env.GIT_BRANCH)] } } stage('Test') { steps { build job: 'test-job', parameters: [ string(name: 'BUILD_ID', value: env.BUILD_ID), string(name: 'TEST_ENV', value: 'staging') ] } } stage('Deploy') { when { branch 'main' } steps { build job: 'deploy-job', parameters: [string(name: 'VERSION', value: env.BUILD_ID)] } } } }빌드, 테스트, 배포 각각 job. 독립적이다. 재사용 가능하다. 테스트만 다시 돌리고 싶으면 test-job만. 배포만 하고 싶으면 deploy-job만. 네 번째 전략. 조건부 실행. stage('Integration Test') { when { anyOf { branch 'develop' branch 'main' changeRequest target: 'main' } } steps { sh 'pytest tests/integration' } }stage('E2E Test') { when { allOf { branch 'main' expression { return env.BUILD_NUMBER.toInteger() % 2 == 0 } } } steps { sh 'pytest tests/e2e' } }stage('Performance Test') { when { triggeredBy 'TimerTrigger' } steps { sh 'pytest tests/performance' } }Integration은 develop, main만. E2E는 main에서 2번에 1번. Performance는 스케줄 트리거만. 리소스 아낀다. 필요한 것만 돌린다. 다섯 번째 전략. 결과 아카이빙. post { always { junit 'reports/*.xml' publishHTML([ reportDir: 'htmlcov', reportFiles: 'index.html', reportName: 'Coverage Report' ]) archiveArtifacts artifacts: 'screenshots/*.png', allowEmptyArchive: true script { def coverage = sh( script: "coverage report | grep TOTAL | awk '{print \$4}'", returnStdout: true ).trim() currentBuild.description = "Coverage: ${coverage}" } } }테스트 결과, 커버리지, 스크린샷 다 저장한다. 나중에 볼 수 있다. 빌드 설명에 커버리지 표시. 목록에서 바로 보인다. 파이프라인 최적화 실전 팁 1년 돌리면서 배운 것들. 캐시 활용 pipeline { agent { docker { image 'python:3.9' args '-v /tmp/pip-cache:/root/.cache/pip' } } stages { stage('Install') { steps { sh 'pip install --cache-dir /root/.cache/pip -r requirements.txt' } } } }pip 캐시 볼륨 마운트. 설치 시간 3분 → 30초. 아티팩트 재사용 stage('Build') { steps { sh 'python setup.py bdist_wheel' stash includes: 'dist/*.whl', name: 'wheel' } }stage('Test') { agent { label 'test-node' } steps { unstash 'wheel' sh 'pip install dist/*.whl' sh 'pytest' } }빌드 한 번. 여러 노드에서 테스트. 중복 빌드 안 한다. 조기 실패 stage('Quick Check') { steps { sh 'pytest tests/smoke -x' // 첫 실패에서 멈춤 } }stage('Full Test') { when { expression { currentBuild.result == null } } steps { sh 'pytest tests/' } }스모크 실패하면 전체 테스트 안 돌린다. 시간 절약. 리트라이 로직 stage('Flaky Test') { steps { retry(3) { sh 'pytest tests/ui' } } }UI 테스트는 3번 재시도. Flaky 때문에. 하지만 남용 안 한다. 진짜 flaky만. 나머지는 고친다. 타임아웃 세분화 stage('Unit Test') { options { timeout(time: 5, unit: 'MINUTES') } steps { sh 'pytest tests/unit' } }stage('E2E Test') { options { timeout(time: 30, unit: 'MINUTES') } steps { sh 'pytest tests/e2e' } }각 단계마다 타임아웃. Unit은 5분. E2E는 30분. 적절하게. 로그 정리 post { always { sh 'pytest --tb=short > test_output.log' script { def log = readFile('test_output.log') if (log.size() > 10000) { writeFile file: 'test_output.log', text: log.take(10000) + "\n... (truncated)" } } archiveArtifacts 'test_output.log' } }로그 너무 길면 잘라낸다. Jenkins 느려진다. 실전 문제 해결 사례 케이스 1: 간헐적 실패 UI 테스트가 10번 중 3번 실패했다. 같은 테스트. 로그 봤다. 타임아웃이었다. 페이지 로딩이 늦을 때. // Before driver.find_element(By.ID, 'submit').click()// After wait = WebDriverWait(driver, 20) element = wait.until(EC.element_to_be_clickable((By.ID, 'submit'))) element.click()명시적 대기 추가. 실패율 0%로. 케이스 2: 메모리 부족 병렬 테스트 돌리면 노드가 죽었다. OOM. // Before parallel { stage('Test 1') { ... } stage('Test 2') { ... } stage('Test 3') { ... } stage('Test 4') { ... } }// After stage('Test Batch 1') { parallel { stage('Test 1') { ... } stage('Test 2') { ... } } }stage('Test Batch 2') { parallel { stage('Test 3') { ... } stage('Test 4') { ... } } }2개씩 배치로. 순차 병렬. 메모리 안정. 케이스 3: 느린 빌드 40분 걸렸다. 너무 길다. 프로파일링 했다. def measureStage(stageName, closure) { def start = System.currentTimeMillis() closure() def duration = System.currentTimeMillis() - start echo "${stageName} took ${duration}ms" }stage('Test') { steps { script { measureStage('Unit') { sh 'pytest tests/unit' } measureStage('Integration') { sh 'pytest tests/integration' } } } }Integration이 25분이었다. DB 초기화 때문. 픽스처 개선. 트랜잭션 롤백으로 변경. 5분으로 단축. 총 15분으로 줄었다. 케이스 4: 환경 차이 로컬은 성공. Jenkins는 실패. 환경이 달랐다. 도커 이미지 통일했다. FROM python:3.9RUN apt-get update && apt-get install -y \ chromium \ chromium-driver \ firefox-esr \ && rm -rf /var/lib/apt/lists/*COPY requirements.txt . RUN pip install -r requirements.txtWORKDIR /app로컬도 이 이미지로. Jenkins도. 결과 동일해졌다. 모니터링과 개선 파이프라인도 모니터링한다. 메트릭 수집. def collectMetrics() { def metrics = [ build_number: env.BUILD_NUMBER, duration: currentBuild.duration, result: currentBuild.result, test_count: sh(script: "grep 'tests passed' report.xml | wc -l", returnStdout: true).trim(), failed_count: sh(script: "grep 'FAILED' report.xml | wc -l", returnStdout: true).trim() ] def json = groovy.json.JsonOutput.toJson(metrics) sh "echo '${json}' >> /metrics/pipeline_metrics.jsonl" }post { always { script { collectMetrics() } } }매 빌드마다 기록. duration, result, test count. 주간 분석한다. 트렌드 본다. import pandas as pddf = pd.read_json('pipeline_metrics.jsonl', lines=True)# 평균 빌드 시간 print(f"Average duration: {df['duration'].mean() / 1000 / 60:.2f} minutes")# 실패율 failure_rate = len(df[df['result'] == 'FAILURE']) / len(df) * 100 print(f"Failure rate: {failure_rate:.2f}%")# 가장 느린 빌드 slowest = df.nlargest(5, 'duration') print(slowest[['build_number', 'duration', 'test_count']])데이터 기반 개선. 느린 빌드 찾고. 실패율 높은 테스트 찾고. 대시보드도 만들었다. Grafana로. post { always { influxDbPublisher( selectedTarget: 'jenkins-metrics', customProjectName: env.JOB_NAME, customData: [ build_duration: currentBuild.duration, test_count: testCount, failed_count: failedCount ] ) } }InfluxDB에 쌓고. Grafana로 시각화. 실시간 보인다. 지금 돌아가는 파이프라인 우리 메인 파이프라인이다. 매일 돌아간다. @Library('qa-shared-lib') _pipeline { agent none parameters { choice(name: 'ENVIRONMENT', choices: ['staging', 'production']) booleanParam(name: 'RUN_PERFORMANCE', defaultValue: false) } options { timeout(time: 1, unit: 'HOURS') buildDiscarder(logRotator(numToKeepStr: '50')) } stages { stage('Parallel Setup') { parallel { stage('Lint') { agent { docker 'python:3.9' } steps { sh 'pip install flake8 pylint' sh 'flake8 tests/' } } stage('Security Scan') { agent { docker 'python:3.9' } steps { sh 'pip install bandit' sh 'bandit -r tests/' } } } } stage('Build') { agent { docker 'python:3.9' } steps { sh 'pip install -r requirements.txt' sh 'python setup.py bdist_wheel' stash includes: 'dist/*.whl', name: 'package' } } stage('Test') { parallel { stage('Unit') { agent { docker 'python:3.9' } steps { unstash 'package' sh 'pip install dist/*.whl' sh 'pytest tests/unit --junitxml=unit.xml' } post { always { junit 'unit.xml' } } } stage('Integration') { agent { docker { image 'python:3.9' args '--network test-network' } } steps { unstash 'package' sh 'pip install dist/*.whl' sh 'pytest tests/integration --junitxml=integration.xml' } post { always { junit 'integration.xml' } } } stage('UI') { agent { label 'selenium-node' } steps { unstash 'package' sh 'pip install dist/*.whl' sh '