【Golang + echo】S3のファイルをバイナリデータでレスポンスする

はじめに

この記事はGo 4 Advent Calendar 2020 17日目の記事です。

こんにちは、株式会社カミナシのエンジニア @ImuKnskです。

早速ですが、S3にあるファイルをバイナリデータで取得してレスポンスに含めたいと思ったことはありませんか?

『カミナシ』ではバッチ処理でファイルを作成してS3にファイルをアップロードした後、Client側でファイルをダウンロードする機能があります。

よくある処理はClient側からAWSにアクセスをしてファイルをダウンロードする方法ですが、今回はAPI側を経由してファイルをダウンロードする方法について書きたいと思います!

ファイルをバイナリデータとしてレスポンスに含めたいがうまくいかず、困っている方の参考になれば幸いです!

この記事で分かること

  • API側でS3ファイルをダウンロードせずに取得する方法
  • 取得したファイルをレスポンスに含める方法(Golang + echo)
  • Client側で自動ダウンロードさせる方法(React Native)

開発環境

  • go (1.14.2)
  • echo (v3.3.10+incompatible)
  • aws-sdk-go (v1.34.4)
API側でS3ファイルをダウンロードせずに取得する方法
package main

import (
    "fmt"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/gommon/log"
)

func main() {
    key := "xxx_key"
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                log.Error(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                log.Error(err)
            default:
                log.Error(aerr)
            }
        }
    }
    defer obj.Body.Close()
}

S3のセッションを作成した後に、GetObjectを使ってファイルをダウンロードせずにオブジェクトとして扱いましょう。 GetObjectに関しては公式サイトを参照ください。

docs.aws.amazon.com

取得したファイルをレスポンスに含める方法(Golang + echo)
package main

import (
    "io"

    "github.com/labstack/echo"
)

func setResponse(c echo.Context, body io.Reader) {
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+"ファイル名+拡張子")
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}

echo.ContextのResponseに対して必要なヘッダ情報、ステータスコードを設定、最後にWriterに対してデータをコピーする流れになります。

ここでハマったのが、Responseの組み立てるところです。 ステータスコード、ヘッダ、データコピーの順番が違うと意図した結果にならず困っていました。

レスポンスの中身をAPI側とClient側でデバッグしたりしてみましたが、何が原因なのか分からず こちらを 参考にして解決できました。どうやらResponseを組み立てる順番があり、順番通りにしないと正常に動作しないようでした。

API側のまとめ
package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/echo"
    echomw "github.com/labstack/echo/middleware"
    "gopkg.in/go-playground/validator.v9"
)

type S3KeyRequest struct {
    Key string `json:"key"`
}

func main() {
    router := initRouter()
    router.Logger.Fatal(router.Start(":8080"))
}

func initRouter() *echo.Echo {
    e := echo.New()
    e.Use(echomw.CORSWithConfig(echomw.CORSConfig{
        AllowOrigins: []string{"*"},
        AllowHeaders: []string{echo.HeaderContentType},
        AllowMethods: []string{echo.POST},
    }))
    e.POST("/sample", getS3FileResponseBinary)
    return e
}

func getS3FileResponseBinary(c echo.Context) error {
    var req S3KeyRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    v := validator.New()
    if err := v.Struct(req); err != nil {
        return err
    }

    obj, err := getS3ObjectOutput(req.Key)
    if err != nil {
        return err
    }
    defer obj.Body.Close()

    setResponse(c, obj.Body)

    return c.NoContent(http.StatusOK)
}

func getS3ObjectOutput(key string) (*s3.GetObjectOutput, error) {
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                return nil, errors.New(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                return nil, errors.New(err)
            default:
                return nil, aerr
            }
        }
    }
    return obj, nil
}

func setResponse(c echo.Context, body io.Reader) {
    // ファイル名:この名前でファイルダウンロードされます
    encodeName := url.QueryEscape("ファイル名.xlsx")
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+encodeName)
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}
Client側から自動ダウンロードしてみましょう!
import React from 'react'
import { View, Button } from 'react-native'

const downloadFile = async (body: string) => {
  const options = {
    method: 'POST',
    body: body,
    headers: {
      'Content-Type': 'application/json'
    }
  }

  let fileName = ''

  return fetch(`http://localhost:8080/sample`, options)
    .then(res => {
      // content-dispositionからファイル名を取得します
      // ここはAPI側で設定したファイル名をそのまま利用するイメージです
      fileName = getFileNameFromContentDisposition(res.headers.get('content-disposition') ?? '') ?? ''
      if (fileName !== '') return res.blob()
    })
    .then(blob => {
      const anchor = document.createElement('a')
      anchor.download = fileName
      anchor.href = window.URL.createObjectURL(blob)
      anchor.click()
    })
    .catch(e => {
      throw Error(e)
    })
}

