DEV Community

John Napiorkowski
John Napiorkowski

Posted on

Modern Perl Catalyst: Docker Setup

Overview

This is the first of a blog series where I review a basic but modern Perl Catalyst application. In this series I will go over some new things I've been playing with, some of them seem to be good ideas that are standing the test of time as I use them on the job and at play, and some of them are neat ideas that might have rough edges. Also some of them might be wacky things I'll regret in a few years :). But people seem to wonder if anyone is doing anything new and different with Perl Catalyst so here's something in response to that.

The application will start off as a pretty old school server side everything application, like we made in the early 'Aughts. Not that Catalyst can't do the new fancy stuff, but I think as a server side platform it's useful for learning purposes to focus on the server side.

In this first part I will be looking just at my basic development setup using docker and some existing images to make a fully containerized application stack. This setup will include a container for the application, one for a Postgresql database and also a container for an application to manage email testing. I include that because I rarely have applications that don't send at least some emails and I find having a neat way to capture SMTP locally is worth a few lines in the docker-compose file. Also I include it as a way to get you thinking about building up applications with interconnected containers rather than trying to get everything setup on on big server.

The application is on Github and you can download it here. Don't like what you see? I'm wrong? Send me a PR and I will mention in in a blog followup.

Docker

This is not a low level docker tutorial. I will assume you are vaguely familiar with it, or you've at least cargo culted bits of docker-compose yaml and so forth. I will assume also you have a local development machine with docker installed and that you know how to git clone the demo repository at https://github.com/jjn1056/ContactsDemo.

Docker if used carefully can be a neat way to build nicely decoupled application stacks. When misused it can resemble a game of Jenga. Let's take a look at my docker-compose.yml file in detail. We will take it by logically grouped lines:

version: "3.8"
name: contacts_demo
Enter fullscreen mode Exit fullscreen mode

This just declares the top level project name and the version of docker compose we are using.

services:
Enter fullscreen mode Exit fullscreen mode

Most of your file is going to reside under this key. This groups each of the containers that make up your application. Docker calls these 'services'. Lets look at the first service

  app_contacts:  # Add Catalyst web service
    container_name: app_contacts
    networks:
      - contacts_network
    build: 
       context: .
       dockerfile: docker/Dockerfile
Enter fullscreen mode Exit fullscreen mode

This starts the definition of the service that runs the Catalyst web application. The most import bit here is at the end where I specify that I'm using a Dockerfile under the 'docker' directory to define this service. We'll look at that file later (or you can peek via Github if you are impatient). Basically those are the instructions that actually build the application, remember docker-compose.yml is all about chaining together a bunch of docker containers and getting them all speaking to each other.

    environment:
      - CATALYST_DEBUG=1
      - DBIC_TRACE=1
      - SESSION_STORAGE_SECRET=${SESSION_STORAGE_SECRET}
      - CSRF_SECRET=${CSRF_SECRET}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_USER=contact_dbuser
      - POSTGRES_DB=contacts
      - DB_HOST=db_contacts
      - DB_PORT=5432
      - SMTP_HOST=maildev
      - SMTP_PORT=1025
Enter fullscreen mode Exit fullscreen mode

These are the environment variables that get passed down to the application. In Dockerworld you are highly encouraged to use environment variables like this over big configuration files. It's a way to promote decoupling. You'll see later how I pull these into the Perl Catalyst application rather than use one of the old but popular configuration file plugins.

