オープンソースこねこね

Webプログラミングなどについてあれこれ。

Bashスクリプトのチートシートと便利なスニペットまとめ

Bashスクリプトを書く際によく自分が使っている小技や関数などです。 またBashでは他のプログラミング言語以上に$:などの記号の使い方が独特でググラビリティが低いので、基本文法などもチートシートとしてまとめておきます。

基本文法

変数と配列

変数

# 変数
v="variable"
echo $v

配列。bashは配列しかデータ構造がない。つらい。。。

# 配列
declare -a array=()

# 初期値
declare -a array=("a" "b" "c")

# 要素数
echo ${#array[@]}

# 先頭に追加
array=("x" "${array[@]}")

# 末尾に追加
array=("${array[@]}" "d") 

# indexを指定して取得
echo ${array[0]}
echo ${array[1]}

# 配列全体を取得
echo ${array[@]}

# 配列をforループで参照する
for v in "${array[@]}"
do
    echo $v
done

参考:

bash 配列まとめ - Qiita

制御構文

while

v=0
while [ $v -lt 10 ]
do
    echo $v
    v=$((v+1))
done

for-in

for i in {0..9}; do
    echo $i
done

if - elif - else

v="hoge"
if [ "$v" = "hoge" ]; then
    echo "v is hoge"
elif [ "$v" = "foo" ]; then
    echo "v is foo"
elif [ ! "$v" = "foo" ]; then
    echo "v is not foo"
else
    echo "v is unknown"
fi

条件判定のあとのブロックを空にすることはできない。

変数は"で囲ったほうがいい。[ $v = "~" ];は変数が空のときsyntaxエラーになる。参考

条件の否定は先頭に!

if文のone-liner

[ "$v" = "hoge" ] && echo "v is hoge"

文字列比較

文字列が等しい (=)

v="hoge"
if [ "$v" = "hoge" ]; then
    echo "equal"
fi

文字列が等しくない (!=)

v="hoge"
if [ "$v" != "foo" ]; then
    echo "not equal"
fi

空文字、文字列長が0 (-z)

v=""
if [ -z "$v" ]; then
    echo "zero length"
fi

空文字でない、文字列長が0でない (-n)

v="aaa"
if [ -n "$v" ]; then
    echo "not zero length"
fi

数値比較

数値が等しい (-eq)

v=55
if [ "$v" -eq 55 ]; then
    echo "equal"
fi

数値が等しくない (-ne)

v=55
if [ "$v" -ne 20 ]; then
    echo "not equal"
fi

数値がより小さい (-lt)

v=10
if [ "$v" -lt 20 ]; then
    echo "less than"
fi

数値がより大きい (-gt)

v=30
if [ "$v" -gt 20 ]; then
    echo "greater than"
fi

ファイルの判定

存在する (-e)

v="/tmp/aaa"
if [ -e "$v" ]; then
    echo "exists"
fi

ファイルである (-f)

v="/path/to/file"
if [ -f "$v" ]; then
    echo "file"
fi

ディレクトリである (-d)

v="/tmp"
if [ -d "$v" ]; then
    echo "directory"
fi

シンボリックリンクである (-L)

v="/path/to/link"
if [ -L "$v" ]; then
    echo "symbolic link"
fi

関数

function hoge() {
  local v="local variable"  # ローカル変数定義
  echo $1   # n番目引数
  echo $2
  echo $@   # 引数全体
  echo $#   # 引数の数
  return 0  # 戻り値
}

hoge "arg1" "arg2"
# 実行結果
# arg1
# arg2
# arg1 arg2
# 2

ブロック内を空にすることはできない。

戻り値は数値のみ。コマンドと同様に0が正常でそれ以外はエラー。

$0は関数名ではなく、呼び出し元スクリプト名が入る。

文字列を戻したいときはechoなどで標準出力を使う。

function hoge() {
    echo "hogehoge"
}

$v=$(hoge)
echo $v

便利なスニペット

実行スクリプトがあるディレクトリを絶対パスで取得する

SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ] ; do SOURCE="$(readlink "$SOURCE")"; done
bin_dir="$( cd -P "$( dirname "$SOURCE" )/" && pwd )"
# /usr/local/bin
# などがbin_dirに取得できる

以下でも良い。

READLINK=$(type -p greadlink readlink | head -1)
if [ -z "$READLINK" ]; then
  echo "cannot find readlink - are you missing GNU coreutils?" >&2
  exit 1
fi

resolve_link() {
  $READLINK "$1"
}

# get absolute path.
abs_dirname() {
  local cwd="$(pwd)"
  local path="$1"

  while [ -n "$path" ]; do
    # cd "${path%/*}" does not work in "$ bash script.sh"
    # cd "${path%/*}"
    cd "$(dirname $path)"
    local name="${path##*/}"
    path="$(resolve_link "$name" || true)"
  done

  pwd -P
  cd "$cwd"
}

bin_dir="$(abs_dirname "$0")"

シンボリックに対応しないなら、以下の記述でもOK。

# スクリプトがシンボリックリンクから呼び出されたときは、リンクを辿らず、リンク先のパスを返す
bin_dir=$(cd $(dirname $0); pwd)

参考:

https://github.com/hashicorp/consul/blob/master/scripts/build.sh

https://github.com/rbenv/rbenv/blob/master/libexec%2Frbenv

標準出力にプリフィクスをつける

prefix() {
  local p="${1:-prefix}"
  local c="s/^/$p/"
  case $(uname) in
    Darwin) sed -l "$c";; # mac/bsd sed: -l buffers on line boundaries
    *)      sed -u "$c";; # unix/gnu sed: -u unbuffered (arbitrary) chunks of data
  esac
}

# 使い方
echo "message" | prefix "[hoge] "
# [hoge] message

# 標準エラー出力も対象とする場合はエラーを標準出力にリダイレクトしてから行う
echo "message" 2>&1 | prefix "[hoge] " 

参考:

https://github.com/heroku/heroku-buildpack-php

標準出力にインデントをつける

プリフィクスをつける、の応用。

indent() {
    local n="${1:-4}"
    local p=""
    for i in `seq 1 $n`; do
        p="$p "
    done;

    local c="s/^/$p/"
    case $(uname) in
      Darwin) sed -l "$c";; # mac/bsd sed: -l buffers on line boundaries
      *)      sed -u "$c";; # unix/gnu sed: -u unbuffered (arbitrary) chunks of data
    esac
}

# 使い方
# デフォルトで4スペースインデント
echo "message" | indent
#    message

# インデントサイズを引数に渡せる
echo "message" | indent 6
#      message

コンソールに確認用のプロンプトを出す

confirm() {
    local response
    # call with a prompt string or use a default
    read -r -p "${1:-Are you sure? [y/N]:} " response
    case $response in
        [yY][eE][sS]|[yY])
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

# 使い方
confirm
if [ $? -ne 0 ]; then
    exit 1
fi
# Are you sure? [y/N]:
# と表示されて入力まちになるのでyやyesを入力で続行。それ以外はexitする。

# 引数で表示メッセージを変えられる。
confirm "Please input yes!:"

# set -eしておけばconfirmの戻りが1のとき即終了するのであと条件分岐を書く必要がない
set -e
confirm

参考:

http://stackoverflow.com/questions/3231804/in-bash-how-to-add-are-you-sure-y-n-to-any-command-or-alias

コンソールにユーザー入力用のプロンプトを出す

ask() {
    local response
    # call with a prompt string or use a default
    read -r -p "${1:->} " response
    echo $response
}

# 使い方
v=$(ask)
echo $v

# > 
# と表示されて入力待ち状態になる。入力値はvに入る。

# プロンプトの表示も買えられる
v=$(ask "[input your name]> ")
echo $v

テキスト装飾(色、太字、アンダーライン)

if [ "${TERM:-dumb}" != "dumb" ]; then
    txtunderline=$(tput sgr 0 1)     # Underline
    txtbold=$(tput bold)             # Bold
    txtred=$(tput setaf 1)           # red
    txtgreen=$(tput setaf 2)         # green
    txtyellow=$(tput setaf 3)        # yellow
    txtblue=$(tput setaf 4)          # blue
    txtreset=$(tput sgr0)            # Reset
else
    txtunderline=""
    txtbold=""
    txtred=""
    txtgreen=""
    txtyellow=""
    txtblue=$""
    txtreset=""
