社内システムのセキュリティ向上のため、Lambda + CloudFront + S3でインフラ基盤を再構築した話

はじめに

ソーシャル経済メディア「NewsPicks」SREチーム・新卒エンジニアの樋渡です。今回は、AWSサービスである「Lambda」「CloudFront」「S3」を用いて、弊社で使用している社内向けシステムの基盤を再構築し、開発者体験の向上やセキュリティ対策を行なったお話です。

お話の内容

弊社で使用している社内向けシステムの一つに「Watson」というシステムがあります。「Watson」とは簡単にいうと「NewsPicks」のユーザーIDをもとにユーザーごとの情報を検索・閲覧できるシステムで、お客様からの問い合わせ対応等に活用される重要なシステムです。「Watson」は構築されたのが8年前と歴史が古く、歴史が古い故に数々の問題を抱えていました。今回のお話では、歴史の古い社内システムのインフラとバックエンドを更改し抱えていた問題を解決したぜ!というお話となっています。

抱えていた課題

弊社では、近年のセキュリティインシデント傾向を鑑みてシステムのセキュリティ対策強化を行なっています。その対象として今回「Watson」が挙げられました。従来はBasic認証+社内VPNのIP制限のみだったものを2段階認証に変更したいという話になりました。そのため早速Watsonの開発に取り掛かろうとしたのですが、調査したところ多くの課題を抱えていました。

まず、Watsonの変更前の基盤(以降、旧基盤)のインフラ的な問題についてです。

移行前のインフラ構成

従来のインフラ構成としては、CLB + EC2(1つ) + DBというシンプルなインフラ構成でした。使用しているフレームワークとしては、 OSは「Amzon Linux 1」、フロントエンドが「React 15.4」、バックエンドとして「Node.js 7.10」を使用し、EC2内のアプリの永続化のためにforeverを利用しています。 このインフラの問題点は、大きく分けて2つありました。1点目が「OS・ランタイム・ライブラリのバージョン更新がメンテナンスされていない」、2点目が「EC2の単一なインスタンス構成のための可用性の問題」です。それぞれについて何故問題かについて説明します。

1点目の「OS・ランタイム・ライブラリのバージョン更新がメンテナンスされていない」の理由と課題点です。なぜメンテナンスがされていないかについては、これは社内システムである点が大きいです。Watsonは、基本的に利用者が社内のカスタマーサポートに閉じており利用者の数としては少ないこと、また「Basic認証 + 社内VPNのIP制限」でセキュリティ対策をしており、基本的に外からの通信はされないこと、これらの理由からインフラのアップデート更新が後回しにされていました。

課題点については、今回のメイン課題であるセキュリティ対策に問題があることです。利用している「OS・ランタイム・ライブラリ」全てがEOLであり、セキュリティアップデートがされていかないです。また2段階認証の導入時にバージョンの不一致が起こるためバージョンをあげていく必要があります。そのような背景から、今回セキュリティ対策を強化していくにあたって、インフラにおけるセキュリティ課題も解決していこうという流れになりました。

次に、2点目の「EC2の単一なインスタンス構成のための可用性の問題」についてです。旧基板では、EC2が一つのシングルインスタンス構成であるため、EC2が何らかの問題で停止するとサービスが止まってしまうという問題がありました。また、旧基盤ではforeverというライブラリを利用しEC2内のNode.jsアプリの永続化を実現していました。EC2へアプリの新バージョンを適用する時にforever stop -> deploy -> forever startという流れを辿ります。その結果、deployをするたびにサービスが一時的に停止してしまうという課題も抱えていました。 旧基盤のような構成であり続けた理由としては、これも社内システムであるという点が大きいです。利用者が少ない社内システムである点、同じ理由からこれまで頻繁に開発がされているわけではない点、これらの理由から課題として認識はしていても旧基盤の更新や改善がされてきませんでした。また単にEC2を複数台立てれば良いという話もありますが、利用者が少ない社内システムにコストをかけたくない思いもありました。