You might notice that some of the environmental variables have funky values that look more like template placeholders. For example "SESSION_STORAGE_SECRET=${SESSION_STORAGE_SECRET}". That's because there's a .env file that contains those (you can see it in the root of the GitHub repository page. As a good practice I try to isolate anything that needs to be secret right off the top. So even though this is a development setup and would need work to turn it into a something suitable for production let's try to start off right not doing the wrong thing by hardcoding all our secrets into various files. At least now there's just one file to secure. And later on if you move to something really secure like Hashicorp's Vault product, or even something open source like git secret you won't have to hunt all over the place for the secrets to keep. Lets now look at the rest of the Catalyst application setup:

    volumes:
      - ".:/app"
    depends_on:
      db_contacts:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

So the depends_on key just says this application needs the database service to be in a healthy state in order to properly startup. We'll look at that service in a bit. Lastly we are defining a volume. Now you know (or should) that the point of containers is that they are suppose to be cheap to make and easy to kill. That promotes ease of setup and deployment. So no more of the old days when someone would log into a machine and spend two days running setup scripts and then the box sits on a shelf for 8 years. You need to be able to blow away and rebuild containers at will. But you do need a bit of persistent data, and the way we handle that in docker is to use volumes. Volumes don't get blown away when you kill your containers. In this case I'm using volumes as a sort of trick to make developing easier. I'm saying here to mount the current directory (the base of the git checkout) as a volume into the container under /app. That makes it so that you can change files on your host computer using whatever development tools you like and when you save those changes they get reflected immediately into the container. You're not going to do it this way in production but this is a handy trick for development. Otherwise you've need to rebuild your containers every time you changed the files. Using this approach as you'll see later we can just HUP the master process, restart the web server and pick up any recent changes. That'd going to be a lot faster. But again this is usually seen for development setups only.

Ok so that's not a lot of stuff, you're wondering 'how is the container made?' We'll look at the Dockerfile in a bit but first lets cover what the database service looks like. Here's the start of it:

  db_contacts:  # Add PostgreSQL service
    container_name: db_contacts
    networks:
      - contacts_network
    build:
      context: .
      dockerfile: docker/Dockerfile-psql
    restart: always
Enter fullscreen mode Exit fullscreen mode

Very similar to the top of the applications service, the main different is we reference a different Dockerfile that defines how this box is made and I specify restart: always since I want to reboot the db each time.

    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U contact_dbuser -d contacts"]
      interval: 1s
      timeout: 5s
      retries: 10
Enter fullscreen mode Exit fullscreen mode

We said before that the Catalyst application depends on the database being healthy. Docker compose has a key that defines the command used to check for health. Here I'm saying the database is healthy when pg_isready returns true. Sometimes the database can have laggy startup so its good practice to make sure you application service doesn't startup until the database is clearly running properly.

    user: postgres
    volumes:
      -  db_contacts_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=contact_dbuser
      - POSTGRES_DB=contacts
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
Enter fullscreen mode Exit fullscreen mode

Normally stuff runs as root but I want the database to run via the postgres user since that's what the image expects. We also define a few environment variables, again making use of .env for secrets. We also expose the database port and setup a persistent volume. That way each time you restart the containers you have data from the last session. You might prefer otherwise for development but it's easy for you to remove that key if you wish. Now lets quickly look at the maildev service

  maildev:  # Add MailDev service
    container_name: maildev
    networks:
      - contacts_network
    image: maildev/maildev
    ports:
      - "1080:1080"
      - "1025:1025"
Enter fullscreen mode Exit fullscreen mode

As I said before Maildev is a neat and easy to use SMTP server that is great for development since you can have your application act as though its really sending emails and have that intercepted directly and reviewed in a neat GUI application. It's a nice way to solve the problem of testing emails but you are certainly welcome to do something else, possible dangerous, like actually send emails from your development box :). Again, I want you to start thinking about chaining applications together as a cluster of single purpose containers, all talking to each other via simple conventions and environment variables (for example you might have noticed that the DB_HOST was the same as the name of the database container; Docker does a great job of offering simple conventions like that which really save a lot of setup headache.

Now there's a bit of postamble to the compose file that's worth talking thru:

volumes:
  db_contacts_data:
    name: db_contacts_data
networks:
  contacts_network:
    name: contacts_network
Enter fullscreen mode Exit fullscreen mode

This is just setup for the volume we use to make the database data persistent and for defining the network that joins all these things together. Feel free to cargo cult stuff like this and not worry too much when you are first starting out. You'll need to deep dive on it when you're trying to setup huge production clusters but lets worry about that another time (or better yet hopefully you work at a company with a dedicated SRE team that will take that job off your hands:))

So that completes looking at the docker compose file. Let's get into the two dockerfiles we saw mentioned; one for the catalyst application and the other for the database. We'll start with the Catalyst application:

FROM perl:5.34
WORKDIR /app

# Core OS setup
RUN apt-get update && apt-get install -y postgresql-client
RUN cpan App::cpanminus

