Published on

OpenResty + Lua 实现域名的动态解析

Authors

需要实现的需求是,根据域名的前缀,去 redis 里查询,获取 IP 地址和端口号,然后把请求转发到这个地址。

需求描述起来很简单,但是实现起来有很多的细节,需求又很着急,最好立即可用。

尝试了 Spring Cloud Gateway,Envoy Gateway 等产品,默认不能动态指定后端的 IP 地址和端口,通过一些 hack 的方式工作量又很大,并且不能很好适配 websocket 的流量。

虽然API 网关有很多产品,但是有的太重量级,需要一辆自行车,结果来了一架飞机,有的又过于简陋,需要定制很多细节,整体看下来选择并不多。

失败的尝试

使用 Spring Cloud Gateway

需要创建一个自定义的GlobalFilter来实现域名前缀查询和请求转发的逻辑。

@RequiredArgsConstructor
public class DynamicRouteFilter implements GlobalFilter, Ordered {
  private final ReactiveStringRedisTemplate redisTemplate;

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    final String fullPath = exchange.getRequest().getURI().toString();
    log.info("--> Full request path: {}", fullPath);
    return resolveTargetUri(exchange)
      .flatMap(uri -> forwardRequest(exchange, chain, uri))
      .onErrorResume(e -> handleNotFoundError(exchange)); // 在无法解析URI时处理错误
  }

  private Mono<URI> resolveTargetUri(ServerWebExchange exchange) {
    final String host = Objects.requireNonNull(exchange.getRequest().getHeaders().getHost()).getHostName();
    final String prefix = host.split("\\.")[0]; // 获取子域名前缀
    log.info("Resolving URI for host: {}", host);
    return redisTemplate.opsForValue().get(prefix)
      .flatMap(ipAndPort -> {
        if (ipAndPort != null && !ipAndPort.isEmpty()) {
          try {
            return Mono.just(createUriFromIpAndPort(ipAndPort, exchange.getRequest()));
          } catch (IllegalArgumentException e) {
            log.error("Error creating URI from IP and port: {}", e.getMessage());
            return Mono.error(new IllegalStateException("Error creating URI"));
          }
        } else {
          log.info("No mapping found in Redis for: {}", prefix);
          return Mono.error(new IllegalStateException("No mapping found"));
        }
      });
  }

  private Mono<Void> handleNotFoundError(ServerWebExchange exchange) {
    // 设置响应状态码为404
    log.error("Error resolving URI");
    exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
    return exchange.getResponse().setComplete();
  }

  private Mono<Void> forwardRequest(ServerWebExchange exchange, GatewayFilterChain chain, URI uri) {
    log.info("Forwarding request to: {}", uri);
    final ServerHttpRequest mutatedRequest = exchange.getRequest().mutate().uri(uri).build();
    return chain.filter(exchange.mutate().request(mutatedRequest).build());
  }

  private URI createUriFromIpAndPort(String ipAndPort, ServerHttpRequest request) {
    log.info("Resolving URI for IP and port: {}", ipAndPort);
    // ipAndPort格式为 "ip:port"
    final String[] parts = ipAndPort.split(":");
    final String ip = parts[0];
    final int port = Integer.parseInt(parts[1]);
    // 保留原始请求的路径和查询参数
    return UriComponentsBuilder.fromUri(request.getURI())
      .scheme("http").host(ip).port(port)
      .build(true).toUri();
  }

  @Override
  public int getOrder() {
    return Integer.MIN_VALUE;
  }
}

上面的实现看起来没有问题,但是默认的路由会覆盖上面的配置,这个地址没有办法动态转发:

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder, DynamicUriGatewayFilterFactory dynamicUriFilter) {
return builder.routes()
  .route(r -> r.path("/**")
    .filters(f -> f.filter(dynamicUriFilter.apply(new DynamicUriGatewayFilterFactory.Config())))
    .uri("http://fallbackUri")) // 提供一个回退URI,以防没有找到匹配的前缀
  .build();
}

基于 Envoy Gateway

Envoy 是一个开源的高性能边缘和服务代理,专为单体应用、微服务和云原生应用而设计,用于解决服务间的网络通信问题。它由Lyft公司开发,并且是CNCF(云原生计算基金会)的毕业项目。Envoy最初是作为Lyft的内部项目启动的,目的是解决微服务架构下服务间通信的复杂性问题,后来因为其出色的性能和功能而被广泛采用。

这种方式本身不能实现动态转发,和现有的架构结合成本很高,遂放弃。

基于 Golang 从零开始实现

原理很简单,就是转发请求,下面是一个基本实现的版本:

func handleRequest(w http.ResponseWriter, r *http.Request) {
	// 过滤无效URL
	_, err := url.Parse(r.URL.String())
	if err != nil {
		log.Println("Error parsing URL: ", err.Error())
		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		return
	}

	// 去掉环境前缀(针对腾讯云,如果包含的话,目前我只用到了test和release)
	newPath := strings.Replace(r.URL.Path, "/release", "", 1)
	newPath = strings.Replace(newPath, "/test", "", 1)

	// 拼接目标URL(带上查询字符串,如果有的话)
	// 如果请求中包含 X-Target-Host 头,则使用该头作为目标域名
	// 优先级 header > args > default
	var targetURL string
	if r.Header.Get("X-Target-Host") != "" {
		targetURL = "https://" + r.Header.Get("X-Target-Host") + newPath
	} else {
		targetURL = target + newPath
	}
	if r.URL.RawQuery != "" {
		targetURL += "?" + r.URL.RawQuery
	}

	// 本地打印代理请求完整URL用于调试
	if os.Getenv("ENV") == "local" {
		fmt.Printf("Proxying request to: %s\n", targetURL)
	}

	// 创建代理HTTP请求
	proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
	if err != nil {
		log.Println("Error creating proxy request: ", err.Error())
		http.Error(w, "Error creating proxy request", http.StatusInternalServerError)
		return
	}

	// 将原始请求头复制到新请求中
	for headerKey, headerValues := range r.Header {
		for _, headerValue := range headerValues {
			proxyReq.Header.Add(headerKey, headerValue)
		}
	}

	// 默认超时时间设置为300s(应对长上下文)
	client := &http.Client{
		// Timeout: 300 * time.Second,  // 代理不干涉超时逻辑,由客户端自行设置
	}

	// 发起代理请求
	resp, err := client.Do(proxyReq)
	if err != nil {
		log.Println("Error sending proxy request: ", err.Error())
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	// 将响应头复制到代理响应头中
	for key, values := range resp.Header {
		for _, value := range values {
			w.Header().Add(key, value)
		}
	}

	// 将响应状态码设置为原始响应状态码
	w.WriteHeader(resp.StatusCode)

	// 将响应实体写入到响应流中(支持流式响应)
	buf := make([]byte, 1024)
	for {
		if n, err := resp.Body.Read(buf); err == io.EOF || n == 0 {
			return
		} else if err != nil {
			log.Println("error while reading respbody: ", err.Error())
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		} else {
			if _, err = w.Write(buf[:n]); err != nil {
				log.Println("error while writing resp: ", err.Error())
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			w.(http.Flusher).Flush()
		}
	}
}

上面代码可以基本实现一个 HTTP 代理的功能,但是 HTTP 协议本身是比较复杂的,可能还有一些边界情况。也没有实现 websocket,完整的实现短期内无法完成。

OpenResty + LuaJIT

LuaJIT通过JIT编译技术将Lua代码直接编译成本地机器代码执行,这大大提高了执行效率。JIT编译过程在程序运行时动态进行,能够根据代码的实际执行情况进行优化。

OpenResty 是一个基于 NGINX 和 LuaJIT 的高性能 web 平台。它通过将 NGINX 用于网络请求处理的强大能力与 LuaJIT 用于动态脚本的高效执行相结合,提供了一个用于构建动态 web 应用的强大环境。OpenResty 通过在 NGINX 中集成各种 Lua 模块,扩展了 NGINX 的功能,使得开发者可以用 Lua 语言编写高效、可扩展的网络应用程序。

伪代码如下:

User
local redis = require "resty.redis"
local redis_config = require "redis_config"

local function get_target_address(prefix)
  local red = redis:new()
  red:set_timeout(3000) -- 1 sec
  local ok, err = red:connect(redis_config.redis_host, redis_config.redis_port)
  if not ok then
    ngx.log(ngx.ERR, "failed to connect to Redis: ", err)
    return
  end

  if redis_config.redis_password and redis_config.redis_password ~= "" then
    local res, err = red:auth(redis_config.redis_password)
    if not res then
      ngx.log(ngx.ERR, "failed to authenticate: ", err)
      return
    end
  end

  -- 在这里选择数据库
  red:select(redis_config.redis_database)

  local ip_port, err = red:get(prefix)
  if not ip_port or ip_port == ngx.null then
    ngx.log(ngx.ERR, "failed to get IP:Port for prefix: ", prefix, ", error: ", err)
    return
  end

  -- 使用完毕后,将连接放回连接池
  -- 参数:最大空闲时间(ms), 连接池大小
  ok, err = red:set_keepalive(10000, 10)
  if not ok then
    ngx.log(ngx.ERR, "failed to set keepalive: ", err)
    return
  end

  return ip_port
end

local function forward_request()
  local host = ngx.var.host
  local prefix = host:match("^(.-)%.") -- captures the prefix before the first dot
  local target = get_target_address(prefix)
  if not target then
    ngx.log(ngx.ERR, "No target found for prefix: ", prefix)
    return ngx.exit(404)
  end

  ngx.var.target = target.ip -- Set the IP for proxy_pass
  ngx.var.target_port = target.port -- Set the port for proxy_pass
end

return {
  forward_request = forward_request
}

local config = {}
config.redis_host = "127.0.0.1"
config.redis_port = 6379
config.redis_password = "your_redis_password"
config.redis_database = 0  -- 这里设置你的数据库编号
return config


http {
    # 其他配置...

    # Lua package path
    lua_package_path '/app/lua/?.lua;;';

    server {
        listen 8080;

        location / {
            set $target ''; # IP to forward
            set $target_port ''; # Port to forward
            access_by_lua_block {
                local forwarder = require "forwarder"
                forwarder.forward_request()
            }

            proxy_pass http://$target:$target_port; # Use the variables set by Lua
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}