A Case for Rust

A Case for Rust

Fearless has always been exploring new and innovative ways to bring modern tech to the government space. This sort of exploratory work is instrumental to our success and the success of our customers, setting us apart from other government contractors as we strive to make an impact in that space. As part of this exploratory work, we've uncovered an extraordinary tool which we believe could be a game changer in scalable, robust solutions for everyone: the Rust programming language. Rust is a systems programming language that produces native executables - it can be used in microservices, command-line tools, and generally anything that requires speed, security, resource efficiency, or reliability.

Rust is a newer programming language, but it's caught the hearts of developers everywhere. For 8 straight years since its 1.0 release, it's been named the #1 most loved programming language on Stackoverflow's annual developer survey. This is of course for good reason - it's a very secure programming language, preventing entire categories of errors and preventing memory-based security vulnerabilities through the language's design. It has all the benefits of compiled languages as well, making it incredibly fast and able to keep up with demand while at the same time requiring very little disk space or memory. For developers, it's a joy to use as well - nearly every tool that a developer needs is included in the box, the language provides high-level constructs that make it feel fresh and modern (reducing boilerplate), upgrading language versions is completely frictionless, and of course feedback from the compiler is incredibly useful, sometimes even telling developers how to fix an error.

Security

Security is a big point in favor of the language. Because Rust enforces an ownership & borrowing system for the management of data, it's able to be memory safe without requiring a garbage collector or language runtime. Memory-based vulnerabilities are something that accounts for about 70% of security vulnerabilities according to Microsoft and Google. As a result both companies have embraced the language - Google is building parts of Android in Rust now and Microsoft is integrating Rust into both Windows and Azure (they're not the only ones either! Rust is being used in the Linux Kernel, at AWS, and at Meta too). It seems to be paying off too. The RustSec project, overseen by Rust's Secure Code Working Group, reported via Twitter that approximately 130 security vulnerabilities were reported across all 100,000+ crates (the name for Rust packages) on the Rust package repository, crates.io, over the course of one year. If one were to assume that each vulnerability were reported for a different package, that would still mean that about 0.1% of all packages on crates.io had a vulnerability reported across all of 2021. This figure is likely even lower than that, as packages could have easily had multiple vulnerabilities opened against them.

Because there's no language runtime, unlike other languages that require runtimes like Node.js or Java, there’s one less security vector for developers to worry about. The only security concern developers need to worry about are their dependencies. Of course, tools are provided to help with this as well - RustSec provides a plugin for cargo (the all-in-one Rust tool) called cargo-audit which can detect reported vulnerabilities for crates that a Rust application depends upon (a GitHub action is also available to integrate with CI as well!). It provides tools for automatically upgrading dependencies to fix the vulnerabilities as well, both the built-in cargo update command and the optional cargo audit --fix command are able to auto-update dependencies to newer versions, meaning less work to mitigate these vulnerabilities on behalf of the developer. The fact that this tool is provided by the Rust team is a boon in and of itself as well; normally in other languages one would need to stand up a 3rd party tool such as Prisma or Trivy, which cost developer time to stand up and take away from time that could instead be used to build solutions. This is a recurring theme in the Rust ecosystem - the things developers need are included with the language, so they can focus on building rather than maintaining.

Reliability

Software built with Rust not only tends to be secure, by the design of the language it's incredibly reliable as well. Rust tackles things like null pointers, dangling resources, and error handling in an incredibly thoughtful way through the design of the language itself, a result of its focus on correctness.

First off, there is no null in safe Rust. The only way to represent a value not being present is to use Rust's Option type, which in turn forces developers to verify whether or not the value is present before even being able to access the data. This isn't to say that the language makes accessing these values tedious by any means, there are language constructs which make both testing if values are present and extracting them easy for a developer such as if let statements or match. This is a stark contrast to something like Java, where any object could potentially be null. While developers can use Java's optional type, they still need to remember to check whether or not the value is present before accessing it meaning the check can be easily missed, turning into very sneaky bugs which may not be caught until the code makes it to production.

