perfkitbenchmarker/linux_benchmarks/nginx_benchmark.py (388 lines of code) (raw):

# Copyright 2024 PerfKitBenchmarker Authors. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Runs HTTPS load generators against a Reverse Proxy or API Gateway which connects to an Nginx file server . References: https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/ https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx/ https://armkeil.blob.core.windows.net/developer/Files/pdf/white-paper/guidelines-for-deploying-nginx-plus-on-aws.pdf """ import ipaddress from absl import flags from perfkitbenchmarker import background_tasks from perfkitbenchmarker import configs from perfkitbenchmarker import provider_info from perfkitbenchmarker import sample from perfkitbenchmarker.linux_packages import wrk2 FLAGS = flags.FLAGS _FLAG_FORMAT_DESCRIPTION = ( 'The format is "target_request_rate:duration:threads:connections", with ' 'each value being per client (so running with 2 clients would double the ' 'target rate, threads, and connections (but not duration since they are ' 'run concurrently)). The target request rate is measured in requests per ' 'second and the duration is measured in seconds. Increasing the duration ' 'or connections does not impact the aggregate target rate for the client.' ) flags.DEFINE_string( 'nginx_global_conf', 'nginx/global.conf', 'The filename (relative to perfkitbenchmarker/data) of an Nginx global' ' config file that should be applied to the server instead of the default' ' one.', ) flags.DEFINE_string( 'nginx_file_server_conf', 'nginx/file_server.conf', 'The filename (relative to perfkitbenchmarker/data) of an Nginx server' ' config file referenced by the global config.', ) flags.DEFINE_string( 'nginx_server_conf', 'nginx/rp_apigw.conf', 'The filename (relative to perfkitbenchmarker/data) of an Nginx proxy' ' config file referenced by the global config.', ) flags.DEFINE_integer( 'nginx_content_size', 1024, 'The size of the content Nginx will serve in bytes. ' 'Larger files stress the network over the VMs.', ) flags.DEFINE_list( 'nginx_load_configs', ['100:60:1:1'], 'For each load spec in the list, wrk2 will be run once ' 'against Nginx with those parameters. ' + _FLAG_FORMAT_DESCRIPTION, ) flags.DEFINE_boolean( 'nginx_throttle', False, 'If True, skip running the nginx_load_configs and run ' 'wrk2 once aiming to throttle the nginx server.', ) flags.DEFINE_string( 'nginx_client_machine_type', None, 'Machine type to use for the wrk2 client if different ' 'from nginx server machine type.', ) flags.DEFINE_string( 'nginx_server_machine_type', None, 'Machine type to use for the wrk2 proxy if different ' 'from nginx upstream server machine type.', ) flags.DEFINE_string( 'nginx_upstream_server_machine_type', None, 'Machine type to use for the nginx server if different ' 'from wrk2 client machine type.', ) flags.DEFINE_boolean( 'nginx_use_ssl', True, 'Use HTTPs when connecting to nginx.' ) flags.DEFINE_enum( 'nginx_scenario', 'reverse_proxy', ['reverse_proxy', 'api_gateway'], 'Benchmark scenario. Can be "reverse_proxy" or "api_gateway". Set to' ' "reverse_proxy" by default.', ) _P99_LATENCY_THRESHOLD = flags.DEFINE_integer( 'nginx_p99_latency_threshold', 100, 'The p99 latency threshold (in milliseconds) for the benchmark to output' ' results.', ) _NGINX_SERVER_PORT = flags.DEFINE_integer( 'nginx_server_port', 0, 'The port that nginx server will listen to. 0 will use ' 'default ports (80 or 443 depending on --nginx_use_ssl).', ) def _ValidateLoadConfigs(load_configs): """Validate that each load config has all required values.""" if not load_configs: return False for config in load_configs: config_values = config.split(':') if len(config_values) != 4: return False for value in config_values: if not (value.isdigit() and int(value) > 0): return False return True flags.register_validator( 'nginx_load_configs', _ValidateLoadConfigs, 'Malformed load config. ' + _FLAG_FORMAT_DESCRIPTION, ) BENCHMARK_NAME = 'nginx' BENCHMARK_CONFIG = """ nginx: description: Benchmarks Nginx server performance. vm_groups: server: vm_spec: *default_dual_core upstream_servers: vm_spec: *default_dual_core vm_count: 6 clients: vm_spec: *default_dual_core vm_count: null """ _CONTENT_FILENAME = 'random_content' _API_GATEWAY_PATH = 'api_old' # refer to data/nginx/rp_apigw.conf _WORKER_CONNECTIONS = 1024 _TARGET_RATE_LOWER_BOUND = 0 _TARGET_RATE_UPPER_BOUND = 1000000 _RPS_RANGE_THRESHOLD = 1000 def GetConfig(user_config): """Load and return benchmark config. Args: user_config: user supplied configuration (flags and config file) Returns: loaded benchmark configuration """ config = configs.LoadConfig(BENCHMARK_CONFIG, user_config, BENCHMARK_NAME) if FLAGS.nginx_client_machine_type: vm_spec = config['vm_groups']['clients']['vm_spec'] vm_spec[FLAGS.cloud]['machine_type'] = FLAGS.nginx_client_machine_type if FLAGS.nginx_server_machine_type: vm_spec = config['vm_groups']['server']['vm_spec'] vm_spec[FLAGS.cloud]['machine_type'] = FLAGS.nginx_server_machine_type if FLAGS.nginx_upstream_server_machine_type: vm_spec = config['vm_groups']['upstream_servers']['vm_spec'] vm_spec[FLAGS.cloud][ 'machine_type' ] = FLAGS.nginx_upstream_server_machine_type return config def _ConfigureNginxServer(server, upstream_servers): """Configures nginx proxy server.""" server.PushDataFile(FLAGS.nginx_global_conf) global_conf_file = FLAGS.nginx_global_conf.split('/')[-1] server.RemoteCommand('sudo cp %s /etc/nginx/nginx.conf' % global_conf_file) server.PushDataFile(FLAGS.nginx_server_conf) server_conf_file = FLAGS.nginx_server_conf.split('/')[-1] server.RemoteCommand( 'sudo cp %s /etc/nginx/conf.d/loadbalance.conf' % server_conf_file ) for idx, upstream_server in enumerate(upstream_servers): server.RemoteCommand( r"sudo sed -i 's|# server <fileserver_%s_ip_or_dns>|server %s|g'" ' /etc/nginx/conf.d/loadbalance.conf' % (idx + 1, upstream_server.internal_ip) ) if FLAGS.nginx_use_ssl: _ConfigureNginxForSsl(server) else: _ConfigureNginxListeners(server) server.RemoteCommand('sudo service nginx restart') def _ConfigureNginxUpstreamServer(upstream_server): """Configures nginx upstream server.""" root_dir = '/usr/share/nginx/html' content_path = root_dir + '/' + _CONTENT_FILENAME upstream_server.RemoteCommand(f'sudo mkdir -p {root_dir}') upstream_server.RemoteCommand( 'sudo dd bs=1 count=%s if=/dev/urandom of=%s' % (FLAGS.nginx_content_size, content_path) ) upstream_server.PushDataFile(FLAGS.nginx_global_conf) global_conf_file = FLAGS.nginx_global_conf.split('/')[-1] upstream_server.RemoteCommand( 'sudo cp %s /etc/nginx/nginx.conf' % global_conf_file ) upstream_server.PushDataFile(FLAGS.nginx_file_server_conf) upstream_server_conf_file = FLAGS.nginx_file_server_conf.split('/')[-1] upstream_server.RemoteCommand( 'sudo cp %s /etc/nginx/conf.d/fileserver.conf' % upstream_server_conf_file ) if FLAGS.nginx_use_ssl: _ConfigureNginxForSsl(upstream_server) else: _ConfigureNginxListeners(upstream_server) upstream_server.RemoteCommand('sudo service nginx restart') def _ConfigureNginxForSsl(server): """Configures an nginx server for SSL/TLS.""" server.RemoteCommand('sudo mkdir -p /etc/nginx/ssl') # Enable TLS/SSL with: # - ECDHE for key exchange # - ECDSA for authentication # - AES256-GCM for bulk encryption # - SHA384 for message authentication server.RemoteCommand( 'sudo openssl req -x509 -nodes -days 365 -newkey ec ' '-subj "/CN=localhost" ' '-pkeyopt ec_paramgen_curve:secp384r1 ' '-keyout /etc/nginx/ssl/ecdsa.key ' '-out /etc/nginx/ssl/ecdsa.crt' ) isipv6 = isinstance( ipaddress.ip_address(server.internal_ip), ipaddress.IPv6Address ) server.RemoteCommand( r"sudo sed -i 's|\(listen 80 .*\)|#\1|g' " r'/etc/nginx/sites-enabled/default' ) server.RemoteCommand( r"sudo sed -i 's|\(listen \[::\]:80 .*;\)|#\1|g' " r'/etc/nginx/sites-enabled/default' ) if not isipv6: server.RemoteCommand( r"sudo sed -i 's|# \(listen 443 ssl .*\)|\1|g' " r'/etc/nginx/sites-enabled/default' ) else: server.RemoteCommand( r"sudo sed -i 's|# \(listen \[::\]:443 ssl .*\)|\1|g' " r'/etc/nginx/sites-enabled/default' ) server.RemoteCommand( r"sudo sed -i 's|\(\s*\)\(listen \[::\]:443 ssl .*;\)|" r'\1\2\n' r'\1ssl_certificate /etc/nginx/ssl/ecdsa.crt;\n' r'\1ssl_certificate_key /etc/nginx/ssl/ecdsa.key;\n' r"\1ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384;|g' " r'/etc/nginx/sites-enabled/default' ) if _NGINX_SERVER_PORT.value: server_port = _NGINX_SERVER_PORT.value replace_str = rf's|\(listen .*\)443 |\1{server_port} |g' server.RemoteCommand( f"sudo sed -i '{replace_str}' /etc/nginx/sites-enabled/default" ) def _ConfigureNginxListeners(vm): """Configures the ports from which nginx listens.""" isipv6 = isinstance( ipaddress.ip_address(vm.internal_ip), ipaddress.IPv6Address ) if not isipv6: vm.RemoteCommand( r"sudo sed -i 's|\(listen \[::\]:80 .*;\)|#\1|g' " r'/etc/nginx/sites-enabled/default' ) else: vm.RemoteCommand( r"sudo sed -i 's|\(listen 80 .*\)|#\1|g' " r'/etc/nginx/sites-enabled/default' ) if FLAGS.nginx_server_port: server_port = FLAGS.nginx_server_port replace_str = rf's|\(listen .*\)80 |\1{server_port} |g' vm.RemoteCommand( f"sudo sed -i '{replace_str}' /etc/nginx/sites-enabled/default" ) # TODO(user): Move to linux_virtual_machine.py if used more broadly. def _TuneNetworkStack(vm): """Tune the network stack to improve throughput. These settings are based on the recommendations in https://armkeil.blob.core.windows.net/developer/Files/pdf/white-paper/guidelines-for-deploying-nginx-plus-on-aws.pdf. Args: vm: The VM to tune. """ if vm.PLATFORM == provider_info.KUBERNETES: # TODO(pclay): Support safe sysctls in Kubernetes. # https://cloud.google.com/kubernetes-engine/docs/how-to/node-system-config # https://kubernetes.io/docs/tasks/administer-cluster/sysctl-cluster/#safe-and-unsafe-sysctls return max_port_num = 65535 small_4k = 4096 large_8m = 8388607 vm.RemoteCommand( f'sudo sysctl -w net.ipv4.ip_local_port_range="1024 {max_port_num}"' ) vm.RemoteCommand( f'sudo sysctl -w net.ipv4.tcp_max_syn_backlog={max_port_num}' ) vm.RemoteCommand(f'sudo sysctl -w net.core.rmem_max={large_8m}') vm.RemoteCommand(f'sudo sysctl -w net.core.wmem_max={large_8m}') vm.RemoteCommand( f'sudo sysctl -w net.ipv4.tcp_rmem="{small_4k} {large_8m} {large_8m}"' ) vm.RemoteCommand( f'sudo sysctl -w net.ipv4.tcp_wmem="{small_4k} {large_8m} {large_8m}"' ) vm.RemoteCommand(f'sudo sysctl -w net.core.somaxconn={max_port_num}') vm.RemoteCommand('sudo sysctl net.ipv4.tcp_autocorking=0') def Prepare(benchmark_spec): """Install Nginx on the server and a load generator on the clients. Args: benchmark_spec: The benchmark specification. Contains all data that is required to run the benchmark. """ clients = benchmark_spec.vm_groups['clients'] server = benchmark_spec.vm_groups['server'][0] upstream_servers = benchmark_spec.vm_groups['upstream_servers'] background_tasks.RunThreaded( lambda vm: vm.Install('nginx'), [server] + upstream_servers ) _ConfigureNginxServer(server, upstream_servers) background_tasks.RunThreaded(_ConfigureNginxUpstreamServer, upstream_servers) background_tasks.RunThreaded(lambda vm: vm.Install('wrk2'), clients) background_tasks.RunThreaded( _TuneNetworkStack, clients + [server] + upstream_servers ) benchmark_spec.nginx_endpoint_ip = server.internal_ip def _RunMultiClient(clients, target, rate, connections, duration, threads): """Run multiple instances of wrk2 against a single target.""" results = [] num_clients = len(clients) def _RunSingleClient(client, client_number): """Run wrk2 from a single client.""" client_results = list( wrk2.Run( client, target, rate, connections=connections, duration=duration, threads=threads, ) ) for result in client_results: result.metadata.update({'client_number': client_number}) results.extend(client_results) args = [((client, i), {}) for i, client in enumerate(clients)] background_tasks.RunThreaded(_RunSingleClient, args) requests = 0 errors = 0 max_latency = 0.0 # TODO(ehankland): Since wrk2 keeps an HDR histogram of latencies, we should # be able to merge them and compute aggregate percentiles. for result in results: if result.metric == 'requests': requests += result.value elif result.metric == 'errors': errors += result.value elif result.metric == 'p100 latency': max_latency = max(max_latency, result.value) error_rate = errors / requests metadata = { 'nginx_scenario': FLAGS.nginx_scenario, 'connections': connections * num_clients, 'threads': threads * num_clients, 'duration': duration, 'target_rate': rate * num_clients, 'nginx_throttle': FLAGS.nginx_throttle, 'nginx_worker_connections': _WORKER_CONNECTIONS, 'nginx_use_ssl': FLAGS.nginx_use_ssl, 'p99_latency_threshold': _P99_LATENCY_THRESHOLD.value, } if not FLAGS.nginx_file_server_conf: metadata['caching'] = True results += [ sample.Sample('achieved_rate', requests / duration, '', metadata), sample.Sample('aggregate requests', requests, '', metadata), sample.Sample('aggregate errors', errors, '', metadata), sample.Sample('aggregate error_rate', error_rate, '', metadata), sample.Sample('aggregate p100 latency', max_latency, '', metadata), ] return results def Run(benchmark_spec): """Run a benchmark against the Nginx server. Args: benchmark_spec: The benchmark specification. Contains all data that is required to run the benchmark. Returns: A list of sample.Sample objects. """ clients = benchmark_spec.vm_groups['clients'] results = [] scheme = 'https' if FLAGS.nginx_use_ssl else 'http' hostip = benchmark_spec.nginx_endpoint_ip hoststr = ( f'[{hostip}]' if isinstance(ipaddress.ip_address(hostip), ipaddress.IPv6Address) else f'{hostip}' ) portstr = f':{FLAGS.nginx_server_port}' if FLAGS.nginx_server_port else '' if FLAGS.nginx_scenario == 'reverse_proxy': # e.g. "https://10.128.0.36/random_content" target = f'{scheme}://{hoststr}{portstr}/{_CONTENT_FILENAME}' # FLAGS.nginx_scenario = 'api_gateway' else: # e.g. "https://10.128.0.36/api_old/random_content" target = ( f'{scheme}://{hoststr}{portstr}/{_API_GATEWAY_PATH}/{_CONTENT_FILENAME}' ) if FLAGS.nginx_throttle: return _RunMultiClient( clients, target, rate=100000000, # 100M aggregate requests/sec should max out requests. connections=clients[0].NumCpusForBenchmark() * 10, duration=60, threads=clients[0].NumCpusForBenchmark(), ) # Binary search for highest RPS under the p99 latency threshold. if _P99_LATENCY_THRESHOLD.value: lower_bound, upper_bound = ( _TARGET_RATE_LOWER_BOUND, _TARGET_RATE_UPPER_BOUND, ) target_rate = upper_bound valid_results = [] while (upper_bound - lower_bound) > _RPS_RANGE_THRESHOLD: results = _RunMultiClient( clients, target, rate=target_rate, connections=clients[0].NumCpusForBenchmark() * 10, duration=60, threads=clients[0].NumCpusForBenchmark(), ) for result in results: if result.metric == 'p99 latency': p99_latency = result.value if p99_latency > _P99_LATENCY_THRESHOLD.value: upper_bound = target_rate else: lower_bound = target_rate valid_results = results target_rate = (lower_bound + upper_bound) // 2 break return valid_results results = [] for config in FLAGS.nginx_load_configs: rate, duration, threads, connections = list(map(int, config.split(':'))) results += _RunMultiClient( clients, target, rate, connections, duration, threads ) return results def Cleanup(benchmark_spec): """Cleanup Nginx and load generators. Args: benchmark_spec: The benchmark specification. Contains all data that is required to run the benchmark. """ del benchmark_spec