every Tech Blog

株式会社エブリーのTech Blogです。

ライブの録画配信にAmazonIVSを活用する

はじめに

こんにちは。DELISH KITCHEN 開発部の村上です。 DELISH KITCHENでは、AmazonIVSを用いて去年ライブ機能をリリースしました。AmazonIVSやライブ配信基盤については以前こちらのブログで紹介しているので気になる方はぜひみてください。

tech.every.tv

今回はこのライブ機能の録画配信にAmazonIVSの録画機能を活用する機会があったのでその取り組みを紹介させていただきます。 なお、社内ではライブの録画配信機能をアーカイブ配信と呼んでいるので、これ以降はアーカイブ配信という言葉を使わせていただきます。

S3への録画機能

AmazonIVSではS3への自動録画を機能として提供しています。AmazonIVSではライブストリームに関する設定情報をチャンネルという単位で提供をしていますが、録画設定はチャンネルから独立しており、複数のチャンネルで同じ録画設定を紐付けることができるようになっています。

以下のような項目を自動録画の設定としてカスタマイズすることができます。

  • 記録するビデオのレンディション
  • サムネイルの記録
    • 記録の間隔
    • 解像度
    • 保存方法
  • フラグメント化されたストリームのマージ
  • 録画動画の格納先S3

コンソール上の設定画面

実際に録画を有効にしたチャンネルでライブストリームを開始すると指定したS3にこのような形で記録したものが保存されていきます。

/ivs/v1/<aws_account_id>/<channel_id>/<year>/<month>/<day>/<hours>/<minutes>/<recording_id>

保存されるものは大きく分けて二つのカテゴリに分かれています。

  • /events
    • 開始、終了、失敗といった録画イベントに対応するJSON形式のメタデータファイル
  • /media
    • hls 配下には再生可能なHLSマニフェスト、メディアファイル
    • thumbnails 配下にはライブ中に記録されたサムネイル画像

このようにAmazonIVSでは録画設定を作成し、チャンネルに紐づけるだけでそこで配信されるライブストリームを自動で録画し、そのまま配信可能な形にS3に保存することができます。

アーカイブ配信での活用

録画されたものをそのままアーカイブ配信に使う場合はすでにHLS配信可能な状態になっているため、あとはCloudFront経由でアクセスできるようにしてしまえば簡単に終わりそうです。しかし、今回DELISH KITCHENが提供するライブでは以下二つの理由からそのまま配信することができませんでした。

  • アプリでライブ配信をする前から配信テストでストリームを繋ぐ関係で録画内容に自動で不必要な映像が録画されてしまう
  • アーカイブ配信を行う前に内容の編集を行いたいニーズがある

そこで今回はこの順序で処理をすることによってIVSの録画機能を活用しつつ、アーカイブ配信まで行えるようにしました。

  1. 録画終了をEventBridge経由でAPI通知
  2. MediaConvertのjobを作成して録画映像をmp4に変換し、ダウンロードと編集可能な状態にする
  3. 再編集したものをS3にアップロードして、EventBridge経由でAPI通知
  4. MediaConvertのjobを作成して、hlsに変換し、アーカイブ配信をCloudFront経由で行う

本記事では前半のmp4変換するまでをAmazonIVSの録画機能が関わるところとして話していきます。注意点として、AmazonIVSの録画機能自体は録画自体を必ず成功させることが担保されているわけではないのでそこのみに依存することはせずに予備として配信ソフトウェアの録画機能だったり、別の仕組みを準備することは前提としています。

録画終了をEventBridge経由でAPI通知

AmazonIVSはEventBridgeと連携して録画の開始や終了、失敗をイベントとして検知し、他システムと連携することが可能になっています。イベントパターンを指定すると録画終了をモニタリングすることができ、適切なターゲットに通知を行います。

{
  "detail": {
    "recording_status": ["Recording End"]
  },
  "detail-type": ["IVS Recording State Change"],
  "source": ["aws.ivs"]
}

通知内容には録画内容の保存先の情報やチャンネルやストリーム情報が入ります。

{
    "version": "0",
    "id": "test-test",
    "detail-type": "IVS Recording State Change",
    "source": "aws.ivs",
    "account": "11111111",
    "time": "2020-06-24T07:51:32Z",
    "region": "ap-northeast-1",
    "resources": [
        "arn:aws:ivs:ap-northeast-1:11111111:channel/AbCdef1G2hij"
    ],
    "detail": {
        "channel_name": "Channel",
        "stream_id": "st-1111aaaaabbbbb",
        "recording_status": "Recording End",
        "recording_status_reason": "",
        "recording_s3_bucket_name": "dev-recordings",
        "recording_s3_key_prefix": "ivs/v1/11111111/AbCdef1G2hij/2020/6/23/20/12/1111aaaaabbbbb",
        "recording_duration_ms": 99370264,
        "recording_session_id": "a6RfV23ES97iyfoQ",
        "recording_session_stream_ids": ["st-254sopYUvi6F78ghpO9vn0A", "st-1A2b3c4D5e6F78ghij9Klmn"]
    }
}

