Look Up

Whilst Docker is a containerisation technology, it's not just about running applications - there's also networking. When you add a container to a docker network, it magically becomes discoverable by other containers on the same network with DNS. All containers use Docker's magical internal DNS server to achieve this. However, whilst Docker networks are accessible to the host (unless you set internal: true), the same isn't true for the DNS server - which is only accessible from inside the Docker network.

For reasons I won't go into (at least in this post), I needed access to Docker's internal DNS server, to discover the address of specific containers by name - sort of like service discovery, but far more naive. There were 2 main approaches as I saw it - the first was by modifying Docker's networking setup to allow access to its DNS server from outside a docker network, and the second is by proxying requests through.

<tease>

But, as a teaser for an upcoming post. The request for this page you're reading involved an internal DNS request to Docker's DNS server.

</tease>

I'm not a networking guy, I much prefer living at layer 5 and up, so I don't want to go anywhere near Docker's networking rules. Docker has a hate-hate relationship with tools like iptables and UFW, which makes firewalling properly much much complex. I don't want to ever have to touch Docker's networking rules if I can possibly avoid it. So, proxying it is!

#Proxying DNS

When talking about proxying in the HTTP world, we tend to think of reverse proxies, which expose multiple services running behind it through a single "proxy" based on various incoming attributes (hostname, path etc). These services aren't accessible to clients directly, perhaps for security, additional functionality / configuration, or simply because there are multiple services on the same system. Common HTTP reverse proxies include nginx, Apache and Traefik.

To expose DNS externally, we need to do the same thing: Receive DNS requests with one tool, and relay them through to a backing service, in this case Docker's DNS server. Unlike a reverse proxy, we need to do very little processing in the middle. In fact, we don't need to do any processing, but there are some benefits to doing at least a little.

The flow of DNS traffic

#Tooling

There are lots of different DNS servers out there: dnsmasq, BIND, unbound, even pihole (dnsmasq in a trenchcoat) and AdGuardHome to name a few popular ones. All of these are capable of being fully-fledged DNS servers - that you provide with DNS records and they'll reply. But that's not what we need right now. Additionally, all have the ability to delegate certain requests to other servers. For example, if you wanted everything to be handled by Cloudflare's DNS servers, but wanted mydomain.com to be handled by your own instance of BIND, you totally can.

#CoreDNS

For me, I didn't use any of these tools - I used CoreDNS. CoreDNS may be familiar to you if you're used to doing funky things with Kubernetes, but it's a highly configurable DNS server with a number of useful plugins and built-in functionality. I've used dnsmasq for a while, but CoreDNS is far easier to configure and work with. It's already running in a few different places in my infrastructure and I'm more than happy with it. There's a first-party Docker container too (unlike dnsmasq), which makes deploying it a breeze and updates even easier.

<rant>

CoreDNS is sadly written in Go

</rant>

#No DNS?

All of these tools speak DNS natively. In the process, they parse and interpret the DNS request, and create a completely separate one to the backing DNS server. However, there's no reason we need to do that. DNS is just UDP, and it's possible just modify the raw packets a little to change their destination (known as DNAT). There are tools to do this, but it's easier to debug and introspect if the connection is completely terminated, and enables a little more functionality.

#Configuration

The premise is simple. When a DNS request comes in, forward it to Docker's DNS server, which is accessible to all containers at 127.0.0.11:53, and relay the response back. No additional processing necessary.

With CoreDNS, this is pretty simple:

Corefile
. {
    errors
    cancel

    forward . 127.0.0.11
}

