A developer tool for providing DNS for Docker containers running on a local Linux development host.
- Linux operating system (e.g. Ubuntu)
systemd-resolved
service (enabled and running)docker
(>= 20.10
)docker-compose
(>= 1.27
)- optionally,
curl
- optionally,
psql
ldhdns
works by running a controller and a lighweight DNS server as docker containers.
The controller manages the DNS server and configures systemd
's resolved
to use it.
The DNS server, uses the Docker API to monitor when containers are started and stopped, and uses specific labels of the containers for the subdomain name to use.
The domain names are dynamically resolveable on the host and from within containers, so that the same names can be used in either scenario, and with the actual service ports so no container to host port mappings are required.
Start the controller, attaching it to the host network, as follows:
Security Note: The container mounts the Docker socket so that it can consume the Docker API
and it is run with the apparmor=unconfined
security option and mounts the SystemBus socket so
that it is able to configure systemd-resolved
dynamically. Please inspect the code
and build the image yourself if you are concerned about security.
LDHDNS_CONTAINER_NAME=ldhdns
docker run \
--name $LDHDNS_CONTAINER_NAME \
--detach \
--network host \
--security-opt "apparmor=unconfined" \
--volume "/var/run/docker.sock:/tmp/docker.sock" \
--volume "/var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket" \
--env LDHDNS_CONTAINER_NAME=$LDHDNS_CONTAINER_NAME \
--restart unless-stopped \
ghcr.io/virtualstaticvoid/ldhdns:latest
Visit the virtualstaticvoid/ldhdns
image repository for available image tags.
The network ID, domain name suffix and subdomain label are configured with environment variables:
LDHDNS_NETWORK_ID
for docker network name to use. The default isldhdns
.LDHDNS_DOMAIN_SUFFIX
for domain name suffix to use. The default isldh.dns
.LDHDNS_SUBDOMAIN_LABEL
for label used by containers. The default isdns.ldh/subdomain
.LDHDNS_CONTAINER_NAME
for the container name of the controller. The default isldhdns
.
NOTE: The LDHDNS_CONTAINER_NAME
environment variable is required since the controller needs
to be able to obtain the ID of the container which it is executing in. The OCI
runtime specification doesn't currently provide a portable way to obtain the
container ID from within and using hacks such as via /proc/self/cgroup
and
/proc/1/cpuset
have proven to be unreliable.
You can provide your own domain name via the LDHDNS_DOMAIN_SUFFIX
environment variable as follows:
LDHDNS_CONTAINER_NAME=ldhdns
docker run \
--name $LDHDNS_CONTAINER_NAME \
--env LDHDNS_DOMAIN_SUFFIX=ldh.example.com \
--detach \
--network host \
--security-opt "apparmor=unconfined" \
--volume "/var/run/docker.sock:/tmp/docker.sock" \
--volume "/var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket" \
--env LDHDNS_CONTAINER_NAME=$LDHDNS_CONTAINER_NAME \
--restart unless-stopped \
ghcr.io/virtualstaticvoid/ldhdns:latest
Tip: If you are using a "real" domain name, be sure to use a subdomain off the apex domain,
such as ldh.
to avoid any clashes with it's public DNS resolution.
E.g. Use ldh.example.com
for the domain name so that a container "foo
" will be resolvable
to foo.ldh.example.com
instead of foo.example.com
.
To make containers resolvable, add the label "dns.ldh/subdomain=<subdomain>
" with the desired
subdomain to use.
This subdomain will be prepended to the domain name in the LDHDNS_DOMAIN_SUFFIX
environment
variable to form a fully qualified domain name.
To apply the label to a container using the command line:
docker run -it --rm --label "dns.ldh/subdomain=foo" nginx
Or with Docker Compose:
# docker-compose.yml
services:
web:
image: nginx
labels:
"dns.ldh/subdomain": "foo"
Note: Make sure to use the same label key you provided in the LDHDNS_SUBDOMAIN_LABEL
environment variable.
Note: Labels cannot be added to existing containers so you will need to re-create them to apply the label if needed.
Start by running Nginx in a container:
docker run --detach --label "dns.ldh/subdomain=foo" nginx
The subdomain provided will now be resolvable to the container IP address.
E.g.
dig -t A foo.ldh.dns
; <<>> DiG 9.16.1-Ubuntu <<>> -t A foo.ldh.dns
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 61163
...
;; ANSWER SECTION:
foo.ldh.dns. 15 IN A 172.17.0.2
...
And you can go ahead and consume the service.
From a terminal running on the host:
curl -v http://foo.ldh.dns
<!DOCTYPE html>
<html>
...
<h1>Welcome to nginx!</h1>
...
</html>
Or from within another container:
docker run -it --rm curlimages/curl -v http://foo.ldh.dns
<!DOCTYPE html>
<html>
...
<h1>Welcome to nginx!</h1>
...
</html>
The development experience is based on docker compose.
The docker-compose.yml file contains two instances of the ldhdns
service;
one configured using the defaults and the other with an alternative configuration.
It also contains sample services with nginx
and postgres
so that tests can be conducted
against these types of services.
The default configuration uses the ldh.dns
DNS suffix with the dns.ldh/subdomain
label,
and the alternative configuration uses the alt.dns
DNS suffix with the alt.ldh/subdomain
label.
Use docker compose build
to build the ldhdns
docker image locally.
Use docker compose up
to run the services locally.
Once the services are running, use docker compose run test
to run the tests from within the test
service, execute test.sh
directly from your host, which uses the curl
and psql
tools,
or navigate to the following URL's with your web browser:
Consider a scenario in development where you are building a Single Page Web Application (SPA) and REST API, with a PostgreSQL database, with each service running in Docker containers on your local machine.
A web browser connects to the Web Application and the REST API, and the API connects to the PostgreSQL database.
In development, to access these services there are number of options:
- Map the container ports to host ports and access the services using
localhost
together with the host port number, - Obtain the IP address of each respective container and access the services using the IP address together with the container port number,
- Using domain names instead of IP addresses, adding them to your
/etc/hosts
, mapping each container IP address to a name.
Each of these methods have difficulties, short-comings and implications, such as:
- No consistent convention for mapping container ports to host ports.
- Potential host port clashes when running multiple instances of a container.
- Manual steps needed to get the IP addresses of containers.
- Editing
/etc/hosts
requires root permissions. - Manual updates to
/etc/hosts
required each time an IP address changes. - Portability issues for other developers on their machines when collaborating on projects.
- Configuration differences on the host vs within the container.
Furthermore, when host to container port mappings are typically used, the mapping could
be 8080
to 80
for the Web Application and 8090
to 80
for the REST API. The SPA Web
Application would therefore have to be configured to use http://localhost:8090
to access
the API. However the API connects directly to PostgreSQL so it would have to configured to
use the PostgreSQL container name.
You may also want to run some ad-hoc SQL queries whilst debugging, so connecting a tool such
as psql
would require a further port mapping of 8432
to 5432
.
As you can see this setup gets messy and complicated quickly and isn't a great developer experience!
Now imagine adding SSL ports (443
) so that you can debug under more production like conditions
with TLS certificates; the situation gets nasty fast. Don't even think about having more than
one instance of a container, such as when using the docker-compose up --scale api=N
to add
more container instances of a service!
ldhdns
provides a simple solution. It monitors running containers, looking for labels which
contain the domain name to use, and configures and runs a lightweight DNS server.
The domain names are dynamically resolveable on the host and from within containers, so that you can use the same fully qualified domain names in each scenario and use the actual service ports just like in production.
In the above mentioned example, you could use web
, api
and pgsql
as the subdomains for
the respective containers, making the Web Application and REST API accessible
via http://web.ldh.dns
and http://api.ldh.dns
respectively, and the PostgreSQL service
accessible via pgsql.ldh.dns
.
ldhdns
consists of two services which are packaged in the same Docker container.
The following diagram illustrates the components which make up the solution, and how they interact with the host machine, the docker API, systemd-resolved and other applications such as a browser or psql.
The controller creates and configures a Docker bridge network and configures systemd-resolved
to resolve DNS queries for the configured domain name. It spawns a second
container to monitors the Docker API for when containers are started or stopped, creating and
removing DNS records accordingly, and runs dnsmasq
to resolve DNS queries for A
(ipv4)
and AAAA
(ipv6) type records for the configured domain.
- I got tired of running
docker ps
to figure out the container name, followed bydocker inspect
to get the IP address and then manually editing/etc/hosts
. - I couldn't come up with a consistent convention for mapping host to container ports. What comes after 8099?
- Finding IPv4 and IPv6 CIDR blocks which aren't in use so that static IP's can be used.
- Not being able to create SSL certificates for
*.xip.io
or*.nip.io
domains.
- Configure systemd-resolved to use a specific DNS nameserver for a given domain
- How to configure systemd-resolved and systemd-networkd to use local DNS server for resolving local domains and remote DNS server for remote domains
dnsmasq
dnsmasq
Tips and Tricks- github.com/programster/docker-dnsmasq
systemd-resolved
- github.com/jonathanio/update-systemd-resolved
MIT License. Copyright (c) 2020 Chris Stefano. See LICENSE for details.