ランサーズ(Lancers)エンジニアブログ > AWS > 8年間お疲れ様でした!社内サーバーをAWSに移行したお話

8年間お疲れ様でした!社内サーバーをAWSに移行したお話

adachin|2019年09月20日
AWS

みなさんお久しぶりです。SREのadachin0817です。
今回は歴史あるランサーズの社内サーバーをAWSに2ヶ月ですべて移行することができました。

どのような取り組みとどのように運用しているのか、
サーバー触っているエンジニアさんは気になるところだと思うので公開します!

ランサーズ開発環境の歴史

2011年から開発環境がラックサーバーで動いており、VMware ESXiで仮想サーバー構築して検証していたようです。
歴史を感じる…

そこから数年経って、Dockerとなり、現在の開発環境になるわけですが、
この数年で、色んなエンジニアが稼働しているVMにバッチや、
その他ツール等運用していくことで誰が管理しているか分からず、技術的負債や属人化が見えるようになっていきました。

クラウドに移行するメリットとデメリット

以下、移行することのメリットとデメリットを記載してみました。

・年に一回ゴールデンウィーク中に実施される法定停電時のサーバー停止、および起動作業が要らなくなる
・技術的負債をなくせる/整理できる
・運用コスト削減/サーバー電気代0円へ
・SREチームが管理することで属人化がなくなる
・AWSのコストが上がる

では現時点で動いているもので必要なサーバーを洗い出してみました。

・バッチ5つ(シェルスクリプト/PHP)
・社内DNSサーバー
・dev X 10台(新機能確認用コンテナ)
・弥生会計
・社内ツール 1つ(Rails)

バッチ

バッチは本番で運用しているサーバーがあるので、5つを移行するだけでしたが、
シェルスクリプト内でC言語でコンパイルしているものがあったりしてなかなか手強かったのを思い出します。

社内DNSサーバー

こちらはRoute53にすべて移行しました。登録するレコードが多く、
aws-cliで一気に登録すると楽なので、以下にサンプルを貼りますので参考にしてみてください。

・53.json

{
  "Comment" : "",
  "Changes" : [
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev0.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev1.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev2.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev3.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev4.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev5.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev6.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
 
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev0-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev1-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev2-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev3-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev4-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev5-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
    {"Action":"CREATE","ResourceRecordSet":{"Name":"dev6-hoge.xxxxxx.jp","Type":"A","AliasTarget":{"HostedZoneId":"xxxxxxxxxxxx","DNSName":"dualstack.rproxy-xxxxxxxx.ap-northeast-1.elb.amazonaws.com","EvaluateTargetHealth":false}}},
   ]
}

・run

$ jq . 53.json
$ aws route53 change-resource-record-sets --hosted-zone-id xxxxxxxx --change-batch file://53.json --profile aws
{
    "ChangeInfo": {
        "Id": "/change/xxxxxxxxxxxx",
        "Status": "PENDING",
        "SubmittedAt": "2019-05-07xxxxxxxxxxxxxxxx",
        "Comment": ""
    }
}

dev X (10台)/弥生会計

dev X(10台)に関しては、社内でエンジニア以外でも確認できるランサーズの検証用環境となります。
ローカルの開発環境との差異をなくすため、EC2上にdockerをインストールして構築しました。
ALBでは各ドメインごとにリスナールールを使用してリクエストをターゲットにルーティングしています。

また、インスタンスが立ち上がっているとコストがかかるため、CloudWatch Eventsで深夜に止めて、
Slack上で個人で使いたいときに予約して起動するようAPI Gateway、Lambda、Slack commandを利用して工夫してみました。
LambdaはNode.jsで書いたので参考にしてみてください。(以下画像より)

弥生会計はEC2上にWindows Server 2019をインストールしてリモートデスクトップを利用して運用しています。
バージョン16から19へのバージョンアップにつまずきましたが、
サポートに問い合わせして、無事移行することができました。
その他社内ツールで利用しているサーバーはAnsible化していなかったので一から作りました。

・確認
/ec2 dev1 status
・起動
/ec2 dev1 start
・停止
/ec2 dev1 stop

・index.js

'use strict';

const AWS = require('aws-sdk');
var targetId = '';

// EC2 インスタンスのステータスを確認
function statusEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: [instanceId],
        DryRun: false
    };
    return new Promise((resolve, reject) => {
        ec2.describeInstances(params, (err, data) => {
            if (err) reject(err);
            else    resolve(data.Reservations[0].Instances[0].State.Name);
        }); 
    });
}

// EC2 インスタンスを起動
function startEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: [instanceId],
        DryRun: false,
    };
    return new Promise((resolve, reject) => {
        ec2.startInstances(params, (err, data) => {
            if (err) reject(err);
            else     resolve(data.StartingInstances[0].CurrentState.Name);
        }); 
    });
}


// EC2 インスタンスを停止
function stopEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: [instanceId],
        DryRun: false,
    };
    return new Promise((resolve, reject) => {
        ec2.stopInstances(params, (err, data) => {
            if (err) reject(err);
            else     resolve(data.StoppingInstances[0].CurrentState.Name);
        }); 
    });
}

// 関数指定してインスタンスを制御
function executeControl(ec2Function) {
    var result = '';
    const a = ec2Function(process.env.EC2_REGION, targetId)
        .then(data => {
            result = data;
        }).catch(err => {
            result = { result: 'hoge', data: err };
        });
    return Promise.all([a]).then(() => result );
}

function getSuccessfulResponse(message, result) {
    return {
        "attachments": [
            {
                "color": "#32cd32",
                "title": 'Success',
                "text": message,
            },
            {
                "title": 'Result',
                "text": '```' + result + '```',
            },
        ],
    };
}

function getErrorResponse(message) {
    return {
        "response_type": "ephemeral",
        "attachments": [
            {
                "color": "#ff0000",
                "title": 'Error',
                "text": message,
            },
        ],
    };
}

# インスタンスIDの指定
exports.handler = (event, context, callback) => {
    if (!event.token || event.token !== process.env.SLASH_COMMAND_TOKEN)
        callback(null, getErrorResponse('Invalid token'));
    if (!event.text)
        callback(null, getErrorResponse('Parameter missing'));

    var target = event.text.split(' ')[0];
    switch (target) {
        case 'dev1':
            targetId = 'i-xxxxxxxxxxxx';
            break;
        case 'dev2':
            targetId = 'i-xxxxxxxxxxxx';
            break;
    }
    
    if (event.text.match(/start/)) {
        executeControl(startEC2Instance).then(result => { callback(null, getSuccessfulResponse(target + ' Starting...', result)); });
    } else if (event.text.match(/stop/)) {
        executeControl(stopEC2Instance).then(result => { callback(null, getSuccessfulResponse(target + ' Stopping...', result)); });
    } else if (event.text.match(/status/)) {
        executeControl(statusEC2Instance).then(result => { callback(null, getSuccessfulResponse(target + ' Get status...', result)); });
    } else {
        callback(null, getErrorResponse('Unknown parameters'));
    }
};

まとめ

8年も動いていた社内サーバーの電源を無事にシャットダウンすることができました。
このラックサーバーのおかげでランサーズの一部を担っているかと思うとなかなか感動的です。

社内でサーバーを運用することはコスト削減になりますが、メンテナンスが大変なのと、
オンプレであればハードウェア障害は避けられず、運用に伴う専門性が問われるため 、
クラウドに移行して誰でも運用できるような検証環境を実現できたので、
なかなか達成感がありました!

読んでいただきありがとうございました。
また会いましょう!! See you next time!!