Docker + Cron環境を実現する３つの方法

Dockerコンテナ内のプログラムを定期的に実行したい！
そんな時のための備忘録です。

素のLinux環境の場合はcronに設定を入れるだけでOKなのですが（実行権限とか、環境変数の話とかありますが）、Dockerでこれをやろうとするとひと手間必要です。

ディストリビューションに限らず、選択肢は３つあります（と思っています）。
（2018/03/16追記：kubernetesにはcronjobというものがあるようです。他のオーケストレーションツールにも何か似たような仕組みがあるかもしれません）

1. コンテナ内でcronを直接起動する
2. コンテナ内でsupervisordからcronを起動する
3. ホストのcronからコンテナ内の対象プログラムを実行する

以降、順を追って解説します。

今回確認に使用したのは次の環境です
CentOS Linux release 7.4.1708 (Core)
Docker version 17.12.0-ce, build c97c6d6

## 1. コンテナ内でcronを直接起動する

yumあるいはaptなどでcronをインストールし、コンテナスタート時に起動するようにします。

例としてdateコマンドの実行結果をコンテナ内の指定ファイルに書き出す処理を毎分で実行する処理を書きます。普段使用しているDockerファイルを元に書いています、プロキシ設定などコメントアウトで残していますが無視してください。

```bash:Dockerfile
FROM centos
MAINTAINER yuuki.miyo <メールアドレス>

# プロキシ設定
# ARG proxy='http://xxxx:8080'
# ARG noproxy='xxx,xxx'
# ENV HTTP_PROXY $proxy
# ENV HTTPS_PROXY $proxy
# ENV NO_PROXY $noproxy
# RUN echo "proxy=$proxy" >> /etc/yum.conf

# タイムゾーン変更
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# yum アップデートとcronのインストール
RUN yum update -y --disableplugin=fastestmirror && \
    yum install -y epel-release --disableplugin=fastestmirror && \
    yum install -y --disableplugin=fastestmirror sudo cronie

# 実行ユーザを追加（全てrootで行う場合は不要です、この場合crontabのコマンド起動ユーザをrootにする必要があります）
# sudoersへの追加は必要な場合のみで問題ありません
# (cronで実行するコマンドにsudoが含まれる場合に必要です)
RUN groupadd -g 1000 developer && \
    useradd  -g      developer -m -s /bin/bash dev-user && \
    echo 'dev-user    ALL=(ALL)    NOPASSWD:ALL' >> /etc/sudoers.d/dev-user

# PAMの設定
RUN sed -i -e '/pam_loginuid.so/s/^/#/' /etc/pam.d/crond

# Dockerfileと同じ階層の"cron.d"フォルダ内にcronの処理スクリプトを格納しておく
ADD cron.d /etc/cron.d/
RUN chmod 0644 /etc/cron.d/*

CMD crond && tail -f /dev/null
```

cronで起動する個別コマンドの設定ファイルです。
/tmp/crontest.txtに一分毎に現在日時を追加し続けます。
Dockerfileと同じ階層からの相対パスです。

```bash:./cron.d/crontest
# crontest
SHELL = /bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
* * * * * dev-user /usr/bin/date >> /tmp/crontest.txt
```

## 2. コンテナ内でsupervisordからcronを起動する

1.で起動しているcronプロセスをsupervisordから起動します。supervisordを利用することによって、cron以外のプロセスも同時に起動するコンテナを作成できます。

1.と同様、dateコマンドの実行結果をコンテナ内の指定ファイルに書き出す処理を書きます。

```bash:Dockerfile
FROM centos
MAINTAINER yuuki.miyo <メールアドレス>

# プロキシ設定
# ARG proxy='http://xxxx:8080'
# ARG noproxy='xxx,xxx'
# ENV HTTP_PROXY $proxy
# ENV HTTPS_PROXY $proxy
# ENV NO_PROXY $noproxy
# RUN echo "proxy=$proxy" >> /etc/yum.conf

# タイムゾーン変更
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# yum アップデートとパッケージのインストール
RUN yum update -y --disableplugin=fastestmirror && \
    yum install -y epel-release --disableplugin=fastestmirror && \
    yum install -y --disableplugin=fastestmirror sudo cronie supervisor

# ユーザの追加処理
RUN groupadd -g 1000 developer && \
    useradd  -g      developer -m -s /bin/bash dev-user && \
    echo 'dev-user    ALL=(ALL)    NOPASSWD:ALL' >> /etc/sudoers.d/dev-user

# PAMの設定
RUN sed -i -e '/pam_loginuid.so/s/^/#/' /etc/pam.d/crond

# cronの個別コマンドの設定ファイルを追加
ADD ./cron.d /etc/cron.d/
RUN chmod 0644 /etc/cron.d/*

# suvervisorの設定
RUN sed -i \
        -e 's/nodaemon=false/nodaemon=true/' \
        /etc/supervisord.conf
ADD ./supervisord /etc/supervisord.d/

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
# CMD ["/bin/bash"]
```


