基於Gor實現流量複製(加middleware功能增強)

預流發表於2018-01-25

最近做功能重構,在上線前要求驗證重構後的程式碼與老程式碼實現邏輯是否一致,基於這個需求,需要在生產環境做一個功能將生產伺服器上的流量複製一份傳送到測試伺服器上。

就這個事情這幾天考察了三種技術,1. 基於 nginx+lua 指令碼,2. tcpcopy,3. gor。這裡大概說一下這三種方案:

  • nginx+lua 指令碼

這種方案的思路是在生產伺服器前端架一層殼子,將請求攔截,然後基於 lua-nginx-module 模組,寫 lua 指令碼,使用其內建的 ngx.location.capture_multi ,對後端發起多個非同步併發請求,然後統一將結果返回給前端。

該方案需要安裝 nginx ,以及依賴 lua-nginx-module ,ngx_devel_kit 等模組,然後需要寫lua程式碼來複制請求。

  • tcpcopy

這種方案是工作在網路等 TCP 和 IP 層做請求複製,因為其間架構調整過兩次,現在的實現架構是在生產環境啟動 tcpcopy 程式,測試環境啟動 intecept 程式,然後配置複製請求的路徑。

  • gor

這是今天搜到的用 Go 語言寫的工具,在生產伺服器上安裝一個 tar 包,用 root 許可權啟動命令即可

1的方案安裝步驟較多,需要理解 nginx 處理請求的過程和 lua 指令碼語法以及相關請求呼叫的 API 。2的安裝簡單一些,只需要在生產和測試伺服器分別安裝 tcpcopy 和 intecept 即可(當然前提是他們依賴的 libpcap 之類也有了,否則也要安裝),然後啟動命令加引數即可,但由於該方案工作在較為底層,看起來比較重,實際跑了個例子就沒繼續研究。3安裝最簡單,下載一個 tar 包,解壓,sudo 執行即搞定。

個人比較傾向於3,所以這裡就介紹一下gor的實現方式。

  1. 下載

根據作業系統環境下載安裝包 https://github.com/buger/gor/releases,建議選擇 master 分支的,我的是 Mac ,所以選擇了 tar 包

  1. 解壓
tar -xvf gor_v0.14.1_mac.tar.gz
複製程式碼
  1. 驗證
sudo ./gor --input-raw :8080 --output-http http://192.168.22.33:8080
複製程式碼

至此就搞定了,簡單吧!!! 這條命令是監控本地的 8080 埠,並實時複製請求到需要 192.168.22.33 的 8080 埠上,需要本地 root 執行許可權。

下面是我的擴充套件用法:

  1. 儲存請求到檔案
sudo ./gor --input-raw :8080 --output-file requests.gor
複製程式碼

這裡將 8080 埠的請求儲存到本地檔案上,可以用於線上請求記錄之後的功能回放

  1. 根據檔案回放請求
sudo ./gor --input-file requests.gor --output-http http://192.168.22.33:8080
複製程式碼

將上面儲存的檔案請求回放到 192.168.22.33 伺服器的 8080 埠上

  1. url 過濾 包含 /order 的 URL 才傳送請求
sudo ./gor --input-raw :8080 --output-http http://192.168.22.33:8080 --http-allow-url ^/order.
複製程式碼
  1. url 過濾+記錄檔案+請求回放
sudo ./gor --input-raw :8080 --output-file gor-order-requests.gor --output-http http://192.168.22.33:8080 --http-allow-url ^/order.

sudo ./gor --input-file gor-order-requests.gor --output-http http://192.168.22.33:8080
複製程式碼
  1. url 過濾+記錄檔案+記錄響應
sudo ./gor --input-raw-track-response --input-raw :8080 --output-file gor-order-request-response.gor --http-allow-url ^/order.
複製程式碼

