单域名多服务器的本地化部署

最后更新:

工作中长期维护的项目提出了一个需求:

现在项目分为 国内版欧洲版。使用的同一个域名的两个子域名,分别指向部署在国内和欧洲的服务。
例如说 www.domain.comeu.domain.com 这样两个应用入口。

虽然我们会在应用上标注清楚是国内版还是欧洲版。但是仍然会有不少的用户会打开错误的应用。所以想要把应用入口统一为一个。项目按照访问用户的IP所在地区自动切换到国内和欧洲的应用中。并且仍然允许用户手动切换服务器,允许跨境使用。

想了很多实现方案最后确定为在DNS上面按照IP归属做分流。通过访问用户的IP把在欧洲的用户定向到欧洲的服务,在国内的分流到国内的服务上。
实质上还是一个地区部署一整套的服务。只不过说现在应用入口的统一了,域名也是统一成 www.domain.com 一个了。

然后在对应的服务器上Nginx 的接口代理转发,保证可以正常访问其他地区的服务。避免因为访问系统的用户IP是固定的,在DNS上直接就被分流掉了,导致无法访问到另一个地区服务的情况。


🌎 简单的方案概述

#1. 在DNS层先按照用户IP归属分流,分别定向到部署在不同地区的服务器。
#2. 前端项目默认请求当前地区的服务API。用户手动切换服务器地区,那么通过修改请求的API前缀,请求不同的API地址。
#3. 通过 Nginx 中配置的代理转发,把API请求转发到正确的目标服务器的后端服务上。

前端还是使用的被DNS分流的当地服务器上返回的前端项目,并没有随用户切换服务器而改变。所以说实质上还是一个地区部署一整套的前后端服务。只不过对后端API的请求通过 Nginx 转发到对应的目标服务器上了。


💻 前端部分调整

首先就是前端部分的调整。新增一个默认目标服务器环境变量配置项。这个很简单应该不用多说了,在对应的 .env.xxx 文件中声明好就可以了。

因为有一些系统用户会全球异动,需要在境外处理境内的事物。所以不光需要指定默认的目标服务器,还需要在应用中增加手动切换服务器的功能。要不然系统也不需要做调整了,直接在域名解析好就完成需求了。

原本项目就有使用状态管理库(Vuex)。目标服务器的状态维护很简单,就在对应的 Store 模块中增加一个 targetServer 属性和对应的状态操作函数:

// store/modules/app.js
import Cookies from 'js-cookie'
import { TARGET_SERVER } from '@CONST_KEY'

const state = {
  // ... 
  targetServer: Cookies.get(TARGET_SERVER) || process.env.VUE_APP_TARGET_SERVER_DEFAULT
}

const mutations = {
  // ...
  SET_TARGET_SERVER: (state, server) => {
    state.targetServer = server
    Cookies.set(TARGET_SERVER, server)
  }
}

const actions = {
  // ...
  setTargetServer({ commit }, server) {
    commit('SET_TARGET_SERVER', server)
  }
}

export default { namespaced: true, state, mutations, actions }

同时修改一下 getters 方便获取状态:

// store/getters.js
const getters = {
  // ...
  targetServer: state => state.app.targetServer
}

export default getters

因为还需要在应用内给用户展示当前的选择的目标服务器。我的话就是在系统顶部导航中用户头像点击后的弹出菜单中增加了切换服务器选项。
以下是脱敏后的示例代码:

// src/layout/components/Navbar.vue
<template>
  <div class="navbar">
    <div class="left-menu">
      <!-- ... -->
      <div class="target-server-switch">
        {{ $t('app.target-server-current')}}: 
        <el-dropdown trigger="click" @command="onChangeTargetServer">
          <span class="el-dropdown-link">
            {{ currentTargetServer }}<i class="el-icon-arrow-down el-icon--right"></i>
          </span>
          <el-dropdown-menu slot="dropdown">
            <el-dropdown-item v-for="server in targetServerList" :key="server.value" :command="server.value" :disabled="server.value === targetServer">
              {{server.label}}
            </el-dropdown-item>
          </el-dropdown-menu>
        </el-dropdown>
      </div>
    </div>

    <div class="right-menu">
      <!-- ... -->
      <el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click" @command="onDropdownCommand">
        <div class="avatar-wrapper">
          <img :src="avatar" class="user-avatar" /><i class="el-icon-caret-bottom" />
        </div>
        <el-dropdown-menu slot="dropdown">
          <!-- .... -->
          <el-dropdown-item command="server">
            <!-- 切换服务器 -->
            <el-dropdown class="block" trigger="click" @command="onChangeTargetServer" @click.native.stop="() => ({})">
              <div class="flex justify-between items-center">
                {{ $t('app.target-server-switch')}} <i class="el-icon-arrow-down el-icon--right" />
              </div>
              <el-dropdown-menu slot="dropdown">
                <el-dropdown-item v-for="server in targetServerList" :key="server.value" :command="server.value" :disabled="server.value === targetServer">
                  {{ server.label }}
                </el-dropdown-item>
              </el-dropdown-menu>
            </el-dropdown>
          </el-dropdown-item>
          <!-- ... -->
        </el-dropdown-menu>
      </el-dropdown>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
import { targetServerList, getServerLocale } from '@dict/app/targetServer'

export default {
  // ...
  data() {
    return {
      // ...
      targetServerList
    }
  },
  computed: {
    ...mapGetters(['targetServer', '...']),
    // 当前服务器
    currentTargetServer() {
      const target = getServerLocale(this.targetServer)
      return target.label
    }
  },
  methods: {
    // 切换目标服务器
    onChangeTargetServer(serverKey) {
      this.$loading({ lock: true });
      const { value } = getServerLocale(serverKey)
      this.$store.dispatch('app/setTargetServer', value)
      // 切换目标服务器,重新加载页面
      window.location.reload()
    }
  }
}
</script>

因为系统有域账户自动登录功能,我在切换目标服务器之后直接使用了 window.location.reload() 重新载入了项目。
在另外一个地区的服务器上当前用户并没有登录,刷新后重新使用缓存中的 token 换取用户信息的请求必定会失败。就会重新尝试使用域账户免登录功能自动登录,对于用户来说是无感的。如果没有提供类似自动登录的功能,那么还需要有一个 CAS 服务,来处理不同服务器之间的登陆状态。

所以我的项目中HTTP请求库的调整也会非常简单,只需要读取一下是否本地 Cookie 中是否已经有存储,如果没有就使用默认值。例如,我使用的 Axios,直接调整在实例化时 baseURL 配置项即可:

// utils/request.js
import axios from 'axios'
import Cookies from 'js-cookie'
import { TARGET_SERVER } from '@CONST_KEY'

const baseUrl = process.env.VUE_APP_BASE_URL
const targetServer = Cookies.get(TARGET_SERVER) || process.env.VUE_APP_TARGET_SERVER_DEFAULT

// 创建axios实例
const service = axios.create({
  baseURL: `${baseUrl}/${targetServer}`.replace(/\/+/g, '/'),
  // ...
})

那么前端部分已经调整的差不多了,接下来开始调整服务器端。


📦 服务端的调整

服务端的调整并不多,只需要调整 Nginx 的配置做代理转发即可。