今回は recording_s3_bucket_namerecording_s3_key_prefix がわかっていれば、放映していたチャンネルIDと録画ファイルの格納先が特定できるのでこの二つにフィルターをかけてAPI通知を行いました。

MediaConvertのjobを作成してmp4変換処理を行う

APIサーバーに通知された録画終了イベントをもとにMediaConvertのjobを作成してmp4変換を行っていきます。MediaConvertではHLS入力をサポートしており、HLSのマニフェストファイルを入力として指定できるようになっています。AmazonIVSでは recording_s3_key_prefix に続く形で /media/hls/master.m3u8 にマニフェストファイルが保存されているのでこちらを入力とします。

これだけでもjobは作成可能なのですが、今回はAmazonIVSのイベントメタデータを活用して変換する録画ファイルの軽量化を行ったのでその紹介をします。

冒頭で説明したようにDELISH KITCHENが提供するライブではライブ配信をする前から配信テストでストリームを繋ぐ関係で録画内容に自動で不必要な映像が録画されるという現象が起こっていました。つまり、実際にユーザーに見える形のライブは30分でも配信テストも含めると1時間録画されていることもあり、このままmp4変換すると無駄に大きいファイルサイズでS3に格納してしまいます。そこでAmazonIVSのイベントメタデータファイルから以下のような処理を行いました。

  1. recording_s3_key_prefix 配下の /events/recording-ended.json から実際の録画開始時間を取得
  2. マスターデータとして保持しているライブ開始時間と録画開始時間の差分を算出
  3. 算出された時間だけ動画の冒頭をクリッピングする設定をMediaConvertのjob設定に追加

/events/recording-ended.json にはこれらの情報が保存されており、recording_started_at に録画開始時間が保存されているのでこれが冒頭余分に録画された内容の差分検出に使うことができます。

{
    "version": "v1",
    "recording_started_at": "2023-12-10T10:16:24Z",
    "recording_ended_at": "2023-12-10T10:22:00Z",
    "channel_arn": "arn:aws:ivs:ap-northeast-1:11111111:channel/AbCdef1G2hij",
    "recording_status": "RECORDING_ENDED",
    "media": {
        "hls": {
            "duration_ms": 338839,
            "path": "media/hls",
            "playlist": "master.m3u8",
            "byte_range_playlist": "byte-range-multivariant.m3u8",
            "renditions": [
                {
                    "path": "480p30",
                    "playlist": "playlist.m3u8",
                    "byte_range_playlist": "byte-range-variant.m3u8",
                    "resolution_width": 480,
                    "resolution_height": 852
                },
                {
                    "path": "360p30",
                    "playlist": "playlist.m3u8",
                    "byte_range_playlist": "byte-range-variant.m3u8",
                    "resolution_width": 360,
                    "resolution_height": 640
                },
                {
                    "path": "160p30",
                    "playlist": "playlist.m3u8",
                    "byte_range_playlist": "byte-range-variant.m3u8",
                    "resolution_width": 160,
                    "resolution_height": 284
                },
                {
                    "path": "720p30",
                    "playlist": "playlist.m3u8",
                    "byte_range_playlist": "byte-range-variant.m3u8",
                    "resolution_width": 720,
                    "resolution_height": 1280
                }
            ]
        },
        "thumbnails": {
            "path": "media/thumbnails",
            "resolution_height": 1280,
            "resolution_width": 720
        }
    }
}

MediaConvertはInputClippingsという設定値で HH:mm:ss:ff 形式で動画のクリッピングを設定できるので検出した差分の時間を使って冒頭余分に録画した部分は切り取る設定をします。この設定を行うことによってmp4変換後のファイルを最大で半分まで軽量化することができ、コストカットも実現できました。

{
  "Inputs": [
    {
      "InputClippings": [
        {
          "StartTimecode": "00:20:04:00"
        }
      ],
      "TimecodeSource": "ZEROBASED",
    }
  ]
}

仕組み構築でのTips

以上がアーカイブ配信でのAmazonIVSの録画機能の活用だったのですが、細かいところで少し工夫があったので最後にそちらの紹介をします。

自動録画の設定項目のチューニング

冒頭で説明したようにAmazonIVSの録画機能はいくつか設定項目があり、今回あえてデフォルトから変えた設定値があります。

記録するビデオのレンディション

今回のように自動で録画されたものをそのままアーカイブ配信に使わない場合、AmazonIVSで記録されるビデオのレンディションは全て保存する必要がありません。そこでデフォルトのすべてのレンディション保存からHDのみを保存するような指定をすることで不必要に録画データを保存することを避けることができます。AmazonIVSでの録画機能はそれ自体に追加料金はかからないですが、S3へ保存されるデータへの従量課金は他と同じようにあります。こうした小さな工夫にも思えますが、ライブの頻度、時間によっては長い期間で大きな差になるところだと思ってます。

フラグメント化されたストリームのマージ

基本的に自動録画の設定はデフォルトでストリーム配信ごとの録画となっています。しかし、配信中に予期せぬトラブルでストリームが切断されてしまう場合もあるでしょう。その場合に再接後に録画が分かれてしまうと不便です。そこでウィンドウでの再接続を有効化し、再接続ウィンドウで再開までの最大間隔を秒数で指定することでその時間だけストリームが終了しても録画を完全に終了するまで待機することができます。つまり、その間での再接続は同じ録画になるためトラブル時に便利な設定となっています。

MediaConvertでのuserMetadataタグの活用

今回説明では省きましたが、MediaConvertでjobを作成したあとはそのまま何もしないというより多くの場合では成功や失敗をまたEventBridge経由で通知し、アプリケーション側で適切な処理をしていくと思います。そこで課題になったのが大きく2点あります。

  1. MediaConvertのjob作成時に判別していた内部のライブ情報やConvertの目的種別が通知されるjobIDなどでは判別できない
  2. MediaConvertが違う目的で複数あるとEventBridgeでは判別できずにどちらの場合でも同じターゲットに通知がいってしまう

そこでuserMetadataタグの活用です。userMetadataタグにはMediaConvertのjob作成時に任意のkey,valueを設定できるようになっており、通知時に受け取りたい情報に含めることができます。

例えば以下のような情報をuserMetadataタグに入れておけば、任意の情報を通知時に渡すことができ、EventBridgeの発火もフィルタリングできます。

{
  "UserMetadata": {
    "live_id": "77",
    "convert_type": "recording"
  },
}

EventBridge側のフィルタリング

{
  "detail": {
    "status": ["COMPLETE"],
    "userMetadata": {
      "convert_type": ["recording"] // メタデータでの種別でのフィルタリング
    }
  },
  "detail-type": ["MediaConvert Job State Change"],
  "source": ["aws.mediaconvert"]
}

おわりに

今回はライブ機能のアーカイブ配信におけるAmazonIVSの録画機能の活用についてご紹介させていただきました。AmazonIVSを使用することでライブ配信だけではなくアーカイブ配信でも活用ができたので、弊社独自の要件もあるとは思いますが参考になれば幸いです。

エブリーではまだまだ一緒にプロダクトを作っていける仲間を募集中です。テックブログを読んで少しでもエブリーのことが気になった方、ぜひ一度カジュアル面談でお話しましょう!!

corp.every.tv

Go testにおける可読性を保つ方法を考える

はじめに

TIMELINE開発部の内原です。

本日はGo言語のテストにおける可読性について考えてみます。この記事を読んでいただいている皆さんにも、テストを書いていて以下のような問題を感じた経験があるのではないでしょうか。

  • 既存のコードに機能追加をするためテストコードにもテストケースを追加しようとしたが、テストコードが複雑で読み解きづらく、テストを追加するのに苦労した
  • テストケースの種類が多く、少しデータを追加しただけでも既存のテストが動かなくなる
  • テストデータの登録方法が複雑で、テストコードの実装以前に手間取る

上記のような問題に対処するべく、実践的なシナリオに従ってGo言語のテストコードを実際に書きつつ都度改善していくことにします。

仕様(ver.1)

  • ユーザ情報には名前、状態(有効、無効)とがある
  • 有効なユーザ一覧を返却する関数 LoadActive() を実装する。その際並び順はIDの昇順とする

データ構造と実行SQL

type User struct {
  ID    int    `db:"id"`
  Name  string `db:"name"`
  State int    `db:"state"`
)
CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  PRIMARY KEY (id)
);
SELECT
    *
FROM
    users
WHERE
    state=1
ORDER BY
    id;

テストコードの実装(抜粋)

LoadActive() 関数のテストコードとしては以下のようになりました。

テストデータとしてActive, Inactiveのユーザを複数件登録し、Activeのユーザのみが返却されること、並び順がIDの昇順であることを確認しています

func setupUser() {
    u := NewUserRepository()
    u.Create(model.User{Name: "user1", State: Active})
    u.Create(model.User{Name: "user2", State: Inactive})
    u.Create(model.User{Name: "user3", State: Active})
}

func teardownUser() {
    // データのクリーンアップ処理など
}

func TestUserLoadActive(t *testing.T) {
    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user3" {
        t.Fatalf("expected user3 but got %v", users[1].Name)
    }
}

仕様(ver.2)

ver.1の仕様に対し、以下の機能追加をすることになりました。

  • 新たにグループというデータ構造を設ける
  • ユーザは1つ以下のグループに属することができるものとする(しないこともできる)
  • LoadActive() が返却するユーザは、グループに属しているもののみとする

データ構造と実行SQL

type User struct {
  ID      int    `db:"id"`
  Name    string `db:"name"`
  State   int    `db:"state"`
  GroupID *int   `db:"group_id"`
)
type Group struct {
  ID   int    `db:"id"`
  Name string `db:"name"`
)
CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  group_id integer NULL,
  PRIMARY KEY (id)
);
CREATE TABLE groups (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  PRIMARY KEY (id)
);

SELECT
    u.*
FROM
    users u
