EATASのシステム構成

はじめに

GLUGの小林です。 飲食関連メディアのEATASのシステム構成についてご紹介します。 見ての通りAWS上です。比較的オーソドックスなので面白味はないかもしれないです。

構成図

f:id:n06utk:20190305191530p:plain
EATAS構成図

各サービスの説明

細かいところはまた別の記事を書いていきますが、ざっくりと全体的なところを記述します。 LambdaはGoで書いています。特に(深い)理由はありません。

CloudFront

動画のアップロード以外は基本的にCloudFrontで受けています。 画像やjs、cssなどのアセットはキャッシュていますが、会員限定の記事とかを考えると(でもそういう記事は今はないという)キャッシュしづらいのでキャッシュしていません。 今の構成で動的コンテンツでも100request/secは余裕でさばけそうなので、重くなってきたら考えます。

S3+Lambda+MediaConvert

動画アップロードを署名付きURLでS3バケットにアップロード、putObjectのイベントでLambda Functionを起動してMediaConvertでHLS形式に変換しています。 プライベートな動画コンテンツがないためyoutube埋め込みで用が足りてしまい、使われていません...

S3+Athena

CloudFrontのアクセスログをS3に保存してAthenaで解析しています。 パーティションとかはこれでやっています。

EC2

今の所トラフィックがあまりないので特にオートスケールはしてないですが、落ちたときに復活させたいのでオートスケーリンググループで起動しています。 t2.nano*2とかだったりします...

ECS、Fargate

Webアクセスはnginx+php-fpmなタスク定義のサービスで受けています。 オーソドックスにLaravelです。 メール送信などのキュージョブについてはキュージョブ用のタスク定義で起動しています。 こちらもLaravelです。 ジョブについては、元がLaravelなので、Fargateでartisan起動しています。ちょっと前はCloudWatchEventからFargateのタスクを起動しようとするとVPC設定ができないため、Lambda挟んでいましたが、今は直接起動しています。Lambdaにしたい。

RDS

MySQLです。特に珍しくもなんともないです。 memcachedを有効にしてセッション管理に使っています。カテゴリとかのサーバ間で共有する必要のないデータはAPCuに保存しています。 db.t2.microだったりします...

Elasticsearch Service

最初はMySQL全文検索していましたが、検索結果が納得行かないとの意見がありElaticsearch Serviceに変更。

Backlogからの一連の部分

GitHubにしたいところですが、諸事情によりBacklogを使っているため、gitもBacklogなので、API GatewayとLambdaでS3にファイルをおいてCodePipelineを起動しています。 SNSからメールで送信しています。(ここは手抜き) 終了したらCloudWatchEvent経由でSlackへ通知するLambda Functionを起動。 この辺の詳細は別記事にて。

テストサーバを社内からのアクセスに制限したい

f:id:n06utk:20190308165040p:plain

はじめに

GLUGの小林です。 テストサーバなどで、社内からのアクセスに制限したいときってよくありますよね。

あたりがよくありますが

  • IPアドレス制限
    • 社屋からアクセスするとは限らない
    • とはいえVPNサーバ立てるのも面倒
  • Basic認証
    • ユーザ管理したくない

というところで難点が。 そこで署名付きCookieと、弊社ではGoogle G Sutieを使っているので、Google認証を組み合わせて制限をかけます。

仕組み

  1. 認証用のエンドポイントへアクセス
  2. Googleの認証画面へリダイレクト
  3. Googleでの認証
  4. 認証完了後のcallback
  5. tokenを使ってGoogleのユーザー情報取得
  6. 署名付きCookieを発行

1と5のURLにはアクセス制限をかけないようにしてください。

事前準備

  1. リンク先を確認してCloudFrontで制限をかけます
  2. 1.で取得したprivate keyをKMSでエンコードし、結果の文字列を保存
  3. GoogleOAuth2認証の設定を行います。

認証用のURLは http(s)://{hostname}/oauth/login CallbackのURLは http(s)://{hostname}//oauth/callback です。

デプロイ

ソースはGitHubにあります。 Goがインストール済みである必要があります。

ツールの入手

$ git clone git@github.com:n06uk06a/oid_connect.git
$ cd oid_connect

ビルド

GOOS=linux go build -o build/oidc

環境変数の設定

名前 内容
KMSPrincipal KMSマスターキーのARN
KMSAliasParameter KMSマスターキーを格納しているパラメータのAlias
OAuthClientID GoogleのOAuth2のClient ID
OAuthClientSecret GoogleのOAuth2のClientシークレット
CFKeyID CloudFrontに設定したキーペアのキーのID
CFKey CloudFrontに設定したキーペアのキー
URLPattern Cookieに設定する許可するURLのパターン
JWSKey 仕組みの2でJWSを発行し、5で確認するためのキー(適当なもので)
HostedDomain G Suiteのドメイン

