Lambdaカクテル

京都在住Webエンジニアの日記です

GraphVizをECSでHTTPサービス化して社内どこからでも使えるようにした話

グラフ作成ツールであるGraphvizを社内でHTTPサービス化し、どこからでも使えるようにした話です。

こういう感じで使えます。

$ curl -X POST http://graphviz.ほげほげ.example.com/ -d 'digraph { foo -> bar -> buzz; }'
https://ほげほげふがふが.amazonaws.com/958508bb186ef076c2cbb92c1e0c34ea0e51316e2d9bfe46620d2d6278db0f94.png

URLを開くとこういう画像になっています。ヤバイ!!

f:id:Windymelt:20200304005910p:plain

Graphviz便利だけどやや不便

Graphvizとは、グラフを作成するツールです。グラフといってもいわゆる棒グラフのようなものではなく、グラフ 理論とかに登場するあの有向グラフとか無向グラフのことです。

f:id:Windymelt:20200304005910p:plain
こういう図、どこかで見たことあるでしょう?

困り

このツールは便利なのですが、便利に使うにはいくつかの障害があります。

  • インストールしなければならない
  • 標準状態では日本語に非対応(文字化けします)
  • 画像をやりとりする場合はどこかにアップロードしなければならない

そういうわけで、パッとプログラム等からグラフを生成したい!しかしこのサーバorマシンorコンテナにはGraphvizがインストールされていない……というようなシチュエーションがよく発生していました。

一応オンラインでGraphvizが使えるサービスはあるのですが、よく知らないサードパーティのサービスにデータを送信したくない、という事情もあることと思います。

HTTP化することで得られるめでたさ

そこで、今回GraphvizをHTTPサービス化して、便利に使ってみようというわけです。

今回は、以下のようなユースケースを想定した設計にしました。

  • サービスに対して curl -XPOST -d 'digraph { foo -> bar }' http://graphviz.example.com/といった/へのPOSTを行うと、パブリックなS3にファイルがグラフ画像がアップロードされ、レスポンスとしてその画像へのURLを受け取ることができる。
    • このURLは秘密にしよう!
  • サービスに対して curl http://graphviz.example.com/img?body=digraph { foo -> bar} といった/imgへのGETを行うと、そのままレスポンスとしてグラフ画像を返す。
    • 今回は雑にキャッシュもプロキシもなしの豪遊仕様です

これならcurlさえあればグラフ画像を生成できますね。今時標準ライブラリにHTTP乗せてない言語なんて無いでしょ………ウッ………頭が………*1という冗談はさておき、AWS Lambdaなどから呼び出すといった活用方法も考えられます。HTMLやMarkdownドキュメントに直接URLを埋め込むこともできて便利だ。 リンクさえ貼れば良いというのがめでたいポイントで、もうアップロードが終わっているというのは素晴らしい。

アーキテクチャ

おおまかに、dotコマンドで生成したpngファイルをaws s3でS3バケットに移動する、という構成にしました。秒間数千リクエストとかが飛んでこない限り大丈夫でしょ。

  • アプリケーションは今風にECSにデプロイする
    • Lambdaでも良かった
  • awsコマンドはPythonなので、サーバもPythonで立てる
  • Graphvizの非公式なDockerイメージがあった。Dockerfileを見ても大丈夫そうなのでこれをベースイメージにする
  • ECSの前段にALBを立てる(詳細は割愛します)
    • Route53でこのALBにドメインを割り付ける(割愛)

ファイル構成

最低限動作させるために必要なファイルを説明します。

Dockerfile

やっていることは以下の3つです。

  • awsコマンドのインストール
  • IPAゴシックフォントのインストール
  • 各種ファイルのコピー
FROM fgrehm/graphviz:latest

RUN apk update && apk add ca-certificates && update-ca-certificates && apk add openssl openssl-dev python3 python3-dev alpine-sdk libffi-dev
RUN pip3 install --upgrade pip
RUN pip3 install --upgrade awscli

# IPA font
RUN cd && wget https://ipafont.ipa.go.jp/IPAfont/ipag00303.zip \
    && unzip ipag00303.zip \
    && mkdir -p /usr/share/fonts/ipa \
    && cp ipag00303/ipag.ttf /usr/share/fonts/ipa \
    && fc-cache -fv


