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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
[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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
[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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
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/