const getFileNameFromContentDisposition = (disposition: string) => {
  const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
  const matches = filenameRegex.exec(disposition)
  if (matches != null && matches[1]) {
    const fileName = matches[1].replace(/['"]/g, '')
    return decodeURI(fileName)
  } else {
    return undefined
  }
}

const App: React.FC = () => {
  return (
    <View>
      <Button
        title="ファイルダウンロード"
        onPress={() => {
          const key = JSON.stringify({ key: 'xxx_key' })
          downloadFile(key)
        }}
      ></Button>
    </View>
  )
}

export default App

Client、APIを繋いでファイルダウンロードした結果はこちらです。 gyazo.com

おわりに

いかがだったでしょうか。今回紹介した機能自体はよく記事にされていると思いますが、一部だけ紹介されているケースがほとんどかなと思います。私も一部だけ紹介されている記事を参考にしながら実装をしていましたが、いざClientとAPIを繋いだときに意図しない結果になり、困ることが多かったです。 そのため全体を通してまとめてみましたので、困っていた方に少しでも参考になれば幸いです!

最後に…

弊社ではエンジニアを募集しており、興味がある、話を聞いてみたい、応募したいという方はWantedlyTwitterのDMからお待ちしております!

【Go言語】自作コンテナ沼。スクラッチでミニDockerを作ろう

f:id:kaminashi-developer:20201214013719p:plain

初めまして。株式会社カミナシPMの@gtongy1です。

Dockerというツール。SRE, Backend, Frontendどの領域のエンジニアも馴染みのあるツールではないでしょうか。
コンテナを利用することにより、インフラの環境を一つの空間に梱包し、その内部で柔軟に様々な環境を作ることが出来ます。
コンテナの実体とはなんなのでしょう? 叡智が詰め込まれたそんな一つの宝箱のように見えます。

コンテナ作ってみたくなりませんか?

僕と同じように知的好奇心をくすぐられたそこのあなた!コンテナ沼の一歩目を一緒に踏み出してみましょう!

検証環境

Dockerの機能おさらい

docs.docker.com

まず、ドキュメント内を読み進めてDockerに対する知識を整理します。

f:id:kaminashi-developer:20201212165419p:plain

DockerはDocker daemonを基幹とし、その呼び出しを行うREST API, Docker Cliと機能が覆い被さっています。

f:id:kaminashi-developer:20201212170642p:plain

普段Cli経由で処理を行う、network, container, image, volumesはCli経由からこのDocker daemonの機能の呼び出しを行います。
RegistryからImageを取得し、ローカル内のImageをベースにコンテナを作成する部分は上記の図のようなイメージで呼び出されています。

そうなると気になるのは、Docker daemon。この内部の処理に全てのエッセンスが詰まっているように思われます。
Docker daemon内で動作しているのは、Linuxカーネルの基本となる機能群。 そこでLinuxのコンテナ周りに必要な機能の詳細、そして最小のコンテナを作って説明していきます。

Linux上にコンテナを呼び出し

大前提として、コンテナとは何者なのでしょうか。
コンテナを一言で言うと「 システムから分離されたプロセス 」です。
Linux上でunshareコマンドを打つことにより、最速でコンテナを作成することが出来ます。

※ 以降のコマンドはLinux上で実行しています。

$ unshare -u /bin/bash
$ hostname newhost && hostname
newhost

上記を実行することで、以下のようにプロセスが別に立ち上がっていることがわかります。
別のセッションでhostnameを実行してみましょう。

gyazo.com

コンテナの誕生です🎉!これであなたも自作でコンテナを作ったと言っても良いでしょう!おめでとう!
この立ち上がったプロセスこそがコンテナであり、この内部で動く技術こそがDockerの基幹の技術なのです。

Linux、コンテナ3種の神器

上記のコンテナの内部ではどのようにして、プロセスの立ち上がったのでしょうか。
これはLinux内に備わったコンテナに必要な3種の神器である、

  • Namespace
  • Control Group
  • File System

が関係しています。一つずつ説明していきましょう。

Namespace

Namespaceはコンテナ上に隔離された層を作成することが出来ます。
同一のカーネル上に別々の環境を作成するために、一意な名前を付けることによって別の環境であることを示すための機能こそがNamespaceです。
ネットワーク, 通信, ファイルマウント等処理を行う様々なプロセスはそれぞれでNamespaceを持っており、上記はunshareはプロセスを分離させNamespaceを作成しました。
Docker上でも同じようにNamespaceを活用しており、以下の種類があります。

  • pid: プロセスID。システム内での一意なことを表すID
  • net: ネットワークの管理
  • ipc: プロセス間通信の管理
  • mnt: ファイルシステムマウント管理
  • uts: バージョン識別子, カーネルの管理

立ち上げたコンテナに上記のようなNamespaceを利用することによって、プロセスを分離して各機能を利用することが出来ます。

Control Group(cgroup)

Control Groupはアプリケーションを特定のリソースセットに制限します。
以下コマンドを実行してみて、内部にファイルとして置かれているものがControl Groupです。

$ cat /sys/fs/cgroup/cpuset/cpuset.cpus
0-3

Control Groupを使用すると、オプションで制限や制約を強制することができます。
メモリの最大利用数や、プロセス最大実行数などそれぞれです。
以下のようなファイルによって、制限を付与することが出来ます。

File System

子プロセスが親からマウントされたFile Systemに関するデータのコピーを取得し、親と同じデータ構造へのポインタを取得して変更できるように出来ます。

$ cat /proc/mounts

システム内でマウントされている情報をファイルから情報を読み解くことが出来ます。

proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
tmpfs /dev tmpfs rw,nosuid,size=65536k,mode=755 0 0
devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0
sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0
tmpfs /sys/fs/cgroup tmpfs ro,nosuid,nodev,noexec,relatime,mode=755 0 0
cpuset /sys/fs/cgroup/cpuset cgroup ro,nosuid,nodev,noexec,relatime,cpuset 0 0
cpu /sys/fs/cgroup/cpu cgroup ro,nosuid,nodev,noexec,relatime,cpu 0 0

このように、Namespaceを利用し、File System, Control Groupを活用することで稼働するコンテナに対して制約を設け、必要なファイル群をマウントし、コンテナの型を作ることが出来るようになります。

Go言語コンテナ始めの一歩

それでは、Go言語を使って実装していきましょう!

// +build linux
package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("help")
    }
}

func run() {
    fmt.Printf("Running %v \n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3]...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    }
    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

まず始めに環境内で引数から渡されたコマンドを実行するまで作成します。
syscall.SysProcAttrとそのフラグのうちでCloneflagsを追加しています。
これは新規のプロセスに対して新しいNamespaceを追加することが出来ます。 これによって、今回のUTS(UNIX Time Sharing), PID, NS(mount周り)を新しいnamespaceで作成しています。

$ go run exec.go run echo hello
Running [echo hello]
hello

上記ファイルを引数を渡し実行すると、内部でコマンドが実行していることがわかると思います。
次はコンテナを子プロセスとして立ち上げてみてみましょう。

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("help")
    }
}

func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    // ...
}

func child() {
    fmt.Printf("Running %v \n", os.Args[2:])
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    must(syscall.Sethostname([]byte("container")))
    must(syscall.Chroot("/"))
    must(os.Chdir("/"))
    must(syscall.Mount("proc", "proc", "proc", 0, ""))
    must(cmd.Run())
    must(syscall.Unmount("proc", 0))
}

ここで変更を加えているポイントとして

  • childの関数を用意し、これまでと似たような関数を用意している。今までと比較して変更している点は以下
  • /proc/self/exe

のあたりが主に変わっているところでしょう。
今回の実装内で新規でchild関数を用意し、その内部でこれまでと同じようにプロセスを立ち上げ、syscall.Sethostnameで新しく立ち上げたプロセスにホスト名を付与しています。
また新しく立ち上げたプロセスに対して、syscall.Mount,Chrootを利用し子プロセスの設定を追加しています。
chrootは、子プロセスのルートの指定、syscall.Mountはファイルのマウントを行う処理です。
上記実装により、新しく立ち上げたプロセスに親プロセスの情報を追記することや、ディレクトリの構成を指定することが出来ています。
また、親プロセス内で実行している、/proc/self/exeは今実行しているプロセスを再実行します。この時の第一引数としてchildを渡しています。
よって、親プロセス内部で子プロセスを呼び出し、処理を実行することが出来ます。
それでは実行してみましょう。

