DEV Community

Cover image for Build a Rust speed test using Actix and WebSockets
alisdairbr for Koyeb

Posted on • Originally published at koyeb.com

Build a Rust speed test using Actix and WebSockets

Introduction

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.

Prerequisites

To follow this guide, you will need:

Steps

To successfully build our speed test site and deploy it on Koyeb, you will need to follow these steps:

  1. Create a new Rust project
  2. Set up a basic web server with Actix
  3. Work with WebSockets using Actix
  4. Send the test file
  5. Create the speed test client
  6. Dockerize the Rust application
  7. Push the Docker image to GitHub Container Registry
  8. Deploy the sample network performance application on Koyeb

Create a new Rust project

To start, create a new rust project:

cargo new koyeb-speed-test
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Great! Now for our project files, make two new directories for our server source code and static files (HTML):

mkdir src static
Enter fullscreen mode Exit fullscreen mode

Populate the src directory with the following files:

touch src/main.rs src/server.rs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Awesome! Our project is looking good. Let's start building our server.

Set up a basic web server with Actix

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:

  1. Serving HTML to users: This will be for the main page of our application. It is how users will start the speed test and see the results.
  2. Serving test files over a WebSocket connection: This will be for performing the speed test.

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Work with WebSockets using Actix

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:

  1. Ensure the socket is open (e.g. check if the socket was either closed by the client or if the connection was interrupted). We'll do this by pinging the client every five seconds.
  2. Upon request from the client, send a 10 MB file over the socket.
  3. Upon request from the client, close the socket.

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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Now that our server logic is finished, we can finally start the server with the following command:

cargo run -- main src/main
Enter fullscreen mode Exit fullscreen mode

Go to your browser and visit http://localhost:8080. You should see a blank index page.

Send The Test File

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));
}
// ...
Enter fullscreen mode Exit fullscreen mode

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.

Create The Speed Test Client

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>
Enter fullscreen mode Exit fullscreen mode

When we visit http://localhost:8080 in the browser, we should now see the following:

Speed Test Homepage

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.');
Enter fullscreen mode Exit fullscreen mode

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.

Dockerize the Rust application

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]

(* ... *)
Enter fullscreen mode Exit fullscreen mode

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
// ...
Enter fullscreen mode Exit fullscreen mode

Next, build an image for our project:

docker build . -t ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
Enter fullscreen mode Exit fullscreen mode

We can see if it runs by running the following command:

docker run -p 8080:8080 ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test
Enter fullscreen mode Exit fullscreen mode

Push the Docker image to GitHub Container Registry

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
Enter fullscreen mode Exit fullscreen mode

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.

Deploy the sample network performance application on Koyeb

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:

  1. Name your application.
  2. Select Docker for the deployment method and fill the Docker image field with the name of the image we previously created, which should look like ghcr.io/<YOUR_GITHUB_USERNAME>/koyeb-speed-test.
  3. Check the box "Use a private registry" and, in the select field, click "Create Registry Secret." A modal opens asking for:
    • A name for this new Secret (e.g. gh-registry-secret)
    • The type of registry provider to simplify generating the Koyeb Secret containing your private registry credentials. In our case, GitHub.
    • Your GitHub username and a valid GitHub token having registry read/write permissions (for packages) as the password. You can create one here: github.com/settings/tokens
    • Once you've filled all the fields, click the "Create" button.
  4. Name your service, for example 'main', and then click the "Create Service" button.

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.

Conclusion

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.

Finally, if you have any questions or suggestions to improve this guide, feel free to reach out to us on Slack.

Top comments (0)