The release upgrades for Ubuntu were a little rough whenever they changed init systems (sysv -> upstart, upstart -> systemd), and occasionally there has been some other weirdness.
But the real key is that do-release-upgrade sucks because it's too conservative because they roll back when there are any errors or dependencies they can't resolve. If you do your release upgrades manually, you can always work through those issues. Debian/Ubuntu busybox includes dpkg, so even if you end up in a very broken state (glibc version mismatch, coreutils don't work, bash is broken, etc.). You just need a copy of busybox installed, tmux, and maybe a portable SSH daemon and a statically compiles curl, and you can do everything the release upgrade process does but with the ability to rescue a failure instead of rolling back or dying. It's probably good to follow the same upgrade path as that tool likes though (only directly upgrade between adjacent releases or from one LTS to the next LTS), so you may have to do this a couple of times if your servers are really old.
(I guess you don't really even need any of those statically compiled tools, either— you can just use Nix to provide whatever you need instead since no Nix-provided tools care about the libs provided by the underlying Ubuntu system.)
At a past job I upgraded in-place some Ubuntu 14.04 web servers to 20.04 through some release-upgrade failures this way. It's generally better to rebuild on a new, clean image, but in-place upgrades on Linux rarely fail and are pretty much always rescuable when they do.
But the real key is that do-release-upgrade sucks because it's too conservative because they roll back when there are any errors or dependencies they can't resolve. If you do your release upgrades manually, you can always work through those issues. Debian/Ubuntu busybox includes dpkg, so even if you end up in a very broken state (glibc version mismatch, coreutils don't work, bash is broken, etc.). You just need a copy of busybox installed, tmux, and maybe a portable SSH daemon and a statically compiles curl, and you can do everything the release upgrade process does but with the ability to rescue a failure instead of rolling back or dying. It's probably good to follow the same upgrade path as that tool likes though (only directly upgrade between adjacent releases or from one LTS to the next LTS), so you may have to do this a couple of times if your servers are really old.
(I guess you don't really even need any of those statically compiled tools, either— you can just use Nix to provide whatever you need instead since no Nix-provided tools care about the libs provided by the underlying Ubuntu system.)
At a past job I upgraded in-place some Ubuntu 14.04 web servers to 20.04 through some release-upgrade failures this way. It's generally better to rebuild on a new, clean image, but in-place upgrades on Linux rarely fail and are pretty much always rescuable when they do.