読者です 読者をやめる 読者になる 読者になる

devひよこのあしあと

いつでもひよっこな気持ちで学びと挑戦を

Amazon LinuxでLet's Encrypt

Amazon LinuxLet's Encryptを使ってSSLサーバー証明書を取得してnginxに設定する方法を紹介します。

TL;DR(とりあえず結論)

コマンド一発でちょー簡単に証明書発行できてしまいます。ただし、Amazon Linux上ではLet's Encryptクライアントアプリがまだ実験段階のようです。とりあえず正常に発行できましたが、ご利用は自己責任でお願いいたします。

システム構成

せっかくなんでリリースされたばかりのAmazon Linux AMI 2016.03を利用してみます。

EC2のセットアップ

以下、Ansibleを使ってEC2インスタンスとnginxをセットアップしていきますが、そのへんはあまり本質ではないのでびゃーっと記事中段あたりまで読み飛ばして頂いても構いません。

事前作業

Ansibleが利用するAWSのアクセスキー&シークレットキーなどplaybook内には直書きしたくない情報を環境変数としてセットしておきます。 私の場合は普段direnvを利用しているので、ローカルの.envrcに登録して環境変数に反映されるようにしました。

$ cd REPO_ROOT
$ cat .envrc
export AWS_ACCESS_KEY_ID=XXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXXXXXX
export AWS_REGION=ap-northeast-1

$ direnv allow

インベントリ

AWSリソースを用いますのでDynamic Inventoryを利用します。Dynamic Inventoryの解説は下記サイトなどを参照ください。

ec2.iniとec2.pyを取得します。

$ cd REPO_ROOT/provision/inventories
$ wget https://raw.githubusercontent.com/ansible/ansible/devel/contrib/inventory/ec2.ini
$ wget https://raw.githubusercontent.com/ansible/ansible/devel/contrib/inventory/ec2.py
$ chmod u+x ec2.py

site.yml

AWSリソースのセットアップタスクで、elasticacheロールとec2ロールを呼び出します。

# PLAYBOOK_ROOT/site.yml

---

- name: AWSリソースのセットアップ
  hosts: localhost
  connection: local
  roles:
    - ec2
  vars:
    service_name: nslides01
    group_name: application
    region: ap-northeast-1

- name: アプリケーションサーバーのセットアップ
  hosts: tag_Name_application
  remote_user: ec2-user
  become: yes
  roles:
    - nginx
  vars:
    private_key: ~/.ssh/nslides01.pem

ec2ロール

ec2ロールではセキュリティグループとEC2インスタンスを作成します。 EC2用セキュリティグループではSSH接続とHTTP/HTTPS接続のみを許可しておきます。

# PLAYBOOK_ROOT/roles/ec2/tasks/main.yml

---

- name: EC2用セキュリティグループを作成
  ec2_group:
    name: "{{ group_name }}"
    description: "{{ group_name }}"
    region: ap-northeast-1
    rules:
      - proto: tcp
        from_port: 22
        to_port:   22
        cidr_ip:   0.0.0.0/0
      - proto: tcp
        from_port: 80
        to_port:   80
        cidr_ip:   0.0.0.0/0
      - proto: tcp
        from_port: 443
        to_port:   443
        cidr_ip:   0.0.0.0/0
    rules_egress:
      - proto: all
        from_port: 0
        to_port:   65535
        cidr_ip:   0.0.0.0/0

- name: EC2インスタンスを作成
  ec2:
    image:         "{{ ami_image }}"
    instance_type: "{{ instance_type }}"
    region:        "{{ region }}"
    key_name:      "{{ service_name }}"
    group:         "{{ group_name }}"
    instance_tags:
      Name:        "{{ group_name }}"
    exact_count:   1
    count_tag:
      Name:        "{{ group_name }}"
    wait:          yes
    wait_timeout:  300
    volumes:
      - device_name: "{{ device_name }}"
        volume_type: "{{ volume_type }}"
        volume_size: "{{ volume_size }}"
        delete_on_termination: yes
    instance_profile_name: ap_servers
  register: ec2


- name: SSHで接続できるようになるまで待機
  wait_for: port=22 host="{{ item.public_ip }}" timeout=300 state=started
  with_items: ec2.instances

- name: 作成したEC2インスタンスをインベントリに追加
  add_host: hostname="{{ item.public_ip }}" groupname="tag_Name_{{ group_name }}"
  with_items: ec2.instances

上記で参照している変数にはAWSの無料枠で収まるように最低限のインスタンスサイズを下記のように指定しています。(節約♪節約♪)

# PLAYBOOK_ROOT/roles/ec2/defaults/main.yml

---

ami_image: ami-f80e0596  # ← Amazon Linux AMI 2016.03.0
instance_type: t2.micro
device_name: /dev/xvda
volume_type: gp2
volume_size: 30

nginxのセットアップ

# PLAYBOOK_ROOT/roles/nginx/tasks/main.yml

---

- yum: name={{item}} state=latest
  with_items:
    - nginx
    - git

- name: change log_dir permission
  become: yes
  file:
    path: /var/log/nginx
    state: directory
    owner: nginx
    group: nginx
    mode: 0755

- name: service登録
  service:
    name: nginx
    state: started
    enabled: yes

ここまでで、EC2インスタンスを立ち上げてnginxをHTTPのみで起動した状態となりました。

Let's Encrypt

ここからはLet's EncryptをセットアップしてSSLサーバー証明書を発行してみます。

クライアントアプリのインストール

Let's Encryptクライアントアプリをインストールします。今回は/opt以下に配置するようにしました。

Gitリポジトリをクローンします。

$ cd /opt
$ sudo git clone https://github.com/letsencrypt/letsencrypt
Cloning into 'letsencrypt'...
remote: Counting objects: 33135, done.
remote: Compressing objects: 100% (82/82), done.
remote: Total 33135 (delta 48), reused 0 (delta 0), pack-reused 33053
Receiving objects: 100% (33135/33135), 8.74 MiB | 4.66 MiB/s, done.
Resolving deltas: 100% (23496/23496), done.
Checking connectivity... done.

クライアントアプリを実行してみます。

$ ./letsencrypt-auto --help
WARNING: Amazon Linux support is very experimental at present...
if you would like to work on improving it, please ensure you have backups
and then run this script again with the --debug flag!

おや? なんかAmazon Linuxは実験的なサポートしかしてないよって警告でましたね...

--debugオプションをつけると先に進めるようなので、自己責任の元、すすめてみましょう。

$ ./letsencrypt-auto --help --debug
Bootstrapping dependencies via Amazon Linux...
yum は /usr/bin/yum です
読み込んだプラグイン:priorities, update-motd, upgrade-helper
パッケージ python-tools は利用できません。
依存性の解決をしています
--> トランザクションの確認を実行しています。
   (中略)

上記のメッセージがでたあと、ずらずらーっと依存パッケージのインストールが実行されます。インストールされるパッケージは下記とそれらが依存しているパッケージのようです。

  • python26
  • python26-devel
  • python26-pip
  • python26-virtualenv
  • augeas-libs
  • dialog
  • gcc
  • libffi-devel
  • openssl-devel
  • system-rpm-config

