I’ve noticed a pattern in APIs that frustrate me: a tendency to structure along the database, rather than along high-level operations.
Personally, I point at the days of REST APIs being trendy (I know they’re useful, they were also a fad back in the early 2010s). REST APIs directly lead themselves to the view/controller/model (“MVC”) type breakdown - there’s a user facing component, data management logic, and data storage. It’s so tempting, and often reasonable, to unify those concepts as strictly as possible.
If you’re not familiar with them, REST APIs focus on objects and “verb” operations. You define some object, say, an item in a store:
{
productCode string
name string
price int
...
}
This object type is given a base URL, such as /api/product
.
The API has different handlers for the 4 CRUD HTTP verbs: create, read, update, delete.
To create product foo, you would do an HTTP POST to /api/product/foo
.
To fetch details of product foo, you would perform a read (HTTP GET) on /api/product/foo
… and so on.
This data interaction model makes sense in a lot of cases, as it unifies the storage concept (AKA what’s really there) with what’s exposed to the downstream consumers. This is great at a low level, but complex APIs often wind up with gaps: what if a high-level operation does not line up with a single database structure?
This antipattern of insufficiently-abstract APIs bothered me a lot in my webdev days. Suppose we have an app where users have one global account, and can have many groups/workspaces/whatever (think like Discord). An obvious but poor login flow would be:
POST /login/user/<user>
{
...
} --> token
POST /login/group/<group>
{
token: <token>
}
The first request authenticates the user, the second request properly logs them into their workspace. There are multiple ways this could be done better, such as:
- Use the user-specific token to authenticate all requests to workspaces, without a workspace-specific login.
- Have a login like
/login/group/<group>/<user>
, which handles both portions.
I’ve seen this example in the wild, and it came from using a nonrelational database, where the author chose not to denormalize group membership data into the user object. Since the process needed data from two separate objects, the login was unavoidably 2 API calls. Database structure strikes.
For another example, suppose I’m trying to spin up a VM with ComputeCo’s API. ComputeCo requires I create a boot disk first, then reference that disk when creating a VM. This makes some sense - you need a disk to be able to create a VM. However, requiring the user perform it as a separate API call has some downsides:
- It’s more code for the user to make 2 calls rather than 1.
- The user is responsible for cleanup logic - if the VM fails to create, what do they do with the disk?
- The process is unnecessarily synchronous. What if there’s other provisioning that could be done in parallel with the disk setup?
- The underlying system’s ability to make informed decisions is reduced. What if the disk was placed on a host that wasn’t a good fit for the VM?
If a single task like “create a VM” or “put an item in a shopping cart” requires multiple steps, you introduce many more ways that things can go wrong, out-of-band of your API. Operations may be order-specific, may need to hit the same backend, and state can get wedged in really interesting ways. With the ComputeCo example, it would be easy to assume the happy path on resource creation, and wind up with a landmine, where a preexisting disk from a failed attempt blocks the creation of a VM. And this is to say nothing of the wedging or inconsistent state that could happen behind the scenes…
The solution to this awkwardness isn’t “abandon REST”, as this isn’t an inherent problem with REST, merely a tunnel-vision that REST encourages. The solution is to add higher-level abstractions, and perhaps mask some lower-level ones.
Kubernetes, as myself and many others have covered, heavily uses tiered abstractions. For example, a pod (group of containers) is the base unit of compute. Rather than handling raw pods, we normally use a ReplicaSet, which takes the instructions “I want this many pods, with this spec” and manages pods for us. The chain goes higher still, EG most users use Deployments, which create multiple ReplicaSets to perform rolling deploys. Instead of managing individual pods at all, a user can specify “I want my Deployment to be like this… now I want my Deployment to be like this…” and the system Just Does It. The “1 action = 1 API call” rule is satisfied in many places.
However, Kubernetes still is extremely object/CRUD oriented in its APIs. For example, what if I want to see all permissions that a pod has? Well, that’s 3+ API calls (get the pod, get the rolebinding, get the role…). Michael Hausenblas wrote an neat tool to visualize this, and I’ve played with cruder versions… but it’s still many API calls.
Kubernetes state is stored in a non historical key-value fashion, which means that pushing data aggregation into the backend doesn’t eliminate consistency woes. Let’s look at an example of something I do a lot - correlating nodes and pods. Seeing what pods are on a node helps with eyeballing issues like a bad node, bad Daemonset, noisy neighbours, and so on. Unfortunately this requires querying both nodes and pods, then (programmatically) correlating the data. By the time I have my data from 2 separate requests, the node may contain new pods that I don’t have the data for, or pods on the node may have just exited, leaving me with incomplete pod data (albeit accurately incomplete).
The most “idiomatic”, but perhaps not most reasonable way to correlate data would be to create a new Kubernetes object, let’s say NodeContents. NodeContents is conceptually an extension of a Node object, with additional data. If we watch Node events and Pod events, we can use that data to update a NodeContents object. For example, when a new pod is launched on a node, its state is added to the NodeContents. If the pod updates, that change is updated. This has some potentially big advantages.
Firstly, we’re fetching one object, which probably isn’t huge… way faster than a mass data dump or multiple calls.
Secondly, you can handle discrepancies in-band. For example, suppose you receive a deletion notice for a pod, before you receive an update to the node’s list of scheduled pods. As the implementer, you have the option to either keep the stale pod state in the NodeContents, or delete it from the NodeContents. If you implemented this via a pair of GETs to the API, you would not know which data was fresher, and would have to make a blind guess, degrade your response, or re-poll.
However, this is a hack to try to relationalize a non-relational storage model, and as such, it could easily go sideways. The database structure strikes again. ;)