管理しているMastodonインスタンスのRedisが攻撃を受けてしまった事件の技術的レポート

概要

12/29にクラウドの移行を実施した。 その際にdockerの設定ミスによりRedisへ外部から自由アクセスできるようになっていた。 後日、Redisへの攻撃の試行を検知した。 そのため、設定の修正を行った。

ユーザの皆様および関係各所申し訳ございません。

影響

  • 期間:2018 12/29-2019 01/05

  • ユーザへの影響: 12/29-01/05にフォローする/されるしたユーザのメールアドレスが、それぞれ短期間(各数秒程度)ではあるものの外部からアクセス可能な状態となっていた。

調査ログ

移行後、頻繁にDOWNアラートを検知。 ログを調査したところ下記出力。

Redis::CommandError: MISCONF Redis is configured to save RDB snapshots,

この時点でRedisまわりかDockerのメモリ関連の設定ミスだろうと疑う。 コンテナを再起動すれば治るのでアラートが起きたら発火する再起動スクリプトで暫定対応を実施。調査は後日することに。

後日調査。 障害時にRedisでsaveが発行できないことを発見。

$ /usr/local/bin/docker-compose exec redis redis-cli
127.0.0.1:6379> save
(error) ERR

Redisログ確認実施。

redis_1      | 1:M 05 Jan 12:23:55.004 * Background saving started by pid 1168
redis_1      | 1168:C 05 Jan 12:23:55.004 # Failed opening the RDB file root (in server root dir /etc/crontabs) for saving: Permission denied

なぜか"/etc/crontabs"に書き込もうとしているログ出力を確認。この時点で攻撃を受けた、あるいは使っているRedisイメージに悪意のあるコードが含まれていた可能性を疑う。

Redisのディレクトリ関連の設定を見てみると下記のように"/etc/crontabs"に書き込む設定にされている。真っ黒。

$ docker-compose exec redis redis-cli
127.0.0.1:6379> config get dir
1) "dir"
2) "/etc/crontabs"

もしやと思いホスト外からRedisにアクセス。普通にできてしまう...

ファイアウォール設定確認。

# firewall-cmd --list-all
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources:
  services: ssh dhcpv6-client https http
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

redis関連のポートは開けていない。

Docker-Composeの設定を疑いportsを削除。 外部からつながらないことを確認。 Docker-Composeの設定により外部からアクセスできるようになっていたことを把握。

RedisのKeyとして"weaponX"等の不審なキーが存在することを確認。

対応

RedisのRDBファイルを削除。

docker-compose.ymlのportsで"IP:port:port"とすることでホストからのみアクセスできるようになる。この設定に直した。

監視拠点を別のConoHa VPSインスタンスとしてたて、プライベートネットワークで通信するようにした。 そして、ConoHaの接続許可ポートをしぼった。

DBへのアクセスは外部からできないことを確認。 また、ログ等を精査しRedis以外には侵入された形式がないことを確認。 sidekiqによるメール配送のためにsidekiqのキューに乗って消化されるまでの間、Redisにユーザのメールアドレスが存在することを確認。

なぜ設定漏れが発生したかの経緯

Sidekiqのキュー監視のためにRedisのポートを開放していた。 ただし、この時点ではIDCFクラウドをつかっており、IDCFクラウドでは上位LBでファイアウォールがあったため外部からRedisへはアクセスができなかった。 IDCF個人プラン廃止のため12/29にConoHaへ移行を実施。 ConoHaの接続許可ポートは未設定。 これは任意ポートを指定して開けることができないため監視を通す必要性から。 firewallの設定を実施。 しかし、「Dockerはfirewalldではなくiptablesに書き込むためfirewalldの設定は無視される」ということに気づいていなかった。

再発防止策

このRedisの攻撃手法についてもっと知りたい人のためのリンク

antirez.com

PythonとC++で雑に作るオンラインマルバツゲーム

この記事はCCS †裏† Advent Calendar 2018

adventar.org

の5日目の記事です。前日のとっちーの記事はこちら yoooomaruuuu.hatenablog.com

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです。おっさんではありません。

はじめに

現代のゲームはいわゆるソシャゲをはじめとして非常に多くのゲームがインターネットに接続されています。しかし、いざ個人でそういった開発しようと思っても情報が豊富にある状況とはいえません。そこで本記事ではオンラインマルバツゲームを題材に実際に動くサンプルを提供することで、インターネットを介したゲームをつくるヒントを提供できればと思います。とおもったが、途中でやる気が消失したので読む人はがんばってよみといてくだせぇ...

概要