上記のインストールが完了したところで画面には下記のようなメッセージが出力されました。

  (中略)
Checking for new version...
Creating virtual environment...
Installing Python packages...
Installation succeeded.
Requesting root privileges to run letsencrypt...
   sudo /home/ec2-user/.local/share/letsencrypt/bin/letsencrypt --help --debug

  letsencrypt-auto [SUBCOMMAND] [options] [-d domain] [-d domain] ...

The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates.  By
default, it will attempt to use a webserver both for obtaining and installing
the cert. Major SUBCOMMANDS are:

  (default) run        Obtain & install a cert in your current webserver
  certonly             Obtain cert, but do not install it (aka "auth")
  install              Install a previously obtained cert in a server
  renew                Renew previously obtained certs that are near expiry
  revoke               Revoke a previously obtained certificate
  rollback             Rollback server configuration changes made during install
  config_changes       Show changes made to server config during installation
  plugins              Display information about installed plugins

Choice of server plugins for obtaining and installing cert:

  --apache          Use the Apache plugin for authentication & installation
  --standalone      Run a standalone webserver for authentication
  (nginx support is experimental, buggy, and not installed by default)
  --webroot         Place files in a server's webroot folder for authentication

OR use different plugins to obtain (authenticate) the cert and then install it:

  --authenticator standalone --installer apache

More detailed help:

  -h, --help [topic]    print this message, or detailed help on a topic;
                        the available topics are:

   all, automation, paths, security, testing, or any of the subcommands or
   plugins (certonly, install, nginx, apache, standalone, webroot, etc)

とりあえず実行準備は整ったようです。

SSLサーバー証明書の発行

下記のコマンドを実行します。

$ ./letsencrypt-auto certonly --webroot -w /usr/share/nginx/html -d nslides.devchick.link --agree-tos -m xxx@example.com

オプションの意味はそれぞれ下記のとおりです。

  • --webroot : HTTPサーバーが既に稼働している場合に指定します。今回はnginxが既に稼働している状態なのでこのオプションを利用します。HTTPサーバーが稼働していないマシンの場合は--standaloneオプションを指定すれば、Let's EncryptクライアントアプリがHTTPサーバーを一時的に立ち上げてくれるようです。
  • -w : HTTPサーバーのドキュメントルートのパスを指定します。このパスの下に.well-known/acme-challenge/xxxxxxxxxxxxxxxxxxxxというようなファイルを一時的に作成しLet's Encryptサーバー側から読み取ってもらうことでドメイン保有者であることを証明しているようです。認証完了後にはこのファイルは削除されます。
  • -d : 取得したいSSLサーバー証明書ドメインを指定します。
  • --agree-tos : Let's Encrypt の利用規約に同意します。事前に利用規約に目を通しておきましょう。https://letsencrypt.org/repository/
  • -m : 自分の連絡先メールアドレスを指定します。緊急の通知や鍵を紛失したときの復旧に使われます。
$ ./letsencrypt-auto certonly --webroot -w /usr/share/nginx/html -d nslides.devchick.link --agree-tos -m xxx@example.com
Checking for new version...
Requesting root privileges to run letsencrypt...
   sudo /home/ec2-user/.local/share/letsencrypt/bin/letsencrypt certonly --webroot -w /usr/share/nginx/html -d nslides.devchick.link --agree-tos -m xxx@example.com
Version: 1.1-20080819
Version: 1.1-20080819

IMPORTANT NOTES:
 - If you lose your account credentials, you can recover through
   e-mails sent to xxx@example.com.
 - Congratulations! Your certificate and chain have been saved at
   /etc/letsencrypt/live/nslides.devchick.link/fullchain.pem. Your cert  # ← ここ!
   will expire on 2016-06-21. To obtain a new version of the
   certificate in the future, simply run Let's Encrypt again.
 - Your account credentials have been saved in your Let's Encrypt
   configuration directory at /etc/letsencrypt. You should make a
   secure backup of this folder now. This configuration directory will
   also contain certificates and private keys obtained by Let's
   Encrypt so making regular backups of this folder is ideal.
 - If you like Let's Encrypt, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

実行すると上記のようなメッセージが出力されます。「ここ!」と記載した箇所に発行されたSSLサーバー証明書が保存されました。 フォルダの中を確認してみると下記のようにシンボリックリンクが張られた状態になります。

$ sudo ls -al /etc/letsencrypt/live/nslides.devchick.link
合計 8
drwxr-xr-x 2 root root 4096  3月 23 13:35 .
drwx------ 3 root root 4096  3月 23 13:35 ..
lrwxrwxrwx 1 root root   41  3月 23 13:35 cert.pem -> ../../archive/nslides.devchick.link/cert1.pem
lrwxrwxrwx 1 root root   42  3月 23 13:35 chain.pem -> ../../archive/nslides.devchick.link/chain1.pem
lrwxrwxrwx 1 root root   46  3月 23 13:35 fullchain.pem -> ../../archive/nslides.devchick.link/fullchain1.pem
lrwxrwxrwx 1 root root   44  3月 23 13:35 privkey.pem -> ../../archive/nslides.devchick.link/privkey1.pem

シンボリックリンク先のcert1.pem1の部分は証明書を発行した回数が連番で記載されます。今後再発行をしていくとカウントアップされていきます。

ちなみに4つのファイルはそれぞれ下記を意味しています。

  • cert.pem : 今回発行したSSLサーバー証明書単体 (Subject: CN=nslides.devchick.link)
  • chain.pem : 上記のSSLサーバー証明書を発行した認証局の証明書 (Subject: C=US, O=Let's Encrypt, CN=Let's Encrypt Authority X1)
  • fullchain.pem : 上記の2つの証明書を1つにまとめたもの
  • privkey.pem : cert.pemに対応する秘密鍵

nginxのセットアップ その2

nginxの設定を修正してHTTPSで接続できるようにします。デフォルトの設定ファイルの中にサンプルがあるので、コメントアウトを解除します。

# /etc/nginx/nginx.conf

    server {
        listen       443 ssl;
        listen       [::]:443 ssl;
        server_name  localhost;
        root         /usr/share/nginx/html;

        ssl_certificate     "/etc/letsencrypt/live/nslides.devchick.link/fullchain.pem";  # ← ここ変更
        ssl_certificate_key "/etc/letsencrypt/live/nslides.devchick.link/privkey.pem";    # ← ここ変更
        # It is *strongly* recommended to generate unique DH parameters
        # Generate them with: openssl dhparam -out /etc/pki/nginx/dhparams.pem 2048
        #ssl_dhparam "/etc/pki/nginx/dhparams.pem";
        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout  10m;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers HIGH:SEED:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!RSAPSK:!aDH:!aECDH:!EDH-DSS-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA:!SRP;
        ssl_prefer_server_ciphers on;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }

「ここ変更」とマークした箇所を修正して先ほど発行されたSSLサーバー証明書のfullchainと秘密鍵を指定します。

動作確認

Webブラウザでサイトにアクセスします。

f:id:devchick:20160324000026p:plain

やった! 無事にSSLサーバー証明書が認識されました。

更新処理