JOIN
    groups g ON u.group_id=g.id
WHERE
    u.state=1
ORDER BY
    u.id;

テストコードの実装(抜粋)

LoadActive() 関数のテストコードにテストデータを追加して、新たに追加されたグループの仕様についてもテストされるようにしました。

新たにユーザ用のレコードを追加し、Activeであってもグループに属していないため返却されない、という確認をしています。

この時点ではテストコード自体には手を入れず、テストデータの追加のみを行いました。それでも追加仕様に対する確認要件は満たせているためです。

func setupUser() {
    g := NewGroupRepository()
    group, _ := g.Create(model.Group{Name: "group"})

    u := NewUserRepository()
    u.Create(model.User{Name: "user1", State: Active, GroupID: &group.ID})
    u.Create(model.User{Name: "user2", State: Inactive, GroupID: nil})
    u.Create(model.User{Name: "user3", State: Active, GroupID: &group.ID})
    u.Create(model.User{Name: "user4", State: Active, GroupID: nil})
    u.Create(model.User{Name: "user5", State: Inactive, GroupID: &group.ID})
}

func teardownUser() {
    // データのクリーンアップ処理など
}

func TestUserLoadActive(t *testing.T) {
    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user3" {
        t.Fatalf("expected user3 but got %v", users[1].Name)
    }
}

仕様(ver.3)

ver.2の仕様に対し、さらに以下の機能追加をすることになりました。

  • グループにも状態(有効、無効)を設ける
  • LoadActive() が返却するユーザは、有効なグループに属しているもののみとする

データ構造と実行SQL

CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  group_id integer NULL,
  PRIMARY KEY (id)
);
CREATE TABLE groups (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  PRIMARY KEY (id)
);

SELECT
    u.*
FROM
    users u
JOIN
    groups g ON u.group_id=g.id
WHERE
    u.state=1 AND
    g.state=1
ORDER BY
    u.id;

テストコードの実装(抜粋)

ver.2 の対応と同じようにテストデータのパターンを増やすこともできますが、今でもそれなりにレコード数があるのにさらに増やすとなると、ユーザの状態、グループ所属有無、グループの状態の組み合わせぶんレコードを作らなければならず、考えただけでも大変そうです。

だんだんとテストを書くのが辛くなってきました。というわけでアプローチを変えてみます。

そもそも LoadActive() が提供している機能はなんでしょうか?

  1. 指定の条件に合致したレコードを返却する
  2. レコードの並び順を定まったものにする

上記の2つであると考えられそうです。分かりやすくするため、上記それぞれについてテストを分けてみます。

1番目については、単に返却されるかされないかだけに着目すればよいので、1件のデータのみを対象とすることにします。 またその際テーブル駆動テストのアプローチを用いて、全組み合わせのテストデータを用意したとしても、テストコードが冗長にならないようにします。

// LoadActive の並び順についてのテスト
func TestUserLoadActive_Order(t *testing.T) {
    setupUser := func() {
        g := NewGroupRepository()
        group, _ := g.Create(model.Group{Name: "group", State: Active})

        u := NewUserRepository()
        u.Create(model.User{Name: "user1", State: Active, GroupID: &group.ID})
        u.Create(model.User{Name: "user2", State: Active, GroupID: &group.ID})
    }

    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user2" {
        t.Fatalf("expected user2 but got %v", users[1].Name)
    }
}

// LoadActive の返却条件についてのテスト
func TestUserLoadActive_Condition(t *testing.T) {
    tests := []struct {
        name       string
        userState  int64
        hasGroup   bool
        groupState int64
        hasUser    bool
    }{
        {"active user,active group", Active, true, Active, true},
        {"active user,inactive group", Active, true, Inactive, false},
        {"active user,no group", Active, false, Inactive, false},
        {"inactive user,active group", Inactive, true, Active, false},
        {"inactive user,inactive group", Inactive, true, Inactive, false},
        {"inactive user,no group", Inactive, false, Inactive, false},
    }

    setupUser := func(userState int64, hasGroup bool, groupState int64) {
        var groupID *int64
        if hasGroup {
            g := NewGroupRepository()
            group, _ := g.Create(model.Group{Name: "group", State: groupState})
            groupID = &group.ID
        }

        u := NewUserRepository()
        u.Create(model.User{Name: "user", State: userState, GroupID: groupID})
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Cleanup(teardownUser)
            setupUser(tt.userState, tt.hasGroup, tt.groupState)

            u := NewUserRepository()
            users, err := u.LoadActive()
            if err != nil {
                t.Fatalf("expected no error but got %v", err)
            }
            if tt.hasUser {
                if len(users) != 1 {
                    t.Fatalf("expected 1 user but got %v", len(users))
                }
            } else {
                if len(users) != 0 {
                    t.Fatalf("expected 0 user but got %v", len(users))
                }
            }
        })
    }
}

さらなる改善

