Testowanie ruchem produkcyjnym. Mirroring – Nginx vs Envoy

Opublikowane przez admin w dniu

Cześć. Pisząc następny wpis do serii “`CI/CD pipeline z użyciem Kubernetesa, AWS, Azure i .NET Core“` natknąłem się na problem z Octopus Deploy, o którym poinformowałem jego deweloperów tutaj. Został on już naprawiony, jednak w międzyczasie popełniłem ten wpis. Wyskoczmy trochę z “hype trainu” k8s.

Wstęp

Jak wiemy, zmiany w aplikacji bywają trudne. Często po wdrożeniu “ulepszonej” wersji aplikacji dostajemy obuchem w głowę, bo nie była ona przetestowana ruchem produkcyjnym. Zrobiliśmy, co mogliśmy: testy jednostkowe, integracyjne, manualne itd., jednak obciążenie aplikacji prawdziwym ruchem często prowadzi do powstania sytuacji, których nie przewidzieliśmy. Aby przetestować ją ruchem produkcyjnym możemy użyć tzw. “`mirroringu requestów“`.

 

Czym jest mirroring ?

Mirroring sam w sobie jest bardzo prostym rozwiązaniem. Polega na skopiowaniu requestu i wysłaniu go do innego serwera i zignorowaniu odpowiedzi. Skopiowany ruch możemy przekierować do infrastruktury, gdzie chcemy przetestować nową wersję aplikacji. Może to być bardzo fajne narzędzie do testów nowych wersji aplikacji bez wpływania na działanie aktualnej.

O czym trzeba pamiętać

Samo skopiowanie requestu zwykle nie wystarczy. Każda aplikacja korzysta z zewnętrznych zależności. Nie możemy po prostu użyć konfiguracji aplikacji produkcyjnej. Powyższe testy będą wymagały oddzielnego środowiska testowego zawierającego oddzielną bazę danych, szynę itp. Może się to okazać kosztowne, np. w przypadku, gdy chcemy przetestować aplikację na realnym ruchu, ale także na realnych danych z bazy. Będziemy musieli odtworzyć jej backup na nowym środowisku. Dodatkowo, aby testy były miarodajne, ważne by zapewnić dobre logowanie, śledzenie i metryki w obu środowiskach (prod i mirror) po to, by móc w jakiś sposób porównać/przeanalizować, czy czegoś nie popsuliśmy.  Popularne narzędzia do tzw. observability to oczywiście Jaeger, Prometheus, Graphana czy ELK. W .NET Core możemy użyć np. Convey, który bardzo ułatwia wdrożenie powyższych do naszej aplikacji. Niestety, mirroringiem możemy przetestować części systemu, które aktualnie przyjmują już jakiś ruch z zewnątrz. Nie przetestujemy tym nowych endpointów, dlatego mirroring najlepiej nadaje się do testów istniejących rozwiązań, np. gdy chcemy przepisać/zrefaktorować istniejący system.

Mirroring Nginx vs Envoy

Przed przystąpieniem do implementacji tego rozwiązania przeprowadziłem testy dwóch popularnych proxy Nginx i Envoya. Stworzyłem dwie proste aplikacje w .NET Core. Pierwsza z nich posiada 4 proste endpointy. Cała konfiguracja potrzebna do testów obydwu rozwiązań znajduje się na moim githubie.

[Route("api/[controller]")]
[ApiController]
public class MirroringController : ControllerBase
{
    [HttpGet("normal")]
    public Task<string[]> GetNormal()
    {
        return Task.FromResult(new[] {"normal"});
    }

    [HttpGet("bugs")]
    public Task<string[]> GetBugs()
    {
        return Task.FromResult(new[] { "bugs" });
    }

    [HttpGet("slow")]
    public Task<string[]> GetSlow()
    {
        return Task.FromResult(new[] { "slow" });
    }

    [HttpPost]
    public void Post([FromBody] Test value)
    {
    }

    public class Test {
        public int Value1 { get; set; }
        public string Value2 { get; set; }
    }

  
}

Każda z nich zwraca prosty tekst odpowiadający temu jak zachowa się mirror. Druga aplikacja posiada odpowiedniki powyższych endpointów.

[Route("api/[controller]")]
[ApiController]
public class MirroringController : ControllerBase
{
    [HttpGet("normal")]
    public Task<string[]> GetNormal()
    {
        return Task.FromResult(new[] { "normal" });
    }

    [HttpGet("bugs")]
    public Task<string[]> GetBugs()
    {
        throw new Exception();
    }

    [HttpGet("slow")]
    public async Task<string[]> GetSlow()
    {
        await Task.Delay(5000);
        return new[] {"slow"};
    }

    [HttpPost]
    public void Post([FromBody] Test value)
    {
    }

    public class Test
    {
        public int Value1 { get; set; }
        public string Value2 { get; set; }
    }

Końcówka normal nie robi nic, bugs rzuca wyjątek, a slow czeka 5 sekund, aż zwróci wynik. Dodatkowo, oba kontrolery posiadają końcówkę POST. Przetestujemy dzięki niej, czy przepisanie całego body requestu wpłynie jakoś na wydajność rozwiązania.

Scenariusze testowe z użyciem nginxa oraz envoya:

  1. Ruch bez mirroringu
  2. Normalny mirroring GET /normal
  3. Normalny mirroring POST
  4. Mirroring, gdzie mirror rzuca wyjątek /bugs
  5. Mirroring, gdzie mirror odpowiada wolno  /slow
  6. Mirroring, gdzie mirror zostanie wyłączony

Dodatkowo, testy zakładają, że nas system posiada obciążenie na poziomie ~200 req/s. Do wytworzenia takiego ruchu użyję narzędzia hey. Wszystkie aplikacje można uruchomić z poziomu plików docker-compose. Oba proxy pozwalają na przekierowanie ruchu częściowo np. “kopiuj tylko 20% requestów”, jednak w poniższych testach kopiowany będzie cały ruch.

NGINX bez mirroringu

Konfiguracja:

events { }

http {

upstream app1 {
  server nginx-app1:5000;
}

server {
    listen 2001;
    location / {
      proxy_pass http://app1;
    }
  }
}

 

Test /normal:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/normal`

 

Wyniki:

 

 

Test endpointu POST:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 -m POST -d {“value1″:1,”value2″:”123”} -T application/json http://localhost:2001/api/mirroring`

 

Wyniki:

 

Komentarz:

Dla pewności możemy sprawdzić, czy nic nie doszło do drugiej aplikacji:

docker logs nginx-app2

NGINX ze zwykłym mirroringiem

Konfiguracja:

events { }

http {
upstream app1 {
  server nginx-app1:5000;
}
upstream app2 {
  server nginx-app2:5003;
}
  server {

    listen 2001;
    location / {

     mirror /mirror;
      proxy_pass http://app1;
    }

    location = /mirror {
      internal;
      proxy_pass http://app2$request_uri;
    }
  }
}

 

Przebudowa kontenerów:

`docker-compose up –build –force-recreate -d`

 

Test /normal

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/normal`

 

Test endpointu POST:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 -m POST -d {“value1″:1,”value2″:”123”} -T application/json http://localhost:2001/api/mirroring`

 

Wyniki:

 

Komentarz:

Wyniki są w miarę podobne. Przy endpoincie GET wynik lekko spadł poniżej ~200 req/s.

NGINX z mirrorem który rzuca błędem

Test /bugs:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/bugs`

 

Wyniki:

 

Komentarz:

Tutaj, jak widać spadek jest już znaczny, około 40% wydajności.

 

NGINX z mirrorem który jest wolny

Test /slow z zwiększonym timeoutem na 10 sekund:

`hey -z 10s -q 210 -n 2100 -c 1 -t 10 http://localhost:2001/api/mirroring/slow`

 

Wyniki:

 

Komentarz:

W przypadku, gdy nasz mirror działa wolno właściwie zabijamy naszą produkcje.

 

NGINX z mirrorem który został wyłączony

Ubijamy mirror:

docker kill nginx-app2

 

Test /normal, timeout 10 sekund:

`hey -z 10s -q 210 -n 2100 -c 1 -t 10 http://localhost:2001/api/mirroring/normal`

 

Wyniki:

 

Komentarz:

Gdy nasz mirror przestanie działać tak naprawdę ubijamy nasz system.

 

Envoy bez mirroringu

Konfiguracja:

admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 2001 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  host_rewrite: envoy-app1
                  cluster: prod
          http_filters:
          - name: envoy.router
  clusters:
  - name: prod
    type: LOGICAL_DNS
    connect_timeout: 5s
    hosts: [{ socket_address: { address: envoy-app1, port_value: 5000 }}]

 

Test /normal:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/normal`

 

Wyniki:

 

Test endpointu POST:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 -m POST -d {“value1″:1,”value2″:”123”} -T application/json http://localhost:2001/api/mirroring`

 

Wyniki:

 

Komentarz:

Do sprawdzenia, czy jakiś request doszedł do mirrora, możemy ponownie użyć:

docker logs envoy-app2

 

Envoy ze zwykłym mirroringiem

Konfiguracja:

admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 2001 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  host_rewrite: envoy-app1
                  cluster: prod
                  request_mirror_policy:
                    cluster: mirrored
                    runtime_fraction: { default_value: { numerator: 100 } }
          http_filters:
          - name: envoy.router
  clusters:
  - name: prod
    type: LOGICAL_DNS
    connect_timeout: 5s
    hosts: [{ socket_address: { address: envoy-app1, port_value: 5000 }}]
  - name: mirrored
    type: LOGICAL_DNS
    connect_timeout: 15s
    hosts: [{ socket_address: { address: envoy-app2, port_value: 5003 }}]

 

Przebudowa kontenerów:

`docker-compose up –build –force-recreate -d`

 

Test /normal:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/normal`

 

Test endpointu POST:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 -m POST -d {“value1″:1,”value2″:”123”} -T application/json http://localhost:2001/api/mirroring`

 

Wyniki:

 

Komentarz:

Bez spadku, lepiej niż NGINX.

Envoy z mirrorem, który rzuca błędem

Test /bugs:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/bugs`

 

Wyniki:

 

Komentarz:

Akceptowalny spadek.

Envoy z mirrorem który jest wolny

Test /slow:

`hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/slow`

 

Wyniki:

 

Komentarz:

Wszystko ok.

Envoy z mirrorem, który został wyłączony

Zabijamy drugą aplikację:

docker kill envoy-app2

 

Test /normal:

hey -z 10s -q 210 -n 2100 -c 1 -t 1 http://localhost:2001/api/mirroring/normal

 

Wyniki:

 

Komentarz:

Ponownie wszystko ok.

 

Podsumowanie

W powyższym teście mirroringu bezwzględnie wygrywa Envoy. Mirroring w Nginx  może z łatwością ubić nasz system. Sam mirroring jest fajnym rozwiązaniem, jednak przez koszty jego wdrożenia osobiście używam go do testowania tylko najbardziej wrażliwych części systemów.

Źródła

https://alex.dzyoba.com/blog/nginx-mirror/

https://norbinsh.github.io/nginx/2018/10/05/mirroring-http-requests-with-nginx.html

https://blog.markvincze.com/shadow-mirroring-with-envoy/