root@24b10018b09a:/go# go run main.go run /bin/bash
Running [/bin/bash]
Running [/bin/bash]
root@container:/#

上記のようにプロセスが2つ実行され、hostnameがcontainerに変換されているのがわかります。
子プロセスの立ち上げと、その内部でプロンプト上で操作することができました!

Control Groupの作成

最後にControl Groupを追加していきます。

func child() {
    // ...
    setControlGroup()
}
func setControlGroup() {
    cgroups := "/sys/fs/cgroup/"
    pids := filepath.Join(cgroups, "pids")
    must(os.Mkdir(filepath.Join(pids, "gtongy"), 0755))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/pids.max"), []byte("20"), 0700))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/notify_on_release"), []byte("1"), 0700))
    must(ioutil.WriteFile(filepath.Join(pids, "gtongy/cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

Control Groupはファイル群です。
立ち上げた子プロセスのpidを設定に登録、このグループ内にその他の制約を設けています。
ここで上げているのはpids.maxで、設定したプロセスに対して最大数を設けています。
ここに追加でmemoryの制限や、CPUのリソースの制約を追加することも出来ます。

コードサンプル

最後にコードサンプルです。一連の流れを追うためにご活用ください。

github.com

終わりに

コンテナ作成の一連の流れを説明、またGo言語を利用してコンテナ化を行いミニDockerを作成してみました。
一連の流れを追い、小さい単位でスクラッチで機能を作成することが深い理解に繋がると思います。
こうして、自作で今まで難しいと思っていた機能を紐解いてみるとワクワクしませんか!
自分の知らなかった世界の広がりと、今動いているシステムの良さや改善できそうなところが滲み出てくる感覚がします。

そんな自作コンテナ沼。一緒に今年の年末年始のあいた時間に遊んでみてはいかがでしょうか。

最後に宣伝です。

カミナシでは上記のようなコンテナをAWS Fargateを利用し、本番環境で元気に動かしています。
そんな環境で、柔軟なインフラ作ったりアプリを成長させたりチャレンジしてみたい熱量持ったエンジニアの方を募集しています。
エントリーお待ちしております!!

参考資料

フロントエンドエンジニアがGoの書き方を理解する

本記事は Go3 Advent Calendar 2020 15日目の記事になります。

こんにちは、株式会社カミナシのエンジニア @tomiです。

JavaScript, Node.jsをメインに扱ってきたエンジニアがGoに触れるときにどう解釈したかを、JavaScriptGolangを比較しながら、理解を深める記事となっています。

はじめに

まず私自身のJavaScriptへの理解としては、VueやReact,Redux.Next.jsなどのフロントエンド周りに加え、ExpressやAdonisJSなどのバックエンドもかじっており、Node.jsでフルスタックに開発できるレベルです。
また、TypeScriptもここ1年ほどは個人開発でも積極的に取り入れております。

そんなJavaScriptにしがみついてきた私が、カミナシへのジョインをきっかけにGo言語を本格的に触れることになり、コードをどう噛み砕いったかを改めてまとめました。

ひとつひとつの機能を紹介するというよりは、Goではこう書くのかという視点となります。

さっそく、コードを見ながら比較してきます!
(Goが静的型付け言語なので、JavaScriptではなくTypeScriptとの比較がほとんどとなります。)

import/export

まずはじめに、コードを開くとpackageimport がほとんどのファイルに書かれています。

// hello/world.go
package hello

import (
  "fmt"
)

func World() {
  fmt.Println("Hello, World!")
}

上のソースを見たらだいたい察しは付くと思いますが、

importは外部パッケージを読み込んだり、作成したpackage を読み込むことができます。

JavaScriptでいうとこの部分。

import fmt from "fmt"

exportに関しては、packageに書いたパッケージ名から関数を読み出せるようにしており、先程のHello, World!を出力するには以下のように書きます。

package main

import (
  "./hello"
)

func main() {
  hello.World()
}
// Hello, World!

これをJavaScriptで書くと、以下のようになります。

// hello/World.js
export default function World () {
  console.log('Hello, World!')
}

// hello/index.js
import World from './World.js'

export default {
  World,
}

// index.js
import hello from './hello'

hello.World()
// => Hello, World!

Goでは同じpackage名のファイルを複数用意できるので、関数を複数のファイルに分割することがとても簡単になっています。

// hello/local.go
package hello

import (
  "fmt"
)

func Local() {
  fmt.Println("Hello, Local!")
}

// main.go
package main

import (
  "./hello"
)

func main() {
  hello.World()
  hello.Local()
}

// Hello, World!
// Hello, Local!

Var(変数)

変数でよく使う書き方が2パターンあります。

// ひとつめ
var hello string
hello = "Hello, World"

// ふたつめ
hello := "Hello, World"

:=とすることで、宣言と代入をまとめることができます。

Typescriptで書くと、以下のようになります。

// ひとつめ
let hello: string
hello = "Hello, World"

// ふたつめ
let hello = "Hello, World"

Function(関数)

上でもさらっと書いていますが、funcが関数となっています。

Goでは、Typescriptのように引数や返り値には型が必須になります。

func GetHello(name string) string {
  message := "Hello, " + name
  return message
}

(name string)で引数nameはString型であることを示し、関数getHelloの返り値の型を引数( )の後ろ書きます。

これをTypescriptで書くと、以下のようになります。

function GetHello(name: string): string {
  const message = `Hello, ${name}`
  return message
}

for ... range(繰り返し)

fruits := []string{"apple", "banana", "orange"}
for index, fruit := range fruits {
  fmt.Println(index, fruit)
}
// 0 apple
// 1 banana
// 2 orange

for i := 0; i < 10; i++ { ... }という書き方もありますが、for ... rangeを使ったやり方の方が良く見かけます。

indexが不要な場合は、ブランク修飾子_を使って、for _, fruit := range fruitsと書きます。

これをTypescriptで書くと、以下のようになります。

const fruits = ["apple", "banana", "orange"]
fruits.forEach(fruit, index) {
  console.log(index, fruit)
}

Struct(構造体)

type User struct {
  name string
  age int
}
tom := User{"Thomas", 20}
fmt.Println(tom.name, tom.age)
// Thomas 20

これをTypescriptで書くと、以下のようになります。

type User = {
  name: string
  age: number
}
const tom = new User("Thomas", 20)
console.log(tom.name, tom.age)

classを使った書き方もできます。

class User {
  name: string
  age: number
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }
}
const tom = new User("Thomas", 20)
console.log(tom.name, tom.age)

レシーバ(構造体と関数の紐付け)

先程作成したtomが、tom.intro()という関数で自己紹介を出力したいとする。

type User = {
  name: string
  age: number
}

func (u *User) intro () {
  fmt.Println("I am " + u.name)
}
const tom = new User("Thomas", 20)
tom.intro()
// I am Thomas

関数名の前に構造体を記述することで、構造体用の関数であることを示し、関数内で構造体を扱うことができる。

これをTypescriptで書くと、以下のようになります。

class User {
  name: string
  age: number
  constructor(name: string, age: number) {
    this.name = name
    this.age = age
  }

  intro() {
    console.log(`I am ${this.name}`)
  }
}
const tom = new User("Thomas", 20)
tom.intro()

はじめてこの関数を見たときは、関数名の前後に( )がある・・・なんだ?🤔となりました。

実際には↓のように引数も返り値もあるので、さらに複雑に見えたのが初見の印象。

func (r *Report) GetReports(limit, offset int64, query *entity.GetReportsQuery) (*entity.Reports, int64, error) { ... }

まとめ

JavaScript脳がGolangを理解するために、JavaScriptGolangを比較してみました。

この書き方で戸惑っていたのかと思うと自分のレベルの低さに落ち込みますが、Goを触ってみたいという人も同じところで悩むはず!
そんな人のお役に立てる記事となれば幸いです。

GoでGraphQL Subscriptionsを実装する

はじめに

この記事はGo3 Advent Calendar 2020の11日目の記事です。gqlgenを使ってGraphQL Subscriptionsを実装する方法とハマったポイントを紹介したいと思います。

利用技術

  • gqlgen
    • GraphQL SchemaからGoのコードを出力するコードファーストなライブラリ
  • Redis Pub/Sub
    • Redisでクライアント間通信を行う
  • echo
    • Webフレームワーク

今回はドメイン3層とgqlgenの生成物を合わせた構成にしています。 詳しくは先日の記事にまとめています。

実装した内容はGitHubに公開しました。
※記事の中のコードはわかりやすさを重視するため今回の内容に関わらない部分は省略しています

github.com

Docker環境の構築

docker-compose.yaml にRedisコンテナを追加します。

version: '3.7'
services:
  redis:
    image: redis:latest
    ports:
      - "6379:6379"

Redis Clientの選定

Goのパッケージに公開されているRedis Clientは公式ドキュメントにまとめられていて、星マークがついてるライブラリがRedis公式に推奨されてます。最初はその中のRadixを使用していましたが、Redisのコマンドを文字列で入力する必要があったため、Redisコマンドをメソッドとして抽象化しているgo-redis/redisを使用しました。

Redis Clientセットアップ

まずライブラリをインストールします。

$ go get -u github.com/go-redis/redis/v8

Redis Clientはインフラ層に定義し、環境変数からURLを取得して初期化しています。

type Store struct {
    Client redis.UniversalClient
    TTL    time.Duration
}

func NewRedisClient(ctx context.Context) (*Store, error) {
    client := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_URL"),
    })
    err := client.Ping(ctx).Err()
    if err != nil {
        return nil, err
    }
    defaultTTL := 24 * time.Hour
    return &Store{Client: client, TTL: defaultTTL}, nil
}