現時点でもそれなりに読みやすいテストコードにはなったと思いますが、まだテストデータの登録処理においていくつか課題があります。

  • テストでは意識する必要がなくとも非NULLなカラム(Group.Nameなど)にはなんらか値を指定しなければならない
  • データの依存関係をテストコード内で意識しておかなければならない
  • 作成処理のエラーハンドリングを省略しており、仮に登録に失敗していた場合テスト自体も正常に動作しなくなる

簡単にテストデータを作成するために factory-go というライブラリを利用することにします。 これはRuby on Railsでよく用いられる factory_bot というライブラリにインスパイアされたもので、使い方は似ています。

以下のようなFactoryを用意しておきます。 usersがgroupsに依存しているため、SubFactoryという機能を用いています。

var UserFactory = factory.NewFactory(
    &model.User{},
).SeqInt64("ID", func(n int64) (interface{}, error) {
    return n, nil
}).Attr("Name", func(args f.Args) (interface{}, error) {
    user := args.Instance().(*model.User)
    return fmt.Sprintf("username-%d", user.ID), nil
}).Attr("State", func(args f.Args) (interface{}, error) {
    return Active, nil
}).SubFactory("Group", GroupFactory).OnCreate(func(args f.Args) error {
    m := args.Instance().(*model.User)
    return insertUser(m)
})

func insertUser(m *model.User) error {
    if m.Group != nil {
        m.GroupID = &m.Group.ID
    }
    _, err := // INSERT INTO usersする処理
    return err
}

var GroupFactory = factory.NewFactory(
    &model.Group{},
).SeqInt64("ID", func(n int64) (interface{}, error) {
    return n, nil
}).Attr("Name", func(args f.Args) (interface{}, error) {
    group := args.Instance().(*model.Group)
    return fmt.Sprintf("groupname-%d", group.ID), nil
}).Attr("State", func(args f.Args) (interface{}, error) {
    return Active, nil
}).OnCreate(func(args f.Args) error {
    m := args.Instance().(*model.Group)
    return insertGroup(m)
})

func insertGroup(m *model.Group) error {
    _, err := // INSERT INTO groupsする処理
    return err
}

上記のようなFactoryを用意しておくことで、テストコードの登録処理が以下のように簡略化できます。

  • MustCreate... の関数は登録処理に失敗するとpanicするため、正しいテストデータが準備できていないままテストが続行されるということはなくなる
  • テストにおいて関心のないカラムについては指定する必要がなくなる(指定してもよい)
  • データの依存関係についてテストコード側で把握しておく必要はなく、Factoryの使用方法を理解しておけば適切なデータ生成が行われる
func TestUserLoadActive_Order(t *testing.T) {
    setupUser := func() {
        UserFactory.MustCreateWithOption(map[string]interface{}{"Name": "user1"})
        UserFactory.MustCreateWithOption(map[string]interface{}{"Name": "user2"})
    }
    // ...
}

func TestUserLoadActive_Condition(t *testing.T) {
    // ...
    setupUser := func(userState int64, hasGroup bool, groupState int64) {
        var group *model.Group
        if hasGroup {
            group = GroupFactory.MustCreateWithOption(map[string]interface{}{
                "State": groupState,
            }).(*model.Group)
        }

        User.MustCreateWithOption(map[string]interface{}{
            "State": userState,
            "Group": group,
        })
    }
    // ...
}

まとめ

今回はGo言語におけるテストコードの可読性を上げるアプローチについて、実際にコードを交えながら考えてみました。

テストコードは挙動を担保する重要な役割を持っていますが、テストコード自体のメンテナンス性が下がると徐々に十分なテストが行われない状態に陥いりがちです。

そういった将来の問題を避けるためにも、自分がテストコードを書くタイミングで、他人が見て理解しやすいコードになっているかを意識しておくのが重要と考えています。

Privacy Manifests対応についての調査

WWDC23で発表されているように、Xcode 15からPrivacy Manifestsという機能が追加されています。 Privacy Manifestsの実体は PrivacyInfo.xcprivacy という名前のplistファイルで、アプリやSDKのプライバシーに関する情報を記述します。

2024年春以降、Privacy ManifestsはApp Storeのレビューの対象になり、新規やアップデートの際に適切に対応しないとリジェクトされるようです。

Privacy Manifestsにはプライバシーに関する複数の情報が記述できますが、レビューで義務化されるものはその一部だけのようです。 この記事では、2024年春時点でアプリに求められる最低限の対応について調べました。

参考

developer.apple.com

developer.apple.com

developer.apple.com

developer.apple.com

developer.apple.com

Privacy Manifestsの機能

Privacy Manifestsにはプライバシーに関する複数の情報を記述できます。

  • Privacy report
  • Tracking domains
  • Required reason APIs

Privacy report (Privacy Nutrition Labels)

PrivacyInfo.xcprivacyNSPrivacyCollectedDataTypes が該当します。

App Storeでアプリを公開するとき、個人情報の収集とトラッキングについてのレポートが必要です。アプリには多くの場合サードパーティーSDKが組み込まれており、それらのSDKで収集/トラッキングされる情報についてもアプリ開発者が把握して、一括して表示する責任があります。 サードパーティーSDKで収集/トラッキングされる情報についてそれぞれ調査し、それらを統合してレポートを作成するのは手間がかかり、不正確になる可能性もありました。

