April 05, 2022
Ari Birnbaum
@ceiphr
Speed tests are an excellent way to check your network connection speed. Fast network connections are key for enjoying a seamless experience on the internet.
In this tutorial, we will build our own speed test application using Rust Actix, WebSockets, and a simple JavaScript client. Then we will Dockerize our application and add it to the GitHub container registry to deploy it to Koyeb.
Actix is a high-performance and popular framework for Rust that we will use to build our web application. Our application will perform speed tests thanks to a WebSocket connection. WebSockets open a two-way connection between clients and servers, allowing for fast and real-time communication.
By the end of this tutorial, you will be able to test the performance of your network connection using your very own speed test site hosted on Koyeb. Thanks to Koyeb, our application will benefit from native global load balancing, autoscaling, autohealing, and auto HTTPS (SSL) encryption with zero configuration on our part.
To follow this guide, you will need:
To successfully build our speed test site and deploy it on Koyeb, you will need to follow these steps:
To start, create a new rust project:
cargo new koyeb-speed-test
Cargo is a build system and package manager for Rust. In the koyeb-speed-test
directory that was just created, you should see a new Cargo.toml
file. This file is where we will tell Rust how to build our application and what dependencies we need.
In that file, edit the dependencies section to look like this:
[dependencies] actix = "0.13" actix-codec = "0.5" actix-files = "0.6" actix-rt = "2" actix-web = "4" actix-web-actors = "4.1" awc = "3.0.0-beta.21" env_logger = "0.9" futures-util = { version = "0.3.7", default-features = false, features = ["std", "sink"] } log = "0.4" tokio = { version = "1.13.1", features = ["full"] } tokio-stream = "0.1.8"
Great! Now for our project files, make two new directories for our server source code and static files (HTML):
mkdir src static
Populate the src
directory with the following files:
touch src/main.rs src/server.rs
main.rs
will be where we initialize and run the server. server.rs
will be where we define our server logic. Specifically, the WebSocket functionality for performing the speed test.
In the static
directory, create an index.html
file and a 10 MB file that we'll send over the network to test the connection speed:
touch static index.html dd if=/dev/zero of=static/10mb bs=1M count=10
NOTE: MacOS users need to do bs=1m
instead of bs=1M
.
That second command is creating a file that is roughly 10 megabytes of null characters. This way, we know exactly how much data we're sending over the network for calculating the connection speed later.
Our directory structure looks like this:
βββ Cargo.lock βββ Cargo.toml βββ src β βββ main.rs β βββ server.rs βββ static βββ 10mb βββ index.html
Awesome! Our project is looking good. Let's start building our server.
Actix is a high-performance web framework for Rust. It is a framework that provides a simple, yet powerful, way to build web applications. In our case, we'll be using it for two things:
In src/main.rs
, create a basic web server that will serve the index.html
file from the static
folder:
use actix_files::NamedFile; use actix_web::{middleware, web, App, Error, HttpServer, Responder}; // This function will get the `index.html` file to serve to the user. async fn index() -> impl Responder { NamedFile::open_async("./static/index.html").await.unwrap() } #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); log::info!("starting HTTP server at http://localhost:8080"); // Here we're creating the server and binding it to port 8080. HttpServer::new(|| { App::new() // "/" is the path that we want to serve the `index.html` file from. .service(web::resource("/").to(index)) .wrap(middleware::Logger::default()) }) .workers(2) .bind(("127.0.0.1", 8080))? .run() .await }
In this case, anytime someone accesses the /
URL, we will serve the index.html
file from the static
folder using the index()
function. The index()
function gets the file using the NamedFile
struct and then returns it to the caller.
Now we're going to start working with WebSockets. We'll be using the actix-web-actors
crate to handle them. The socket logic will have to do the following:
To set up the WebSocket logic, add all of the following to src/server.rs
:
use std::fs::File; use std::io::BufReader; use std::io::Read; use std::time::{Duration, Instant}; use actix::prelude::*; use actix_web::web::Bytes; use actix_web_actors::ws; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); pub struct MyWebSocket { hb: Instant, } impl MyWebSocket { pub fn new() -> Self { Self { hb: Instant::now() } } // This function will run on an interval, every 5 seconds to check // that the connection is still alive. If it's been more than // 10 seconds since the last ping, we'll close the connection. fn hb(&self, ctx: &mut <Self as Actor>::Context) { ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { ctx.stop(); return; } ctx.ping(b""); }); } } impl Actor for MyWebSocket { type Context = ws::WebsocketContext<Self>; // Start the heartbeat process for this connection fn started(&mut self, ctx: &mut Self::Context) { self.hb(ctx); } } // The `StreamHandler` trait is used to handle the messages that are sent over the socket. impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWebSocket { // The `handle()` function is where we'll determine the response // to the client's messages. So, for example, if we ping the client, // it should respond with a pong. These two messages are necessary // for the `hb()` function to maintain the connection status. fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) { match msg { // Ping/Pong will be used to make sure the connection is still alive Ok(ws::Message::Ping(msg)) => { self.hb = Instant::now(); ctx.pong(&msg); } Ok(ws::Message::Pong(_)) => { self.hb = Instant::now(); } // Text will echo any text received back to the client (for now) Ok(ws::Message::Text(text)) => ctx.text(text), // Close will close the socket Ok(ws::Message::Close(reason)) => { ctx.close(reason); ctx.stop(); } _ => ctx.stop(), } } }
There is a lot going on in this code snippet, here is an explanation of what this code is doing and why it is important.
Sockets are a way of allowing for continuous communication between a server and a client. With sockets, the connection is kept open until either the client or the server closes it.
Each client that connects to the server has their own socket. Each socket has a context, which is a type that implements the Actor
trait. This is where we will be working with the socket.
There are issues with this model. Specifically, how do we ensure the socket is still open if connection interruptions can disconnect it? This is what the hb()
function is for! It is first initialized with the current socket's context and then runs an interval. This interval will run every 5 seconds and will ping the client. If the client does not respond within 10 seconds, the socket will be closed.
Now, update src/main.rs
too so that it can use the WebSocket logic we just wrote:
use actix_files::NamedFile; // Add HttpRequest and HttpResponse use actix_web::{middleware, web, App, Error, HttpRequest, HttpResponse, HttpServer, Responder}; use actix_web_actors::ws; // Import the WebSocket logic we wrote earlier. mod server; use self::server::MyWebSocket; async fn index() -> impl Responder { NamedFile::open_async("./static/index.html").await.unwrap() } // WebSocket handshake and start `MyWebSocket` actor. async fn websocket(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> { ws::start(MyWebSocket::new(), &req, stream) } #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); log::info!("starting HTTP server at http://localhost:8080"); HttpServer::new(|| { App::new() .service(web::resource("/").to(index)) // Add the WebSocket route .service(web::resource("/ws").route(web::get().to(websocket))) .wrap(middleware::Logger::default()) }) .workers(2) .bind(("127.0.0.1", 8080))? .run() .await }
Now that our server logic is finished, we can finally start the server with the following command:
cargo run -- main src/main
Go to your browser and visit http://localhost:8080
. You should see a blank index page.
Currently, if a client were to send text to the server, through the WebSocket, we would echo it back to them.
However, we are not concerned with the text that is sent over the socket. Whatever the client sends, we want to respond with the test file, since that is the sole purpose of our server. Let's update the Text
case in the handle()
function to do that:
// ... Ok(ws::Message::Text(_)) => { let file = File::open("./static/10mb").unwrap(); let mut reader = BufReader::new(file); let mut buffer = Vec::new(); reader.read_to_end(&mut buffer).unwrap(); ctx.binary(Bytes::from(buffer)); } // ...
Now, whenever a client sends text to the server through the WebSocket, we'll write the 10mb file to a buffer and send that to the client as binary data.
Now, we will create a client that can send text to the server and receive the test file as a response.
Open up static/index.html
and add the following to create the client:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Speed Test | Koyeb</title> <style> :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; font-size: 14px; } .container { max-width: 500px; width: 100%; height: 70vh; margin: 15vh auto; } #log { width: calc(100% - 24px); height: 20em; overflow: auto; margin: 0.5em 0; padding: 12px; border: 1px solid black; border-radius: 12px; font-family: monospace; background-color: black; } #title { float: left; margin: 12px 0; } #start { float: right; margin: 12px 0; background-color: black; color: white; font-size: 18px; padding: 4px 8px; border-radius: 4px; border: none; } #start:disabled, #start[disabled] { background-color: rgb(63, 63, 63); color: lightgray; } .msg { margin: 0; padding: 0.25em 0.5em; color: white; } .msg--bad { color: lightcoral; } .msg--success, .msg--good { color: lightgreen; } </style> </head> <body> <div class="container"> <div> <h1 id="title">Speed Test</h1> <button id="start">start</button> </div> <div id="log"></div> <div> <p> Powered by <a href="https://www.koyeb.com/" target="_blank"> Koyeb</a>. </p> </div> </div> <script></script> </body> </html>
When we visit http://localhost:8080
in the browser, we should now see the following:
Great! Now we need to add some JavaScript, so this page can perform the test. Add the following script inside the <script>
tag inside index.html
:
const $startButton = document.querySelector("#start"); const $log = document.querySelector("#log"); // Calculate average from array of numbers const average = (array) => array.reduce((a, b) => a + b) / array.length; const totalTests = 10; let startTime, endTime, testResults = []; /** @type {WebSocket | null} */ var socket = null; function log(msg, type = "status") { $log.innerHTML += `<p class="msg msg--${type}">${msg}</p>`; $log.scrollTop += 1000; } function start() { complete(); const { location } = window; const proto = location.protocol.startsWith("https") ? "wss" : "ws"; const wsUri = `${proto}://${location.host}/ws`; let testsRun = 0; log("Starting..."); socket = new WebSocket(wsUri); // When the socket is open, we'll update the button // the test status and send the first test request. socket.onopen = () => { log("Started."); // This function updates the "Start" button updateTestStatus(); testsRun++; // Get's the time before the first test request startTime = performance.now(); socket.send("start"); }; socket.onmessage = (ev) => { // Get's the time once the message is received endTime = performance.now(); // Creates a log that indicates the test case is finished // and the time it took to complete the test. log( `Completed Test: ${testsRun}/${totalTests}. Took ${ endTime - startTime } milliseconds.` ); // We'll store the test results for calculating the average later testResults.push(endTime - startTime); if (testsRun < totalTests) { testsRun++; startTime = performance.now(); socket.send("start"); } else complete(); }; // When the socket is closed, we'll log it and update the "Start" button socket.onclose = () => { log("Finished.", "success"); socket = null; updateTestStatus(); }; } function complete() { if (socket) { log("Cleaning up..."); socket.close(); socket = null; // Calculates the average time it took to complete the test let testAverage = average(testResults) / 1000; // 10mb were sent. So MB/s is # of mega bytes divided by the // average time it took to complete the tests. let mbps = 10 / testAverage; // Change log color based on result let status; if (mbps < 10) status = "bad"; else if (mbps < 50) status = ""; else status = "good"; // Log the results log( `Average speed: ${mbps.toFixed(2)} MB/s or ${(mbps * 8).toFixed( 2 )} Mbps`, status ); // Update the "Start" button updateTestStatus(); } } function updateTestStatus() { if (socket) { $startButton.disabled = true; $startButton.innerHTML = "Running"; } else { $startButton.disabled = false; $startButton.textContent = "Start"; } } // When the "Start" button is clicked, we'll start the test // and update the "Start" button to indicate the test is running. $startButton.addEventListener("click", () => { if (socket) complete(); else start(); updateTestStatus(); }); updateTestStatus(); log('Click "Start" to begin.');
Nice, our client is complete! Our client sends 10 requests to the server and then calculates the average time it took to complete each request.
For deployment, we'll be using Docker. Docker is a lightweight containerization tool that allows us to run our server in a container.
To Dockerize our server, create a simple Dockerfile
in the root of the project directory. Add the following to it:
FROM rust:1.59.0 WORKDIR /usr/src/koyeb-speed-test COPY . . RUN cargo install --path . EXPOSE 8080 CMD ["koyeb-speed-test-server"]
For consistency, name the working directory after the package name in the Cargo.toml
file. In our case, it's koyeb-speed-test
.
Let's break down what this file is doing. When we build the Docker image, it will download an official existing image for Rust, create the working directory and copy all of our project files into said directory. Then it will run the cargo install
command to install all of our dependencies and expose port 8080.
A small thing that might help build times is to create a .dockerignore
file in the root of the project directory. Add the following to it:
target
This way, when we build the Docker image, it will ignore the target
directory, which is where the cargo build
command creates the final executable.
The last and most important part is that it will run the koyeb-speed-test-server
command to start the server. We'll need to define this command in the Cargo.toml
file:
[package] name = "koyeb-speed-test" version = "1.0.0" edition = "2021" [[bin]] name = "koyeb-speed-test-server" path = "src/main.rs" [dependencies] (* ... *)
The last thing we must do to ensure our project works in the Docker container is to change the bind address in src/main.rs
to 0.0.0.0
instead of 127.0.0.1
:
// ... HttpServer::new(|| { App::new() .service(web::resource("/").to(index)) .service(web::resource("/ws").route(web::get().to(echo_ws))) .wrap(middleware::Logger::default()) }) .workers(2) .bind(("0.0.0.0", 8080))? // Change bind address to 0.0.0.0 .run() .await // ...
Next, build an image for our project:
docker build . -t ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
We can see if it runs by running the following command:
docker run -p 8080:8080 ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
Now that we know our project runs in the container, push it to the registry:
docker push ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
Of course, you can use the container registry you prefer - Docker Hub, Azure ACR, AWS ECR - Koyeb supports those as well. In this guide, we are pushing to GitHub Container Registry.
It's now time to deploy our container image on Koyeb. On the Koyeb Control Panel, click the "Create App" button.
On the app creation page:
ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
.You will automatically be redirected to the Koyeb App page, where you can follow the progress of your application's deployment. In a matter of seconds, once your app is deployed, click on the Public URL ending with koyeb.app
. You should see your speed test site in action!
If you would like to look at the code for this sample application, you can find it here.
You now have your very own speed test site written in Rust, Dockerized, and hosted on Koyeb. Our application natively benefits from global load balancing, autoscaling, autohealing, and auto HTTPS (SSL) encryption with zero configuration from us.
If you'd like to learn more about Rust and Actix, check out actix/examples and actix-web. This article was actually based on the echo example from Actix, found here.
If you would like to read more Koyeb tutorials, checkout out our tutorials collection. Have an idea for a tutorial you'd like us to cover? Let us know by joining the conversation over on the Koyeb community platform!
Koyeb is a developer-friendly serverless platform to deploy any apps globally.
Start for freeDeploy 2 services for free and enjoy our predictable pricing as you grow
Get up and running in 5 minutes