Pub/SubのクライアントはデータストアであるRedis本体とは目的が異なるため、Storeとは別の構造体で定義しました。

model.Matchドメイン層の構造体で、ユーザー間のマッチングをやり取りするためのデータです。model.Match が生成されたタイミングで通知するchannelを定義します。今回は一つのみにしていますが、GraphQL Subscriptionsが複数ある場合はchannelも複数定義していきます。

type PubSubClient struct {
    Store         Store
    MatchChannels map[string]chan *model.Match
    Mutex         sync.Mutex
}

func NewPubSubClient(ctx context.Context, store Store) *PubSubClient {
    subscriber := &PubSubClient{
        Store:         store,
        MatchChannels: map[string]chan *model.Match{},
        Mutex:         sync.Mutex{},
    }
    return subscriber
}

定義した2つのクライアントをResolverに注入します。

type Resolver struct {
    DB           *gorm.DB
    StorePool    *store.Store
    PubSubClient *store.PubSubClient
}

各ResolverでRedisを利用できるようになりました。

スキーマの追加

GraphQLスキーマにSubscriptionsを追加します。 今回はユーザーとユーザー間を Match で関連付けるデータ構造にしているため、 Match オブジェクトが生成されたタイミングでユーザーに通知する仕組みを作ります。

type Mutation {
    createMatch(input: NewMatch!): Match!
}

type Subscription {
    createMatch(userUID: String!): Match
}

スキーマを編集した後、 gqlgen コマンドを実行してSubscriptionsのResolverを生成します。 resolver パッケージに以下のコードが追加されたら完了です。

func (r *subscriptionResolver) CreateMatch(ctx context.Context, userUID string) (<-chan *model.Match, error) {
   panic(fmt.Errorf("not implemented"))
}

他のResolverと異なり、関数の返り値がchannelになっています。

Subscriptionsの実装

GraphQLでSubscriptionsを実装する場合、以下の内容を追加する必要があります。

  1. APIがRedis PubSubから配信されるメッセージを受信
  2. ユーザーがSubscriptionsを実行したときびWebSocket通信
  3. ユーザーがMutationを実行したときにRedis PubSubに配信

実装は PubSub Client のメソッドに追加していき、Resolverでは定義されたメソッドを呼び出すようにしました。

それぞれ実装をみていきます。

1. APIがRedis PubSubから配信されるメッセージを受信

API起動時にRedis PubSubからのメッセージを受信し、パースしたデータをChannelに流し込みます。 msg.Payload がRedisからのメッセージになっています。

func (s *PubSubClient) StartMatchChannel(ctx context.Context) {
    go func() {
        pubsub := s.Store.Client.Subscribe(ctx, channel.MatchChannel)
        defer pubsub.Close()

        for {
            msgInterface, _ := pubsub.Receive(ctx)
            switch msg := msgInterface.(type) {
            case *redis.Message:
                m := model.Match{}
                if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
                    continue
                }
                for _, ch := range s.MatchChannels {
                    ch <- &m
                }
            default:
            }
        }
    }()
}

2. ユーザーがSubscriptionsを実行したときのWebSocket通信

ユーザーがSubscriptionsを実行するときにユーザーごとのchannelを生成します。

