关于cdn:alicdn边缘节点不稳定导致页面崩溃问题

5次阅读

共计 11469 个字符,预计需要花费 29 分钟才能阅读完成。

问题概述

某工作日,线上某用户向客服专员反馈没法失常拜访“查看报价页面”,页面内容没有出现。客服专员收到反馈后,将问题转交给 SRE 解决。很奇怪的是,SRE拜访生产环境“查看报价页面”显示失常,为了进一步剖析定位问题,SRE向用户申请了近程操作,将将一些具备价值的信息记录下来,次要有以下两个方面:

  • 用户拜访“查看报价页面”存在款式和字体文件没有加载胜利;

  • 没有加载胜利的字体和款式文件的申请域名并不是公司的,而是公网收费的域名(at.alicdn.com、g.alicdn.com);

剖析与定位

通过上述信息,能够晓得用户与 SRE 拜访页面的差别,SRE拜访“查看报价页面”能够失常获取所有资源,而用户无奈获取局部字体和款式文件。依据浏览器加载渲染原理,局部字体和款式加载失败大概率不会导致页面 DOM 无奈出现,无奈下结论之时,无妨先假如字体和款式文件影响到了 DOM 渲染。

当无奈从表象剖析出线上问题起因时,第一步须要在开发环境或者测试环境复现问题场景,而后排查从申请资源到页面渲染的执行过程。

问题的引入点:域名解析

在复现场景之前,须要先晓得拜访胜利和失败之间的差别。通过收集到的信息来看,申请域名解析的 IP 有显著不同:

  • 失常拜访资源,DNS域名解析
Request URL Remote Address
https://at.alicdn.com/t/font_1353866_klyxwbettba.css 121.31.31.251:443
https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css 119.96.90.252:443
https://at.alicdn.com/t/font_2296011_yhl1znqn0gp.woff2 121.31.31.251:443
https://at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505 121.31.31.251:443
  • 生产环境申请资源失败,DNS域名解析

    • at.alicdn.com116.153.65.231
    • g.alicdn.com211.91.241.230

用户和 SRE 所处地区不同,拜访资源时域名解析命中的边缘节点服务也会不同,而 at.alicdn.comg.alicdn.com是公网收费的 CDN 域名,某些边缘节点服务稳定性不够,拉取不到资源也是可能产生的。

问题根本原因:模块加载

开发环境与测试环境复现差别

批改本地hosts,增加用户域名解析的地址映射,在测试环境和开发环境尝试复现。两个环境均不能获取到字体和款式文件,测试环境(https://ec-hwbeta.casstime.com)页面内容没有出现(复现胜利),开发环境页面内容失常出现(复现失败),剖析开始陷入胡同。

开发环境:

测试环境:

这时候就要开始剖析了,两个环境复现问题的差别点在哪里?

不难发现,两个环境最次要的区别在于 yarn startyarn build的区别,也就是构建配置的区别。

开发环境

1、create-react-app要害构建配置

  • 启用 style-loader,默认通过style 标签将款式注入到 html 中;
  • 不启用 MiniCssExtractPlugin.loader 拆散款式和 OptimizeCSSAssetsPlugin 压缩款式;
  • 启用 optimization.splitChunks 代码宰割;
  • 启用 optimization.runtimeChunk 抽离 webpack 运行时代码;
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [isEnvDevelopment && require.resolve('style-loader')
    isEnvProduction && {
      loader: MiniCssExtractPlugin.loader,
      // css is located in `static/css`, use '../../' to locate index.html folder
      // in production `paths.publicUrlOrPath` can be a relative path
      options: paths.publicUrlOrPath.startsWith('.')
        ? {publicPath: '../../'}
        : {},},
  ].filter(Boolean);
  
  return loaders;
}

module: {
  rules: [
    {
      oneof: [
        {
          test: cssModuleRegex,
          use: getStyleLoaders({
            importLoaders: 1,
            sourceMap: isEnvProduction && shouldUseSourceMap,
            modules: {getLocalIdent: getCSSModuleLocalIdent,},
          }),
        },
        {
          test: sassModuleRegex,
          use: getStyleLoaders(
            {
              importLoaders: 3,
              sourceMap: isEnvProduction && shouldUseSourceMap,
              modules: {getLocalIdent: getCSSModuleLocalIdent,},
            },
            'sass-loader'
          ),
        },
      ]
    }
  ]
}
  