Privacy Manifestsで提供されるPrivacy report は、この問題を解決するためのものです。サードパーティーSDKに含まれるPrivacy Manifestsには収集とトラッキングの情報が含まれており、Xcodeがそれらを自動的に統合して一つのわかりやすいレポートにまとめてくれます。

Privacy Manifestsの内容が自動的にApp Storeに反映され、手動で入力する必要がなくなると思っていたのですが、現時点ではそうではないようです。 Xcodeが出力したレポートを参照しながらApp Store Connectに入力する想定のようです。

2024年春時点では対応は必須でありません。

Tracking domains

PrivacyInfo.xcprivacy では、 NSPrivacyTracking , NSPrivacyTrackingDomains で記述します。

App Tracking Transparencyによるトラッキングの制限をより確実に行うための機能です。

トラッキングをする前にトラッキング許可ステータスをチェックする必要がありますが、SDKの利用方法の誤りなどによって意図せずトラッキングが行われてしまう場合があります。

意図しないトラッキングを防ぐために、Privacy Manifestsにはトラッキングを行うドメインが含まれています。ユーザーがトラッキング許可を提供していない場合、iOS 17 は、アプリに含まれるPrivacy Manifestsで指定されているドメインへの接続を自動的にブロックします。

これによって、実装ミスなどにより意図せずトラッキングをしてしまう可能性をなくすことができます。

2024年春時点では対応は必須でありません。

Required reason APIs

PrivacyInfo.xcprivacy では、 NSPrivacyAccessedAPITypes が該当します。

ユーザーがアプリにトラッキングの許可を与えていたとしても、フィンガープリンティングは許されません。

フィンガープリンティングに悪用される可能性のあるAPIのリストが公開されています。

developer.apple.com

これらのAPIを使用する場合には、Privacy ManifestsにAPIを使用する理由を記述する必要があります。これに基づいて、APIが本来の目的に沿って適切に使用されているかレビューされます。

2024年春時点で対応必須です。

サードパーティーSDKの対応

一般的に使用されるサードパーティSDKのリストが公開されています。

developer.apple.com

アプリにこれらのSDKが組み込まれている場合は、SDKのPrivacy Manifestsを含める必要があります。 SDKがPrivacy Manifestsに対応していることを確認して、必要ならSDKを最新版にアップデートしたり、場合によっては他のSDKへの変更を検討する必要があるかもしれません。

2024年春時点で対応必須です。

まとめ

以上をまとめると、2024年春時点でアプリに求められる最低限の対応は以下のようになると考えています。

  • Required reason APIs の対応
    • 該当するAPIを使用している場合、 PrivacyInfo.xcprivacy を作成し、APIの使用理由を記述する必要があります。
  • サードパーティーSDKの対応
    • リストに該当するサードパーティSDKを利用している場合、SDKがPrivacy Manifestsに対応し、署名されている必要があります。SDKの対応状況を確認し、必要ならSDKを最新版にアップデートしたり、場合によっては他のSDKへの変更を検討する必要があるかもしれません。

Datadog Synthetic Monitoring API Tests で 20 を超えるドメインの SSL を監視した

エブリーで小売業界向き合いの開発を行っている @kosukeohmura です。

昨年、エブリーではネットスーパーの事業を株式会社ベクトルワン様から引き継ぎました。引き継いだシステムを運用していく中で、ネットスーパーの各種サイトや API に使用している 20 個超の SSL 証明書の有効期限を切らさないように更新していく必要があり、そのために監視を導入したお話をします。

引き継ぎ作業の概観については以前公開しました ゼロからはじめるシステム引き継ぎ - every Tech Blog に書きましたので、合わせて御覧ください。

背景とモチベーション

システムを引き継いだ時点では SSL 証明書の更新の運用は素朴なものでした。具体的にはエンジニアが有効期限を切らさないようにたまにそれぞれのサイトの有効期限をチェックし、有効期限が近づいたものを発見次第手動で更新作業を行うというものです。抜け漏れが容易に起こり得ますし、更新が漏れた際はサービス停止せざるを得ないため、ミスの起こりづらい仕組みを導入する必要性を感じていました。そこでまずは有効期限に近づいたことを気付けるようにすることにしました。ゆくゆくは更新作業自体を自動化したいところですが、それを行ったとしても自動的に有効期限が近づいた際に検知する仕組みは依然として必要になると考えています。

お手軽に Google カレンダーやタスクツールへの手動登録・管理を考えましたが、タスクや予定の登録などの作業が面倒なのとオペレーションミスの可能性が許容できなそうだと思っていたところ、Datadog で有効期限を外形監視できることを知り、早速利用することとしました。

Datadog での SSL の監視にかかる料金

Datadog の SSL 監視機能は正しくは SSL Tests といい、Datadog の Synthetic Monitoring というプロダクトの中の API Tests というテスト群の 1 種として存在します。API Tests の料金 は 2024/02 現在月々 10,000 回のテスト実行ごとに $6.25 です。