構造体に定義しているchannelのキーをユーザーIDにしてRedisに保存し、受信状態を作ります。

func (s *PubSubClient) Receive(ctx context.Context, userUID string) <-chan *model.Match {
    _, err := s.Store.Client.SetXX(ctx, userUID, userUID, 60*time.Minute).Result()
    if err != nil {
        return nil
    }

    match := make(chan *model.Match, 1)
    s.MatchChannels[userUID] = match

    go func() {
        <-ctx.Done()
        delete(s.MatchChannels, userUID)
        s.Store.Client.Del(ctx, userUID)
    }()

    return match
}

3. ユーザーがMutationを実行したときにRedis PubSubに配信

最後にSubscriptionsが発火するMutation内でRedis PubSubにデータを配信します。

func (s *PubSubClient) Publish(ctx context.Context, match *model.Match) error {
    matchJSON, err := json.Marshal(match)
    if err != nil {
        return err
    }
    s.Store.Client.Publish(ctx, channel.MatchChannel, matchJSON)
    return nil
}

これでMutationが実行されるたびにユーザーにデータを配信できます。

ハマったポイント

gqlgen generateできない

最初にリポジトリを生成してから2ヶ月ほど経過してから修正したため、gqlgenのバージョンがCLIは0.12.2に対してgo.modは0.13.0と差分が出てました。 この状態で gqlgen generate を実行すると generated.go のなかで not declared by package エラーが発生してしまいます。

今回はCLIのgqlgenを再インストールすることで解決しました。

WebSocket通信できない

gqlgenの handler には NewDefaultServer メソッドが存在していて、返り値のserverには大枠の設定が自動で行われています。しかしWebSocketの設定も追加されているためRedis PubSubと接続するときにoriginチェックの処理で落ちてしまいます。

そのため handlerNew メソッドを使って必要な部分だけ Transport するようにしました。

WebSocket通信が認証エラーになる

実装をまとめてるリポジトリでは元々Firebase Authを組み込んでいいて、Go上ではクライアントから渡されたトークンをFirebase Authに確認しユーザーIDを取得する処理を行っていました。 Firebase Authの認証処理をechoの middleware で定義していたため、WebSocket通信でも認証チェックが行われてしまい、ヘッダーのトークンを参照できないためエラーになってしまいました。

今回は echo.Context に含まれている IsWebSocket メソッドを利用してWebSocker通信では認証処理をしないようにしました。

まとめ

gqlgenとRedis PubSubを使ってGraphQL Subscriptionsを実装しました。 Goのchannelに流し込むとクライアントに配信してくれるので比較的簡単に機能を作れました。 今後はchannelが増えたときに設計をどう拡張・修正していくか考えてみたいです。

参考

Gatsby・Wordpress・Netlifyで開発したときの躓きと解決方法

こんにちは、株式会社カミナシのエンジニア @tomiです。

前回、「サービスサイトをGatsby×Wordpress×NetlifyでJamstackなサイトにリニューアル」という記事を書き、たくさんの反響をいただきました。ありがとうございます。

kaminashi-developer.hatenablog.jp

今回は、GatsbyWordpress・Netlifyで開発したときの躓きについての話で、似た構成で開発される方の役に立つと思います。

逆に、違う構成の場合は、全くためにならない内容となっていますので、ご容赦を。

開発していて躓いたところ

  1. Docker上でWordpressからGraphQLでの記事の取得がエラーになる
  2. WordpressをGraphQLで読み込む際プラグインのバグがある
  3. Gutenbergのブロック作成に手こずった
  4. GutenbergブロックのTypeScriptの型指定に困った
  5. Netlifyでbuildエラー
  6. Netlifyにネームサーバーを変更せずにルートドメインを繋げる

各躓きの解消方法

① Docker上のWordpressから記事の取得がエラーになる

Dockerで開発環境を整備している際、以下のようなエラーが発生した。

error  gatsby-source-wordpress  

Unable to connect to WPGraphQL. 

Received HTML as a response. Are you sure http://wordpress/graphql is the correct URL?

Wordpressへの接続エラーが発生してしまった。

原因は、WordpressのURL設定のアドレスに強制的にリダイレクトされるため

Wordpressの仕様として、サイトアドレスに設定されているURLにリダイレクトされる。

f:id:kaminashi-developer:20201120163336p:plain

f:id:kaminashi-developer:20201120163331g:plain

127.0.0.1:8000にアクセスするが、localhost:8000にリダイレクトされる

そのため、http://wordpress/graphqlから取得しようとするが、localhost:8000にリダイレクトされてしまう。

Gatsbyコンテナからlocalhost:8000にアクセスしてもWordressコンテナにアクセスすることはできないので、接続エラーが発生してしまった。

f:id:kaminashi-developer:20201120221138p:plain

解決方法

http://wordpress/graphqlでアクセスできるように、docker-compose.ymlに以下の設定を追加しました。

wordpres:
  environment:
    WORDPRESS_CONFIG_EXTRA: |
      define('WP_SITEURL', 'http://' . $$_SERVER['HTTP_HOST']);
      define('WP_HOME', 'http://' . $$_SERVER['HTTP_HOST']);

この設定によって、Wordpressにアクセスした際のURLがサイトアドレスとなる。

f:id:kaminashi-developer:20201201011853p:plain

このハックはWordpressをDockerで起動する際によく使いそうなので、覚えておくと便利そう。

WordpressをGraphQLで読み込む際プラグインのバグがある

gatsby-source-wordpress-experienceはまだまだバグがありそうです。

gatsby-source-wordpressというのが今のメインバージョンだが、もうしばらくすると破壊的変更が来るので、多少のリスクは承知で次のバージョンであるgatsby-source-wordpress-experienceを使うことを選択しました。

今回引っかかったのは、カテゴリの小カテゴリが取得できないというバグ。(今は解決済み) https://github.com/gatsbyjs/gatsby-source-wordpress-experimental/issues/145

開発を進めていくうちに、あれ?うまく取得できない・・・みたいなことがある。

ちなみに、Gutenbergブロックの取得がデプロイするとき取得できず、たまにデプロイエラーが発生する。

f:id:kaminashi-developer:20201120223419p:plain

発生条件は不明だが、WP管理画面から「WP GraphQL Gutenberg」プラグイン設定ページのUpdateボタンを押してからデプロイすると治る。。

f:id:kaminashi-developer:20201120223315p:plain

③ Gutenbergのブロック作成に手こずった

Gutenbergブロックを自作しようと思っているなら、覚悟を持って取り組みましょう!

とりあえずドキュメントが少なすぎる!