optimization: {
  minimize: isEnvProduction,
  minimizer: [
      // 压缩 css
    new OptimizeCSSAssetsPlugin({
      cssProcessorOptions: {
        parser: safePostCssParser,
        map: shouldUseSourceMap
          ? {
              // `inline: false` forces the sourcemap to be output into a
              // separate file
              inline: false,
              // `annotation: true` appends the sourceMappingURL to the end of
              // the css file, helping the browser find the sourcemap
              annotation: true,
            }
          : false,
      },
    })
  ],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {name: entrypoint => `runtime-${entrypoint.name}`,
  },  
}

css-loader在解析样式表中 @importurl()过程中,如果 index.module.scss 中应用 @import 引入第三方款式库 aliplayer-min.css@import aliplayer-min.css 局部和 index.module.scss 中其余部分将会被拆散成两个 module,而后别离追加到款式数组中,数组中的每个”款式项“将被style-loader 解决应用 style 标签注入到 html

2、执行链路

开发环境的构建配置根本分明,再来看看执行流程。执行 yarn start 启用本地服务,localhost:3000拜访“查看报价页面”。首先会通过匹配路由,而后 react-loadable 调用 webpack runtime 中加载 chunk 的函数 __webpack_require__.e,该函数会依据入参chunkId 应用基于 promise 实现的 script 申请对应 chunk,返回Promise<pending>。如果Promise.all() 存在一个 Promise<pending> 转变成 Promise<rejected>,那么Promise.all 的执行后果就是 Promise<rejected>。因为css chunk 是通过 style 标签注入到 html 中,所以 __webpack_require__.e 只须要加载 js chunk,当所有的js chunk 都申请胜利时,Promise.all的执行后果就是 Promise<fulfilled>fulfilled 状态会被 react-loadable 中的 then 捕捉,更新组件外部状态值,触发从新渲染,执行 render 函数返回 jsx element 对象。因而,内容区域失常显示。

生产环境

1、create-react-app要害构建配置

  • 不启用 style-loader,默认动态创建link 标签注入款式;
  • 启用了 MiniCssExtractPlugin.loader 拆散款式;
  • 启用 optimization.splitChunks 代码宰割;
  • 为了更好的利用浏览器强缓存,设置optimization.runtimeChunk,拆散webpack runtime
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [isEnvDevelopment && require.resolve('style-loader')
    isEnvProduction && {
      loader: MiniCssExtractPlugin.loader,
      // css is located in `static/css`, use '../../' to locate index.html folder
      // in production `paths.publicUrlOrPath` can be a relative path
      options: paths.publicUrlOrPath.startsWith('.')
        ? {publicPath: '../../'}
        : {},},
  ].filter(Boolean);
  
  return loaders;
}

module: {
  rules: [
    {
      oneof: [
        {
          test: cssModuleRegex,
          use: getStyleLoaders({
            importLoaders: 1,
            sourceMap: isEnvProduction && shouldUseSourceMap,
            modules: {getLocalIdent: getCSSModuleLocalIdent,},
          }),
        },
        {
          test: sassModuleRegex,
          use: getStyleLoaders(
            {
              importLoaders: 3,
              sourceMap: isEnvProduction && shouldUseSourceMap,
              modules: {getLocalIdent: getCSSModuleLocalIdent,},
            },
            'sass-loader'
          ),
        },
      ]
    }
  ]
}

optimization: {
  minimize: isEnvProduction,
  minimizer: [],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {name: entrypoint => `runtime-${entrypoint.name}`,
  },
},

plugins: [
  // Generates an `index.html` file with the <script> injected.
  new HtmlWebpackPlugin(
    Object.assign({},
      {
        inject: true,
        template: paths.appHtml,
      },
      isEnvProduction
        ? {
            minify: {
              removeComments: true,
              collapseWhitespace: true,
              removeRedundantAttributes: true,
              useShortDoctype: true,
              removeEmptyAttributes: true,
              removeStyleLinkTypeAttributes: true,
              keepClosingSlash: true,
              minifyJS: true,
              minifyCSS: true,
              minifyURLs: true,
            },
          }
        : undefined
    )
  ),
  // Inlines the webpack runtime script. This script is too small to warrant
  // a network request.
  // https://github.com/facebook/create-react-app/issues/5358
  isEnvProduction &&
    shouldInlineRuntimeChunk &&
      // 将运行时代码内联注入到 html 中
    new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
]

设置 optimization.runtimeChunk,将webpack runtime(运行时代码,治理chunk 依赖关系和加载)独自打包进去,这样就不会因为某个 chunk 的变更导致依赖该 chunkchunk也变更(文件名 hash 扭转),从而导致浏览器缓存生效。