最後に、今回のインフラ刷新の目的である2段階認証を導入する理由についてです。「Basic認証 + IP制限」がされていますが、これらのセキュリティ対策には課題があります。例えば、社内のPCを紛失・盗難されてしまったとします。紛失したPCには、セキュリティロックがかかっておらず「Basic認証のID・Password」、「社内VPNの接続情報」が記録されていたとすると悪意ある第3者に渡ってしまった際に、従来のセキュリティ対策では突破されてしまいます。紛失や盗難以外にも、ハッキングされてしまった際にも同様です。 これらのセキュリティインシデントを未然に防ぐため、紛失・盗難されてしまった時にセキュリティロックを突破されてしまわないように手元の端末からの2段階認証を導入します。  (余談ですが、弊社の社内VPNには既に2段階認証は導入済みです。ですが、念には念を入れたいです。)

長くなってしまいました。まとめると↓です

  • 「OS・ランタイム・ライブラリのバージョン更新がメンテナンスされていない」
    • EOLになっているものが多くセキュリティアップデートがされていかない。バージョンが古すぎて2段階認証が導入できない
  • 「EC2のシングルインスタンス構成による可用性の問題」
    • サービスの可用性が低い。コスト的な観点からEC2台数を増やすことも難しく、開発者体験も悪い
  • 「Basic認証 + IP制限では不十分」
    • 紛失や盗難などのセキュリティインシデントが起こった際に突破される可能性があり、十分じゃない

どうやって課題を解決していくか

では、課題点と解決の必要性がわかったので、どうやって解決していくのかについてです。今回あげた課題の中で、解決が急務な課題は開発者体験の悪さです。開発者体験の悪さがネックとなり、Watsonに関する改善やセキュリティ対策がこれまでされてきませんでした。古いシステムゆえにインフラもIaCで管理されておらず、dev環境が用意しづらいこともこの開発者体験の悪さの大きな要因でした。

では、どうやって解決するのか。そうです、cdkで管理することにします。簡単ですね!! 今回は、従来のインフラをそのままIaC化するのではなく、「Lambda + Cloudfront + S3」でサーバレスなインフラを構築することにします。 下の図が具体的に構築するインフラとその利点です。

Lambda + S3 + Cloudfront

CloudFront + S3がフロントエンドで、API Gateway + Lambda + DB(RDS + Redshift)がバックエンドを担っています。バックエンド基盤としてLambdaを採用した理由は2つです。1つ目が、旧基盤で問題になっていたdeploy時のダウンタイム問題が発生しないことです。既にあるLambdaに対して新しいバージョンをdeployしている最中に通信があった場合、Lambdaは旧バージョンのコードを元に応答を行なってくれます。2つ目が、コストが削減されることです。旧基盤のEC2では、サービスを利用しない時間帯でも起動し続けて運用を行なっていました。そのため利用しない時間帯、業務時間外にも料金が発生し続けてしまい余計にコストがかかってしまう状態でした。Lambdaの料金体系は、従量課金制で起動時間等は関係なくLambdaに対する通信の回数で料金が決まります。そのため今回のような社内システムで利用者が多くない時には、Lambdaを利用する方がコストが低くなることを期待しました。

最後になぜIaC化する必要があったのかについてです。IaC化する理由は管理がしやすくなるため!というのはそうなのですが、他にも開発者的な目線で理由がありました。それは、開発環境が欲しい!ということです。今回対象とするWatsonは、古いシステムということもあり開発に使える開発環境が存在していませんでした。そのためアプリの開発をする際は、localでアプリを立ち上げlocalのポートにAWSのDBたち(RDSやredshift)のエンドポイントを持ってくることで開発を行なっているという状態でした。今後セキュリティ対策のために開発をしていくにあたって、このような状況は開発者体験としても効率としても非常に悪く、これからの開発の障壁となっていました。この問題を解決したいがためにIaC化します。cdk管理にすることによって開発環境を簡単に用意でき、また管理の面でもコード管理にすることにより削除・作成・変更が容易になります。

またまた長くなりましたが、まとめます。

  • EC2 -> Lambdaにすることにより、deploy時のダウンタイム発生を回避できる。
    • バックエンドにLambdaを利用することで、EC2の時に発生していたdeploy時のダウンタイムを回避する。
    • ダウンタイムが発生しないので利用者とのdeploy時間調整が不要となる。
  • EC2 -> Lambdaにすることにより、コスト削減が期待できる。
    • EC2は起動時間による従量課金、Lambdaは呼び出し回数による従量課金。
    • 社内システムのような利用者と利用時間が限られるサービスではコスト削減効果が大きい。
  • コードによるインフラの管理が行えるため、dev環境の整備が容易になる
    • newspicksではdev環境が15環境(面)用意されているが、これらの面への反映も容易になる。
    • 継続的な改善もやりやすくなる。

