Windows 使用 Docker Compose + Jenkins 自动部署 Spring Boot 后端

本文以 joy-admin-backend 为例,将 Mac/Linux 流程迁移到 Windows 10/11。发布链路为:

Codeup -> Jenkins(Docker 容器) -> Docker BuildKit -> Docker Compose -> 后端容器

Jenkins 运行在 Docker Desktop 的 Linux 容器内,因此 Pipeline 中继续使用 Linux 的 sh;只有 Windows 主机命令使用 PowerShell。

1. 环境准备

安装 Windows 10 22H2 或 Windows 11、Docker Desktop 和 WSL2。Docker Desktop 的 Settings -> General 中开启 Use the WSL 2 based engine,并使用 Linux containers。

管理员 PowerShell:

wsl --install
wsl --update
wsl --set-default-version 2
wsl --version

重启后检查:

wsl --status
docker version
docker compose version
docker info --format 'OS={{.OSType}}, Arch={{.Architecture}}, CPUs={{.NCPU}}, Memory={{.MemTotal}}'
docker run --rm hello-world

验证构建工具:

docker run --rm eclipse-temurin:21-jdk java -version
docker run --rm maven:3.9.9-eclipse-temurin-21 mvn -version
docker run --rm node:16.20.2-bullseye sh -c "node -v && npm -v"

先手动创建统一工作目录 D:/work,然后使用 PowerShell 创建所需子目录:

New-Item -ItemType Directory -Force -Path 'D:/work/joy-admin-deploy/jenkins','D:/work/ssh','D:/work/joy-admin-backend-deploy/data'

WSL2 模式通常会自动处理 Windows 盘符挂载;如果出现 Mounts denied,根据 Docker Desktop 版本在 Settings -> Resources -> File sharing 或 Settings -> Resources -> WSL Integration 中加入 D:/work 后重启。检查端口:

Get-NetTCPConnection -LocalPort 8080,1024,9080 -State Listen -ErrorAction SilentlyContinue

规划:8080 Jenkins,1024 前端,9080 后端。

2. 配置 Codeup SSH

Windows 通常自带 OpenSSH Client。使用 PowerShell:

$SshDir = "D:/work/ssh"
New-Item -ItemType Directory -Force -Path $SshDir
ssh-keygen -t ed25519 -C 'jenkins-codeup' -f "$SshDir/joy-admin-jenkins-codeup"

将 joy-admin-jenkins-codeup.pub 内容添加到 Codeup 的个人设置 -> SSH 公钥。私钥只上传到 Jenkins。

获取并核对主机指纹:

$KnownHosts = "D:/work/joy-admin-deploy/jenkins/known_hosts"
ssh-keyscan -T 10 -t rsa codeup.aliyun.com | Set-Content -Encoding ascii $KnownHosts
ssh-keygen -lf $KnownHosts

必须核对阿里云 Codeup 官方 RSA 指纹:SHA256:yEGmgQNVrc3QAvDvoBrTCxxxxxxxxxx+AbWi9vSt/fE。不一致时不要继续使用该信任文件。

验证:

$Key = "D:/work/ssh/joy-admin-jenkins-codeup"
ssh -T -i $Key -o IdentitiesOnly=yes git@codeup.aliyun.com
$env:GIT_SSH_COMMAND = "ssh -i $Key -o IdentitiesOnly=yes"
git ls-remote git@codeup.aliyun.com:组织/仓库.git refs/heads/main
Remove-Item Env:GIT_SSH_COMMAND

3. 创建 Jenkins 容器

目录:

D:/work/joy-admin-deploy/
├── .env
├── compose.jenkins.yml
└── jenkins/
    ├── Dockerfile
    └── known_hosts

jenkins/Dockerfile:

FROM maven:3.9.9-eclipse-temurin-21 AS maven_tool
FROM node:16.20.2-bullseye AS node_tool
FROM jenkins/jenkins:lts-jdk21