fi

# 使い方 (装飾後は${txtreset}で戻すようにして使う)
${txtred}this text is red${txtreset}

参考:

https://linuxtidbits.wordpress.com/2008/08/11/output-color-on-bash-scripts/

http://stackoverflow.com/questions/2924697/how-does-one-output-bold-text-in-bash

エラーメッセージを出して終了する

上記のテキスト装飾を使って、赤文字のエラーメッセージを出力してプログラムを終了させる

abort() {
  { if [ "$#" -eq 0 ]; then cat -
    else echo "${txtred}${progname}: $*${txtreset}"
    fi
  } >&2
  exit 1
}

# 使い方
abort "error message"

コンソールにラインを引く

hr() {
    printf '%*s\n' "${2:-$(tput cols)}" '' | tr ' ' "${1:--}"
}

# 使い方
hr

# 引数でラインのキャラクタを変えられる(デフォルトは-)
hr "="

# 第2引数でラインの長さを変えられる(デフォルトはターミナルの幅いっぱい)
hr "=" 10

参考:http://wiki.bash-hackers.org/snipplets/print_horizontal_line

文字列を小文字->大文字に変換する

upper() {
    echo -n "$1" | tr '[a-z]' '[A-Z]'
}

# 使い方
v=$(upper "abcdefg")
echo $v

オプションとサブコマンドを扱うためのテンプレート

よくあるhoge.sh [<options...>] <command>という形式のコマンドをbashで作るためのテンプレート。

#!/usr/bin/env bash
set -eu

progname=$(basename $0)
progversion="0.1.0"

# actions.
usage() {
    echo "Usage: $progname [OPTIONS] COMMAND"
    echo
    echo "Options:"
    echo "  -h, --help         show help."
    echo "  -v, --version      print the version."
    echo "  -d, --dir <DIR>    change working directory."
    echo
    echo "Commands:"
    echo "  help        show help."
    echo
}

printversion() {
    echo "${progversion}"
}

# parse arguments and options.
declare -a params=()
for OPT in "$@"
do
    case "$OPT" in
        '-h'|'--help' )
            usage
            exit 0
            ;;
        '-v'|'--version' )
            # パラメータを取らないオプション
            printversion
            exit 0
            ;;
        '-d'|'--dir' )
            # パラメータを取るオプション。 "-d /tmp"のようにスペースで区切ってパラメータを渡す。
            if [[ -z "${2:-}" ]] || [[ "${2:-}" =~ ^-+ ]]; then
                echo "$progname: option '$1' requires an argument." 1>&2
                exit 1
            fi
            optarg="$2"

            cd $optarg
            shift 2
            ;;
        '--'|'-' )
            shift 1
            params+=( "$@" )
            break
            ;;
        -*)
            echo "$progname: illegal option -- '$(echo $1 | sed 's/^-*//')'" 1>&2
            exit 1
            ;;
        *)
            if [[ ! -z "${1:-}" ]] && [[ ! "${1:-}" =~ ^-+ ]]; then
                params+=( "$1" )
                shift 1
            fi
            ;;
    esac
done

# サブコマンドに対応して処理を実行
command="" && [ ${#params[@]} -ne 0 ] && command=${params[0]}
case $command in
    'help' )
        usage
        exit 0
        ;;
    '' )
        usage
        exit 0
        ;;
    *)
        echo "$progname: illegal command '$command'" 1>&2
        exit 1
        ;;
esac

参考:

bash によるオプション解析 - Qiita

Tips

標準出力を標準エラー出力にリダイレクト

echo "error" 1>&2

標準エラー出力を標準出力にリダイレクト

echo "error" 2>&1

プロセスIDを取得する($$)

echo $$
# 99807

コマンドの戻り値を取得する ($?)

[ "aaa" = "bbb" ]
echo $?
# 1

パイプでつないだコマンドの戻り値を取得する (${PIPESTATUS[0]})

$?は最後に実行されたコマンドの戻り値なので、前述のindent関数などをパイプでつなげた場合、もとの実行コマンドの戻り値は取れない。この場合はPIPESTATUSを使う。

[ "aaa" = "bbb" ] | indent; status=${PIPESTATUS[0]}
echo $status
# 1

