Ansible + CloudFormation + docker-compose で作った Redmine のバックアップ


今までの話:

* Ansible で AWS にワンタッチで Redmine を作る: https://qiita.com/propella/items/7209dfda27f665766867
* Let's Encrypt と Nginx を使った Web アプリの HTTPS 化: https://qiita.com/propella/items/6a4b964fd010585530d2

Ansible や CloudFormation を使ってインフラ構成をコードで書くと、気楽にサーバを作ったり消したり出来て気持ちいよいです。寒いデータセンターに籠もってケーブルやシリアル端末と格闘した日々を比べると神の気分です。しかしうっかり大切なデータを消してしまわないよう定期的に S3 に保存する事にしました。¯¯¯

まず、S3 の作成と EC2 から書き込む権限を与える CloudFormation スタックの抜粋です。作った S3 の名前を EC2 の /home/ec2-user/backup-bucket.txt に書き込んで後でバックアップスクリプトに使います。AWS::S3::Bucket の BucketName で S3 の名前を指定するとこのような小技を使わなくて良いですが、なんとなく AWS に名前をつけてもらった方が応用が効くかなと思いこうしました。

一応バックアップを上書きしても履歴が残るよう S3 の VersioningConfiguration オプションを有効にしています。

```yaml
  Backup: # バックアップ用 S3 の作成
    Type: AWS::S3::Bucket
    Properties:
      VersioningConfiguration:
        Status: Enabled

  BackupRole: # EC2 に S3 に書き込める権限を与える設定(ロール)
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      
  BackupPolicy: # バックアップ用の S3 に書き込める権限を作成
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: redmine-bucket-policy
      Roles:
        - !Ref BackupRole # AWS::IAM::Policy はいわゆるインラインポリシーを作るので、Groups, Roles, Users のどれかが必須です。ここでちょっと悩みました。
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - s3:PutObject
              - s3:GetObject
              - s3:PutObjectAcl
              - s3:ListBucket
              - s3:DeleteObject
              - s3:GetBucketLocation
            Resource:
              - !GetAtt Backup.Arn
              - !Sub "${Backup.Arn}/*"

  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.nano
      ImageId: ami-02892a4ea9bfa2192
      KeyName: hogekey
      SecurityGroups:
      - Ref: WebServerSecurityGroup
      IamInstanceProfile: !Ref WebServerProfile # ここで作成したロールを設定する
      UserData: # インスタンス作成時に一度だけ実行される。S3 の名前を backup-bucket.txt に書き出す。
        Fn::Base64: !Sub |
          #!/bin/bash -xe
          echo -n "${Backup}" > /home/ec2-user/backup-bucket.txt
```

https://redmine.jp/faq/system_management/backup/ によると、redmine のバックアップは以下の二点だそうです。

* 添付ファイルは /usr/src/redmine/files を保存
* その他のデータは mysqldump を保存

そこで、バックアップを考慮した docker-compose.yml を作ります。

* ホストの /home/ec2-user/redmine-files を redmine にマウントして添付ファイルを置きます。
* mysql のパスワードはコンテナの環境変数 MYSQL_ROOT_PASSWORD で渡しています。

```yaml
  app:
    image: redmine
    container_name: redmine_app
    restart: always
    ports:
      - 3000:3000
    volumes:
      - /home/ec2-user/redmine-files:/usr/src/redmine/files
    environment:
      REDMINE_DB_MYSQL: db
      REDMINE_DB_PASSWORD: ${DBPASS}
      REDMINE_SECRET_KEY_BASE: supersecretkey
      REDMINE_DB_ENCODING: utf8mb4

  db:
    image: mysql:5.7
    container_name: redmine_db
    restart: always
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MYSQL_ROOT_PASSWORD: ${DBPASS}
      MYSQL_DATABASE: redmine
```

S3 の前を /home/ec2-user/backup-bucket.txt に書き込んであるので、あとは mysqldump と redmine-fils を書き込む backup.sh を書くだけです。mysql のパスワードをコマンドラインで渡してはいけないと警告が出ますね。後で考えます。バックアップは次のようにしました。

* mysqldump はそのまま gzip で圧縮して保存。
* /usr/src/redmine/files は aws s3 sync を使って同期。なので、新規ファイル以外は転送されないはず。

