非認証APIのアクセス対策(粗大ごみセンターのシステムの例)
前回の記事で書いたとおり、全くユーザーが動作させる機能がないようなコーポレートサイトやLPサイトは静的サイトで作成するほうが、あらゆる面でメリットがある。ところがそれら以外の普通のWEBシステムでは、バックエンドでの動的な処理が必要なはずだ。今回はそのセキュリティの話。 バックエ …
リクエストからレスポンスまでの時間はなるだけ短いほうがいい。ユーザー操作性的にも好ましいし、昨今ではリクエスト~レスポンスまでの時間で課金されるクラウドサービスも多い(GCPのCloudRunとか)。特にマイクロサービス設計の場合は、リクエスト~レスポンスまでの時間がより重要になってくる。
今回はプログラミング言語や通信プロトコルによってどのくらい時間が変わるのかを紹介。このような記事は量産されてきているだろうけど、その量産に加わることにする。なお以下検証は、普通にググって出てくるような実装方法で行っているので、最速最適化を行ったプログラムコードではないことをご了承いただきたい。
ものすごい単純計算なんだけど、10,000人のユーザーが存在していて、年間で1人あたり1000回コールするサービスがあるとするじゃん?もし、そのWebAPIのレスポンスまでの時間を100ms(ミリ秒)短縮できると、全部でおよそ280時間もの短縮になるんだよね。
逆に言うと、よろしくないプログラムコードを書いたり通信方法の選択によって、 みんなから280時間も時間を奪っているかもしれない。しかもそのうち無駄なコンピュータ処理時間もかかっているため、コストも上がる。もちろんユーザーの通信状態によってレスポンス時間が大きく変わるのは間違いないが、今回は、プログラム言語(プログラムの実行時間)と通信プロトコル(リクエスト・レスポンスの通信時間)のみにフォーカスする。またAPIの実行環境はGCPのCloudRun(1cpu 512MB)とする。
まずはサーバーサイドで実行される処理を決める。DBを用意するのはめんどくさいので今回はDBなし。そして特に意味のない処理だけど、ファイル読み込みJSONパース、文字列のハッシュ値計算を含んだAPIを想定する。また、実行時間の測定はそれぞれの言語で測定し、3回実行の平均とする。当然すべてCloudRUN上の同一環境で測定している。
バージョンはNodejs14.16.1。以下は測定箇所のみ。
const crypto = require('crypto');
const fs = require('fs');
const users = JSON.parse(fs.readFileSync('user.json', 'utf8'));
const user = users.find(u => {
return u.id == input_id // ユーザーID
})
const hash = crypto.createHash('sha512').update(input_password).digest('hex'); // ユーザーの入力値
簡単に書くと、user.jsonに10000件のユーザーデータが書かれていて、それを読み込みパースする。そして送信されてきたパスワード文字列をSHA512でハッシュ化するという処理だ。普通はこの後なんかするんだろうけど、ここまでの処理で測定する。
このくらいの処理は普通にありそうだよね?そう、普通にやりそうな処理で測定したいわけ。
ペチパーたちの主食であるPHPのバージョンは7.4.3。
$json = file_get_contents("user.json");
$users = json_decode($json, true);
$index = array_search($input_id, array_column( $users, 'id'));
$user = $users[$index];
$hash = bin2hex(mhash(MHASH_SHA512, $input_password));
最近PHPが爆速になったって聞いてたけど、やはり処理速度はとても速い。7.45ms。PHPのフレームワークもLaravelがとてもよくできているので、PHPはかなり優秀になった。
pythonのバージョンは3.8
import json
import hashlib
with open('user.json') as f:
users = json.load(f)
user = next(filter(lambda u: u["id"] == input_id, users), None)
hash_str=hashlib.sha512(str(input_password).encode("utf-8")).hexdigest()
pythonもPHPと同じくらい高速だ。やはり人気言語は標準モジュールの関数が最適化されているので速い。8.22ms。
Pythonの場合は、処理速度云々よりもコードの可読性が高い気がする。今回のコード1つ見てもシンプルで理解しやすい。
Dotnet6でreleaseビルドしたもので測定
class User
{
public int id { get; set; }
public string hash { get; set; }
}
using (var sr = new StreamReader("user.json"))
{
var jsonData = sr.ReadToEnd();
var users = JsonSerializer.Deserialize<IEnumerable<User>>(jsonData);
var user = users.Where(x => x.id == input_id).First();
byte[] result = SHA512.Create().ComputeHash(System.Text.Encoding.UTF8.GetBytes(input_password));
var hash = "";
for (var i = 0; i < result.Length; i++)
{
hash += String.Format("{0:x2}", result[i]);
}
}
C#クソ遅すぎ。54ms。phpやpythonと比較して5倍以上遅い。で、どの処理に時間がかかっているのか調べたところ、Jsonのシリアライズが異様に遅いことがわかった。
ちなみに多くの人が使っているであろう「Newtonsoft.Json」を使用するとさらに3倍くらい遅くなってしまう。私はC#が比較的好きだから非常にくやしいんだけど、なぜこんなに遅いのか。
結局、意識高い系言語Rustが爆速でしょ。
use serde::{Deserialize};
use std::fs;
extern crate crypto;
use self::crypto::digest::Digest;
use self::crypto::sha2::Sha512;
#[derive(Deserialize)]
struct User {
id: u16,
hash: String
}
fn main() {
let content = fs::read_to_string("user.json").unwrap();
let users: Vec<User> = serde_json::from_str(&content).unwrap();
let index = users.iter().position(|x| x.id == input_id).unwrap();
let user = &users[index];
let mut hasher = Sha512::new();
hasher.input_str(input_password);
let hash = hasher.result_str();
}
Rust最速。とにかく速い(3ms)。ただPythonなどと比べるとちょっとコードが冗長に見えてしまうのが欠点。今回は検証しないけど、C++もこれと同じくらいのスピードになると想定される。ただ、今回のような処理だと、別にPHPやPythonでもいい気がするけど。
3回実行の平均は以下になった。
NodeJs | PHP | Python | C# | Rust |
---|---|---|---|---|
20ms | 7ms | 8ms | 54ms | 3ms |
C#だけ異様に遅くね?何回も処理や実行環境を疑ったんだけどおそらくこの結果は間違いない。今回の処理に限って言えば、RustはC#の20倍弱速いってこと。ちなみにJSONシリアライズ・デシリアライズって最近のアプリケーションでは超頻繁に行うと思うけど、C#環境だとものすごく処理時間を損しているかもしれない。
プログラムの処理速度は予想通りRustが最速だったけど、レスポンスまでの時間に与える影響はそれほど大きくない気がする。(C#とRustだと50ms以上も変わるけど)
次は通信プロトコルによる影響を見てみる。APIの処理は上記と同じコードを使用する。言語は最速だったRust、サーバーは上記と同じCloudRun(東京リージョン)で検証。
RustでRestAPIを実装するのには、actix_webを使う。
use actix_web::{get, web, App, HttpServer, Responder};
#[derive(Debug, Serialize, Deserialize)]
struct RequestBody {
password: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ResponseBody {
hash: String,
}
#[put("/{input_id}")]
async fn index(web::Path((input_id)): web::Path<(u16)>, body: web::Json<RequestBody>) -> impl Responder {
let password = &body.password;
// 上記の処理
// 省略
HttpResponse::Ok().json(ResponseBody {
hash: hash
})
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(index))
.bind("0.0.0.0:8080")?
.run()
.await
}
そしてCloudRunにデプロイしてRestAPIをレスポンス時間を測定する。一般的に行われるPUT通信で、URLパラメータにID、JSONBodyで送信するやつだ。
### RestAPIレスポンス時間測定テスト
PUT {{endpoint}}/9999 HTTP/1.1
content-type: application/json
{
"password": "thisispassword"
}
次にgRPCでHTTP2通信を行ってみる。Rustの場合gRPCが少々ややこしいけどtonicで実装した。まずprotosファイルの定義から。
syntax = "proto3";
package auth;
service Auth {
rpc Test (RequestBody) returns (ResponseBody){
}
}
message RequestBody {
int64 id = 1;
string password = 2;
}
message ResponseBody {
string hash = 1;
}
そしてbuild.rsを作成してprotosを対象に含める。
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.type_attribute(".", "#[derive(Serialize, Deserialize)]")
.type_attribute(".", "#[serde(default)]")
.compile(
&["protos/auth.proto"],
)?;
Ok(())
}
最後にtonicの処理部分を実装。(長いから一部)
use auth::auth_server::{Auth, AuthServer};
use auth::*;
use tonic::{transport::Server, Code, Request, Response, Status};
pub mod auth {
tonic::include_proto!("auth");
}
#[derive(Debug, Default)]
pub struct AuthApi {}
#[tonic::async_trait]
impl Auth for AuthApi {
async fn test(&self, request: Request<RequestBody>) -> Result<ResponseBody, Status> {
let id = request.into_inner().id;
let password = request.into_inner().password;
// 上記の処理
// 省略
Ok(Response::new(auth::LoginResponse { hash }))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "0.0.0.0:8080".parse()?;
let auth_api = AuthApi::default();
Server::builder()
.add_service(AuthServer::new(auth_api))
.serve(addr)
.await?;
Ok(())
}
あとはgRPCのClientを作成して呼び出すだけなんだけど、クライアントもRustで作成して時間を計測した。
use auth::auth_client::*;
use auth::*;
pub mod auth {
tonic::include_proto!("auth");
}
async fn get_grpc_channel() -> Result<Channel, Box<dyn std::error::Error + Sync + Send + 'static>> {
let tls = ClientTlsConfig::new().domain_name(DOMAIN);
let ret = Channel::from_static(CLOUDRUN_URL)
.tls_config(tls)
.unwrap()
.connect()
.await?;
Ok(ret)
}
pub async fn get_client() -> Result<AuthClient<Channel>, Box<dyn std::error::Error>> {
let channel = get_grpc_channel().await.unwrap();
let service = AuthClient::with_interceptor(channel);
Ok(service)
}
pub async fn main() { // これの時間を測定
let request = auth::RequestBody {
id: 9999,
password: "thisispassword",
};
let client_channel = super::client::get_client().await.unwrap();
client_channel.test(request).await?.into_inner();
}
3回実行の平均は以下になった。当然CloudRUNのコンテナが起動状態になってからの3回の平均msになる。またサーバーキャッシュもなしである。
RESTApi(Actix) | gRPC(tonic) |
---|---|
182ms | 102ms |
gRPCってやっぱ速いんだ!HTTP2通信プロトコルによりさらに最適化されているっていうのもあるだろうけど、ここまで速いのはもしかしたらクライアントの処理、サーバー側の待ち受け処理の違いが大きいかも。ちなみにRustのActixWebってRestAPIの中でめちゃくちゃ速いフレームワークで有名だったからね。C#のDotnetのAPIとかにするともっと遅くなると思われる。
今回調査した環境では、プログラミング言語による違いで最大51ms、通信プロトコルによる違いで80msの差がでた。もちろん環境や処理の違いによって様々になるのは間違いないけど。もしかしたらC#のDotnetが最速になるパターンもあるだろうし、PythonやPHPが処理的に非常に遅くなるケースもある。また、実装コードを見てもらうとわかると思うけど、Rustは正直出来上がったコードが冗長的で理解するのが難しい気がする。
で、思うんだけど結局、 数十msとかどうーでもよくね? ここまで書いておいてなんだけど、80msって人間にとってはおそらく感知できないくらい短い時間だよ。しかも、年間で1万人全員の時間が280時間ムダって、あまりにどうでもいい尺度な気がする(私二重人格ではない)。てかみんなもっと多くの時間を無駄にしながら生きてるでしょ。
どのプログラミング言語でも、HTTP1のRESTApiでも、問題ないレスポンス時間を出すことはできる。例えば300msが頑張って1msになるっていうならいろいろ頑張りたいけど、300msが250msになるからといって言語を変えたり、わざわざgRPCに変更したりする必要はない。ユーザーは誰もそんなこと気にしてないから。
前回の記事で書いたとおり、全くユーザーが動作させる機能がないようなコーポレートサイトやLPサイトは静的サイトで作成するほうが、あらゆる面でメリットがある。ところがそれら以外の普通のWEBシステムでは、バックエンドでの動的な処理が必要なはずだ。今回はそのセキュリティの話。 バックエ …
WEBサイトって一般的にブラウザがHTMLやCSS、Js、その他画像などのファイルを読み込んで描写されているものを言うよね?一方Webサイトを作成するには、Webサーバーが必要という。特に有名なCMSであるWordPressではサーバーでPHPやデータベースが動作してサイトが構築 …