参考: パイプでつないだコマンドの戻り値を調べる@bash | Mazn.net

未定義の変数を使用するとそこでスクリプトを終了する (set -u)

set -u

基本的に次のset -eも含めてset -euとしておくのがよさそう。

コマンドがエラーだった場合そこでスクリプトを終了する (set -e)

set -e

しておくと、エラー(戻り値0以外を戻すコマンド実行)があると即時終了する

set -eの状態でエラー後も処理を続ける (&&:)

安全のため基本的にset -eをしておいていいが、これはスクリプト内のすべてのコマンド実行の戻り値に対して効果をもつので、困ることも多い。 たとえばコマンド実行の結果をみてメッセージを出したい場合、以下のように書いたりする。

set -e

/path/to/yourcommand
if [ $? -eq 0 ]; then
    echo "yourcommand OK"
else
    echo "yourcommand NG"
fi

しかし/path/to/yourcommandがエラーの時は即時終了してしまうので[ $? -eq 0 ];は評価されず、結果yourcommand NGは絶対に表示されない。 このような場合はコマンドに&&:を続けておくとうまく動作する。(動作原理は下記参考のリンク先参照)

set -e

/path/to/yourcommand &&:
if [ $? -eq 0 ]; then
    echo "yourcommand OK"
else
    echo "yourcommand NG"
fi

参考:

`set -e` しているときにコマンドの戻り値を得る - Qiita

パイプで繋いだコマンドがエラーのとき終了させる

パイプでつなぐとset -eをしててもエラー時に終了しないので以下のようにかく。

foo | indent; status=${PIPESTATUS[0]}; [[ ! $status -eq 0 ]] && exit $status

スクリプト終了時にコマンドを実行する

trapコマンドでシグナルをハンドリングできる

echo "start"
trap "echo 'after end'" 0
echo "end"

参考: シグナルと trap コマンド | UNIX & Linux コマンド・シェルスクリプト リファレンス

一時ファイルを作る

# 一時ファイルを作成
tmpfile=$(mktemp -t prefix.XXXXXXXX)
echo $tmpfile

# 終了時に一時ファイルを削除
trap "rm $tmpfile" 0

osxlinuxで挙動が違う。どちらでもひとまず動くバージョン linuxだとプリフィクスの末尾に"X"が必要でここがランダムな文字列に置き換わる。 osxだとXがリプレースされずそのまま使用され、その後にランダムな文字列が追加される

一時ディレクトリを作る

# 一時ディレクトリを作成
tmpdir=$(mktemp -d -t prefix.XXXXXXXX)
echo $tmpdir

# 終了時に一時ディレクトリを削除
trap "rm -rf  $tmpdir" 0

rootユーザでのみ実行を許可する

user=`whoami`
if [ $user != "root" ]; then
    echo "you need to run it on the 'root' user." 1>&2
    exit 1
fi

標準入力からデータを受け取る

if [ -t 0 ]; then
  echo "stdin is not pipe" 1>&2
  exit 1
else
  cat -
fi | yourcommand

ヒアドキュメント

ヒアドキュメント内のシェル変数は展開される

cat << EOF
This is a heredoc
home is $HOME
EOF

# This is a heredoc
# home is /Users/kohkimakimoto

変数は展開をさせたくないときはコロンで囲む

cat << 'EOF'
This is a heredoc
home is $HOME
EOF

# This is a heredoc
# home is $HOME

ヒアドキュメントの内容を変数に入れる

heredoc=`cat << 'EOF'
This is a heredoc
home is $HOME
EOF`

echo $heredoc
# This is a heredoc
# home is $HOME

ヒアドキュメントの内容をファイルに出力する

cat << 'EOF' > heredocfile
This is a heredoc
home is $HOME
EOF

cat heredocfile
# This is a heredoc
# home is $HOME

スクリプトのロック(多重起動防止)

exec 9< $0
perl -mFcntl=:flock -e "open(LOCK,'<&=9');exit(!flock(LOCK,LOCK_EX|LOCK_NB))" || {
    echo "duplicate process." >&2
    exit 1
}

参照:

http://qiita.com/ngyuki/items/cc4f6aeaa9b53baa3b68