http {
  # ...
  server {
    listen          80;
    server_name     example.com;

    # 前端项目
    location / {
        root                  /data/project_name;
        try_files             $uri $uri/ /index.html;
        index                 index.html  index.htm;

        location ~* \.html$ {
          add_header          Cache-Control no-store;
          expires             -1;
        }

        location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
          expires             72h;
        }
    }
    
    # 后端接口转发 - 默认
    location /api/ {
      proxy_pass              http://localhost:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      proxy_set_header        Host $http_host;
      proxy_set_header        X-Real-IP $remote_addr;
      proxy_set_header        REMOTE-HOST $remote_addr;
      proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
      client_max_body_size    50m;
    }
    
    # 后端接口转发 - 中国
    location /api/cn/ {
      proxy_pass              http://localhost:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      proxy_set_header        Host $http_host;
      proxy_set_header        X-Real-IP $remote_addr;
      proxy_set_header        REMOTE-HOST $remote_addr;
      proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
      client_max_body_size    50m;
    }
    # 后端接口转发 - 欧洲
    location /api/eu/ {
      proxy_pass              http://example2.com:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      proxy_set_header        Host $http_host;
      proxy_set_header        X-Real-IP $remote_addr;
      proxy_set_header        REMOTE-HOST $remote_addr;
      proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
      client_max_body_size    50m;
    }
  }
}

配置完成后使用 nginx -t 测试配置文件是否正确。然后 nginx -s reload 重新载入配置文件就可以进行测试了。


💢 UPDATE

当我做完了全部调整开始测试之后,发现实际业务中有大量各种没有通过封装好的HTTP请求库发起的请求。比如说一些历史的上转下载功能。得同步调整大量的历史业务代码,工作量会变的巨大无比。所以我想这是否可以有其他更便利的处理方案。

确实,其实可以通过把 TARGET_SERVER 放到请求头中,或者随 Cookies 传递给服务器。

因为会有图片请求之类的操作,所以我建议是放到 Cookies 中,让浏览器处理随着请求一起发送。
而不是通过修改请求路径的方式,并且 Nginx 的代理转发也可以简化处理一下:

http {
  # ...
  server {
    listen          80;
    server_name     example.com;

    upstream server_cn {
        server      example1.com:9101; # 使用真实的 domain:port 或者 ip:port 替换
    }

    upstream server_eu {
        server      example2.com:9101; # 使用真实的 domain:port 或者 ip:port 替换
    }
    
    map $cookie_target_server $backend { # 从 Cookies 中获取
    # map $http_target_server $backend { # 从 headers 中获取
        "cn" server_cn;
        "eu" server_eu;
        default server_cn;
    }

    # 前端项目
    location / {
        root                  /data/project_name;
        try_files             $uri $uri/ /index.html;
        index                 index.html  index.htm;

        location ~* \.html$ {
          add_header          Cache-Control no-store;
          expires             -1;
        }

        location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
          expires             72h;
        }
    }
    
    # 后端接口转发
    location /api/ {
      proxy_pass              http://$backend; # 注意后面不能有 / 结尾,不然会丢失 path 信息
      proxy_set_header        Host $http_host;
      proxy_set_header        X-Real-IP $remote_addr;
      proxy_set_header        REMOTE-HOST $remote_addr;
      proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
      client_max_body_size    50m;
    }

  }
}

这样处理的话,其实HTTP请求的修改也不需要了,接口还是原本的那个 /api,而不是改造后的 /api/[targetServer]。工作量就减少了非常多了。

💥 额外的一些细节

在Nginx中使用 $cookie_ 获取 Cookie 信息时需要注意对应的 Key 键名称。

  • 比如使用 短横线 的 CookieName,得在 map 映射 时单独匹配处理。

而使用 $http_ 获取 header 信息时,Nginx会帮你处理成全小写,并且短横线替换成下划线。

$cookie_name
the name cookie

$http_name
arbitrary request header field; the last part of a variable name is the field name converted to lower case with dashes replaced by underscores


所以如果有一些特殊的 Cookie 键名,比如说 Target-Server 这样的,你可以这样匹配:

map $http_cookie $target_server {
    "~*Target-Server=([^;]+)" $1;
}

map $target_server $backend {
    "cn" server_cn;
    "eu" server_eu;
    default server_cn;
}

当然也可以合并起来,但其实也差不多

map $http_cookie $backend {
    "~*Target-Server=cn" server_cn;
    "~*Target-Server=eu" server_eu;
    default server_cn;
}