コンテンツにスキップするには Enter キーを押してください

サーバーレスな社内Gyazoの作り方 [AWS SAM+Api Gateway+Lambda(Ruby)+S3]

こんにちは、岡嵜です。
今回、AWS上でサーバーレスな社内Gyazoシステムを構築する機会があったので、作り方をまとめます。

きっかけ

弊社テストエンジニアの吉武からこんなDMが届いたことがきっかけです。

彼にはいつもテストで ご迷惑をかけて お世話になっているので、望みを叶えてあげることにしました。

Gyazoとは

オープンソースのスクリーンショットソフトウェアです。撮影したスクリーンショットが即時サーバーへアップロードされ、アクセスするためのURLが生成されます。

画像データをURL化することで、画像データを直接扱う場合に発生する、一覧化や整理の手間、GithubやBacklog等に貼り付ける際の手間といった問題を、解決してくれます。

サーバーレスで構築する意義

Gyazoのソースコードは Github に公開されていますが、これはCGIとして実装されています。これをそのまま利用する場合、以下のような懸念がありました。

  • サーバーの運用・保守のコストが高い
  • CGIを使うためだけに、Webサーバーのインストール・設定が必要
  • アップロードした画像のバックアップ方法を検討する必要がある
  • インターネット・クラウドを利用した機能拡張が難しい(slackbotとの連携等)

これらを解決すべくサーバーレスでGyazoと同等のシステムを構築することにしました。

要件

社内Gyazoに求める要件は以下の通り。

  • システムのテストで問題が見つかったときに画面のスクリーンショットを取得する目的で使用する
  • 取得したスクリーンショットはGithub issueやBacklogに貼り付ける。画像が展開されなくてもOK。
  • アップロード・ダウンロードにはIP制限をかけたい
  • Gyazoで公開されているクライアントを利用したい。とりあえずmacのみ対応すれば良い。

構成

インフラをAWS上に構築します。

Gyazoのクライアントからのリクエスト先としてAPI Gateway、リクエストの処理先としてLambdaを配置します。ファイルの格納先としてS3を準備します。

クライアントには画像のURLを返す必要があるので、S3の公開URLを返します。

構築

出来上がったソースコードは こちら で公開しています。

AWS SAMを利用したサーバーレス環境の構築

いわゆるサーバーレスの基本的な構成なので、AWS SAMを用いて構築しました。まずは、 aws-sam-cli をインストールします。

$ brew install awscli
$ aws configure
$ brew install aws-sam-cli

これで sam コマンドが使えるようになります。 sam init コマンドで必要なファイル一式を自動生成します。

$ sam init --runtime ruby2.5
$ cd sam-app
$ bundle install

sam local コマンドでAWSへデプロイすることなく、ローカル環境でLambda Functionの動作確認ができます。

$ sam local generate-event apigateway aws-proxy > event_file.json
$ sam local invoke HelloWorldFunction --event event_file.json

※このあたりの手順は この記事 を参考にしました。

template.ymlに追記

AWS SAMを使う以上、清く正しくInfrastructure as Codeします。ポイントとしては以下の通りです。

  • パブリックアクセス可能なS3のバケットを追加する
  • Lambda FunctionによるバケットへのPUTを許可する
  • API Gatewayのバイナリメディアタイプとして image/png を追加

template.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
    sam-app

    Sample SAM Template for sam-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
    Api:
        BinaryMediaTypes:
            - image~1png
    Function:
        Timeout: 3


Resources:
    Bucket:
        Type: AWS::S3::Bucket
        Properties:
            AccessControl: PublicRead
            WebsiteConfiguration:
                IndexDocument: index.html
                ErrorDocument: error.html
        DeletionPolicy: Retain

    BucketPolicy:
        Type: AWS::S3::BucketPolicy
        Properties:
            Bucket: 
                Ref: Bucket
            PolicyDocument: 
                Statement: 
                    - 
                        Action: 
                            - "s3:GetObject"
                        Effect: "Allow"
                        Resource: 
                            Fn::Join: 
                                - ""
                                - 
                                    - "arn:aws:s3:::"
                                    - 
                                        Ref: Bucket
                                    - "/*"
                        Principal: "*"

    GyazoUploadFunction:
        Type: AWS::Serverless::Function
        Properties:
            CodeUri: app/
            Handler: app.gyazo_upload
            Runtime: ruby2.5
            Policies:
                - AWSLambdaExecute # Managed Policy
                -
                    Version: '2012-10-17' # Policy Document
                    Statement:
                        -
                            Effect: Allow
                            Action:
                                - s3:PutObject
                            Resource: !GetAtt Bucket.Arn
            Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
                Variables:
                    REGION: !Ref "AWS::Region"
                    BUCKET_NAME: !Ref Bucket
            Events:
                GyazoUpload:
                    Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
                    Properties:
                        Path: /upload
                        Method: post

