Testowanie ruchem produkcyjnym. Mirroring – Nginx vs Envoy

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:
- Ruch bez mirroringu
- Normalny mirroring GET /normal
- Normalny mirroring POST
- Mirroring, gdzie mirror rzuca wyjątek /bugs
- Mirroring, gdzie mirror odpowiada wolno /slow
- 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/