有為転変というには儚さが足りませんが、ビジネスパーソンたる私たちを取り巻く環境も例に漏れず変化していくものです。何とかそれに適応していくためにも社内向けの研修システムがあると便利ですよね。そこで今回は社内向けの動画研修システムを考えてみたいと思います。
今回を構築編として、次回はレビュー編を予定しています。
目次
シナリオ
今回はこんなシナリオを考えてみました。
現状
ある会社は様々な工事を請け負っており、社員は作業の仕方や機械の使い方などを学ぶ必要があります。
手順書やマニュアルも用意していますが、動画で学ぶ方が直感的で分かりやすいです。今までは研修用の動画を撮影して動画ファイルを社内ネットワークの共有フォルダにアップロードし、全社員にそれを視聴するようメールすることで展開していました。各社員はコンピューターから共有フォルダへアクセスし、受講する必要のある研修の動画ファイルをダウンロードして保存し、それを再生することで研修を行っていました。会社や部門で必須としている研修もありますが、研修動画を再生するまでのステップがやや煩雑であるため、面倒がって動画を見ない社員もいることが課題になっています。
そんな中、うれしいことに会社の業績は好調で、社員が200名程度まで増えてきました。熟練度の高い社員も出てきており、若手社員に作業のコツなどのノウハウを伝授してくれたりしています。会社としてはそういったノウハウもどんどんシェアできる環境にしていきたいと思っています。
こういった背景から社員全体のスキルを維持、向上するため研修動画の重要性が増してきたことに伴い、システム化 を検討し始めました。
システム化に当たって達成したいことは次の3つです。
- 社員が簡単かつ手間なく研修動画を視聴できるようにしたい
- 研修動画の視聴が完了した社員を管理できるようにしたい
- 社員が研修動画をアップロードしてナレッジシェアできるようにしたい
システム化後
社員は社内の動画研修サービスにアクセスし、研修一覧から選択するだけで動画を視聴することができます。動画を最後まで見終わると研修を完了したことが記録され、画面のステータスが完了に切り替わります。
また、社員は動画をアップロードする事で新たな研修を作成することができ、自身のナレッジを共有することができます。
これにより面倒がっていた社員も研修動画視聴へのハードルが下がり、熟練社員のナレッジシェアも気軽に行うことができるようになるはずです。
動画研修サービスの使い勝手は以下のGIF動画のようになります。
(表示されない場合はこちらhttps://github.com/kkmtyyz/video-training-service/blob/v1.0.0/readme_img/demo.gif)
GIF動画の画面遷移ごとに少し動作を見ていきましょう。
社員が動画研修サービスにアクセスすると社内のIdPへSAML認証を行いログインすることができます。
メインの画面として研修一覧が表示されます。
研修を開くと動画をストリーミングにより視聴することができます。
動画を最後まで見終えると画面上のステータスが緑色の完了済みに変化します。このときシステムにも社員の研修完了が記録されます。研修一覧に戻り、再度研修を開くとステータスがちゃんと完了済みになっていることがわかります。
次に研修作成画面へ遷移して研修を作成します。
作成ボタンを押すと動画のアップロードが始まり、ぐるぐるが表示され、しばらくすると「研修の作成受付が完了しました。結果はメールでお知らせします。」というメッセージのアラートが表示されます。
動画をmp4からHLSへ変換する処理がシステム側で実行されているため、すぐには研修一覧には作成した研修が表示されません。
変換処理が成功すると次のようなメールが来ます。研修一覧を更新すると作成した研修が表示されました。
研修を開くとアップロードした動画を再生することができます。
シナリオとサービスの動作が分かったところで、早速構築に入っていきましょう。
システム構築
最初に方針および完成後の全体像を全てのソースコードと共に提示し、その後全体をいくつかのポイントに分けながらアーキテクチャを考えていきたいと思います。
方針
以下の方針で作りたいと思います。
完成後のアーキテクチャとソースコード
完成後のアーキテクチャは次のようになります。
サブネットは全て2つずつになりますが、分かりやすさのために1つだけ描いています。
AWSリソースのCDKコード、Lambda関数のコード、Webアプリのコードは全て以下Githubリポジトリに公開しています。
CDKはTypeScript、Lambda関数はPython、WebアプリはReactを使ってJavaScriptで書きました。
今回の記事で使用するバージョンはリリースv1.0.0
になります。
ユーザーとAWS間のアクセス経路
AWSへは次のいずれかの方法で繋がってるものとします。
今回はAWS Client VPNを使って接続します。
サービスはプライベートサブネットに置くのでClient VPN Endpointも同じサブネットとアソシエーションすることとし、次のような感じになります。
CDKのコードでは以下の部分です。
証明書はAWS Certificate Managerへアップロード済みのものを使用します。
AWSリソースの名前解決を行うため、DNSサーバーにAmazon DNSサーバーのIPアドレスを指定しています。
今回はVPCのCIDRが10.0.0.0/16
なので10.0.0.2
となります。
まだ出てきていませんが、Cognitoエンドポイントへアクセスするため0.0.0.0/0
への承認ルールとルートも追加しています。
接続にはAWS Client VPNのクライアントソフトウェアを使います。
video-training-service/cdk/lib/config.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub video-training-service/cdk/lib/vpn.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
Webアプリ
Webアプリは内部ALBからS3インターフェースエンドポイント経由で取得
ユーザー数が200人と少なく、研修サービスなのでアクセスされない時間も長いため、極力計算機リソースを持たないようにしたいです。
Webアプリは静的なSPAなのでS3でホスティングすることにします。
S3のWebサイトホスティングはHTTPSをサポートしていないため、何かを前に噛ませる必要があります。
上記ドキュメントにも書いてある通り、よく目にするのはCloudFrontを使用してHTTPSに対応させる方法ですが、今回は社内向けサービスなのでApplication Load Balancer(ALB)を使用します。アプリと同じプライベートサブネットにマッピングした内部ALBとして作成します。
S3へはプライベートサブネットからのアクセスとなるためVPCエンドポイントが必要になりますが、内部ALBのターゲットとするためにプライベートIPを持つインターフェース型エンドポイントとして作成する必要があります。
経路としては次のようになります。
アクセスはClient VPNエンドポイントからのみ許可します。
インターフェースエンドポイントのIPアドレスはカスタムリソースで取得
上記ALBおよびS3インターフェースエンドポイントはCDKのコードでは以下の部分です。
コンソールからエンドポイントを作成する際にはENIのIPアドレスを指定することができますが、CloudFormationでは指定できないため、デプロイ後にしかENIのIPアドレスが分かりません。
なのでカスタムリソースを作ってIPアドレスを取得します。コードはAWS re:Postのこの回答のものをそのまま使用しますが、今回は複数回呼び出せるように関数として切り出しました(最初はAPI GWのインターフェースエンドポイントも作成していたためです)。リソースIDが被るとcdk synth
に失敗するため、第二引数にリソースIDを取るようにしました。注意点というわけではありませんが、カスタムリソースはLambda Functionとして作成されるので、この関数を呼べば呼ぶほどLambda Functionが増えていきます。
video-training-service/cdk/lib/vpc-endpoint.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub video-training-service/cdk/lib/application-load-balancer.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
ヘルスチェックに使用するプロトコルとHTTPコードはエンドポイントのサービスよって異なるため、コード内のコメントのリンク先を参照します。S3は200,307,405
になります。
リスナーはポート443のものを作成し、デフォルトアクションをS3のターゲットグループに向けます。Cognitoを使用した認証については後ほどSAML認証のところで取り上げます。また、S3はオブジェクト以外へのアクセスにはXMLで応答するため、*/
を/index.html
へリダイレクトするアクションを作成します。
API用のLambda関数へはパスを/api/*
としました。
注意点として、ALBからLambda関数へのヘルスチェックはデフォルトで無効化されていますが、有効化することもできます。その場合はヘルスチェックのアクセスごとに通常通りLambda関数の課金が生じます。
S3バケット名はRoute53 プライベートホストゾーンのドメインと合わせる
WebアプリをホスティングするS3バケットを作りますが、HTTPでアクセスする場合はURLがそのままS3のパスとして使用されるため、バケット名をドメイン名と一致させる必要があります。
今回はRoute53でプライベートホストゾーンを作ります。CDKのコードでは以下のようにドメイン名をconfig.appDomain
としており、configファイルでSSM パラメーターストアから取得する値としています。ドメイン名の他にもいくつか動的にパラメーターストアから取得するようにしているため、試してみる際には事前に設定しておく必要があります。
今回はvt-test.com
としました。
プライベートホストゾーンなので自由に名前を付けることができます。
レコードはALBへのAレコードのみを作成しておきます。
video-training-service/cdk/lib/config.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub video-training-service/cdk/lib/hosted-zone.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
静的コンテンツを置くS3バケットを作ります。
バケットポリシーでS3インターフェースエンドポイントからのアクセスを許可してあげます。
静的サイトをホスティングしますが、S3の機能としての「静的ウェブサイトホスティング」は無効のままで大丈夫です。
video-training-service/cdk/lib/s3.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
SPAアプリのルーティングはハッシュルーティングのみ
これでユーザーからVPN、ALB、VPCエンドポイントを通してS3のwebサイトまで経路が出来上がりましたが、SPAのページルーティングに少し注意が必要です。
SPAの場合物理的なhtmlファイルがindex.htmlのみになるため、ブラウザのページリロードに対応するためにURL据え置きのフォワードの設定が必要になります。しかし、ALBにはその機能がないため、URLのハッシュプロパティを使用した所謂ハッシュルーティングにする必要があります。ブラウザのパスを利用したルーティングの場合はリロードするとS3キーが無い旨のエラーが記載されたXMLが返ります。
ユーザー認証
今回は会社にあるIdPでSAML認証を行う想定です。
IdPとしてKeycloakをDockerで建てました。
SPにはALBと簡単に統合できるため、Cognitoを使用することにします。属性のマッピングはユーザーのIDをメールアドレスとするため、emailだけです。
KeycloakとCognitoユーザープールの設定は以下の記事が丁寧で非常に分かりやすいです。
上記を参考にしたうえで、今回は以下の設定を変更します。
ホストされたUIのIDプロバイダーはSAMLアイデンティティプロバイダーのみにする
Cognitoのアプリケーションクライアントをコンソールから作成する場合、ホストされたUIのIDプロバイダーにはデフォルトでCognitoユーザープールが含まれていますが、今回は作成したkeycloak用のもののみにします。
Cognitoユーザープールを含んでしまうと、SAML認証の際にCognitoがホストするログイン画面が表示されてしまうためです。
keycloak
の方のボタンを押せばローカルのkeycloakにリダイレクトされるためアクセス自体は問題ありませんが、この画面はCloudFrontに置かれているスタイルシートを使用しているため、後ほどNetwork Firewallを設定する際に.cloudfront.net
も許可してあげなければならなくなります。
もしCognitoドメインのみをFirewallで許可した場合はスタイルシートが取れなくなるので次のように壊れた画面になります。
Cognitoユーザープールを外してあげることで、サービスアクセス後Cognitoユーザープールのログイン画面を挟まずにすぐにIdPへリダイレクトすることができます。
ALBと連携する際はコールバックURLとOAuth付与タイプを変更する
ドキュメントに記載のある通り、ALBと連携する際にはコールバックURLのパスを変更し、後ろに/oauth2/idpresponse
を付ける必要があります。
また、OAuth付与タイプを認証コード付与
にする必要があります。CDKでは以下のauthorizationCodeGrant
になります。
これで変更点は以上です。 CDKのコードは以下のようになります。
video-training-service/cdk/lib/cognito.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
AWS Network Firewallを入れてアウトバウンドを制限
SAML認証にCognitoを使用しますが、CognitoはVPCエンドポイントが用意されていません。そのためInternet Gatewayを通してCognitoエンドポイントへアクセスする必要がありますが、インターネットへのアウトバウンドは最小限としたいので、今回はAWS Network Firewallを使用します。
以下のようにFirewall用のサブネットを置き、NAT Gatewayを介してInternet Gatewayに抜けるようにします。
CDKでは以下の部分です。
Cognitoドメインは自分で指定したプレフィックスに.auth.ap-northeast-1.amazoncognito.com
が付いたものになるので、ドメインフィルターでこのドメインのみを許可します。ステートレスルールは無しで、ステートフルルールを「厳密な順序」と「確立された接続のパケットをドロップ」に設定します。
PrivateサブネットとPublicサブネットの間にFirewall Subnetを置くので、アウトバウンドとインバウンドのトラフィックがそれぞれFirewall Subnetを経由するようにルートを変更してあげる必要があります。
これでWebサイトへはアクセスできるようになりました。
API
今回はユーザーも少なく、サービスの性質上そんなにアクセスも来ないのでAPIにはAamazon Lambda関数を使用します。
Lambda関数はALBから直接リクエストを転送できるため簡単に連携することができます。API Gatewayのような便利な機能はありませんが、レスポンスタイムアウトがデフォルトでLambda関数が実行終了するまで待ってくれます。API Gatewayは30秒以上はクォータの引き上げ申請が必要になります。
作成するAPIは以下の5つです。
ALBとの連携かつAPIも少なくシンプルな実装なのでLambdalithにする
Lambda関数を使用したAPIの作り方にはいくつかありますが、今回は全てのリクエストを1つのLambda関数で捌くモノリスなLambda関数、Lambdalithで作ることにします。
ALBからの連携でこのLambdaだけでなくS3エンドポイントにも分岐するため、できるだけパスを多く複雑にしたくないのと、非常に簡単なAPIが5つだけ、かつGET以外のリクエストがほとんど呼ばれないことが想定されるためです。
よく見るAPI Gatewayを使った作りだと、次のようにメソッドごとにLambda関数を分ける方法があります。私もこれを良く使いますし、綺麗だから好きです。リクエストごとにコードが分かれ、共通部分はLambdaレイヤーに分離させることで沢山のAPIがあっても全体をシンプルにできます。同時実行数などの設定もLambda関数ごとに決めることができます。
また、折衷案として参照系と更新系で分けるパターンもあります。
CDKのLambda関数はシンプルで以下のようになります。
Lambda関数の実行時間の限界である15分まで待つことができますが、タイムアウトだとしてもユーザーをそこまで待たせたくないので120秒としています。
video-training-service/cdk/lib/lambda.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
ALBからCognitoのユーザークレームを受け取る
/training/status
のPUTリクエストは社員の研修状態を完了に変更するAPIです。状態はDynamoDBに持ちますが、どの社員が研修を完了したのか記録するためにIdPから受け取ったユーザーに関する情報が必要になります。
Cognitoを使用したSAML認証の場合はALBがx-amzn-oidc-data
ヘッダーにユーザークレームをJWT形式で付けてくれます。
今回はその中からユーザーのメールアドレスを取得しています。
参照できる研修の制限などの認可処理もこれを利用して行うことができます。
Lambda関数では以下の部分です。
社員ごとの研修状態を持っているDynamoDBテーブルUserTrainingStatusTable
は次のようになります。
Email(S) | TrainingId(S) | IsCompleted(BOOL) |
---|---|---|
<ユーザーのメールアドレス> | <研修ID> | <完了ならtrue> |
video-training-service/cdk/lib/dynamodb.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
S3インターフェースエンドポイントでDNS名を有効化する場合、ゲートウェイエンドポイントを作成するとプライベートなままで最も低コストとなる
研修作成の際、ユーザーは動画ファイルをS3へアップロードする必要があります。しかしLambda関数へ送信できるリクエストBodyは最大1MBなので、S3署名付きURLを使って直接S3へアップロードする方法がよくとられます。
今回も例にもれずその方法を採用しますが、S3署名付きURLはhttps://s3.ap-northeast-1.amazonaws.com/<バケット名>/...
の形となり、そのドメインすらも変更することが許されません。そのためユーザーの環境からs3.ap-northeast-1.amazonaws.com
を名前解決できる必要があり、これはS3インターフェースエンドポイントのプライベートDNS名を有効化することで解決できます。
これだけでも動作自体は問題ありませんが、もう一つの設定としてインバウンドエンドポイントに対してのみのプライベートDNS
という設定も有効化し、かつS3ゲートウェイエンドポイントを作成すると、経路がAWSプライベートネットワーク経由になるため、最も低コストになります。
ドキュメントにはこちらに記載があります。
CDKではデフォルトで上記設定が有効となっています。
video-training-service/cdk/lib/vpc-endpoint.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
上記の理由でドメインがWebアプリと異なるため、S3バケットにはCORS設定が必要になります。
video-training-service/cdk/lib/s3.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
また、S3に直接アップロードするにあたり、いくつか注意点があります。
- SDKで署名付きURLを作る際に署名バージョン4を指定しないと403になる
ClientMethod
など他のパラメーターもアップロード時の内容と一致している必要がある- 署名付きURLを作成するIAMロールに当該S3バケットへのアップロード権限が必要になる
- 5GBを越えるファイルをアップロードする場合はファイル分割してマルチパートで送る必要がある
- S3のPUTリクエストはフォームデータをサポートしていないため、バイナリデータをそのまま送らないとファイルが壊れる(エラーにならないので特定がやや難しいです)
S3署名付きURLを生成するLambda関数のコードと、動画ファイルをアップロードするWebアプリのコードは次のようになります。
上記を踏まえて動画アップロードの経路は次のようになります。
動画変換処理
最後の機能として研修作成機能を考えていきます。
ユーザーが研修作成APIを呼ぶと最終的にAWS Step Functionsで組んだ動画変換処理が実行され、完了したらユーザーにメールで通知します。
動画変換は非同期で行う
研修作成画面でユーザーが研修の作成ボタンを押してからの流れは大きく次のようになります。
動画変換はStep Functions標準ワークフローで行いますが、時間がかかるため非同期で呼び出しています。
研修アイテムを持つDynamoDBテーブルTrainingTable
は次のようになっています。
VideoKeyだけは研修作成APIのアイテム作成時には埋まらず、動画変換後完了後にStep Functionsワークフローからアップデートされます。
また、研修一覧取得APIはVideoKeyがNULL以外のアイテムを取得して返すことにより変換中あるいは変換失敗となった研修を返さないようになっています。
TrainingId(S) | Description(S) | Title(S) | VideoKey(S) |
---|---|---|---|
研修ID | 研修概要 | 研修タイトル | 動画のS3キー |
video-training-service/cdk/lib/dynamodb.ts at v1.0.0 · kkmtyyz/video-training-service · GitHub
MediaConvertのPro設定はコンソールからだと簡単に見つけられる
動画変換処理を行うStep Functions標準ワークフローは次のようになっています。
タスクごとの内容は次のようになります。
- Convert Input Param: Lambda関数から入力された研修ID、研修タイトル、動画アップロード先S3バケット名、S3キー名を後続のタスクで取得しやすいように整理します
- Convert Video: AWS ElementalMediaConvertのジョブを同期実行します。変換後動画は静的コンテンツ用S3バケットに出力されます
- Update DynamoDB Training Item: 研修アイテムのVideoKeyを変換後動画のS3キーでアップデートします
- Success: ステータスを成功として次のPublish Notification Topicタスクを呼び出します
- Fail: ステータスを失敗として次のPublish Notification Topicタスクを呼び出します
- Publish Notification Topic: 研修タイトルとステータスに応じて文章を作り、メール送信します
最後のメール送信はSESで特定のユーザー宛てに出すべきですが、今回はSNSでメール通知しています。
FailタスクへはConvert VideoかUpdate DynamoDB Training Itemが失敗した際に遷移します。
MediaConvertはオンデマンド料金で使用することができますが、沢山ある設定の中にいくつかプロフェッショナル階層というものがあります。
このプロフェッショナル階層の設定を有効にしてしまうと追加料金が発生します。沢山設定項目があり、なんとなくテンプレートから作成したりするとプロ設定が含まれてしまい追加料金が生じるなんてことが起こり得ます。
また、Step FunctionsのようにAPIから呼び出す場合は複雑なJSONを入力する必要があります。
しかしこの問題は両方ともコンソールから最初に確認することで解決できます。
まずプロフェッショナル階層ですが、もしも設定に含まれてしまっている場合は次のようにジョブの作成の横にProと出てきます。設定にもコンソールならPro設定はProとバッジが付いています。
入力用JSONについては一度コンソールでJobを実行すると概要の右上「JSONの表示」ボタンからそのままJSONをコピーできます。 Step Functionsでタスク定義する際にはこのJSONをそのまま張り付ければ完了です。 入力ファイルと出力ファイル部分は動的に埋めるようにする必要があります。
これで完成です。 もう一度全体を見てみましょう。
検証作業の流れとコスト
今回の検証作業は次の流れで行いました。
- コンソールでシステム全体を完成まで作る
- CDKに起こす
- ブログ記事を書く
10/7から作業を始めてブログが書き終わるまで約5週間半かかっており、作業した日の合計は19日間程度でした。 かかった費用は127ドル程度で、以下がその間の使用状況レポートになります。
完成に近づくにつれてリソースが増えていくのでコストも増えているのが分かります。
節約ポイント
CDK化はシステム全体完成後なので、完成までは継続して料金がかかるはずですが、上記グラフだと作業していない日はほぼ0ドルです。
理由は簡単で、時間で課金されるサービスや機能をきちんと把握して作業後に削除や解除を行っていたからです。なのでALBなんかは10回以上手で再作成しています。もう少し早くそこだけCDK化しても良かったかもと思いますが。
今回の構成だと節約ポイントは以下になります。
- AWS Client VPNはサブネットとエンドポイントとのアソシエーションに課金されるため、エンドポイント自体は全て残したままアソシエーションのみ解除する。次回はアソシエーションとルートだけポチっとすれば良いので楽
- ALBは全部消す。これは少し手間
- NAT Gatewayも消す。EIPは一緒に解放されないので忘れず解放しに行く
- インターフェース型エンドポイント各種も消す(作り直す際にプライベートDNS名の有効化を忘れずに)
- 高価なサービスほど後に構築する。今回はNetwork Firewallが最後
最も高価なサービス
今回最も効果だったサービスは上記グラフだとやや見づらいですが、AWS Network Firewallでした。 38.35ドル(5,996円)かかっています。
私が高いと思っているNAT Gatewayのおよそ6.3倍です。
リソース | 料金 |
---|---|
Network Firewall Endpoint | USD 0.395/時間 |
NAT Gateway | USD 0.062/時間 |
Application Load Balancer | USD 0.0243/時間 |
その代わりと言ってはなんですが、Network Firewallを使うとNAT Gatewayの料金が無料になります。 以下、ドキュメントからの引用になります。
Network Firewall エンドポイントについて請求される 1 時間および 1 GB ごとに、1 時間および 1 GB の NAT ゲートウェイを追加料金なしで使用できます。
高価なことに変わりはありませんが、一緒に使うことが多いサービスだと思うのでありがたいですね。
おわりに
前々からやってみたいと思っていた検証だったので今回できて良かったです。頭の中ではすんなり構築できても、実際にやってみると細かな設定で躓いたりすることが多いもので勉強になります。発表やイベントや仕事やプライベートも忙しく、なかなかハードな1カ月と少しでした。年内は予定が詰まっていて忙しいので体調に気を付けながら頑張りたいです。
次回はレビュー編を予定しています。Well-Archに絡めていろいろ考えていきたいと思います。