下面是別人整理的一些詳細配置說明,可以參考一下

  -cpuprofile string
        write cpu profile to file
  -debug verbose
        開啟debug模式,顯示所有介面的流量 
  -http-allow-header value
        用一個正規表示式來匹配http頭部,如果請求的頭部沒有匹配上,則被拒絕 
         gor --input-raw :8080 --output-http staging.com --http-allow-header api-version:^v1 (default [])
  -http-allow-method value
        類似於一個白名單機制來允許通過的http請求方法,除此之外的方法都被拒絕.
        gor --input-raw :8080 --output-http staging.com --http-allow-method GET --http-allow-method OPTIONS (default [])
  -http-allow-url value
        一個正規表示式用來匹配url, 用來過濾完全匹配的的url,在此之外的都被過濾掉 
         gor --input-raw :8080 --output-http staging.com --http-allow-url ^www. (default [])
  -http-disallow-header value
        用一個正規表示式來匹配http頭部,匹配到的請求會被拒絕掉
         gor --input-raw :8080 --output-http staging.com --http-disallow-header "User-Agent: Replayed by Gor" (default [])
  -http-disallow-url value
        用一個正規表示式來匹配url,如果請求匹配上了,則會被拒絕
         gor --input-raw :8080 --output-http staging.com --http-disallow-url ^www. (default [])
  -http-header-limiter value
        讀取請求,基於FNV32-1A雜湊來拒絕一定比例的特殊請求 
         gor --input-raw :8080 --output-http staging.com --http-header-imiter user-id:25% (default [])
  -http-original-host
        在--output-http的輸出中,通常gor會使用取代請求的http頭,所以應該禁用該選項,保留原始的主機頭
  -http-param-limiter value
        Takes a fraction of requests, consistently taking or rejecting a request based on the FNV32-1A hash of a specific GET param:
         gor --input-raw :8080 --output-http staging.com --http-param-limiter user_id:25% (default [])
  -http-rewrite-url value
        Rewrite the request url based on a mapping:
        gor --input-raw :8080 --output-http staging.com --http-rewrite-url /v1/user/([^\/]+)/ping:/v2/user/$1/ping (default [])
  -http-set-header value
        Inject additional headers to http reqest:
        gor --input-raw :8080 --output-http staging.com --http-set-header 'User-Agent: Gor' (default [])
  -http-set-param value
        Set request url param, if param already exists it will be overwritten:
        gor --input-raw :8080 --output-http staging.com --http-set-param api_key=1 (default [])
  -input-dummy value
        Used for testing outputs. Emits 'Get /' request every 1s (default [])
  -input-file value
        從一個檔案中讀取請求
        gor --input-file ./requests.gor --output-http staging.com (default [])
  -input-http value
        從一個http介面讀取請求
        # Listen for http on 9000
        gor --input-http :9000 --output-http staging.com (default [])
  -input-raw value
        Capture traffic from given port (use RAW sockets and require *sudo* access):
        # Capture traffic from 8080 port
        gor --input-raw :8080 --output-http staging.com (default [])
  -input-tcp value
       用來在多個gor之間流轉流量
        # Receive requests from other Gor instances on 28020 port, and redirect output to staging
        gor --input-tcp :28020 --output-http staging.com (default [])
  -memprofile string
        write memory profile to this file
  -middleware string
        Used for modifying traffic using external command
  -output-dummy value
        用來測試輸入,列印出接收的資料. (default [])
  -output-file value
        把進入的請求寫入一個檔案中
        gor --input-raw :80 --output-file ./requests.gor (default [])
  -output-http value
        轉發進入的請求到一個http地址上
        # Redirect all incoming requests to staging.com address 
        gor --input-raw :80 --output-http http://staging.com (default [])
  -output-http-elasticsearch string
        把請求和響應狀態傳送到ElasticSearch:
        gor --input-raw :8080 --output-http staging.com --output-http-elasticsearch 'es_host:api_port/index_name'
  -output-http-redirects int
        設定多少次重定向被允許
  -output-http-stats
        每5秒鐘輸出一次輸出佇列的狀態 
  -output-http-timeout duration
       指定http的request/response超時時間,預設是5秒
  -output-http-workers int
        gor預設是動態的擴充套件工作者數量,你也可以指定固定數量的工作者
  -output-tcp value
        用來在多個gor之間流轉流量
        # Listen for requests on 80 port and forward them to other Gor instance on 28020 port
        gor --input-raw :80 --output-tcp replay.local:28020 (default [])
  -output-tcp-stats
        每5秒鐘報告一次tcp輸出佇列的狀態
  -split-output true
        By default each output gets same traffic. If set to true it splits traffic equally among all outputs.
  -stats
        開啟輸出佇列的狀態
  -verbose
        Turn on more verbose output
