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

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

现在项目分为 国内版欧洲版。使用的同一个域名的两个子域名,分别指向部署在国内和欧洲的服务。
例如说 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_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;
      proxy_pass              http://localhost:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      client_max_body_size    50m;
    }
    
    # 后端接口转发 - 中国
    location /api/cn/ {
      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;
      proxy_pass              http://localhost:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      client_max_body_size    50m;
    }
    # 后端接口转发 - 欧洲
    location /api/eu/ {
      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;
      proxy_pass              http://example2.com:9101/; # 使用真实的 domain:port 或者 ip:port 替换
      client_max_body_size    50m;
    }
  }
}

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