Yes, that's really it - that's the whole configuration. CoreDNS will listen on port 53 (its default), and forward everything through to Docker. The errors and cancel lines are plugins to abort slow requests and show errors if they happen (there's no access logs by default).

<hint>

If the configuration here reminds you of Caddy's - that's because it literally is.

</hint>

If you take this configuration and deploy it (for example, with docker-compose), you'll be all set:

docker-compose.yml
version: "2.3"

services:
  coredns:
    image: coredns/coredns:latest
    volumes:
      - ./Corefile:/home/nonroot/Corefile:ro
    ports:
      - 5533:53/udp
    networks:
      - default
      - coredns

networks:
  coredns:
    external: true

This exposes containers on the "coredns" network via a DNS server listening on port 5533. If you add the device to multiple networks, it should expose the devices on all the networks it's a part of.

<warning>

5353 is also a common custom port, but it turns out that breaks mDNS.

</warning>

#Extras

But, whilst this works perfectly fine, I wanted a little more out of this DNS server. And if you're still reading, you probably do too.

#Subdomain

Docker responds to requests for a container's "name", which in the case of my website is website-website-1. So dig website-website-1 @127.0.0.1 -p 5533 returns the correct IP address. But if someone sees website-website-1 in my configuration, it might not be clear what it is or where it's coming from. To deal with that, it'd be nice if instead it would respond to website-website-1.docker. Docker can't do that, but we can fake it.

To achieve this, we need to strip the .docker suffix from the incoming domain (if one exists), and stick it back on the reply. With CoreDNS, that's a single line with the rewrite plugin:

Corefile
rewrite name suffix .docker . answer auto

rewrite name suffix replaces all records ending with .docker with . (thus stripping it off). answer auto is the magic sauce which ensures the DNS response says the record is still for .docker, rather than the root. That's rarely an issue for clients, but it's still good practice to keep the query and response consistent.


And just like that, requests for *.docker work too.

#Restrictions

The purpose of this DNS server is to serve requests for Docker's DNS server only. Docker is smart enough to only reply to requests for services it knows about, but CoreDNS isn't. Whilst it's not an issue, it'd be nice if CoreDNS rejected any other kind of traffic, so it's easier to see a DNS leak and debug issues. To achieve that, we need to only accept requests for .docker, and block everything else. That's a few more lines of configuration, but not many.

#Only accepting .docker requests

CoreDNS borrows the concept of "views" from BIND. Views determine which requests a given server block (the top level thing in curly brackets) can respond to. CoreDNS's view plugin lets us define this:

Corefile
view docker {
    expr name() endsWith '.docker.'
}

Whichever server block defines this "view" will only respond do requests ending .docker.

<note>

The trailing dot is intentional - DNS records actually end with a dot

</note>

#Rejecting anything else

The final step is to ensure that any request not serviced by our main "view" is rejected. By default, if no blocks are valid for the incoming request, CoreDNS will simply return no answer. For our use case, that's fine, but it'd be nicer to hint to the user the reason they got no answer.

For this, we can lean on CoreDNS's built-in ACL support from the acl plugin. Normally, ACLs allow restricting who can make queries to a given view. In our case, we want to define a catch-all view which allows no one to access it.

Much like everything with CoreDNS, that's just another few lines of configuration, this time right at the end of the file:

Corefile
. {
    acl {
        block
    }
}

By not defining any allow statements, CoreDNS will "block" all requests which hit this view (ie which don't end .docker). It's also possible to drop the requests, that trades a little more security with a lot more debugging annoyance.

#Conclusion

Once CoreDNS is up and running, it's now possible to request the IP address of a device on a Docker network using just its name, on a server which won't reply to anything else.

Bash Session
$ dig @127.0.0.1 -p 5533 website-website-1.docker +short  
172.20.74.5

Both CoreDNS and Docker's DNS are fast - I've never seen a request report more than 0ms with dig. Whilst you can add caching to CoreDNS, there's not much point, and you'd be better off adding caching to whatever is making the DNS request in the first place. Docker gives records a TTL of 10 minutes, but if a container restarts its IP address might change, so I'd recommend leaving the cache pretty low if the container might move.

If you take one thing away from this post, it's that CoreDNS is actually quite nice, and if you need to do something semi-complex with DNS, it's a great tool to know about.

Share this page

Similar content

View all →

None

Docker in LXC

Docker is a great containerization technology for running applications. It keeps multiple applications completely isolated from each other, only allowing connections exactly when you tell them to. But what if you’re on a hypervisor? You want your host OS to be as lean as possible (else it defeats the point),…

Library Database

Upgrading Databases in Docker

2021-12-23
5 minutes

For me, every Monday is updates day. All machines have OS updates, and the handful which run docker get their containers pulled. However, pulling containers merely updates the underlying container OS, or maybe patch versions of the application (because I do container pinning properly). Updating actual versions can be a…