つまり、このインフラを実装してあげれば、旧基盤が抱えていた課題を解決することができセキュリティ対策を進めることができるようになります!

利用するAWSサービス

今回の通知の仕組みを実装するために利用したAWSサービスは以下の通りです。

  • AWS Lambda
  • AWS CloudFront
  • AWS S3
  • AWS API Gateway
  • AWS RDS
  • AWS Redshift

今回主流なAWSサービスを使用しているため、サービスごとの機能の説明は割愛します。 ここからは、実装したリソースの実装方法について説明します。

Lambdaのリソースの定義

今回の基盤更改にあたって、バックエンドのフレームワークをExpressへと変更しました。そのためコードとしては、Lambda + S3 + serverless-expressの使用例となります。

cdkでの実装です。通常通り、lambda関数を作成してあげて、handlerにlambda.Code.fromAsset()でExpressアプリを読み取ります。

        const lambdaFunction = new lambda.Function(this, "Function", {
            functionName: `${prefix}watson-server`,
            code: lambda.Code.fromAsset(__dirname + "/../../server"),
            handler: "lambda.handler",
            runtime: lambda.Runtime.NODEJS_18_X,
            vpc,
            vpcSubnets: { subnets },
            securityGroups: [securityGroup],
            logRetention: logs.RetentionDays.ONE_MONTH,
            environment: { NODE_ENV: envName },
            timeout: Duration.seconds(30),
            initialPolicy: [policy],
        });

serverlessExpressを使用して、handlerへとExpressAppを渡します。

import serverlessExpress from "@codegenie/serverless-express";

import { app } from "./express";

export const handler = serverlessExpress({ app });

S3のリソースの定義

WatsonのクライアントであるReactのSPAを保持しておくS3バケットを定義します。

export class ClientAppBucket extends Bucket {
    constructor(scope: Construct, id: string, props: ClientAppBucketProps) {
        const { prefix } = props;
        super(scope, id, {
            bucketName: `${prefix}watson-client-app`,
            publicReadAccess: false,
            blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
            encryption: BucketEncryption.S3_MANAGED,
            objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED,
            accessControl: BucketAccessControl.PRIVATE,
            removalPolicy: RemovalPolicy.DESTROY,
            autoDeleteObjects: true,
        });
    }
}

CloudFrontのリソースの定義

S3バケットの内容を配信するためのCouldFrontディストリビューションを定義します。再構築前のURLとの互換性のためにCloudFront Functionsを使ったリダイレクト処理も定義します。

export class WatsonDistribution extends cloudfront.Distribution {
    constructor(scope: Construct, id: string, props: WatsonDistributionProps) {
        const { prefix, bucket, certificate, domainName } = props;
        const origin = new S3Origin(bucket);

        super(scope, id, {
            defaultBehavior: {
                origin: origin,
                cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
            },
            domainNames: [`${prefix}watson.${domainName}`],
            certificate,
            defaultRootObject: "index.html",
        });

        this.addBehavior("app/*", origin, {
            functionAssociations: [
                {
                    // 旧Watsonの/app/以下へのリクエストをルートパスにリダイレクトするビューワーリクエスト関数
                    eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
                    function: new cloudfront.Function(this, "RedirectRootPathFunction", {
                        functionName: `${prefix}watson-redirect-root-path`,
                        code: cloudfront.FunctionCode.fromFile({
                            filePath: join(__dirname, "./functions/redirect-root-path.js"),
                        }),
                        runtime: cloudfront.FunctionRuntime.JS_2_0,
                    }),
                },
            ],
        });

        new ARecord(this, "ARecord", {
            zone: HostedZone.fromLookup(this, "HostedZone", { domainName }),
            recordName: `${prefix}watson.${domainName}`,
            target: RecordTarget.fromAlias(new targets.CloudFrontTarget(this)),
        });
    }
}

redirect-root-path.jsの実装は次のようになります。今回の基盤更改のタイミングでURLを変更したので、変更前の/app/から変更後の/にリダイレクトさせています。