RUN mkdir -p /app/tmp
WORKDIR /app
COPY server.py /app/server.py
COPY conv.sh /app/conv.sh

CMD [ "python3", "server.py" ]

server.py

Pythonはぜんぜん書かないのでコピペでつぎはぎしたフランケンシュタイン&&激雑です。でもサクッと書けてPython便利ですね。だいたい思った通りに書けます。動けばよかろうなのだ。

awsコマンドを入れるときにPythonが入るので、エコの実践としてPythonで書きました。

from http.server import BaseHTTPRequestHandler, HTTPServer
import hashlib
import subprocess
from urllib.parse import parse_qs, urlparse

def run(server_class=HTTPServer, handler_class=BaseHTTPRequestHandler):
    server_address = ('0.0.0.0', 8000)
    httpd = server_class(server_address, handler_class)
    print('listening')
    httpd.serve_forever()

class MyHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed_path = urlparse(self.path)
        print(parsed_path)
        if (parsed_path.path == "/img"):
            body = parse_qs(parsed_path.query)['body'][0]
            returnbody = subprocess.run(["/bin/sh", "/app/conv.sh" , "-"], input=body.encode('utf-8'), stdout=subprocess.PIPE).stdout
            self.send_response(200)
            self.send_header('Content-Type', 'image/png')
            self.end_headers()
            self.wfile.write(returnbody)
            return
            
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write("Hello!!".encode('utf-8'))

    def do_POST(self):
        print('path = {}'.format(self.path))

        content_length = int(self.headers['content-length'])
        
        rawbody = self.rfile.read(content_length)
        body = rawbody.decode('utf-8')
        digest = hashlib.sha256(rawbody).hexdigest()
        print('digest: {}'.format(digest))

        subprocess.run(["/bin/sh", "/app/conv.sh" , digest], input=rawbody)
        subprocess.run(["/usr/bin/aws", "s3", "mv", "tmp/{}.png".format(digest), "s3://ほげほげ/{}.png".format(digest), "--acl", "public-read"])
        
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.end_headers()
        self.wfile.write("https://ほげほげ/{}.png".format(digest).encode('utf-8'))

run(handler_class=MyHTTPRequestHandler)

ここでやっているのは以下の2つです。

  • GETされたらHello!!と返すだけ
    • /imgだったらイメージを生成して返す
  • POSTされたらイメージをファイルに生成し、aws s3 mv を使ってS3バケットにアップロードする
    • DOTドキュメントをSHA-256にかけたもののhex文字列+.pngをファイル名とする

conv.sh

実際に変換をするスクリプトです。実態はdotのラッパーです。

Graphvizは標準では日本語対応フォントを読み込まないので、ここで設定を上書きしてIPAゴシックを読み込ませます。

出力先を第1引数で指定しています。-が渡ってきた場合はstdoutに出力させています。

#!/bin/sh

if [[ $1 = "-" ]] ; then
    exec dot -T png -Nfontname=IPAGothic
else
    exec dot -T png -o tmp/$1.png -Nfontname=IPAGothic
fi

このDocker Imageのハマりポイントですが、ベースがalpineなのでbashはありません。shにしないとコケます。

CD用ファイル

これだけでも充分にECS化は可能ですが、せっかくなのでCD化して、masterブランチにpushされたら自動デプロイできるようにします。

task-definition.json

タスク定義ファイルです。ウィザードで作成したものをダウンロードしたので、もっと削れるはずですがECS力が足りなかった。

特に注目すべきポイントは、

  • 8000番をALBのために露出させている

くらいでしょうか。