http://qiita.com/knaka/items/582289c5b98ca5f55506

yum updateでエラー(Error: Package: 2:irqbalance-1.0.7-5.el6.x86_64)

古いCentOS6のサーバにyum updateをかけたら依存の問題で以下のエラーがでて、更新できなかった。

# yum update

...

Error: Package: 2:irqbalance-1.0.7-5.el6.x86_64 (base)
           Requires: kernel >= 2.6.32-358.2.1
           Installed: kernel-2.6.32-71.el6.x86_64 (@anaconda-CentOS-201106060106.x86_64/6.0)
               kernel = 2.6.32-71.el6
               kernel = 2.6.32-71.el6
           Installed: kernel-2.6.32-71.29.1.el6.x86_64 (@updates)
               kernel = 2.6.32-71.29.1.el6
               kernel = 2.6.32-71.29.1.el6

カーネルが古すぎて、あたらしいカーネルを必要とするパッケージが入れられないのはわかるので、yum update kernelなどを行ってみたが

No Packages marked for Update

と表示されてアップデートできず。調べたところ/etc/yum.confカーネルアップデートを除外するよう、デフォルトで設定が書かれていたからと判明する。

[main]
cachedir=/var/cache/yum/$basearch/$releasever
keepcache=0
debuglevel=2
logfile=/var/log/yum.log
exactarch=1
obsoletes=1
gpgcheck=1
plugins=1
installonly_limit=5
bugtracker_url=http://bugs.centos.org/set_project.php?project_id=16&ref=http://bugs.centos.org/bug_report_page.php?category=yum
distroverpkg=centos-release

#  This is the default, if you make this bigger yum won't see if the metadata
# is newer on the remote and so you'll "gain" the bandwidth of not having to
# download the new metadata and "pay" for it by yum not having correct
# information.
#  It is esp. important, to have correct metadata, for distributions like
# Fedora which don't keep old packages around. If you don't like this checking
# interupting your command line usage, it's much better to have something
# manually check the metadata once an hour (yum-updatesd will do this).
# metadata_expire=90m

# PUT YOUR REPOS HERE OR IN separate files named file.repo
# in /etc/yum.repos.d
exclude=kernel*

最後のexclude=kernel*コメントアウトしたら、無事アップデートできた。

IntelliJからMacの辞書(Dictionary.app)を開くプラグインをつくった

Macの辞書アプリはかなり便利で、何かキーワードを選択している状態でCTRL+CMD+Dのショートカットを押すとそのキーワードで辞書を引いてくれる。 主に英語ドキュメントなどにある分からない英単語を調べるのに使うのだけど、メインに使っているエディタのAtomIntelliJ IDEAだとなんでかこのキーバインドが効かなくて残念な思いをしてた。そこでしばらく前にAtomから辞書を引けるようにしたパッケージとして以下を作った。

github.com

そしてここ最近Go言語をIntelliJ IDEAで書くようになったので、こっちでも辞書を引けるようにしたくて、さきほどちょちょいと作ってみた。

github.com

実装にあたってAtom版もIntelliJ版も両方ともすでに存在していたDashのプラグインを参考にした。 これにはエディタの選択領域からテキストを抽出して、外部コマンドとしてDashを起動するというコードある。 私がやったことは、そのあたりを参考元からを切りだしてDictionary.appを叩くように変えただけのもの。よって細かいところなどにはまるで気を使っていない実装なのだけど、個人的に使う分には必要十分に動くのでこれで満足している(^o^)

なお、Atomの方はパッケージの登録までしているのでAtomの設定画面から直接インストールできるが、IntelliJの方はJarをGithubのリリースページにおいただけなので、インストールするにはJarをダウンロードしてローカルファイルからインストールする必要があります。

sshのラッパーコマンドを作った

GoでSSHコマンドに便利機能を追加したコマンドを作りました。

2017/04/11 追記: この記事の内容は古くなっています!

2017/04/11時点の最新の仕様は、次の記事を参照してください

SSHラッパーコマンドEsshのv1.0.0をリリースしました - オープンソースこねこね

追記ここまで。以下は2015/11時点の古い情報となります。

f:id:kohkimakimoto:20151115163938g:plain