Lambda Functionを作成

Lambda Functionでやるべきことはそこまで多くありません。

  1. リクエストに含まれる画像ファイルを取り出す(必要があればデコードする)
  2. URL(≒ファイル名)に付与するランダムな文字列を生成
  3. S3にファイルを格納する
  4. URLをレスポンスとして返す

ソースコードにすると以下の通り。わずか20行程度です。

app.rb

require 'json'
require 'aws-sdk-s3'
require 'base64'
require 'securerandom'

def gyazo_upload(event:, context:)
  body = event['body']
  body = Base64.decode64(body) if event['isBase64Encoded']
  key = SecureRandom.urlsafe_base64
  object = Aws::S3::Resource
             .new(region:ENV['REGION'])
             .bucket(ENV['BUCKET_NAME'])
             .put_object({ key: "#{key}.png", content_type: 'image/png', body: body })
  {
    statusCode: 200,
    body: object.public_url
  }
end

クライアントを改修

Gyazoが公開しているクライアントをそのまま使えると最高!だったのですが、以下2点を改修しました。

  1. リクエスト先をAPI Gatewayのエンドポイントに変更する
  2. リクエストのContent-Typeを multipart/form-data から image/png に変更

1はhostsファイルで強引にリクエスト先を変えても良かったかもしれません。
2はLambda Functionで multipart/form-data から画像ファイルを取り出すのが面倒になったので、素直に画像ファイルだけを送付するように修正しました。

ソースコードは以下。(ライセンス上の懸念があったのでこのソースはリポジトリに含めていません)

/Applications/Gyazo.app/Contents/Resources/script

##################################
# 元ファイル198行目付近
##################################
CGI = '/Prod/upload'
teams = nil
PORT = 443

# HOST = 'upload.gyazo.com'
# teams = (get_info('CFBundleIdentifier') == 'com.gyazo.mac.teams')
# if teams
#   CGI = '/teams/upload'
# else
#   CGI = '/upload.cgi'
# end
# PORT = 443



##################################
# 元ファイル210行目付近
##################################
data = imagedata
# data = <<EOF
# --#{boundary}\r
# content-disposition: form-data; name="id"\r
# \r
# #{id}\r
# --#{boundary}\r
# content-disposition: form-data; name="metadata"\r
# \r
# #{metadata}\r
# --#{boundary}\r
# content-disposition: form-data; name="imagedata"; filename="gyazo.com"\r
# \r
# #{imagedata}\r
# --#{boundary}\r
# content-disposition: form-data; name="scale"\r
# \r
# #{scale}\r
# --#{boundary}--\r
# EOF

header = {
  'Content-Length' => data.length.to_s,
  'Content-type' => "image/png",
#   'Content-type' => "multipart/form-data; boundary=#{boundary}",
  'User-Agent' => UA,
}

IP制限をかける

以上の改修でGyazoとして利用できる状態にはなっていますが、社外からの画像アップロード・ダウンロードを禁止するために、IP制限を設定することにしました.

「清く正しくInfrastructure as Code」を貫きたかったのですが、SAMのテンプレートでAPI Gatewayのリソースポリシーを設定する方法がわからず、やむを得ず手動設定しました。

API Gatewayのリソースポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:{{region}}:{{account_id}}:{{resource_id}}/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "xxx.xxx.xxx.xxx/xx"
                }
            }
        }
    ]
}

S3のバケットポリシー

{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{{バケット名}}/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "xxx.xxx.xxx.xxx/xx"
                }
            }
        }
    ]
}

デプロイ

README.mdに記載の通り、以下コマンドで簡単にデプロイできます。デプロイ用にS3のバケットを予め別に用意しておく必要があるので注意してください。

$ sam package \
    --output-template-file packaged.yaml \
    --s3-bucket REPLACE_THIS_WITH_YOUR_S3_BUCKET_NAME
$ sam deploy \
    --template-file packaged.yaml \
    --stack-name sam-app \
    --capabilities CAPABILITY_IAM

動作確認

このように撮影したスクリーンショットがブラウザに表示され、URLが付与されます。

将来的にやりたいこと

  • IP制限をかけていることもあって、Github issueやSlackにこのURLを貼り付けても画像が展開されない問題があります。S3の一時URLを活用したり、slackbotを作るなどして、もっと便利に使えるようにしたいところです。
  • Windowsのクライアントにも対応したいですね。

まとめ

ちょっと持ち上げすぎな気がしますが、喜んでもらえたようです。
めでたし。めでたし。

2018年の年明けに組込み畑からやってきた、2児の父 兼 Webエンジニアです。
業務ではRuby on Rails、最近ではフロントエンドにVue.jsを使っています。趣味でGo言語を触ることも。
Lab.Consoleのプロダクトオーナーをしており、AWSと仲良くなれるよう日々勉強中です。

コメントする

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です