In this post a Phoenix app will be installed as a service on FreeBSD. The goals are the same as the Elixir as a Service on FreeBSD post.

  • The app needs to be able to be rebuilt and the service restarted to reflect changes on a development machine.
  • The service needs to automatically start when the machine is booted.
  • The service sould work like any other service.
service phoenix_service start
service phoenix_service stop
service phoenix_service restart
service phoenix_service status

This post assumes Phoenix, Elixir and PostgreSQL are already installed on FreeBSD.

Software Versions

$ date -u "+%Y-%m-%d %H:%M:%S +0000"
2016-03-21 15:48:51 +0000
$ uname -vm
FreeBSD 11.0-CURRENT #0 r296925: Wed Mar 16 20:53:04 JST 2016     root@mirage.sennue.com:/usr/obj/usr/src/sys/MIRAGE_KERNEL  amd64
$ mix hex.info
Hex:    0.11.3
Elixir: 1.2.3
OTP:    18.2.4
* snip *
$ mix phoenix.new -v
Phoenix v1.1.4

Creating a Sample Project

Create a new Phoenix project.

mix phoenix.new phoenix_service --no-brunch
cd phoenix_service
mix ecto.create

This sample project needs to be simple. Let’s make a JSON memo service.

mix phoenix.gen.json Memo memos title:string body:string

Revise the web/router.ex file. The “/api” scope needs to be uncommented and the “/memos” route needs to be added.

web/router.ex file

defmodule PhoenixService.Router do
  use PhoenixService.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", PhoenixService do
    pipe_through :browser # Use the default browser stack
  
    get "/", PageController, :index
  end

  scope "/api", PhoenixService do
    pipe_through :api

    resources "/memos", MemoController, except: [:new, :edit]
  end
end

Run the migration.

mix ecto.migrate

Set server to true in config/prod.exs. Also make sure a dynamic port configuration is used.

config/prod.exs partial listing

config :phoenix_service, PhoenixService.Endpoint,
  http: [port: {:system, "PORT"}], # dynamic port configuration
  url: [host: "example.com", port: 80],
  cache_static_manifest: "priv/static/manifest.json", # added comma
  server: true # this line is new

Optionally, add a dynamic port configuration with a default value to config/dev.exs. The above build time solution is suitable for releases. The interpreted solution below is suitable for development.

config/dev.exs partial listing

  # http: [port: 4000], # old line 10
  http: [port: System.get_env("PORT") || Application.get_env(:phoenix_service, :port) || 4000],

Tests should pass

mix test

Start the server.

mix phoenix.server

POST and GET a memo to make sure the server works. A prior post covers a shell script for conveniently interacting with this sample app.

# POST new
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "New Title", "body": "This is the new memo body."}}' http://localhost:4000/api/memos
# GET id 1
curl -H 'Content-Type: application/json' http://localhost:4000/api/memos/1

Generating a Release

Now that the Phoenix app is working, it is time to build a release. Add the elixir release manager (exrm) to mix.exs as a project dependency.

mix.exs partial listing

  defp deps do
    [{:phoenix, "~> 1.1.4"},
     {:postgrex, ">= 0.0.0"},
     {:phoenix_ecto, "~> 2.0"},
     {:phoenix_html, "~> 2.4"},
     {:phoenix_live_reload, "~> 1.0", only: :dev},
     {:gettext, "~> 0.9"},
     {:exrm, "~> 1.0.2"}, # this line is new
     {:cowboy, "~> 1.0"}]
  end

Install exrm and build a release. This will create the rel/ directory.

mix deps.get
mix deps.compile
MIX_ENV=prod mix ecto.create
MIX_ENV=prod mix ecto.migrate
MIX_ENV=prod mix compile
# brunch build --production # if using brunch
MIX_ENV=prod mix phoenix.digest
MIX_ENV=prod mix release

The rc script will use environment variable knobs to configure the app. Note that the RELX_REPLACE_OS_VARS=true environment variable needs to be defined to use environment variables for dynamic configuration.

The vm.args file is primarily used to configure the erlang VM. It can also be used to define application configure parameters. Application configuration parameters defined in this file can be passed into the program as atoms or integers. Note that the location of this file can be configured with the RELEASE_CONFIG_DIR environment variable. Add the following to rel/vm.args.

rel/vm.args

## Name of the node
-name ${NODE_NAME}

## Cookie for distributed erlang
-setcookie ${COOKIE}

## App Settings
-phoenix_service port ${PORT}