必読なのは、公式のチュートリアル
https://ja.wordpress.org/team/handbook/block-editor/tutorials/create-block/

これでGutenbergブロックを作るプラグインを作成する流れを掴むことができます。

次にブロックの設定周りはこちらのドキュメントを何度も見ました。 https://ja.wordpress.org/team/handbook/block-editor/developers/richtext/

ただし

  • リスト形式のブロックを作りたいだとか、
  • 投稿タイプのテンプレートにブロックを配置しておきたいとか、

基本的な部分はドキュメントに掲載されているが、実装されているすべての機能のドキュメントが見当たらなかった。

なので、やりたいことが実現できそうなコンポーネントを、こちらのメニューの一覧から探す。
https://developer.wordpress.org/block-editor/components/

目的のコンポーネントぽいな〜と思っても、このドキュメントには詳細は書かれていないので、コンポーネントリポジトリを読み漁る! https://github.com/WordPress/gutenberg/tree/master/packages/components/src

ひたすらトライ&エラーを繰り返して感覚を掴むしかありません! 経験あるのみ!

④ GutenbergブロックのTypeScriptの型指定に困った

WordpressからGraphQLで取得したデータもgatsby-plugin-typegenで型は自動生成できてとても便利だが、特定のブロックにだけあるプロパティを使うときの型指定が複雑になってしまった。

以下の画像のようにblocksの中に多数のブロックの型が含まれている。

f:id:kaminashi-developer:20201120230953p:plain

セミナーの開催日時をGutenbergブロックから抽出しようとすると、日付のプロパティはSeminarGuidelineDateBlockTypeというブロックの型にしか含まれていないので、blocksの数ある中から型を指定しなければならなかった。

その型定義のために、コードが複雑になってしまった。

SeminarGuidelineDateBlockTypeを指定するためだけに書いたコード

f:id:kaminashi-developer:20201120233525p:plain

⑤ Netlifyでbuildエラー

f:id:kaminashi-developer:20201130165721p:plain

4:26:04 PM: error There was an error in your GraphQL query:
4:26:04 PM: Unknown type "WpSeminarGuidelinesBlock". Did you mean "WpSeminarFlowBlock", "WpSeminarFieldsEnum", "WpSeminarFilterInput", "WpSeminarConnection", or "WpSeminarGroupConnection"?

上記画像のようにビルドするとエラーがでることがありました。

エラー内容から察するに、自作ブロックがGraphQLで見つけられないようです

コードは以下のように、blocksから自作ブロックを取得しているところが原因でした。

export const wpSeminarEdgeFields = graphql`
  fragment WpSeminarEdgeFields on WpSeminarEdge {
    node {
      blocks {
        name
        ... on WpSeminarGuidelinesBlock {
          innerBlocks {
            name
            ... on WpSeminarGuidelineBlock {
              attributes {
                content
                label
              }
              innerBlocks {
                name
                saveContent
                ... on WpSeminarGuidelineDateBlock {
                  attributes {
                    endAt
                    startAt
                  }
                }
              }
            }
          }
        }
      }
      featuredImage {
        node {
          localFile {
            childImageSharp {
              fixed(width: 1200, height: 628, cropFocus: CENTER) {
                ...GatsbyImageSharpFixed
              }
            }
          }
        }
      }
      id
      slug
      title
    }
  }
`

解決方法

gatsby-node.jsで生成するページを公開記事だけに絞り込むことで、解決できました。

  const createSeminar = async () => {

    // ...

    const result = await graphql(`{
      seminar: allWpSeminar(filter: { status: { eq: "publish" } }) {  // <--ここ
        pageInfo {
          totalCount
        }
        edges {
          node {
            slug
          }
        }
      }
    }`)

   // ...

    seminars.forEach((edge, i) => {
      createPage({
        path: `/seminar/${decodeURIComponent(edge.node.slug)}/`,
        component: showTemplate,
        context: {
          slug: edge.node.slug,
        },
      })
    })
  }

⑥ Netlifyにネームサーバーを変更せずにルートドメインを繋げる

Netlifyにカスタムドメインを設定する方法は、Netlify DNSを利用するか、外部DNSの設定を編集する、2パターンがあります。

今回は、外部DNSでかつルートドメイン(kaminashi.jp)をNetlifyに繋げました。

ドキュメントを見ると、CNAME, ANAME, ALIASで繋げる方法が載っているのですが、使ってるDNSサーバーがルートドメインに対してCNAME等が使えずに少しハマりました。

結局ドキュメントに目をしっかり通しておらず、Aレコードでの設定についても書いてあるのを見落としていただけでした。。。

Point the record to Netlify's load balancer IP address: 104.198.14.52.

まとめ

以上、GatsbyWordpress・Netlifyで開発したときに実際に躓いたところ、困ったところでした。

gatsby-source-wordpress-experimentalwp-graphqlGutenbergもまだ成熟していないので、不安定なところが多いです。

しかし、SSGのGatsby、比較的新しいAPI企画のGraphQL、Wordpressの新しいエディタGutenbergと新しいことだらけでチャレンジしがいのあるの技術でした。

振り返ってみると、この投稿の前日に公開された、カミナシが掲げている3つのバリューのひとつである「β版マインド」を実行していたなと。
今後もトライ&エラーを繰り返しながら愚直に成長していきます。

カミナシのミッション・バリューが気になった方はこちら

corp.kaminashi.jp

iPhone 12 Pro(LiDAR) + ARFoundationで ARゲームことはじめ

f:id:kaminashi-developer:20201127042610p:plain

こんにちは、カミナシ・エンジニアの浦岡です。

iPhone12 Pro に搭載されているLiDARセンサーを活用したくARゲームの作成にチャレンジしようと思います。

今回、以下のような簡易的なゲームの作成を目標にします。

f:id:kaminashi-developer:20201127042833p:plain
ゲームの流れ

①現実世界のスキャン

ゲームの開発環境にはUnityのARFoundationを使用します。

UnityEditor上でARFoundationのARMeshManagerを設定することで、LiDAR(ARKit/RealityKit)でスキャンした現実世界のメッシュ情報をUnityEngine側に反映させることができます。

ここでゲームのリアリティを高めるためにOcclusionを有効にしたいと思います。 この設定を有効にすることで、下の画像のように仮想コンテンツが現実の物体に遮蔽されるようになります。

f:id:kaminashi-developer:20201127043659j:plain
Occlusion有効

(境界のディテールがまだ荒かったりしますが、今後の進化に期待したいポイントですよね)

ここまでが設定された状態のサンプルが以下のScenes/Meshing/OcclusionMeshing にあるので、以降これをベースに進めます。

