* Single inheritance. Dockerfiles cannot be easily composed, you need to arrange them in particular order. This leads to either kitchen-sink images or deep, brittle hierarchies.
* Leading to: inheritance for construction. Lots of images include a bunch of irrelevant material because it was easier to FROM an existing image than to tease apart the kitchen-sink into the brittle hierarchy.
* Meaning: using composition isn't really "a thing" in the sense that it's commonly used. I'm given to understand that OCI layers can, if you recalculate the hashes, be layered in arbitrary order. In an ideal world the build tool can check that there won't be layer conflicts, meaning you can treat layers as immutable mixins, rather than immutable parents. Instead of FROM, you could have UNION or PLUS.
Then of course the less abstract stuff:
* Mystery meat. Dockerfiles make development solely responsible for managing all dependencies from the OS up. An awesome tool and a problematic responsibility. You've graduated from worrying about XSS holes in your web framework to fretting about kernel bugs and glibc glitches. Where I work we have an entire team devoted to tracking CVEs that affect code we ship. Do you?
* Mystery meat, at scale. Now you have 5,000 containers running ... what, exactly? What versions of what OSes? Are the runtimes up to date? You're going to need to either rigidly control Dockerfiles -- in which case, what did it buy in terms of dev flexibility? -- or you're going to need to buy tools to scan all your images. Docker sells such a service, as it happens.
Dockerfiles are a simple, clean exposure of the original concept of layered FSes, processes and ports. But they are not without limitations.
Disclosure: I work for Pivotal, we compete with Red Hat and Docker in the PaaS space.
As a sibling mentions, Nix is has support for working on building Docker images. The syntax is declarative by default but does allow for an escape-hatch if required to run custom commands[1].
The support is decent and has a couple of interesting properties.
The first is bringing all of the stateful code `apt update` etc. out of the image. It is not necessary for it to be there in the first place. Because of the packages being updated out of the container, and the way nix caching works we don't need to worry about security fixes being missed because of the Docker caching.
Another benefit is declaratively building the image, This gives us a guarantee that we will get the same image on two machines. They are currently working towards reproducible builds meaning we will get to a point where two builds will produce _exactly_ the same docker image.
I agree with all that you're saying, Dockerfiles seem to be artificially constrained and a poor interface for building images.
I've elsewhere put the view that configuration management tools more or less fill this role, as they build on the existing nature of filesystems: spread your stuff horizontally into tree structures, so it doesn't collide.
Most of the ordering dependencies don't show up until runtime. The bits at rest can generally be played in any order.
The Dockerfile language has some very frustrating limitations.
One I've found frequently frustrating is the inability to do any sort of globbing on COPY commands. This can make it very difficult to do some types of layer optimizations. For example in the Java world I have multi-project Gradle builds which consist of a tree of directories each of which contain a "build.gradle" file. I would like to copy all of these files, preserving the directory structure, into the container so that I can run a preliminary "gradle assemble" command which installs third-party dependencies, then only after that copy the remaining contents of these directories. That way I only need to re-download dependencies if any of the "build.gradle" files change when building the container.
This turns out to be impossible to do in the Dockerfile language without manually listing out every "build.gradle" file in the directory tree.