MastodonをGoogle Cloud Platformで構築する

mastodonをGoogle Compute Engine、SQL、Storageを使って構築し、pawoo.netの素晴らしいイラストを収集する方法

Last Update: 2017/05/05

Google Container Engine(GKE)で構築する方法はこちら

公式のProduction-guide を参考に構築します。

公式の通りにローカルに構築することもできますが、ここでは メディアファイルを溜め込むため、データストアにGoogle Cloud Storageを利用し、 DBにGoogle Cloud SQLでPostgreSQLを利用します。

ここでは、メール送信にmailgunを利用します。他のメールサービスや自前のサーバでもかまいません。

料金について

計算上、月に$50強となります。

構築の方法

  1. mailgun

    mailgunにアクセスし、アカウントを取得します。 画面に従いドメインの登録を行います。指示に従い、お使いのDNSサーバにエントリを追加しておきます。 クレジットカードの登録をしてUnity Planに昇格させておかないと、メールが送れず困るので登録を忘れずに。 10,000emails/monthまで無料です。

    Domainsページから、登録したドメインを選択し、設定をメモします。

    Default SMTP LoginとDefault Passwordが後で必要になります。

  2. Google Cloud Storage

    Google Cloud Storageにアクセスし、バケットを作っておきます。

    ここでは、名前を"mastodon"、Regionalを指定し、ロケーションは後でサーバーを立てるリージョンを指定します。

    設定の相互運用性に行き、"新しいキーを作成"からアクセスキーと非公開を作成し、メモしておきます。

  3. Google Cloud SQL

    Google Cloud SQLにアクセスし、インスタンスを作成しておきます。

    インスタンスの作成から、データベースエンジンの選択に進み、PostgreSQLを選択します。

    次にインスタンスIDを適当に決め、リージョンをサーバーを立てるところに指定しておきます。 マシンタイプは最小の1vCPU, 3.75GBかその下のdb-pg-f1-micro(共有CPU, RAM:0.6GB)で十分です。その他はデフォルトのままでよいです。 デフォルトのユーザーpostgresのパスワードを生成して、記録しておきます。

  4. Google Compute Engine

    1. インスタンスの作成

      Google Compute Engineにアクセスし、インスタンスを作成します。

      • マシンタイプは1vCPU, 3.75GBで十分です。
      • ここで説明する構成の場合、ブートディスクは、Debian GNU/Linux 8 (jessie)の10GBで足ります。 storageにデータを保存しない場合は、ディスク容量が必要になるので、/homeに別途ディスクを大きく割り当てる必要があります。 pawoo.netをリモートフォローした場合、1日に3Gくらい増えたりするので、ローカルディスクではなくS3やstorageに飛ばすのをおすすめします。
      • ファイアウォールはHTTP/HTTPSを許可します。
      • 次にネットワーキングから、外部IPを選択し、新しい静的IPアドレスを選択し確保します。
      • 必要でしたら、SSH認証鍵からお使いの鍵を登録しておきます。
    2. DNSの設定

      確保した外部IPに向けて、DNSのAレコードを設定しておきます。

    3. mastodonアカウントの設定

      サーバが立ち上がったら、設定したホスト名でssh接続します。

      mastodonユーザーを作成します。

      
      $ sudo adduser mastodon
      
    4. 依存パッケージの導入

      
      $ sudo apt-get update
      $ sudo apt-get install imagemagick ffmpeg libpq-dev libxml2-dev libxslt1-dev nodejs file git curl
      $ curl -sL https://deb.nodesource.com/setup_4.x | sudo bash -
      
      $ sudo apt-get install nodejs
      
      $ sudo npm install -g yarn
      
    5. Redisの導入

      
      $ sudo apt-get install redis-server redis-tools
      
    6. Postgresの導入

      
      $ sudo apt-get install postgresql postgresql-contrib
      
    7. SQLサーバの設定

      Google Cloud SQLにアクセスし、先程作成したインスタンスを開きます。

      アクセス制御から承認済みネットワークを開き、Google Compute Engineで作成したインスタンスの外部IPを登録します。

    8. SQLサーバへの接続確認

      SQLに接続できるか確認します。

      
      $ psql -h (SQLサーバのIPアドレス) -U postgres
      Password for user postgres:(設定したpostgresユーザパスワード)
      psql (9.4.10, server 9.6.1)
      WARNING: psql major version 9.4, server major version 9.6.
               Some psql features might not work.
      SSL connection (protocol: TLSv1.2, cipher: ECDHE-RSA-AES128-GCM-SHA256, bits: 128, compression: off)
      Type "help" for help.
      
      postgres=> \q
      

      SQLインスタンスのIP4アドレスを指定して接続します。パスワードは先程設定したものを入力します。 \qを入力して終了します。

    9. rbenvの導入

      
      $ sudo apt-get install rbenv
      $ sudo apt-get install ruby-build
      

      rubyのバージョンを指定してインストールしたいので、rbenvを導入します。

    10. mastodonユーザに切替

      ここからmastodonユーザに切り替えて作業します。

      
      $ sudo su - mastodon
      
    11. ruby 2.4.1のインストール

      
      mastodon$ rbenv install 2.4.1
      mastodon$ rbenv rehash
      mastodon$ rbenv global 2.4.1
      mastodon$ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
      mastodon$ echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
      mastodon$ source ~/.bash_profile
      
    12. mastodonのインストール

      
      mastodon$ cd
      mastodon$ git clone https://github.com/tootsuite/mastodon.git live
      mastodon$ cd live
      mastodon$ git checkout $(git tag | tail -n 1)
      

      ソースをとってきて~/liveに置きます。 masterブランチは危険なので、タグついているリリースを使うようにします。

      
      mastodon$ gem install bundler
      mastodon$ bundle install --deployment --without development test
      mastodon$ yarn install
      
    13. 設定ファイルの作成

      サンプル設定ファイルをコピーします。

      
      $ cp .env.production.sample .env.production
      $ vi .env.production
      

      必要なところを修正します。

      
      # Service dependencies
      REDIS_HOST=127.0.0.1
      REDIS_PORT=6379
      # REDIS_DB=0
      DB_HOST=(SQLサーバのIP4アドレス)
      DB_USER=postgres
      DB_NAME=postgres
      DB_PASS=(設定したpostgresユーザパスワード)
      DB_PORT=5432
      
      # Federation
      LOCAL_DOMAIN=example.com	#DNSで設定したホストを指定
      LOCAL_HTTPS=true
      
      # Application secrets
      # Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
      PAPERCLIP_SECRET=xxxxxxx	#コマンドラインでrake secretを3回実行して
      SECRET_KEY_BASE=xxxxxxx 	#その出力結果をそれぞれの欄に
      OTP_SECRET=xxxxxx       	#指定する
      
      # Optionally change default language
      DEFAULT_LOCALE=ja		#日本語を指定する
      
      # E-mail configuration
      # Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
      # If you want to use an SMTP server without authentication (e.g local Postfix relay)
      # then set SMTP_AUTH_METHOD to 'none' and *comment* SMTP_LOGIN and SMTP_PASSWORD.
      # Leaving them blank is not enough for authentication method 'none'.
      SMTP_SERVER=smtp.mailgun.org
      SMTP_PORT=2525					#Google Computue Engineからは2525でないと接続不能なので注意
      SMTP_LOGIN=(mailgunのDefault SMTP Login)
      SMTP_PASSWORD=(mailgunのDefault Password)
      SMTP_FROM_ADDRESS=mastodon@example.com		#mastodonから送られるメールのfrom。適切に設定する
      
      # S3 (Minio Config (optional) Please check Minio instance for details)
      S3_ENABLED=true
      S3_BUCKET=mastodon                              #バケット名
      AWS_ACCESS_KEY_ID=********************    	#Google Cloud Storageで設定したアクセスキー
      AWS_SECRET_ACCESS_KEY=********************	#非公開と書いてあるシークレット
      S3_REGION=us-central1                       	#バケットを作成したリージョン(なくても大丈夫?)
      S3_PROTOCOL=https
      S3_HOSTNAME=storage.googleapis.com
      S3_ENDPOINT=https://storage.googleapis.com
      # S3_SIGNATURE_VERSION=                   	#多分空けておいて大丈夫
      
    14. セットアップ

      データベースの初期化を行います。

      
      $ RAILS_ENV=production bundle exec rails db:setup
      

      CSSとJavaScriptを生成します。

      
      $ RAILS_ENV=production bundle exec rails assets:precompile
      

      両方とも出力がズラズラ出ます。成功っぽい雰囲気ならOK

    15. Systemdへサービスとして登録

      /etc/systemd/system/mastodon-web.service

      
      [Unit]
      Description=mastodon-web
      After=network.target
      
      [Service]
      Type=simple
      User=mastodon
      WorkingDirectory=/home/mastodon/live
      Environment="RAILS_ENV=production"
      Environment="PORT=3000"
      ExecStart=/home/mastodon/.rbenv/shims/bundle exec puma -C config/puma.rb
      TimeoutSec=15
      Restart=always
      
      [Install]
      WantedBy=multi-user.target
      

      /etc/systemd/system/mastodon-sidekiq.service

      
      [Unit]
      Description=mastodon-sidekiq
      After=network.target
      
      [Service]
      Type=simple
      User=mastodon
      WorkingDirectory=/home/mastodon/live
      Environment="RAILS_ENV=production"
      Environment="DB_POOL=5"
      ExecStart=/home/mastodon/.rbenv/shims/bundle exec sidekiq -c 5 -q default -q mailers -q pull -q push
      TimeoutSec=15
      Restart=always
      
      [Install]
      WantedBy=multi-user.target
      

      /etc/systemd/system/mastodon-streaming.service

      
      [Unit]
      Description=mastodon-streaming
      After=network.target
      
      [Service]
      Type=simple
      User=mastodon
      WorkingDirectory=/home/mastodon/live
      Environment="NODE_ENV=production"
      Environment="PORT=4000"
      ExecStart=/usr/bin/npm run start
      TimeoutSec=15
      Restart=always
      
      [Install]
      WantedBy=multi-user.target
      

      この3つのファイルをrootに上がれるアカウントに戻って作成します。

      起動時に自動的に立ち上がるように登録し、起動します。

      
      $ sudo systemctl enable /etc/systemd/system/mastodon-*.service
      $ sudo systemctl start mastodon-web.service mastodon-sidekiq.service mastodon-streaming.service
      
    16. nginxのインストール

      
      $ sudo apt-get install nginx
      
    17. nginxの設定

      /etc/nginx/conf.d/mastodon.conf

      
      map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
      }
      
      server {
        listen 80;
        listen [::]:80;
        server_name mastodon.example.com;
        return 301 https://$host$request_uri;
      }
      
      server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name mastodon.example.com;
      
        ssl_protocols TLSv1.2;
        ssl_ciphers EECDH+AESGCM:EECDH+AES;
        ssl_ecdh_curve prime256v1;
        ssl_prefer_server_ciphers on;
        ssl_session_cache shared:SSL:10m;
      
        #ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem; #一旦コメントアウトして動かす
        #ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
      
        keepalive_timeout    70;
        sendfile             on;
        client_max_body_size 0;
      
        root /home/mastodon/live/public;
      
        gzip on;
        gzip_disable "msie6";
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 6;
        gzip_buffers 16 8k;
        gzip_http_version 1.1;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
      
        add_header Strict-Transport-Security "max-age=31536000 ; includeSubdomains ; Preload ";
        add_header X-Content-Type-Options nosniff;
        add_header X-XSS-Protection "1; mode = block";
        add_header Referrer-Policy "unsafe-url";
        add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; script-src 'self'; base-uri 'none'";
      
        location / {
          try_files $uri @proxy;
        }
      
        location @proxy {
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto https;
          proxy_set_header Proxy "";
          proxy_pass_header Server;
      
          proxy_pass http://127.0.0.1:3000;
          proxy_buffering off;
          proxy_redirect off;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection $connection_upgrade;
      
          tcp_nodelay on;
        }
      
        location /api/v1/streaming {
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto https;
          proxy_set_header Proxy "";
      
          proxy_pass http://localhost:4000;
          proxy_buffering off;
          proxy_redirect off;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection $connection_upgrade;
      
          tcp_nodelay on;
        }
      
        error_page 500 501 502 503 504 /500.html;
      }
      
    18. Let's Encryptの設定

      証明書の更新にrootがいるので、rootのところにインストールします。

      
      $ sudo -i
      # git clone https://github.com/certbot/certbot
      # cd certbot
      # ./cerbot-auto -n
      

      次に証明書を取得します。

      
      # ./certbot-auto --nginx -d mastodon.example.com -m sample@example.com --agree-tos -n
      

      うまくいくと、/etc/letsencrypt/live/mastodon.example.com/ 以下に保存されます。

    19. nginxの設定の修正

      /etc/nginx/conf.d/mastodon.conf

      
        ssl_certificate     /etc/letsencrypt/live/mastodon.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/mastodon.example.com/privkey.pem;
      

      証明書の部分を先程取得したものに差し替えます。

      
      $ sudo systemctl reload nginx
      
    20. 接続の確認

      https://mastodon.example.com/ にアクセスし、mastodonの画面が出れば成功です。 お疲れ様でした。

    21. https証明書の更新処理の設定

      証明書の更新が90日ごとに必要なので、定期的に回しておきます。 早すぎる場合は適宜スキップされるので心配要りません。2日に1回以下の頻度でお好きに

      
      $ sudo -i
      # crontab -e
      

      内容は以下にようにします。

      
      @weekly /root/certbot/certbot-auto renew
      
    22. mastodonの定期処理の設定

      定期的にmastodonの管理タスクを走らせる必要があるので登録しておきます。

      
      $ sudo su - mastodon
      mastodon$ crontab -e
      

      内容は以下にようにします。

      
      RAILS_ENV=production
      @daily cd /home/mastodon/live && /home/mastodon/.rbenv/shims/bundle exec rake mastodon:daily > /dev/null
      

      くれぐれも忘れないように。いつの間にかリモートから取得できなくなります。


設定が完了したら、すかさず登録処理を行ってアカウント番号1を確保しておきます。

アカウントが登録できたら、次のコマンドで管理者に設定しておきます。


mastodon$ cd ~/live
mastodon$ RAILS_ENV=production bundle exec rails mastodon:make_admin USERNAME=alice

メディアファイルの一覧を出力する

以上の作業により、mastodonサーバが無事構築できたと思います。 好きなイラストレータさんをいっぱいフォローしておくとGoogle Cloud Storageのバケットの madia_attachments/以下に流れてきたメディアファイルが保存されていきます。

このままでは、見るのが大変なのでwebで一覧できるように仕込みます。

メディアファイルの場所を保存するデータベースをGoogle Cloud Datastoreに作り、バケットに書き込まれるたびにGoogle Cloud Functionsを使ってデータベースに書き込みます。 もうひとつFunctionsで、httpsアクセスがある度に、htmlを生成するようにします。

  1. Google Cloud Datastore

    Google Cloud Datastoreにアクセスし、エンティティを作成します。

    種類のところを任意に設定し(ここではtest)、キー識別子を数値ID(自動生成)とし、プロパティにindex(整数)、path(文字列)を追加します。 このプロパティをインデックス登録するにチェック入れたままにしておきます。

    適当な値を一旦入れて作り、消しておきます。

  2. Google Cloud Datastore

    Google Cloud Functionにアクセスし、関数を2つ作ります。

    1. インデックスを作成する関数

      データが保存される度に呼び出され、保存されたファイル名を保存するタスクを作ります。

      トリガーを、Cloud Storageバケットにし、メディアを保存することにしたバケットを選択します。

      インラインエディタで編集する場合は、保存先としてステージバケットを指定する必要があるので適宜生成しておきます。

      package.json

      
      {
        "name": "sample-cloud-storage",
        "version": "0.0.1",
        "dependencies":{
          "gcloud": "latest"
        }
      }
      

      index.js

      
      const gcloud = require('gcloud')({
          projectId: 'プロジェクトのidを入れる'
      });
      
      // Instantiates a client
      const datastore = gcloud.datastore();
      
      
      /**
       * Triggered from a message on a Cloud Storage bucket.
       *
       * @param {!Object} event The Cloud Functions event.
       * @param {!Function} The callback function.
       */
      exports.processFile = function(event, callback) {
        var name = event.data.name;
        if(/media_attachments\/files\/\d{3}\/\d{3}\/\d{3}\/original/.test(name)){
        	console.log('Processing file: ' + name);
          const taskKey = datastore.key('test');
          var indstr = name.replace(/media_attachments\/files\/(\d{3})\/(\d{3})\/(\d{3})\/original\/.*/, "$1$2$3");
          var index = Number(indstr);
          var entry = {
            key: taskKey,
            data: [
              {
                name: 'path',
                value: name
              },
              {
                name: 'index',
                value: index
              }
            ]
          };
          datastore.save(entry, function(err) {
            if(err){
              console.log('err: '+name+err);
            }
          });
        }
        callback();
      };
      
    2. httpリクエストに応じる関数

      ユーザがhttpでリクエストしてきたら、メディアの一覧をhtmlにして出力する関数を作ります。

      トリガーを、HTTPトリガーにします。実行する関数をhelloHttpにします。

      package.json

      
      {
        "name": "sample-http",
        "version": "0.0.1",
        "dependencies":{
          "google-cloud": "latest"
        }
      }
      

      index.js

      
      const gcloud = require('google-cloud');
      
      // Instantiates a client
      const datastore = gcloud.datastore({
          projectId: 'プロジェクトのidを入れる'
      });
      
      const maxnum = 100;
      
      
      function getList(sort, start) {
        var descend = true;
      
        const paths = [];
        var query;
        if(sort == 'old'){
          descend = false;
        }
        if(start > 0){
          if(descend){
            query = datastore.createQuery('test')
            .filter('index', '<=', start-0)
            .filter('index', '>', start-maxnum-0)
            .order('index', {
              descending: true
            }).limit(maxnum);
          }
          else{
            query = datastore.createQuery('test')
            .filter('index', '<', start+maxnum-0)
            .filter('index', '>=', start-0)
            .order('index').limit(maxnum);
          }
        }
        else{
          query = datastore.createQuery('test')
          .order('index', {
            descending: descend
          }).limit(maxnum);
        }
        
        return datastore.runQuery(query)
        .then((results) => {
            const entities = results[0];
            
            if(entities.length){
              start = entities[0].index;
              var end = start;
              entities.forEach((task) => {
                paths.push(task.path);
                end = task.index;
              });
      
              return [paths, start, end];
            }
            else {
              return getList(sort, null);
            }
        });
      }
      
      /**
       * Responds to any HTTP request that can provide a "message" field in the body.
       *
       * @param {!Object} req Cloud Function request context.
       * @param {!Object} res Cloud Function response context.
       */
      function handleGET (req, res) {
        const baseUrl = 'https://mastodon.storage.googleapis.com/';
        // Do something with the GET reques
        
        var start = req.query.start || 0;
        var sort = req.query.sort || 'new';
        getList(sort, start).then((ret)=>{
          var list = ret[0];
          var start = ret[1];
          var end = ret[2];
          var backlink = 'list?sort=' + sort + '&start=' + ((sort == 'new')? start + maxnum: start - maxnum);
          var nextlink = 'list?sort=' + sort + '&start=' + ((sort == 'new')? end - 1: end + 1);
          var header = [
            '<!DOCTYPE html>',
            '<html>',
            '<head>',
            '<meta charset="UTF-8">',
            '<title>mastodon cache media</title>',
            '<style type="text/css">',
            '<!--',
            'html, body {',
            'background-color: #000000;',
            'color: #7f7f7f;',
            '}',
            '//-->',
            '</style>',
            '</head>',
            '<body>',
            '<h1>mastodonに流れてきたメディアファイル</h1>',
            '<p>流しそうめんにつかれたら、ザルのところでゆっくりお召し上がりください</p>',
            '<p><a href="'+backlink+'"><back</a>&emsp;<a href="'+nextlink+'">next></a>&emsp;order : <a href="list">newer</a>&emsp;<a href="list?sort=old">older</a></p>'
          ];
          var body = [];
          list.forEach((img) => {
              if(/\.mp4$/.test(img)){
                body.push('<a href="'+baseUrl+img+'"><video controls src="'+baseUrl+img+'" width="300px"></a>');
              }
              else{
                body.push('<a href="'+baseUrl+img+'"><img src="'+baseUrl+img+'" style="width:300px;border:0;"></a>');
              }
          });
          var footer = [
            '<p><a href="'+backlink+'"><back</a>&emsp;<a href="'+nextlink+'">next></a></p>',
            '</body>',
            '</html>'
          ];
          res.status(200).send(header.concat(body).concat(footer).join('\n'));
        });
      }
      
      function handlePUT (req, res) {
        // Do something with the PUT request
        res.status(403).send('Forbidden!');
      }
      
      /**
       * Responds to a GET request with "Hello World!". Forbids a PUT request.
       *
       * @example
       * gcloud alpha functions call helloHttp
       *
       * @param {Object} req Cloud Function request context.
       * @param {Object} res Cloud Function response context.
       */
      exports.helloHttp = function helloHttp (req, res) {
        switch (req.method) {
          case 'GET':
            handleGET(req, res);
            break;
          case 'PUT':
            handlePUT(req, res);
            break;
          default:
            res.status(500).send({ error: 'Something blew up!' });
            break;
        }
      };
      
  3. 稼働開始からFunction設定までに保存されたメディアのインデックスの生成

    mastodonが稼働してから、storageパケットトリガを仕掛けるまでに保存されたメディアのリンクが保存されていないので、修正します。

    まず、gsutilを使えるようにしておきます。認証が足りていないときは次のようにして取得しておきます。

    
    $ gcloud auth login
    

    現時点で取得済みのリストを生成します。

    
    $ gsutil ls gs://mastodon/media_attachments/files/*/*/*/** | grep '/original/' > list
    $ sed 's|gs://mastodon/||' list > list2
    

    リストにあって、まだインデックスに登録されていないものを登録します。

    
    $ pip install --upgrade google-cloud-datastore
    $ gcloud auth application-default login
    

    putdata.py

    
    #!/usr/bin/python
    
    from google.cloud import datastore
    import re
    
    client = datastore.Client()
    query = client.query(kind='test')
    db = list(query.fetch())
    
    dbfile = [];
    for item in db:
        dbfile.append(item['path'])
    
    f = open('list2')
    lines = f.readlines()
    f.close()
    
    for line in lines:
        line = unicode(line.strip(), "utf-8")
        index = int(re.sub(ur'media_attachments/files/(\d{3})/(\d{3})/(\d{3})/original/.*', ur'\1\2\3', line))
        print index, line
    
        key = client.key('test')
        entity = datastore.Entity(key)
        entity['path'] = line
        entity['index'] = index
    
        if line in dbfile:
            for item in db:
                if item['path'] == line:
                    if index in item and index == item['index']:
                        continue
                    entity = item
                    entity['index'] = index
                    break
    
        client.put(entity)
    

    生成したpythonファイルを実行します。

    
    $ chmod +x putdata.py
    $ ./putdata.py
    

HTTPトリガにした、Google Cloud Functionsのアドレスにアクセスして、メディアが一覧になれば完成です。


mastodon: https://mastodon.lithium03.info/
medialist: https://us-central1-mastodon-164718.cloudfunctions.net/list