Similarly, functions producing errors in Rust don't just wantonly throw errors, they're represented through Rust's Result type. Just like the Option type, the developer is forced to verify that an error did not occur before they're allowed to access the return value inside the Result. Again, there are language constructs which make dealing with errors easy - match statements are available to allow the developer to handle code based on the shape of the data rather than declaring many imperative comparisons, and returning the error to the caller is an opt-in option as well. It's as simple as adding a single question mark on the end of an error-producing function. As a result, by just looking at Rust code developers can tell every single place an error can occur making the logic much easier to reason about and making bugs easier to track down. It should also be mentioned that match statements are exhaustive; if a function says it could potentially return an HTTP error with different variants such as "Bad Status Code" or "Could Not Connect", the language will require the developer to either handle every possible variant of the error or just a couple and a "catch-all" for anything else. Because of this every possible path the program could take has an explicitly defined behavior by the programmer and the application will not crash unless specifically instructed to.

In other languages like JavaScript, errors can be nearly invisible and could come from two or three function calls down the call stack without the developer even knowing they need to account for said error. Other languages have tried to tackle this problem as well; in Go errors are also returned as values but it's incredibly difficult to inspect these returned errors as the actual errors that are returned are veiled behind the idiomatic "error" interface which has a single defined method that returns the error message. To determine what actual errors could be returned and inspected, the source code of the function would need to be manually read by developers. To inspect these errors the developer would need to coerce the error from the "error" interface back into the original error and sometimes even provide two separate coercions - one for the error implementation itself and one for a pointer to the error implementation in the event it was constructed slightly differently via the new keyword. Additionally Go allows developers to ignore the error value and try to work with the result anyway which could be disastrous, there's no common pattern for what the result should be when an error is returned. It could be a null pointer or an empty data structure filled with meaningless data or something else entirely. Rust's approach is much more developer friendly, presenting what both the success type will be and the set of possible errors the function can produce, arming developers with the knowledge of what they can expect from the function without needing to worry about surprises or reading the source code of a function they're using.

Another tenet of reliability in Rust is its heavy use of the RAII pattern in the standard library, an acronym meaning Resource Allocation Is Initialization. It effectively means as long as the developer has acquired a resource, whether it's access to a file, a chunk of memory, or a lock providing exclusive access to data inside a mutex, when that data drops out of scope it becomes cleaned up automatically. In terms of data structures that acquire heap memory under the hood such as Strings or Vectors (Rust's dynamically resizable array collection type), this means that all acquired heap memory is tied to a value on the stack, and when that value drops out of scope (like at the end of a function) the acquired memory is released without the developer needing to remember to do so. Similarly if a file is opened, the file is automatically closed when it drops out of scope. With mutexes, the developer cannot access the data inside unless they've first locked the mutex. When the lock drops out of scope, the mutex automatically unlocks itself again. In other languages, this sort of behavior is something the developer must remember to take care of. Java handles this with a "try-with-resources" block, something the developer must remember to add or manually add logic to close the resource themselves, and some constructs in the language don't even implement the interface that allows them to be closed automatically via try-with-resources such as the Semaphore implementation. Go handles this with its defer statements which are an improvement, but sometimes resources that require closing can be properties on data structures that are only talked about if one reads the documentation carefully, such as Go's HTTP response body property which can leak TCP connections and tank the application if one is not careful to close the body on every HTTP request.

Speed and Efficiency

