Thiết lập cài đặt NGINX làm Load Balancer cho Backend Server

Thiết lập cân bằng tải(load balancer) trong hệ thống máy chủ Nginx

1. Thế nào Load Balancing

Load Balancing hay còn gọi là Cân bằng tải ?? một kỹ thuật thường được sử dụng để tối ưu hóa việc sử dụng tài nguyên, băng thông, giảm độ trễ, và tăng cường khả năng chịu lỗi.

Khi chúng ta có nhiều hơn một web server, cùng với đó là sự gia tăng lưu lượng truy cập thì việc bổ sung thêm một máy chủ để phân phối lưu lượng này một cách hợp lý là cần thiết. Máy chủ được bổ sung này được gọi là Load balancer

Load balancers thường được chia thành hai loại chính: Layer 4 và Layer 7.

Về mặt công cụ thì xin trả lời luôn là có rất nhiều tool hỗ trợ giải quyết vấn đề Load Balancing. Nổi bật trong số đó là Nginx và HA.

2. Sử dụng NGINX làm load balancer

Trong bài viết này mình sẽ sử dụng 3 server đều được cài đặt nginx, với vai trò như sau:

Cả 2 webserver 1 & 2 đều có chứa source code của một ứng dụng php và kết nối tới chung một MariaDB server.

Công việc của chúng ta là cấu hình sao cho khi người dùng truy cập vào vhost thì Master server sẽ điều hướng tới một trong hai Backend server, đồng thời không để mất session người dùng.

2.1. Cấu hình Upstream trên Master

Để bắt đầu sử dụng NGINX với một nhóm các máy chủ web, đầu tiên, bạn cần khai báo upstream group. Directive này được đặt trong http block.

http {
    upstream backend {
        server backend1;
        server backend2;
    }
}

Ở đây backend1 và backend2 chính là server name của 2 máy chủ web, ta có thể thay bằng địa chỉ IP tương ứng.

Để truyền các request từ người dùng vào một group các server, tên của group được truyền vào với directive proxy_pass (hoặc fastcgi_passmemcached_passuwsgi_passscgi_pass tùy thuộc vào giao thức). Trong bài viết này, virtual server chạy trên NGINX sẽ truyền tất cả các request tới backend upstream server:

server {
    location / {
        proxy_pass http://backend;
    }
}

Kết hợp lại ta có cấu hình sau cho Master server:

http {
    upstream backend {
        server backend1;
        server backend2;
    }
    server {
        location / {
            proxy_pass http://backend;
        }
    }
}

2.2. Lựa chọn thuật toán cân bằng tải

Có rất nhiều thuật toán cho mọi người lựa chọn như round-robinleast_connleast_timeip_hash, …

  • Trong bài viết này mình lựa chọn round-robin: Các request lần lượt được đẩy về 2 server backend1 và backend2 theo tỉ lệ dựa trên server weights, ở đây là 1:1:
upstream backend {
    server backend1;
    server backend2;
}
  • Ngoài ra nếu bạn muốn sử dụng thuật toán least_conn (sử dụng phương pháp này load balancer sẽ điều hướng request tới server có ít active connection nhất) thì hãy thiết lập config như sau:
upstream backend {
    least_conn;
    server backend1;
    server backend2;
}
  • Hoặc có thể đánh trọng số (weight) cho các server, server có trọng số weight lớn sẽ được ưu tiên xử lý các request. Với ví dụ bên dưới server backend1 sẽ nhận lượng request gấp đôi server backend2:
upstream backend {
    server backend1 weight=4;
    server backend2 weight=2;
}

2.3. Bảo toàn session người dùng

Hãy thử tưởng tượng bạn có một ứng dụng yêu cầu đăng nhập, nếu khi đăng nhập, session lưu trên Backend 1, sau một hồi request lại được chuyển tới Backend 2, trạng thái đăng nhập bị mất, hẳn là người dùng sẽ vô cùng nản. :rage:

Để giải quyết vấn đề này, chúng ta có thể lưu session vào memcached hoặc redis. Tất nhiên việc cài cắm thêm 1 thứ gì đó lên server thì không phải ai cũng thích, hơn nữa nếu có nhiều hơn 2 server memcached hoặc redis bạn sẽ cần cấu hình Replicate cho các server này.


Khi làm product cho Khách hàng tôi đã từng gặp bài toán website sử dụng 2 server memcached để share session (Việc read & write session sử dụng memcached driver) người dùng giữa nhiều cụm web server backend sử dụng Apache + PHP 5.6 + Laravel 5.2. Và khi đọc log thì ngỡ ngàng việc xảy ra quá nhiều lỗi:

TokenMismatchException in VerifyCsrfToken.php

Đơn giản chỉ vì việc đồng bộ hóa session giữa 2 server memcached diễn ra quá chậm, bị ảnh hưởng bởi yếu tố Network, I/O

