プロキシによる透過的なURLの変換による複数ドメイン・複数テスト環境の並行運用

技術部基盤グループの高橋です。初投稿です。 auカブコム証券では、主にサーバを中心としたハードウェア、データセンター等ファシリティー周りと、ちょいちょいAWSつまみ食いしています。 今回は、【複数ドメイン・複数テスト環境の並行運用】という内容です。皆さんの何かしらのお役に立てれば幸いです。

f:id:UT__TA:20201023093322p:plain:w400
データセンター内観

背景

創業当初からの増築により複雑化したテスト環境。会社の成長とともに案件も大規模化、特にデータベースへの変更規模が多い案件が並列した際にテスト環境も並列で利用可能としたい要望が多くなってきました。 そこで、ある程度当社証券システムをまるっと複製・並列で構築したのですが、なんとPC・スマフォやガラケー含む、インターネット経由でのテスト環境(サイトへアクセス)のための外部公開用の本番URLが、ハードコードされている事実がわかりました。 つまり、環境を作った分だけテスト環境個々のURLをアプリケーションに直接ハードコードせざるを得ない状況でした。

事前知識・課題点の整理

改めて、背景から課題を整理すると以下にまとめることができます。

  • 会員サイトをインターネットに外部公開する際に、一部のチャネル(PCブラウザ・スマートフォン等) で利用する画面遷移におけるURLがハードコードされている
  • 複数のテスト環境をインターネットへ公開する場合、単一のURLドメインをF5ロードバランサー(BIG-IP/LTM)により着信するWebサーバを切り替えているため、並行運用が行えない状態である
  • ログイン処理を含む相当量のハードコードがあり大規模に修正する工数・工期が捻出ができない状態である
  • テスト環境に利用するドメインが本番ドメインのサブドメインであり、今後の拡張と運用負荷を考えるとテスト環境用のドメインを用意すべきである
  • テスト環境はオンプレにあり、ハードウェアも老朽化してきたため、迅速にリソースを調達できるAWSにサーバ毎移行するべきである。
システム構成のイメージ図

サーバに配置されたモジュールにドメインや、Cookieがハードコードされており、さらにロードバランサーとサーバ間はhttps通信をしています。 テスト環境として外部公開する場合は、モジュールにハードコードされたドメインを環境毎にを変更する必要があります。

f:id:UT__TA:20200121085829p:plain
システム構成のイメージ図

プロキシによる透過的なURLの変換

アプリケーションの改修が大規模になりそうであったため、基盤ソリューションでの解決を模索しようと以下を目標に設計を開始。

  • 当該実証にて、当社保有資産(Webアプリケーション)に修正を加えることなく、テスト環境を複数外部公開可能になる。
  • 環境ごとにソースコードの管理(リバースプロキシー以外)が不要になり、リポジトリ管理、デプロイ管理が容易になる。
対策後の構成イメージ図

f:id:UT__TA:20200121073412p:plain
リバースプロキシーによるURLドメインの変換イメージ(※ドメインはサンプルです)

  • プロキシーサーバが、URL,Header,Cookie,Body内のドメインを本番ドメイン<>各テスト環境用ドメインとで相互変換を行う。
  • 着信するURLドメインがIPアドレスとなるためSSL証明書のSAN'sに各Webサーバ・リバースプロキーのIPアドレスを追加する。 -- プロキシーサーバからWebサーバに着信するURLは ://IPアドレス/となるが、HostHeader情報はテスト環境用ドメインとする。

とにかく自前のプロキシーサーバがhttp通信の中身をみて必要な変換処理をしてくれてます。

プロキシーサーバ

プロキシサーバはgolangで書かれたコンテナアプリケーションで、EC2上のLinux上にDockerで動いてます。 簡単な構成概要とサンプルコードも貼り付けておきます。

構成概要

f:id:UT__TA:20200206073509p:plain
go_arch

