Windows 使用 Docker Jenkins 自动部署 Vue 前端项目

本文以 joy-admin-frontend 为例,将 Mac/Linux 流程迁移到 Windows 10/11。前端使用 Vue 2、Vue CLI 4、webpack 4,构建环境固定为 Node.js 16.20.2、npm 8.19.4。

发布链路:

Codeup -> Jenkins Linux 容器构建 dist
       -> 共享挂载写入 Windows D:/work 发布目录
       -> Windows OpenSSH 执行 PowerShell 切换 current
       -> Windows Nginx 检查并 reload
       -> HTTP 验证失败时恢复旧版本

Jenkins 在 Docker Desktop 的 Linux 容器内运行,所以 Pipeline 使用 sh;Windows 主机命令使用 PowerShell。

一、目录和 Nginx

先手动创建 D:/work等目录,PowerShell执行:

New-Item -ItemType Directory -Force -Path 'D:/work/joy-admin-frontend-deploy/releases','D:/work/joy-admin-frontend-deploy/scripts','D:/work/joy-admin-deploy/jenkins','D:/work/nginx','D:/work/ssh'
New-Item -ItemType File -Force -Path 'D:/work/joy-admin-deploy/jenkins/known_hosts' | Out-Null

目录结构:

D:/work/
├── joy-admin-frontend-deploy/
│   ├── releases/
│   └── scripts/
├── nginx/
│   ├── nginx.exe
│   └── conf/
└── ssh/

将 Windows 版 Nginx 解压到 D:/work/nginx,检查:

Test-Path 'D:/work/nginx/nginx.exe'
Test-Path 'D:/work/nginx/conf/nginx.conf'

在 nginx.conf 的 http 块中加入:

include D:/work/nginx/conf/nginx-joy-admin.conf;

创建 nginx-joy-admin.conf:

server {
    listen 80;
    server_name dev.joy.admin.com;

    root D:/work/joy-admin-frontend-deploy/current;
    index index.html;

    location /prod-api/ {
        proxy_pass http://127.0.0.1:9080/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 10s;
        proxy_read_timeout 60s;
    }

    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    location /static/ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800, immutable";
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }

    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}

检查和启动 Nginx:

D:/work/nginx/nginx.exe -t -p D:/work/nginx -c D:/work/nginx/conf/nginx.conf
Start-Process -FilePath 'D:/work/nginx/nginx.exe' -WorkingDirectory 'D:/work/nginx'

已运行时只 reload:

D:/work/nginx/nginx.exe -s reload -p D:/work/nginx -c D:/work/nginx/conf/nginx.conf

本机测试域名时,管理员 PowerShell 执行:

Add-Content -Path 'C:/Windows/System32/drivers/etc/hosts' -Value '127.0.0.1 dev.joy.admin.com'
ipconfig /flushdns

二、Windows OpenSSH

Jenkins 通过 SSH 调用 Windows PowerShell 和 Nginx,不使用 rsync 传输文件;文件由 Docker Desktop 共享挂载提供。