因为启用了 MiniCssExtractPlugin.loader 拆散款式,@import "aliplayer-min.css"将被拆散到一个 css chunk 中,所以 aliplayer-min.css 申请链有三级

)

2、执行链路

在剖析执行链路之前,先将生产环境构建配置中的代码压缩性能正文掉,不便浏览和调试源代码

optimization: {
  minimize: false, // 改成 false,禁用压缩
  minimizer: [],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {name: entrypoint => `runtime-${entrypoint.name}`,
  },
},

plugins: [
  // Generates an `index.html` file with the <script> injected.
  new HtmlWebpackPlugin(
    Object.assign({},
      {
        inject: true,
        template: paths.appHtml,
      },
      isEnvProduction
        ? {
            minify: {
              removeComments: true,
              // collapseWhitespace: true,
              // removeRedundantAttributes: true,
              // useShortDoctype: true,
              // removeEmptyAttributes: true,
              // removeStyleLinkTypeAttributes: true,
              // keepClosingSlash: true,
              // minifyJS: true, // 不压缩注入到 html 中的 js
              // minifyCSS: true, // 不压缩注入到 html 中的 css
              // minifyURLs: true,
            },
          }
        : undefined
    )
  ),
]

执行 yarn build,失去构建产物,在build 目录下启用服务 http-server -p 3000。为了跨域拜访测试环境服务,本地装置nginx 配置反向代理,localhost:4444端口拜访“查看报价页面”即可在本地拜访,跟测试环境一样。