デプロイ

$ aws cloudformation package \
    --template-file template.yaml \
    --s3-bucket ${BUCKET_NAME} \
    --output-template-file package.yaml
$ aws cloudformation deploy \
    --template-file package.yaml \
    --stack-name OIDConnect \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides \
        KMSPrincipal=${KMS_KEY_ADMIN_ARN} \
        KMSAliasParameter=${KMS_ALIAS} \
        OAuthClientID=${OAUTH2_CLIENT_ID} \
        OAuthClientSecret=${OAuthClientSecret} \
        CFKeyID=${CLOUDFROMT_KEY_ID} \
        CFKey=${CFKey} \
        URLPattern=${URL_PATTERN} \
        JWSKey=${JWSKey} \
        HostedDomain=${GOOGLE_HOSTED_DOMAIN}

注意点

とりあえず有効期限を1時間にしていたり、ドメイン1個だけ許可していたり、認証要求やCallbackのパスとかが固定だったりするので、その辺はカスタマイズしてください。あとCookie使うので、同じDistribution(同じドメイン)に入れておかないとハマります(ハマりました)

Eloquentの多対多関係でattach/detach/sync時にイベント発火

f:id:n06utk:20190308164355p:plain

GLUGの小林です。初めてなので軽めのネタで。 弊社の子会社が運営しているEATASというメディアで、記事にカテゴリがついており、元のカテゴリ部分は以下でした。

class Article extends Model
{
    public function category()
    {
        return $this->belongsTo('App\Models\Category');
    }

以下新しい要件が出てきました。

  • カテゴリをネストしたい
  • カテゴリを複数をつけたい。
    • パンくずは流入経路を優先
    • それ以外は優先順位に従って
  • カテゴリページが欲しい

そこで以下に変更

class Article extends Model
{
    public function categories()
    {
        return $this->belongsToMany('App\Models\Category')->withPivot('priority');
    }
class Category extends Model
{
    public function articles()
    {
        return $this->belongsToMany('App\Models\Article');
    }
    public function parentCategory()
    {
        return $this->belongsTo(self::class,'parent_category_id');
    }

    public function childCategories()
    {
        return $this->hasMany(self::class,'parent_category_id');
    }

ここまでは問題なかったのですが、以下の新しい要件でちょっと問題が

  • NEWSカテゴリについてはトップページに表示しない

今のままだとSQLで表現するとこうなるので、ちょっとパフォーマンス的に厳しい

SELECT * 
FROM articles
    INNER JOIN article_category ON articles.id = article_category.areticle_id
WHERE
    article_category.category_id = 1
    AND
    他の条件

なので、articlesテーブルにis_newsカラム作ってそれで絞り込もう、と考えましたが、

$article->categories()->attach(Category::NEWS_CATEGORY_ID);

のときにいちいち

$article->is_news = 1;
$article->save();

するわけにもいかないので、attach/detach/syncのときにイベント発生しないかあと考えました。 割とやりたい人いそうな気がしたのですが、ぐぐってもやり方出てこず。意外といないものなのですね。 ということで、

https://github.com/laravel/framework/blob/5.5/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php#L210

public function attach($id, array $attributes = [], $touch = true)
{
    // Here we will insert the attachment records into the pivot table. Once we have
    // inserted the records, we will touch the relationships if necessary and the
    // function will return. We can parse the IDs before inserting the records.
    $this->newPivotStatement()->insert($this->formatAttachRecords(
        $this->parseIds($id), $attributes
    ));
    if ($touch) {
        $this->touchIfTouching();
    }
}

https://github.com/laravel/framework/blob/5.5/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php#L677

public function touchIfTouching()
{
    if ($this->touchingParent()) {
        $this->getParent()->touch();
    }
    if ($this->getParent()->touches($this->relationName)) {
        $this->touch();
    }
}

touch()呼んでるみたいなので、以下対応でカテゴリ追加・削除時にis_newsが変わるようになりました。

class Article
{
    public function touch(){
        $article = $this->fresh();
        if ($article->categories != null && $article->categories->find(Category::NEWS_CATEGORY_ID)){
            $article->is_news = 1;
        }else{
            $article->is_news = 0;
        }
        if ($this->is_news == $article->is_news){
            parent::touch();
        }else{
            $article->save();
        }
    }
}
class Category
{
    protected $touches = ['articles'];
}