管理员 PowerShell 安装并启动 OpenSSH Server:

Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
if (-not (Get-NetFirewallRule -Name sshd -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 }

创建普通部署用户,用户名是jenkinsdeploy:

$Password = Read-Host '输入 jenkinsdeploy 密码' -AsSecureString
New-LocalUser -Name 'jenkinsdeploy' -Password $Password -Description 'Jenkins frontend deployment user'
Add-LocalGroupMember -Group 'Users' -Member 'jenkinsdeploy'

在 Windows宿主机生成密钥,管理员 PowerShell 执行:

ssh-keygen -t ed25519 -C 'jenkins-nginx-deploy' -f 'D:/work/ssh/jenkins-nginx-host'

将公钥加入宿主机授权文件,管理员 PowerShell 执行:

$SshDir = 'C:/Users/jenkinsdeploy/.ssh'
New-Item -ItemType Directory -Force -Path $SshDir | Out-Null
Copy-Item 'D:/work/ssh/jenkins-nginx-host.pub' "$SshDir/authorized_keys" -Force

测试,管理员 PowerShell 执行:

ssh -i D:/work/ssh/jenkins-nginx-host -o IdentitiesOnly=yes jenkinsdeploy@127.0.0.1 'whoami'

输出 jenkinsdeploy 即可。Nginx 最好由该部署用户启动,确保该用户可以执行 reload。

三、修改 Jenkins Compose 挂载

在 Windows Jenkins Compose(compose.jenkins.yml) 的 jenkins 服务 volumes 增加:

- "D:/work/joy-admin-frontend-deploy:/frontend-deploy"

完整内容参考:

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
  - "D:/work/joy-admin-frontend-deploy:/frontend-deploy"

重新创建Jenkins docker容器:

Set-Location 'D:/work/joy-admin-deploy'
docker compose --env-file .env -f compose.jenkins.yml up -d --force-recreate
docker exec joy-jenkins test -d /frontend-deploy/releases

Jenkins 容器能访问 /frontend-deploy/releases 才能继续。

从 Jenkins 容器扫描 Windows OpenSSH 主机密钥,写入宿主机挂载文件:

docker exec joy-jenkins ssh-keyscan -T 10 -H -t ed25519,rsa host.docker.internal | Set-Content -Encoding ascii 'D:/work/joy-admin-deploy/jenkins/known_hosts'
docker exec joy-jenkins ssh-keygen -lf /root/.ssh/known_hosts

确认指纹和 Windows 主机一致后再继续。known_hosts 文件通过挂载实时提供给 Jenkins。

四、创建 current 切换脚本

创建 D:/work/joy-admin-frontend-deploy/scripts/switch-current.ps1:

param([Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9._-]+$')][string]$Tag)

$ErrorActionPreference = 'Stop'
$Root = 'D:/work/joy-admin-frontend-deploy'
$NginxExe = 'D:/work/nginx/nginx.exe'
$NginxPrefix = 'D:/work/nginx'
$NginxConf = 'D:/work/nginx/conf/nginx.conf'
$Release = Join-Path $Root "releases/$Tag"
$Current = Join-Path $Root 'current'
$Previous = Join-Path $Root 'current.previous'
$PreviousTagFile = Join-Path $Root 'previous-tag.txt'

if (-not (Test-Path (Join-Path $Release 'index.html'))) { throw "发布目录不存在: $Release" }

$PreviousTag = ''
if (Test-Path $Current) {
    $Link = Get-Item -LiteralPath $Current
    $LinkTarget = $Link.Target
    if (-not $LinkTarget) { $LinkTarget = $Link.LinkTarget }
    if ($LinkTarget) { $PreviousTag = Split-Path -Leaf $LinkTarget }
}
Set-Content -Path $PreviousTagFile -Value $PreviousTag -Encoding ascii

if (Test-Path $Previous) { Remove-Item -LiteralPath $Previous -Force }
if (Test-Path $Current) { Move-Item -LiteralPath $Current -Destination $Previous -Force }

try {
    New-Item -ItemType Junction -Path $Current -Target $Release | Out-Null
    & $NginxExe -t -p $NginxPrefix -c $NginxConf
    if ($LASTEXITCODE -ne 0) { throw 'Nginx 配置检查失败' }
    & $NginxExe -s reload -p $NginxPrefix -c $NginxConf
    if ($LASTEXITCODE -ne 0) { throw 'Nginx reload 失败' }
    Write-Output "切换成功: $Tag"
    exit 0
}
catch {
    if (Test-Path $Current) { Remove-Item -LiteralPath $Current -Force }
    if ($PreviousTag) {
        $OldRelease = Join-Path $Root "releases/$PreviousTag"
        New-Item -ItemType Junction -Path $Current -Target $OldRelease | Out-Null
        & $NginxExe -s reload -p $NginxPrefix -c $NginxConf | Out-Null
    }
    Write-Error $_
    exit 1
}

五、Jenkins 凭据和 Pipeline

Jenkins 中配置:

codeup-ssh       Codeup 代码拉取私钥
nginx-host-ssh    jenkinsdeploy 用户的 Windows SSH 私钥

nginx-host-ssh 凭据填写:

Kind:SSH Username with private key
Username:jenkinsdeploy
Private Key:D:/work/ssh/jenkins-nginx-host 的私钥内容
ID:nginx-host-ssh

创建 New Item -> joy-admin-frontend -> Pipeline。完整脚本如下,替换 Codeup 仓库地址:

pipeline {
    agent any

    parameters {
        choice(name: 'ACTION', choices: ['DEPLOY', 'ROLLBACK'], description: '发布或回滚')
        string(name: 'GIT_BRANCH', defaultValue: 'main', description: '前端分支')
        string(name: 'FRONTEND_URL', defaultValue: 'http://dev.joy.admin.com', description: '前端访问地址')
        string(name: 'ROLLBACK_TAG', defaultValue: '', description: '回滚版本')
    }

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

    environment {
        DEPLOY_HOST = 'host.docker.internal'
        DEPLOY_USER = 'jenkinsdeploy'
        DEPLOY_ROOT = '/frontend-deploy'
        HOST_DEPLOY_ROOT = 'D:/work/joy-admin-frontend-deploy'
        FRONTEND_HOST = 'dev.joy.admin.com'
        NGINX_PORT = '80'
        SWITCH_SCRIPT = 'D:/work/joy-admin-frontend-deploy/scripts/switch-current.ps1'
        NODE_OPTIONS = '--max-old-space-size=4096'
    }

    stages {
        stage('拉取代码') {
            when { expression { params.ACTION == 'DEPLOY' } }
            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 frontend
                    '''
                }
                script {
                    env.GIT_SHORT = sh(script: 'git -C frontend rev-parse --short=8 HEAD', returnStdout: true).trim()
                    env.RELEASE_TAG = "${env.BUILD_NUMBER}-${env.GIT_SHORT}"
                }
            }
        }

        stage('构建前端') {
            when { expression { params.ACTION == 'DEPLOY' } }
            steps {
                dir('frontend') {
                    sh '''
                        set -eu
                        printf '%s\n' "VUE_APP_BASE_API=/prod-api" "VUE_APP_FRONT_URL=$FRONTEND_URL" > .env.production.local
                        node -v
                        npm -v
                        npm ci --no-audit --no-fund
                        npm run build:prod
                        test -f dist/index.html
                        test -d dist/static
                    '''
                }
            }
        }

        stage('发布或回滚') {
            steps {
                sshagent(credentials: ['nginx-host-ssh']) {
                    script {
                        if (params.ACTION == 'ROLLBACK') {
                            if (!params.ROLLBACK_TAG.trim()) { error('回滚必须填写 ROLLBACK_TAG') }
                            env.TARGET_TAG = params.ROLLBACK_TAG.trim()
                        } else {
                            env.TARGET_TAG = env.RELEASE_TAG
                        }

                        sh '''
                            set -eu
                            case "$TARGET_TAG" in *[!A-Za-z0-9._-]*) echo "非法版本标签"; exit 1 ;; esac
                            RELEASE_DIR="/frontend-deploy/releases/$TARGET_TAG"
                            REMOTE="$DEPLOY_USER@$DEPLOY_HOST"
                            SSH_OPTS="-o PreferredAuthentications=publickey -o PasswordAuthentication=no -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/root/.ssh/known_hosts"

                            if [ "$ACTION" = "DEPLOY" ]; then
                                rm -rf "$RELEASE_DIR"
                                mkdir -p "$RELEASE_DIR"
                                cp -a frontend/dist/. "$RELEASE_DIR/"
                            else
                                ssh $SSH_OPTS "$REMOTE" "powershell.exe -NoProfile -NonInteractive -Command \"if (-not (Test-Path '$HOST_DEPLOY_ROOT/releases/$TARGET_TAG/index.html')) { exit 1 }\""
                            fi

                            ssh $SSH_OPTS "$REMOTE" "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $SWITCH_SCRIPT -Tag $TARGET_TAG"

                            if curl --fail --silent --show-error -H "Host: $FRONTEND_HOST" "http://$DEPLOY_HOST:$NGINX_PORT/" >/dev/null; then
                                echo "前端发布成功: $TARGET_TAG"
                                exit 0
                            fi

                            PREVIOUS_TAG="$(ssh $SSH_OPTS "$REMOTE" "powershell.exe -NoProfile -NonInteractive -Command \"(Get-Content '$HOST_DEPLOY_ROOT/previous-tag.txt')\"" | tr -d '\r')"
                            if [ -n "$PREVIOUS_TAG" ]; then
                                ssh $SSH_OPTS "$REMOTE" "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $SWITCH_SCRIPT -Tag $PREVIOUS_TAG"
                            fi
                            exit 1
                        '''
                    }
                }
            }
        }
    }

    post {
        always { deleteDir() }
    }
}

Pipeline 中继续使用 sh,因为 Jenkins 运行在 Linux 容器内。

六、发布、回滚和验证

首次发布参数:

ACTION=DEPLOY
GIT_BRANCH=main
FRONTEND_URL=http://dev.joy.admin.com
ROLLBACK_TAG=留空

版本目录格式:

D:/work/joy-admin-frontend-deploy/releases/构建号-Git短Commit

回滚参数:

ACTION=ROLLBACK
ROLLBACK_TAG=需要恢复的版本目录名

Windows 验证:

Get-Item 'D:/work/joy-admin-frontend-deploy/current' | Format-List *
curl.exe -I -H 'Host: dev.joy.admin.com' http://127.0.0.1/
curl.exe -I -H 'Host: dev.joy.admin.com' http://127.0.0.1/dashboard
curl.exe -i -H 'Host: dev.joy.admin.com' http://127.0.0.1/prod-api/

七、常见问题

Jenkins 不能访问发布目录

docker exec joy-jenkins test -d /frontend-deploy/releases

检查 Jenkins Compose 是否挂载 D:/work/joy-admin-frontend-deploy 到 /frontend-deploy。

npm ci 失败

确认仓库包含 package-lock.json,并且 Jenkins 中的 Node.js 版本为 16.20.2、npm 版本为 8.19.4。不要在发布流水线中自动生成新的 lock 文件;如果 lockfile 版本与 npm 8 不兼容,应统一项目锁文件和构建镜像中的 npm 版本后再发布。

Windows OpenSSH 连接失败

Get-Service sshd
Get-NetFirewallRule -Name sshd
ssh -i D:/work/ssh/jenkins-nginx-host jenkinsdeploy@127.0.0.1 'whoami'

Junction 创建失败

以管理员 PowerShell 执行,或开启 Windows Developer Mode,并确保部署用户对 D:/work 有读写权限。不要预先创建普通 current 目录。

Nginx reload 失败

D:/work/nginx/nginx.exe -t -p D:/work/nginx -c D:/work/nginx/conf/nginx.conf
Get-Process nginx

确认 Nginx 由 jenkinsdeploy 用户启动,或者该用户拥有控制 Nginx 进程的权限。

/prod-api 返回 403 或 404

proxy_pass 末尾必须保留 /,这样 /prod-api/login 会转发为后端 /login。跨域部署时,后端 CORS_ALLOWED_ORIGINS 必须包含实际前端来源。

八、安全和迁移

  • Jenkins 使用普通 Windows 用户,不要使用管理员账号。
  • Jenkins 只绑定 127.0.0.1,不直接暴露公网。
  • Jenkins Docker Socket 等同于宿主机 Docker 管理权限。
  • SSH 私钥、环境文件不得提交 Git。
  • releases 目录保留最近 5 至 10 个版本。
  • Nginx 配置变更必须先 nginx -t,再 reload。
  • 迁移到新 Windows 机器时,重新配置 D:/work、Nginx、OpenSSH、known_hosts 和 Jenkins 凭据。

更多推荐