liliff's gravatar

いらっしゃいませ! I'm Musee, often referred to as lae on the interwebs. I'm an infrastructure engineer at FireEye by day and sysadmin/developer by night who mostly just hacks things here and there nowadays. I deal a lot with Ansible as well.

A Practical Behind the Scenes, Running Mastodon at Scale (Translation)

The following is a translation of this pixiv inside article.

Good morning! I'm harukasan, the technical lead for ImageFlux. 3 days ago at Pixiv, on April 14, we decided to do a spontaneous launch of Pawoo—and since then I've found myself constantly logged into Pawoo's server environment. Our infrastructure engineers have already configured our monitoring environment to monitor Pawoo as well as prepared runbooks for alert handling. As expected, we started receiving alerts for the two days following launch and, despite it being the weekend, found ourselves working off hours on keeping the service healthy. After all, no matter the environment, it's the job of infrastructure engineers to react to and resolve problems!

pawoo.net Architecture

Let's take a look at the architecture behind Pawoo. If you perform a dig, you'll find that it's hosted on AWS. While we do operate a couple hundred physical servers here at Pixiv, it's not really that possible to procure and build up new ones so quickly. This is where cloud services shine. nojio, an infrastructure engineer who joined us this April, and konoiz, a recent graduate with 2 years of experience, prepared the following architecture diagram pretty quickly.

Pawoo Architecture Diagram Using as many of the services provided by AWS as we could, we were able to bring up this environment in about 5 hours and were able to launch the service later that day.

Dropping Docker

One can pretty easily bring up Mastodon using Docker containers via docker-compose, but we decided to not use Docker in order to separate services and deploy to multiple instances. It's a lot of extra effort to deal with volumes and cgroups, to name a few, when working with Docker containers - it's not hard to find yourself in sticky situations, like "Oh no, I accidentally deleted the volume container!" Mastodon does also provide a Production Guide for deploying without Docker.

So, after removing Docker from the picture, we decided to let systemd handle services. For example, the systemd unit file for the web application looks like the following:

Description=mastodon-web
After=network.target

[Service]
Type=simple
User=mastodon
WorkingDirectory=/home/mastodon/live
Environment="RAILS_ENV=production"
Environment="PORT=3000"
Environment="WEB_CONCURRENCY=8"
ExecStart=/usr/local/rbenv/shims/bundle exec puma -C config/puma.rb
ExecReload=/bin/kill -USR1 $MAINPID
TimeoutSec=15
Restart=always

[Install]
WantedBy=multi-user.target

For RDB, Redis and the load balancer, we decided to use their AWS managed service counterparts. That way, we could quickly prepare a redundant multi-AZ data store. Since ALB supports WebSocket, we could easily distribute streaming as well. We're also utilizing S3 as our CDN/uploaded file store.

Utilizing AWS' managed services, we were able to launch Pawoo as fast as we could, but this is where we began to run into problems.

Tuning nginx

At launch, we had stuck with the default settings for nginx provided by the distro, but it didn't take too long before we started seeing HTTP errors returned so I decided to tweak the config a bit. That said, the important settings to increase are worker_rlimit_nofile and worker_connections.

user www-data;
worker_processes 4;
pid /run/nginx.pid;
worker_rlimit_nofile 65535;