```shell
#!/bin/sh

set -o errexit
set -o pipefail
set -o nounset
set -o xtrace

DIR=/home/ec2-user

BACKUP_BUCKET=$(cat $DIR/backup-bucket.txt)

docker exec redmine_db /bin/bash -c 'mysqldump -uroot -p$MYSQL_ROOT_PASSWORD --default-character-set=utf8mb4 redmine' | gzip > $DIR/redmine-mysql.sql.gz

aws s3 cp $DIR/redmine-mysql.sql.gz s3://${BACKUP_BUCKET}/redmine-mysql.sql.gz
aws s3 sync $DIR/redmine-files s3://${BACKUP_BUCKET}/redmine-files --delete
```

リストア用のスクリプト restore.sh です。

ここでトリッキーなのは、添付ファイルを redmine が読み書きできるよう chown -R 999:999 する部分です。ダサいことに uid 決め打ちです。chown が必要なので sudo restore.sh のように実行する必要があります。Docker ドキュメントでは exec を使ってコンテナの中で解凍するのをお勧めしています。mysqldump の方はお勧めに従ったのですが、files の方は redmine の中に aws コマンドが無く面倒なので root ユーザで書き込んで chwon する力技にしてしまいました。

```shell
#!/bin/sh

set -o errexit
set -o pipefail
set -o nounset
set -o xtrace

BACKUP_BUCKET=$(cat /home/ec2-user/backup-bucket.txt)

# Restore files
aws s3 sync s3://${BACKUP_BUCKET}/redmine-files /home/ec2-user/redmine-files --delete
chown -R 999:999 /home/ec2-user/redmine-files

# Restore SQL
aws s3 cp s3://${BACKUP_BUCKET}/redmine-mysql.sql.gz  redmine-restore.sql.gz
zcat redmine-restore.sql.gz | docker exec -i redmine_db /bin/bash -c 'mysql -uroot -p$MYSQL_ROOT_PASSWORD redmine'
```

あとは backup.sh を cron に仕込めば完成です。Ansible ではこんな感じです。

```yaml
  - name: Schedule backup
    cron:
      name: Backup
      hour: "18"
      minute: "37"
      job: "/home/ec2-user/backup.sh"
```

## 失敗談

と簡単そうに書きましたが大量の失敗談があります。

### バックアップ方式の選定

迷った末です。

* 案1: 添付ファイルのバックアップ用 S3 を redmine の /usr/src/redmine/files としてマウント。
    * 無限の容量が使えて嬉しいが遅いので辞めた。
* 案2: redmine コンテナ内で files と mysqldump を S3 に送る。
    * マウントの必要無くスッキリしているがコンテナ内に awscli も mysql コマンドも無いので辞めた。
* 案3: ホストに files をマウント、docker exec で mysqldump を実行して S3 に送る。
    * これを採用しました。

### 定期実行方式の選定

今どきの linux は systemd を使うのが正義だと思って cron の代わりに systemd timer を試みましたが止めました。一応やり方を書いておきます。

/etc/systemd/system/backup.service を作成

```ini
[Unit]
Description=Backup Redmine

[Service]
Type=simple
ExecStart=/home/ec2-user/backup.sh
User=ec2-user
Group=ec2-user
```

/etc/systemd/system/backup.timer を作成

```ini
[Unit]
Description=Backup Timer

[Timer]
OnUnitActiveSec=24hours

[Install]
WantedBy=timers.target
```

backup.service と backup.timer を両方有効化して開始

```shell
sudo systemctl enable backup
sudo systemctl start backup
sudo systemctl enable backup.timer
sudo systemctl start backup.timer
```

設定の確認とログを見る

```shell
systemctl list-timers
journalctl
```

system timer はこのように一つのジョブを設定するのに .service と .timer という二つの設定ファイルが必要です。

systemd timer の最大の問題は、タイマーを設定する時にコマンド (この場合 backup.sh) が走ってしまう事です。例えばバックアップスクリプトの場合、インスタンス起動時に backup.sh が動くと、せっかく残しておいたバックアップを上書きしてしまうので困るのです。こんな仕様のわけが無いと思って色々試しましたが、どう頑張っても `systemctl start backup.timer` の前に `systemctl start backup` が必要でした(お恥ずかしい話ですが、誰かが気づいて教えてくれる事を期待して書いている)。

結局労力の割に合わないので伝統的な cron を使う事にしました。