Let's Encryptで発行される証明書は有効期限が90日間と非常に短いものとなっています。なので、定期的に更新してあげないといけません。

cronで1ヶ月に1回更新させるように、/etc/cron.monthlyディレクトリ以下にスクリプトをひとつセットしておきます。

$ sudo vi /etc/cron.monthly/letsencrypt
/opt/letsencrypt/letsencrypt-auto renew --webroot -w /usr/share/nginx/html --force-renew --debug
/sbin/service nginx reload

$ sudo chmod a+x /etc/cron.monthly/letsencrypt

--debugオプションを付けていないとまたAmazon Linuxは実験的なサポートしかしてないよって警告がでて処理失敗してしまうので付けています。

おわりに

非常に簡単に、かつ無料でSSLサーバー証明書を発行&設定できてしまいました。

HTTP/2の恩恵を受けるためにはHTTPS通信が必要ですが、Let's Encryptのおかげでいろいろなサイトで利用できるようになりそうです。

参考

ズンドコキヨシ with post-commit

git Ruby

gitでコミットする度にコミットハッシュ値でズンドコ判定します。

$ echo "zundoko" >> test.txt ; git add test.txt ; git commit -m "zundoko"
ズンドコズンドコズンドコドコドコズンドコズンドコズンドコズンドコズンズンドコズンズンドコドコドコドコドコズンドコドコズンドコズンズンズンドコズンズンズンズンドコズン
[master (root-commit) 0d6383d] zundoko
 1 file changed, 1 insertion(+)
 create mode 100644 test.txt

$ echo "zundoko" >> test.txt ; git add test.txt ; git commit -m "zundoko"
ドコズンズンドコドコドコドコドコドコズンドコドコドコドコズンズンズンズンズンドコキ・ヨ・シ!
[master 746bf11] zundoko
 1 file changed, 1 insertion(+)

実装

.git/hooks/post-commitに下記を登録

git rev-parse HEAD | ruby -ne 'puts $_.split(//).map { |c| ["ズン", "ドコ"][c.hex % 2] }.join.sub(/(.*(?:ズン){5}ドコ).*/) {"#{$1}キ・ヨ・シ!"}'

コミットハッシュ値を一文字ずつ数値化して偶奇をズンorドコにマッピングし、例のフレーズが出てきたら「キ・ヨ・シ!」を出力して終了するようにしています。

元ネタ

まとめ

qiita.com

ActionCable関係記事まとめ

ActionCable Rails Ruby

Rails5の新機能であるActionCableを扱った記事・資料をまとめです。随時更新していきます。

f:id:devchick:20160317215605j:plain

photo by Nic McPhee | Flickr - Photo Sharing!

リリースノート

チュートリアル

これからActionCableを使ったアプリケーションを作りたい方はまずはチュートリアルを参照してみましょう。

アプリケーション事例

ActionCableを利用・応用したアプリケーション事例です。

ハック / 解説 / やってみた

インフラ構築 / デプロイ / 運用

ハマりどころ / 失敗談

だいたい皆さんallowed_request_originsにはハマるようですね。(もちろん私もハマりました...)

そもそもWebSocketとは

AnsibleでElastiCacheをセットアップしてRails5から利用する

ActionCable AWS Rails Ansible ElastiCache Sidekiq Capistrano

下記の記事で紹介したスライド共有システム「nslides」ではAnsibleを用いてインフラ周りの設定をしています。今回はその中からElastiCacheでredisを立ち上げてRails5から利用する設定をしている部分を紹介します。

devchick.hatenablog.com

nslidesはこちらで公開中です。 公開終了しました (2017/02/27)

システム構成:

コードはGitHubにて公開しています。

https://github.com/devchick/nslides

事前作業

Ansibleが利用するAWSのアクセスキー&シークレットキーなどplaybook内には直書きしたくない情報を環境変数としてセットしておきます。 私の場合は普段direnvを利用しているので、ローカルの.envrcに登録して環境変数に反映されるようにしました。

$ cd REPO_ROOT
$ cat .envrc
export AWS_ACCESS_KEY_ID=XXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXXXXXX
export AWS_REGION=ap-northeast-1
export DB_USERNAME=XXXXXX
export DB_PASSWORD=XXXXXX
export APP_SERVER_IP=XXXXXXXX
export SECRET_KEY_BASE=XXXXXXXXXXXXXXXXXXXXXXXX

$ direnv allow

playbook

インベントリ

AWSリソースを用いますのでDynamic Inventoryを利用します。Dynamic Inventoryの解説は下記サイトなどを参照ください。

ec2.iniとec2.pyを取得します。

$ cd REPO_ROOT/provision/inventories
$ wget https://raw.githubusercontent.com/ansible/ansible/devel/contrib/inventory/ec2.ini
$ wget https://raw.githubusercontent.com/ansible/ansible/devel/contrib/inventory/ec2.py
$ chmod u+x ec2.py

site.yml

AWSリソースのセットアップタスクで、elasticacheロールとec2ロールを呼び出します。

# PLAYBOOK_ROOT/site.yml

---

- name: AWSリソースのセットアップ
  hosts: localhost
  connection: local
  roles:
    - ec2
    - elasticache
  vars:
    service_name: nslides01
    group_name: application
    region: ap-northeast-1

- name: アプリケーションサーバーのセットアップ
  hosts: tag_Name_application
  remote_user: ec2-user
  become: yes
  roles:
    - sudo
    - nginx
    - mysql
    - ruby
    - panel_base
  vars:
    db_host: "{{ groups.rds[0] }}"
    db_username: "{{ lookup('env', 'DB_USERNAME') }}"
    db_password: "{{ lookup('env', 'DB_PASSWORD') }}"
    redis_host: "{{ groups.elasticache_redis[0] }}"
    private_key: ~/.ssh/nslides01.pem

今回はElastiCacheの設定の話なので直接は関係ないですが、DBへ接続するためのユーザー名やパスワードは先述の.envrcで設定した環境変数値を読み込むようにしています。

ec2ロール

ec2ロールではセキュリティグループとEC2インスタンスを作成します。 EC2用セキュリティグループではSSH接続とHTTP/HTTPS接続のみを許可しておきます。

# PLAYBOOK_ROOT/roles/ec2/tasks/main.yml

---

- name: EC2用セキュリティグループを作成
  ec2_group:
    name: "{{ group_name }}"
    description: "{{ group_name }}"
    region: ap-northeast-1
    rules:
      - proto: tcp
        from_port: 22
        to_port:   22
        cidr_ip:   0.0.0.0/0
      - proto: tcp
        from_port: 80
        to_port:   80
        cidr_ip:   0.0.0.0/0
      - proto: tcp
        from_port: 443
        to_port:   443
        cidr_ip:   0.0.0.0/0
    rules_egress:
      - proto: all
        from_port: 0
        to_port:   65535
        cidr_ip:   0.0.0.0/0

