CI/CD Pipelines

CI/CD là gì?

Continuous Integration (CI)

Định nghĩa: CI là practice trong đó developers thường xuyên integrate code changes vào shared repository. Mỗi integration được verify bằng automated build và tests.

Continuous Delivery (CD)

Định nghĩa: CD đảm bảo code changes được automatically prepared for release to production.

GitHub Actions Example

name: Java CI Pipeline

on:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Run tests
      run: mvn test

Best Practices

  1. Fast Feedback: Run tests quickly
  2. Security: Scan for vulnerabilities
  3. Quality Gates: Set coverage thresholds
graph LR
    A[Code Commit] --> B[Build]
    B --> C[Unit Tests]
    C --> D[Integration Tests]
    D --> E[Deploy to Staging]
    E --> F[E2E Tests]
    F --> G[Deploy to Production]

    H[CI] --> B
    H --> C
    H --> D

    I[CD] --> E
    I --> F
    I --> G

GitHub Actions CI/CD

Basic GitHub Actions Pipeline

# .github/workflows/ci-cd.yml
name: Java CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  JAVA_VERSION: '17'
  MAVEN_OPTS: '-Xmx1024m'

jobs:
  # Job 1: Build và Test
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: rootpassword
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3
      with:
        fetch-depth: 0  # Full clone for SonarCloud

    - name: Set up JDK ${{ env.JAVA_VERSION }}
      uses: actions/setup-java@v3
      with:
        java-version: ${{ env.JAVA_VERSION }}
        distribution: 'temurin'
        cache: maven

    - name: Cache Maven dependencies
      uses: actions/cache@v3
      with:
        path: ~/.m2
        key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
        restore-keys: |
          ${{ runner.os }}-m2

    - name: Run unit tests
      run: mvn test -Dspring.profiles.active=test

    - name: Run integration tests
      run: mvn verify -Dspring.profiles.active=integration-test
      env:
        SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
        SPRING_DATASOURCE_USERNAME: root
        SPRING_DATASOURCE_PASSWORD: rootpassword

    - name: Generate test report
      uses: dorny/test-reporter@v1
      if: success() || failure()
      with:
        name: Maven Tests
        path: target/surefire-reports/*.xml
        reporter: java-junit

    - name: Code coverage
      run: mvn jacoco:report

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: target/site/jacoco/jacoco.xml

    - name: SonarCloud Scan
      uses: SonarSource/sonarcloud-github-action@master
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  # Job 2: Security Scanning
  security:
    runs-on: ubuntu-latest
    needs: test

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Set up JDK ${{ env.JAVA_VERSION }}
      uses: actions/setup-java@v3
      with:
        java-version: ${{ env.JAVA_VERSION }}
        distribution: 'temurin'
        cache: maven

    - name: OWASP Dependency Check
      run: mvn org.owasp:dependency-check-maven:check

    - name: Upload dependency check results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: dependency-check-report
        path: target/dependency-check-report.html

  # Job 3: Build và Package
  build:
    runs-on: ubuntu-latest
    needs: [test, security]
    if: github.ref == 'refs/heads/main'

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Set up JDK ${{ env.JAVA_VERSION }}
      uses: actions/setup-java@v3
      with:
        java-version: ${{ env.JAVA_VERSION }}
        distribution: 'temurin'
        cache: maven

    - name: Build application
      run: mvn clean package -DskipTests

    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v4
      with:
        images: |
          mycompany/user-service
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=sha,prefix={{branch}}-
          type=raw,value=latest,enable={{is_default_branch}}

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2

    - name: Login to Docker Hub
      uses: docker/login-action@v2
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

    - name: Build and push Docker image
      id: build
      uses: docker/build-push-action@v4
      with:
        context: .
        file: ./Dockerfile
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        build-args: |
          BUILD_DATE=${{ github.event.head_commit.timestamp }}
          VCS_REF=${{ github.sha }}
          VERSION=${{ steps.meta.outputs.version }}

  # Job 4: Deploy to Staging
  deploy-staging:
    runs-on: ubuntu-latest
    needs: build
    environment: staging

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Deploy to staging
      uses: azure/k8s-deploy@v1
      with:
        namespace: staging
        manifests: |
          k8s/deployment.yaml
          k8s/service.yaml
          k8s/ingress.yaml
        images: |
          mycompany/user-service:${{ needs.build.outputs.image-tag }}
        kubectl-version: 'latest'

    - name: Run smoke tests
      run: |
        sleep 30  # Wait for deployment
        curl -f https://staging.myapp.com/actuator/health
        curl -f https://staging.myapp.com/api/users/health

  # Job 5: Deploy to Production
  deploy-production:
    runs-on: ubuntu-latest
    needs: [build, deploy-staging]
    environment: production
    if: github.ref == 'refs/heads/main'

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Deploy to production
      uses: azure/k8s-deploy@v1
      with:
        namespace: production
        manifests: |
          k8s/deployment.yaml
          k8s/service.yaml
          k8s/ingress.yaml
        images: |
          mycompany/user-service:${{ needs.build.outputs.image-tag }}
        kubectl-version: 'latest'
        strategy: blue-green

    - name: Health check
      run: |
        sleep 60  # Wait for deployment
        curl -f https://api.myapp.com/actuator/health
        curl -f https://api.myapp.com/api/users/health

    - name: Notify Slack
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        channel: '#deployments'
        message: |
          Deployment to production completed!
          Image: ${{ needs.build.outputs.image-tag }}
          Commit: ${{ github.sha }}
      env:
        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Advanced Pipeline với Matrix Strategy

# .github/workflows/matrix-build.yml
name: Matrix Build Strategy

on:
  push:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        java-version: [11, 17, 21]
        exclude:
          - os: windows-latest
            java-version: 21
          - os: macos-latest
            java-version: 11

    steps:
    - uses: actions/checkout@v3

    - name: Set up JDK ${{ matrix.java-version }}
      uses: actions/setup-java@v3
      with:
        java-version: ${{ matrix.java-version }}
        distribution: 'temurin'

    - name: Run tests
      run: mvn test

    - name: Upload test results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: test-results-${{ matrix.os }}-java${{ matrix.java-version }}
        path: target/surefire-reports/

Jenkins Pipeline

Declarative Pipeline

// Jenkinsfile
pipeline {
    agent any

    environment {
        JAVA_HOME = '/usr/lib/jvm/java-17-openjdk'
        MAVEN_HOME = '/usr/share/maven'
        DOCKER_REGISTRY = 'docker.company.com'
        APP_NAME = 'user-service'
        SONAR_PROJECT_KEY = 'user-service'
    }

    tools {
        maven 'Maven-3.8.6'
        jdk 'JDK-17'
    }

    options {
        buildDiscarder(logRotator(numToKeepStr: '10'))
        timeout(time: 30, unit: 'MINUTES')
        skipStagesAfterUnstable()
        parallelsAlwaysFailFast()
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                script {
                    env.GIT_COMMIT_SHORT = sh(
                        script: 'git rev-parse --short HEAD',
                        returnStdout: true
                    ).trim()
                    env.BUILD_VERSION = "${env.BUILD_NUMBER}-${env.GIT_COMMIT_SHORT}"
                }
            }
        }

        stage('Build') {
            steps {
                sh 'mvn clean compile -DskipTests'
                sh 'mvn versions:set -DnewVersion=${BUILD_VERSION}'
            }
            post {
                success {
                    echo 'Build completed successfully'
                }
                failure {
                    echo 'Build failed'
                }
            }
        }

        stage('Test') {
            parallel {
                stage('Unit Tests') {
                    steps {
                        sh 'mvn test'
                    }
                    post {
                        always {
                            publishTestResults testResultsPattern: 'target/surefire-reports/*.xml'
                            publishCoverage adapters: [
                                jacocoAdapter('target/site/jacoco/jacoco.xml')
                            ], sourceFileResolver: sourceFiles('STORE_LAST_BUILD')
                        }
                    }
                }

                stage('Integration Tests') {
                    steps {
                        sh 'mvn verify -DskipUnitTests'
                    }
                    post {
                        always {
                            publishTestResults testResultsPattern: 'target/failsafe-reports/*.xml'
                        }
                    }
                }

                stage('Static Analysis') {
                    steps {
                        script {
                            def scannerHome = tool 'SonarQubeScanner'
                            withSonarQubeEnv('SonarQube') {
                                sh """
                                    ${scannerHome}/bin/sonar-scanner \
                                    -Dsonar.projectKey=${SONAR_PROJECT_KEY} \
                                    -Dsonar.sources=src/main/java \
                                    -Dsonar.tests=src/test/java \
                                    -Dsonar.java.binaries=target/classes \
                                    -Dsonar.junit.reportPaths=target/surefire-reports \
                                    -Dsonar.jacoco.reportPaths=target/jacoco.exec
                                """
                            }
                        }

                        timeout(time: 10, unit: 'MINUTES') {
                            waitForQualityGate abortPipeline: true
                        }
                    }
                }
            }
        }

        stage('Security Scan') {
            steps {
                sh 'mvn org.owasp:dependency-check-maven:check'

                publishHTML([
                    allowMissing: false,
                    alwaysLinkToLastBuild: true,
                    keepAll: true,
                    reportDir: 'target',
                    reportFiles: 'dependency-check-report.html',
                    reportName: 'OWASP Dependency Check Report'
                ])
            }
        }

        stage('Package') {
            steps {
                sh 'mvn package -DskipTests'

                archiveArtifacts artifacts: 'target/*.jar', fingerprint: true

                script {
                    def jarFile = sh(
                        script: 'find target -name "*.jar" -not -name "*-sources.jar" | head -1',
                        returnStdout: true
                    ).trim()
                    env.JAR_FILE = jarFile
                }
            }
        }

        stage('Docker Build') {
            steps {
                script {
                    def dockerImage = docker.build(
                        "${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_VERSION}",
                        "--build-arg JAR_FILE=${JAR_FILE} ."
                    )

                    docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') {
                        dockerImage.push()
                        dockerImage.push('latest')
                    }

                    env.DOCKER_IMAGE = "${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_VERSION}"
                }
            }
        }

        stage('Deploy to Staging') {
            when {
                branch 'develop'
            }
            steps {
                script {
                    kubernetesDeploy(
                        configs: 'k8s/staging/*.yaml',
                        kubeconfigId: 'k8s-staging-config',
                        enableConfigSubstitution: true
                    )
                }

                sh """
                    sleep 30
                    curl -f https://staging-api.company.com/actuator/health
                """
            }
        }

        stage('Deploy to Production') {
            when {
                allOf {
                    branch 'main'
                    not { changeRequest() }
                }
            }
            steps {
                input message: 'Deploy to production?', ok: 'Deploy',
                      submitterParameter: 'DEPLOYER'

                script {
                    kubernetesDeploy(
                        configs: 'k8s/production/*.yaml',
                        kubeconfigId: 'k8s-production-config',
                        enableConfigSubstitution: true
                    )
                }

                sh """
                    sleep 60
                    curl -f https://api.company.com/actuator/health
                """
            }
            post {
                success {
                    slackSend(
                        channel: '#deployments',
                        color: 'good',
                        message: """
                            ✅ Production deployment successful!
                            Application: ${APP_NAME}
                            Version: ${BUILD_VERSION}
                            Deployed by: ${DEPLOYER}
                            Build: ${BUILD_URL}
                        """
                    )
                }
                failure {
                    slackSend(
                        channel: '#deployments',
                        color: 'danger',
                        message: """
                            ❌ Production deployment failed!
                            Application: ${APP_NAME}
                            Version: ${BUILD_VERSION}
                            Build: ${BUILD_URL}
                        """
                    )
                }
            }
        }
    }

    post {
        always {
            cleanWs()
        }
        success {
            echo 'Pipeline completed successfully!'
        }
        failure {
            emailext(
                subject: "Pipeline Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",
                body: """
                    Pipeline failed for ${env.JOB_NAME} - Build ${env.BUILD_NUMBER}

                    Check the build at: ${env.BUILD_URL}

                    Last commit: ${env.GIT_COMMIT_SHORT}
                """,
                to: "${env.CHANGE_AUTHOR_EMAIL ?: 'dev-team@company.com'}"
            )
        }
    }
}

Shared Library cho Jenkins

// vars/javaPipeline.groovy
def call(Map config) {
    pipeline {
        agent any

        environment {
            APP_NAME = config.appName
            JAVA_VERSION = config.javaVersion ?: '17'
            MAVEN_GOALS = config.mavenGoals ?: 'clean package'
        }

        stages {
            stage('Build') {
                steps {
                    buildJavaApp(config)
                }
            }

            stage('Test') {
                steps {
                    testJavaApp(config)
                }
            }

            stage('Deploy') {
                when {
                    branch 'main'
                }
                steps {
                    deployJavaApp(config)
                }
            }
        }
    }
}

// vars/buildJavaApp.groovy
def call(Map config) {
    sh """
        mvn clean compile -DskipTests
        mvn ${config.mavenGoals ?: 'package'} -DskipTests
    """
}

// vars/testJavaApp.groovy
def call(Map config) {
    sh 'mvn test'

    publishTestResults testResultsPattern: 'target/surefire-reports/*.xml'

    if (config.enableSonarQube) {
        withSonarQubeEnv('SonarQube') {
            sh 'mvn sonar:sonar'
        }
    }
}

// vars/deployJavaApp.groovy
def call(Map config) {
    script {
        def dockerImage = docker.build("${config.appName}:${env.BUILD_NUMBER}")

        docker.withRegistry(config.dockerRegistry, config.dockerCredentials) {
            dockerImage.push()
            dockerImage.push('latest')
        }

        if (config.kubernetesConfig) {
            kubernetesDeploy(
                configs: config.kubernetesConfig,
                kubeconfigId: config.kubeconfigId
            )
        }
    }
}

Sử dụng Shared Library

// Jenkinsfile in application repository
@Library('jenkins-shared-library') _

javaPipeline([
    appName: 'user-service',
    javaVersion: '17',
    mavenGoals: 'clean package',
    enableSonarQube: true,
    dockerRegistry: 'https://docker.company.com',
    dockerCredentials: 'docker-registry-credentials',
    kubernetesConfig: 'k8s/*.yaml',
    kubeconfigId: 'k8s-production-config'
])

GitLab CI/CD

GitLab CI Configuration

# .gitlab-ci.yml
image: maven:3.8.6-eclipse-temurin-17

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version"
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"

cache:
  paths:
    - .m2/repository/
    - target/

stages:
  - validate
  - test
  - quality
  - build
  - security
  - deploy-staging
  - deploy-production

before_script:
  - apt-get update -y && apt-get install -y curl

# Validate stage
validate:
  stage: validate
  script:
    - mvn $MAVEN_CLI_OPTS validate compile

# Test stages
unit-tests:
  stage: test
  script:
    - mvn $MAVEN_CLI_OPTS test
  artifacts:
    when: always
    reports:
      junit:
        - target/surefire-reports/TEST-*.xml
    paths:
      - target/surefire-reports/
    expire_in: 1 week
  coverage: '/Total.*?([0-9]{1,3})%/'

integration-tests:
  stage: test
  services:
    - mysql:8.0
  variables:
    MYSQL_ROOT_PASSWORD: rootpassword
    MYSQL_DATABASE: testdb
    SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/testdb
    SPRING_DATASOURCE_USERNAME: root
    SPRING_DATASOURCE_PASSWORD: rootpassword
  script:
    - mvn $MAVEN_CLI_OPTS verify -DskipUnitTests
  artifacts:
    when: always
    reports:
      junit:
        - target/failsafe-reports/TEST-*.xml
    paths:
      - target/failsafe-reports/
    expire_in: 1 week

# Quality stage
code-quality:
  stage: quality
  script:
    - mvn $MAVEN_CLI_OPTS sonar:sonar
      -Dsonar.host.url=$SONAR_HOST_URL
      -Dsonar.login=$SONAR_TOKEN
      -Dsonar.projectKey=$CI_PROJECT_NAME
      -Dsonar.projectName="$CI_PROJECT_TITLE"
      -Dsonar.projectVersion=$CI_COMMIT_SHORT_SHA
      -Dsonar.sources=src/main/java
      -Dsonar.tests=src/test/java
      -Dsonar.java.binaries=target/classes
      -Dsonar.junit.reportPaths=target/surefire-reports
      -Dsonar.jacoco.reportPaths=target/jacoco.exec
  only:
    - main
    - develop
    - merge_requests

# Build stage
build:
  stage: build
  script:
    - mvn $MAVEN_CLI_OPTS package -DskipTests
    - echo "APPLICATION_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> build.env
  artifacts:
    paths:
      - target/*.jar
    reports:
      dotenv: build.env
    expire_in: 1 week

# Security stage
dependency-check:
  stage: security
  script:
    - mvn org.owasp:dependency-check-maven:check
  artifacts:
    when: always
    paths:
      - target/dependency-check-report.html
    expire_in: 1 week
  allow_failure: true

# Docker build
docker-build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  dependencies:
    - build
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main
    - develop

# Deploy to staging
deploy-staging:
  stage: deploy-staging
  image: alpine/k8s:latest
  environment:
    name: staging
    url: https://staging-api.company.com
  script:
    - kubectl config use-context staging
    - sed -i "s/<VERSION>/$CI_COMMIT_SHORT_SHA/g" k8s/staging/*.yaml
    - kubectl apply -f k8s/staging/
    - kubectl rollout status deployment/user-service -n staging
    - sleep 30
    - curl -f https://staging-api.company.com/actuator/health
  only:
    - develop

# Deploy to production
deploy-production:
  stage: deploy-production
  image: alpine/k8s:latest
  environment:
    name: production
    url: https://api.company.com
  when: manual
  script:
    - kubectl config use-context production
    - sed -i "s/<VERSION>/$CI_COMMIT_SHORT_SHA/g" k8s/production/*.yaml
    - kubectl apply -f k8s/production/
    - kubectl rollout status deployment/user-service -n production
    - sleep 60
    - curl -f https://api.company.com/actuator/health
  only:
    - main

Pipeline Best Practices

1. Pipeline as Code

  • Store pipeline configuration in version control
  • Use declarative syntax when possible
  • Make pipelines reproducible

2. Fast Feedback

  • Run fastest tests first
  • Parallel execution where possible
  • Fail fast on critical issues

3. Security Integration

  • Dependency vulnerability scanning
  • SAST (Static Application Security Testing)
  • Container image scanning
  • Secrets management

4. Quality Gates

  • Code coverage thresholds
  • Static analysis quality gates
  • Performance benchmarks
  • Security vulnerability limits

5. Environment Consistency

  • Use containers for consistent environments
  • Infrastructure as Code
  • Configuration management
  • Immutable deployments

6. Monitoring và Observability

  • Pipeline metrics và monitoring
  • Deployment tracking
  • Error alerting
  • Performance monitoring

CI/CD pipelines là backbone của modern software development, enabling teams to deliver high-quality software reliably và efficiently.