Docker containers share the host’s kernel, network stack, and filesystem drivers, and generally don’t run complex services like systemd or CUPS or sshd; they only run the packaged application. A VM generally has a virtualized network setup and disk and runs a full-blown operating system, on top of the OS the host is already running.
Reading through the questions that get asked repeatedly on this forum should give you a taste of what’s hard to do in Docker, because those parts aren’t there. A container doesn’t actually have a NIC, doesn’t actually run a DHCP client, and can’t (easily) run low-level network protocol software; Docker “borrows” the host iptables for its own use and if you want very specific network policy per container it can get tricky; host storage is shared across all containers and you can’t readily assign a quota to a container. In a VM there is a (virtual) NIC, and a DHCP client, and a firewall inside the VM, and so on.
I feel like Docker’s sweet spot is as a packaging and distribution mechanism for network services that speak “simple” TCP protocols, like HTTP.
If your big concern is disk utilization, one thing I’ve found is that a full C toolchain is big. Depending on what you’re trying to install, it can be complicated to avoid needing this (even in interpreted languages like Python and Ruby). (The same techniques will work fine for building smaller VMs, as it happens.) Looking at the description of the standard Ruby image, a simple
docker pull ruby will get an intentionally large image.