- name: EC2インスタンスを作成
  ec2:
    image:         "{{ ami_image }}"
    instance_type: "{{ instance_type }}"
    region:        "{{ region }}"
    key_name:      "{{ service_name }}"
    group:         "{{ group_name }}"
    instance_tags:
      Name:        "{{ group_name }}"
    exact_count:   1
    count_tag:
      Name:        "{{ group_name }}"
    wait:          yes
    wait_timeout:  300
    volumes:
      - device_name: "{{ device_name }}"
        volume_type: "{{ volume_type }}"
        volume_size: "{{ volume_size }}"
        delete_on_termination: yes
    instance_profile_name: ap_servers
  register: ec2


- name: SSHで接続できるようになるまで待機
  wait_for: port=22 host="{{ item.public_ip }}" timeout=300 state=started
  with_items: ec2.instances

- name: 作成したEC2インスタンスをインベントリに追加
  add_host: hostname="{{ item.public_ip }}" groupname="tag_Name_{{ group_name }}"
  with_items: ec2.instances

上記で参照している変数にはAWSの無料枠で収まるように最低限のインスタンスサイズを下記のように指定しています。(節約♪節約♪)

# PLAYBOOK_ROOT/roles/ec2/defaults/main.yml

---

ami_image: ami-383c1956
instance_type: t2.micro
device_name: /dev/xvda
volume_type: gp2
volume_size: 30

elasticacheロール

elasticacheロールでは、セキュリティグループとElastiCacheを作成します。 先ほど作成したEC2用のセキュリティグループを適用してあるインスタンスからのみRedisへの接続を許可するように設定します。

# PALYBOOK_ROOT/roles/elasticache/tasks/main.yml

---

- name: ElastiCache用セキュリティグループを作成
  ec2_group:
    name: redis
    description: redis
    region: "{{ region }}"
    rules:
      - proto: tcp
        from_port: 6379
        to_port:   6379
        group_name: "{{ group_name }}"  # ← EC2用セキュリティグループ名を指定
    rules_egress:
      - proto: all
        from_port: 0
        to_port:   65535
        cidr_ip:   0.0.0.0/0
  register: sg

- name: AWS ElastiCacheのセットアップ
  elasticache:
    name: "{{ service_name }}"
    state: present
    engine: redis
    cache_engine_version: 2.8.24
    node_type: cache.t2.micro
    num_nodes: 1
    cache_port: 6379
    cache_security_groups: []   # ☆
    security_group_ids:         # ☆
      - "{{ sg.group_id }}"     # ☆
    region: "{{ region }}"
    zone: ap-northeast-1a
  register: result

- name: 作成したElastiCacheのエンドポイント
  debug: msg="The new ElastiCache endpoint is {{ result.elasticache.data.CacheNodes[0].Endpoint }}"

ハマったのは上記の☆の部分。cache_security_groupsVPCがない頃?の古いインスタンスたちのときに利用するオプションのようですが、VPC内に作成するからといって無視してはいけなくて、空配列を明示的に指定しておかなければならないようです。

A list of cache security group names to associate with this cache cluster. Must be an empty list if inside a vpc

security_group_idsの方はVPC内のセキュリティグループを指定するのですが、なぜかここはセキュリティグループ名だと抽出に失敗してしまってエラーになりました。なのでセキュリティグループIDを指定するために先に作成したセキュリティグループの結果をregister: sgしておき、そのデータを利用しています。

Ansible 1.9を使ってるからかなー? 2.0で直ってたらいいな。

Railsアプリデプロイ先を構成

nslidesシステムではRailsアプリのデプロイにはCapistrano3を使っています。Capistrano3用にデプロイ先のフォルダやデプロイ先固有の設定を構築するようにAnsibleを設定します。

# PALYBOOK_ROOT/roles/panel_base/tasks/main.yml

---

- name: アプリケーションディレクトリ作成
  file:
    path: "{{ item }}"
    state: directory
    owner: "{{ remote_user }}"
    group: rbenv
    mode: 0775
  with_items:
    - /app/nslides/panel/shared/config
    - /app/nslides/panel/shared/config/initializers
    - /app/nslides/panel/shared/tmp/pids
    - /app/nslides/panel/shared/tmp/sockets
    - /app/nslides/panel/shared/tmp/cache
    - /app/nslides/panel/shared/tmp/sessions
    - /app/nslides/panel/shared/log
    - /app/nslides/panel/shared/vendor/bundle

- name: 環境設定
  become: yes
  template:
    src:  "{{ item.src }}"
    dest: "{{ item.dst }}"
    mode: 0644
  with_items:
    - { src: panel_envs.sh.j2, dst: /etc/profile.d/panel_envs.sh }

- name: 設定ファイル作成
  template:
    src: "{{ item.src }}"
    dest: "/app/nslides/panel/shared/{{ item.dst }}"
    owner: "{{ remote_user }}"
    group: rbenv
    mode: 0660
  with_items:
    - { src: database.yml.j2, dst: config/database.yml            }
    - { src: secrets.yml.j2,  dst: config/secrets.yml             }
    - { src: cable.yml.j2,    dst: config/cable.yml               }
    - { src: sidekiq.rb.j2,   dst: config/initializers/sidekiq.rb }

- name: Bitbucketアクセス用SSH鍵の配置
  copy:
    src: "{{ private_key }}"
    dest: "/home/ec2-user/.ssh/id_rsa"
    owner: "{{ remote_user }}"
    group: "{{ remote_user }}"
    mode: 0400

設定ファイルはAnsibleのテンプレート機能を使います。

例えばshared/config/cable.ymlの場合は下記のような内容になります。

# Action Cable uses Redis by default to administer connections, channels, and sending/receiving messages over the WebSocket.
production:
  adapter: redis
  url: "redis://{{ redis_host }}:6379"

development:
  adapter: async

test:
  adapter: async

shared/config/initializers/sidekiq.rbの場合は下記の内容になります。

Sidekiq.configure_server do |config|
  case Rails.env
    when 'production' then
      config.redis = { url: "redis://{{ redis_host }}:6379", namespace: 'sidekiq' }
    when 'staging' then
      config.redis = { url: "redis://{{ redis_host }}:6379", namespace: 'sidekiq' }
    else
      config.redis = { url: 'redis://127.0.0.1:6379', namespace: 'sidekiq' }
  end
end

Sidekiq.configure_client do |config|
  case Rails.env
    when 'production' then
      config.redis = { url: "redis://{{ redis_host }}:6379", namespace: 'sidekiq' }
    when 'staging' then
      config.redis = { url: "redis://{{ redis_host }}:6379", namespace: 'sidekiq' }
    else
      config.redis = { url: 'redis://127.0.0.1:6379', namespace: 'sidekiq' }
  end
end

Capistrano

RailsアプリをデプロイするCapistrano3の設定を下記のようにします。(関係ありそうな部分のみピックアップしてます)

# config valid only for current version of Capistrano
lock '3.4.0'

set :application, 'panel'
set :repo_url, 'git@bitbucket.org:tasaki-i3/nslides.git'

# Default deploy_to directory is /var/www/my_app_name
set :deploy_to, '/app/nslides/panel'

# Default value for :linked_files is []
set :linked_files, fetch(:linked_files, []).push('config/database.yml', 'config/secrets.yml', 'config/cable.yml', 'config/initializers/sidekiq.rb')

# Default value for linked_dirs is []
set :linked_dirs, fetch(:linked_dirs, []).push('log', 'tmp/pids', 'tmp/cache', 'tmp/sockets', 'tmp/sessions', 'vendor/bundle', 'public/system')

# capistrano/git/sub_directory_strategy
set :git_strategy, Capistrano::Git::SubDirectoryStrategy
set :project_root, 'panel'

Ansibleの方で/app/nslides/panel/sharedの下にファイルやフォルダをセットしてありますので、それらをリンクするようにしています。 なお、リポジトリ内のサブディレクトリにRAILS_ROOTがあるため、git_strategyを変更してます。

参考: capistrano 3 でサブディレクトリをデプロイするやつ · GitHub

実行

さて、準備ができたところでAnsibleを実行してインフラを整備します。

$ cd REPO_ROOT
$ ansible-playbook -i provision/inventories/ec2.py provision/site.yml --private-key=~/.ssh/nslides01.pem

PLAY [AWSリソースのセットアップ] *********************************************************

GATHERING FACTS ***************************************************************
ok: [localhost]

TASK: [elasticache | ElastiCache用セキュリティグループを作成] ***
changed: [localhost] => {"changed": false, "group_id": "sg-bdcf08d9"}

TASK: [elasticache | AWS ElastiCacheのセットアップ] ********************
changed: [localhost] => {"changed": false, "elasticache": {"data": {"AutoMinorVersionUpgrade": true, "CacheClusterCreateTime": 1456835365.451, "CacheClusterId": "nslides01", "CacheClusterStatus": "available", "CacheNodeType": "cache.t2.micro", "CacheNodes": [{"CacheNodeCreateTime": 1456835365.451, "CacheNodeId": "0001", "CacheNodeStatus": "available", "Endpoint": {"Address": "nslides01.xxxxxx.0001.apne1.cache.amazonaws.com", "Port": 6379}, "ParameterGroupStatus": "in-sync", "SourceCacheNodeId": null}], "CacheParameterGroup": {"CacheNodeIdsToReboot": [], "CacheParameterGroupName": "default.redis2.8", "ParameterApplyStatus": "in-sync"}, "CacheSecurityGroups": [], "CacheSubnetGroupName": "default", "ClientDownloadLandingPage": "https://console.aws.amazon.com/elasticache/home#client-download:", "ConfigurationEndpoint": null, "Engine": "redis", "EngineVersion": "2.8.24", "NotificationConfiguration": null, "NumCacheNodes": 1, "PendingModifiedValues": {"CacheNodeIdsToRemove": null, "EngineVersion": null, "NumCacheNodes": null}, "PreferredAvailabilityZone": "ap-northeast-1a", "PreferredMaintenanceWindow": "thu:17:00-thu:18:00", "ReplicationGroupId": null, "SecurityGroups": [{"SecurityGroupId": "sg-bdcf08d9", "Status": "active"}]}, "name": "nslides01", "status": "available"}}

TASK: [elasticache | 作成したElastiCacheのエンドポイント] *********
changed: [localhost] => {
    "msg": "The new ElastiCache endpoint is {'Port': 6379, 'Address': 'nslides01.xxxxx.0001.apne1.cache.amazonaws.com'}"
}

...