On top of being very reliable, Rust applications are incredibly fast and resource efficient. In our testing of similar endpoints on a Rust microservice using the Axum HTTP library and a Java microservice using the Spring framework, we found the following:

  • The Rust microservice started instantly and was ready to receive connections, while the Java microservice had to perform approximately 20-50 seconds of bootstrapping before it could receive traffic.
  • This is likely because the Spring framework is heavily dependent on runtime reflection to perform dependency injection. In order to do so it must first scan all the code inside the Java application and determine a dependency tree, then it needs to construct and inject all the dependencies in the tree. It does this every time it starts, leading to slow startups.
  • When idle, the Rust microservice was able to respond to a REST request within 5-8 milliseconds, while the Java microservice took 100-200 milliseconds to respond to the request, a speed improvement of about 20x
  • The speed improvements were even greater under heavy load from a benchmarking tool - the mode of the Rust microservice's response times was about 42 milliseconds while the mode of the Java microservice's response times came out to about 2.4 seconds, a speed improvement of about 57x
  • In the 5 minutes of benchmarking the two microservices, the Rust microservice managed to respond to about 350,000 requests while the Java microservice only processed about 6,000 requests - in requests per second, the Rust microservice processed 1194 requests per second while the Java microservice processed 21 requests per second
  • When idle, the Rust microservice only consumed about 10 MB of memory while the Java microservice consumed 500MB of memory. In production, the Java microservice was eating close to 1 GB of memory, while the Rust microservice only got up to about 20 MB of memory used during the benchmark - making Rust 50x more memory efficient
  • When packaged into a Docker container, Rust simply needs an executable in an Alpine Linux container, allowing us to create docker containers as small as 26 MB in size while the Java microservice's container was 342 MB in size, containing both the JAR file and all dependencies as well as the Java runtime - a size improvement of 13x
  • It should be noted that this is in part due to the fact that Java programs are packaged as “fat JARs”, meaning every single dependency they have is included in the result, even if parts of those dependencies aren’t used. This is partially because Java frameworks tend to be heavily dependent on reflection, meaning they need to load Java code dynamically at runtime, so there’s not a simple way to cut out the stuff that isn’t used. Rust on the other hand is able to perform Dead Code Elimination during compilation and linking since it doesn’t have reflection, meaning any code that isn’t used doesn’t end up in the final executable!

So what does the choice of Rust mean for projects we work on? Overall, cost savings! With Rust we don't need much disk space for our docker registries, we don't need to run as many replicas of the same service to be able to serve our applications at scale, we don't need particularly RAM- or CPU-heavy instances to host our services, and there's minimal downtime due to Rust's instant cold start. As a bonus, people consuming the Rust services are more happy due to the fast response times! The reduced requirements for resources mean less dollars paid to AWS or other hosting providers while providing potentially even higher quality of service, which is an obvious win for using the language.

Developer Happiness

The icing on the proverbial cake that is Rust is of course how much developers love using it. The all-in-one tool for the language, cargo, provides the following out of the box:

  • A compiler (cargo build and cargo run)
  • A code demo tool (Examples, via cargo run --example)
  • A linter with support for auto-fixing lint errors (cargo clippy, fixes available with the --fix flag)
  • A test suite runner (cargo test)
  • A benchmarking tool (cargo bench)
  • A code formatter (cargo fmt)
  • An automatic dependency upgrader (cargo update)
  • An automatic compile error fixer (cargo fix)

Additionally, other plugins are available for cargo which do more and can easily be installed through Cargo itself:

  • cargo audit, a vulnerability audit tool provided by RustSec
  • cargo vet, a supply chain verification tool provided by Mozilla

Many of the tools available by default in cargo are things that developers would need to find, configure, and set up themselves in other languages. In Java this might be setting up JUnit and Spotless for testing and code formatting. In Javascript it could be Jest and Prettier. The fact that these tools are just available for the get-go is a major accelerator for Rust developers and something that contributes to their love for the language.

Speaking of the compiler, its helpful insights and suggestions have been lauded as one of the ecosystem's best features. Take a look at this error message pulled from Google Images, for example:

No alt text provided for this image
An error message generated by the Rust compiler.

Even if one has never developed with Rust before, it's clear to see from this error message how to fix the problem - it says the semicolon on line 6 needs to be removed. This pattern of the compiler almost providing coaching and clearly explaining what's wrong with the code is a staple of the Rust experience, and something that makes the language approachable for developers of all experience levels.