SSL Tests はそう高頻度で実施しても意味が薄いので、比較的テスト回数が少なく済み、料金はお手頃になります。仮に 1 日毎の SSL Test 実行であればドメインごとに月々 30 テスト実行程度に収まるので、300 を超えるドメインを $6.25 で監視できることになります。一方 API Tests には SSL の他に HTTP や gRPC といったテストも存在しますが、こういった死活監視となると高頻度でテストを行うことになり料金が高くなります。仮に 1 分毎に 1 つの API のテストを行うとすると、月のテスト回数は 40,000 回を超えてくるため月々 $25 かかってきます。これは割高に感じました。

ちなみにエブリーでは Web サイトや API の死活監視に Pingdom の Uptime monitoring を使用していますが、そこでは月々 $10 で 10 個の URL / IP に対して 1 分毎の外形監視が可能です。

このように Datadog の API Tests ではテスト実行回数で価格が決まるので、行うテストの特性・頻度によって費用には大きく幅が出ます。

導入してみる

私自身こういった外形監視を一から設定することは初めてでしたが、ドキュメント を読むと特に迷うこと無く設定できました。主な設定値を次に記します:

  • テスト元のロケーション
    • 1 つ (Tokyo) のみ。複数ロケーションからリクエストする意義は少ないと感じたため
  • 有効期限のアサート閾値
    • 30 日。運用次第で調整するかもしれませんが、最初は余裕を持って
  • 再通知間隔
    • 3 日
  • リトライ条件
    • リトライを行わないように設定。証明書の有効期限が近づいている場合何度テストしても同じ結果となるため
  • テスト頻度
    • 1 日に 1 度

アラート時の通知先は Slack とし、メッセージは下記のような内容としました。

