OK… I managed to figure out one way of doing this. I’m not 100% happy with it, but it does work and may be appealing to others that are trying a simple single-container fat (includes postgres, redis, etc.) Discourse image deployment to Kubernetes.
My Approach
After examining the bootstrap process it became clear to me that unfortunately it mixes in two different kinds of operations—ones that only affect the underlying container, and others that poke out into the surrounding environment, mainly through the /shared volume mount where the postgres data files live. Rather than trying to tease these steps apart, it seems more sane to just run the bootstrapping steps in the environment where the container is actually going to be deployed.
Unfortunately, launcher bootstrap wants to create a container and use Docker. So running launcher inside another container (for example, in a container running on our cloud) means either tangling with a Docker-in-Docker setup (doable, but not considered best practices) or exposing the underlying Docker daemon. I’m not even sure that that second approach would work, since I think that it would interpret a volume mount against the node’s local filesystem, whereas in our scenario we want to volume mount /shared to a persistent Kubernetes volume. Maybe the Docker-in-Docker route would work, but then you’d also have a weird triple volume mount from inside the nested container into the outer container and from there to the persistent Kubernetes volume. That sounds… unwise.
However, essentially launcher bootstrap creates one large .yml file by processing the templates value in the app.yml and then passes that to the Discourse base image when finishes the bootstrap process. So if we can extract the configuration file we can generate the configuration on any machine and then we only need to figure out how to pass it to a container we start in the cloud.
So as an overview, here are the steps we are going to follow:
- Generate the bootstrap configuration using a modified
launcher - Pass that to a modified Discourse base image that will perform the bootstrapping (using
pups) and then start Discourse
Generating the Bootstrapping Configuration
Here’s the required change to launcher to support a dump command that writes the merged configuration to STDOUT:
run_dump() {
set_template_info
echo "$input"
}
(Note that this command is available in our fork of discourse_docker.)
So the first step is to use the new launcher dump command added above to create our bootstrap configuration:
# Substitute whatever your container configuration is called for app
./launcher dump app > bootstrap.yml
Initial Container Creation
Next we need a container that knows to run pups to bootstrap the container before booting via /sbin/boot. I used the following Dockerfile to make a tiny change to the base discourse image:
FROM discourse/base:2.0.20191219-2109
COPY scripts/bootstrap.sh /
CMD bash bootstrap.sh
Where scripts/bootstrap.sh contains:
cd /pups/ && /pups/bin/pups --stdin < /bootstrap/bootstrap.yml && /sbin/boot
I published this as geoffreychallen:discourse_base:2.0.20191219-2109. (Note that you could probably also accomplish the same thing by modifying the boot command of the base Discourse docker image, but I was having a hard time getting that to work with the shell redirection required to get pups to read the configuration file.)
Kubernetes Configuration
Now we need our Kubernetes configuration. Mine looks like this:
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: kotlin-forum-pvc
namespace: ikp
spec:
storageClassName: rook-ceph-block
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 64Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kotlin-forum-deployment
namespace: ikp
spec:
replicas: 0
selector:
matchLabels:
app: kotlin-forum
template:
metadata:
labels:
app: kotlin-forum
spec:
volumes:
- name: kotlin-forum
persistentVolumeClaim:
claimName: kotlin-forum-pvc
- name: bootstrap
configMap:
name: kotlin-forum-bootstrap
containers:
- name: kotlin-forum
image: geoffreychallen/discourse_base:2.0.20191219-2109
imagePullPolicy: Always
volumeMounts:
- name: kotlin-forum
mountPath: /shared/
- name: bootstrap
mountPath: /bootstrap/
ports:
- containerPort: 80
env:
- name: TZ
value: "America/Chicago"
- name: LANG
value: en_US.UTF-8
- name: RAILS_ENV
value: production
- name: UNICORN_WORKERS
value: "3"
- name: UNICORN_SIDEKIQS
value: "1"
- name: RUBY_GLOBAL_METHOD_CACHE_SIZE
value: "131072"
- name: RUBY_GC_HEAP_GROWTH_MAX_SLOTS
value: "40000"
- name: RUBY_GC_HEAP_INIT_SLOTS
value: "400000"
- name: RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
value: "1.5"
- name: DISCOURSE_DB_SOCKET
value: /var/run/postgresql
- name: DISCOURSE_DEFAULT_LOCALE
value: en
- name: DISCOURSE_HOSTNAME
value: kotlin-forum.cs.illinois.edu
- name: DISCOURSE_DEVELOPER_EMAILS
value: challen@illinois.edu
- name: DISCOURSE_SMTP_ADDRESS
value: outbound-relays.techservices.illinois.edu
- name: DISCOURSE_SMTP_PORT
value: "25"
---
apiVersion: v1
kind: Service
metadata:
name: kotlin-forum
namespace: ikp
spec:
type: NodePort
ports:
- name: http
port: 80
targetPort: 80
selector:
app: kotlin-forum
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
namespace: ikp
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
name: kotlin-forum-ingress
spec:
rules:
- host: kotlin-forum.cs.illinois.edu
http:
paths:
- backend:
serviceName: kotlin-forum
servicePort: 80
Yours will look different. Note that I’m terminating HTTPS upstream, hence the modifications to the Ingress configuration. I also like to put everything in one file, delete pieces that don’t work as I iterate, and then let Kubernetes skip duplicates on the next kubectl create -f. Also note that I set replicas: 0 so that the deployment doesn’t start as soon as its configured. That’s because we have one bit of additional configuration to finish.
I copied the list of environment variables from what I saw being passed to the container by launcher start. I don’t know if all of these are necessary and others may be missing depending on your configuration. YMMV.
Note that we have two volume maps pointing into the container: the first is for postgres, configured as a persistent volume that will survive pod restarts. The second is a configuration mapping created like this:
kubectl create configmap kotlin-forum-bootstrap --from-file=bootstrap.yml=<path/to/bootstrap.yml>
Where kotlin-forum-bootstrap needs to match your Kubernetes configuration and path/to/bootstrap.yml is the path to the bootstrap.yml file we created using launcher dump above.
Once your configmap is in place, you should be able to scale your deployment to one replica and see Discourse booting and running the same bootstrap process that launcher bootstrap would have performed. That takes a few minutes. When that is done, your Discourse installation will boot.
Others Bits of Configuration
A few other notes that I ran on the way to getting this (at least for now) fully configured:
- Any upstream proxies must forward
X-Forwardedheaders, including bothX-Fowarded-For,X-Forwarded-Proto,X-Forwarded-Port. Not doing so will result in strange authentication errors when trying to use Google login and probably other login providers. - Your
nginxingress controller must be configured to pass headers by settinguse-forwarded-headersin the global config map. This took me a while to get right, since at least several times I edited the wrong configuration map, and then expected my ingress containers to restart when the configuration map changed. (They didn’t.)
Updating
To update the deployed installation, you regenerate the new bootstrap.yml file, update the config map, and then restart the container (easiest by scaling to 0 and then back to 1 replica).
This does incur a bit of downtime since the bootstrapping happens before the container is built. But this seems inevitable to me in cases where you need to update the configuration and/or change the base image. launcher rebuild is documented as stop; bootstrap; start, meaning that the bootstrap process will still cause downtime even if performed using the launcher script.
Comments
This fat container Discourse deployment pattern would be much easier to support if the launcher script would more cleanly separate (a) bootstrap steps that could be performed offline and only affect the files in the container and (b) bootstrap steps that modify or need access to the database or other state outside the container. The approach described above is a bit frustrating because you do see all kinds of JS uglification, asset minification, and other things that could be done with the previous deployment running… but they are just too mixed in with other things (like database migrations) that can’t be done without access to the database. I briefly thought about creating a container that would only perform the steps in templates/postgres.yml, but then noticed that database migrations were being done by the web template, and thought about plugins, and then just gave up
.
With better separation redeployment for fat containers could work something like this:
- Build new fat container offline performing all steps internal to the container
- Publish that container
- When ready to upgrade, stop the previous container, start the new fat container, and let it finish any steps that need database access. Based on my experimentation those seem faster than some of the other bootstrap steps.
That would result in a bit less downtime. It’s probably not worth the effort for that reason alone, but I can imagine that this might also simplify more complex deployment scenarios involving shared databases or whatever.