USER root
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg git openssh-client rsync \
    && install -m 0755 -d /etc/apt/keyrings \
    && curl --retry 5 --retry-delay 3 --retry-all-errors -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
    && chmod a+r /etc/apt/keyrings/docker.asc \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
    && apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin docker-compose-plugin \
    && rm -rf /var/lib/apt/lists/*
COPY --from=maven_tool /usr/share/maven /usr/share/maven
COPY --from=node_tool /usr/local /usr/local
ENV MAVEN_HOME=/usr/share/maven
ENV PATH=/usr/share/maven/bin:/usr/local/bin:$PATH
RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh \
    && java -version && mvn -version && node -v && npm -v \
    && docker --version && docker buildx version && docker compose version
RUN jenkins-plugin-cli --plugins workflow-aggregator pipeline-stage-view git ssh-credentials ssh-agent credentials-binding docker-workflow timestamper ws-cleanup
USER root

docker-buildx-plugin 不能省略,因为后端 Dockerfile 使用 RUN --mount=type=cache,target=/root/.m2,该语法需要 BuildKit。

compose.jenkins.yml:

services:
  jenkins:
    build:
      context: ./jenkins
      dockerfile: Dockerfile
    image: joy-jenkins:lts-jdk21
    container_name: joy-jenkins
    user: root
    restart: unless-stopped
    ports:
      - "${JENKINS_BIND_ADDRESS}:${JENKINS_HTTP_PORT}:8080"
    environment:
      TZ: UTC
      JAVA_OPTS: >-
        -Duser.timezone=UTC
        -Djenkins.install.runSetupWizard=true
    volumes:
      - jenkins_home:/var/jenkins_home
      - jenkins_npm_cache:/root/.npm
      - /var/run/docker.sock:/var/run/docker.sock
      - ./jenkins/known_hosts:/root/.ssh/known_hosts:ro
volumes:
  jenkins_home:
    name: joy_jenkins_home
  jenkins_npm_cache:
    name: joy_jenkins_npm_cache

部署目录 .env:

JENKINS_BIND_ADDRESS=127.0.0.1
JENKINS_HTTP_PORT=8080

构建并启动:

Set-Location 'D:/work/joy-admin-deploy'
docker compose --env-file .env -f compose.jenkins.yml build --pull
docker compose --env-file .env -f compose.jenkins.yml up -d
docker compose --env-file .env -f compose.jenkins.yml ps
docker logs --tail 100 joy-jenkins
docker exec joy-jenkins cat /var/jenkins_home/secrets/initialAdminPassword

访问 http://127.0.0.1:8080 完成 Jenkins 初始化。验证工具:

docker exec joy-jenkins bash -lc 'java -version; mvn -version; node -v; npm -v; git --version; docker version; docker buildx version; docker compose version'
docker exec joy-jenkins ssh-keygen -lf /root/.ssh/known_hosts

4. Jenkins 凭据

进入 Manage Jenkins -> Credentials -> System -> Global credentials。

Codeup 凭据:

Kind:SSH Username with private key
Username:git
Private Key:joy-admin-jenkins-codeup(不带 .pub)
ID:codeup-ssh

后端环境文件作为 Secret file 上传,ID 为 joy-admin-backend-env。示例:

# Docker image and host binding
JOY_ADMIN_IMAGE=joy-admin-backend
JOY_ADMIN_IMAGE_TAG=latest
JOY_ADMIN_BIND_ADDRESS=0.0.0.0
JOY_ADMIN_HOST_PORT=9080

# Docker宿主机公共工作根目录
HOST_MACHINE_ROOT=D:/work/joy-admin-backend-deploy
# Docker公共工作根目录
DOCKER_ROOT=D:/work/joy-admin-backend-deploy

# JVM. Adjust these percentages to the server capacity.
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=25.0 -XX:+ExitOnOutOfMemoryError -Djava.io.tmpdir=${DOCKER_ROOT}/tmp

# Business calendar. JVM/database timestamps remain UTC; business dates use this zone.
BUSINESS_TIME_ZONE=Asia/Kolkata

# Remote PostgreSQL. Use a private IP or internal DNS name.
# For TLS, use sslmode=require or preferably verify-full with the required certificates.
DB_URL=jdbc:postgresql://192.168.1.114:5432/onevone?sslmode=disable&connectTimeout=10&socketTimeout=120&tcpKeepAlive=true
DB_USER=onevone
DB_PASSWORD=onevone
DB_POOL_INITIAL_SIZE=5
DB_POOL_MIN_IDLE=10
DB_POOL_MAX_ACTIVE=20

# Remote Redis. Never expose Redis directly to the public internet.
REDIS_HOST=host.docker.internal
REDIS_PORT=6379
REDIS_DATABASE=3
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_SSL=false
REDIS_TIMEOUT=10s

# Quartz uses the PostgreSQL connection above.
QUARTZ_INSTANCE_NAME=QuartzScheduler-admin-production
QUARTZ_CLUSTERED=true
QUARTZ_THREAD_COUNT=10
QUARTZ_DB_MAX_CONNECTIONS=10

# Application security. Generate a long random value for production.
TOKEN_SECRET=replace_with_a_long_random_secret
TOKEN_EXPIRE_MINUTES=43200

# Upstream service called by joy-admin.
UPSTREAM_SERVICE_URL=http://localhost:9080
UPSTREAM_SERVICE_SIGNATURE=replace_me

# Optional external integrations. Leave blank only when the feature is unused.
OSS_PREFIX_CDN=
OSS_PREFIX_OSS=
OSS_PREFIX_HTTP=
OSS_BUCKET_NAME=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_ENDPOINT=
FEISHU_WEBHOOK_URL=

# Production switches
SWAGGER_ENABLED=false
DRUID_CONSOLE_ENABLED=false
DRUID_CONSOLE_USERNAME=
DRUID_CONSOLE_PASSWORD=

# 开放域名CORS跨域
CORS_ALLOWED_ORIGINS=https://xxx.com

# Docker宿主机持久化目录
# JOY_ADMIN_LOG_DIR:Docker宿主机路径
JOY_ADMIN_DATA_DIR=${HOST_MACHINE_ROOT}/data
JOY_ADMIN_LOG_DIR=${HOST_MACHINE_ROOT}/logs

# Docker持久化目录
# DOCKER_LOG_DIR:Docker路径
DOCKER_DATA_DIR=${DOCKER_ROOT}/data
DOCKER_LOG_DIR=${DOCKER_ROOT}/logs

# Docker容器内部日志路径
# APP_LOG_FILE:容器内部Linux路径
APP_LOG_FILE=${DOCKER_LOG_DIR}/joy-admin.log
APP_LOG_FILE_PATTERN=${DOCKER_LOG_DIR}/archive/joy-admin.%d{yyyy-MM-dd}.%i.log.gz
APP_LOG_LEVEL=info
APP_LOG_MAX_FILE_SIZE=100MB
APP_LOG_MAX_HISTORY=30
APP_LOG_TOTAL_SIZE_CAP=3GB

如果数据库或 Redis 在 Windows 主机,容器内使用 host.docker.internal,不能使用 127.0.0.1。流水线结束后应删除工作区中的 backend/.env。

5. Pipeline 发布

创建 New Item -> joy-admin-backend -> Pipeline。沿用原 Mac/Linux Pipeline Script,只替换 Codeup 仓库地址:

git clone --depth 1 --branch "$GIT_BRANCH" \
  git@codeup.aliyun.com:组织/仓库.git backend

必须保留 codeup-ssh、joy-admin-backend-env、DOCKER_BUILDKIT=1、BUILD_NUMBER-GIT_SHORT 镜像标签、docker compose build --pull、docker compose up --wait --wait-timeout 300、健康检查失败自动回滚、ACTION=ROLLBACK 使用已有镜像,以及 post 阶段删除 .env 和工作区。

不要将 Pipeline 中的 sh 改成 bat。Pipeline 在 Linux Jenkins 容器中执行。

完整 Pipeline Script

以下脚本可以直接粘贴到 Jenkins,替换仓库地址即可:

pipeline {
    agent any

    parameters {
        choice(
            name: 'ACTION',
            choices: ['DEPLOY', 'ROLLBACK'],
            description: '发布新版本或回滚旧版本'
        )
        string(name: 'GIT_BRANCH', defaultValue: 'main', description: '构建分支')
        string(name: 'ROLLBACK_TAG', defaultValue: '', description: '回滚标签,例如15-a8c37f21')
    }

    options {
        timestamps()
        timeout(time: 60, unit: 'MINUTES')
        disableConcurrentBuilds()
        skipDefaultCheckout(true)
        buildDiscarder(logRotator(numToKeepStr: '30'))
    }

    environment {
        IMAGE_NAME = 'joy-admin-backend'
        DOCKER_BUILDKIT = '1'
        TZ = 'UTC'
    }

    stages {
        stage('拉取代码') {
            steps {
                deleteDir()
                sshagent(credentials: ['codeup-ssh']) {
                    sh '''
                        set -eu
                        export GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/root/.ssh/known_hosts"
                        git clone --depth 1 --branch "$GIT_BRANCH" \
                          git@codeup.aliyun.com:组织/仓库.git backend
                    '''
                }
                script {
                    env.GIT_SHORT = sh(
                        script: 'git -C backend rev-parse --short=8 HEAD',
                        returnStdout: true
                    ).trim()
                    env.NEW_TAG = "${env.BUILD_NUMBER}-${env.GIT_SHORT}"
                }
            }
        }

        stage('准备环境配置') {
            steps {
                withCredentials([
                    file(credentialsId: 'joy-admin-backend-env', variable: 'BACKEND_ENV_FILE')
                ]) {
                    sh '''
                        set -eu
                        install -m 600 "$BACKEND_ENV_FILE" backend/.env
                    '''
                }
            }
        }

        stage('执行发布或回滚') {
            steps {
                dir('backend') {
                    script {
                        if (params.ACTION == 'ROLLBACK') {
                            if (!params.ROLLBACK_TAG.trim()) {
                                error('回滚必须填写ROLLBACK_TAG')
                            }
                            env.TARGET_TAG = params.ROLLBACK_TAG.trim()
                            sh '''
                                set -eu
                                export JOY_ADMIN_IMAGE="$IMAGE_NAME"
                                export JOY_ADMIN_IMAGE_TAG="$TARGET_TAG"

                                docker image inspect "$IMAGE_NAME:$TARGET_TAG" >/dev/null
                                docker compose up -d --no-build --force-recreate \
                                  --wait --wait-timeout 300 joy-admin

                                CONTAINER_ID="$(docker compose ps -q joy-admin)"
                                docker exec "$CONTAINER_ID" curl --fail --silent \
                                  http://127.0.0.1:9080/ >/dev/null
                                echo "回滚成功:$IMAGE_NAME:$TARGET_TAG"
                            '''
                        } else {
                            env.TARGET_TAG = env.NEW_TAG
                            sh '''
                                set -u
                                export JOY_ADMIN_IMAGE="$IMAGE_NAME"
                                export JOY_ADMIN_IMAGE_TAG="$TARGET_TAG"

                                CURRENT_ID="$(docker compose ps -q joy-admin 2>/dev/null || true)"
                                PREVIOUS_IMAGE=""
                                if [ -n "$CURRENT_ID" ]; then
                                    PREVIOUS_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$CURRENT_ID")"
                                fi

                                docker compose config >/dev/null || exit 1
                                docker compose build --pull joy-admin || exit 1
                                docker image inspect "$IMAGE_NAME:$TARGET_TAG" >/dev/null || exit 1

                                DEPLOY_OK=false
                                if docker compose up -d --no-build \
                                  --wait --wait-timeout 300 joy-admin
                                then
                                    CONTAINER_ID="$(docker compose ps -q joy-admin)"
                                    if docker exec "$CONTAINER_ID" curl --fail --silent \
                                      http://127.0.0.1:9080/ >/dev/null
                                    then
                                        DEPLOY_OK=true
                                    fi
                                fi

                                if [ "$DEPLOY_OK" = "true" ]; then
                                    echo "发布成功:$IMAGE_NAME:$TARGET_TAG"
                                    docker inspect \
                                      --format='镜像={{.Config.Image}} 健康={{.State.Health.Status}}' \
                                      "$CONTAINER_ID"
                                    exit 0
                                fi

                                echo "新版本启动失败"
                                docker compose logs --tail=200 joy-admin || true

                                if [ -z "$PREVIOUS_IMAGE" ]; then
                                    echo "首次发布,没有旧版本可回滚"
                                    exit 1
                                fi

                                if ! docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then
                                    echo "旧镜像不存在:$PREVIOUS_IMAGE"
                                    exit 1
                                fi

                                PREVIOUS_TAG="${PREVIOUS_IMAGE##*:}"
                                export JOY_ADMIN_IMAGE_TAG="$PREVIOUS_TAG"

                                if docker compose up -d --no-build --force-recreate \
                                  --wait --wait-timeout 300 joy-admin
                                then
                                    echo "已自动回滚:$PREVIOUS_IMAGE"
                                else
                                    echo "自动回滚失败,需要人工处理"
                                fi
                                exit 1
                            '''
                        }
                    }
                }
            }
        }
    }

    post {
        always {
            sh 'rm -f backend/.env 2>/dev/null || true'
            deleteDir()
        }
    }
}

首次发布参数:

ACTION=DEPLOY
GIT_BRANCH=main
ROLLBACK_TAG=留空

6. 验证和回滚

docker ps --filter "name=joy-admin"
docker images joy-admin-backend
docker logs --tail 200 (docker ps -q --filter "name=joy-admin")
docker inspect --format '{{.Config.Image}}' (docker ps -q --filter "name=joy-admin")
docker inspect --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}' (docker ps -q --filter "name=joy-admin")

回滚参数:

ACTION=ROLLBACK
GIT_BRANCH=main
ROLLBACK_TAG=15-a8c37f21

回滚只切换已有镜像,不回滚数据库。

7. 常见问题

挂载目录失败

Test-Path 'D:/work/joy-admin-backend-deploy/data'

确认 Docker Desktop 文件共享设置、目录存在,且没有使用网络映射盘或复杂特殊字符路径。发布后还要通过 docker inspect 确认 /app/data 的 Source 指向预期 Windows 目录。

这是 Jenkins 容器通过 Docker Socket 调用 Docker Desktop daemon 的场景,挂载源必须能被 Docker Desktop daemon 识别。若日志提示 invalid mount config 或 /Users/mac/Documents/work/joy-admin-backend-deploy/data 没有指向 D:/work,可在后端仓库增加 compose.windows.yml:

services:
  joy-admin:
    volumes:
      - joy_admin_backend_data:/Users/mac/Documents/work/joy-admin-backend-deploy/data

volumes:
  joy_admin_backend_data:
    name: joy_admin_backend_data

然后将 Pipeline 的每个命令改为 docker compose -f compose.yml -f compose.windows.yml <subcommand>,并从后端环境文件中删除 JOY_ADMIN_DATA_DIR。named volume 由 Docker Desktop 管理,不依赖 Jenkins 容器解析 Windows 盘符。

Jenkins 找不到 Docker

docker exec joy-jenkins docker version
docker exec joy-jenkins docker buildx version

确认 Docker Desktop 正在运行,并保留 /var/run/docker.sock 挂载。

Codeup 认证失败

确认 Jenkins 上传的是私钥而不是 .pub,known_hosts 包含 codeup.aliyun.com,Pipeline 凭据 ID 为 codeup-ssh。

数据库或 Redis 连接失败

容器中的 127.0.0.1 指向容器自身。Windows 主机服务使用 host.docker.internal,远程服务使用局域网 IP 或 DNS。不要将数据库和 Redis 暴露到公网。

/prod-api 返回 403 或 404

Cloudflare Tunnel 不会自动删除 /prod-api。Tunnel 直接指向后端 9080 时,应让 Nginx 转发:

location /prod-api/ {
    proxy_pass http://127.0.0.1:9080/;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

跨域部署时,后端环境文件必须配置准确的 CORS_ALLOWED_ORIGINS。

8. 迁移和维护

迁移到另一台 Windows:安装 Docker Desktop 和 WSL2,创建相同目录,复制 Compose、Dockerfile、known_hosts,重新配置两个 Jenkins 凭据,然后执行 DEPLOY。流水线每次从 Codeup 拉取代码,不依赖本地项目目录。

安全注意事项:

  • .env 和 SSH 私钥禁止提交 Git;
  • Jenkins 只绑定 127.0.0.1,不直接暴露公网;
  • Jenkins 挂载 Docker Socket 后拥有宿主机 Docker 管理权限;
  • 定期备份 joy_jenkins_home 和业务数据;
  • 保留最近 5 至 10 个镜像,不要随意执行 docker system prune -a;
  • 镜像回滚不会回滚数据库;
  • 跨机器保留镜像时使用私有 Docker Registry。
docker system df

参考:Jenkins DockerJenkins PipelineDocker ComposeCodeup SSH

更多推荐