使用 grunt-ssh 将子应用分别部署到不同 bucket

解读

面试官抛出这道题,并不是想听你背诵 grunt-ssh 的 API,而是考察你在“多子应用 + 多 bucket”场景下,如何用 Grunt 做自动化部署保证密钥安全避免任务串扰可灰度可回滚。国内真实环境通常还要兼顾阿里云 OSS/腾讯云 COS 的权限模型CI 机无固定公网 IP子应用独立版本号等痛点,因此答案必须体现“配置隔离 + 并发安全 + 失败重试 + 日志追溯”四项能力。

知识点

  1. grunt-ssh 与 sftp 原理:基于 ssh2 协议,sftp 上传是流式写入,大文件需分片并发控制。
  2. bucket 映射策略:国内云厂商 bucket 名称全局唯一,建议用“项目-环境-子应用”三段式命名,如 web-proj-pay
  3. 多任务并发:Grunt 默认串行,需用 grunt-concurrent 或自定义 this.async() 实现 bucket 级并发,但并发数 ≤ 5,否则会被云厂商限流。
  4. 密钥托管:CI 环境禁止硬编码,通过环境变量注入,本地开发用 ~/.ssh/config 的 IdentityFile 指向只读 pem,权限 400
  5. 差分上传:grunt-ssh 本身无 diff,需前置 grunt-newer 或自定义 md5 清单文件,实现“增量上传 + 秒级回滚”。
  6. 国内加速:阿里云 OSS 上传域名与下载域名分离,上传走内网 Endpoint(如 oss-cn-shanghai-internal.aliyuncs.com)可省 80% 流量费
  7. 合规审计:金融类项目要求留痕 6 个月,需在 Gruntfile 里把上传日志同时写到 logs/deploy-${timestamp}.log 并转存到审计 bucket。

答案

以下是一份可直接落地的 Gruntfile 片段,演示“三个子应用 → 三个 bucket”的完整流程,兼顾并发、安全、失败重试、灰度

module.exports = function(grunt) {
  // 1. 密钥与 bucket 配置完全抽离,符合国内合规要求
  const cfg = {
    pay: {
      host: process.env.DEPLOY_HOST_PAY,   // 仅 CI 可见
      bucket: 'web-proj-pay',
      path: '/'
    },
    admin: {
      host: process.env.DEPLOY_HOST_ADMIN,
      bucket: 'web-proj-admin',
      path: '/'
    },
    h5: {
      host: process.env.DEPLOY_HOST_H5,
      bucket: 'web-proj-h5',
      path: '/'
    }
  };

  // 2. 动态生成 ssh 任务,避免手工复制
  Object.keys(cfg).forEach(app => {
    grunt.config.set(`sshexec.upload-${app}`, {
      command: [
        `ossutil cp -r -f dist/${app}/ oss://${cfg[app].bucket}/${cfg[app].path}`
      ].join(' && '),
      options: {
        host: cfg[app].host,
        username: process.env.DEPLOY_USER,
        privateKey: grunt.file.read(process.env.DEPLOY_KEY),
        readyTimeout: 20000,
        maxRetries: 3
      }
    });
  });

  // 3. 并发上传,但限制并发数 3,防止被云厂商限流
  grunt.config.set('concurrent.deploy', {
    tasks: ['sshexec:upload-pay', 'sshexec:upload-admin', 'sshexec:upload-h5'],
    limit: 3,
    logConcurrentOutput: true
  });

  // 4. 灰度:先上传,再切换流量
  grunt.registerTask('deploy:canary', function(target) {
    const done = this.async();
    grunt.util.spawn({
      cmd: 'ossutil',
      args: ['ls', `oss://${cfg[target].bucket}/version.txt`]
    }, (e, res) => {
      if (e) return grunt.fail.fatal('bucket 不存在,请先创建');
      // 写入灰度版本号
      grunt.file.write(`dist/${target}/version.txt`, Date.now());
      grunt.task.run(`sshexec:upload-${target}`);
      done();
    });
  });

  // 5. 回滚:利用 ossutil 的版本管理
  grunt.registerTask('rollback', function(target, version) {
    grunt.config.set(`sshexec.rollback-${target}`, {
      command: `ossutil cp -r -f oss://${cfg[target].bucket}/.archive/${version}/ oss://${cfg[target].bucket}/`,
      options: grunt.config.get(`sshexec.upload-${target}.options`)
    });
    grunt.task.run(`sshexec:rollback-${target}`);
  });

  grunt.loadNpmTasks('grunt-ssh');
  grunt.loadNpmTasks('grunt-concurrent');
  grunt.registerTask('deploy-all', ['build', 'concurrent:deploy']);
};

使用方式
本地灰度:DEPLOY_KEY=~/.ssh/deploy.pem grunt deploy:canary:pay
CI 全量:Jenkinsfile 里 sh 'grunt deploy-all'
回滚:grunt rollback:pay:20240625120000

拓展思考

  1. 无服务器化:如果公司全面迁到 Serverless,可改用 grunt-aws-lambda-invoke 直接触发函数计算部署,不再走 ssh,节省堡垒机成本
  2. 多云容灾:国内监管要求“两地三中心”,可在 Gruntfile 里再包一层 ossutil sync --delete把主 bucket 同步到异地容灾 bucket,RPO ≤ 15 min。
  3. 权限最小化:对 CI 机只授予 oss:PutObjectoss:GetObject关闭 ListBucket 权限,防止黑客列文件。
  4. 大前端 monorepo:子应用数 > 20 时,用 lerna + grunt 动态生成任务,避免 Gruntfile 膨胀到上千行。
  5. 审计闭环:把每次上传的 etag、耗时、CI 构建号写入阿里云 SLS 日志库对接 Grafana 大盘,实现“一键定位哪次部署导致现网白屏”。