async function handler(event) {
    const request = event.request;
    const host = request.headers.host.value;
    const uri = request.uri;

    if (uri.startsWith("/app/")) {
        const newUri = uri.replace("/app/", "/");
        const newUrl = `https://${host}${newUri}`;
        const response = {
            statusCode: 301,
            statusDescription: "Permanent Redirect",
            headers: { location: { value: newUrl } },
        };
        return response;
    }
    return request;
}

API Gatewayのリソースの定義

lambda関数の公開にはAPI Gatewayを利用します。HttpLambdaAuthorizerとしてIP制限を行うLambda関数(authHanlder)を指定することでサーバー実装とIP制限を分離しています。認証は2段階認証を使う実装に置き換えていく予定です

export class WatsonRestApi extends Construct {
    constructor(scope: Construct, id: string, props: WatsonRestApiProps) {
        super(scope, id);

        const { prefix, lambdaFunction, certificate, domainName } = props;

        const restApiName = `${prefix}watson-api`;
        const recordName = `${restApiName}.${domainName}`;
        const apigwv2DomainName = new apigwv2.DomainName(this, "DomainName", {
            domainName: recordName,
            certificate,
        });

        const authHandler = new NodejsFunction(this, "authHandler", { functionName: `${restApiName}-auth` });

        new apigwv2.HttpApi(this, "HttpApi", {
            apiName: restApiName,
            defaultIntegration: new HttpLambdaIntegration("LambdaHandlerIntegration", lambdaFunction),
            defaultDomainMapping: {
                domainName: apigwv2DomainName,
            },
            defaultAuthorizer: new HttpLambdaAuthorizer("HttpLambdaAuthorizer", authHandler, {
                identitySource: [], // 空配列を指定しないとAutorizationヘッダーが必要になる
                responseTypes: [HttpLambdaResponseType.IAM],
                resultsCacheTtl: Duration.seconds(0),
            }),
        });

        const zone = HostedZone.fromLookup(this, "HostedZone", { domainName });
        new ARecord(this, "ARecord", {
            zone,
            recordName,
            target: RecordTarget.fromAlias(
                new ApiGatewayv2DomainProperties(
                    apigwv2DomainName.regionalDomainName,
                    apigwv2DomainName.regionalHostedZoneId,
                ),
            ),
        });
    }
}

IP制限を行うauthHandlerの実装は次の通りです。aws:SourceIpに許可するIPアドレスを追加してあげます。

export const handler = async (event: any): Promise<any> => {
    const response = {
        principalId: "authHandler",
        policyDocument: {
            Version: "2012-10-17",
            Statement: [
                {
                    Effect: "Allow",
                    Action: "execute-api:Invoke",
                    Resource: event.methodArn,
                    Condition: {
                        IpAddress: {
                            "aws:SourceIp": [
                                "192.0.2.0/24", //許可するIPアドレスを入れます。
                                "203.0.113.0/24"
                            ],
                        },
                    },
                },
            ],
        },
    };
    return response;
};

実装後のよかった点

これらのインフラを実装してみて、よかった点は以下3点です!

  • dev環境の整備ができ、メンテナンスとトラブルシューティングが容易になった。開発者体験の向上につながった。
  • 開発者体験が向上したことにより、開発のスピードが上がった。セキュリティ問題の解決にも着手できた
  • 旧基盤のシングルインスタンス構成をLambdaに変えたことにより、ダウンタイムが完全に撲滅。いつでもリリース可能になった。
  • EC2を廃止し、従量課金制のLambdaにしたことにより、結果的にコストが削減された

結果として、開発者体験の向上をすることで、セキュリティ対策も解決され、普段の運用の負担も減りました!!

まとめ

今回のまとめです。 今回は社内の古いシステムをAWSサービスを駆使してインフラ再構築し、抱えていた開発者体験の課題を解決することでセキュリティ対策の課題も解決できるようにしたお話でした。開発者体験の改善をすることで、結果として普段の開発スピードが上がりセキュリティ対策や機能開発等の価値が提供できるようになったかと思います!

事業の継続的な価値提供のために、開発者体験や普段の運用負担軽減等の改善に励んでいきたいと思います。 ここまで読んでいただきありがとうございました!!

Page top