# Copy only the files needed for installing dependencies
COPY cpanfile .
RUN cpanm --notest --installdeps .

# Copy the rest of the application source code
COPY . .

# Run the Catalyst application
CMD make server 
Enter fullscreen mode Exit fullscreen mode

For developing I find the official Perl docker images, running on a lightweight version of Debian, to be perfectly fine. Later on you might hand roll the skinniest possible image but the beauty of this setup is you can do that later and you don't need to change anything else. There's really not a lot going on here. First I declare the base image, which is as I said the official Perl image. I'm not using the latest Perl here because the application uses Sqitch for managing database migrations and that needs an update (there's a PR pending) to run on the most recent Perl so we'll just use a very nearly recent one instead. WORKDIR just defines where your application is installed. You can put it anywhere you want within reason. I like simple things so I use the most simple of all the conventions I've seen around.

Next we'll install a few application dependencies. Remember this runs as root unless you specify otherwise in the compose file. I want the postgresql client since Sqitch will need psql. I also install cpanminus because I find that's more reliable than classic cpan and at some point you're going to want to use carton or something similar to lock down your dependencies and cpanminus supports that out of the box.

Because of the way that docker caches its steps I will first copy and install CPAN dependencies before doing other steps. That way if you change cpanfile when you run docker-compose build that will be noticed and the new dependencies added. After that we copy over the rest of the application and then run a make command to start the server. We'll look at the makefile last. I like to use makefiles for overall application control and container management because its good way to basically self document how to use the application and also people are used to looking at the makefile for instruction on what to do.

Again, there's not a lot in this dockerfile today but it's likely to have more stuff eventually so it's great to have a place for it to go. Now lets see what the docker file for the Postgresql database looks like:

FROM postgres:15.3

# Make postgres user sudoer (maybe don't want this in production)
RUN apt-get update && apt-get install -y sudo
RUN echo "postgres        ALL=(ALL)       NOPASSWD: ALL" >> /etc/sudoers
RUN sed -i "s/^.*requiretty/#Defaults requiretty/" /etc/sudoers

RUN apt-get update && apt-get install -y postgresql-contrib
RUN apt-get update && apt-get install -y postgis postgresql-15-postgis-3
RUN apt-get update && apt-get install -y postgresql-15-pgvector

# Add the database initialization script
ADD docker/psql/init.sql /docker-entrypoint-initdb.d
Enter fullscreen mode Exit fullscreen mode

I will recommend you review the documentation for the official Postgresql docker images which you can read here.

Most of this shouldn't freak anyone out. I add the postgres user to the sudoers file (you might recall that we specified the postgres user as the build user in the database service section of the docker compose file). You don't need that for installing and running the DB but if you need to log into the shell and poke around for troubleshooting it will be handy to have. I also install a number of postgresql extensions that I like. Free free to tweak and modify this section to your heart's content.

Two things might be bothering you, especially if you didn't go read the documentation I linked above ;). The first one is that we don't seem to be creating any databases or users. That actually is something the image build takes care of for us. Remember I said using environment variables is the docker way? Well those vars you set in the docker compose file actually tell the image to create a database and user using their values. So we don't need to create the contacts database, we just needed to specify POSTGRES_DB=contacts.

Now you are totally free to create more uses and other databases as needed. Which brings me to possible 'I didn't read the docs' confusion #2: What is this line doing?

ADD docker/psql/init.sql /docker-entrypoint-initdb.d
Enter fullscreen mode Exit fullscreen mode

Again to make things easy on people using the official image, when the database starts up the first time it will look in the directory /docker-entrypoint-initdb.d and execute files there. You can put bash scripts and sql files in it. So my ADD command is 'adding' my custom startup script and that looks like this:

-- Enable pgcrypto extension
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;

-- Enable pg_vector extension
CREATE EXTENSION IF NOT EXISTS vector;
Enter fullscreen mode Exit fullscreen mode

Basically I'm just enabling those handful of extensions but you can use this file to create more users and do any other type of initial database setup you like.

So that's a pretty basic but reasonable complete Perl Catalyst Docker for development setup. In following blogs we will dive into the actual application but we will be building on a solid and reproducible base.