events {
  worker_connections 8192;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  sendfile on;
  tcp_nopush on;
  keepalive_timeout 15;
  server_tokens off;

  log_format global 'time_iso8601:$time_iso8601\t'
                  'http_host:$host\t'
                  'server_name:$server_name\t'
                  'server_port:$server_port\t'
                  'status:$status\t'
                  'request_time:$request_time\t'
                  'remote_addr:$remote_addr\t'
                  'upstream_addr:$upstream_addr\t'
                  'upstream_response_time:$upstream_response_time\t'
                  'request_method:$request_method\t'
                  'request_uri:$request_uri\t'
                  'server_protocol:$server_protocol\t'
                  'body_bytes_sent:$body_bytes_sent\t'
                  'http_referer:$http_referer\t'
                  'http_user_agent:$http_user_agent\t';

  access_log /var/log/nginx/global-access.log global;
  error_log /var/log/nginx/error.log warn;

  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-enabled/*;
}

Afterward, without changing a lot of settings, nginx started to work pretty well. This and other ways to optimize nginx are written in my book, "nginx実践入門" (A practical introduction to nginx).

Configure Connection Pooling

PostgreSQL, which Mastodon uses, by nature forks a new process for every connection made to it. As a result, it's a very expensive operation to reconnect. This is the biggest difference Postgres has from MySQL.

Rails, Sidekiq, and the nodejs Streaming API all provide the ability to use a connection pool. These should be set to an appropriate value for the environment, keeping in mind the number of instances. If you suddenly increase the number of application instances to e.g. handle high load, the database server will cripple (or should I say, became crippled). For Pawoo, we're using AWS Cloud Watch to monitor the number of connections to RDS.

As the number of connections increased, our RDS instance would become more and more backed up, but it was easy to bring it back to stability just by scaling the instance size upwards. You can see that CPU usage has been swiftly quelled after maintenance events in the graph below:

RDS Graph

Increasing Process Count for Sidekiq

Mastodon uses Sidekiq to pass around messages, though it was originally designed to be a job queue. Every time someone toots, quite a few tasks are enqueued. The processing delay that comes from Sidekiq has been a big problem since launch, so finding a way to deal with this is probably the most important part of operating a large Mastodon instance.

Mastodon uses 4 queues by default (we're using a modified version with 5 queues for Pawoo - see issue):

  • default: for processing toots for display when submitted/received, etc
  • mail: for sending mail
  • push: for sending updates to other Mastodon instances
  • pull: for pulling updates from other Mastodon instances

For the push/pull queues, the service needs to contact the APIs of other Mastodon instances, so when another Mastodon instance is slow or unresponsive, this queue can become backlogged, which then causes the default queue to become backlogged. To prevent this, run a separate Sidekiq instance for each queue.

Sidekiq provides a CLI flag that lets you specify what queue to process, so we use this to run multiple instances of Sidekiq on a single server. For example, one unit file looks like this:

[Unit]
Description=mastodon-sidekiq-default
After=network.target

[Service]
Type=simple
User=mastodon
WorkingDirectory=/home/mastodon/live
Environment="RAILS_ENV=production"
Environment="DB_POOL=40"
ExecStart=/usr/local/rbenv/shims/bundle exec sidekiq -c 40 -q default # defaultキューだけにする
TimeoutSec=15
Restart=always

[Install]
WantedBy=multi-user.target

The most congested queue is the default queue. Whenever a user that has a lot of followers toots, a ginormous number of tasks are dropped into the queue, so if you can't process these tasks immediately, the queue becomes backlogged and everyone notices a delay in their timeline. We're using 720 threads for processing the default queue on Pawoo, but this is a big area for introducing and discussing performance improvements in.

Changing the Instance Type

We weren't quite sure of what kind of load to expect at launch, so we decided to use a standard instance type and change it around after figuring out how Mastodon uses its resources. We started out with instances from the t- family, then switched to using the c4- family after distinguishing that heavy load was occurring every time an instance's CPU credits ran out. We're probably going to move to using spot instances in the near future to cut down costs.

Contributing to Mastodon

Now, we've been mainly trying to improve Mastodon performance by changing aspects of the infrastructure behind it, but modifying the software is the more effective way of achieving better performance. That said, several engineers here at Pixiv have been working to improve Mastodon and have submitted PRs upstream.

A list of submitted Pull Requests:

We actually even have a PR contributed by someone who's just joined the company this month fresh out of college! It's difficult to showcase all of the improvements that our engineers have made within this article, but we expect to continue to submit further improvements upstream.

Summary

We've only just begun but we expect Pawoo to keep growing as a service. Upstream has been improving at great momentum, so we expect that there will be changes to the application infrastructure in order to keep up.

This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

The translator of this article can be found on Mastodon at lae@kirakiratter.

Increasing Maximum Volume with ALSA

Since I don't have a set of monitors for my desktop, I use a pair of headphones often as speakers for audio. This setup usually is insufferable because of low audio, though mplayer has a "softvol" plugin that lets you increase your sound output's decibel level - and since I hardly needed sound for anything other than music or video, this solution worked perfectly (of course, for anything else I could put on my headphones).

That solution sufficed for me until recently, and I found out ALSA actually has a softvol plugin that lets you set max_dB (usually 0 by default). This can be done in /etc/asound.conf or ~/.asoundrc with the following definitions:

pcm.softvol {
        type softvol
        slave.pcm "cards.pcm.default"
        control {
                name "Software"
                card 0
        }
        max_dB 20.0
}
pcm.!default {
        type            plug
        slave.pcm       "softvol"
}

Depending on your configuration, the line slave.pcm "cards.pcm.default" and card 0 may need modification - you can run aplay -Ll to list your devices and card indices. Changes will take effect upon restarting applications that use sound.

This will create a "Software" control in applications like alsamixer, which will let you increase the decibel level up to 20dB (though there isn't any indication of what the decibel level is at other than percentages). Since I didn't specify min_dB, it defaults to -51dB.

You can also control the left and right channels independently, which is useful when you need volume to be louder in one speaker or headphone in a stereo setup.

Happy π Day!

And there you have it.

As a present, here's an illustration of Japan National Route 20 from the promotional video of 言の葉の庭 (The Garden of Words). It's a new film directed by Makoto Shinkai scheduled to air in theaters (Japan only, though) on 31 May. Feel free to use this as your wallpaper.

Route 20 illustration in 言の葉の庭

In other news, I open-sourced my Showtimes app a few weeks ago. You can see it in action at Commie Subs.

I'll just sit here and continue to stare at the Raspberry Pi I recieved last year but still have yet to use for anything.

Installing Redmine 1.4 on cPanel Shared Hosting

Redmine 1.4.6 (and earlier) can be installed in a shared environment. This article will detail the easiest and most reliable method of getting a Redmine instance set up on a shared cPanel web hosting account, using mod_passenger instead of Mongrel.

I wrote this article a while ago when cPanel 11.32 was the most recent version, which used Rails 2.3.14, but most of it should still apply with cPanel 11.36 and Rails 2.3.17. Redmine 2.2 requires Rails 3.x, and as a result is not likely to be supported on shared servers (though with root access, you could set up Rails 3 in a server using cPanel, but that's beyond the scope of this article).

This article was also written for a HostGator shared hosting account, so I can't vouch for other providers like DreamHost, so please feel free to contact me letting me know if this setup works on other providers using cPanel (as I'd like to believe).

Note: Ensure that you have SSH access enabled on your account before proceeding! You will need to be somewhat familiar with using SSH before you install Redmine in any way. Please see "How do I get and use SSH access?" for more information.

Step 1 - Setup database and subdomain

Go to your cPanel (this is the only time you will need to), and create a database to be used for your Redmine. See "How do I create a MySQL database..." for more information. You can also reference the following screenshot:

Creating a Database in cPanel

We'll call ours cubecity_redmine. Be sure to save your password, as you'll need it later on.

Next, create a subdomain and point it to the public directory of where you will place your Redmine instance. We'll be using rails_apps/redmine/public in this example:

Creating a Subdomain in cPanel

Note: It is not necessary to use a subdomain - you can definitely use a subdirectory or your primary domain, just be sure to make the appropriate changes. For simplicity and ease of maintenance, we will use a subdomain in this article.

Now that we have these set up, let's start configuring our environment for Rails applications.

Step 2 - Setup your Rails environment

Connect to your account via SSH. The following should look similar to where you're at now:

A terminal after connecting via SSH

We will now want to edit our shell's environment variables, so that it knows where to find our ruby gems. You can use any text editor - we'll use nano in our examples. Type the following:

nano ~/.bash_profile

This will open up the nano editor. You will want to add or ensure that the following variables are in your .bash_profile:

export GEM_HOME=$HOME/.gem/ruby/1.8
export GEM_PATH=$GEM_HOME:/usr/lib/ruby/gems/1.8
export PATH=$PATH:$HOME/bin:$GEM_HOME/bin
export RAILS_ENV=production

The contents of .bash_profile as shown in nano

You can navigate the file using your arrow keys. Save it by pressing Ctrl+X (by pressing Ctrl and the X key at the same time). It may ask to save your changes, so press y and then click enter to save it.

After this, type the following so that your environment variables are reloaded from your profile:

source ~/.bash_profile

Now we will want to edit our rubygems configuration file. Open .gemrc in nano as you did with .bash_profile above.

---
gem: --remote --gen-rdoc --run-tests
gemhome: /home/cubecity/.gem/ruby/1.8
gempath:
 - /home/cubecity/.gem/ruby/1.8
 - /usr/lib/ruby/gems/1.8
rdoc: --inline-source --line-numbers

The contents of .gemrc as shown in nano

If the file is empty, type all of the above. Ensure that your gempath and gemhome keys use your own username. Mine is cubecity in the above, so just replace that. Save the file using Ctrl+X after you are done.

That's it! Your environment is set up, so now let's go into downloading and installing Redmine.

Step 3 - Download and Install Redmine

Let's first move out the folder created by cPanel when we went to make a subdomain. Run the following commands:

cd rails_apps
mv redmine oldredmine

Now we want to download the latest version of Redmine 1.4. Visit the RubyForge page for Redmine and find the tarball for latest Redmine. We'll use 1.4.4 as that is the latest at this time of writing, and download it directly to the server like below:

wget http://rubyforge.org/frs/download.php/76255/redmine-1.4.4.tar.gz

You will then want to extract the tarball. Use the following to extract it:

tar xzvf redmine-1.4.4.tar.gz

Your session should look similar to this before you extract the file:

Terminal prior to executing the tar command

After you've finished untarring the download, rename your extracted directory to redmine using the mv command and go into that directory:

mv redmine-1.4.4 redmine
cd redmine

We will be using Bundler to install Redmine's dependencies. Bundler should be available on the shared server, but if it is not, you can locally install a copy by running gem install bundler. As we will be using MySQL, issue the following:

bundle install --without development test postgresql sqlite

Your session should now look like this:

Terminal after installing a bundle for Redmine

Redmine's installed! Now let's finish up and configure it...

Step 4 - Configure Redmine

Copy over the example database configuration provided by Redmine and start editing it, like below:

cp config/database.yml.example config/database.yml
nano config/database.yml

Edit your configuration for the production environment with the database name, user, and password you created at the beginning of this tutorial:

The contents of database.yml as shown in Nano

Press Ctrl+X to save. Now let's run our initial Rake tasks to create a secret and set up your database's tables:

rake generate_session_store
rake db:migrate

Terminal before running rake db:migrate

Finally, we will edit our .htaccess so that mod_passenger can handle requests for your Redmine instance:

nano public/.htaccess

Add the following two lines:

Options -MultiViews
RailsBaseURI /

Press Ctrl+X to save, and you're done! Visit the subdomain you created in step 1, and your Redmine installation should be handling requests as normal.

This method requires no stopping or starting of services, however if you find (very rarely this will occur) that you need to restart your app, create a restart.txt file in your application:

touch tmp/restart.txt

The application will restart the next time it is loaded in a browser.

Have fun resolving bugs!