github.com

機能として

  • Lua~/.ssh/configに相当する設定を書ける。
  • zshの補完機能を使って、接続先一覧を出す。
  • サーバ接続時にフックを仕込める。自分はスクリーンの色を変えるの使っている。
  • 複数のリモートサーバにまとめてコマンドを実行する。

といったところです。詳細はリポジトリのREADMEを見ていだければと思います。 ビルド済みバイナリをリリースページにおいてあるので、インストールはダウンロードして解凍してパスの通ったディレクトリに配置すればOKです。

~/.ssh/configを上書きするので~/.ssh/configのバックアップを取っておくのをお忘れなく。

https://github.com/kohkimakimoto/zssh/releases/latest

使い方

zsshsshのラッパーコマンドになっているのでsshコマンドと同様に使えます。zsshを実行するとsshコマンドで実際の処理を実行する前に、設定ファイル~/.ssh/zssh.luaを読み込んで~/.ssh/configを生成するしくみになっています。

~/.ssh/zssh.luaに以下のような設定を書いておくと

Host "web01.localhost" {
    ForwardAgent = "yes",
    HostName = "192.168.0.11",
    Port = "22",
    User = "kohkimakimoto",
    -- 小文字で始まる設定は、~/.ssh/configに出力されない。descriptionはzsh補完の説明文に使用される(後述)
    description = "my web01 server",
}

Host "web02.localhost" {
    ForwardAgent = "yes",
    HostName = "192.168.0.12",
    Port = "22",
    User = "kohkimakimoto",
    description = "my web02 server",
}

zssh実行時に次のような~/.ssh/configを自動生成して上書きします。

Host web01.localhost
    ForwardAgent yes
    HostName 192.168.0.11
    Port 22
    User kohkimakimoto

Host web02.localhost
    ForwardAgent yes
    HostName 192.168.0.12
    Port 22
    User kohkimakimoto

これで、sshコマンドと同様に以下のようにしてサーバにSSH接続できます。

zssh web01.localhost

ZSH補完

zsh補完をサポートしているので、以下のコードを~/.zshrcに書いておくと

eval "$(zssh --zsh-completion)"

上に貼り付けたアニメgifのようにサーバが説明文付きで候補にでるようになります。

フック

hooksの設定でサーバ接続時と切断時にローカルでLuaのコードを実行できます。 os.executeでコマンドを実行できるので、以下のようにするとMacのターミナルの色を変更できます。

Host "web01.localhost" {
    HostName = "192.168.0.11",
    Port = "22",
    User = "kohkimakimoto",
    ForwardAgent = "yes",
    description = "my web01 server",

    -- フックの設定
    hooks = {
        before = function()
            -- This is an example to change screen color to red.
            os.execute("osascript -e 'tell application \"Terminal\" to set current settings of first window to settings set \"Red Sands\"'")
        end,
        after = function()
            -- This is an example to change screen color to black.
            os.execute("osascript -e 'tell application \"Terminal\" to set current settings of first window to settings set \"Pro\"'")
        end,
    }
}

ちなみに、ターミナルの色を変える方法は以前にも書きましたので、よければそちらも参照ください。

http://kohkimakimoto.hatenablog.com/entry/2015/04/02/211232

マクロ

コマンドを複数サーバにまとめて実行できます。

Host "web01.localhost" {
    HostName = "192.168.0.11",
    Port = "22",
    User = "kohkimakimoto",
    ForwardAgent = "yes",
    description = "my web01 server",
    tags = {
        role = "web"
    },
}

Host "web02.localhost" {
    HostName = "192.168.0.12",
    Port = "22",
    User = "kohkimakimoto",
    ForwardAgent = "yes",
    description = "my web02 server",
    tags = {
        role = "web"
    },
}

Macro "example" {
    -- 並列実行するか?
    parallel = true,
    -- 実行前に確認プロンプトを出す。
    confirm = "Are you OK?",
    -- zsh補完時の説明
    description = "example macro",
    -- 実行先サーバを指定。Host設定のtagsで設定したタグを指定する。指定しないとローカルでの実行になる。
    on = {role = "web"},
    -- ttyを使うか? tail -f でログを監視するときなどはtrueに
    tty = false,
    -- コマンドの内容
    command = [[
        ls -la
    ]],
}

