Phoenix as a Service on FreeBSD
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:
- Phoenix, Deployment
- Phoenix, Building a JSON API With Phoenix
- Phoenix, Building a versioned REST API with Phoenix Framework
- Phoenix, Default (4000) port busy
- Phoenix, Start Phoenix app with cowboy server on different port
- Phoenix, does not evaluate Env config overrides at runtime
- Phoenix, Installing Phoenix, Elixir and PostgreSQL on FreeBSD
- Phoenix, A Shell Script for Working with Phoenix JSON APIs
- Elixir Release Manager
- Elixir, exrm, Release Configuration
- Elixir, exrm, Where should I place app.conf and vm.args
- Elixir, exrm, Packages on Hex
- Elixir, edeliver
- Elixir, Mix.Config
- Elixir as a Service on FreeBSD
- Ruby, adamkittelson’s Cap File
- PostgreSQL, Installing PostgreSQL on FreeBSD
- Sh - the Bourne Shell
- FreeBSD, KDE4 localization
- ION DTN as a Service on FreeBSD