```bash:./cron.d/crontest
# crontest
SHELL = /bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
* * * * * dev-user /usr/bin/date >> /tmp/crontest.txt
```

```bash:./supervisord/cron.ini
[program:crond]
; 自分用の備忘録として、今回使用していないオプションについてもコメントアウトで記載しています。
; cronの起動時に、正常起動しているはずなのにリトライが走っていたので、無理やり止めています。
; このあたり、もっと良いやり方がありそうです。

; 実行コマンド
command = /sbin/crond

; プロセス名（numprocsが1以上の場合は%(process_num)sを使用して重複を避ける）
process_name=%(program_name)s

; 実行ユーザ
; user=root

; 起動時のカレントディレクトリ
; directory = /home/dev-user/

; 自動リスタート
; true:常に再起動,false:常に再起動しない,unexpected:終了コードがexitcodesのあるもの以外なら再起動
autorestart=false

; この値より早く終了し場合に異常終了として扱う（超えて終了した場合は正常終了）
startsecs = 0

; リスタートの試行回数
startretries=0

; この値（秒）を超えた場合、SIGKILLを送信
; stopwaitsecs = 3600

; 子プロセスまでkillする
; stopasgroup = false

; SIGKILLをプロセスグループ全体に送信
; killasgroup = true

; logに関する設定
; ローテートするログファイル容量
logfile_maxbytes=50MB

; ローテートで残す世代数
logfile_backup=10

; ログファイル
stdout_logfile=/var/log/supervisor/%(program_name)s.log
stderr_logfile=/var/log/supervisor/%(program_name)s-error.log
```

## 3. ホストのcronからコンテナ内の対象プログラムを実行する

この方法はコンテナ側での設定が不要なのでコンテナのセットアップがシンプルで済みます。
ホスト側のcronに次のような記述を入れてコンテナ内のプログラムを実行します。

ここでは前２つの例とは別に、自前で用意したPythonプログラムを定期的に実行する想定で書きます。
私はPyenv+Virtualenv環境を使用しているので、このあたりのお膳立てをするための起動用スクリプトから起動しています。cronから起動した際のログはホスト側の指定場所に保存されます。

```bash:(ホスト側の)crontab_-e
# 1分毎にdocker execを使用してコンテナ内のプログラムを実行
* * * * * docker exec -i dev-user /home/dev-user/cron-scripts/1minutes.sh >> /home/dev-user/log/cron-1minutes.log
```

```bash:/home/dev-user/cron-scripts/1minutes.sh（コンテナ内に用意された起動用スクリプト）
#!/usr/bin/sh

# crawlerを実行する場合などWebアクセスのためのプロキシ設定
# export HTTP_PROXY="http://xxx:8080"
# export HTTPS_PROXY="http://xxx:8080"
# export NO_PROXY="xxx,xxx"

# pyenv/virtualenvのための設定
# pyenvは~/.pyenvへインストールされている前提
# 素のPythonを利用する場合は不要
export PYENV_ROOT="/home/dev-user/.pyenv"
export PATH="$PYENV_ROOT/bin:/usr/local/bin:$PATH"

# このevalを行わないとpyenvで環境が変わらなかった（はず）
eval "cd /home/dev-user/projects/myproject"
cd /home/dev-user/projects/myproject/bin

# 処理を実行
# pythonプログラムは適当に用意してください。もちろん、1.や2.の例と同じdateの実行で試すこともできます。
/home/dev-user/.pyenv/versions/myenv/bin/python myscripts.py >> /home/dev-user/log/myscripts.log
```