Alternatively, sys.config can be used to pass in application configuration parameters. In this file, application configuration parameters defined with environment variables must be strings. Pass the port setting in as above or add the following to rel/sys.config. The app module should work with either solution. Adding both files will not break anything. Note that rel/sys.config is written in Erlang.

rel/sys.config

[
  {phoenix_service, [
    {port, "${PORT}"}
  ]}
].

Rebuild the release with the configuration files.

MIX_ENV=prod mix release

Start the release in the console.

RELX_REPLACE_OS_VARS=true PORT=7777 rel/phoenix_service/bin/phoenix_service console

Make sure the server responds.

curl http://localhost:7777/api/memos

Exit the console with ^C.

Installing the Release as a Service

Now that the release is working, it is time to set it up as a service. Perform the initial install.

su
sh
PROJECT=phoenix_service
INSTALL_DIR=/usr/local/opt
VERSION=$(cat rel/${PROJECT}/releases/start_erl.data | cut -d' ' -f2)
INSTALL_TAR=`pwd`/rel/$PROJECT/releases/$VERSION/$PROJECT.tar.gz
mkdir -p $INSTALL_DIR/$PROJECT
(cd $INSTALL_DIR/$PROJECT; tar -xf $INSTALL_TAR)
pw adduser $PROJECT -d $INSTALL_DIR/$PROJECT -s /usr/sbin/nologin -c "$PROJECT system service user"
chown -R $PROJECT:$PROJECT $INSTALL_DIR/$PROJECT

An rc script defines the the service.

  • phoenix_service_run() is called from the other functions. It configures and calls the release. HOME is set to the installation directory to force the erlang cookie file to be written there regardless of the phoenix_service_user setting.
  • phoenix_service_status() echoes a user friendly message if the release can be pinged.
  • extra_commands is used to add the console and remote_console commands. console is used to start a new service and attach a console to it. remote_console() is used to connect a console to the service if it is already running.
  • Add shutdown to the keyword list if the service needs to gracefull shutdown when the machine restarts.
  • The rest is standard rc configuration.

Add following to /usr/local/etc/rc.d/phoenix_service

/usr/local/etc/rc.d/phoenix_service

#!/bin/sh
#
# PROVIDE: phoenix_service
# REQUIRE: networking
# KEYWORD:
 
. /etc/rc.subr
 
name="phoenix_service"
rcvar="${name}_enable"
install_dir="/usr/local/opt/${name}"
version=$(cat ${install_dir}/releases/start_erl.data | cut -d' ' -f2)
command="${install_dir}/bin/${name}"
 
start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"
console_cmd="${name}_console"
remote_console_cmd="${name}_remote_console"
extra_commands="console remote_console"

load_rc_config $name
: ${phoenix_service_enable:="no"}
: ${phoenix_service_port:="4000"}
: ${phoenix_service_user:=${name}}
: ${phoenix_service_node_name:="${name}@127.0.0.1"}
: ${phoenix_service_cookie:="${name}"}
: ${phoenix_service_config_dir:="${install_dir}/releases/${version}/${name} start"}

phoenix_service_run()
{
  RELX_REPLACE_OS_VARS=true \
  HOME="${install_dir}" \
  RELEASE_CONFIG_DIR="${phoenix_service_config}" \
  NODE_NAME="${phoenix_service_node_name}" \
  COOKIE="${phoenix_service_cookie}" \
  PORT="${phoenix_service_port}" \
  su -m "$phoenix_service_user" -c "$command $1"
}

phoenix_service_start()
{
  phoenix_service_run start
}

phoenix_service_stop()
{
  phoenix_service_run stop
}

phoenix_service_status()
{
  ping_result=`phoenix_service_run ping`
  echo "${ping_result}"
  case "${ping_result}" in
    *pong*)
      echo "${name} is running."
      ;;
  esac
}

phoenix_service_console()
{
  phoenix_service_run console
}

phoenix_service_remote_console()
{
  phoenix_service_run remote_console
}

load_rc_config $name
run_rc_command "$1"

Make /usr/local/etc/rc.d/phoenix_service read only and executable.

chmod 555 /usr/local/etc/rc.d/phoenix_service

Enable and configure the service in /etc/rc.conf.

/etc/rc.conf lines to add

phoenix_service_enable="YES"
phoenix_service_port=8248
phoenix_service_node_name="suzaku"
phoenix_service_cookie="cookie-of-the-southern-flame"

The service can now be started. If the service is enabled, it will automatically start when the machine boots.

service phoenix_service start
# POST new
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "Service Running", "body": "The Phoenix service is running on '"$(hostname)"'."}}' http://localhost:8248/api/memos
# GET id 1
curl -H 'Content-Type: application/json' http://localhost:8248/api/memos/1

