なぜ弱火のレシピを時短のために強火にしてはいけないか1次元お肉を熱伝導方程式で焼いてみる

はじめに

料理の失敗談として「弱火で10分のところを強火ですれば時間短縮になる!」といって「外は黒焦げ、中が生焼け」となるという話をよく(?)聞きます。これはなぜでしょうか?今回はこれについて熱伝導方程式を解いてもとめようというのが今回の記事の趣旨です。 f:id:ikaro1192:20190603134316p:plain

理論と手法

今回は話を簡単にするために1次元の熱伝導を計算します。また、厳密に解けそうな気がしますが数値計算を用いて解いていきます。

ということで1次元のお肉を焼いていきましょう。1次元の熱伝導方程式は下記のように表現できます。

  \frac{\partial T}{\partial t} = \kappa  \frac{{\partial}^2 T}{{\partial} {x}^2}

ここでTは肉内部の温度分布で時間tと位置xの関数、κは熱拡散率です。肉の熱拡散率は(杉山,2013)*1にかかれている1.30×10^-7 m2/sを使います。境界条件としてフライパンに接している側は140度と200度(IHで弱火と強火でよく使われている値)とします。とりあえずあとで数式に登場させるためこれを T_hと置きます。また、空気と接している側の境界条件ですが断熱とします。これは料理をしている最中に肉と比べて部屋の温度がどんどん上がっていかないという物理的条件に対応します。また熱拡散率は本来温度に依存しますが簡単のため定数とします。焼くお肉の厚さですがここは庶民ですので L=1 cmのもの*2を用意します。そして、初期条件としてお肉の温度を一様に T_0=10度とします。 以上の条件を式にすると

  T(t,x=0) = T_h

   \frac{\partial T}{\partial x}  |_{x=L}= 0

  T(t=0,x \lt L) = T_0

次に数値計算できるよう離散化をします。今回は微分方程式の解き方としては陽解法を採用します。具体的には微分を下記の差分に置き換えます。

 \frac{\partial T}{\partial t} = \frac{T(t+\Delta t) - T(t)}{\Delta t}

 \frac{{\partial}^2 T}{\partial {x}^2} = \frac{T(x+\Delta x) - 2 T(x) + T(x+\Delta x)}{( {\Delta x}^2)}

ここで \Delta t \Delta xはそれぞれを表しています。

  \frac{T(t+\Delta t) - T(t)}{\Delta t} = \kappa  \frac{T(x+\Delta x) - 2 T(x) + T(x+\Delta x)}{( {\Delta x}^2)}

整理すれば

  T(t+\Delta t, x)  = T(t, x) + \frac{\kappa \Delta t}{{\Delta x}^2}  (T(t, x+\Delta x) - 2 T(t, x) + T(t, x+\Delta x))

を得ます。この調子で断熱の境界条件も離散化しましょう。

 \frac{\partial T}{\partial x} |_{x=L} = \frac{T(L) - T(L-\Delta x)}{\Delta x} =0

  T(L)  =T(L-\Delta x)

と最後とその手前の温度が同じという条件に帰着します。

以上によって求めた計算結果を1分、2分、3分での熱の分布を140度で加熱した場合、200度で加熱した場合で比較していきます。

実装

次に実装するだけです。今回はC++で実装しました。下にソースコードを示します。 また、いくつか式には出てこない部分があるので解説をしておきます。

#include<iostream>
#include<vector>

