Thousands of applications are hosted on Scalingo, they can be deployed, restarted or scaled at any time. Each of these operations impacts the internal topology of our infrastructure. Traffic routers handle the incoming requests and route them dynamically into the infrastructure. Since the beginning, we relied on Nginx to power these servers. With our growth, this solution became unsatisfying in terms of performance, reliability and flexibility, restraining the implementation of new features. We needed to find a way to improve or replace this piece of software to keep growing. In this article, we offer you a travel in the depths of Scalingo infrastructure, telling you how we ended up using OpenResty.
In this article, we will cover the main cons of the current solution leading to the deployment of a brand new shining reverse proxy. Then, we will show how this critical new version has been tested and released to minimize the risk for our customer's applications.
Our infrastructure is composed of a multitude of servers running our customer's
applications. These servers are not reachable from the wild outside world, they
are hidden behind some frontend servers called reverse proxies. A reverse
proxy is a HTTP server which receives requests and forwards them to the
application containers. When a request is received, the proxy looks at the targeted
domain name thanks to the Host
HTTP header for a simple HTTP request or to
the SNI extension in
the case of a secured HTTPS connection. Once the domain name is defined, the connection
is forwarded to the container(s) of the application matching this domain name.
In a second time, the application container processes the request and sends the
answer to the client through the reverse proxy.
Configuring a reverse proxy commonly takes place in one or multiple configuration files in order to setup the destination of the requests depending on the targeted domain name, sometime called virtual hosts. When the reverse proxy boots, it reads the configuration files and creates data structures, it builds a routing table which is stored in memory. This table is not modified after the initialization to speed up the processing of the requests. In our case, a modification of the routing table is mandatory in different cases: the deployment of an application, the restart of an application, the scaling of an application, etc. When such event occurred, we had to reload the configuration by reading all the files again and re-build the routing table in memory. This method to handle the configuration is limiting when you have thousands of websites to serve.
Since the beginning of Scalingo, these reverse proxies where powered by good old Nginx instances with thousands of configuration files, one for each hosted application. The cons of such a solution became too important to be ignored:
All these problems became more frequent with the number of configuration changes increasing over time.
For these reasons we have been investigating solutions to make the configuration of a reverse proxy more dynamic.
In our quest to find a good dynamic reverse proxy, we found OpenResty, a dynamic web platform based on Nginx and LuaJIT. Being used by big companies like Cloudflare, we are confident about the serious and durability of this project. OpenResty is not an out-of-the-box solution. It still requires some Lua development to perfectly fit our infrastructure. Three main components have to be developed for our OpenResty setup to be able to fully replace the legacy Nginx:
TLS Certificate / Key: every application hosted on Scalingo has a certificate associated. With our OpenResty integration, certificates are now stored in a Redis database and are fetched dynamically when a request reaches a reverse proxy. Additionally, to prevent hitting Redis at each incoming HTTPS request and reducing latency, a in-memory LRU cache has been developed. When the configuration changes the cache is invalidated using Redis Pub/Sub.
Locate application inside Scalingo's infrastructure: every application is available on one or multiple servers on Scalingo's infrastructure. The reverse proxy must know the destination of all incoming requests. These information are also stored in Redis and cached to get the best performance possible.
Logs management: when using our legacy Nginx setup, it was configured to store the logs in one file for each application. No aggregation was possible, and it was a really static manner log the connection information. Given the dynamic nature of OpenResty, much more possibilities are opened to us. A micro-service written in Go has been created, which aims at receiving logs stream and handle these logs exactly as we wish (file system, indexing, message queue, etc.)
The biggest part in the development of this new reverse proxy was the testing part. We wanted to ensure that OpenResty really outperforms the legacy Nginx setup in regard with the outlined cons. Both the new and the old setup have been extensively tested in order to compare them. In this section, we will explain the experiments we conducted and provide the results to extensively compare OpenResty with Nginx.
In the remaining of this document, we will call Legacy the old setup with Nginx only and OpenResty the new one.
Load tests were the first achieved experiments on both reverse proxies. The tool Vegeta v6.1.1 has been used in order to send a high number of HTTP requests to each reverse proxy. Four parameters are varying for this experiment:
In order to compare both solutions, the following metrics have been defined:
On the following images, the mean response time and the 99th percentile are displayed. It concerns the experiments without reloading the configuration. Results are displayed for the 1 minute long experiment as there wasn't any noticeable difference between the different durations. The x-axis is the number of requests per second and the y-axis is the mean response time. The pink bars show the results for the experiments with Legacy and the blue bars show the results for OpenResty. Each bar displays a line on top to show the 99th percentile. Its purpose is to help us understand how are distributed the values behind the average. Long story short: the longer the line, more distributed the values are (it's bad!).
The HTTP results are really close between OpenResty and Legacy. At 500 requests per second, the average response time fluctuate from 22 ms to 27 ms. At 750 requests per second, the average response time is slightly higher. But above all, some requests take much more time to receive an answer with a 99th percentile of 45 ms. At 1000 requests per second, the variation of response time becomes unreasonable with a 99th percentile of 246 ms.
When using HTTPS, the results are slightly unfavourable for Legacy with unreasonable response time starting from 500 requests per second (187 ms on average) whereas response time with OpenResty is 50 ms on average.
Let's now have a look at the following experiments in which the configuration is reloaded frequently. The results are still in favor of OpenResty. However, in both cases (OpenResty and Legacy), the performances degraded due to the frequent reload of the configuration.
Regarding this first batch of experiments we can conclude that OpenResty and Legacy both provide similar results with a slight advantage for OpenResty when handling HTTPS requests which is a good point as said previously, three quarters of incoming requests are using HTTPS. However, beyond 500 requests per second, the reverse proxy does not respond in reasonable delay in the worst case scenario. As of today, in production, our reverse proxies receive from 80 to 170 requests per second. This results is a good gauge for us to detect when additional proxies need to be deployed.
After validating that the OpenResty setup can handle the actual load of Scalingo, we want to ensure that some of the problems raised when using Legacy fade out with OpenResty. One of these really annoying problem affect WebSocket connections. WebSockets are full-duplex channels often used to ease real time communication between a client and a server.
When using Legacy, updating the routing table requires to reload the
configuration files. Nginx spawns new workers which read the configuration
files then kill the previous worker when the newcomers are ready to accept
connections. This method ensures no downtime in the process as Nginx does not
break existing connections. If one of the old workers still has an open
WebSocket connection, it goes to the state shutting down
: it does not close
the existing connections but do not accept new one. When all the existing
connections are closed, the worker can gracefully shutdown. In the meantime,
these workers are using resources: 400 MB of memory on average. If a high
number of configuration reload occurs in a short interval of time, resources
used by these workers are not negligible. Hence, we developed a script which
aims at closing the WebSocket connections to free the resources. In production,
this script must be executed every 15 minutes at peak time. This sudden
connection cut led to problem for some applications and many questions about
that to our support team.
Thanks to its dynamic nature, OpenResty configuration is not stored in
configuration files but in a Redis database. Hence, the configuration files do
not need to be reloaded: Nginx does not need to spawn new workers when the
routing table are updated. Nginx workers should not be in the shutting down
state and ongoing WebSocket connections do not need to be brutally killed to
free the resources.
The following experiments aim at confirming that long running WebSocket
connections are not affected by an update of the Nginx routing table. To make
these experiments, A WebSocket connection is opened to each reverse proxy
(Legacy and OpenResty) using the command line tool
wscat
. The connection can be kept
opened for a long time if it sends a keepalive message at regular intervals.
(Idle connections are
closed after 30
seconds). The command is:
> wscat --connect ws://{openresty,legacy}.scalingo.com/sockjs/6l78edj/websocket --keepalive 30
Then, the routing table is updated by changing the number of containers
associated to the targeted application
(sample-ruby-sinatra
):
> SCALINGO_API_URL=https://api-staging.scalingo.com scalingo --app sample-ruby-sinatra scale web:+1
As expected, the Nginx worker does not shutdown immediately with Legacy as it waits for the WebSocket connection to close. The following commands highlights this behavior:
> ps aux | grep nginx
www-data 13377 3.9 6.4 [...] 15:04 0:06 nginx: worker process is shutting down
www-data 13740 8.2 6.4 [...] 15:05 0:08 nginx: worker process
The process 13377
waits for the WebSocket connection to shutdown. The process
13740
is the new process spawned after the configuration reload. Killing the
process 13377
will shut the connection down.
On OpenResty side, no worker is in the shutting down
state:
> ps aux | grep nginx
root 4044 0.0 0.0 [...] Feb06 0:00 nginx: master process openresty
www-data 4046 10.8 3.0 [...] Feb06 414:38 nginx: worker process
WebSocket connections are not stopped by an update of the routing table.
The last case to test is about the logs. When using Legacy, Logs were configured to be stored in one file per application. Given the dynamic nature of OpenResty, the same simple setup is not usable anymore. A micro-service written in Go has been built, which aims at receiving logs stream and handle them as we wish (writing on disk, send to indexation service etc.). OpenResty has been configured to send the logs per packet of 64 KB. We need to ensure that this service will handle the load.
The first log storage backend developed has been to the file system: when receiving logs, the logger opens one file for the logs of each application. It keeps internally a structure of all the open file descriptors. At some point, log rotation becomes mandatory to prevent a too big log file. Our service must close the old file descriptor after a log rotation to acquire the file descriptor of the new log file. As this procedure use resources, we need to ensure that the performance do not degrade too quickly with a frequent reload of the file descriptors.
For this experiment, a small program has been written to send logs per packet of 64 KB to the logger service. Two parameters vary for these tests:
The duration of the experiment is 1 minute.
We want to ensure that the service handles the incoming logs faster than they arrive. At the end of the experiment, the amount of remaining lines of logs are displayed. If it is greater than 0, this service do not handle the lines of logs quickly enough.
The results displayed here are those of the worst case scenario (i.e. with a reload of the file descriptors) in the following table.
Logs packets per second | Remaining log lines |
---|---|
100 | 0 |
250 | 0 |
312 | 4096 |
375 | 4096 |
This service handles up to 250 packets of 64 KB logs per second in the worst case scenario. Hence, the logger handles a maximum of \(250\times 64 = 16000~KB/s\). We noted in a previous section that one reverse proxy cannot handle more than 500 requests per second. This service would be overloaded if every log line weights \(\frac{16\,000}{500} = 32~KB\). As it seems unreasonably high, this service is ready for production use!
New reverse proxies have been deployed a few weeks ago without any of you noticing (nearly ;-) and are very happy with the results. With this new traffic router based on OpenResty in place, we are ready to sustain Scalingo's growth!
In the process, the tests we conducted on the reverse proxies grew our knowledge about the resilience of the infrastructure.
Thanks to the acquired dynamist, deployment of new features had been greatly eased. It already let us deploy the wonderful Let's Encrypt integration. Stay tuned, new features are on the way!
At Scalingo (with our partners) we use trackers on our website.
Some of those are mandatory for the use of our website and can't be refused.
Some others are used to measure our audience as well as to improve our relationship with you or to send you quality content and advertising.