wezm.net/v1/content/technical/2019/02/alpine-linux-docker-infrastructure.md

433 lines
18 KiB
Markdown

For more than a decade I have run one or more servers to host a number of
personal websites and web applications. Recently I decided it was time to
rebuild the servers to address some issues and make improvements. The last time
I did this was in 2016 when I switched the servers from [Ubuntu] to [FreeBSD].
The outgoing servers were managed with [Ansible]. After being a Docker skeptic
for a long time I have finally come around to it recently and decided to
rebuild on [Docker]. This post aims to describe some of the choices made, and
why I made them.
_Before we start I'd like to take a moment to acknowledge this infrastructure
is built to my values in a way that works for me. You might make different
choices and that's ok. I hope you find this post interesting but not
prescriptive._
Before the rebuild this is what my infrastructure looked like:
- FreeBSD 11 server in [DigitalOcean][DigitalOcean] (New York) hosting:
- [PostgreSQL] 9
- [nginx]
- [Varnish]
- 2 [Rails] apps
- Static sites
- Debian 9 server in DigitalOcean (New York) hosting:
- Wizards [Mattermost] instance
- FreeBSD 12 server in [Vultr][Vultr] (Sydney) hosting:
- [rust.melbourne] Mattermost instance
- PostgreSQL 11
You'll note 3 servers, across 2 countries, and 2 hosting providers. Also the
Rust Melbourne server was not managed by Ansible like the other two were.
I had a number of goals in mind with the rebuild:
- Move everything to Australia (where I live)
- Consolidate onto one server
- https enable all websites
I set up my original infrastructure in the US because it was cheaper at the
time and most traffic to the websites I host comes from the US. The Wizards
Mattermost instance was added later. It's for a group of friends that are all
in Australia. Being in the US made it quite slow at times, especially when
sharing and viewing images.
Another drawback to administering servers in the US from AU was that it makes
the Ansible cycle time of "make a change, run it, fix it, repeat", excruciatingly
slow. It had been on my to do list for a long time to move Wizards to Australia
but I kept putting it off because I didn't want to deal with Ansible.
While having a single server that does everything wouldn't be the
recommended architecture for business systems, for personal hosting where the
small chance of downtime isn't going to result in loss of income the simplicity
won out, at least for now.
This is what I ended up building. Each box is a Docker container running on the
host machine:
![Graph of services](/images/2019/services.svg)
- `pkb` is <https://linkedlist.org>
- `binary_trance` is <https://binarytrance.com>
- `wizards` and `rust_melbourne` are [Mattermost] instances
- The rest are software of the same name
I haven't always been in favour of Docker but I think enough time has passed to
show that it's probably here to stay. There are some really nice benefits to
Docker managed services too. Such as, building locally and then shipping the image
to production, and isolation from the host system (in the sense you can just
nuke the container and rebuild it if needed).
## Picking a Host OS
Moving to Docker unfortunately ruled out FreeBSD as the host system. There is a
[very old Docker port for FreeBSD][docker-freebsd] but my previous attempts at using it showed
that it was not in a good enough state to use for hosting. That meant I
needed to find a suitable Linux distro to act as the Docker host.
Coming from FreeBSD I'm a fan of the stable base + up-to-date packages model.
For me this ruled out Debian (stable) based systems, which I find often
have out-of-date or missing packages -- especially in the latter stages of
the release cycle. I did some research to see if there were any distros that
used a BSD style model. Most I found were either abandoned or one person
operations.
I then recalled that as part of his [Sourcehut] work, [Drew DeVault was
migrating][sr.ht-announce] things to [Alpine Linux]. I had played with Alpine
in the past (before it became famous in the Docker world), and I consider Drew's
use some evidence in its favour.
Alpine describes itself as follows:
> Alpine Linux is an independent, non-commercial, general purpose Linux
> distribution designed for power users who appreciate security, simplicity and
> resource efficiency.
Now that's a value statement I can get behind! Other things I like about Alpine
Linux:
- It's small, only including the bare essentials:
- It avoids bloat by using [musl-libc] (which is MIT licensed) and
[busybox userland][busybox].
- It has a 37Mb installation ISO intended for virtualised server
installations.
- It was likely to be (and ended up being) the base of my Docker images.
- It enables a number of security features by default.
- Releases are made every ~6 months and are supported for 2 years.
Each release also has binary packages available in a stable channel that
receives bug fixes and security updates for the lifetime of the release as well
as a rolling edge channel that's always up-to-date.
Note that Alpine Linux doesn't use [systemd], it uses [OpenRC]. This didn't
factor into my decision at all. `systemd` has worked well for me on my Arch
Linux systems. It may not be perfect but it does do a lot of things well. Benno
Rice did a great talk at linux.conf.au 2019, titled, [The Tragedy of
systemd][systemd-tragedy], that makes for interesting viewing on this topic.
## Building Images
So with the host OS selected I set about building Docker images for each of the
services I needed to run. There are a lot of pre-built Docker images for
software like nginx, and PostgreSQL available on [Docker Hub]. Often they also
have an `alpine` variant that builds the image from an Alpine base image. I
decided early on that these weren't really for me:
- A lot of them build the package from source instead of just installing the
Alpine package.
- The Docker build was more complicated than I needed as it was trying to be
a generic image that anyone could pull and use.
- I wasn't a huge fan of pulling random Docker images from the Internet, even
if they were official images.
~~In the end I only need to trust one image from [Docker Hub]: The 5Mb [Alpine
image][alpine-docker-image]. All of my images are built on top of this one
image.~~
**Update 2 Mar 2019:** I am no longer depending on any Docker Hub images. After
the [Alpine Linux 3.9.1 release][alpine-3.9.1] I noticed the official Docker
images had not been updated so I built my own. Turns out it's quite simple.
Download the miniroot tarball from the Alpine website and then add it to a
Docker image:
```language-docker
FROM scratch
ENV ALPINE_ARCH x86_64
ENV ALPINE_VERSION 3.9.1
ADD alpine-minirootfs-${ALPINE_VERSION}-${ALPINE_ARCH}.tar.gz /
CMD ["/bin/sh"]
```
An aspect of Docker that I don't really like is that inside the container you
are root by default. When building my images I made a point of making the
entrypoint processes run as a non-privileged user or configure the service drop
down to a regular user after starting.
Most services were fairly easy to Dockerise. For example here is my nginx
`Dockerfile`:
```language-docker
FROM alpine:3.9
RUN apk update && apk add --no-cache nginx
COPY nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p /usr/share/www/ /run/nginx/ && \
rm /etc/nginx/conf.d/default.conf
EXPOSE 80
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/sbin/nginx", "-g", "daemon off;"]
```
I did not strive to make the images especially generic. They just need to work
for me. However I did make a point not to bake any credentials into the images
and instead used environment variables for things like that.
## Let's Encrypt
I've been avoiding [Let's Encrypt] up until now. Partly because the short expiry of the
certificates seems easy to mishandle. Partly because of [certbot], the recommended
client. By default `certbot` is interactive, prompting for answers when you run
it the first time, it wants to be installed alongside the webserver so it can
manipulate the configuration, it's over 30,000 lines of Python (excluding
tests, and dependencies), the documentation suggests running magical
`certbot-auto` scripts to install it... Too big and too magical for my liking.
Despite my reservations I wanted to enable https on all my sites and I wanted
to avoid paying for certificates. This meant I had to make Let's Encrypt work
for me. I did some research and finally settled on [acme.sh]. It's
written in POSIX shell and uses `curl` and `openssl` to do its bidding.
To avoid the need for `acme.sh` to manipulate the webserver config I opted to
use the DNS validation method (`certbot` can do this too). This requires a DNS
provider that has an API so the client can dynamically manipulate the records.
I looked through the large list of supported providers and settled on [LuaDNS].
**Update 11 May 2020:** I contributed support for LuaDNS to [lego] and replaced
`acme.sh` with it. The motivation was that I never got auto-renewal working
with `acme.sh`. Turns out it was nothing to do with `acme.sh` but the exercise
in moving to `lego` made me discover and fix the problem.
LuaDNS has a nice git based workflow where you define the DNS zones with small
Lua scripts and the records are published when you push to the repo. They also
have the requisite API for `acme.sh`. You can see my DNS repo at:
<https://github.com/wezm/dns>
Getting the [acme.sh] + [hitch] combo to play nice proved to be bit of a
challenge. `acme.sh` needs to periodically renew certificates from Let's
Encrypt, these then need to be formatted for `hitch` and `hitch` told about
them. In the end I built the `hitch` image off my `acme.sh` image. This goes
against the Docker ethos of one service per container but `acme.sh` doesn't run
a daemon, it's periodically invoked by cron so this seemed reasonable.
Docker and cron is also a challenge. I ended up solving that with a
simple solution: use the host cron to `docker exec` `acme.sh` in the `hitch`
container. Perhaps not "pure" Docker but a lot simpler than some of the options
I saw.
## Hosting
I've been a happy [DigitalOcean] customer for 5 years but they don't have a
data centre in Australia. [Vultr], which have a similar offering -- low cost,
high performance servers and a well-designed admin interface -- do have a
Sydney data centre. Other obvious options include AWS and GCP. I wanted to
avoid these where possible as their server offerings are more expensive, and
their platforms have a tendency to lock you in with platform specific features.
Also in the case of Google, they are a massive [surveillance capitalist] that I
don't trust at all. So Vultr were my host of choice for the new server.
Having said that, the thing with building your own images is that you need to
make them available to the Docker host somehow. For this I used an [Amazon
Elastic Container Registry][ECR]. It's much cheaper than Docker Hub for private
images and is just a standard container registry so I'm not locked in.
## Orchestration
Once all the services were Dockerised, there needed to be a way to run the
containers, and make them aware of each other. A popular option for this is
[Kubernetes] and for a larger, multi-server deployment it might be the right
choice. For my single server operation I opted for [Docker Compose], which is,
"a tool for defining and running multi-container Docker applications". With
Compose you specify all the services in a YAML file and it takes care of
running them all together.
My Docker Compose file looks like this:
```language-yaml
version: '3'
services:
hitch:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/hitch
command: ["--config", "/etc/hitch/hitch.conf", "-b", "[varnish]:6086"]
volumes:
- ./hitch/hitch.conf:/etc/hitch/hitch.conf:ro
- ./private/hitch/dhparams.pem:/etc/hitch/dhparams.pem:ro
- certs:/etc/hitch/cert.d:rw
- acme:/etc/acme.sh:rw
ports:
- "443:443"
env_file:
- private/hitch/development.env
depends_on:
- varnish
restart: unless-stopped
varnish:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/varnish
command: ["-F", "-a", ":80", "-a", ":6086,PROXY", "-p", "feature=+http2", "-f", "/etc/varnish/default.vcl", "-s", "malloc,256M"]
volumes:
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
ports:
- "80:80"
depends_on:
- nginx
- pkb
- binary_trance
- wizards
- rust_melbourne
restart: unless-stopped
nginx:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/nginx
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./volumes/www:/usr/share/www:ro
restart: unless-stopped
pkb:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/pkb
volumes:
- pages:/home/pkb/pages:ro
env_file:
- private/pkb/development.env
depends_on:
- syncthing
restart: unless-stopped
binary_trance:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/binary_trance
env_file:
- private/binary_trance/development.env
depends_on:
- db
restart: unless-stopped
wizards:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/mattermost
volumes:
- ./private/wizards/config:/mattermost/config:rw
- ./volumes/wizards/data:/mattermost/data:rw
- ./volumes/wizards/logs:/mattermost/logs:rw
- ./volumes/wizards/plugins:/mattermost/plugins:rw
- ./volumes/wizards/client-plugins:/mattermost/client/plugins:rw
- /etc/localtime:/etc/localtime:ro
depends_on:
- db
restart: unless-stopped
rust_melbourne:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/mattermost
volumes:
- ./private/rust_melbourne/config:/mattermost/config:rw
- ./volumes/rust_melbourne/data:/mattermost/data:rw
- ./volumes/rust_melbourne/logs:/mattermost/logs:rw
- ./volumes/rust_melbourne/plugins:/mattermost/plugins:rw
- ./volumes/rust_melbourne/client-plugins:/mattermost/client/plugins:rw
- /etc/localtime:/etc/localtime:ro
depends_on:
- db
restart: unless-stopped
db:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/postgresql
volumes:
- postgresql:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
env_file:
- private/postgresql/development.env
restart: unless-stopped
syncthing:
image: 791569612186.dkr.ecr.ap-southeast-2.amazonaws.com/syncthing
volumes:
- syncthing:/var/lib/syncthing:rw
- pages:/var/lib/syncthing/Sync:rw
ports:
- "127.0.0.1:8384:8384"
- "22000:22000"
- "21027:21027/udp"
restart: unless-stopped
volumes:
postgresql:
certs:
acme:
pages:
syncthing:
```
Bringing all the services up is one command:
docker-compose -f docker-compose.yml -f production.yml up -d
The best bit is I can develop and test it all in isolation locally. Then when
it's working, push to ECR and then run `docker-compose` on the server to bring
in the changes. This is a huge improvement over my previous Ansible workflow
and should make adding or removing new services in the future fairly painless.
## Closing Thoughts
The new server has been running issue free so far. All sites are now
redirecting to their https variants with `Strict-Transport-Security` headers
set and get an A grade on the [SSL Labs test]. The Wizards Mattermost is _much_
faster now that it's in Australia too.
There is one drawback to this move though: my sites are now slower for a lot of
visitors. https adds some initial negotiation overhead and if you're reading
this from outside Australia there's probably a bunch more latency than
before.
I did some testing with [WebPageTest] to get a feel for the impact of this.
My sites are already quite compact. Firefox tells me this page and all
resources is 171KB / 54KB transferred. So there's not a lot of slimming
to be done there. One thing I did notice was the TLS negotiation was happening
for each of the parallel connections the browser opened to load the site.
Some research suggested HTTP/2 might help as it multiplexes requests on a
single connection and only performs the TLS negotiation once. So I decided to
live on the edge a little and enable [Varnish's experimental HTTP/2
support][varnish-http2]. Retrieving the site over HTTP/2 did in fact reduce the
TLS negotiations to one.
Thanks for reading, I hope the bits didn't take too long to get from Australia
to wherever you are. Happy computing!
<div class="seperator"><hr class="left"><hr class="right"></div>
Previous Post: [My Rust Powered linux.conf.au e-Paper Badge](/technical/2019/01/linux-conf-au-rust-epaper-badge/)
Next Post: [A Coding Retreat and Getting Embedded Rust Running on a SensorTag](/technical/2019/03/sensortag-embedded-rust-coding-retreat/)
[acme.sh]: https://github.com/Neilpang/acme.sh
[Alpine Linux]: https://alpinelinux.org/
[alpine-3.9.1]: https://alpinelinux.org/posts/Alpine-3.9.1-released.html
[alpine-docker-image]: https://hub.docker.com/_/alpine
[Ansible]: https://www.ansible.com/
[busybox]: https://www.busybox.net/
[certbot]: https://certbot.eff.org/
[DigitalOcean]: https://m.do.co/c/0eb3d3d839ea
[Docker Compose]: https://docs.docker.com/compose/overview/
[Docker Hub]: https://hub.docker.com/
[docker-freebsd]: https://www.freshports.org/sysutils/docker-freebsd/
[Docker]: https://www.docker.com/
[ECR]: https://aws.amazon.com/ecr/
[FreeBSD]: https://www.freebsd.org/
[hitch]: https://hitch-tls.org/
[Kubernetes]: https://kubernetes.io/
[Let's Encrypt]: https://letsencrypt.org/
[LuaDNS]: https://luadns.com/
[Mattermost]: https://mattermost.com/
[musl-libc]: http://www.musl-libc.org/
[nginx]: http://nginx.org/
[OpenRC]: https://wiki.gentoo.org/wiki/Project:OpenRC
[PostgreSQL]: https://www.postgresql.org/
[Rails]: https://rubyonrails.org/
[rust.melbourne]: https://rust.melbourne/
[Sourcehut]: https://sourcehut.org/
[sr.ht-announce]: https://lists.sr.ht/~sircmpwn/sr.ht-announce/%3C20190117003837.GA6037%40homura.localdomain%3E
[SSL Labs test]: https://www.ssllabs.com/ssltest/index.html
[surveillance capitalist]: https://en.wikipedia.org/wiki/Surveillance_capitalism
[systemd-tragedy]: https://youtu.be/o_AIw9bGogo
[systemd]: https://freedesktop.org/wiki/Software/systemd/
[Ubuntu]: https://www.ubuntu.com/
[varnish-http2]: https://info.varnish-software.com/blog/varnish-cache-5-http2-support
[Varnish]: https://varnish-cache.org/
[Vultr]: https://www.vultr.com/?ref=7903263
[WebPageTest]: https://www.webpagetest.org/
[lego]: https://github.com/go-acme/lego