Jenkins sigue siendo una de las herramientas de CI/CD mas utilizadas en la industria. Aunque han surgido alternativas modernas como GitHub Actions, GitLab CI o Tekton, Jenkins mantiene su relevancia por su flexibilidad extrema, su ecosistema de mas de 1800 plugins y su capacidad de adaptarse a practicamente cualquier flujo de trabajo. Sin embargo, esa flexibilidad viene con complejidad: un Jenkins mal configurado puede convertirse en un cuello de botella operativo. Esta guía cubre la arquitectura, la escritura de pipelines, la integración con Docker y Kubernetes, y las prácticas que separan un Jenkins productivo de uno problematico.

Arquitectura de Jenkins

Jenkins utiliza una arquitectura controller-agent (anteriormente llamada master-slave) donde el controller coordina y los agents ejecutan.

Controller (Master)

El controller es el cerebro de Jenkins. Se encarga de:

  • Servir la interfaz web y la API REST.
  • Gestionar la configuración de jobs y pipelines.
  • Programar builds y asignarlos a agents.
  • Almacenar resultados, logs y artefactos.

En producción, el controller no debe ejecutar builds directamente. Esto consume recursos y es un riesgo de seguridad, ya que los builds podrian acceder a la configuración de Jenkins.

Agents (Nodos)

Los agents son maquinas (fisicas, VMs o contenedores) que ejecutan los builds. Se conectan al controller via:

  • SSH: El controller se conecta al agent por SSH. Simple y confiable.
  • JNLP (Java Web Start): El agent inicia la conexión hacia el controller. Util cuando el agent esta detras de un firewall.
  • Kubernetes plugin: Crea pods efimeros en un cluster de K8s para cada build. Ideal para escalabilidad.

Jenkinsfile: declarativo vs scripted

El Jenkinsfile define el pipeline como código. Existen dos sintaxis:

Pipeline declarativo

Es la forma recomendada. Estructura clara y predecible:

pipeline {
    agent {
        docker {
            image 'node:20-alpine'
            args '-v /tmp:/tmp'
        }
    }

    environment {
        APP_ENV = 'production'
        REGISTRY = 'mi-registry.com/mi-app'
    }

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

    stages {
        stage('Install') {
            steps {
                sh 'npm ci'
            }
        }

        stage('Lint') {
            steps {
                sh 'npm run lint'
            }
        }

        stage('Test') {
            steps {
                sh 'npm test -- --coverage'
            }
            post {
                always {
                    junit 'reports/junit.xml'
                    publishHTML(target: [
                        reportDir: 'coverage/lcov-report',
                        reportFiles: 'index.html',
                        reportName: 'Coverage Report'
                    ])
                }
            }
        }

        stage('Build Image') {
            when {
                branch 'main'
            }
            steps {
                script {
                    def tag = "${env.BUILD_NUMBER}-${env.GIT_COMMIT.take(7)}"
                    sh "docker build -t ${REGISTRY}:${tag} ."
                    sh "docker push ${REGISTRY}:${tag}"
                }
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                sh "kubectl set image deployment/mi-app mi-app=${REGISTRY}:${tag}"
            }
        }
    }

    post {
        failure {
            slackSend(channel: '#deploys', message: "Build FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
        }
        success {
            slackSend(channel: '#deploys', message: "Build OK: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
        }
    }
}

Pipeline scripted

Ofrece control total con Groovy, pero es mas difícil de mantener:

node('docker') {
    try {
        stage('Checkout') {
            checkout scm
        }

        stage('Build') {
            docker.image('maven:3.9-eclipse-temurin-17').inside {
                sh 'mvn clean package -DskipTests'
            }
        }

        stage('Test') {
            docker.image('maven:3.9-eclipse-temurin-17').inside {
                sh 'mvn test'
            }
        }

        if (env.BRANCH_NAME == 'main') {
            stage('Deploy') {
                withCredentials([usernamePassword(
                    credentialsId: 'registry-creds',
                    usernameVariable: 'USER',
                    passwordVariable: 'PASS'
                )]) {
                    sh "docker login -u $USER -p $PASS mi-registry.com"
                    sh "docker build -t mi-registry.com/app:${BUILD_NUMBER} ."
                    sh "docker push mi-registry.com/app:${BUILD_NUMBER}"
                }
            }
        }
    } catch (e) {
        currentBuild.result = 'FAILURE'
        throw e
    }
}

Usa declarativo para pipelines estandar y scripted solo cuando necesites lógica compleja que el declarativo no soporta.

Shared Libraries

Las shared libraries permiten reutilizar código de pipeline entre multiples proyectos. Es la forma correcta de evitar duplicación cuando tienes decenas o cientos de Jenkinsfiles.

Estructura de una shared library

jenkins-shared-lib/
  vars/
    standardPipeline.groovy    # Funciones globales
    dockerBuild.groovy
    notifySlack.groovy
  src/
    com/miempresa/
      DeployHelper.groovy      # Clases Groovy
  resources/
    templates/
      deployment.yaml          # Archivos de recursos

Ejemplo de una función global

// vars/standardPipeline.groovy
def call(Map config) {
    pipeline {
        agent { docker { image config.buildImage ?: 'node:20' } }

        stages {
            stage('Test') {
                steps {
                    sh config.testCommand ?: 'npm test'
                }
            }
            stage('Build') {
                steps {
                    sh config.buildCommand ?: 'npm run build'
                }
            }
            stage('Deploy') {
                when { branch 'main' }
                steps {
                    sh "kubectl apply -f k8s/"
                }
            }
        }
    }
}

Uso en un Jenkinsfile

@Library('mi-shared-lib') _

standardPipeline(
    buildImage: 'node:20-alpine',
    testCommand: 'npm run test:ci',
    buildCommand: 'npm run build'
)

Con una shared library bien diseñada, un Jenkinsfile nuevo puede ser de 5 lineas.

Integración con Docker

Jenkins se integra con Docker de varias formas:

  • Docker como agent: Cada stage corre en un contenedor limpio. Garantiza reproducibilidad y aislamiento.
  • Docker-in-Docker (DinD): El agent corre dentro de Docker y puede construir imagenes. Util pero tiene implicaciones de seguridad.
  • Docker socket mount: Montar /var/run/docker.sock en el agent. Mas simple que DinD pero comparte el daemon del host.

Para construir imagenes de forma segura, considera herramientas como Kaniko que no requieren acceso al daemon de Docker.

Jenkins en Kubernetes

El plugin de Kubernetes para Jenkins permite crear agents dinamicos como pods efimeros:

pipeline {
    agent {
        kubernetes {
            yaml """
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: maven
    image: maven:3.9-eclipse-temurin-17
    command: ['sleep', 'infinity']
  - name: docker
    image: docker:24-dind
    securityContext:
      privileged: true
    env:
    - name: DOCKER_TLS_CERTDIR
      value: ""
"""
        }
    }

    stages {
        stage('Build') {
            steps {
                container('maven') {
                    sh 'mvn clean package'
                }
            }
        }
        stage('Docker Build') {
            steps {
                container('docker') {
                    sh 'docker build -t mi-app:latest .'
                }
            }
        }
    }
}

Cada build obtiene un pod nuevo y limpio. Cuando termina, el pod se destruye. Esto elimina el problema de “builds contaminados” por estado residual en agents permanentes.

Seguridad en Jenkins

Gestión de credenciales

Nunca hardcodees credenciales en el Jenkinsfile. Usa el Credentials Store:

withCredentials([
    string(credentialsId: 'aws-secret-key', variable: 'AWS_SECRET_ACCESS_KEY'),
    file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG')
]) {
    sh 'aws s3 ls'
    sh 'kubectl get pods'
}

Prácticas de seguridad esenciales

  • Habilita la autorización basada en roles (Role-Based Strategy plugin).
  • Restringe que plugins pueden usarse en pipelines (Script Security plugin).
  • Ejecuta builds en agents aislados, nunca en el controller.
  • Mantiene Jenkins y sus plugins actualizados. Las vulnerabilidades en plugins son frecuentes.
  • Habilita audit logging para rastrear cambios en configuración y ejecución de builds.
  • Deshabilita el acceso anonimo y configura autenticación via LDAP, SAML u OIDC.

Jenkins vs alternativas modernas

AspectoJenkinsGitHub ActionsGitLab CI
HostingSelf-hosted (o CloudBees)SaaS (o self-hosted runners)SaaS o self-hosted
ConfiguraciónJenkinsfile (Groovy)YAMLYAML
Plugins1800+Marketplace de ActionsFeatures integradas
EscalabilidadManual (K8s plugin ayuda)AutomaticaAutomatica
Curva de aprendizajeAltaBaja-mediaMedia
FlexibilidadMaximaAltaAlta
MantenimientoAlto (tu lo operas)BajoBajo-medio

Jenkins sigue siendo la mejor opción cuando necesitas control total sobre la infraestructura de CI/CD, tienes requisitos de compliance que impiden usar SaaS, o tu organización ya tiene una inversion significativa en pipelines y shared libraries de Jenkins.

Conclusion

Jenkins es una herramienta poderosa que recompensa la configuración cuidadosa. La clave para operarlo de forma sostenible es tratar los pipelines como código (Jenkinsfile en el repositorio), centralizar lógica comun en shared libraries, usar agents efimeros en Kubernetes para escalabilidad y aislamiento, y mantener una postura de seguridad estricta con gestión adecuada de credenciales. Aunque las alternativas modernas ofrecen menor complejidad operativa, Jenkins sigue siendo insustituible en escenarios que demandan flexibilidad extrema y control total.

Recursos adicionales