What becomes clear if debugging with strace, and nc/socat, is that nat/POSTROUTING's MASQUERADE didn't alter the initially chosen address intended to be used for going out. Probably because it's still considered a local address about to be "routed" to lo so doesn't need alteration: the MASQUERADE rule has no effect here.
Anyway, that's what happened. So when replying an UDP query, the server actually connects back to the source which sent data, now use as destination. Naturally the best source is chosen to be used for this destination, the same local address, which is not 127.0.0.1. So here's what's happening if following this with conntrack -E, with an example local IP of 192.0.2.2 and a destination of 198.51.100.1 UDP port 53:
[NEW] udp 17 30 src=192.0.2.2 dst=198.51.100.1 sport=40037 dport=53 [UNREPLIED] src=127.0.0.1 dst=192.0.2.2 sport=53 dport=40037
[NEW] udp 17 30 src=192.0.2.2 dst=192.0.2.2 sport=53 dport=40037 [UNREPLIED] src=172.16.0.22 dst=172.16.0.22 sport=40037 dport=53
The reply is not correlated to the initial query (because the source IP isn't 127.0.0.1) so conntrack is handling this as a 2nd flow. Meanwhile the client put its UDP socket in connected mode, meaning an UDP packet received from the wrong source IP (even if correct ports) will be rejected, and the server receives an ICMP error (this can be witnessed with tcpdump -i lo).
The correction is quite simple: don't use MASQUERADE but SNAT. Of course it now has to be specialized for this specific flow (you don't want to SNAT everything to 127.0.0.1), so replace the MASQUERADE line with this instead:
iptables -t nat -A POSTROUTING -p udp --dport 53 -j SNAT --to-source 127.0.0.1
With the corrected flow, the local server now replies using conntrack's expected address which now associate it in the previous flow and de-SNATs it correctly:
[NEW] udp 17 30 src=192.0.2.2 dst=198.51.100.1 sport=38871 dport=53 [UNREPLIED] src=127.0.0.1 dst=127.0.0.1 sport=53 dport=38871
[UPDATE] udp 17 30 src=192.0.2.2 dst=198.51.100.1 sport=38871 dport=53 src=127.0.0.1 dst=127.0.0.1 sport=53 dport=38871
The client receives the expected source 198.51.100.1 and all works as intended.
TCP doesn't suffer the same result, because once the connection is established between 192.0.2.2 and 127.0.0.1, the reply is within the same established connection, it's not a new connection as with UDP, so will have already the expected source and is handled correctly by conntrack. Better anyway add this for consistency:
iptables -t nat -A POSTROUTING -p tcp --dport 53 -j SNAT --to-source 127.0.0.1
Two notes:
for your specific case, route_localnet is not needed because all packets are local and stay on lo. The opposite: forwarding elsewhere packets sent to 127.0.0.1 would require it (as well as other tricks).
You will probably need additional exception rules if your DNS server is also a DNS client (which would be the case for a recursive DNS server) sending queries outside, or its own queries will be rerouted to itself creating a loop. Usually solved by having the server running with a specific user and using iptables' -m owner match. Something like inserting before each group of rules (in nat/OUTPUT and nat/POSTROUTING) this:
iptables -t nat -I .... -m owner --uid-owner unbound -j RETURN