Migrating From Dokku to Kamal: Scheduling Cron Jobs

This is the third post of the series "Migrating From Dokku to Kamal" and today I am gonna show you how I've set cron with Kamal, click here to read the second post of the series in case you've missed it.

I needed a way run periodic tasks on Kamal to replace Dokku's scheduled cron tasks.

Cron seemed the way to go according to this pull request on the Kamal repo, but making it work turned out to be not so simple as that.

The idea is to have a new container on the worker server responsible for scheduling jobs defined on a crontab file.

Defining cron jobs

First thing is to create this crontab file defining the tasks to be scheduled by cron, I've put this file on config/app.crontab:

59 20 * * 1,2,3,4,5 /rails/bin/cron-executor.sh make whatever

Note that I am passing the command I want to run (make whatever in this case) to a bin/cron-executor.sh:

#!/bin/bash -e

PATH=$PATH:/usr/local/bin

cd /rails || exit

echo "CRON: ${@}" >/proc/1/fd/1 2>/proc/1/fd/2

exec "${@}" >/proc/1/fd/1 2>/proc/1/fd/2

this shell script is responsible for doing a few things before running the desired command:

  • adds /usr/local/bin to $PATH, so ruby and bundle can be loaded
  • enters the directory of the application (/rails)
  • prints the command that is about to be executed
  • executes the command

In case you are wondering, >/proc/1/fd/1 2>/proc/1/fd/2 is required to redirect the output of the command to the container's stdout/stderr. If you don't add this, you won't be able to see the logs of the cron jobs when running docker logs for example.

Setting up the Dockerfile

The next step is to make sure the application's Dockerfile installs cron and applies the jobs from the crontab file we just defined:

ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim-bullseye as base

...

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install -y cron \
    # Remove package lists for smaller image sizes
    && rm -rf /var/lib/apt/lists/* \
    && which cron \
    && rm -rf /etc/cron.*/*

COPY config/app.crontab /etc/cron.d/cronfile
RUN chmod 0644 /etc/cron.d/cronfile
RUN crontab /etc/cron.d/cronfile

USER root

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000
CMD ["./bin/rails", "server"]
The default Dockerfile for Rails 7.1 sets up a non-root user for security reasons, but I couldn't get cron working with a non-root user, that's why I ended up using USER root. Please let me know in case you managed to get that working.

Passing environment variables to cron

Cron reads environment variables from /etc/environment, so we need to set them there by adding one line to bin/docker-entrypoint:

#!/bin/bash -e

# pass env vars to cron
env >> /etc/environment

# If running the rails server then create or migrate existing database
if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

Defining a new server

The last step is to define a new server on Kamal's deploy.yml with the same ip as the worker:

servers:
  web:
    hosts:
      - <%= ENV['KAMAL_WEB_IP'] %>
    labels:
      traefik.http.routers.domain.rule: Host(`*.domain.com`)
      traefik.http.routers.domain.entrypoints: websecure
      traefik.http.routers.domain.tls.certresolver: letsencrypt

  worker:
    hosts:
      - <%= ENV['KAMAL_WORKER_IP'] %>
    cmd: bin/run-worker.sh

  cron:
    hosts:
      - <%= ENV['KAMAL_WORKER_IP'] %>
    cmd: bash -c "cron -f -L 2"

Note that bash -c "cron -f -L 2" is set as the command to run on the container, it'll make cron to run on the foreground and set the logging verbosity.

That's all folks, I hope you have enjoyed the series.

Ps: I'd like to thank you Jason for writing this post on running cron on Docker, it was really useful to me!

Written on January 23, 2024

Share: