Hacker News new | ask | show | jobs
by nightpool 1771 days ago
I love using Docker Compose in theory, but I've found it really difficult to do local development with a "Docker only" setup on Mac, due to the performance issues with the filesystem layer (even when using cached volumes, etc). Ruby gemfiles and node_modules are big culprits here, since they involve a lot of filesystem accesses to load/install dependencies. It might be more manageable if I was just using Postgres from docker and had e.g. rubyenv and nodenv installed locally, but that sacrifices a lot of the benefits you gain from having a docker-compose setup, and I've never had any problems with managing multiple PG versions in my Postgres.app install.
7 comments

You could look into Nix for Ruby and Nodejs. I'm not a particularly experienced Ruby developer, but having Nix take care of all versioning and dependencies for me made the whole ecosystem really accessible (in the sense that I don't need to care about most of it). Everything is still in your filesystem so there shouldn't be any performance issues, and you still get the isolation benefits from Docker (like versions and dependencies not leaking from one project to another).
You mean Nix as a base image?
No, Nix installed in your system.
I don't know much about Nix.

But is there really any isolation if it's installed on your host os? I always thought Nix was primarily a package management tool. Like brew?

Docker isolation is different.

Nix is primarily a package management tool, yes, but it provides isolation in the sense that you don't have to globally install anything (except for Nix, of course). A tool I use extensively when developing is a "nix shell", which is a shell that's configured with a `default.nix` file.

For example, in project A I use Node.js v12. The project root contains a `default.nix` file that says it needs the package `nodejs-12_x`. When I run `cd /project/root && nix-shell` I'm dropped into a shell that has `nodejs-12_x` along with the rest of my "normal" shell. Once I exit it, `nodejs-12_x` is no longer available. If in project B I use Node.js v14, all I have to do is declare in its `default.nix` file that it uses `nodejs-14_x` and there will be no conflicts whatsoever.

Of course this is different from the isolation Docker provides, but I find that for development it is the perfect middle-ground between "everything is installed globally and conflicts with each other" and "everything is so perfectly isolated I can't get anything done".

Nix is more sort of a… content-addressable executable environment manager.

Brew has a single shared “brew env” that it executes all its installs in the context of. Which is better than nothing, but it still means that different programs can’t be fixed to rely on different locked+resolved commits of the same symbolic-named ref of a dependent formula. (And Brew is very “naive” in this regard, as formulae can’t even specify a version constraint for their formula dependencies. If a lib updates, and breaks its dependents? Too bad, the Brew maintainers need to go update all the dependents. This creates long-standing update PRs in the homebrew-core repo, as the same PR that introduces an update, is expected to also then fix all the problems that introducing that update created for the rest of the ecosystem.)

Slightly more savvy package managers, like Rubygems, allow version constraints, but only globally; there can only be one resolved version of each package (this is a fundamental limitation — loading multiple versions of the same library into a single Ruby runtime would generate namespace collisions), so Rubygems emits “constraint resolution failures” when different deps want incompatible versions of something.

And then there’s the Node.js approach, where everything can specify its own version constraints, and gets those specified versions installed recursively into its own nested node_modules dir. Which is nice, but 1. still requires all the code to be “source compatible”, as it’s all still being loaded into a single interpreter, and 2. makes it impossible to “share” deps and deduplicate the work of building them, even if you explicitly create two dependent libs that both depend on the same fixed version of an upstream. (I think this latter part is hacked around by tools like yarn, but it’s still part of the “architecture” of the Node.js package ecosystem.)

In Nix, meanwhile, each “package” is a really a build environment, consisting of:

1. specific, locked commits of all upstream build environments;

2. a listing of build artifacts from those upstream build-environments that should be linked into this build environment;

3. a specific, locked commit (or a release tarball with an explicit SHA) of the upstream source of the package.

When you “install” a Nix “package”, you’re really just doing the moral equivalent of a recursive git-submodule checkout — each dep tells Nix to check out explicit refs of its own deps in turn, build those deps, and then link artifacts from those deps into this Nix build env.

But unlike Node.js modules, or git submodules (which form trees of refs), Nix environments form a DAG of references; so if two things in your tree share the exact same “submodule ref”, they can share/reuse the existing build env and its artifacts. (But if they don’t—if they envs they reference are even slightly different—they’ll do separate builds. Though perhaps they’ll share a git-repo cache for separately checked-out worktrees.)

Note that this mechanism isn’t really unique to “packages” per se. It’s less about packages, and more about build environments.

In other words: Nix is a manager for defining reproducible/deterministic chroots, which bootstrap themselves by grabbing other previously-defined reproducible/deterministic chroots and doing things inside them.

Nix “Packages” are just chroots that define build steps, so that other chroots downstream of them can ask the upstream chroot to build itself, and then import/link build artifacts from it. But these chroots don’t have to have build steps. You can totally use Nix to create a leaf-node chroot that doesn’t emit any build artifacts, but rather is just a perfectly-set-up environment to run something in.

Throw an nsenter(2) on top of that chroot(2), and you’ve got yourself a container!

Or take a flattened snapshot of the final chroot, and call it a Docker image. (Nix provides tooling for this: https://nix.dev/tutorials/building-and-running-docker-images)

I personally just .dockerignore the node_modules directory and run the front end outside of docker, but still get all the benefits of backend isolation, databases and caching layers via docker-compose, etc.
This is off-topic, but I feel this more for live reloading/watching of rebuilding a node/webpack app vs. databases. For databases locally, I'm doing so little write usually that it's not a big deal. For coding and recompiling/hot reloading it's a big deal, and the perf is a pain. I really love working with Docker, so I hope they make the Mac experience more friendly soon.
Using docker-sync helps alot, and then using it's ability to ignore certain folders (like folders with high churn like tmp and log folders) helps even more. Over time the performance story has improved, but I still find docker-sync to be the best approach for me, and I've been 100% Docker Compose for about 4 years now (even on projects that don't deploy to Docker)
I used docker-sync for a long time, but last time I tried it (2019) it was too unreliable—I spent 30 minutes to an hour every week diagnosing issues with out of sync files and broken sync processes.
I've had good luck with it, but there are a few times I've had to go into the project's issues to find a solution for problems.
I've run into problems bind-mounting node_modules, so I just do an install for the image. There are the performance problems you mentioned, but also sometimes libraries build differently in Linux vs. Mac.
Yes, we no longer bind-mount node_modules or bundled gems, but I still run into a lot of performance issues with Docker generally (especially very high kernel CPU usage). Additionally, not having node_modules and bundled gems accessible from my development environment makes it harder to diagnose dependency issues when I need to (e.g. pull up the source code for a gem and see why it's not working).
Last I checked, docker for mac also couldn't do bridge networking, which makes it a pain in having to map every service to a port on the host.
Windows is a much better Docker host these days.