One more thing

What about the Makefile? It was briefly mentioned in the dockerfile for the Catalyst application setup. As I said I use Makefiles as a handy place to hang commands to run and work on my application. You can run make help to get a summary, but let's look at the actual lines. You can see the whole file here and this is an overview of the most important bits. This first group of commands are intended to run inside the container so you'll seldom run them directly.

update_cpanlib:
        @echo "Installing CPAN libs"
        @cpanm --notest --installdeps .
Enter fullscreen mode Exit fullscreen mode

When developing and I'm adding new dependencies the last thing I want to do is to be forced to rebuild the container.

update_sqitch_conf:
        @echo "Updating Sqitch config"
        @echo '[core]' > sqitch.conf
        @echo '    engine = pg' >> sqitch.conf
        @echo '    top_dir = sql' >> sqitch.conf
        @echo '    target = main' >> sqitch.conf
        @echo '[target "main"]' >> sqitch.conf
        @echo '    uri = db:pg:$(POSTGRES_DB)?user=$(POSTGRES_USER)&password=$(POSTGRES_PASSWORD)&host=$(DB_HOST)&port=$(DB_PORT)' >> sqitch.conf

update_db: update_sqitch_conf
        @echo "Deploying Sqitch"
        @sqitch deploy
Enter fullscreen mode Exit fullscreen mode

Ditto installing migrations. Sadly the sqitch.conf file can't read environment variables (yet) so I need to actually build the file. Remember those environment variables got defined over in the docker compose file.

update: update_cpanlib update_db
Enter fullscreen mode Exit fullscreen mode

Since I often need to do both at once

server: update
        @echo "Starting demo application"
        @start_server --port 5000 --pid-file=$(PID_FILE) -- \
                perl -Ilib \
                ./lib/ContactsDemo/PSGI.pm run \
                --server Gazelle --max-workers 20 --max-reqs-per-child 1000 --min-reqs-per-child 800

hup:
        @kill -HUP $$(cat $(PID_FILE));
Enter fullscreen mode Exit fullscreen mode

This is used to start the web server. You'll note when starting the server I also double check the dependencies and migrations. We're using Server::Starter here which makes it easy to just HUP the process and restart.

Again unless you want to install and run the demo app on your local host computer you probably won't run any of these commands directly; later on you'll see we execute them directly in the container. This next group of commands is used to start and manage the containers themselves:

up: 
        @docker-compose up -d

stop:
        @docker-compose stop

restart: stop up
Enter fullscreen mode Exit fullscreen mode

Rebuild the docker image

build:
        @docker-compose build
Enter fullscreen mode Exit fullscreen mode

These are just simple wrappers on the docker commands. You don't really need them if you know all the commands by heart but I find it's nice to have everything in one place. This next group of targets actually execute stuff inside the running containers.

app-shell:
        @docker-compose exec app_contacts bash

db-shell:
        @docker-compose exec db_contacts bash

db-psql:
        @docker-compose exec db_contacts psql -U contact_dbuser contacts

app-update:
        @docker-compose exec app_contacts make update

app-hup:
        @docker-compose exec app_contacts make hup

app-restart: app-update app-hup

app-prove:
        docker-compose exec app_contacts prove -lvr $(filter-out app-prove,$(MAKECMDGOALS))
Enter fullscreen mode Exit fullscreen mode

The first two targets open shells directly inside the running containers. It's generally consider bad form to work inside the containers like that but there's times when you need to do debugging or poke around. The third command opens a psql shell into the database in case you need that.

The next three commands app-update, app-hup and app-restart actually hit the makefile targets inside the container. Generally when I'm working on a running docker setup I will make changes to the application files, the dependencies and the migrations and then run make app-restart which will apply those changes and the HUP the server so I can see it live (or see the error message if I screwed up).

The last command app-prove is used to run test cases directly inside the container. Test results go to STDOUT/ERR so you can see them directly in the terminal.

So that's it for basic setup and control of a docker based development environment, or at least one that works well for me. Did I make a mistake? Tell me about it or send me a pull request ;)

Top comments (1)

Collapse
 
tonytronics profile image
Tony

Nicely done. This was very easy to follow.
thanks for sharing