ARFoundation Sample github.com

②バーチャルなキャラを出現させる

次に、メインキャラクターとして弊社マスコットの「ごーとん」をゲーム内で使用できるようにします。

  • 「ごーとん」3DオブジェクトをUnityにインポートして、Prefab化。

  • そのPrefabにColliderとRigidbodyを設定します。

  • さらに当たり判定時の識別用にtagを付与するようにします。

f:id:kaminashi-developer:20201127044507p:plain
「ごーとん」の3Dオブジェクトをインポート

「ごーとん」の出現イベントを定義します。 (今回は簡易的にするために、プレイヤーがタップした位置に「ごーとん」を出現させます。)

シーン配下のAR Session Origin > AR Cameraを選択して、 [Add Component]でスクリプトを追加。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARSubsystems;

namespace UnityEngine.XR.ARFoundation.Samples
{
   [RequireComponent(typeof(Camera), typeof(ARRaycastManager))]
   public class SetGoat : MonoBehaviour
   {
      
       [SerializeField]
       Rigidbody m_GoatnPrefab;

       public Rigidbody goatnPrefab
       {
           get => m_GoatnPrefab;
           set => m_GoatnPrefab = value;
       }
    
       private ARRaycastManager raycastManager;
       private List<ARRaycastHit> hitResults = new List<ARRaycastHit>();

       void Awake()
       {
           raycastManager = GetComponent<ARRaycastManager>();
       }

       void Update()
       {
           if (ARGameManager.Instance.GetState() != ARGameState.Scanning) {
               return;
           }

           if (m_GoatnPrefab == null)
               return;

           if (Input.touchCount < 1)
               return;
          
          
           var touch = Input.touches[0];

           if (touch.phase == TouchPhase.Began
               && raycastManager.Raycast(touch.position, hitResults, TrackableType.Planes))
           {
               var spawnPosition = hitResults[0].pose.position + new Vector3(0, 2, 0);
               Instantiate(m_GoatnPrefab, spawnPosition, Quaternion.identity);
               ARGameManager.Instance.ChangeState(ARGameState.Capturing);
           }
       }
   }
}

ゲームの状態を保持するためのクラスを作成しておき、ゲーム状態がScanningで、画面がタップされた場合にRaycastして平面を取得。その座標にごーとんを配置します。 メッシュにめり込んだ状態で出現しないようにy軸方向にオフセットをつけています。 出現後、ゲームの状態をCapturingに更新します。

③ボールを当てて捕獲

最後に、捕獲用のボールです ボールオブジェクトはサンプルで使われているもの(Projectile)を流用します。 このPrefabに当たり判定用のスクリプトを追加。

ボールが 「ごーとん」 とぶつかった場合の処理を実装できます。

f:id:kaminashi-developer:20201127050031p:plain
ボールオブジェクト

ボールの投下処理もサンプルを流用します。 AR Session Origin > AR Camera の ProjectileLauncher

using UnityEngine;

namespace UnityEngine.XR.ARFoundation.Samples
{
   [RequireComponent(typeof(Camera))]
   public class ProjectileLauncher : MonoBehaviour
   {
       [SerializeField]
       Rigidbody m_ProjectilePrefab;

       public Rigidbody projectilePrefab
       {
           get => m_ProjectilePrefab;
           set => m_ProjectilePrefab = value;
       }

       [SerializeField]
       float m_InitialSpeed = 25;

       public float initialSpeed
       {
           get => m_InitialSpeed;
           set => m_InitialSpeed = value;
       }

       void Update()
       {
           if (ARGameManager.Instance.GetState() != ARGameState.Capturing) {
               return;
           }

           if (m_ProjectilePrefab == null)
               return;

           if (Input.touchCount < 1)
               return;
          
           var touch = Input.touches[0];
           if (touch.phase == TouchPhase.Began)
           {
               var ray = GetComponent<Camera>().ScreenPointToRay(touch.position);
               var instant = Instantiate(m_ProjectilePrefab, ray.origin, Quaternion.identity);
               var rigidbody = instant.GetComponent<Rigidbody>();
               rigidbody.velocity = ray.direction * m_InitialSpeed;
           }
       }
   }
}

ここまでで、Unityでbuild、さらにXCodeで実機ビルドした状態が以下です。

youtu.be

簡単な流れはできましたがゲーム性が皆無なので全体的に作り込みが必要ですね

感想

AR的な要素は画面上の設定でほぼ完了するので、ARゲームというよりUnityゲームを作っている感じです。動作確認するのに毎回実機ビルドが面倒なので、作り込む場合は一旦Unity側でゲーム完成させるか、ARFoundation editor remote(有料)などデバッグツールが必須かもしれません。

個人的に、ゲーム以外での実用面含めてARな世界を待望しています。 いつかカミナシのプロダクトでもAR使ってみたいのでそれまでゲームで勉強するぞ👾👾👾

スタートアップが取組むコンテナ化。EC2からECS Fargate移行の道のり

初めまして。株式会社カミナシPMの@gtongy1です。

みなさんは、インフラのコンテナ化はお済みでしょうか?

弊社は今年6月頃にサービスを正式にリリースしたのですが、それ以前はEC2 + ELBでインフラを構築しており、それまでになかなかコンテナ化をしたくても出来ない状態でした。
各社様々な背景はあると思いますが、自分は

  • コンテナ化をすればいいのは、なんとなくわかる。ただ、どこから始めたらいいんだろうか
  • EC2構成でも動いているがために、なかなか変えようとする一歩目が踏み出せない
  • コンテナ化を本番環境で構築した経験がない。実際にどんなことが課題として上がるんだろうか

あたりに不安を感じていました。
ただ、インフラ運用に事業の足を取られてしまうリスクを抱える、それが嫌でコンテナ化を今回行いました。
そんな中での取り組みや課題感を先日話してきたので、その詳細をお伝え出来ればなと思います。

発表資料

そもそもの構成(EC2 + ELB)の課題感

サービス初期は、以下のような構成で開発を行っていました。

f:id:kaminashi-developer:20201125013310p:plain

よくあるEC2 + ELBの冗長化構成を取っていたのですが

  • EC2へ直接scpでバイナリコピーを行う
    • CI上でバイナリを挿げ替えるタイミングで一時的にダウンタイムが発生してしまう
  • EC2内のミドルウェア(fluentd, systemdなど)がブラックボックス
    • 外部サービスへの繋ぎこみの難易度の高さ、インフラの壊しにくさ

の課題感にぶつかりました。