上記のようなのがずらずらーっと出力されて実行が完了します。(一発でうまくはいきませんけどね (^^;

続いてRailsアプリのデプロイです。

$ cd REPO_ROOT/panel
$ bundle exec cap production deploy
INFO [91dc5ead] Running /usr/bin/env mkdir -p /tmp/panel/ as ec2-user@xx.xx.xx.xx
INFO [91dc5ead] Finished in 0.099 seconds with exit status 0 (successful).
INFO Uploading /tmp/panel/git-ssh.sh 100.0%
INFO [ba2628eb] Running /usr/bin/env chmod +x /tmp/panel/git-ssh.sh as ec2-user@xx.xx.xx.xx
INFO [ba2628eb] Finished in 0.100 seconds with exit status 0 (successful).
INFO [0a23f6b1] Running /usr/bin/env mkdir -p /tmp/panel/ as ec2-user@xx.xx.xx.xx
INFO [0a23f6b1] Finished in 0.099 seconds with exit status 0 (successful).
INFO Uploading /tmp/panel/git-ssh.sh 100.0%
INFO [ecc80eb7] Running /usr/bin/env chmod +x /tmp/panel/git-ssh.sh as ec2-user@xx.xx.xx.xx
INFO [ecc80eb7] Finished in 0.100 seconds with exit status 0 (successful).
Please enter branch (release):

...

上記のようなのがずらずらーっと出力されて実行完了です。

おわりに

AnsibleでElastiCacheをセットアップしてRails5のActionCableやSidekiqから利用するための設定を紹介しました。

Ansibleでばばーっとインフラが立ち上がっていくのは爽快感がありますね。誰かが「ピタゴラスイッチ感」って名づけてましたがそれを実感できました。

ただ、なかなか一発で全部が動くことはなく試行錯誤の上でやっとここまでたどり着いた感じです。なので、既に本番稼働しているシステムに対してplaybookを修正して再度実行するときはかなーりドキドキしますね。ステージング環境用意した方がいいかなぁ。あー、でもAWS無料枠超えてしまうな。うーむ。。。

初心者なのでAPI Gateway+LambdaなSlack Botで円の面積を求めてみた

AWS Lambda Serverless Node.js Slack

整数で値をひとつ読み込み、それを半径とする円の面積を求めて表示するプログラムを作成しなさい。 円周率は3.14とし、計算結果は、小数第2位を四捨五入して小数第一位まで表示すること。

知恵袋方面に上記のプログラムをJavaScriptで書きたい人がいるという噂を聞きました。というわけで、AWSAPI GatewayとLambdaを利用して書いてみましょう。API GatewayもLambdaもNode.jsも初心者ですが、がんばります!

元ネタ:

qiita.com

Slack Bot「parvati」の作成

まずはSlack Bot本体を作成します。AWS Lambdaと連携したSlack Botを作るnpmモジュールが公開されていたのでこれを利用してみます。

qiita.com

GitHub - mumoshu/lambda_bot

まずはnpm初期化と必要なnpmモジュールのインストールをします。

$ npm init -y
Wrote to /Users/d-tasaki/dev/parvati/package.json:

$ npm install --save lambda_bot node-env-file bluebird
npm WARN package.json parvati@1.0.0 No description
npm WARN package.json parvati@1.0.0 No repository field.
npm WARN package.json parvati@1.0.0 No README data
lambda_bot@1.0.0 node_modules/lambda_bot

node-env-file@0.1.8 node_modules/node-env-file

bluebird@3.3.4 node_modules/bluebird

lambda_botのモジュールからサンプルをコピーしてきます。

$ cp node_modules/lambda_bot/example_bot.js ./index.js

コピーしてきたサンプルを元に今回の要件に合わせてロジックを修正します。

// NODE_ROOT/index.js

var LambdaBot = require('lambda_bot');
var env = require('node-env-file');

env(__dirname + '/.env');

var bot = new LambdaBot({
    iconEmoji: process.env['SLACK_ICON_EMOJI'],
    userName: process.env['SLACK_USER_NAME'],
    channelName: process.env['SLACK_CHANNEL_NAME'],
    slackIncomingWebhookURL: process.env['SLACK_INCOMING_WEBHOOK_URL']
});

var round = function(x) {
    return Math.round(x * 10) / 10.0;
};

bot.respond(/parvati:?\s*([\d\.]+)/, function(res) {
    const pi = 3.14;
    var r = parseFloat(res.match[1]);
    var area = round(r * r * pi);

    return res.reply(area);
});

exports.handler = bot.createHandler();

SlackのIncoming WebHookの登録

計算した円の面積をSlackのチャンネル上で回答を投稿するためにIncoming WebHookを登録します。

f:id:devchick:20160314233342p:plain

SlackのApp DirectoryからIncoming WebHookを選択します。

f:id:devchick:20160314233708p:plain

回答を投稿するチャンネルを指定します。

f:id:devchick:20160314233909p:plain

WebHook URLというモノが表示されますので、これをローカルコンソールの環境変数に登録します。 私の場合は普段direnvを利用しているので、ローカルの.envrcに登録して環境変数に反映されるようにしました。

# NODE_ROOT/.envrc

export SLACK_INCOMING_WEBHOOK_URL='https://hooks.slack.com/services/XXXXXX/XXXXXXXXXXXXXXXXXXXX'
export SLACK_CHANNEL_NAME='times_tasaki'
export SLACK_USER_NAME='parvati'
export SLACK_ICON_EMOJI=':parvati:'

AWSアカウントの開設

AWS LambdaとAPI Gatewayを利用するためにAWSアカウントを開設します。

f:id:devchick:20160314230344p:plain

メールアドレスとパスワードを入力します。

f:id:devchick:20160314230555p:plain

連絡先情報を入力します。

f:id:devchick:20160314230741p:plain

お支払い情報を入力します。

f:id:devchick:20160314230855p:plain

電話を使って本人確認します。

f:id:devchick:20160314231111p:plain

サポートプランを選択します。

これでアカウント開設は完了です。

Lambda関数の登録用のアクセスキーの作成

lambda_bot npmモジュールではLambda関数のデプロイ作業もやってくれる機能があります。中身を見るとどうやらaws cliを実行しているようなので、ローカルPCからaws cliでlambda create-functionなどが利用できるようにIAMユーザーを作成してアクセスキー&シークレットキーを取得します。

f:id:devchick:20160314231643p:plain

IAMの画面のUsersメニューを開き、Create New Userをクリックします。

f:id:devchick:20160314232013p:plain

ユーザー名は適当にuploaderさんとしてCreateをクリックします。

f:id:devchick:20160314232158p:plain

表示されたアクセスキーとシークレットキーをメモします。

アクセスキー&シークレットキーは環境変数に登録するか、~/.aws/credentialsにdefaultプロファイルとして登録します。 先述の通りdirenvを利用していますので、.envrcに追加登録します。

# NODE_ROOT/.envrc

export SLACK_INCOMING_WEBHOOK_URL='https://hooks.slack.com/services/XXXXXX/XXXXXXXXXXXXXXXXXXXX'
export SLACK_CHANNEL_NAME='times_tasaki'
export SLACK_USER_NAME='parvati'
export SLACK_ICON_EMOJI=':parvati:'

export AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
export AWS_REGION=ap-northeast-1

f:id:devchick:20160314232848p:plain

作成したIAMユーザーにはAWSLambdaFullAccessポリシーを適用しておきます。

Lambda実行用のIAMロールの作成

Lambda関数が実行できるように権限を付与したIAMロールを作成します。

f:id:devchick:20160314235503p:plain

lambda_botのデプロイコマンドの中身を見るとlambda_basic_executionという名前のロールを探しているようなので、この名前で作成します。

f:id:devchick:20160314235602p:plain

ロールタイプにはAWS Lambdaを選択します。

f:id:devchick:20160314235709p:plain

適用するポリシーにはAWSLambdaBasicExecutionRoleを選択します。

Lambda関数の登録

さていよいよLambdaにNodeモジュールを登録します。

登録するLambda関数名とハンドラーを環境変数に登録します。例によって.envrcに追加登録です。最終的に.envrcは下記の内容となりました。

# NODE_ROOT/.envrc

export SLACK_INCOMING_WEBHOOK_URL='https://hooks.slack.com/services/XXXXXX/XXXXXXXXXXXXXXXXXXXX'
export SLACK_CHANNEL_NAME='times_tasaki'
export SLACK_USER_NAME='parvati'
export SLACK_ICON_EMOJI=':parvati:'

export AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
export AWS_REGION=ap-northeast-1

export LAMBDA_FUNCTION_NAME='parvati_bot'
export LAMBDA_HANDLER='index.handler'

これらの環境変数を利用してlambda_botのデプロイコマンドを実行します。

$ ./node_modules/lambda_bot/bin/lambda_bot deploy
Archiving a lambda function.
Uploading the lambda function.
The lambda function named parvati_bot does not exist. Creating.
Testing the lambda function.

おぉー! なんかできたっぽい。念のためaws cliで確認してみます。

$ aws lambda list-functions --region ap-northeast-1
{
    "Functions": [
        {
            "Version": "$LATEST",
            "CodeSha256": "MpF1uD2maCj4bGsDv9GcggbjkqC7/1/YVtbWDNa7/60=",
            "FunctionName": "parvati_bot",
            "MemorySize": 128,
            "CodeSize": 177250,
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXX:function:parvati_bot",
            "Handler": "index.handler",
            "Role": "arn:aws:iam::XXXXXXXXXXXXX:role/lambda_basic_execution",
            "Timeout": 3,
            "LastModified": "2016-03-14T12:35:37.110+0000",
            "Runtime": "nodejs",
            "Description": ""
        }
    ]
}

API Gateway

次はLambda関数を発火させるAPI Gatewayを設定します。

f:id:devchick:20160315000342p:plain

API名はなんか適当に。

f:id:devchick:20160315000535p:plain

なんかよくわからないけど/にPOSTメソッドAPIを作成したらいいらしい。

f:id:devchick:20160315000656p:plain

Lambda Functionを選択肢、先ほど作成したLambda関数名を指定します。

f:id:devchick:20160315000944p:plain

Lambda関数呼び出すための権限付与すんぜ!!? とかなんかきかれるので渋々OKします。

f:id:devchick:20160315001321p:plain

するとこんな画面が表示されます。なんかAPI GatewayとLambdaが連携できたっぽい感じになりました。

ただ、この状態だとAPI GatewayにはSlackからの通知はContent-typeがapplication/x-www-formurlencodedとして渡ってきます。一方、LambdaはJSON形式を想定しているので途中で変換する必要があるとかないとか。

それを上記画面のIntegration Requestというところで指定します。

f:id:devchick:20160315001903p:plain

画面下部のMapping TemplateというところでContent-typeのところにapplication/x-www-formurlencodedします。すると右側になんか入力するフォームが表示されるので、そこに以下のコードを入力します。

#set($httpPost = $input.path('$').split("&"))

{
#foreach( $keyValue in $httpPost )
 #set($data = $keyValue.split("="))
 "$util.urlDecode($data[0])" : "$util.urlDecode($data[1])"#if( $foreach.hasNext ),#end
#end
}

初めて見る書式ですが、VTL(Velocity Template Language)とかいう言語らしいです。

f:id:devchick:20160315003000p:plain

APIをデプロイします。ステージ名は適当に。

f:id:devchick:20160315003248p:plain

Invoke URLというものが表示されるのでこれをメモっておきます。

SlackのOutgoing WebHookの登録

最後はSlack上で入力された文字列をAPI Gatewayに通知するための設定です。

SlackのApp DirectoryからOutgoing WebHookを選択します。

f:id:devchick:20160315003426p:plain

監視するチャンネルを選択し、URLに先ほどメモっておいたAPI GatewayのInvoke URLを入力します。

ここでTrigger WordというのにBot名を指定しておきます。これを指定していないと全部の発言がLambdaに通知されます。今回は要件にないし、AWSの無料枠での運用なので少しでも稼働を少なくさせるために指定しました。

円の面積は?

SlackでBotに向かって発言してみます。

f:id:devchick:20160315004218p:plain

やった!初心者だけど出来ちゃった!

参考

Rails5の新機能ActionCableを使ったスライド共有システムを作ってみた

Rails ActionCable React.js AWS

社内LT大会で発表するネタとして、Rails5の新機能である ActionCable を使って某動画共有サイト風のスライド共有システム「nslides」を構築してみました。

まずは下記を参照ください。

ニコニコRails5 〜Rails5の新機能 ActionCable 使ってみたよ!〜 公開終了しました (2017/02/27)

f:id:devchick:20160313225406p:plain

ソースコードおよびシステム構築のためのプロビジョニング設定などはgithubにて公開しています。

https://github.com/devchick/nslides

ActionCableとは

ActionCableとはRailsアプリの中でWebSocketを簡単に利用できるようにするフレームワークです。 WebSocketを利用すると、クライアント側からサーバー側へいちいちHTTPリクエストでポーリングしたりすることなく、必要なタイミングでサーバー・クライアントが互いに通信することができます。これにより、サーバー側から通知情報をプッシュすることなどができるようになります。

ActionCableのサンプルとしてRails作者のDHHさんがシンプルなチャットシステムの作り方動画をこちらで公開しています。また、この動画の日本語解説が@jnchitoさんによってこちらで公開されています。

nslidesでは、コメントの配信と発表者のページ遷移に追従させるためにActionCableを利用しています。

ぶっちゃけてしまうと、「WebSocketって何? なんか便利らしいけどよーわからん。」程度にしかWebSocketを知りませんでしたがActionCableを使うことでそれなりに動作するモノが作れてしまったので、これからWebSocketの敷居が下がってくるのではないかと期待します。

Cable実装(サーバー側)

さて、ActionCableを使って実装している部分をピックアップして紹介します。詳細はリポジトリを参照ください。

Rails5においてActionCable関係のフォルダ構成は下記のようになっており、nslidesシステム用に作成したファイルはslide_channel.rbになります。

RAILS_ROOT
 └ app
   └ channels
     ├ application_cable
     │ ├ channel.rb
     │ └ connection.rb
     └ slide_channel.rb

SlideChannelはApplicationCable::Channelを継承したクラスで、クライアントからの接続要求の受け付けとアクションの受け付けを担わせました。ポイントは、ユーザーがいま閲覧しているスライドについての情報をやり取りしたいのでスライドIDごとにチャンネルを分けるようにしています。

なお、ApplicationCable::ChannelはActionCable::Channel::Baseを継承したクラスで、モデルでいうApplicationModelに相当する共通機能を定義しておく親クラスになります。

# RAILS_ROOT/app/channels/slide_channel.rb

class SlideChannel < ApplicationCable::Channel
  # 接続要求を受け付ける
  def subscribed
    # スライドIDごとにチャンネルを分ける
    stream_from "slide_channel_#{params[:slide_id]}"
  end

  # ユーザー定義のアクションメソッド
  def add_comment(data)
    data = data.with_indifferent_access
    comment = slide.comments.create(data[:comment])
    # 他の閲覧者への配信はバックグラウンドジョブに任せる
    CommentBroadcastJob.perform_later(comment, params)
  end

  private

  def slide
    @slide ||= Slide.find(params[:slide_id])
  end
end

上記で受信したコメント情報を接続されているソケット全部に対してメッセージ送信する処理は、下記のようにActiveJobを使って非同期に実施しています。

# RAILS_ROOT/app/jobs/comment_broadcast_job.rb

class CommentBroadcastJob < ApplicationJob
  queue_as :default

  def perform(comment, params)
    # スライドIDごとのチャンネルに対してメッセージ送信
    ActionCable.server.broadcast "slide_channel_#{params[:slide_id]}", comment: comment
  end
end

Cable実装(クライアント側)

クライアント側のActionCableに関する実装は下記のようにしました。フレームワークとして用いているReact.jsの方言がありますが、要はビューがレンダリングされたときにチャンネルにケーブル接続し、ケーブルを通してサーバー側からの通知を受ける仕組みとサーバー側に通知する仕組みを作っています。

// RAILS_ROOT/app/assets/javascript/components/views/slide_show.js.jsx

var SlideShow = React.createClass({
    componentDidMount() {
        // ビューのレンダリングが終わったら呼び出されるコールバックでチャンネルにケーブルを接続する
        this.subscriptChannel();
    },

    // ActionCableチャンネルの購読
    subscriptChannel() {
        App.slide = App.cable.subscriptions.create(
            // チャンネルの種類とスライドのIDを指定。これがサーバー側にparamsとしてセットされる。
            { channel: "SlideChannel", slide_id: this.state.slide.id },
            {
                // ActionCableが受信したときのコールバック
                received(data) {
                    // 受信したデータを解析して状態を更新する
                    var next_state = this.mergeData(data);
                    this.setState(next_state);
                },

                addComment(comment) {
                    // ケーブルを通してコメントを通知。サーバー側のadd_commentメソッドが呼び出される
                    this.perform('add_comment', { comment: comment });
                }
            }
        );
        App.slide.received = App.slide.received.bind(this);
    }
}

あとは、スライド下部に配置しているボタンが押下されたときにApp.slide.addComment()をコールしてやることで、コメント文字列がサーバー側に通知され、その後前述のロジックによりサーバー側から全クライアントへコメント文字列が配信されてきます。

var ActionBox = React.createClass({
    render() {
        return (
            <div className="action_box">
              <form>
                <Input type="textarea" placeholder="コメント" rows={1} />
                <ButtonInput type="submit" onClick={ this.addComment } value="コメントする" bsStyle="warning" />
              </form>
            </div>
        );
    },

    addComment(event) {
        event.preventDefault();
        var comment = {
            page_id:       this.props.current_page.id,
            body:          event.target.form[0].value,
            recorded_time: this.props.getElapsedTime()
        };
        if (comment.body) App.slide.addComment(comment); // ← ケーブルを通してコメントを通知
        if (event.target.form) event.target.form[0].value = "";
    }
}

地雷踏み

今回はRails 5.0.0.beta2というRCにも満たないベータ版を利用しています。そのためなのか開発していて少しハマったことがありました。

rails sでpumaをdevelopment状態で起動した状態でJSなどのソースコードを修正したあとブラウザをリロードすると修正後のコードがキチンと適用されます。ところが、ActionCableを接続している状態で同じことをすると下記のようなエラーが発生し、pumaを再起動しないと動作しない状態となってしまいました。

ActionController::RoutingError - uninitialized constant CommentsController:
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:46:in `rescue in controller'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:44:in `controller'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:30:in `serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/journey/router.rb:42:in `block in serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/journey/router.rb:29:in `serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:724:in `call'
    ...

RC1もしくは正式リリースでは動作するようになってると嬉しいなー。

システム構成

LT大会の枠が余ってるので何か発表して〜って主催者に依頼されたとき、Rails5の最新版が5.0.0.beta2だったのでこれを利用しています。この記事執筆時は5.0.0.beta3がリリースされており、更に近々5.0.0 RC1がリリースされることが示唆されています (3/1って書いてあるけどまだ出てないよな…)。RC1へのバージョンアップについてなどは機会があればまた別の記事で。

バックエンドをRails5で構成し、フロント部分にはReact.jsを今回は用いました。私自身はビューやJS、CSSあたりがちょっと苦手なもので、流行りのJSフレームワークに乗っかって楽をしたかったためです。初めてReact.jsに触れてみましたがなかなかすっきりと構成できて良い感じの印象です。Flux?とかいうのはまだ勉強足りてなくて導入してません。

インフラ周りはAWSを利用しています。AWSアカウント開設から1年間はいろいろなサービスの無料枠がありますので、それらをフル活用して構成しました。なので、今回のサービス開設にあたって掛かった費用はいまのところ、ドメイン取得のための88円のみ (ありがとうムームードメインさん)。アクセス数が増えてきた時にAWS無料枠の中だけで収まるかな〜。

今後の展開

  • HTTP/2による高速化
  • AWS Lambdaを使ってPDFファイル分解
  • ユーザー認証機能
  • チーム内共有機能
  • かっこいいデザイン
  • 社員総会でこのシステムを使う!!
    • 社長による来期の方針発表中とかにコメント送ってみたいwww

参考

  • nslidesの元ネタ

死活監視ツールGodが暴走するときの対処方法

God Ruby DelayedJob

結論

DelayedJobのような長時間実行するプロセスに対するGod設定にはgraceを設定しましょう。

God.watch do |w|
  ...
  w.grace = 5.seconds
  ...
end

Godとは

Godとはプロセスの死活監視ツールの一つです。プロセスが死ねば再び命を与え、プロセスがコンピューティングリソースを浪費するような悪さをすれば罰を与えるという、まさに『神』の如きツールです。

Rubyで作られたツールであり、設定ファイルもRubyによるDSLを用いて柔軟に構成することができます。 下記は、いろいろなサイトで紹介されているよくある設定です。

RAILS_ROOT = "/path/to/rails_root"
RAILS_ENV  = "production"

3.times do |num|
  God.watch do |w|
    w.name = "DelayedJob-#{num}"
    w.pid_file = "#{RAILS_ROOT}/tmp/pids/delayed_job.#{num}.pid"
    w.start = "cd #{RAILS_ROOT} && script/delayed_job --environment=#{RAILS_ENV} --identifier=#{num} start"
    w.restart = "kill -TERM `cat #{RAILS_ROOT}/tmp/pids/delayed_job.#{num}.pid`"
    w.stop = "cd #{RAILS_ROOT} && script/delayed_job --environment=#{RAILS_ENV} --identifier=#{num} stop"
    w.log = File.join(RAILS_ROOT, "log/god_delayed_monitor.#{num}.log")
    w.behavior(:clean_pid_file)

    w.start_if do |start|
      start.condition(:process_running) do |c|
        c.interval = 5.seconds
        c.running = false
      end
    end

    w.restart_if do |restart|
      restart.condition(:memory_usage) do |c|
        c.above = 500.megabytes
        c.interval = 10.seconds
      end
    end
  end
end

この設定によって以下のような挙動となります。

  • DelayedJobプロセスを3つ稼働させる
  • 5秒ごとにプロセスの存在をチェックし、存在していなければ起動させる
  • 10秒ごとにプロセスの物理メモリ使用量をチェックし500MBを超過したら再起動させる
    • 再起動にはkillを使いプロセスにTERMシグナルを送信する

こまりごと

前述の設定で死活監視としてはきちんと動作します。しかし、監視対象がDelayedJobの場合に困った事象が発生します。

DelayedJobはTERMシグナルを受け取ったときにジョブ実行中の場合、割り込み処理で内部のフラグを立てたのち元のジョブ処理に戻ります。そしてジョブ実行が完了した後にプロセスを終了するという、gracefulな終了をしてくれます。

一方Godは、監視対象プロセスがメモリ超過したことを検知し、TERMシグナルを送信しますが、DelayedJobプロセスはすぐには死なない。なので再びTERMシグナルを送信します。が、やっぱり死なない... このときのGodの挙動がヒドイ。鬼神の如くこれでもかってくらい殺しにかかる。

2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:15+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:16+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:16+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:16+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
2015-09-18T18:40:16+0900: [Worker(delayed_job.1 host:BG.EXAMPLE.COM pid:31039)] Exiting...
  ...

その数、1秒間に約12回。TERMシグナルを送るためにbashプロセスとkillプロセスがそれぞれ生成されるし、シグナル受け取った側はその度に割り込み処理と復帰処理が発生。ジョブが1時間くらいかかるものだった場合、その間ずっとこれらの処理が繰り返されます。おかげで、サーバーのCPU負荷が上がりまくり。

我々は神の怒りをかってしまった。この世に救いはないのか...

神の怒りを沈める方法

Godのソース(ver. 0.13.6)を眺めていたら下記の記述がありました。

def action(a, c = nil)
  ...
  case a
  when :start
    call_action(c, :start)
    sleep(self.start_grace + self.grace)       # ← ん?
  when :restart
    if self.restart
      call_action(c, :restart)
    else
      action(:stop, c)
      action(:start, c)
    end
    sleep(self.restart_grace + self.grace)     # ← んん!?
  when :stop
    call_action(c, :stop)
    sleep(self.stop_grace + self.grace)        # ← おおぉぉ!!!?
  end
  self
end

(ノ゚ρ゚)ノ ォォォ・・ォ・・ォ・・・・ 神は我らに慈悲を与え給うた...

Godのwatchブロックにはgraceおよびstart_grace, restart_grace, stop_graceが用意されていました。これを設定すると再起動等のアクションが実行された後、通常の監視状態に戻る前に指定時間だけsleepしてくれます。

God.watch do |w|
  ...
  w.grace = 5.seconds
  ...
end

この設定をいれることによって、穏やかに再起動が実行されるようになりました。めでたし。

まとめ

GodでDelayedJobを監視したときに再起動処理が短時間に何度も繰り返される挙動を緩和する方法を紹介しました。

余談ですが、死活監視にGodというメタファーを用い、ウェイト時間にgrace(慈悲、寵愛)という名前を付けるセンスが羨ましい...