server {
    listen       4444;
  server_name  localhost;

  location /maindata {proxy_pass https://ec-hwbeta.casstime.com;}
  location /market {proxy_pass https://ec-hwbeta.casstime.com;}
  location /agentBuy {proxy_pass https://ec-hwbeta.casstime.com;}
  location /mall {proxy_pass https://ec-hwbeta.casstime.com;}
  location /inquiryWeb {proxy_pass https://ec-hwbeta.casstime.com;}
  location /cart {proxy_pass https://ec-hwbeta.casstime.com;}
  location /msg {proxy_pass https://ec-hwbeta.casstime.com;}
  location /webim {proxy_pass https://ec-hwbeta.casstime.com;}
  location /pointshop {proxy_pass https://ec-hwbeta.casstime.com;}
  location /partycredit {proxy_pass https://ec-hwbeta.casstime.com;}

  location / {proxy_pass http://127.0.0.1:3000;}
}

当用户拜访“查看报价页面”时,首先通过匹配路由,而后 react-loadable 调用 webpack 运行时加载 chunk 的函数 __webpack_require__.e,该函数会依据入参chunkId 应用基于 promise 实现的 linkscript申请对应 chunk,返回Promise<pending>。如果Promise.all() 中存在一个 Promise<pending> 转变成 Promise<rejected>,那么Promise.all 的执行后果就是 Promise<rejected>。因为其中有一个蕴含@import "aliplayer-min.css"css chunk申请失败了,所以 Promise.all 的执行后果就是 Promise<rejected>rejected 状态会被 react-loadable 中的 catch 捕捉,更新组件外部状态值,触发从新渲染,执行 render 函数返回null。因而,内容区域显示空白。

注:应用 link 加载 css chunk,如果css chunk@import url()申请失败,那么会触发 $link.onerror 回调函数

起因

至此,问题的根本原因曾经明了了。因为生产环境构建将 cssjs拆分成一个个 chunk,运行时函数在依据chunkId 加载资源时,其中存在一个含 @import "aliplayer-min.css"css chunk加载失败,导致整个 Promise.all 执行后果为 Promise<rejected>,以致react-loadable 高阶组件中 catch 捕捉到 rejected 后,更新 state,从新渲染,执行render 函数返回null,页面内容显示空白。

解决方案

在解决该问题之前,须要先摸清楚问题批改的范畴有多大,毕竟援用 alicdn 动态资源的工程可能不止一个。在 gitlab 全局搜寻发现,波及工程有十几个。如果每一个援用的链接手动去改,很容易改漏,因而我筹备写一个命令行工具,敲一个命令就能够搞定全副链接替换。

初始化命令行我的项目

创立一个构造,如下所示:

+ kennel-cli
  + cmds
    + dowmload-alicdn.js
  - index.js

而后,在根文件夹中初始化:

$ npm init -y  # This will create a package.json file

配置 bin

关上你的 package.json 并定义将在可执行文件和终点文件上应用的名称:

"bin": {"kennel-cli": "index.js"},

而后,应用以下命令通知 npmindex.js 是一个 Node.js 可执行文件 #!/usr/bin/env node(必须指定执行环境,不然执行会报错):

#!/usr/bin/env node
'use strict'

// The rest of the code will be here...
console.log("Hello world!")

调试应用程序

咱们能够对 NPM 说,您以后开发的应用程序是一个全局应用程序,因而咱们能够在咱们的文件系统中的任何中央测试它:

$ npm link  # Inside the root of your project

而后,您曾经能够从计算机上的任何门路执行您的应用程序:

$ kennel-cli     # Should print "Hello world" on your screen

加载所有命令

批改 index.js 文件,应用 yargs.commandDir 函数加载此文件夹中的每个命令(上面的示例)。

#!/usr/bin/env node
"use strict";

const {join} = require("path");
require("yargs")
  .usage("Usage: $0 <command> [options]")
  .commandDir(join(__dirname, "cmds"))
  .demandCommand(1)
  .example("kennel-cli download-alicdn")
  .help()
  .alias("h", "help").argv; // 最初肯定要.argv,不然命令执行不会有任何反馈

实现一个命令

在文件夹 cmds 中的一个文件中指定了一个命令。它须要导出一些命令配置。例如:

const {join} = require("path");
const fs = require("fs");

exports.command = "download-alicdn";

exports.desc = "将引入的阿里云动态资源文件下载到本地我的项目";

exports.builder = {};

exports.handler = (argv) => {
  // 执行命令的回调
  downloadAlicdn();};

/**
 * @description 读取 public/index.html
 * @returns
 */
function readHtml() {
  // 不能应用__dirname,因为__dirname 示意以后执行文件所在的目录,如果在某工程执行该命令,__dirname 指的就是 download-alicdn.js 寄存的目录
  const htmlURL = join(process.cwd(), "public/index.html");
  // 同步读取,本地读取会很快
  return fs.readFileSync(htmlURL).toString();}

/**
 * @description 替换 alicdn 动态资源
 * @param {*} source
 */
async function replaceAlicdn(source) {// node-fetch@3 是 ESM 标准的库,不能应用 require,因而这儿应用 import()动静引入
  const fetch = (...args) =>
    import("node-fetch").then(({default: fetch}) => fetch(...args));
  const reg = /(https|http):\/\/(at|g).alicdn.com\/.*\/(.*\.css|.*\.js)/;
  const fontReg = /\/\/(at|g).alicdn.com\/.*\/(.*\.woff2|.*\.woff|.*\.ttf)/;

  const fontDir = join(process.cwd(), "public/fonts");
  const staticDir = (suffix) => join(process.cwd(), `public/${suffix}`);

  let regRet = source.match(reg);
  while (regRet) {const [assetURL, , , file] = regRet;
    // 申请资源
    let content = await fetch(assetURL).then((res) => res.text());
    let fontRet = content.match(fontReg);
    while (fontRet) {const [curl, , cfile] = fontRet;
      // @font-face {
      //   font-family: "cassmall"; /* Project id 1353866 */
      //   src: url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505') format('woff2'),
      //        url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff?t=1639626666505') format('woff'),
      //        url('//at.alicdn.com/t/font_1353866_klyxwbettba.ttf?t=1639626666505') format('truetype');
      // }
      const childContent = await fetch("https:" + curl).then((res) =>
        res.text());
      if (fs.existsSync(fontDir)) {fs.writeFileSync(join(fontDir, cfile), childContent);
      } else {fs.mkdirSync(fontDir);
        fs.writeFileSync(join(fontDir, cfile), childContent);
      }
      content = content.replace(fontReg, "../fonts/" + cfile);
      fontRet = content.match(fontReg);
    }
    const suffix = file.split(".")[1];
    const dir = staticDir(suffix);
    if (fs.existsSync(dir)) {fs.writeFileSync(join(dir, file), content);
    } else {fs.mkdirSync(dir);
      fs.writeFileSync(join(dir, file), content);
    }
    source = source.replace(reg, `./${suffix}/${file}`);
    regRet = source.match(reg);
  }

  fs.writeFileSync(join(process.cwd(), "public/index.html"), source);
}

async function downloadAlicdn() {
  // 1、获取 public/index.html 模板字符串
  // 2、正则匹配 alicdn 动态资源链接,并获取链接内容写入到本地,援用链接替换老本地引入
  // 3、如果 alicdn css 资源外部还有引入 alicdn 的资源,也须要下载替换引入链接
  // https://at.alicdn.com/t/font_1353866_klyxwbettba.css
  // https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css
  const retHtml = readHtml();
  await replaceAlicdn(retHtml);
}

理论我的项目测试

正文完
 0