通信する方法は複数考えられます。 速度を考えなくて良い場合ではプロトコルとしてHTTPを使用するのが良い選択です。 実際に多くのソシャゲでHTTPが利用されています*1。 HTTPというのはHypertext Transfer Protocolの略です。 もともとはwebサイトのhtmlなどのファイルをwebサーバ-ブラウザ間でやりとりするために考えられました。 しかし、HTTPプロトコルはどのようにデータをやりとりするかを規定しているだけです。 そのためhtml以外のデータをサーバ-クライアントとやりとりするために使っても問題ありません。 多くのソシャゲではサーバとクライアントでJSONという形式でやり取りすることで通信を成立させます。 クライアント-クライアント間の通信はサーバを仲介することによって実現します。

つまり、作る必要があるものとしては - HTTPプロトコルをつかってJSONをサーバとやりとりできるクライアント - HTTPプロトコルを使ってクライアントとJSONをやりとりできるWebアプリケーション があります。 ライブラリとしてHTTPプロトコルライブラリ及びJSONを扱えるライブラリがあればよさそうですね。 クライアント側のライブラリ、つまりC++のライブラリ、としてはcpprestsdkがあります。 cpprestsdkはMicrosoftが出しているクロスプラットフォームなHTTPライブラリでJSONも扱えます。 サーバぐぁのライブラリ、つまりPythonのライブラリ、としては今回はFlaskを利用します。

また、当然Webアプリケーションを実行するためのサーバ環境(=インフラ)も必要です。 こちらも時間があれば書きます。

APIの仕様

次はAPIの仕様を決定します。 書くのだるくなってきた... ソースコードみて察してください

ソースコード

めんどくさくなってきて途中まで...裏だしいいよね。 後ろに改善点等書きます。 また、勝敗判定の実装は読者への課題とします。 ユーザ登録もバグってる気がするのでDBに手動で書き込むかなおすかしてあげてください。

サーバ側

import json
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)


class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True)
    victoryCount = db.Column(db.Integer, nullable=False)
    defeatCount = db.Column(db.Integer, nullable=False)

    def __init__(self, username):
        self.username = username
        self.victoryCount = 0
        self.defeatCount = 0

    def __repr__(self):
        return '<User %r>' % self.username


class Room(db.Model):
    __tablename__ = 'rooms'
    id = db.Column(db.Integer, primary_key=True)
    board = db.Column(db.Text)
    player1 = db.Column(db.Integer, db.ForeignKey('users.id'))
    player2 = db.Column(db.Integer, db.ForeignKey('users.id'))
    nowPlayer = db.Column(db.Integer)
    # 0 : waiting user, 1: battle, 2 end
    nowState = db.Column(db.Integer)
    player1_relationship = db.relationship("User", foreign_keys=[player1])
    player2_relationship = db.relationship("User", foreign_keys=[player2])

    def __init__(self, player1):
        self.board = '[[0,0,0],[0,0,0],[0,0,0]]'
        self.nowState = 0
        self.player1 = player1

    def __repr__(self):
        return '<Room %r>' % self.id


# ホントはGETも必要だけど...
@app.route("/api/v1.0/user", methods=['POST'])
def get_user():
    req_data = request.get_json()
    user = db.session.query(User).filter_by(username=req_data["user_name"]).first()
    if user is None:
        user = User(name)
        db.session.add(user)
        db.session.commit()
        result = {'result': {'user_id': int(user.id), 'user_name': user.username}}
        return jsonify(result)
    else:
        result = {'result': {'user_id': int(user.id), 'user_name': user.username}}
        return jsonify(result)


# POST(player_id)
# GET(room_id)
@app.route("/api/v1.0/lobby", methods=['GET', 'POST'])
def get_lobby():
    req_data = request.get_json()
    if request.method == 'GET':
        room_id = request.args.get("room_id", type=int)
        room = db.session.query(Room).filter_by(id=room_id).first()
        result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
        return jsonify(result)
    elif request.method == 'POST':
        # 空きの検索
        room = db.session.query(Room).filter_by(nowState=0).first()
        if room is None:
            room = Room(req_data['player_id'])
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
            return jsonify(result)
        else:
            room.nowState = 1
            room.player2 = req_data['player_id']
            room.nowPlayer = 1
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
        return jsonify(result)


# POST(location_x,location_y,player_id)
@app.route("/api/v1.0/board/<room_id>", methods=['GET', 'POST'])
def get_board(room_id):
    room = db.session.query(Room).filter_by(id=int(room_id)).first()
    if request.method == 'GET':
        if room.nowPlayer == 1:
            now_player = room.player1
        elif room.nowPlayer == 2:
            now_player = room.player2
        result = {'result': {'room_id': int(room_id), 'now_player_id': now_player, 'board': json.loads(room.board)}}
        return jsonify(result)
    elif request.method == 'POST':

        req_data = request.get_json()

        if room.nowPlayer == 1:
            now_player = room.player1
        elif room.nowPlayer == 2:
            now_player = room.player2

        if req_data['player_id'] != now_player:
            result = {'status': "bad request"}
            # ステータスコードをかえしたい...
            return jsonify(result)

        board = json.loads(room.board)
        if board[req_data["location_y"]][req_data["location_x"]] == 0:
            board[req_data["location_y"]][req_data["location_x"]] = room.nowPlayer
            room.board = json.dumps(board)
            if room.nowPlayer == 1:
                room.nowPlayer = 2
            elif room.nowPlayer == 2:
                room.nowPlayer = 1
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room_id), 'now_player_id': now_player, 'board': json.loads(room.board)}}
            return jsonify(result)
        else:
            result = {'result': "bad"}

            return jsonify(result)


