aurea plectura aeterna

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:

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 n Nix derivations into m 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 ci for derivation i; that gives us a nice n×1 vector c (ie the collection of ci), where n 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 i changes (ie, all derivations that transitively depend on derivation i). To obtain a nice representation, say that Dij=1 if derivation j transitively depends on derivation i (including i=j), 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 pi as the probability of derivation i (in our build closure) changing, ipiDij gives us the approximate probability of derivation j (in our image) needing to be reuploaded and stored. We end up with p being a nc×1 vector, and D being a nc×n matrix, where nc is the number of derivations inside our build closure. We are going to be specifically using pD, a 1×n 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 Xij=1 if derivation i is placed on layer j and zero otherwise, which gives us a cool n×m matrix, where m 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:

min f(X)=pDXXc

We get an outer product of X, which is multiplied left and right respectively by vectors pD and c (the mathematically inclined might recognize this as a general partitioning problem if you squeeze pD into q): a beautiful representation that's just a bit wrong. You see, DX 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 a,b,c where b depends on a and c depends on both a and b; and b,c are on the same layer (I'm limiting the math to just include the aforementioned layer and skipping out the zeros):

D=[111101]X=[11]

which gives

DX=[221]

This says that the layer would get invalidated twice by a change in a or b. However, realistically speaking, the most times a layer can get invalidated is once. So with a small modification via an elementwise min, we end up with the following (more correct) objective function:

minX f(X)=pmin(DX,1)Xc

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 X are either one or zero, with all elements of the matrix adding up to a constant (n). 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 a,b,c be three derivations that will be included in two layers, with both b and c depending on a. This gives us:

D=[111010001]Xp=[101001]Xs=[100101]

where Xp is the parent-child merge, and Xs is the sibling merge. We get

min(DXp,1)=[111001]min(DXs,1)=[110101]

and

min(DXp,1)Xp=[111110001]min(DXs,1)Xs=[111011011]

To compare them, we can calculate

p(min(DXp,1)Xpmin(DXs,1)Xs)c<0

 

p[000101010]c<0

 

pbca<pccb+pbcc

Arranging this, and taking symmetry into account (there is more than one parent-child merge)

cacccb<pcpb and cacbcc<pbpc

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.