グラフ作成ツールであるGraphvizを社内でHTTPサービス化し、どこからでも使えるようにした話です。
こういう感じで使えます。
$ curl -X POST http://graphviz.ほげほげ.example.com/ -d 'digraph { foo -> bar -> buzz; }' https://ほげほげふがふが.amazonaws.com/958508bb186ef076c2cbb92c1e0c34ea0e51316e2d9bfe46620d2d6278db0f94.png
URLを開くとこういう画像になっています。ヤバイ!!
Graphviz便利だけどやや不便
Graphvizとは、グラフを作成するツールです。グラフといってもいわゆる棒グラフのようなものではなく、グラフ 理論とかに登場するあの有向グラフとか無向グラフのことです。
困り
このツールは便利なのですが、便利に使うにはいくつかの障害があります。
- インストールしなければならない
- 標準状態では日本語に非対応(文字化けします)
- 画像をやりとりする場合はどこかにアップロードしなければならない
そういうわけで、パッとプログラム等からグラフを生成したい!しかしこのサーバ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_ID
とAWS_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形式で返すツールを先日作成しました。
Scalaアプリケーションはsbt-assembly
でJARに固めるとLambdaに突っ込むとすぐ動くという噂なので、これを使って以下のようなものを作ろうと画策しています。
- Google Spreadsheet上でスクリプトを回す
- シートがCSVに変換され、API Gatewayに届く
- API GatewayがLambdaにCSVを渡す
- LambdaはCSVをDOTに変換し、今回作成したGraphvizサーバにDOTを投げる
- Spreadsheetに画像が届き、貼り付けられる
すごい!!!Spreadsheetでボタンを押すだけでいきなりPERT図が得られて見積りに役立てられる!!!
結語
なんかの入力を受けてなんかを返すだけのサービスはだいたいHTTP化できるような気がします。サンキューです。
*1:Scalaは標準でHTTPライブラリ付けてくれという気持ちがあります。