@app.route("/api/v1.0/battle_status/<room_id>", methods=['GET'])
def get_battle_status(room_id):
    room = db.session.query(Room).filter_by(id=int(room_id)).first()
    result = {'result': {'now_user_id': room.nowPlayer, 'now_state': room.nowState, 'room_id': int(room_id)}}
    return jsonify(result)


if __name__ == "__main__":
    app.run()

requirements.txt

Click==7.0
Flask==1.0.2
Flask-SQLAlchemy==2.3.2
itsdangerous==1.1.0
Jinja2==2.10
MarkupSafe==1.1.0
SQLAlchemy==1.2.14
Werkzeug==0.14.1

クライアント側

#include <cpprest/http_client.h>
#include <cpprest/filestream.h>
#include<iostream>
#include<string>

using namespace utility;                    // Common utilities like string conversions
using namespace web;                        // Common features like URIs.
using namespace web::http;                  // Common HTTP functionality
using namespace web::http::client;          // HTTP client features
using namespace concurrency::streams;       // Asynchronous streams

int main(int argc, char* argv[])
{


    //ユーザ認証・登録処理
    std::string user_name;
    int user_id;
    std::cout << "user IDを入力してください" << std::endl;
    std::cin >> user_name;

    {
        json::value PostData;
        PostData["user_name"] = json::value::string(user_name);
        http_client client(("http://127.0.0.1:5000/api/v1.0/user"));


        auto post = client.request(methods::POST,"",  PostData.serialize(), "application/json").then(
                [&user_id](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&user_id](json::value json)
                {
                    user_id =  json["result"].as_object()["user_id"].as_integer() ;
                }
                );
        post.wait();
    }
    std::cout << "user id" << user_id << std::endl;
    int player_id = user_id;

    // ロビー処理
    int lobby_state =0;
    int room_id = 0;

    {
        json::value PostData;
        PostData["player_id"] = json::value::number(player_id);
        http_client client(("http://127.0.0.1:5000/api/v1.0/lobby"));


        auto lobby_post = client.request(methods::POST,"",  PostData.serialize(), "application/json").then(
                [&lobby_state, &room_id](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&lobby_state,&room_id](json::value json)
                {
                    room_id =  json["result"].as_object()["room_id"].as_integer() ;
                    lobby_state = json["result"].as_object()["state"].as_integer() ;
                }
                );
        lobby_post.wait();
    }

    std::cout << lobby_state << std::endl;


    std::cout << "対戦相手の選別中..." << std::endl;
    while(lobby_state == 0){
        http_client client(("http://127.0.0.1:5000/api/v1.0/lobby"));
        uri_builder builder;
        builder.append_query(U("room_id"), U(std::to_string(room_id)));

        auto lobby_get = client.request(methods::GET,builder.to_string()).then(
                [&lobby_state](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&lobby_state](json::value json)
                {

                    lobby_state = json["result"].as_object()["state"].as_integer() ;
                }
                );

        lobby_get.wait();
    }

    // ゲーム本体

    while(1){
        int now_player = -1;
        while(now_player != player_id){
            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::GET,builder.to_string()).then(
                    [&now_player](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([&now_player](json::value json)
                    {

                        now_player = json["result"].as_object()["now_player_id"].as_integer() ;
                    }
                    );

            board_get.wait();
        }


        {
            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::GET,builder.to_string()).then(
                    [&now_player](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([&now_player](json::value json)
                    {

                        std::cout << "現在の盤面" << std::endl;
                        for(auto col :json["result"].as_object()["board"].as_array()){
                            for(auto val:col.as_array()){
                                std::cout << val;
                            }
                            std::cout << std::endl;
                        }
                    }
                    );

            board_get.wait();
        }

        int x,y;
        std::cout << "石を置く位置を選択してください" << std::endl;
        std::cin >> x >> y;

        {
            json::value PostData;
            PostData["player_id"] = json::value::number(player_id);
            PostData["location_x"] = json::value::number(x);
            PostData["location_y"] = json::value::number(y);


            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::POST,builder.to_string(),  PostData.serialize(), "application/json").then(
                    [](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([](json::value json)
                    {
                        std::cout << "現在の盤面" << std::endl;
                        for(auto col :json["result"].as_object()["board"].as_array()){
                            for(auto val:col.as_array()){
                                std::cout << val;
                            }
                            std::cout << std::endl;
                        }
                    }
                    );

            board_get.wait();
        }


    }
    return 0;
}

追加要素

エラーハンドリングをまったくと言っていいほどしていません。 悪いプログラムの見本です。実際には接続切れ、想定しない結果が返ってきたなどに備える必要があります。 ちゃんとしましょう。

また、データは暗号化しないとチートされる確率があがります。実際にはちゃんと暗号化しましょう。暗号化アルゴリズムとしてはAESを使うという記事をよく見かけます。 Roomの情報をDBで持ってますが、こういう情報はRedis等のKVSにもたせたほうがbetterだと思います。

今回は雑にGETとPOSTをつかう、ステータスコードも200しか返してませんが実際にはもっとちゃんとします。RestfulAPIとかGraphQLとかで調べてみるといいでしょう。

GUIを使う際はローディング画面なり出すと思います。その時はクライアント側はwaitじゃなくて非同期にできるものもあるのでそちらを利用しましょう。

また、すごい勢いでgetしてますが普通こういうデータはWebSocketとかつかってサーバ側からpushするようにしたほうがいいんじゃないかな。

おわりに

ほんとはもっとちゃんと書くつもりだったけど途中でやる気が無くなってしまいました。 気が向いたらなおすかもしれません。 会員の皆さんは本記事を参考に(反面教師に?)ソシャゲをつくってみましょう!

明日はkamewo氏のマイクラ話です。

*1:ただし、HTTPはセキュアではないのでSSL/TLSプロトコル上でHTTPを運用するHTTPSを使うのが普通です。

MackerelとUptimeRobotでサークルのサーバを監視している話

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです. CCS Advent Calendar 2017 の22日目の記事として MackerelとUptimeRobotでサークルのサーバを監視はじめた話を書こうと思います. サーバ構築の話についてはこちらの記事Ansible+Dockerでシンプルにサークルのサーバを管理しはじめた話 - まどろみの思考空間で書いています.

前日のアドベントカレンダーの記事はこちら↓ nirup.hatenablog.com

はじめに

サーバは構築して終わりではなく,むしろその後,継続してサービス(たとえば,サイトなら表示できるとか)を提供することが重要です. 継続してサービスを提供するには,サービス提供できていない状況(=異常)を検知する必要があります. そのため,監視をする必要があります.

しかし監視をすることには色々な点でコストがかかります. まず,監視対象とは別の監視拠点を用意しなくてはなりません. これは監視対象と監視拠点を一つにした場合,障害の内容によっては検知できないことがあるためです. また,監視拠点が落ちてしまっても監視を継続できません. そのため,通常は複数拠点による監視が必要です. また,それらの監視拠点を自前で保つ場合は監視拠点のメンテナンスコストもかかります.

そしてサークルでは(サークルでなくてもですが)予算は限られています. また,潤沢にサーバ管理に投入できる人的リソースがあるわけでもありません. また,代替りが頻繁に行われるため,あまり複雑なことをしてよくわからないけどうごいているものをふやすことも得策でもありません. そこでいかにこれらのコストを下げるかということが重要になってきます.

 監視の構成

サークルのwebサイトは商用サイトと比べクリティカルな案件ではありません. したがって,サービスのダウン検知だけを行えれば良いというのが最低条件です. また,即座にログインできるとはかぎりませんのでできるかぎりメトリクスはとっておきたいです. そこでサイト表示不可,ユーザから見て正常に動作していない状況ではアラートを出す,それ以外はメトリクスをとるのみという方針にすることにしました.

メトリクス収集にはMackerelを利用しています. これは下記の理由によるものです. 弊サークルではIDCFクラウドを利用しています. そのため,MackerelのIDCFプランが無料で利用できます. 通常のFreeプランとの違いはホスト数が無制限であること,グラフ表示期間が1週間であることです. Mackerelでは基本的にメトリクスのみを取るようにしています. とっているメトリクスは下記となります. また,wikiと階委員専用ページはdockerで動いています. そのため,dockerのメトリクスを取るようにしています. 例外的に「ログ監視」と「ディスク残量」の監視は行っています.

外形監視にはUptimeRobotを利用しています. これはMackerelでは外形監視を無料枠ではできないためです. 弊サークルのサイトは「トップページ」,「Wiki」,「会員専用ページ」の1つの静的ページおよび,2つのWebアプリケーションで構成されています. UptimeRobotではこの3つのそれぞれのトップページを文字列検知で監視しています.

議論

UptimeRobotでの外形監視は正常にサービスを提供できているかの基準で監視を行っています. そのために,特定Webアプリケーションがみれないということを防ぐため,アプリケーション単位で外形監視を作っています. 単に疎通ではなく,監視文字列検知にしている理由は以下のとおりです. ひとつめはアプリケーション側の何らかの不具合により200(正常ステータス)を返しているものの必要な情報を提供できていないことを防ぐためです. アプリケーション側の不具合により,空白ページが発生してしまっても疎通確認ではこれを検知することはできません. 二つ目の理由は改ざん防止です. 悪意がある変更がhtmlに加えられた場合でも監視文字列未検知で検知できる可能性があります. もちろん,html全体の変化を追跡しているわけではないので,微妙な変化には耐えられませんが, 通常の運用では監視文字列検知でよいと考えています.

mackerelでの監視は外形監視では検知できないがサービス影響が出る可能性があるもの, サービス影響はないものの,対応に時間が掛かる可能性があるものを監視する方針としてします. サークルのサーバではありませんが,運用している別サービスではログ監視を複数いれています. これはエラーログにエラーがでていて使えない機能がでていたものの,外形監視で検知できていなかったためです. ディスク残量はファイル削除で対応できる場合は問題がありません. しかし,ディスク増設が必要な場合は金銭的問題もありサークルでの承認を得る必要があり対応に時間がかかるため監視をしています.

サークルのサーバではあまり障害が起きていないので,同様にUptimeRobotとMackerelで監視している別サービス(shimaidon.net)でのアラートと状況について書きます. 理想は障害発生時に即座にログインで対応することです. 現実には仕事でしているわけではないためつねに見張ってるわけにはいきません. したがって障害時のリソースをサーバにログインしてとることができないことがあります. しかし,メトリクスをMackerelでとっているため,あとからでも当時のリソースをある程度は復元できます. 1週間分のデータでも周期的な変動でまた起きる可能性があることかどうかが推測できます. これは再発防止策を考える必要があるかの判断基準として助かっています. また,検知できなかった障害については監視項目追加,対応が不要だった項目については抑制という方針をとっています.

まとめ

UptimeRobotで外形監視を,Mackerelでメトリクスをとることで低コストで監視が行えるよという話でした.

明日は@here_1115氏です!

MacでX11 Forwardingで接続が切れる問題をどうにかした

わたしの研究室では手元のクライアントから計算機(Linuxマシン)にsshで繋いで作業をすることが多い. 作図なんかも多くの場合繋いだ先の計算機で行う. その際に作図した図の確認をするためにGUIが必要である. 手元にもってきてもいいがsshでつないでGUIを使う方法としてX11 Forwardingがある.

困ったことになぜかMacからつなぐとX11 Fowardingがなぜか途中で切れる. この現象がなかなか解決せずここ数年放置してたものの,いちいちつなぎ直すのも面倒なのでついに調べてみた. 結論から言うと手元のsshの設定で「X11 Fowarding」を設定すればよかった.

今まではTCPKeepAliveしても改善しないなぁと思っていた. なにか他に設定できるところはないかssh_configのmanをよく読むと下記があった.

     ForwardX11Timeout
             Specify a timeout for untrusted X11 forwarding using the format described in the TIME FORMATS section of sshd_config(5).  X11 connections received by ssh(1) after this time will be refused.  The
             default is to disable untrusted X11 forwarding after twenty minutes has elapsed.

つまり,sshのコネクションだけではなくX11タイムアウトもありそれがデフォルトだと20分で切れるということらしい. そこでこのForwardX11Timeoutの値を24hに設定してあげた. 結果,今のところ切れずに使えている.

結論.manをちゃんとよんで設定しましょう.

MastodonでIDCFクラウドのオブジェクトストレージを利用する

はじめに

最近Mastodonが流行っていますね。 そこで@reki_frequent 氏と一緒にshimaidon.netというインスタンスをたちあげました。

AWSを使う手もありましたが料金の魅力からIDCFクラウドを選択しました。 IDCFクラウドでとりあえずたててみたという記事は他にも多数ありますので、 建て方については他の記事に譲るとして本記事ではオブジェクトストレージを使用して料金を節約する方法を紹介します。

Mastodonとは

Mastodonとは分散型ミニブログの規格であるOStatusのOSS実装の一つです。 そのため誰でもインスタンスをたてることが可能です。 またインスタンス間の協調も定義されています。 したがって特定の組織に依存しないSNSのネットワークを構成することが可能です。

Mastodonを個人で運営していく上での問題点

Mastodonを運営していく上での問題の一つに画像等で大量のディスク容量が必要とされることが挙げられます。 他のサーバリソースは登録者の制限等で一定水準に保つことが可能ですがディスク使用量は少人数しかいないインスタンスでもどんどん消費されていきます。 そのため安価にディスク容量を確保することは個人でMastodonを運営していく上で重要となります。 IDCFクラウドは通常のストレージよりオブジェクトストレージを使用したほうが安価(しかも50GBまで無料で使える)です。 IDCFクラウドのオブジェクトストレージはAWSのS3との高い互換性を謳っています。 また、MastodonではS3をストレージとして利用することが可能です。 したがって、IDCFクラウドのオブジェクトストレージをストレージとして利用することが可能です。

実装

本例の環境はCentOS7.3.1611でmastodonのバージョンは1.3.2を利用しています。

実装方針としては.env.productionに設定を追加することでMastodon側でオブジェクトストレージを利用する設定にします。 IDCFクラウドのオブジェクトストレージの課金の特徴としてIDCFクラウド内の通信は無料です。 そこでVMのnginxを必ず経由させるようにしてさらに利用料を抑えます。

shimaidonの場合.env.productionに追加した設定は下記です。

S3_ENABLED=true
S3_BUCKET=shimaidon
AWS_ACCESS_KEY_ID=<API Key>
AWS_SECRET_ACCESS_KEY=<Scret Key>
S3_REGION=ds.jp-east
S3_ENDPOINT=https://ds.jp-east.idcfcloud.com
S3_PROTOCOL=https
S3_SIGNATURE_VERSION=s3
S3_HOSTNAME=shimaidon.net/s3_contents

S3_ENABLEDでS3を有効にするかどうかの設定をします。 S3_BUCKETは使用するバケットを指定します。 このバケットは予め作成しておきます。例ではshimaidonというバケットにしました。 「AWS_ACCESS_KEY_ID」、「AWS_SECRET_ACCESS_KEY」はそれぞれ適切に設定してください。 S3_ENDPOINTはエンドポイント、S3_REGIONはエンドポイントから「idcfcloud.com」を取り除いたものとなります。 S3_PROTOCOLはhttpsです。 S3_SIGNATURE_VERSIONはIDCFクラウドはv4に対応していないのでs3としてください。 S3_HOSTNAMEですがnginxでオブジェクトストレージにproxyさせるため「<自ドメイン>/<サブディレクトリ>」としてください。 今回の例ではshimaidon.net/s3_contentsとしてあります。サブディレクトリ名は当然ですがmastodonのものと被らないように気をつけてください。

次にnginxの設定です。 雑に下記のようにしてあります。

        location /s3_contents/ {
            proxy_set_header host $host;
            proxy_pass https://ds.jp-east.idcfcloud.com/;
        }

/s3_contents/に来たアクセスをs3_contentsを取り除いたファイル名をhttps://ds.jp-east.idcfcloud.com/ に転送しています。

コメント

今回は料金を下げるという点に注目しました。 最後のnginxでリバースプロキシさせるというのは料金とトラフィックやサーバの負荷のトレードオフとなっています。 お金にものを言わせて大規模インスタンスを運営する際はnginxを経由させないようにS3_HOSTNAMEをds.jp-east.idcfcloud.comにするほうがよいです。 お金があるならAWSでPaaSを駆使したほうがよさげではありますが。

まとめ

本記事では各種クラウドの中でも比較的安価なIDCFクラウドを用いてさらにそのオブジェクトストレージを用いることで料金を下げる方法を紹介しました。 具体的にはS3とIDCFのオブジェクトストレージが互換性があること、IDCFクラウド内での通信は無料であるという特性を利用しました。 このような形態の運用はお金のない個人インスタンスを運営する上で有用です。

2016年サークルのサーバの障害まとめ

新年あけましておめでとうございます. 新年早々,障害の記事なんてって感じですが年越し前に書くつもりがこんな時期になっていました… 障害があったことは年超えても忘れずに今後にいかしていきましょうということで…

さて,本日の話題は去年にサークルで発生した大きめの障害(単純な再起動等では復旧しなかったもの)のまとめです. 2つとも人為障害でした…

事例1:パーミッションの設定忘れによる一部ファイル閲覧不可

下記が対応ログより抜粋

   nginxログ確認
    →下記出力を確認
> 2016/10/20 21:48:08 [crit] 35340#0: *6 open() "/var/lib/nginx/tmp/proxy/2/00/0000000002" failed (13: Permission denied) while reading upstream, client: 133.82.251.196, server: _, request: "GET /only/static/file_management_rule.pdf HTTP/1.1", upstream: "http://127.0.0.1:8082/only/static/file_management_rule.pdf", host: "densanken.com", referrer: "http://densanken.com/only/"
    
   /var/lib/nginx/配下の権限確認
    →nginxユーザが所有者であることを確認
> [root@main01 /]# ll /var/lib/nginx/
> 合計 0
> drwx------ 7 nginx nginx 73 10月 16 19:37 tmp

   /var/lib/nginx配下の所有者変更実施
    →正常に変更できたことを確認

nginxの実行ユーザをwebadminにしたのですが,"/var/lib/nginx/“の権限を変え忘れて死んでたという感じです. 対応ログではさくっとnginxのエラーログ見て対応してみたいに書いていますが, 最初はアプリケーション側の障害を疑ってました. 「推測するな.計測せよ.」,通信経路順におっていけっていう教訓です.

事例2: SSL化にともなってhttpとURLが記載されていた場所が死ぬ.

Webアプリケーションにhttpと書かれていた部分によって引き起こされました. 下手にhttp→httpsにリダイレクトしていたため,なぜか更新ができないという 一見,SSL証明書と関係ないバグのような感じで手間取りました.

最終更新日を確認するとSSL化した日付以降更新がないのでもしやと思ったらビンゴでした.

まとめ

今回の障害はサーバの設定を行う時に確認すべき項目が整備されてなかったことによるものでした. 面倒ですが,

  1. 設定変更したときに意図しない動作をするようにしていないか確認手順を設ける
  2. 機械的にテストできるものはテストする

ようにしましょう(といいつつしてないので今度メインで管理している後輩たちに投げたい)

Ansible+Dockerでシンプルにサークルのサーバを管理しはじめた話

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです。 CCS Advent Calendar 2016 の6日目の記事として 老害なので現役生を差し置いてサーバ管理をしちゃってる話を今日は書こうと思います。

サークルでサーバ管理をする上での問題点

大学サークルの特徴(少なくともCCSは)として下記があります。

  1. 人間の流動が激しい(役職は一年交代)
  2. 様々なWebアプリケーションがデプロイされる可能性がある
  3. 予算がその年のサークルの人数によって決まる
  4. 仕事でしているわけではないのでかけられる時間が限られる

したがって

  1. サーバを構築した人が連絡がつかず誰も構成を把握・対応できなくなる
  2. 環境がコンフリクトしてしまい最悪各種サービスが提供できなくなる
  3. いつIDCの変更を余儀なくされるかわからない
  4. 時間的制約で後回しにされがち

といった問題があります。

これらの問題を解決するために構成をシンプルにしてAnsible+Dockerでコード化をすすめてみたよというのが今日のお話。 今後CCSのWeb管になる人、他のサークル等で同様の悩みを抱えている人の参考になれば幸いです。

技術選択

クラウド

会室にサーバ機を置くのはネット回線を引く手間、盗難・火事等が怖いのでまず最初に却下としました。 クラウドとかVPSの候補としてはAWSGCP,IDCFクラウド、さくらVPS、ServersMan@VPSが候補に。 料金・拡張性を考えてIDCFクラウドとしました。 IDCFクラウドなら最低ワンコイン(オブジェクトストレージつかうともっとさげられますが...)からはじめられます。 (ServersMan@VPSは安いけど前使った感じあんまり印象が良くなくてお遊び以上には使えないなーっていう感じがあったので却下)

お金があるんだったらAWSとかGCPのPaaSをがんがんつかっていくというのがよい選択な気がします。

OS

現在はCentOS7を使っています。 これは最近のものがまぁまぁ動いてかつ枯れていて、サポート期間も長いため選択しました。 過去にCoreOSやFeodraつかってみましたが、CoreOSは小規模環境ではただただつらい、 Fedoraはサーバ機でする選択ではなかったですね...

構成管理

構成管理ツールのひとつであるAnsibleで構成管理を行っています。 Ansibleを使い構成をコード化することで(Ansibleがわかる人なら)誰でもサーバの構成を把握しやすくなりました。 また、将来のクラウド移転を行うことになった際もだいぶ楽なはずです。

Ansible特徴としてはエージェントレスであることYAMLで記述するため比較的設定がシンプルであることが挙げられます。 Chef-soloなんかでもいい気も一瞬しましたが、シンプルにできることはシンプルに済ませるというのが引き継ぎや作業のコスパを考えるといい気がしたのでAnsibleを選択しました。

Webアプリケーション

ここでコンテナ型仮想化技術のDockerです。 これでAnsibleを導入したのと同様の環境のコード化を手に入れ、 また環境がホスト・他のDockerコンテナと分離されるのでコンフリクトを気にする必要がなくなります。

Webサーバ

軽量・高速なWebサーバであるNginxを採用しました。 Nginxで静的サイトの配信及びWebアプリケーションへの振り分けを行っています。 各種環境に依存するサービス、特にWebアプリケーション(というか今はWebアプリケーションしかコンテナ化してない...)をコンテナ化しました。

監視

外形監視にはUptimeRobotを利用しています。 また,内部のリソース管理にはNagiosを利用しています。 Mackerelを使い始めました(詳細はこちらMackerelとUptimeRobotでサークルのサーバを監視している話 - まどろみの思考空間)

Nagiosを利用しているのはわたしが慣れているからという理由だけなのでZabbixでもMackerelでもなんでもいいと思います。

コードの管理

Ansibleのplaybook, DockerのDockerfile, そしてコンテンツ系ソースコードの管理はGitHubで行っています。 gitによるバージョン管理を行うことで構築でミスって障害を引き起こしても最悪直前の状態までもどせるという安心をえることができます。 学生ならアカデミックライセンスがつかえますのでプライベートリポジトリもつかえていい感じです。

実装

仮想マシンの立ち上げ・基本設定

IDCFはCloudStack APIに対応していますのでAnsibleのCloudStackモジュールを利用することで上位のファイアウォールの設定はもちろん、VMインスタンスを自動的にたてて基本的な設定をするといったことまで可能です。 これは下記を参考に実装しました。

AnsibleでCloudStackを操作する(基礎編:仮想マシン作成とプロビジョニング) - Qiita

ユーザはセオリー通りsudo権限を持つ管理者ユーザ、webコンテンツの管理者ユーザ(sudoは不可)を用意しました。 webコンテンツの管理者ユーザはDockerの各種コマンドが実行できるようにdockerグループに入れてあります。

SSL証明書の取得

世の中の常時SSL化の流れにしたがってCCSでもすべてのページを原則https化しています。

SSL証明書の取得にはLet's Encryptを利用しました。 Let's Encryptを使うと無料でSSL証明書を取得できます。 また,コマンドラインで取得,更新が可能ですので非常に自動化向きです。 certbotyumでいれて定期的に更新するようなcronを仕込めば完了です。 Ansible化するための注意点としてSSL証明書の取得のコマンドをそのまま叩いてしまうと冪等性が保証されないので --keep-until-expiringオプションを付ける必要がありました。

Nginx

これもAnsibleで管理です。 特に細かいチューニング等はしていません。 また,一時期動的にdockerコンテナにproxyする実装にしようかと思いましたが, 実装コストを考えて手動で設定ファイルを書き換えて各種コンテナにproxyさせるようにしています。 必要になったら実装すればよいでしょう。

監視

例のごとく監視設定,監視エージェント(NRPE)もAnsibleで管理しています。 監視項目で特にかわっている点としてはサーバにSWAPがないのでメモリ使用量監視にしているということでしょうか。 これには下記のプラグインを利用しています。

GitHub - koemu/nagios_plugin_check_memfreetotal: Checking overall memory free space (RAM + Swap) plugin for Nagios.

また,コンテナの起動監視もしたいと思っていて,現在こちらのプラグインを作成中です。

監視サーバは暫定的にわたしの個人契約しているのを利用しているのでそのうち移行したいと考えています。しました。 Mackerelに移行した記事はこちら(詳細はこちらMackerelとUptimeRobotでサークルのサーバを監視している話 - まどろみの思考空間)

コンテンツ系

先程説明したとおり,WebアプリケーションはDockerで管理しています。 基本的に揮発しても良いものはDockerfileにかいて、 データはVolumeでマウントするようにしています。 gitで管理はしているものの,基本的に手動でデプロイです。 ポート番号とかも手動管理... これはいけてないのでそのうち直したい...

実際に運用してみて

メリットとしては作った本人も構築当時の設定等をわすれてしまうもので, 何かあったときとりあえずGitHubをみて設定を確認できるってのは便利だなって思ってます。 今までは構築担当者が死んだらサーバの構成は闇の中でしたがそういったこともなくなりました。 また,教える時に余計コストが増えるかと思いきやAnsibleやDockerの概念さえ教えてしまえば、 細かいところに気をとられることなくざっくりと教えられる、ダブルチェックの手間が減る等 で時間的コストを大幅に減らすことに成功しました。 具体的には昔は構築は教えながらだと2,3日fullでしても終わらないとかでしたが 今は最短数秒,教えながらしても合計12時間程度の時間になりました。

今回サーバ構成で意識したこととしては、要求を満たしかつ凝りすぎないということです。 再利用性や柔軟性をかんがえたり、もっとストイックに全部Dockerで管理したり... ともっとやろうと思えば色々できたと思います。 ただそういうことをしてしまうとメリットをデメリットが上回ってしまいます。 また、シンプルなシステムのほうが壊れにくいし何かあっても対処しやすい。 そういったことを考えて妥協点を探るというのが特にサークルのサーバような 必要経費ではあるものの全力でそれに時間をかけているものではないといった場合に 必要なのではないかと思います(もちろんお金があるんだったら専門の会社とかにアウトソーシングとかもありなんでしょうけど...) 実は明らかな技術的負債も作り込んでしまっていますが、これはきっと後輩たちが解決してくれるでしょう(他人任せ

ただ,緊急で対応したいとかテストしたいとかのときになかなか面倒だなーっていうところはあるので そういったのに対応しやすいPlaybookとかかきたい(テスト環境をいい感じに整備することもふくめ)なあといった感じです。

まとめ

サークルのような低予算かつ人の流動が激しい環境ではAnsible+Docker等で出来る限り環境のコード化する, 構成をシンプルに保つことで幸せになれるかもというお話でした。

次は @littlebird514 君の予定です。よろしくおねがいいたします