
How to Run Exim in a Docker Container
Running Exim in a container is not straightforward, especially as it is not an application built natively to run in a container. Of course, it would be great if Exim was a twelve-factor app, but it isn’t and sometimes you just have to deal with older-style apps. This post was originally posted on the 6th August 2020, today we update it with some new practices and a new operating system.
This post deliberately exposes the “building block” development process of creating a Docker image and making it close to production-ready to run in a container. Rather than configure Exim to run in an Atmail specific environment here, we are describing a generic installation – you will definitely need to customise the below for your environment so that it integrates into your broader architecture and runs your desired configuration. I am not going to get into the specifics of optimising your container environment, for example how to allocate appropriate volumes for a mail server, nor am I going to overly explain the Docker commands and will largely use the defaults – that is out of scope of this article. I am simply showing the building blocks of getting a container running in Docker.
Note: atmail doesn’t currently ship images that a compatible with our mail server product, but we are exploring. This post is part of that exploration. We are likely to deploy containers as part of our cloud solution long before we make them available in our on-premises products.
Step 1: Create the Dockerfile
We will base our Exim image on Ubuntu LTS 22.04 (jammy). First, I just want to ensure this is working and running. As we go, we will ensure that each step of the way is working before we add another layer of complexity.
Create the following Dockerfile in $HOME/exim-blog-post/Dockerfile:
# build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected]
Now, build this image:
$ cd $HOME/exim-blog-post $ docker build -t exim-blog-post:0.1 .
Check that it runs – at the moment it’s just a base install of Ubuntu, so should work:
$ docker images exim-blog-post REPOSITORY TAG IMAGE ID CREATED SIZE exim-blog-post 0.1 9b13596365a3 4 hours ago 69.2MB $ $ docker run -it exim-blog-post:0.1 /bin/bash root@94ead8c256a4:/# echo "it works" it works root@94ead8c256a4:/# exit exit $
Step 2: Ensure a secure image
Here we will ensure that we apply all updates to the image, using apt. The commands that are run with the “RUN” directive in the Dockerfile are added to the image itself, not run at run-time – so you only get these updates on image build – remember this! It is good practice to do this when building an image to ensure you have the latest code, including all security updates.
Note: As an operational practice within atmail, we build all images to the CIS Level 2 hardened linux specifications. For practical purposes, I won’t detail exactly how we do that here in this blog post, but in short, we use our own base image, rather than a publicly available one.
$ cat Dockerfile # build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected] # update the apt database and install security updates ARG DEBIAN_FRONTEND="noninteractive" RUN apt update RUN apt upgrade -y $ docker build --quiet -t exim-blog-post:0.2 . sha256:0cfc94cd7edbaab837228ba2cefa17e98d29f8967e688af8039d8ab15707b31e
Step 3: Let’s get Exim involved
We will now add Exim into the install – we are using the ‘exim4-daemon-heavy’ package, as it has more features enabled that is more suitable for a production MTA environment that Atmail are used to running.
# build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected] # update the apt database and install security updates ARG DEBIAN_FRONTEND="noninteractive" RUN apt update RUN apt upgrade -y # install exim and run it whenever this container runs RUN apt install -y exim4-daemon-heavy CMD ["/usr/sbin/exim", "-bd", "-q1h"] $ docker build --quiet -t exim-blog-post:0.3 . sha256:bcfd464a24cfced2cad81eba210d148766a86ba87b776a6d2e92a49d9ac8214b $ docker run exim-blog-post:0.3 $
Hmm, that didn’t do much. Why?
Step 4: A little bit of troubleshooting
Let’s connect to the image and see where logging is going, the current configuration, and what might be going on…
$ docker run -it exim-blog-post:0.3 /bin/bash root@809e566123ad:/# /usr/sbin/exim -bP | grep "log_file_path" log_file_path = /var/log/exim4/%slog
Well, that’s a normal Exim configuration file and it’s writing it’s log file to disk – in this case to /var/log/exim4/mainlog, /var/log/exim4/rejectlog and /var/log/exim4/paniclog (that’s what %s means).
Step 5: Map a local file as the logs
Docker allows you to map in local files and directories (or even remote ones) into the container environment. What we are going to do below is map in a file for each of the log files produced that resides on our local disk so that we can see the contents of the log files. In the previous command the logs will be written to the files inside the container, but as soon as the container exits we lose access to those files.
$ mkdir logs $ touch logs/mainlog logs/rejectlog logs/paniclog $ docker run --mount src=`pwd`/logs/mainlog,target=/var/log/exim4/mainlog,type=bind --mount src=`pwd`/logs/rejectlog,target=/var/log/exim4/rejectlog,type=bind --mount src=`pwd`/logs/paniclog,target=/var/log/exim4/paniclog,type=bind exim-blog-post:0.3 $
Why is our container exiting…?
$ cat logs/mainlog 2022-10-05 04:13:52 Warning: No server certificate defined; will use a selfsigned one. Suggested action: either install a certificate or change tls_advertise_hosts option
Well, that’s just a warning – it isn’t dire. After a bit of thinking I realise that the container itself is running the specified command and then exits, and because our exim daemon is set up to background itself then Docker believes it is finished running and terminates the container – doh! Let’s stop exim from going to the background and enable debug mode.
$ cat Dockerfile # build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected] # update the apt database and install security updates ARG DEBIAN_FRONTEND="noninteractive" RUN apt update RUN apt upgrade -y # install exim and run it whenever this container runs RUN apt install -y exim4-daemon-heavy CMD ["/usr/sbin/exim", "-bdf", "-d", "-q1h"] $ $ docker build --quiet -t exim-blog-post:0.3 . sha256:d7ab312341cea73cb3cd85606cec42a889a8bed183bcd4e0b2742d82856294db $ docker run --mount src=`pwd`/logs/mainlog,target=/var/log/exim4/mainlog,type=bind --mount src=`pwd`/logs/rejectlog,target=/var/log/exim4/rejectlog,type=bind --mount src=`pwd`/logs/paniclog,target=/var/log/exim4/paniclog,type=bind exim-blog-post:0.3 Exim version 4.95 uid=0 gid=0 pid=1 D=f7715cfd Support for: crypteq iconv() IPv6 PAM Perl Expand_dlfunc GnuTLS TLS_resume move_frozen_messages Content_Scanning DANE DKIM DNSSEC Event I18N OCSP PIPE_CONNECT <....snip....> >>>>>>>>>>>>>>>> Exim pid=7 (cipher-validate) terminating with rc=0 >>>>>>>>>>>>>>>> tls_validate_require_cipher child 7 ended: status=0x0 1 creating notifier socket 1 @/var/spool/exim4/exim_daemon_notify 1 listening on 127.0.0.1 port 25 1 LOG: MAIN 1 socket bind() to port 25 for address ::1 failed: Cannot assign requested address: waiting 30s before trying again (9 more tries)
OK, it seems exim is trying to bind to an IPv6 port, but IPv6 isn’t enabled in my docker host. Whilst it is becoming a better idea as each day passes to implement IPv6 natively everywhere, for this blog post we are going to instruct exim to not bind to the IPv6 loopback address, and bind to all available interfaces by updated the ‘local_interfaces’ directive. To do so, we will need to create a local config file, map that file into the container into a specific location and then tell exim to use this config file rather than it’s default location.
While we are at it, we need to fix up the config file produced by exim as some of the configuration options won’t be read by Exim as produced (yeah, weird!):
$ docker run exim-blog-post:0.3 exim -bP > exim-blog-post.conf $ egrep -v "^(openssl_options|errors_reply_to|smtp_ratelimit_|syslog_facility|system_filter_user|system_filter_group|smtp_load_reserve|queue_only_load|deliver_queue_load_max|local_interfaces)" exim-blog-post.conf > tmpfile; mv tmpfile exim-blog-post.conf $ echo "local_interfaces = 0.0.0.0" >> exim-blog-post.conf $ cat Dockerfile # build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected] # update the apt database and install security updates ARG DEBIAN_FRONTEND="noninteractive" RUN apt update RUN apt upgrade -y # install exim and run it whenever this container runs RUN apt install -y exim4-daemon-heavy CMD ["/usr/sbin/exim", "-bdf", "-d", "-q1h", "-C", "/etc/exim-blog-post.conf"] $ $ docker build --quiet -t exim-blog-post:0.3 . sha256:d7ab312341cea73cb3cd85606cec42a889a8bed183bcd4e0b2742d82856294db $ docker run --mount src=`pwd`/logs/mainlog,target=/var/log/exim4/mainlog,type=bind --mount src=`pwd`/logs/rejectlog,target=/var/log/exim4/rejectlog,type=bind --mount src=`pwd`/logs/paniclog,target=/var/log/exim4/paniclog,type=bind --mount src=`pwd`/exim-blog-post.conf,target=/etc/exim-blog-post.conf,type=bind exim-blog-post:0.3
Excellent – we now seem to have exim running with debug too!
Now, how to connect to it so we can send email?
Step 6: Some service ports
Now, we need a way to connect to Exim that is running in the container, from outside of the container. So I need port 25 (which is defined in the config as “daemon_smtp_ports = smtp” in the Exim config, and if you follow the bouncing ball, smtp is defined as port 25 in /etc/services), it is also common to include 465 (submissions) and 587 (submission), but not here, not today. I am going to map the container port 25 to the non-privileged port of 1025 on my local linux host:
$ cat Dockerfile # build file for exim # Set specific version number and architecture requirements FROM --platform=linux/arm64/v8 ubuntu:jammy MAINTAINER [email protected] # update the apt database and install security updates ARG DEBIAN_FRONTEND="noninteractive" RUN apt update RUN apt upgrade -y # install exim and run it whenever this container runs RUN apt install -y exim4-daemon-heavy CMD ["/usr/sbin/exim", "-bdf", "-d", "-q1h", "-C", "/etc/exim-blog-post.conf"] # expose Exim to the network EXPOSE 25 $ $ docker build --quiet -t exim-blog-post:0.4 . sha256:c54f4bc7403d2368ddecb3ebf6bb8035132351b89245e9a1a609e3921dbee3a6 $ docker run --mount src=`pwd`/logs/mainlog,target=/var/log/exim4/mainlog,type=bind --mount src=`pwd`/logs/rejectlog,target=/var/log/exim4/rejectlog,type=bind --mount src=`pwd`/logs/paniclog,target=/var/log/exim4/paniclog,type=bind --mount src=`pwd`/exim-blog-post.conf,target=/etc/exim-blog-post.conf,type=bind -p 1025:25/tcp exim-blog-post:0.4
That seems to have worked. Can we connect to Exim from my localhost?
$ telnet localhost 1025 Trying ::1... Connected to localhost. Escape character is '^]'. 550 Administrative prohibition Connection closed by foreign host.
Yes, it seems we can. Great, so what are we missing? Well, obviously the config doesn’t allow me to connect and send a message, but that’s an exim config issue – I’m not here to discus how to configure Exim outside of running it in a container (which we are now doing).
The beauty of containers is to be able to run them in all sorts of environments, using a known set of libraries and ideally we run as many of them as we need across a fleet of physical servers ….. however, Exim does maintain state, i.e. it’s queue. So let’s take care of that.
Step 7: Queue state
The elephant in the room at this point is, as we all know, email servers (including Exim) are normally stateful. That is, each instance of Exim maintains it’s own state directory in the form of a message queue or spool area. So, each Exim container needs it’s own, persistent, spool area. To do this, we create a Docker volume and bind it into the spool area defined in exim.conf (or as compiled into the binary):
Each container needs it’s own spool area, so let’s create one for this instance:
$ docker volume create exim-spool-001 exim-spool-001
This time, when we run our container, we will mount the volume into /var/spool/exim and we will give our instance a name by which we can reference it (i.e. exim-001):
$ docker run --mount src=`pwd`/logs/mainlog,target=/var/log/exim4/mainlog,type=bind --mount src=`pwd`/logs/rejectlog,target=/var/log/exim4/rejectlog,type=bind --mount src=`pwd`/logs/paniclog,target=/var/log/exim4/paniclog,type=bind --mount src=`pwd`/exim-blog-post.conf,target=/etc/exim-blog-post.conf,type=bind -v exim-spool-001:/var/spool/exim4 -p 1025:25/tcp --name exim-001 exim-blog-post:0.4 $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES d76915cf10ff exim-blog-post:0.4 "/usr/sbin/exim -bdf…" 4 seconds ago Up 3 seconds 0.0.0.0:1025->25/tcp exim-001 $ docker stop exim-001 exim-001
Step 8: Mail logs
In a large environment it is typical for mail logs to be sent to a central log server, typically via syslog – which is supported by Exim out of the box. However, one of the basic premises of Docker is that apps log to stderr and stdout, and you can do whatever you want with that with various Docker log drivers. However, Exim will not send it’s logs to stdout or stderr – so what to do? We can stick with sending those logs to a file on the docker host like we have so far, but that’s not great when it comes to log rotation and management, and it gets worse if you involve something like Kubernetes where your container can be scheduled to run on any one of many hosts, you are likely to end up with log fragments all over the place!
There are a few ways to deal with this, none are particularly elegant or even available on all platforms, but let’s look at a few popular ways:
Syslog to Docker host
If you are a little lucky and are running Linux as your Docker host, then you can connect syslog on the Docker host to the container (via /dev/log) so that the Docker host receives the syslog messages (and then you can send them to anywhere you want from there).
To do this you need to change a couple of little settings in /etc/rsyslogd.conf on the Docket host, like so: [Source]
- Remove/comment out $ModLoad imjournal
- Set $OmitLocalLogging to off
- Make sure $ModLoad imuxsock is present.
- Also remove/comment out: $IMJournalStateFile imjournal.state
Then we need to change the logging directive in the Exim config and rebuild the image:
$ grep -v "^log_file_path" exim-blog-post.conf > tmpfile $ echo "log_file_path = syslog" >> tmpfile $ mv tmpfile exim-blog-post.conf $ docker build --quiet -t exim-blog-post:0.5 sha256:04ffc0bfebb7a7b5e57d97d07827c0f21889253797944605f545b64abfea2bbe $
Then, we have to map the /dev/log device into the container but remove the local file maps, so we run:
$ docker rm exim-001 exim-001 $ docker run --mount src=`pwd`/exim-blog-post.conf,target=/etc/exim-blog-post.conf,type=bind -v exim-spool-001:/var/spool/exim4 --mount src=/dev/log,target=/dev/log,type=bind -p 1025:25/tcp --name exim-001 exim-blog-post:0.5 $
Now, we can see the logs appearing in the Docker host syslog server, and can then obviously be directed to wherever you want, using rsyslogd’s other configuration options.
Apr 29 16:06:58 myhost exim[6]: exim 4.92.3 daemon started: pid=6, -q1h, listening for SMTP on port 25 (IPv4) port 587 (IPv4) and for SMTPS on port 465 (IPv4) Apr 29 16:06:58 myhost exim[8]: Start queue run: pid=8 Apr 29 16:06:58 myhost exim[8]: End queue run: pid=8</blockquote>
Ingestion through volume files
The other, non-elegant way, would be to map the Exim logs into a Docker volume and have Exim log to that volume, then map that same volume into a dedicated syslog container and configure the syslog in that container to ingest logs from those files. Not overly elegant, probably a little more portable because it will work in most Docker environments, I haven’t tested this with Kubernetes but it will probably work there too if you use a shareable disk type (something like an NFS volume).
We are not going to detail here exactly how to do this for Exim, but you should be able to figure it out pretty fast by using this blog post by the syslog-ng team.
Really, it is frustrating that the good folks at Docker and possibly others containerisation teams haven’t solved this problem already. I couldn’t imagine that it would be overly complex for the Docker host to map in a /dev/log device and/or port UDP/TCP 514 to map into the Docker infrastructure and direct logs using the Docker hosts to a destination determined by the administrator – same could apply to Kubernetes. But alas, it’s not to be it seems.
Using a sidecar container
A common pattern in use in the container world is to use a container “sidecar”. Unfortunately this doesn’t seem to be an applicable pattern for Exim as it simply uses the local syslog libraries which will log directly to /dev/log – it would be great if Exim could log directly to a remote syslog server, but it doesn’t so we can’t use this pattern.
journald?
Most modern linux distro’s actually have journald at the other end of /dev/log which will then send to syslog. So is there an opportunity there?
Not really, well no more than syslog – it pretty much has the same problems as syslog and the same set of inelegant solutions. Let me know if you disagree.
Oh, and also most containers do not run systemd so unless you go out of your way to include it, journald isn’t typically available in a container. The proof:
/dev/log in a regular Ubuntu 20.04 LTS system:
# ls -la /dev/log lrwxrwxrwx 1 root root 28 Oct 5 13:49 /dev/log -> /run/systemd/journal/dev-log
…and the same file from inside our exim container:
$ docker exec exim-001 ls -la /dev/log ls: cannot access '/dev/log': No such file or directory
Step 9: Administer the container
So, the next logical question is: How do I manage this instance of Exim? You know, get a list of the mail queue, or find out what Exim is doing, etc… Well, Docker has this figured out, you can simply run commands inside a running container like so:
First, restart our container that we stopped in the last step:
$ docker restart exim-001 exim-001
Now, run the commands we’d like to run:
$ docker exec exim-001 exim -bpc # queue count 0 $ docker exec exim-001 exim -bp # print the queue, not very exciting with 0 messages $ docker exec exim-001 exim -bt [email protected] R: nonlocal for [email protected] [email protected] is undeliverable: Mailing to remote domains not supported
So, all very straight forward, and exactly what you are used to.
The wrap up
Well, there we go, we have Exim running in a Docker container. The key limitations identified are:
- Logging to syslog is a bit of a workaround – it would be better to have an option to log to stdout/stderr.
Let us know here if you have any questions or comments.
Find this useful?
We’re taking ideas for future how-to posts about the behind-the-scenes workings of email services.
If you’d like to make a request, we invite you to drop us a line here.
About atmail
atmail is an email solutions company with more than 20 years of global, white label, email expertise. You can trust us to deliver an email platform that is secure, stable and scalable. We power more than 170 million mailboxes worldwide and offer modern, white-labelled, cloud hosted email with your choice of US or (GDPR compliant) EU data centres. We also offer on-premises webmail and/or mail server options. Contact us anytime, here.