The language is also stuffed with modern programming conveniences - as stated previously, pattern matching is available in the language, allowing developers to write what might be deeply nested conditionals in other languages as single lines in Rust by specifying the shape of the data they want to match against. Other modern features of note are destructuring declarations (just like the ones in JavaScript), where defining variables and setting them to nested values in a data structure can happen in a single line of code. Range statements are available for iteration, so instead of the more confusing for (int number = 1; number < 10; number++) syntax in other languages, developers can use the much more readable for number in 1..10 syntax. Functionality like having the ability to perform deep copies or be deserialized from JSON is also able to be implemented automatically on data types thanks to Rust's derive() macro.

What drives that ability to auto-implement functionality, though? That would be Rust's trait system, which helps the language use composition since inheritance is not present in the language at all, but that's a topic for another time. Traits are similar to interfaces in other languages, specifying functions that can be called by anything that implements them. Just about every feature within Rust is able to be represented by a trait, and some of those traits can be auto-implemented with the derive() macro. Want to be able to compare two data structures with the < or > operators? Use the Ord trait. Want to be able to add two data structures together with the + operator? That's the Add trait. Deep copies are done with the Clone trait, equality is tested with Eq, and so many other traits are available to help developers add their own code to the language that feels like a natural part of it. Because data structure and behavior are defined separately, traits can even be used to add new functionality to 3rd party libraries which wasn't present before, which is how Rust's rayon crate is able to extend the data structures in the standard library to enable parallel, multi-threaded iteration just like Java's .parallelStream() functionality. The possibilities are endless, and developers love the flexibility the language allows.

There are many, many more reasons why Rust is such a joy for developers to use. That fact combined with all the other benefits the language brings makes us very excited to see how we can use the language in our work.

Drawbacks

There are drawbacks to using the language of course, as it can't all be sunshine and rainbows all the time. Developers say the language has a steeper learning curve compared to other languages, but this is justified. As one master of Discrete Models and Algorithms, Michal Vaner, puts it in his blog post:

All things together, Rust [is strict, and] insists that your program will be correct or it won’t compile. Strict typing makes you think about the relations in your program. It checks that you don’t get data races. [...] This is a good thing. It makes sure your programs are better than of the competition (if you manage to compile them, of course), that they are less likely to crash and burn or something even worse. It makes the language easier to use (imagine if you had to actively think about all these things instead of relying on the compiler to check it for you ‒ welcome to C). But at the same time, it makes learning it a bit harder, because it insists on you learning everything needed to write a good program.

Because of the strictness of the compiler, compile times can also be initially slow as the Rust compiler needs to first compile all dependencies before compiling code. However, this slowdown is restricted to the first compile, and subsequent compilations will be much faster as only the project’s code needs to be compiled. This can also be mitigated in Continuous Integration by caching these built artifacts between jobs.

Finally, Rust is still a younger language than other more established languages like C and Java. There are libraries for just about anything a project needs, but they may not be quite as full-featured as libraries in more established languages since it just hasn't been around as long. This seems to only impact projects in very niche areas however, such as with libraries for working with proprietary file formats like Excel spreadsheets. In 99% of cases though, the language has proven its worth and can take on workloads as well as any other language.

Wrapping up

Rust is here to stay, as top companies like Google, Microsoft, Amazon, the Linux Project, and Meta are all investing into it heavily. As a tool for modernization, we feel Rust can bring incredible benefits to our customers with cost savings and reliable, responsive systems. The efficiency, speed, reliability, developer experience, and security that the language provides on its own makes it an incredibly exciting venture to us, and we can't wait to see what we can build with it.


#FearlessEngineeringHerd #rust #softwareengineering #securecoding #security #microservices #java #go #golang

Awesome! I found similar savings going from Java (with heavy frameworks) to Go (using native libraries). Also, my go binaries could be turned into a static binary that was the sole active part of a Docker container, reducing vulnerabilities and hack/attack vectors.

Like
Reply

To view or add a comment, sign in

Explore topics