Nhanh gọn hơn NGINX có cung cấp sticky directive, giúp NGINX tracks user sessions và đưa họ tới đúng upstream server.

Tham khảo: https://www.nginx.com/products/session-persistence/

Tuy nhiên, vấn đề ở chỗ NGINX chỉ cung cấp giải pháp này cho phiên bản thương mại: NGINX Plus mà chúng ta chỉ cần bỏ cỡ 2500$ ra mua là xong.

Theo một hướng khác, tại sao ta ko dùng ip_hash làm phương thức cân bằng tải ??

  • Hash được sinh từ 3 chỉ số đầu của một IP, do đó tất cả IP trong cùng C-class network sẽ đc điều hướng tới cùng một backend.
  • Tất cả user phía sau một NAT sẽ truy cập vào cùng một backend.
  • Nếu ta thêm mới một backend, toàn bộ hash sẽ thay đổi, đương nhiên session sẽ mất.

Sau khi tham khảo nhiều giải pháp thì mình tìm được hướng giải quyết hơi tù như sau :3

upstream backend {
    server backend1;
    server backend2;
}
map $cookie_backend $sticky_backend {
    backend1 backend1;
    backend2 backend2;
    default backend;
}
server {
    listen 80;
    server_name localhost;
    location / {
        set $target http://$sticky_backend;
        proxy_pass $target;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
  • proxy_pass: thông báo cho nginx biết địa chỉ của backend cần gửi yêu cầu truy cập tới, giá trị truyền vào có thể là IP Address, Alias, Domain name.
  • proxy_set_header Host: Dòng này rất quan trọng, bởi khi đi qua reverse proxy, nếu giá trị $host empty (không được set), Nginx ở Backend sẽ không thể nhận diện request từ virtual host nào để mà đưa ra xử lý request.
  • proxy_set_header X-Real-IP: Set IP của request client vào header khi gửi request vào backend
  • proxy_set_header X-Forwarded-Proto: Cho backend biết giao thức mà client gửi request tới Proxy, httphay https
  • Step 1: Khi user lần đầu tiên truy cập vào Master server, lúc đó sẽ ko có backendcookie nào được đưa ra, và dĩ nhiên $sticky_backendNGINX variable sẽ được chuyển hướng tới upstream group. Trong trường hợp này , request sẽ được chuyển tới Backend 1 hoặc Backend 2 theo phương thức round robin.
  • Step 2: Trên các webserver Backend 1 và Backend 2, ta cấu hình ghi các cookie tương ứng với mỗi request đến:
server {
    listen 80 default_server;
    ...
    location ~ ^/.+\.php(/|$) {
        add_header Set-Cookie "backend=backend1;Max-Age=3600";
        ...
    }
}

server {
    listen 80 default_server;
    ...
    location ~ ^/.+\.php(/|$) {
        add_header Set-Cookie "backend=backend2;Max-Age=3600";
        ...
    }
}

Dễ thấy nếu request được pass vào backend nào, thì trên client của user sẽ ghi một cookie có name=backend & value=backend1 or backend2 tương ứng.

  • Step 3: Mỗi khi user request lại tới Master, NGINX sẽ thực hiện map$cookie_backend với $sticky_backendtương ứng và chuyển hướng người dùng vào server đó qua proxy_pass.

Không biết cách này có tốt không, nhưng ở mức độ demo thì vẫn tạm ổn. Nếu chẳng may server tương ứng với cookie lăn ra chết thì session vẫn ra đi mãi mãi 🙁

Hiện tại mình đang set cookie valid trong khoảng thời gian 1h. Nếu qua 1h thì cookie sẽ hết hạn và người dùng có thể bị chuyển qua server khác :joy: Nếu muốn chắc chắn hơn ta có thể bổ sung thêm 1 lớp check với IP address ở phía sau cookie check.

2.4. Health checks

Health checks (Giống kiểm tra sức khỏe vậy :3) là thực liên tục việc kiểm tra các server trên upstream được khai báo trong config của bạn để tránh việc điều hướng các request của người dùng vào các server không hoạt động. Tóm lại là việc này nhằm đảm bảo người dùng không nhìn thấy các page thông báo lỗi khi chúng ta tắt đi 1 server nào đó, hoặc server nào đó đột xuất bị lỗi không hoạt động.

  • max_fails: Số lần kết nối không thành công trong một khoảng thời gian nhất định tới backend server. Giá trị mặc định là 0 (disabled heath checks)
  • fail_timeout: Khoảng thời gian xảy ra số lượng max_fails kết nối không thành công. Giá trị mặc định là 10.

Khi có 1 server backend bị fail, nginx master sẽ điều hướng toàn bộ các traffic sang lần lượt các backend còn lại.

upstream backend {
    server backend1 max_fails=3 fail_timeout=10s;
    server backend2 max_fails=3 fail_timeout=10s;
}
  • Quay lại mục [2.3. Bảo toàn session người dùng] việc điều hướng tới server nào đang nắm giữ session dựa vào $sticky_backend, tuy nhiên nếu $sticky_backend=backend1 mà server backend 1 ra đi thì sao ? lúc này ta buộc phải chuyển hướng các user ở backend 1 sang các server backend còn lại. Ở đây mình trigger event set lại $sticky_backend sang server khác khi có lỗi Gateway Time-out xảy ra trên proxy server.
upstream backend {
    server backend1;
    server backend2;
}
...
server {
    listen 80;
    server_name localhost;
    location / {
        set $target http://$sticky_backend;
        proxy_pass $target;
        ...
        # 504 Gateway Time-out
        error_page 504 = @backend_down;
    }

    location @backend_down {
      proxy_pass http://backend;
    }
}

PS: Nếu có $ thì có thể dùng add-on health checks của Nginx Plus: https://www.nginx.com/products/application-health-checks/

3. Thực hành

3.1. Setup

Làm thế nào để kiếm được 3 con server để triển khai thử bây giờ ??? Đừng lo, Docker sẽ giúp bạn tạo ra hàng chục, hàng trăm server trong 1 nốt nhạc ! Mặc dù về bản chất thì ko giống server vật lý nhé :v
.
Tuy nhiên nếu bạn không muốn phải cấu hình khổ sở như bài viết này mà vẫn còn Load Balancer thì Docker cũng cung cấp sẵn rồi (yaoming), về hàng của Docker thì mời qua đây tham khảo: https://github.com/docker/dockercloud-haproxy

Chuẩn bị:

Command:

$ git clone git@github.com:euclid1990/nginx-load-balancing.git
$ cd nginx-load-balancing
$ docker-compose up

Demo này mình chạy trên Ubuntu host, nếu chạy trên Mac hoặc Windows có thể phải sửa lại file docker-compose.ymlcho phù hợp. (Để hiểu thêm về composer file, vui lòng tham khảo https://docs.docker.com/compose/compose-file/)

Khi làm demo này, phần mình cảm thấy rắc rối nhất là generate ra file config cho Master server, vì nếu không khai báo IP của container Backend 1 & Backend 2 thì NGINX không tự động resolve đc host name theo tên service, trong khi IP của 2 container này không cố định. Dưới đây là file config cho Nginx Master giữ vai trò Load Balancer giữa các server Backend.

# Resolver a name of container from docker host
{{ range $host, $containers := groupByMulti $ "Env.BACKEND" "," }}
    {{ range $index, $value := $containers }}
    {{ $network := index $value.Networks 0 }}
    {{ $alias := index $value.Labels "com.docker.compose.service" }}

upstream {{ $alias }} { server {{ $network.IP }}; }

    {{ end }}
{{ end }}

upstream backend {
    server nginx-backend1 max_fails=3 fail_timeout=10s;
    server nginx-backend2 max_fails=3 fail_timeout=10s;
}

map $cookie_backend $sticky_backend {
    backend1 nginx-backend1;
    backend2 nginx-backend2;
    default backend;
}

server {
    listen 80;

    server_name localhost;

    location / {
        resolver 127.0.0.1;
        set $target http://$sticky_backend;
        proxy_pass $target;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 5;

        # 504 Gateway Time-out
        error_page 504 = @backend_down;
    }

    location @backend_down {
      proxy_pass http://backend;
    }
}

Từ file template này và docker-gen (https://github.com/jwilder/docker-gen), chúng ta sẽ sinh ra được file /etc/nginx/conf.d/default.conf cho Master server.
Có thể lần đầu nhìn file template này hơi khó hiểu, tuy nhiên sẽ không có gì xa lạ nếu bạn đã từng code Go-lang, thực chất nó chỉ là file template với cú pháp Go-lang để pass param vào. Chi tiết: https://golang.org/pkg/text/template/

3.2. Build & Run

Sau khi quá trình build và run container hoàn tất. Ta sẽ có 3 cụm máy chủ có thể access theo địa chỉ sau:

Ở đây source code web có được chỉnh sửa một chút, để ta dễ thấy việc chuyển hướng tới các máy chủ khác nhau khi truy cập qua Load balancer Master

Trên các browser truy cập vào địa chỉ của Master:

Dễ thấy Master đã chuyển hướng ta tới 2 server khác nhau, trên các server Backend lần lượt sinh các session _tokenkhác nhau.
Đến đây, ta thử submit dữ liệu lên Database coi sao :heart_eyes:

Trên mỗi Form submit đều có csrf _token, và source code php mình có validate lại trường này có khớp với session_token trên server hay không. Nhằm kiểm chứng việc server vẫn giữ lại session cho người dùng.

Với mỗi request tới Backend, chúng ta đều ghi cookie cho Backend tương ứng để chuyển hướng đúng người dùng về server đầu tiên họ vào, tránh việc bị mất session khi truy cập.

4. Tham khảo

Nguồn bài viết: https://viblo.asia