複製程式碼

當然最好的還是直接關注作者的 git 專案:https://github.com/buger/gor/wiki

下面是我基於這個工具做的一個 Middleware 的介紹。 關於 Middleware 的原理建議看看 https://github.com/buger/gor/wiki/Middleware ,不再贅述。這裡介紹下我做的 MiddleWare 實現的功能:

  1. 我需要將生產環境的請求回放到測試環境,然後將生產的響應和測試的響應結果做比對,以校驗功能重構是否正常。所以需要在生產的日誌結果中加一個標記(比如自增長的ID或隨機數等),同時在請求回放的時候能夠將該標記帶到測試環境去。我原來想法是加一個自定義的請求 Header ,經過試驗發現這並不能將結果帶到測試輸出的請求響應檔案中,導致無法根據兩份日誌檔案比對。所以直接在請求體的第一行開頭中加上一個自定義固定的 URL 引數:GorRequestId=***&,這個 GorRequestId 的值取的就是請求塊中第一行的第二項。根據git上的描述,該值本來就是作者來做 request 和 response 的比對用的。

  2. gor 支援根據 URL 匹配過濾請求,但目前還不能同時過濾出請求對應的響應,我通過自定義的 java 版 middleware 來實現了這個需求,原理是在解析請求塊的時候記錄下需要輸出的 URL 的 requestId 到一個 HashSet 中,在解析響應體的時候根據 requestId 匹配過濾輸出。利用的就是請求和響應公用一個 requestId 這個特性。(這個問題我已經向作者提了 isssue :https://github.com/buger/gor/issues/344 ,根據回覆後續會實現該功能。)

下面就是我的程式碼實現:

package go.middleware;

import javax.xml.bind.DatatypeConverter;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.HashSet;
import java.util.Set;

/**
 * Gor中介軟體Java版本,增強的功能有:
 *
 * 1.在請求體中注入引數GorRequestId,用於請求回放時的原始請求比對
 * 2.支援根據url配置過濾請求和響應的輸出
 * <p>
 * Created by niwei on 16/7/22.
 */
public class Stdout {
    private static final String SPLITTER_HEADER_BODY_SPLITTER = "\r\n\r\n";
    private static final String SPLITTER_HEAD_FIRST_LINE = "\n";
    private static final String SPLITTER_HEADER_ITEM = " ";
    /**
     * payload type, possible values: 1 - request, 2 - original response, 3 - replayed response
     */
    private static final String PAYLOAD_TYPE_REQUEST = "1";
    private static final String PAYLOAD_TYPE_ORIGINAL_RESPONSE = "2";

    /**
     * 定義新增加的requestId引數名稱
     */
    private static String INJECT_TO_REQUEST_ENTITY_REQUEST_ID = "GorRequestId";

    /**
     * 定義需要輸出的請求和響應的requestId
     */
    private static Set<String> recordRequestIds = new HashSet<>();

    /**
     * convert hex to string
     *
     * @param hexStr
     * @return
     * @throws Exception
     */
    public static String hexDecode(String hexStr) throws Exception {
        byte[] decodedHex = DatatypeConverter.parseHexBinary(hexStr);
        String decodedString = new String(decodedHex, "UTF-8");

        return decodedString;
    }

    /**
     * convert string to hex
     *
     * @param str
     * @return
     * @throws Exception
     */
    public static String encodeHex(String str) throws Exception {
        if (str == null) {
            return null;
        }
        byte[] strBytes = str.getBytes();
        String encodeString = DatatypeConverter.printHexBinary(strBytes);

        return encodeString;
    }

    private static String getRequestHeader(String key, String value) {
        StringBuilder result = new StringBuilder(SPLITTER_HEAD_FIRST_LINE);

        result.append(key).append(":").append(SPLITTER_HEADER_ITEM).append(value);

        return result.toString();
    }

    /**
     * gor原始內容增強
     *
     * @param content 原始的gor工具輸出的內容
     * @param allowUrlRegular 允許記錄檔案的url正規表示式
     * @return 增強後輸出的內容
     */
    public static String enhanceContent(String content, String allowUrlRegular) {
        if ((allowUrlRegular == null) || (allowUrlRegular.trim().equals(""))){
            allowUrlRegular = "*";
        }

        String result = content;

        /**
         * get first line content
         */
        String[] lines = content.split(SPLITTER_HEAD_FIRST_LINE);
        if (lines == null || lines.length <= 1) {
            return result;
        }
        String firstLine = lines[0];
        String secondLine = lines[1];

        String[] firstLineItems = firstLine.split(SPLITTER_HEADER_ITEM);
        if (firstLineItems.length != 3) {
            return result;
        } else {
            String payloadType = firstLineItems[0];
            String requestId = firstLineItems[1];

            if (PAYLOAD_TYPE_REQUEST.equals(payloadType)) {
                String[] secondLineItems = secondLine.split(SPLITTER_HEADER_ITEM);
                String url = secondLineItems[1];
                String uri = url;
                int urlIndex = url.indexOf("?");
                if (urlIndex > 0) {
                    uri = url.substring(0, urlIndex);
                }

                String requestIdPair = INJECT_TO_REQUEST_ENTITY_REQUEST_ID + "=" + requestId + "&";
                result = content.replaceFirst(SPLITTER_HEADER_BODY_SPLITTER, SPLITTER_HEADER_BODY_SPLITTER + requestIdPair);

                boolean isMatch = false;
                String[] allowUrls = allowUrlRegular.split(",");
                for (String allowUrl : allowUrls) {
                    if (uri.matches(allowUrl)){
                        recordRequestIds.add(requestId);
                        isMatch = true;
                        break;
                    }
                }
                if(!isMatch){
                    //URL不能匹配上的則不輸出到檔案
                    result = "";
                }

            } else if (PAYLOAD_TYPE_ORIGINAL_RESPONSE.equals(payloadType)) {
                if (recordRequestIds.contains(requestId)) {
                    recordRequestIds.remove(requestId);
                } else {//不再recordRequestIds記錄中則不輸出到檔案
                    result = "";
                }
            }
        }

        return result;
    }

    /**
     * java go.GorEnhance
     *
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {

        String line;
        StringBuilder allowUrlRegular = new StringBuilder();
        int bytesRead = 0;
        byte[] buffer = new byte[1024];

        try (BufferedInputStream bufferedInput = new BufferedInputStream(Class.class.getClassLoader().getSystemResourceAsStream("go/middleware/allow-url.txt"))) {
            while ((bytesRead = bufferedInput.read(buffer)) != -1) {
                allowUrlRegular.append(new String(buffer, 0, bytesRead));
            }
        }

        BufferedReader stdin = new BufferedReader(new InputStreamReader(
                System.in));
        while ((line = stdin.readLine()) != null) {
            System.out.println(encodeHex(enhanceContent(hexDecode(line), allowUrlRegular.toString())));
        }

    }
}
複製程式碼

在執行 gor 命令時,加上引數 --middleware "java go.middleware.Stdout" 就可以了。程式碼中的 go/middleware/allow-url.txt 是在當前類的同級目錄下增加的一個 URL 過濾的配置檔案:比如 .confirm. ,就將只記錄 URL 中包含 confirm 的請求,如果有多項 URL 則直接以逗號(,)分割即可。

本專案原始碼已經放在 github 上:https://github.com/niweicumt/copyflow

相關文章