マクロ名を指定して実行できます。

$ zssh example

Goでgraceful restartに対応したデーモンプロセスをつくる

Goで書かれたWebアプリのプロセスをデーモンにしたり、ダウンタイムなしでデプロイできるようにするための情報をいろいろ調べていたのですが、どうもSupervisorなど外部のツールを使ったりするのが定番か、herokuなどのPaaSにまかせてしまうという手法が多いようです。 でも個人的にGoの一番気に入っているところは依存のない独立した単一バイナリを生成できる点なので、Webアプリもなるべく外部に依存せずにどうにかできないものかと調査していました。

さてGoのデーモン化とgracefulのライブラリはそれぞれいくつかあるのですが、両方に対応したライブラリは見つからず、組み合わせたサンプルなども見当たらなかったので、各種ライブラリのソースを読んだりしながら、最終的に以下のものを取り上げてみました

この2つを組み合わせてgraceful restartできるデーモンを作ろうとしたわけですが、上記のライブラリではdaemonziseもgracefulも内部的にforkの代わりにos.StartProcessで外部コマンドとして自分自身を再起動し、環境変数を使って状態の制御をおこなうということをやっています。一緒に使うと、その辺が干渉してうまく動かない。。。

で、いろいろワークアラウンドをいれて、ひと通り動くようになったのが以下のコードです。やっていることはコード内のコメントを参照してください。

package main

import (
    "flag"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "path/filepath"
    "strconv"
    "time"

    "github.com/VividCortex/godaemon"
    "github.com/facebookgo/grace/gracehttp"
    "github.com/kardianos/osext"
)

var (
    logfile = flag.String("l", "goserver.log", "log file")
    pidfile = flag.String("p", "goserver.pid", "pid file")
)

func main() {
    flag.Parse()

    // os.Args[0]を絶対パスに書き換える。
    // デーモナイズ後、カレントディレクトリが変わるので、
    // 相対パスのままだとgracehttpがos.StartProcessするとき自分自身を指し示すファイルパスを解決できない。
    bin, err := osext.Executable()
    if err != nil {
        log.Fatal(err)
    }
    os.Args[0] = bin

    // pidとlogのファイルのパスも絶対パスにする。
    logfilepath, err := filepath.Abs(*logfile)
    if err != nil {
        log.Fatal(err)
    }
    pidfilepath, err := filepath.Abs(*pidfile)
    if err != nil {
        log.Fatal(err)
    }

    // デーモナイズする
    // "LISTEN_FDS"があるときはgraceful restart時なので、スキップさせる。
    if os.Getenv("LISTEN_FDS") == "" {
        godaemon.MakeDaemon(&godaemon.DaemonAttr{})
    }

    // 以下のロジックはデーモナイズ後の状態で実行される。

    // ログの出力先をファイルに。
    f, err := os.OpenFile(logfilepath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    log.SetOutput(f)

    // pidファイルのプロセスIDを書き込む
    pid := os.Getpid()
    if err = ioutil.WriteFile(pidfilepath, []byte(strconv.Itoa(pid)), 0666); err != nil {
        log.Fatal(err)
    }
    log.Printf("Generated pidfile %s (%d)\n", pidfilepath, pid)

    // WebAppサーバ
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(5 * time.Second)  // 動作検証用に5秒リクエストをつかみっぱなしにする(restartしても切れないことを見る)
        fmt.Fprintf(w, "hello world!")
    })
    server := &http.Server{Addr: ":1234", Handler: mux}

    // graceful restartをサポートして起動
    gracehttp.Serve(server)
}

github.com/kardianos/osextは実行ファイルの絶対パスを取得するための便利ライブラリです。

これをビルドし起動した後、graceful restartするには-USR2シグナルを送る。

kill -USR2 5678

シャットダウンもリクエストが途中で切れないgraceful shutdownになる。

kill 5678

動作させてみたところうまく動いているようではあるが、os.Args[0]を書き換えたりしてキモいコードになっている。 daemonizeとgracefulをまとめて、ルーティングとmiddlewareまわりの構造を提供してくれる軽量フレームワークがあったらいいなあ、と妄想しています。