{
  "ipcMode": null,
  "executionRoleArn": "arn:aws:iam::ほげほげ:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "dnsSearchDomains": null,
      "logConfiguration": {
        "logDriver": "awslogs",
        "secretOptions": null,
        "options": {
          "awslogs-group": "/ecs/windymelt-gvizd",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "entryPoint": null,
      "portMappings": [
        {
          "hostPort": 8000,
          "protocol": "tcp",
          "containerPort": 8000
        }
      ],
      "command": null,
      "linuxParameters": null,
      "cpu": 256,
      "environment": [],
      "resourceRequirements": null,
      "ulimits": null,
      "dnsServers": null,
      "mountPoints": [],
      "workingDirectory": null,
      "secrets": null,
      "dockerSecurityOptions": null,
      "memory": null,
      "memoryReservation": null,
      "volumesFrom": [],
      "stopTimeout": null,
      "image": "ほげほげ.dkr.ecr.ap-northeast-1.amazonaws.com/windymelt-gvizd:latest",
      "startTimeout": null,
      "firelensConfiguration": null,
      "dependsOn": null,
      "disableNetworking": null,
      "interactive": null,
      "healthCheck": null,
      "essential": true,
      "links": null,
      "hostname": null,
      "extraHosts": null,
      "pseudoTerminal": null,
      "user": null,
      "readonlyRootFilesystem": null,
      "dockerLabels": null,
      "systemControls": null,
      "privileged": null,
      "name": "gvizd"
    }
  ],
  "placementConstraints": [],
  "memory": "512",
  "taskRoleArn": "arn:aws:iam::ほげほげ:role/windymelt-gvizd-taskrole",
  "family": "windymelt-gvizd",
  "pidMode": null,
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "networkMode": "awsvpc",
  "cpu": "256",
  "inferenceAccelerators": null,
  "proxyConfiguration": null,
  "volumes": []
}

.github/workflows/aws.yml

満を持してGithub Actions Workflowです。Githubの「Actions」タブを選択するといきなりECSにデプロイするためのワークフローのテンプレートが用意されていて、適宜変数を書き換えたらいきなり動きます。 今回もそれを使いました。

実際にこのworkflowを動作させるためには、リポジトリのSettingsからSecretsを設定する必要があります。適切なIAMユーザを適切な権限で作成し、リポジトリのSecretsにAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを設定しましょう。

動作させるのに必要なECSクラスタなどはあらかじめ準備しておきます。といってもECSクラスタとALBを用意すればよいのであまり困ることはありませんでした。 といいつつも、コンテナにパブリックIPアドレスを配る設定を忘れて、コンテナがECRにアクセスできずにデプロイに失敗する、というハマりがありました。疎通には気をつけましょう。

on:
  push:
    branches:
      - master

name: Deploy to Amazon ECS

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: windymelt-gvizd
        IMAGE_TAG: ${{ github.sha }}
      run: |
        # Build a docker container and
        # push it to ECR so that it can
        # be deployed to ECS.
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
        echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
    - name: Fill in the new image ID in the Amazon ECS task definition
      id: task-def
      uses: aws-actions/amazon-ecs-render-task-definition@v1
      with:
        task-definition: task-definition.json
        container-name: gvizd
        image: ${{ steps.build-image.outputs.image }}

    - name: Deploy Amazon ECS task definition
      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
      with:
        task-definition: ${{ steps.task-def.outputs.task-definition }}
        service: gvizd
        cluster: windymelt-sandbox
        wait-for-service-stability: true

これでプッシュするとECSにサービスがデプロイされます。この後はよしなにALBに接続しましたが、特に見所もないので割愛です。

連携例

タスクをCSVで渡すと、PERT図をDOT形式で返すツールを先日作成しました。

blog.3qe.us

Scalaアプリケーションはsbt-assemblyでJARに固めるとLambdaに突っ込むとすぐ動くという噂なので、これを使って以下のようなものを作ろうと画策しています。

  • Google Spreadsheet上でスクリプトを回す
  • シートがCSVに変換され、API Gatewayに届く
  • API GatewayがLambdaにCSVを渡す
  • LambdaはCSVをDOTに変換し、今回作成したGraphvizサーバにDOTを投げる
  • Spreadsheetに画像が届き、貼り付けられる

すごい!!!Spreadsheetでボタンを押すだけでいきなりPERT図が得られて見積りに役立てられる!!!

結語

なんかの入力を受けてなんかを返すだけのサービスはだいたいHTTP化できるような気がします。サンキューです。

f:id:Windymelt:20200304015807p:plain

*1:Scalaは標準でHTTPライブラリ付けてくれという気持ちがあります。