int main(){

    int input_temprature;
    int input_time;

    std::cin >> input_temprature >> input_time ;

    const double boundary_condition = input_temprature;//境界条件 [℃]
    const double temperature_init = 10; // 初期条件 [℃]
    const double thermal_diffusivity = 0.130; // [mm^2/s]
    const double x_max = 10; // お肉の厚さ [mm]
    const double dx = 0.1;
    const int Nx = x_max/dx;

    const double t_max = input_time;//加熱時間[s]
    const double dt = 0.8 * dx*dx/(2*thermal_diffusivity); //安定条件より。0.8は念の為。解説参照
    const int Nt = int(t_max/dt);

    std::vector<double> before_temperature;
    std::vector<double> now_temperature;

    //境界条件
    before_temperature.push_back(boundary_condition);

    //初期値を入れる
    for(int i =1; i<Nx;++i){
        before_temperature.push_back(temperature_init);
        now_temperature.push_back(temperature_init);
    }

    for(int j=0; j<Nt;++j){
        //境界条件
        now_temperature[0] = boundary_condition;
        for(int i =1; i < Nx-1; ++i){
            now_temperature[i] = before_temperature[i] + ((thermal_diffusivity* dt)/(dx*dx))*(before_temperature[i+1]-2*before_temperature[i]+before_temperature[i-1]);
        }

        //境界条件
        now_temperature[Nx-2] = now_temperature[Nx-3];
        before_temperature = now_temperature; // 本文参照
    }

    double x = 0;
    for(auto temperature : now_temperature){
        x+=dx;
        std::cout << x <<" "<< temperature << std::endl;
    }

    return 0;
}

まずは安定条件について。詳しくは数値計算の教科書等を参照してほしいのですが、今回採用した方法では \Delta t \Delta xが大きいと解が安定しません。具体的には

 \Delta t \leq \frac{{(\Delta x)}^2}{2\kappa}

を満たしている必要があります。そのためdtを下記式で求めています。

const double dt = 0.8 * dx*dx/(2*thermal_diffusivity); //安定条件より。0.8は念の為。解説参照

各経過時間を配列で確保することもできるのですが、それではメモリが多量に必要になります。そこで2ステップ分の配列を用意してそれを入れ替えていくことでメモリを節約します。下記部分です。

        before_temperature = now_temperature; // 本文参照

ほんとはこんなコピー発生させてコストがーとおもいますが、今回は計算を回してみてもそんなに時間がかからなかったのでこのままにしてあります。

境界条件は怪しいですのでもしバグを見つけたらおしえてください()

計算結果

記載の便宜上、「焼きすぎ」、「適温」、「生焼け」の3つを定義します*3。100度を超えた領域を「焼きすぎ」、50度を下回る領域を「生焼け」、そして100度以下、50度以上を「適温」とします。また適温部分の長さを「可食部」とすることにします。また、140度で3分やいたとき、200度で1分焼いたとき、200度で2分20秒焼いたときの温度分布をそれぞれパターンA、パターンB、パターンCとします。

計算結果のグラフは下記となります。

f:id:ikaro1192:20190611020718p:plain
フライパンからの距離と温度の関係。140度で3分やいたとき、200度で1分焼いたとき、200度で2分20秒焼いたときのそれぞれ温度分布を赤線、青線、黒線で示してある。破線は50度(生焼けの境界温度)と100度(焼きすぎの境界温度)を示している。

パターンAでは約2.9 mmにいったところで適温となっています。これには生焼け部はありませんので、したがってパターンAの可食部は7.1 mmとなります。パターンBは焼きすぎの領域がパターンAと一致しています。パターンBは5.0 mmのところで生焼けとなります。したがって、このときの可食部は2.1 mmとなります。残りは生焼けの部分で5.0 mmです。パターンCでは4.5 mmで適温となります。したがって可食部は5.5 mmとなります。表にまとめると下記のようになります。

パターン 焼きすぎ部の長さ(mm) 生焼け部の長さ(mm) 可食部部(mm)
A 2.9 0 7.1
B 2.9 5.0 2.1
C 4.5 0 5.5

パターンAとBを比べると2.9 mmを堺にしてプライパン側ではAよりBの温度が高くなり、外側ではAよりBの温度が低くなっています。パターンAとCを比べると常にCの温度がAの温度を上回っています。

考察

どう頑張っても140度の温度分布と200度の温度分布を一致させることはできませんでした。どんな時間加熱しても200度で加熱した場合は可食部は140度3分の場合の可食部以下となってしまいます。これは加熱する温度によって温度分布は異なるものになることを示します。温度分布が異なるのですから、弱火を強火で代用することができないことがわかります。

パターンAとBの比較からは表面の焼け具合を同様にすると中が生焼けになってしまうことがわかります。また、焼きすぎの部分でも200度のほうが同じ位置なら温度が高いです。これは「外は黒焦げ、中が生焼け」という状況に対応します。パターンCのグラフの意味することは外が黒焦げとなってしまった状況に対応します。つまり200度のほうがはやく表面は焼けますが、熱が中まではやく伝わっていくかは別問題ということを意味します。これは実は数値計算をするまでもなく数式でも理解することができます。離散化した式を見てみると次の瞬間の温度というのは周囲の温度差にある定数をかけたものとよむことができます。つまり、温度差があればそれだけ早く"平均的な"温度に落ち着こうとするものの、瞬時にその温度にいけるわけではないということを示しています。そういった時間ラグがあるため肉の中を一瞬で熱は伝わらず結局、表面には早く熱が伝わり焦げ、中の方は伝わらず生焼けとなってしまうのです。

まとめ

今回は1次元の豚肉を焼くとその温度分布はどうなるかを熱伝導方程式を数値計算を用いて解くことでもとめました。結果は加熱側の温度が違えば加熱時間をどう調整しようと同じ結果にすることはできないというものです。式を考察することにより熱は一瞬で伝わることができないため加熱温度が高ければ表面には早く熱が伝わり焦げ、中の方は伝わらず生焼けとなってしまうことを明らかにしました。ということでレシピの火加減と時間はまもるようにしましょう。

*1:https://www.jstage.jst.go.jp/article/cookeryscience/46/4/46_299/_pdf

*2:実は最初は3 cmでやったのですがうまく火が通らなかったのでこうしました

*3:実際に焦げたり美味しく安全に食べれる温度とは異なります

Nature RemoとGoogle Cloud Functionsをつかって日報を送ったら家の電気をつけるようにしてみた

はじめに

どうもこんにちはいかろちゃんです。最近自宅のスマートホーム化をはじめました。その第一弾として帰ることを検知して家の照明をつけようというのが今回の目的です。

個人用に研究室でBacklogをつかって日報という形で日々のまとめをその日の最後に書いています。また、家に帰ったら照明がついていてほしいですよね。日報が送って照明がつくようになればやる気も返った時のテンションも爆上がり間違いなしです。そこでBacklog→Google Cloud Functions(GCF)→Nature Remo→照明という流れで日報を送ったら家の照明をつける仕組みをつくってみました。

ハードウェアと使うサービスの概要

まずはNature Remoについて。これはいわゆる学習リモコンと呼ばれるもので、赤外線で動く電化製品のリモコンの信号を登録することによりそのリモコンとして振る舞えることができるようになるリモコンのことです。もちろん複数の電化製品の信号を学習させることも可能です。スマートフォンや、Google Home等のスマートスピーカーはもちろんリモコンとしてつかうことができます。そして一番嬉しい特徴がNature Remoはインターネット経由でアクセスできるWeb APIが公開されているという点です。このAPIをつかうことで任意のプログラムから赤外線信号を受け取る電化製品を操作することができるようになります。Nature RemoとNature Remo miniが存在しますが今回は安いRemo miniを利用しました(下記製品)。搭載しているセンサーと大きさ以外の機能は基本的に同一です。

Nature Remo mini 家電コントロ-ラ- REMO2W1

Nature Remo mini 家電コントロ-ラ- REMO2W1

次に照明のスマート化について。もともと自宅にあった照明はペンダント型照明(ようするに紐をひっぱるやつ)でした。紐を引くような機構をつくるのもありですが、よのなかには照明と電源の間に割って入って赤外線リモコンで操作可能にしてくれる製品も存在します。今回は「照明リモコンスイッチ OCR-CRS01W」を利用しました(下記製品)。この製品の嬉しいところは電灯に状態をもたせてボタンでトグルするのではなくonとoffボタンが存在することです。これにより面倒な状態管理をせずに操作できます。

次に日報管理です。これはBacklogを使っています。Backlogがどういうサービスかは今回とは関係ないので省きます。今回重要なのはコメントの追加をフックするWeb hockを登録できるという点です。これをNature Remoにつなげれば今回の目的は達成です。

Nature RemoのWeb APIはWeb HockではないのでBacklogと直接はつなげません。そこで今回はGoogle Cloud Functions(GCF)を仲介役としてはさみます。GCFはFunction as a Service(FaaS)の一つです。FaaSについての詳細な解説はここでははぶきますが、サーバレスであるイベントをうけてあるイベントを発火することが可能です。今回はBacklogからGCFを叩き、GCFがRemoのWeb APIを叩くという構成にします。

プログラムの流れ

Nature Remoをインターネット越しに操作するためにはアクセストークンを発行する必要があります。まずはhttps://home.nature.global/にアクセスしてログインします。その後、"Generate Access Token"でアクセストークンを発行しましょう。アクセストークンは後から確認できないようになっているのでメモを忘れずに。

Nature Remoに登録されている家電に対するそれぞれの操作はSignal IDとして管理されています。したがって、家電を操作するためにはSignal IDを特定する必要があります。下記コマンド(取得したアクセストークンの部分は適宜変更してください)で登録されている電化製品とその情報を得ることができます。nicknameを手がかりに対象の電化製品を特定しましょう。私の環境の場合は下記のような部分がありましたのでこれの電源onのSignal ID(dc1642ea-1d35-47bb-9ead-ab4aa8a51ac9")を使います。

curl -X GET "https://api.nature.global/1/appliances" -H "accept: application/json" -k --header "Authorization: Bearer 取得したアクセストークン"
(前略)
   "model": null,
    "type": "IR",
    "nickname": "照明",
    "image": "ico_light",
    "settings": null,
    "aircon": null,
    "signals": [
      {
        "id": "dc1642ea-1d35-47bb-9ead-ab4aa8a51ac9",
        "name": "電源on",
        "image": "ico_lightup"
      },
      {
        "id": "4ec42e50-b764-470a-963a-48a44e6ca364",
        "name": "消灯",
        "image": "ico_lightdown"
      }
(後略)

いよいよGCFをつかって連携部分を作っていきます。やることは単純で特定プロジェクトに投稿されたらNature RemoのAPIを叩くだけです。まずはGCFにログインし(この手順は省きます)、「関数を作成」をクリックします。関数名はdaily_report,メモリは128 MB,トリガーはHTTPとしました。表示されている「URL」という項目がBacklogに登録するWeb Hockとなります。ソースコードは下記のようにしました。下記のaccess_token, target_signal, project_keyを適切に設定すると動きます。project_keyはBacklogから調べてください。

import requests

access_token = '取得したアクセストークン'
target_signal = '取得したSignal ID'
project_key =  '日報投稿先のプロジェクトキー'

def daily_report(request):
    request_json = request.get_json()
    if  str(request_json["project"]["projectKey"]) == project_key:
        headers = {
            'accept': 'application/json',
            'Authorization': 'Bearer {}'.format(access_token),
        }
        response = requests.post('https://api.nature.global/1/signals/{}/send'.format(target_signal), headers=headers)


    return ""

最後にBacklogににWeb HockとしてGCPのURLを追加します。「プロジェクト設定」→「インテグレーション」→「Webhook」→「設定」をクリックしていきます。「WebHook URL」は先程GCFでえたURLとしましょう。また、通知するイベントは「課題にコメント」のみとしました。

これで完成です。投稿してみて実際に家の電気がつくか試して遊びましょう。

まとめと展望

今回はBacklogからRemoをGCFを使ってつなぐということを行いました。これによって日報を出し忘れると家が暗いままというかなしいことになり日報を出すモチベーションがアップです。今回はBacklogということでしたがWebhookに対応してれば同じパターンが使えますのでSlackやその他のものとも同様な手法でつなぐことができると思います。また、Remoは多様な電化製品に対応しており温度センサー等をそなえているので暑い時期ならついでにクーラーをつけるというようなことも可能です。いろいろアイディア次第で楽しいことができそうなのでたのしんでいきましょう!

管理している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クラウド内での通信は無料であるという特性を利用しました。 このような形態の運用はお金のない個人インスタンスを運営する上で有用です。