サンプルコード
//変換表(proxysetting_st.json)
        "hosts" : {
            "s10.testsite.jp"       : {"scheme": "https", "address": "xxx.xxx.xxx.xxx:443", "hostname": "s10.kabu.co.jp"},
            "s20.testsite.jp"       : {"scheme": "https", "address": "yyy.yyy.yyy.yyy:443", "hostname": "s20.kabu.co.jp"},
        // (後略)
//■変換マップを管理(config.go)
func InitializeConfig(configPath string) error {
    var err error = nil
    var once sync.Once
    once.Do(func() {
        var file []byte
        file, err = ioutil.ReadFile(configPath)
        if err == nil {
            var tmp PxProxyConfig
            json.Unmarshal(file, &tmp)
            Config = &tmp
            Config.ConfigPath = configPath
            err = Config.validateAndSetting()
        }
    })
    return err
}
func (this *PxProxyConfig) validateAndSetting() error {
    if this.CommandReceiver.ListenAddress == "" {
        return errors.New("Config error:[commandReceiver/listenAddress] setting is required.")
    }
    if this.WebServer.ListenAddress == "" {
        return errors.New("Config error:[webServer/listenAddress] setting is required.")
    }
    if this.Request.DefaultTarget == "" {
        return errors.New("Config error:[request/defaultTarget] setting is required.")
    }
    if len(this.Request.Hosts) < 1 {
        return errors.New("Config error:[request/hosts] setting is required.")
    } else {
        for k, v := range this.Request.Hosts {
            if v.Scheme != "http" && v.Scheme != "https" {
                return errors.New("Config error:[request/hosts/" + k + "/scheme] setting is allowed [http] or [https].")
            }
            if v.Address == "" {
                return errors.New("Config error:[request/hosts/" + k + "/address] setting is required.")
            }
        }
    }
    array := this.Response.Replace.Cookie
    for i := 0; i < len(array); i++ {
        array[i].FromB = []byte(array[i].From)
        array[i].ToB   = []byte(array[i].To)
    }
    array = this.Response.Replace.Location
    for i := 0; i < len(array); i++ {
        array[i].FromB = []byte(array[i].From)
        array[i].ToB   = []byte(array[i].To)
    }
    array = this.Response.Replace.Body
    for i := 0; i < len(array); i++ {
        array[i].FromB = []byte(array[i].From)
        array[i].ToB   = []byte(array[i].To)
    }
    return nil
}
//ProxyServer(pixyproxy.go)
func (this *ProxyServer) Start() error {
    if this.running {
        return nil
    }
    this.logger.Info("Start ReverseProxy.")
    // Create Transport
    transport, err := this.buildTransport()
    if err != nil {
        return err
    }
    // Create ReverseProxy
    var rp http.Handler
    if transport != nil {
        rp = &httputil.ReverseProxy {
            Director        : this.director,    //リクエスト用ハンドラ
            ModifyResponse  : this.modifier,        //レスポンス用ハンドラ
            Transport       : transport,
        }
    } else {
        rp = &httputil.ReverseProxy {
            Director        : this.director,
            ModifyResponse  : this.modifier,
        }
    }
    // Create HTTP server and start listen
    this.httpserver = http.Server{
        Addr    : this.config.WebServer.ListenAddress,
        Handler : rp,
    }
    err = nil
    go func() {
        this.running = true
        err = this.httpserver.ListenAndServe()
        this.running = false
    }()
    return err
}
// リクエスト処理
func (this *ProxyServer) director(request *http.Request) {
    target := this.getRequestTarget(request.Host)
    if target == nil {
        //(中略)
    } else {
        this.logger.Trace(fmt.Sprintf("Request :[Method:[%s] Host:[%s] Url:[%s]].", request.Method, request.Host, request.URL.String()))
        url := *request.URL
        url.Scheme = target.scheme
        url.Host = target.address
        if request.Body != nil {
            //(中略)

            request.Host = target.hostname  //リクエストはhostヘッダーのみ変換
        }
    }
}
//レスポンス処理
func (this *ProxyServer) modifier(response *http.Response) error {
    // Check ignore
    ignore := response.Request.Header.Get(HEADERNAME_IGNORE)
    if ignore != HEADERVAL_IGNORE {
            //(中略)
        // Exchange cookie
            //(中略)
            response.Header["Set-Cookie"] = cooks    //Cookie書き換え

        // Exchange location
            //(中略)
            response.Header.Set("Location", location) //Location書き換え

        // Exchange body
            //(中略)
            //エンコードの判定、文字列への変換の手間を考え、
            //bodyはバイナリのまま書き換え。(マルチバイト文字変換で不備がある可能性は残る。)
            tmp := bytes.Replace(*buffer, val.FromB, val.ToB, -1) 
    }
    return nil
}

実装結果

想定どおり同一モジュールを複数環境で展開しつつ、環境毎に任意のドメインで外部公開が可能となりました。
プロキシーサーバのEC2インスタンスはT3.smallだけどパフォーマンスは十分でした。(テスト環境であり最大10TPS程度処理できれば問題無いレベルの非機能要件)
プロキシサーバで変換するドメインのマッピング情報をConfigファイルで管理しており、変更に都度デプロイが必要になってしまったので、今後は、パラメータストア等に入れて管理したいと考えています。
また、今回基盤側で対応してしまったが、厳密なテスト環境を求められた際は、リバースプロキシー自体が本番環境に適用されていないため、やはりドメインのハードコードが無いWebアプリケーションへのマイグレーションが必要なため積極的にアプリケーション開発部門を巻き込んで改善に努めていきたいと思います。

これまでにでてきた主な仕様

Webサーバ

  • os  ・・・windows server 2008 ~ 2019
  • web server  ・・・ IIS 6.0 ~ 10.0
  • application  ・・・ ClassicASP , ASP.NET MVC , ASP.NET WebAPI
  • pg lang  ・・・vbscript , C#

プロキシサーバ

  • os  ・・・Amazon linux 2
  • middleware  ・・・docker
  • pg lang  ・・・ golang

ロードバランサー - on premise ・・・ F5 BIGIP LTM - cloud    ・・・ AWS ALB

おわりに

auカブコム証券では一緒に働く仲間を募集しています。
私が所属している システム技術部 基盤グループでは、システム基盤のモダナイゼーションをミッションとし、走攻守を掲げ、「走:スピードに拘る」、「攻:最新技術に拘る」、「守:品質に拘る」ことをモットーに、オンプレミス・クラウド両基盤を提供しています。

採用についてはこちら auカブコム証券株式会社の会社情報 - Wantedly