The Dockerfile format is not declarative.
Declarative means you define the state you want
Imperative means you define the commands to run
Dockerfile is 100% imperative
The Dockerfile format is not declarative.
Declarative means you define the state you want
Imperative means you define the commands to run
Dockerfile is 100% imperative
https://zwischenzugs.com/2023/04/27/is-it-imperative-to-be-d...
TL;DR you're probably right, but it's more complicated than I thought.
https://github.com/nlewo/nix2container/blob/master/examples/...
(disclosure: I have contributed to that repo)
this is not looking particularly more declarative than docker
Not saying the system doesn’t apply this declaratively, but I find it difficult to intuit the above is checking for a state and applying changes only if necessary.
The "writeText" function produces a derivation (basically an atomic build recipe) that produces that file. The crux of nix is that you make deterministic derivations, and then you can always refer to the results of a derivation from the hash of the derivation and its inputs.
What nix adds is glue logic to chain these derivations together in a way that preserves reproducibility of the individual imperative, but deterministic, components.
Unless you are using something like recursive-nix, you can completely evaluate the nix expression without building any of the derivations.
This is fundamentally different from a Dockerfile or Ansible script which have no idea what the "starting point" of the target environment is and are pretty much just mindlessly imposing mutations on top of whatever happens to already be there.
FROM nginxIn the Nix example, the image we build is in the expression `nix2container.buildImage { ... }`.
The `nginxWebRoot` is the package with the index.html:
nginxWebRoot = pkgs.writeTextDir "index.html" ''
<html><body><h1>Hello from NGINX</h1></body></html>
'';
It's reasonable to say "writeTextDir" modifies the disk. I don't think it's reasonable to say just because changes in state occur that the code is imperative. (e.g. SQL is a declarative language, but clearly allows modifying the database).Similarly:
nginxVar = pkgs.runCommand "nginx-var" {} ''
mkdir -p $out/var/log/nginx
mkdir -p $out/var/cache/nginx
'';
We can say the contents of the runCommand argument are run imperatively, sure. (Especially: if you change the order of the bash commands, you might get a different result).
But unlike the Dockerfile, the order where we declare this nginxVar package doesn't matter.Or, say: the `copyToRoot` in the `nix2container.buildImage` takes in a list of packages where the contents are copied to root. The copying is an action; but the list of what to copy is not an action. -- And again, `copyToRoot` could be put after the `config` attribute.
The mechanisms describing how the copying is done is elsewhere.
By comparison, "FROM nginx" is just grabbing whatever is the latest in some external registry that you don't control— it's the same as starting a Dockerfile with "apt update; apt dist-upgrade", you have this huge chunk of external mutable state that you're dependent on which immediately throws any kind of real reproducibility out the window.
(And yes, doing "FROM nginx:x.y" or "FROM nginx:<sha>" does help a little, but the point remains that you're pulling a big binary blob that is essentially mystery meat— trying to make sense of what's in there is why there's now entire companies dedicated to untangling software bills of materials.)
But even in a world where Debian's reproducible build project completely achieves all its goals, a given docker build is always going to have temporal state in it if it depends on external images or a mutating package repository. So yes, you may have the Dockerfile that purportedly produced that disk image, but you're unlikely to be able to completely rebuild or verify it unless you also have a snapshot of the apt Packages.gz.
A nix2container image could in principle build completely from scratch, in just one command line invocation, with no external cache present, and get a bit-for-bit identical result. The only real "trusted" input that you have to start with is I believe a small busybox binary and gcc toolchain that is the initial bootstrap.
Some is declarative, e.g. `FROM`, `ENV`, `EXPOSE`. While on the other hand `RUN`, `CMD`, etc. is fully imperative.
`FROM` also has more use cases when using multi stage builds.
Acutally, come to think of it, since `RUN` may depend on any other Dockerfile statement (even `EXPOSE` might make a difference in code), does this mean that even a single imperative statement that is introduced in some language, makes the language imperative?
In comparison, a Nix file is actually declarative ("I want my result system to have this" as opposed to "Do this to get me towards my result system"), and is actually reproducible.
But notice the sizes of both a Docker image (many megabytes) and a Nix file (a couple of K)...
A Dockerfile provides no guarantees that it will succeed, is what I was getting at- which is why people download Docker images to begin with, because the build product is guaranteed to work, since it's immutable at that point.
If you pretend that COPY means with these files like those files on host machine as of execution time, RUN means with this command executed at build time, etc., then even Dockerfile becomes fairly declarative. Every declaration defines a new immutable layer with its own unique hash, it’s just that some declarations can easily be used in ways that make the outcome vary based on the state of the entire world as of build time.
Just as in Ansible you can use “declarative” YAML in a very imperative lasagna of a setup, you can do the same with a Dockerfile, Nix, Haskell, or Python. You can also get pretty close to purely declarative bliss with any of them, but it will grow impractical before that point.
I get why it is the way it is, but if it were more declarative, it would be easier to manage Dockerfiles through changes, security updates, etc.
Dockerfile is intentionally bare-bones. It just gives you RUN straight up, no scary hidden option, but it’s up to you how to use it. If you want to write imperative, you can. If you don’t write imperative, pin everything to a hash, shell out only to Dhall and Prolog, it can get very declarative…
…at a cost. The fact that people do not tend to go this route means equally that 1) they are lazy, and 2) using RUN this way is pragmatic.
I'm not suggesting it change. I'm saying that calling it "not declarative" seems fair, based on the way that most people actually use it.
But the syntax itself is conducive to imperative image definitions, and they are by far the most common
This blurs the meaning of "declarative" to the point of meaningless.
Consider the following pseudocode:
x = 5
print x
x = 10
print x
This is clearly 'imperative'.But from the perspective of 'each line of code declares a new program', then we consider the code snippet a declaration of a program.
> Just as in Ansible you can use “declarative” YAML in a very imperative lasagna of a setup
One of the things which makes Dockerfiles imperative is the sequence of Dockerfile commands is significant; if you swap the order of a COPY, RUN to a RUN, COPY, the result changes significantly.
The alternative is to draw a clear line where there is none.
> One of the things which makes Dockerfiles imperative is the sequence of Dockerfile commands is significant; if you swap the order of a COPY, RUN to a RUN, COPY, the result changes significantly.
By that logic you can call every Nix program “imperative”. They are sequences of strings, the order of which matters. The horror!
A Docker image is a stack of layers. Every layer points to a preceding layer. It’s not a huge leap from that structure to a fairly elegant sugar of a string array, where each string is a declaration that applies to preceding layer thus describing the next one. I don’t see anything fundamentally imperative about it; it can be used in imperative ways, but anything can.
I'd say that more/less "imperative" refers to describing sequences of actions that take place (especially those which might modify some state), whereas "declarative" is more about a structure of what the result should be.
I can agree that a Docker image can be considered as a structure, and that it's possible to construct one declaratively. Whereas I'd say a Dockerfile is an imperative construction of that.
> But from the perspective of 'each line of code declares a new program', then we consider the code snippet a declaration of a program.
The critical difference is that each line in a Dockerfile yields (declares) a filesystem state which can be referenced and recreated. In contrast, in your example, I have no way to say "give me a snapshot of the system between the third and fourth instructions".
Dockerfiles have all sorts of rules and restrictions that make these semantics possible. You cannot create loops; there is nothing like a "function", at least within the context of one Dockerfile.
> One of the things which makes Dockerfiles imperative is the sequence of Dockerfile commands is significant; if you swap the order of a COPY, RUN to a RUN, COPY, the result changes significantly.
I reject this line of reasoning, simply because decalative languages are indeed order-dependent:
let y = f(g(x))
is different than: let x = g(f(x))
Much in the same way that these are different: FROM x AS y
RUN g
RUN f
vs FROM x AS y
RUN f
RUN gEh, I can kinda see that. -- It's possible to imagine a declaration where the order of items in a list has consequence.
Still, I'd Dockerfiles as a sequence of statements more akin to a bash script than to an SQL expression.
> Still, I'd Dockerfiles as a sequence of statements more akin to a bash script than to an SQL expression.
lead to some quite inefficient and poorly-factored Dockerfiles.
It’s just not a reasonable way of making the distinction; claiming X is “imperative” because you are using it in imperative ways is logically flawed, it is not a statement of truth about X.
Dockerfile is fundamentally declarative, as you note (that’s just how Docker works: every line describes a layer), and it has not even enough features to make it imperative (control flow? goto?).
Haskell's "do" notation is frequently described as an imperative syntax for functional/declarative transformations. I would put Dockerfiles in the same boat. They behave declaratively, but users can think imperatively when they write them (to a certain extent) and this is part of what makes them more accessible to newcomers.
Ansible is a great example of the opposite. It looks declarative but, like you said, basically runs a bunch of scripts one after another on a system, with state and all.
COPY . .
RUN npm install
And: COPY package.* .
RUN npm install
COPY . .
The first file reruns npm install every single time any file changes in the code. The second only reruns npm install if the packages change. That can make the difference between a 5-minute build and a 5-second build, so it's not a small optimization.Given how important the order of instructions is, it's hard for me to think of it as declarative. It fits better in my mind as a sequence of instructions, which is the very definition of imperative.
I find Docker an interesting example of a fundamentally elegant approach that, possibly to facilitate commercialisation, was documented in a way that, instead of imposing any particular culture (such as, say, Nix or Haskell), strongly aligned with the preexisting culture of operations and system administration—the resulting ease of adoption and popularity (and, I assume, broadly deserved financial well-being of the original creators), however, could not come without a variety footguns along the lines of which you have described.
“FROM ubuntu:latest” is pretty declarative then…
Here's an example Dockerfile:
FROM McDonalds Cheeseburger HappyMeal
RUN Get ketchup packets
RUN Squirt ketchup on fries
RUN Dump happymeal on plate
The first is declarative, a menu, you point at what you want and software makes it so.
The rest is a recipe, an imperative, a command, you tell software to perform, and software makes it so.
In point of fact the image side of a Dockerfile, where an image is a DAG of other images referenced by an immutable ID or pointer to hosted content, is "100%" declarative. It's only the "build" syntax that is ordered.
The "image side of a Dockerfile" isn't a Dockerfile, it's an image, more specifically an image in the OCI Image Format [0]. A Dockerfile is just the most common syntax for controlling software that can create an OCI image (such as Docker and Podman).
You could argue that the OCI Image Format is declarative, but that's not relevant to OP's comment about Dockerfiles.