Optional: Adding a Release to the Systemwide Path

Adding a release to the systemwide path is not necessary, but it can be convenient. Create a directory for the convenience pass through script.

mkdir -p $INSTALL_DIR/bin

PORT, NODE_NAME and COOKIE need default values because vm.args has no useful default fallbacks. There is no good way to automatically select a port, so 8080 is a hard coded default. Add the following script to /usr/local/opt/bin/phoenix_service.

/usr/local/opt/bin/phoenix_service

#!/bin/sh

SCRIPT=$(realpath $0)
BASENAME=$(basename $SCRIPT)
BASEDIR=$(dirname $SCRIPT)
COMMAND=$(realpath $BASEDIR/../$BASENAME/bin/$BASENAME)

: ${NODE_NAME:=${BASENAME}}
: ${COOKIE:=${BASENAME}}
: ${PORT:="8080"}

NODE_NAME=${NODE_NAME} \
COOKIE=${COOKIE} \
PORT=${PORT} \
RELX_REPLACE_OS_VARS=true \
$COMMAND "$@"

Make the script executable.

chmod +x $INSTALL_DIR/bin/$PROJECT

Add /usr/local/opt/bin to the global path in /etc/profile for sh, and /etc/csh.cshrc for csh. Consider updating the root path in /root/.cshrc.

/usr/local/opt/bin partial listing

PATH=/usr/local/opt/bin:$PATH
export PATH

/etc/csh.cshrc partial listing

set path=(/usr/local/opt/bin $path)

/root/.cshrc partial listing

set path = (/usr/local/opt/bin /sbin /bin /usr/sbin /usr/bin /usr/local/sbin /usr/local/bin $HOME/bin)

Update the path in the current shell if necessary.

# sh bash
source /etc/csh.cshrc
# csh tcsh
. /etc/profile

Fix permissions if you want to be able to run as any user. This may have security implications.

chmod 755 $INSTALL_DIR/$PROJECT/bin/$PROJECT
chmod 755 $INSTALL_DIR/$PROJECT/bin/nodetool
chmod 755 $INSTALL_DIR/$PROJECT/releases/$VERSION/$PROJECT.sh
mkdir -p $INSTALL_DIR/$PROJECT/log
chmod 777 $INSTALL_DIR/$PROJECT/log
chmod 777 $INSTALL_DIR/$PROJECT/log/*.*
mkdir -p $INSTALL_DIR/$PROJECT/tmp/erl_pipes/$PROJECT
chmod 777 $INSTALL_DIR/$PROJECT/tmp/erl_pipes/$PROJECT
mkdir -p $INSTALL_DIR/$PROJECT/running-config/
chmod 777 $INSTALL_DIR/$PROJECT/running-config/
chmod 666 $INSTALL_DIR/$PROJECT/running-config/*.*
chown -R $PROJECT:$PROJECT $INSTALL_DIR/$PROJECT

The release can now be conveniently controlled.

# start service
NODE_NAME=rudra COOKIE=treasure PORT=5678 phoenix_service start
# POST new
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "Service Running", "body": "The Phoenix service is running on '"$(hostname)"'."}}' http://localhost:5678/api/memos
# GET id 1
curl -H 'Content-Type: application/json' http://localhost:5678/api/memos/1
# stop service
NODE_NAME=rudra COOKIE=treasure phoenix_service stop

Setup complete. Switch from root to a normal user.

exit # sh
exit # su

Updating

Casual updates on a development machine can be performed as follows.

# brunch build --production # if using brunch
MIX_ENV=prod mix phoenix.digest # if static assets could have changed
MIX_ENV=prod mix release
su
sh
PROJECT=phoenix_service
INSTALL_DIR=/usr/local/opt
VERSION=$(cat rel/${PROJECT}/releases/start_erl.data | cut -d' ' -f2)
INSTALL_TAR=`pwd`/rel/$PROJECT/releases/$VERSION/$PROJECT.tar.gz
(cd $INSTALL_DIR/$PROJECT; \
tar -xf $INSTALL_TAR)
chown -R $PROJECT:$PROJECT $INSTALL_DIR/$PROJECT
service $PROJECT restart
exit # sh
exit # su

Note that the path permissions will need to be fixed again if you added the release to the systemwide path and want to be able to run it as any user.

Troubleshooting

Make sure to set server to true in config/prod.exs. If the server is mysteriously not working, start the release with console to see error messages.

What Next?

Consider looking into edeliver for deployment. “edeliver is based on deliver and provides a bash script to build and deploy Elixir and Erlang applications and perform hot-code upgrades.”

References: