To learn Docker in details I decided to use it to run a local time server using
ntpd from the ntp.org project.
I have used an incremental approach where I started with an easy setup and then increased the challenges either to improve the time server or to better understand Docker.
So why ntpd and not <put your favourite time server>
ntpd for having configured it many times in the past 10 years. So I wanted to start with it first to quickly get time synchronisation working.
I have a Raspberry Pi (abbreviated RPi from now on) which serves as DHCP server and local forwarding and caching of DNS queries for my LAN. It had early support for Docker back in October when I started my experiment which added a bit of spice to it.
Getting Docker on a Raspberry Pi
There are many ways to get Docker running on your RPi. You could get the Hypriot OS Linux distribution which has everything setup nicely for running Docker containers. You can compile Docker on your platform of choice (which I had to do to squash a few early adopters’ bugs). You can install a tarball containing the binaries for your platform. But if running Raspbian Jessie – like I was – you can today just include Docker’s own repository and install a binary version using
apt-get. Make sure your Kernel is recent (Docker requires 3.10 at least, but if you have a properly updated Raspbian it should be running 4.4 at the time of writing).
You can follow Docker’s installation guide for Debian, but by default it will install you the x86_64 Docker repository. As hinted in the documentation, for other architecture you need to use the
[arch=...] clause. In addition, Docker provides a specific variant of the package for Raspbian. So for Raspbian Jessie, use the following entry for your docker.list file:
deb [arch=armhf] https://apt.dockerproject.org/repo raspbian-jessie main
Continue to follow the Docker guide, including how to set up non-root access to a specific user.
Creating a Docker image for ntpd
Create a specific folder somewhere on your Raspberry Pi storage (e.g.
mkdir -p ~/projects/docker/ntpd) and create a file
Dockerfile.armhf (I use the extension
.armhf so I can have distinct Dockerfiles for each platform I use) with the following content:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ntp \
&& apt-get clean -q \
&& rm -Rf /var/lib/apt/lists/*
Note: This file as well as newer version of it and instructions to build and run the container are available on my GitHub ntp container project. In the rest of this blog post, I’m only going to detailed how I approach running the container and solve problems.
The first line state that the base image for the container will be Ubuntu 16.04 (the specific variant for RPi architecture). The second until the fifth lines are commands we execute on top of the base image, basically it updates the packages list to install the latest version of ntpd with the smallest dependencies, and it removes any cached or temporary files. So we minimise the size of the image on disk. Finally the last line, is the command that will be executed by Docker when instructed to run the container. I have used the term
ENTRYPOINT because it allows me – while experimenting – to change the list of parameters I send to ntpd when I create the container and run it. This gives me flexibility with testing different parameters.
I picked up Ubuntu as the base image because it has sane default for the ntpd configuration file. It will use the NTP Pool project and the configuration is secured by default. Note that other base images could have also worked and have also sane default. I could have used Alpine Linux base image, it is really compact and lightweight, would have been perfect for a small platform like a Raspberry Pi, but it does not provide the
ntpd packages from the NTP project which I wanted to start with. It only supports OpenNTPD (which does not support leap seconds, so it was a no go for me) and Chrony (which could be a good alternative but as I mentioned before I wanted to first experiment with Docker not learn yet another NTP application).
Let’s build the container image (I named the image “article/armhf/ntpd” and tagged it with the current date, but just name it like you want):
$ docker build -f Dockerfile.armhf -t article/armhf/ntpd:20170106.1 .
Running the NTP container
We are now going to spawn an instance of the container image in foreground to see what is going on and to notice any error:
$ docker run --rm -it article/armhf/ntpd:20170106.1 -n
6 Jan 14:03:30 ntpd: ntpd firstname.lastname@example.org Wed Oct 5 12:38:30 UTC 2016 (1): Starting
6 Jan 14:03:30 ntpd: Command line: /usr/sbin/ntpd -n
6 Jan 14:03:30 ntpd: Cannot set RLIMIT_MEMLOCK: Operation not permitted
6 Jan 14:03:30 ntpd: proto: precision = 1.198 usec (-20)
6 Jan 14:03:30 ntpd: Listen and drop on 1 v4wildcard 0.0.0.0:123
6 Jan 14:03:30 ntpd: Listen normally on 2 lo 127.0.0.1:123
6 Jan 14:03:30 ntpd: Listen normally on 3 eth0 172.17.0.2:123
6 Jan 14:03:30 ntpd: Listen normally on 4 lo [::1]:123
6 Jan 14:03:30 ntpd: Listening on routing socket on fd #21 for interface updates
6 Jan 14:03:30 ntpd: start_kern_loop: ntp_loopfilter.c line 1126: ntp_adjtime: Operation not permitted
6 Jan 14:03:30 ntpd: set_freq: ntp_loopfilter.c line 1089: ntp_adjtime: Operation not permitted
6 Jan 14:03:31 ntpd: Soliciting pool server 220.127.116.11
6 Jan 14:03:32 ntpd: Soliciting pool server 18.104.22.168
6 Jan 14:03:32 ntpd: adj_systime: Operation not permitted
6 Jan 14:03:32 ntpd: Soliciting pool server 22.214.171.124
6 Jan 14:03:33 ntpd: Soliciting pool server 126.96.36.199
6 Jan 14:03:33 ntpd: Soliciting pool server 188.8.131.52
6 Jan 14:03:34 ntpd: Soliciting pool server 184.108.40.206
6 Jan 14:03:34 ntpd: Soliciting pool server 220.127.116.11
^C 6 Jan 14:03:40 ntpd: ntpd exiting on signal 2 (Interrupt)
We have a few errors (Operation not permitted) which I have highlighted above, one is about RLIMIT_MEMLOCK (this is about resetting the limit of the maximum locked-in-memory address space,
ntpd uses it to forbid its main process from swapping to limit jitter) and the other ones are about ntp_adjtime and adj_systime (both are used by
ntpd to interface with the Kernel and adjust the system time).
ntpd is running as root user, so it should have enough privilege for these operations. In addition, even though Docker supports running unprivileged containers (i.-e. the root user inside the container is mapped to a normal user on the host, this is based on user namespaces (see namespaces(7)), this is not the default Docker configuration, so my root user inside the container is the root user outside the container (and if Docker would be configured to use user namespace, they are not compiled in the Raspberry Pi foundation Kernel. So it is at the moment not possible to use that feature on a Raspberry Pi without some extra efforts, but I will details this in a future article).
In order to implement basic privilege limitations of container, Docker can use various security feature of the Linux Kernel to limit the container accessing certain sensible Kernel calls, the most notable ones are Linux Capabilities (since Docker 1.2), Linux SECCOMP filtering (since Docker 1.10, but better use Docker 1.12+ as pervious default SECCOMP profiles were in conflict with the Linux Capabilities management of Docker. In addition, the Raspbian Kernel (version 4.4 as of writing) has not the built-in support for SECCOMP filtering, so this functionality is not usable on Raspberry Pi, unless you compile your own Kernel) and Linux MAC (like SELinux or AppArmor, but none of them are available on Raspberry Pi without recompiling your own Kernel and installing the user space tools). So Docker on Raspberry Pi can only use Linux Capabilities as security feature.
By default Docker provides each container with a reasonable set of capabilities (see Docker documentation on capabilities). If you check both documentation (the Linux Capability manual and the Docker runtime privileges doc), you will find out that basically our container is missing the CAP_SYS_RESOURCE and CAP_SYS_TIME capabilities. Now there are 2 ways to add them, most online guide would tell you that when you run into “operations denied” errors, just add the
--privilege flag to the
docker run command line and it will be fixed, that’s the first way and it’s the wrong approach (sure it works, but it is like deactivating SELinux because you are not allowed to perform an operation). The other way is to add the missing capabilities to the container. This can be done by using the
--cap-add flag. That’s what I’m going to show now:
$ docker run --rm -it --cap-add SYS_RESOURCE --cap-add SYS_TIME article/armhf/ntpd:20170106.1 -n
7 Jan 11:19:24 ntpd: ntpd email@example.com Wed Oct 5 12:38:30 UTC 2016 (1): Starting
7 Jan 11:19:24 ntpd: Command line: /usr/sbin/ntpd -n
7 Jan 11:19:24 ntpd: proto: precision = 1.823 usec (-19)
7 Jan 11:19:24 ntpd: Listen and drop on 0 v6wildcard [::]:123
7 Jan 11:19:24 ntpd: Listen and drop on 1 v4wildcard 0.0.0.0:123
7 Jan 11:19:24 ntpd: Listen normally on 2 lo 127.0.0.1:123
7 Jan 11:19:24 ntpd: Listen normally on 3 eth0 172.17.0.2:123
7 Jan 11:19:24 ntpd: Listen normally on 4 lo [::1]:123
7 Jan 11:19:24 ntpd: Listening on routing socket on fd #21 for interface updates
7 Jan 11:19:25 ntpd: Soliciting pool server 18.104.22.168
7 Jan 11:19:26 ntpd: Soliciting pool server 22.214.171.124
7 Jan 11:19:26 ntpd: Soliciting pool server 126.96.36.199
7 Jan 11:19:27 ntpd: Soliciting pool server 188.8.131.52
7 Jan 11:19:27 ntpd: Soliciting pool server 184.108.40.206
7 Jan 11:19:27 ntpd: Listen normally on 7 eth0 [fe80::42:acff:fe11:2%6]:123
7 Jan 11:19:27 ntpd: new interface(s) found: waking up resolver
7 Jan 11:19:27 ntpd: Soliciting pool server 220.127.116.11
7 Jan 11:19:28 ntpd: Soliciting pool server 18.104.22.168
7 Jan 11:19:28 ntpd: Soliciting pool server 22.214.171.124
7 Jan 11:19:28 ntpd: Soliciting pool server 126.96.36.199
7 Jan 11:19:28 ntpd: Soliciting pool server 188.8.131.52
^C 7 Jan 11:22:40 ntpd: ntpd exiting on signal 2 (Interrupt)
To make sure this is working, first verify that you do not have any time synchronisation service running:
$ sudo systemctl stop systemd-timesyncd ntp.
Then change the system time by shifting it by 5 seconds:
$ sudo date -s "5 seconds".
Check that your system clock is now off by 5 seconds:
$ ntpdate -q time1.google.com
server 184.108.40.206, stratum 2, offset -5.002284, delay 0.14117
18 Jan 11:27:55 ntpdate: step time server 220.127.116.11 offset -5.002284 sec
Start the container in the background this time:
$ docker run --name ntpd --detach --restart always --cap-add SYS_RESOURCE --cap-add SYS_TIME article/armhf/ntpd:20170106.1 -g -n
Wait a few seconds and query again the network time using the above
ntpdate command. The offset should now be below 5 seconds and probably close to 0 second.
You have now a ntp service running inside a container and synchronising your system clock using Internet time servers from the NTP pool project. If you want to stop the experiment here and restore your system, you need to stop the container (
$ docker stop ntpd) and block it from restarting at next boot (
$ docker update --restart=no ntpd) and perhaps reboot so that you reactivate the default time synchronisation service.
But if you want to keep experimenting or let the container do its job of time synchronisation, you should make sure to deactivate any other time synchronisation mechanisms to avoid conflicts if you want to keep your NTP container running:
$ sudo timedatectl set-ntp false
$ sudo systemctl disable ntp chronyd
$ sudo systemctl mask systemd-timesyncd
$ sudo systemctl stop systemd-timesyncd ntp chronyd
Foreword about Time and NTP on a Raspberry Pi
The Raspberry Pi (at the time of writing this applies to all models) has no real time clock (RTC) module on its board. A RTC is a small oscillator (e.g. quartz, like in your electronic wristwatch) plus some electronic to keep track of time and a battery (or equivalent). Those RTCs help a system keep track of time when there are off and in the early phases of boot. On a standard desktop or laptop computer the motherboard has an RTC. Many oscillators are not particularly accurate (low quality) with non-stable frequencies which can depend on external factors such as room temperature. It is possible to add a RTC module to the Raspberry Pi (I will have a detailed article on that soon), but without RTC you need a network connection in order for the RPi to know the current time.
On Linux, the kernel manage 2 clocks, the hardware clock (which is based on the RTC) and the system clock (which is the clock used by the system to query/set the time, this clock is ticking using a clocksource such as a CPU/SoC timer, Kernel jiffies, etc.). On boot, the current time is read from the hardware clock and is used to initialise the system clock. The system clock is then driven by the ticks from the selected clocksource and the time read at boot from the hardware clock. Usually, on shutdown, many Linux distribution are configured to store the system clock in the hardware clock.
The Raspberry Pi has maybe no hardware clock but it has a clock source (current clocksource on Raspberry Pi 2, other models may differ):
[ 0.000000] arm_arch_timer: Architected cp15 timer(s) running at 19.20MHz (phys).
[ 0.000000] clocksource: arch_sys_counter: mask: 0xffffffffffffff max_cycles: 0x46d987e47, max_idle_ns: 440795202767 ns
[ 0.000010] sched_clock: 56 bits at 19MHz, resolution 52ns, wraps every 4398046511078ns
[ 0.000032] Switching to timer-based delay loop, resolution 52ns
So if the time is set on boot, the OS can keep track of the time even if disconnected and as long as it is up and running. Systemd 213 introduced a new service systemd-timesyncd which is a SNTP client implementation, so it is able to query a network time server and set the OS system time based on the response. This service has an extra feature for systems without RTCs, it saves the system time on disk on shutdown. So when your Raspberry Pi reboot, it can use the stored time to initialise its system time while waiting for more accurate time once the network is ready. Sure during the early boot process the system time might be off by a couple of seconds but it is better than nothing.
As for NTP, it is adjusting the system time based on responses from network time servers or when offline based on the clock drift NTP has been calculating for the current clock source. This means that if you run NTP, it is good to let it run at least 24hours so it can accurately measure the clock source drift and then it can compensate it during network disconnection periods. In addition, NTP will regularly sync back the system time to the hardware time to correct the RTC clock. In up coming articles, we will see how we can add a RTC to our Raspberry Pi and how to overcome the challenges of allowing RTC access to NTP inside the container and increasing the clock accuracy. In addition, we will see how we can become an NTP network time server for the local LAN.
What did we learn about Docker
First, we practiced the basics of building a container (the Dockerfile syntax and
docker build ... command), running a container in foreground or background mode (
docker run ...) and controlling the running container (
docker stop ... and
docker update ...). I did not yet elaborate much on the capabilities of these commands offer but it is my intention that we will discover them further as we progress we the experiment.
Second, we learned about some of Docker security measures (like Linux capabilities) and limitations of the current Raspberry Pi platform (like no SECCOMP filtering or AppArmor or user namespace), and we also learned how to extend a container permission by adding new capabilities.
Next to learn will be how to provide access to specific devices (such as an RTC), how to do simple monitoring (checking the container is running, its resource usage and logs), how to increase its security (dropping unnecessary capabilities, using the other security measures). With this quest we will learn a lot on the Raspberry Pi as well, we will add an RTC module, we will compile our own Kernels in order to add new security functions and improve the OS jitter, etc.