EC2を利用している方は馴染みのある課題感だと思いますが、弊社も例外ではなく同じような課題に悩まされていました。
立ち上げ期/検証のフェーズのため、インフラそのものを止めたとしてもリクエストも多くなく、障害となることも当時は少数でした。
ただ、今後のサービスの安定性を担保するフェーズにおいて、インフラそのもの柔軟性が下がるとどうか。
将来のコストを暗黙的に増大させることになる未来が想像出来ます。

なるべく初期のタイミングで、インフラの足回りを整えることは開発のサイクルを高速で回すためにも必要なコスト!と割り切り、移行へと黙々と取組みました。

コンテナ化後のインフラ構成

コンテナ化後は以下のような構成を取っています。

f:id:kaminashi-developer:20201125014530p:plain

golangで吐き出したシングルバイナリを起動するalpine linuxのコンテナをFargate上で起動し、その他のミドルウェアに関連するコンテナをサイドカーパターンで別で切り分けるようにしています。

f:id:kaminashi-developer:20201125015558p:plain
サイドカーパターン。コンテナの責務を各ユニットで分割

f:id:kaminashi-developer:20201125090351p:plain

メインコンテナとはまた別のユニットとして立ち上げるため、仮にサイドカーで立ち上げたコンテナにエラーが発生した時にもメインコンテナ内への影響はなく動作するところがメリットです。
またサイドカー形式で立ち上げる時に、ログ周りのメリットも大きくてAWS側のfirelensを利用することが出来ます。

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/using_firelens.html

firelensは、サイドカーで立ち上げているFluentdもしくはFluent Bitに対してログを流し込むことが可能となるルーティングの設定です。

{
  "containerDefinitions": [
   {
      "name": "log_router",
      "essential": true
      "logConfiguration": {
        "logDriver": "awsfirelens"
      }
   },
   {
      "name": "api_server",
      "essential": true,
      "firelensConfiguration": {
        "type": "fluentbit",
        "options": {
          "config-file-type": "file",
          "config-file-value": "/fluent-bit/etc/extra.conf",
          "enable-ecs-log-metadata": "true"
        }
      }
    }
  ]
}

APIサーバー側からfirelensを利用して、Fluent Bitへログを流しこみます。
ログだけを切り取ってみるとAPIサーバー側はどこにログを出力を行うか、Fluent Bit側は受け取ったログをどう取り扱うかに責務がうまく分けることが出来ます。
弊社はFluent Bit側の設定ではdatadogへログを流し込んでいるのですが、EC2の時はAPIサーバーにこの辺りの設定も書き込まれていたりしたので、この辺がとてもすっきりしました。

またCodePipelineで記述されているCI/CD周りはAWS CDKでIaC化し、壊しやすいインフラは積極的にコード化していきました。
この時に使ったformer2というものがとても便利。
CloudFormationだけでなく、AWS CDKにも使えたりするので実装時には結構重宝しました。

dev.classmethod.jp

課題の探索

大枠のインフラの構成を作った後はひたすらテストですが、このあたりは負荷テストを行いながら課題を探索して行きました。

あたりの観点から、負荷テストの各項目に対して負荷をかけながら実際のエラーを測定してきました。 APM(AWS X-Ray)と統合監視ツール(Datadog Synthetic Monitoring)を使い分けしながら、実際の性能や単体ごとのケースを確認していきます。

性能テスト - スループット, 応答時間

Datadog Synthetic Monitoringは各エンドポイントに対して想定されるパターンにリクエストを送信します。
この時、どのくらいのレスポンスタイムがかかるか、また想定通りのパラメータが返ってきたかを確認しながら判定を行います。
一度エンドポイントのテストを作成すればその後の運用も楽になるのもいいところ。

f:id:kaminashi-developer:20201125215930p:plain

このあたりは先日まとめたので、気になる方は以下をご覧ください。

docs.google.com

過負荷テスト, 長時間テスト, 障害テスト

過負荷をかけるテストに関してはvegetaを利用しています。

f:id:kaminashi-developer:20201125220736p:plain

CLI経由から気軽に過負荷, 長時間のテストを実行できるのがいいところ。
また、レポートもhtmlで出力してくれるため、実際に負荷をかけて一つずつ課題を潰していくような流れでテストを行っています。

障害テスト周りでは、

  • Auroraで立ち上がったDBのinstanceに対してfailoverさせてみる
  • ECSのデプロイ時にRollBack
  • ECS内のタスクを一つ削除

してみたりと、一つずつケースを潰す作業を行いながらテストを進めていきました。

起こった課題

  • 各タスクのCPUの負荷が100%を超過するので、タスクのCPUの調整
  • タスク内のlimit値の調整
  • リクエスト数過多によるDead Lockの発生
  • N+1問題の解決
  • DBのConnection数の爆発

などなど。
負荷をかけてみることで改めて顕在化するようなデグレを地道に潰す作業です。
この負荷テストを行うまで自分は、golangのdatabase/sqlパッケージを利用して毎回Open/Closeしてました。ぽかミスすぎて恥ずかしい。

golangのdatabase/sqlパッケージは、ファイルのOpen/Closeとかとは違って毎回Open/Closeする必要はなく、ランタイム内で既存のconnectionを使い回すことを想定した作りになっています。

www.alexedwards.net

その設定の中で、SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetimeを使い、コネクションの最大接続数/最大接続時間を制御します。
ここで急激にconnectionが増加してとても焦りましたが、X-Rayとか実際のRDBのログとかを追いながらエラー探しあてていました。

f:id:kaminashi-developer:20201125222647p:plain
X-Rayでリクエストのエラーを表示

この辺りにツール使いながら、特定の箇所でのトレーシングを行えたり、接続数を見ながら負荷テストを行えたのもいいところですね。

まとめ

スタートアップが取り組むコンテナ化の例として、カミナシが取り組んできたインフラ移行の事例について紹介させていただきました。
今回やってみて思ったのは、えいやで変更する気概と動作テストがとにかく大事。ベストプラクティスは世の中に多く転がっていたりするので。
あとは課題に恐れず前に進めることで、とそのあとの運用が楽になる切符を手にすることが出来ます。
将来継続的に事業障害となる箇所を未然に防ぐため、未知なものの投資を積極的に行っていきましょう!

最後に宣伝!!

カミナシではこんな感じで、日々技術の課題も現場の課題も山積みです。どちらもメンバー全員で共に山に登っている真っ最中です。
そんな環境で、チャレンジしてみたい熱量持ったエンジニアの方を募集しています。エントリーお待ちしております!!

参考資料