Dealing Nix derivations into OCI image layers, part 1: Stating the problem
Nix is a great build tool for OCI images, as I'm told (Nix is a better Docker image builder than Docker's image builder). The parts I specifically like are that I can leverage my existing build process for building the image (ie goodbye Dockerfiles), and it's really easy to understand how the image is built from my derivations' outputs (it's just stacking the output paths on top of each other, which gives you the full image).
What sometimes gives me trouble though, is the ~125 layer limit OCI images have. It's not unreasonable to end up with more than a thousand derivations in your image if you're trying to package a Javascript application. When you want to put more than 125 derivations in an image, you end up having to "merge" some derivations together to fit the image.
Let's say that I'm working on one Javascript repository that produces an OCI image. I'll have to ship a couple of things (and their runtime closures) inside the image:
- Bash (for interactivity)
- Node.js
- Any application-specific runtime dependencies (eg ffmpeg, nix, and git (this is an intriguing application))
- Dependencies fetched from npm
- Application code
To simplify, suppose these derivations are split into five layers, all grouped together by the bullet points. Unlike a typical nix build where dependencies can be reused granularly, layers enforce stricter boundaries. If any of my npm dependencies change, I'll have to rebuild and reupload the whole npm-dependency layer. An ideal distribution of derivations to layers would reduce the amount of work that needs to be repeated.
(Aside: I'll be using to mean both "derivation" and "derivation output"/"store path" through this post. I apologize for this, but "derivation output" is a mouthful and having the same term for both the recipe and the output helps illustrate the connection I want to leverage and show off. As additional justification--after reading the rest of the post--feel free to convince yourself that separating a single derivation's outputs into separate layers makes no sense.)
So, which derivation goes into which layer? If you want a pre-packaged solution, the commonly used algorithm (by GrahamC) and an improvement on it (by tazjin) exist; and you wouldn't be wrong for choosing them. (And if you haven't read these posts, then I heavily recommend you to read at least the first one as it describes the setup much better than I can.) But, you also wouldn't be fully right. You see, they are algorithms for putting derivations into layers; but they aren't exactly clear on what the algorithms are trying to achieve. Moreover, they tend to put almost everything inside a single layer, while other layers keep only one derivation. Instinctively, I feel this distribution is suboptimal for my use case. But I need to convince myself that coming up with a new method is worth the investment: If we want to compare these algorithms for our use case, we need an objective function to evaluate these algorithms and the distributions that are obtained from them.
Functioning objectively
We ask ourselves this question: What should we attempt to achieve when dealing Nix derivations into layers?
Let's start by assessing the situation. The first fact is: these layers are going to be uploaded into an OCI image registry. We incur costs on upload bandwidth and storage depending on the size of the derivation. Since all of these costs are only dependent on the derivation (and your provider's cost structure), we can pull a simplifying trick and call the sum of all costs for derivation ; that gives us a nice vector (ie the collection of ), where is the number of derivations we're going to put inside the OCI image layers. For our sanity, I'm going to assume putting multiple derivations into a layer will just add the costs for the derivations together. Feel free to extend this model without this assumption if you need more granular results, but know that the computation costs might explode.
The second fact is that Nix derivations change. You will either make changes to your application, or bump your nixpkgs version and see your dependencies get updated. In our problem, these changes end up as layer invalidations. If any derivation inside the layer changes, that layer needs to get reuploaded. (The reality is a bit more complicated: OCI image layers are content-addressed, so a changing derivation/output path doesn't necessarily imply invalidation. You might want to extend this model by working out a more complex dependency system)
This would normally put you in a very difficult spot--since packages usually change together--meaning that you would need to get involved with conditional probabilities combining many derivations' probabilities together. To avoid this, we're going to pull another simplifying trick in two steps: The first step will be utilizing the derivation dependency graph that Nix calculates internally. That way, we will know which derivations will have to be rebuilt if a derivation changes (ie, all derivations that transitively depend on derivation ). To obtain a nice representation, say that if derivation transitively depends on derivation (including ), and zero otherwise.
The second step will be to assume that: aside from the transitive changes caused by the dependency graph, derivations don't change together. This ends up being a pretty reasonable assumption if you look into nixpkgs' commit history: Generally commits cause changes in one derivation's definition file. To further justify our independence claim, we're going to keep track of all dependencies in our build closure instead of only seeing the derivations we'll be placing into our OCI image. This will improve the accuracy of our model, and make our assumption more palatable. Thus, if we define as the probability of derivation (in our build closure) changing, gives us the approximate probability of derivation (in our image) needing to be reuploaded and stored. We end up with being a vector, and being a matrix, where is the number of derivations inside our build closure. We are going to be specifically using , a vector that shows the probability of change for the derivations that are going to be inside our image.
The last question here is how to denote us distributing the distributions into layers. Since we have nice vector representations for everything, I'll describe it as a matrix (I'm a fan of matrices for describing problems, since the dimensions end up being a pseudo-type system that catch any errors I might make): say that if derivation is placed on layer and zero otherwise, which gives us a cool matrix, where is the number of layers we are going to be using inside the OCI image.
With the final piece in hand, the objective function can be constructed:
We get an outer product of , which is multiplied left and right respectively by vectors and (the mathematically inclined might recognize this as a general partitioning problem if you squeeze into ): a beautiful representation that's just a bit wrong. You see, is supposed to show that which layers get invalidated depending on the changes of the derivations inside the build closure; so the entries must be either one or zero, but this is not the case here. Consider the following example with three derivations where depends on and depends on both and ; and are on the same layer (I'm limiting the math to just include the aforementioned layer and skipping out the zeros):
which gives
This says that the layer would get invalidated twice by a change in or . However, realistically speaking, the most times a layer can get invalidated is once. So with a small modification via an elementwise , we end up with the following (more correct) objective function:
At this point I'm not too bummed from the loss of linearity, as dealing items into buckets is a combinatorics problem--we were never going to be able to use calculus/linear algebra methods to solve it. But using matrices was of great help during the formulation!
(An aside on our partitioning problem: in our situation, the left and right multipliers are greater than zero; and the elements of are either one or zero, with all elements of the matrix adding up to a constant (). This creates a whole lot of structure for the intuition behind the solution, and the main intuition is that our layers need to balance the probabilities of change cost of the derivations they include. We can validate this intuition when we get to solving the problem.)
So, what now?
Now that we know what the problem is, we can finally get to solving it. Kind of. The issue is, I need to gather some data from nixpkgs history to get the p vector which will take some time. So I'll leave the numerical calculations to part 2. What I can do now, is to show what kind of decisions we can make when we have the numbers. For example:
Question: Which choice is better: merging parent-child pairs, or merging sibling pairs?
Let be three derivations that will be included in two layers, with both and depending on . This gives us:
where is the parent-child merge, and is the sibling merge. We get
and
To compare them, we can calculate
Arranging this, and taking symmetry into account (there is more than one parent-child merge)
and the answer ends up being it depends: Merging a more stable child with the parent makes more sense. Or we can say that if the parent's invalidation cost is less than a child's, you should never merge siblings together. With the numbers in hand, you'll be able to make exact decisions; but it's also great to see the model still points you to a direction even when we're playing with make-believe numbers.