{{#is_alert}}
{{#is_renotify}}
引き続き、SSL 証明書の期限が 30 日以内もしくは SSL が正常でない状態が続いています。
SSL の状態を確認し、必要であればフローに沿って証明書の更新を進めてください。

- [SSL 証明書更新フロー](<社内ドキュメントへの URL>)
{{/is_renotify}}
{{^is_renotify}}
SSL 証明書の期限が 30 日以内もしくは SSL の状態が正常ではありません。
{{/is_renotify}}
{{/is_alert}}
{{#is_no_data}}
SSL のデータが取れていません。
{{/is_no_data}}
{{#is_recovery}}
SSL の状態が正常になりました。
{{/is_recovery}}

上記設定をそれぞれのドメインに対して繰り返した結果、下記のように今回の監視対象の 20 以上の SSL 証明書の期限を Datadog 上に一覧化できました!有効期限もひと目で分かり、30 日以内となったもののステータスは ALERT となっています。いい感じです。

SSL Test 一覧

Slack にも下記のように通知が届きます(ちょっと文言が変わっていますが)。

実際の Slack 通知

導入時に困ったこと

ひとたび把握・設定してしまえば簡単に SSL の監視が実現でき、しかも料金もお手頃で満足度は高いのですが、少し思うところもあったので挙げてみます。

有効期限切れまでの残日数をアラート時のメッセージに入れられない

Datadog には Template variables というアラート通知時のメッセージに現在のメトリクスを埋め込める機能がありますが、ドキュメント を見る限り SSL Tests で埋め込み可能なメトリクスはなさそうでした。理想的にはアラートメッセージに有効期限切れまでの残日数を含めて SSL 証明書の期限切れまで {{ value }} 日です。 のような形にすることでアラートを受けたエンジニアが Datadog へ飛ばずとも残日数を把握できるようなメッセージにしたかったのですが、今回は無理そうなので諦めました。

同一の SSL Test を参照する形で多数のドメインのテストを行えない

今回 20 個の SSL Tests を作成しましたが、そのために 1 つの SSL Test を Clone してドメイン名を書き換えるという作業を 20 回実施しました。設定を頻繁に書き換えることは現時点で想定していませんが、例えばメッセージのテンプレートを変えたくなった場合に、20 個の SSL Tests を更新して回らなければならないということになります。1 つの SSL Test を複数のドメインに対して適用するようなことができれば DRY になり設定変更作業も最低限で済むと思ったのですが、現時点では (Web からの作業では) 一度作った SSL Test を Clone するしか道はなさそうでした。

これについては Terraform Datadog provider を利用し Datadog の設定をコード化することで DRY 化を実現できるとは思いますが、今回は工数に余裕がなく見送りました。(ちょっと脱線しますが、)これに限らず私たちのチームでは Datadog の設定内容のコード化は行っていないのですが、サービスに直接影響はなくともコード化のメリットは享受できるところも多いと今回感じたため、今後コード化してみたいと思っています。

最後に

Datadog Synthetic Monitoring API Tests で 20 を超えるドメインの SSL を監視したお話でした。このように、エブリーではスーパーマーケットなどの小売業界へより良いソリューションを提供できるようにプロダクトの改善を進めておりますので、また何か紹介できればと思います。お読みいただきありがとうございました。

Nuxt3へのアップグレードに向けた挑戦

はじめに

はじめまして。DELISH KITCHEN開発部の羽馬(@naokihaba)と申します。

私が所属するDELISH KITCHEN開発部では、現在Nuxt3へのアップグレードに向けた取り組みを進めています。

この記事では、私たちが行っているNuxt3へのアップグレードに向けた取り組みについて紹介いたします。

移行背景

私が所属するDELISH KITCHEN開発部では、いくつかのプロダクトでNuxt.jsバージョン2.xを利用しています。

しかしながら、Nuxt.js@2.xのサポートは2024年6月30日に予定されており、Nuxt3にアップグレードする必要が出てきました。

そこで、私たちはNuxt3へのアップグレードに向けて「Nuxt3に移行するべきか、それともNext.jsに移行するべきか」という議論を続けてきました。

Nuxt3への移行を選択した理由

近年、Next.jsは人気を集めており、弊社でもNext.jsを採用するプロダクトがあるため、Next.jsへの移行も選択肢の一つとして考慮しました。

しかしながら、Next.js@13.x で新しく導入された App Router がまだ発展途上であること、そして頻繁に更新されていることを理由に、Next.jsは一定の安定性が求められるシチュエーションにおいてはリスクを伴うかもしれないと考えました。

それに対して、Nuxt3への移行を選択した要因はいくつかあります。

  • Nuxt3やNuxt Bridgeのマイグレーションガイドが整備されている
  • Nuxt3への移行についての情報が共有されている
  • Nuxt3のドキュメントが整備されている

これらを総合的に考慮した結果、Next.jsへの移行と比べて、Nuxt3への移行コストが全体的に低いと結論付けました。

移行課題

Nuxt3への移行には、以下のような課題があります。

  • Nuxt.js v2 から Nuxt3 へのアップグレード
  • Vue.js v2 から Vue3 へのアップグレード
  • @Nuxt/axiosからfetch APIへの移行
  • VuexからPiniaへの移行とする or Vuex4へのアップグレード
  • Webpack v4からv5へのアップグレード
  • 関係しているパッケージ・ライブラリのアップグレードに伴う修正作業(PostCSS etc...)

これらの課題に対して、私たちは次のようなアプローチを取ることにしました。

移行計画

Nuxt3への移行には、主に2つのアプローチが考えられます。

  1. Nuxt Bridgeを利用したアップグレード
  2. Nuxt.js v2 から Nuxt3 への直接アップグレード

Nuxt2からNuxt3への直接アップグレードも不可能ではありません。

しかしながら、関連するパッケージのアップグレードとそれに伴う修正作業と付随して、移行作業が複雑化することが予想されます。

また、大規模なリリースとなることが予見されるため、Nuxt Bridgeを利用した段階的なアップグレードを選択しました。

移行作業

ここでは、Nuxt3への移行作業の一部をご紹介します。

ただし、移行作業はまだ進行中であり、完了した部分のみをご紹介します。

移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。

NuxtBridgeへの移行

まず、Nuxt Bridgeへの移行作業について説明します。

Nuxt Bridgeへの移行に際して解決すべき課題は以下の通りです。

  • Nuxt.jsをバージョン2.15.8から2.17.2へアップグレード
  • Vue.jsをバージョン2.6.0から2.7.0へアップグレード

Nuxt 2.17.2 へのアップグレード

Nuxt 2.17.2・Vue 2.7.0へのアップグレードについては概ねスムーズに進行しました。

しかし、Nuxt 2.17.2 へのアップグレードを行う際に、バージョン間のリリースノートを確認したところ、Nuxt 2.16.0 からpostcssv8にアップグレードされた点に注意が必要です。

注視すべきは、postcss.preset.importFrompostcss.preset.exportToが廃止されていることです。

これにより、特定のソースから変数やミックスインをインポートする機能が失われています。

この問題を解決するため、@csstools/postcss-global-dataを利用することにしました。

PostCSS v8へのアップグレードに関する詳細な作業は、PostCSSの公式リポジトリのWikiにまとめられています。こちらを参照してください。

具体的な設定変更については以下を参照してください。

// nuxt.config.js
export default {
    build: {
        postcss: {
            plugins: {
                '@csstools/postcss-global-data': {
                    files: [
                        'assets/styles/_variables.css',
                    ],
                },
            },
        },
    }
}

まとめ

この記事では、Nuxt3へのアップグレードに向けた取り組みについて紹介しました。

Nuxt3への移行には、いくつかの課題がありますが、Nuxt Bridgeを利用した段階的なアップグレードを進めることで、移行作業を進めています。

移行作業が完了した際には、全体の移行作業について詳しくまとめたブログ記事を公開する予定です。

エブリーで一緒に働くエンジニアを募集しています

最後になりますが、エブリーでは、一緒に働くエンジニアを積極的に募集しています。

この記事に興味を持っていただけた方や、エブリーに興味を持っていただけた方は、エブリーの採用情報 をご覧ください。