lambdaから別アカウントのroute53にアクセス
AWS STSのAssume roleを使用してdev環境からprod環境で作成したroleを借りてリソースにアクセスする。
背景
AWS Organizationsでprod/dev環境を分離した。
もともとEIPのコスト削減のために、EC2インスタンスが作成・削除された際自動でレコード追加・削除が行われる仕組み(上記参考記事)をcloudwatch event + lambdaで実装していたのだが、今回dev環境でもその仕組みを実装したい。
要点
- dev/prod環境はorganizationでアカウント単位で分離。
- prod側のRoute53に社内用ドメインをホストしている。
- dev側のEC2の管理はcloudformation
- インスタンスの作成時に自動でGlobalIPをつけている。
- 作成時にはdomainというタグをつけ、valueで登録したいFQDNを指定する。
- dev側のcloudwatch eventでインスタンスのステータス変化をチェックし、dev側のlambdaでprod側のroute53にレコード操作を行う。
Route53(prod環境)
IAM(prod環境)
stsのAssume Roleを指定して、devアカウントのroleと紐づける。
- policy: AmazonRoute53FullAccess
作成後、概要->信頼関係タブから信頼関係の編集、以下ポリシーを記載
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::[YourDevAccountId]:role/service-role/[YourDevAccountLambdaRoleName]"
},
"Action": "sts:AssumeRole"
}
]
}
Principalにdevアカウント側で作成するロールのArnを指定する。
※ devアカウントのパスは/service-role/としています。
完了するとコンソールでは信頼されたエンティティにdevアカウント側のロールが表示されます。
IAM(dev環境)
lambda->route53(prod)アクセス用roleの作成
policyの作成
- StsAccessPolicy
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::[YourProdAccountId]:role/[YourProdRoute53AccessRole]",
"Effect": "Allow"
}
]
}
Resourceに先ほど作成したroleのArnを指定
- LambdaBasicAccessPolicy
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:ap-northeast-1:[YourDevAccountID]:*",
"Effect": "Allow"
},
{
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:ap-northeast-1:[YourDevAccountID]:log-group:/aws/lambda/[YourDevAccountLambdaFunctionName]:*",
"Effect": "Allow"
}
]
}
lambdaからcloudwatch logsにアクセスするためのデフォルトポリシー
2つ目のActionのResourceにはLambdaのArnを指定する
roleの作成
使用するAWSサービス -> lambda
上記二つのポリシーに加えて、以下のポリシーをアタッチ
- AmazonEC2ReadOnlyAccess
- CloudWatchEventsFullAccess
lambda(dev環境)
レコード変更用関数の作成
- ソース(node.js)
var AWS = require('aws-sdk');
AWS.config.region = 'ap-northeast-1';
function getGlobalIp(instanceId, cb) {
var ec2 = new AWS.EC2();
var params = {
Filters: [
{
Name: 'instance-id',
Values: [ instanceId ]
}
]
};
ec2.describeInstances(params, function(err, data) {
if(err) {
console.log(err, err.stack);
} else {
console.log(data);
console.log(data.Reservations[0].Instances[0]);
var instance = data.Reservations[0].Instances[0];
cb(instance.PublicIpAddress, get('domain', instance.Tags));
}
});
}
function get(key, tags) {
for(var i=0; i<tags.length; i++) {
if(tags[i].Key == key) {
return tags[i].Value;
}
}
return null;
}
function setRoute53(hostedZoneId, domain, ip, session_param, cb) {
var route53 = new AWS.Route53(session_param);
var params = {
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet: {
Name: domain,
Type: 'A',
ResourceRecords: [
{
Value: ip
},
],
TTL: 300,
}
},
],
Comment: ''
},
HostedZoneId: hostedZoneId
};
route53.changeResourceRecordSets(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else cb(data); // successful response
});
}
function removeRoute53(hostedZoneId, domain, ip, session_param, cb) {
var route53 = new AWS.Route53(session_param);
var params = {
ChangeBatch: {
Changes: [
{
Action: 'DELETE',
ResourceRecordSet: {
Name: domain,
Type: 'A',
ResourceRecords: [
{
Value: ip
},
],
TTL: 300,
}
},
],
Comment: ''
},
HostedZoneId: hostedZoneId
};
route53.changeResourceRecordSets(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else cb(data); // successful response
});
}
// Acquisition of assumerole using sts.
async function stsAssumeRole() {
const sts = new AWS.STS();
const params = {
RoleArn: "arn:aws:iam::[YourProdAccountId]:role/[YourProdRoute53AccessRole",
RoleSessionName: 'CrossAccountCredentials',
};
const assumeRole = await sts.assumeRole(params).promise();
console.log('Changed Credentials');
const accessparams = {
accessKeyId: assumeRole.Credentials.AccessKeyId,
secretAccessKey: assumeRole.Credentials.SecretAccessKey,
sessionToken: assumeRole.Credentials.SessionToken,
};
console.log(accessparams);
return accessparams;
}
exports.handler = (event, context, callback) => {
console.log(event);
console.log(context);
// Promiss return of stsassumerole function by async.
stsAssumeRole().then(function(param){
var state = event.detail['state'];
if(state == 'running') {
getGlobalIp(event.detail['instance-id'], function(ip, domain) {
if(!domain) {
callback(null, 'skip: ' + event.detail['instance-id']);
} else {
setRoute53("[YourRoute53HostedZoneId]", domain, ip, param, function(data) {
callback(null, 'Success: ' + data);
});
}
});
} else if(state == 'shutting-down') {
getGlobalIp(event.detail['instance-id'], function(ip, domain) {
if(!domain) {
callback(null, 'skip: ' + event.detail['instance-id']);
} else {
removeRoute53("[YourRoute53HostedZoneId]", domain, ip, param, function(data) {
callback(null, 'Success: ' + data);
});
}
});
} else {
callback(null, "invalid state: " + state);
}
});
};
stsAssumeRoleで一時的な認証情報を取得し、prod環境のroute53にアクセスする。
(非同期処理なので実行順序に気をつけるのが厄介でした。)
-
role
先ほど作成したroleを指定 -
timeout
sts鍵取得に若干時間がかかるため30sに指定
cloudwatch event(dev環境)
EC2のステータスがrunningまたはshutdownになった場合にlambdaを実行する
イベントルールの作成
- イベントソース -> イベントパターン
- サービス名: EC2
- イベントタイプ: EC2 instance State-change Notification
- 特定の状態: running, shuting-down
- ターゲット: 先ほど作成したLambda関数
テスト
dev環境でEC2を作成して確認。
作成時にdomainというタグをつけ、valueにFQDN(subdomain.yourdomain.xx)を指定し、prod環境のRoute53にレコードが追加されていれば完成。
EC2を削除すれば自動でレコードも削除されます。
cloudformation化
dev環境側の設定をcloudformation化して運用している。
(スタックを再作成した場合にprod側IAMの信頼関係を再設定する必要がある。)
- template
AWSTemplateFormatVersion: '2010-09-09'
Description:
Create DDNS for Route53 in prod
Resources:
IamRole:
Type: 'AWS::IAM::Role'
Properties:
RoleName: '[YourDevAccountLambdaRole]'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- 'lambda.amazonaws.com'
Action: 'sts:AssumeRole'
Path: '/service-role/'
ManagedPolicyArns:
- 'arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess'
- 'arn:aws:iam::aws:policy/CloudWatchEventsFullAccess'
StsPolicy:
Type: 'AWS::IAM::Policy'
Properties:
PolicyName: 'StsAccessPolicy'
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: 'sts:AssumeRole'
Resource: 'arn:aws:iam::[YourProdAccountId]:role/[YourProdAccountRoute53AccessRole]'
Roles:
- Ref: 'IamRole'
LambdaPolicy:
Type: 'AWS::IAM::Policy'
Properties:
PolicyName: 'LambdaBasicAccessPolicy'
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: 'logs:CreateLogGroup'
Resource: 'arn:aws:logs:ap-northeast-1:[YourDevAccountId]:*'
- Effect: Allow
Action:
- 'logs:CreateLogStream'
- 'logs:PutLogEvents'
Resource: 'arn:aws:logs:ap-northeast-1:[YourDevAccountId]:log-group:/aws/lambda/[YourDevAccountLambdaName]:*'
Roles:
- Ref: 'IamRole'
Lambda:
Type: 'AWS::Lambda::Function'
Properties:
Code:
ZipFile: |
var AWS = require('aws-sdk');
AWS.config.region = 'ap-northeast-1';
function getGlobalIp(instanceId, cb) {
var ec2 = new AWS.EC2();
var params = {
Filters: [
{
Name: 'instance-id',
Values: [ instanceId ]
}
]
};
ec2.describeInstances(params, function(err, data) {
if(err) {
console.log(err, err.stack);
} else {
console.log(data);
console.log(data.Reservations[0].Instances[0]);
var instance = data.Reservations[0].Instances[0];
cb(instance.PublicIpAddress, get('domain', instance.Tags));
}
});
}
function get(key, tags) {
for(var i=0; i<tags.length; i++) {
if(tags[i].Key == key) {
return tags[i].Value;
}
}
return null;
}
function setRoute53(hostedZoneId, domain, ip, session_param, cb) {
var route53 = new AWS.Route53(session_param);
var params = {
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet: {
Name: domain,
Type: 'A',
ResourceRecords: [
{
Value: ip
},
],
TTL: 300,
}
},
],
Comment: ''
},
HostedZoneId: hostedZoneId
};
route53.changeResourceRecordSets(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else cb(data); // successful response
});
}
function removeRoute53(hostedZoneId, domain, ip, session_param, cb) {
var route53 = new AWS.Route53(session_param);
var params = {
ChangeBatch: {
Changes: [
{
Action: 'DELETE',
ResourceRecordSet: {
Name: domain,
Type: 'A',
ResourceRecords: [
{
Value: ip
},
],
TTL: 300,
}
},
],
Comment: ''
},
HostedZoneId: hostedZoneId
};
route53.changeResourceRecordSets(params, function(err, data) {
if (err) console.log(err, err.stack); // an error occurred
else cb(data); // successful response
});
}
// Acquisition of assumerole using sts.
async function stsAssumeRole() {
const sts = new AWS.STS();
const params = {
RoleArn: "arn:aws:iam::[YourProdAccountId]:role/[YourProdAccountRoute53AccessRole]",
RoleSessionName: 'CrossAccountCredentials',
};
const assumeRole = await sts.assumeRole(params).promise();
console.log('Changed Credentials');
const accessparams = {
accessKeyId: assumeRole.Credentials.AccessKeyId,
secretAccessKey: assumeRole.Credentials.SecretAccessKey,
sessionToken: assumeRole.Credentials.SessionToken,
};
console.log(accessparams);
return accessparams;
}
exports.handler = (event, context, callback) => {
console.log(event);
console.log(context);
// Promiss return of stsassumerole function by async.
stsAssumeRole().then(function(param){
var state = event.detail['state'];
if(state == 'running') {
getGlobalIp(event.detail['instance-id'], function(ip, domain) {
if(!domain) {
callback(null, 'skip: ' + event.detail['instance-id']);
} else {
setRoute53("[YourRoute53HostedId]", domain, ip, param, function(data) {
callback(null, 'Success: ' + data);
});
}
});
} else if(state == 'shutting-down') {
getGlobalIp(event.detail['instance-id'], function(ip, domain) {
if(!domain) {
callback(null, 'skip: ' + event.detail['instance-id']);
} else {
removeRoute53("[YourRoute53HostedId]", domain, ip, param, function(data) {
callback(null, 'Success: ' + data);
});
}
});
} else {
callback(null, "invalid state: " + state);
}
});
};
Description: [YourDevAccountLambdaName]
FunctionName: [YourDevAccountLambdaName]
Handler: index.handler
MemorySize: 128
Role: !GetAtt IamRole.Arn
Runtime: nodejs8.10
Timeout: 30
Rule:
Type: 'AWS::Events::Rule'
Properties:
Description: [YourCloudWatchEventName]
Name: [YourCloudWatchEventName]
EventPattern:
source:
- aws.ec2
detail-type:
- EC2 Instance State-change Notification
detail:
state:
- 'running'
- 'shutting-down'
State: ENABLED
Targets:
- Arn: !GetAtt Lambda.Arn
Id: lambda
LambdaEvent:
Type: 'AWS::Lambda::Permission'
Properties:
Action: 'lambda:InvokeFunction'
FunctionName: !Ref Lambda
Principal: 'events.amazonaws.com'
SourceArn: !GetAtt Rule.Arn
あとはstackを作成するだけ。今回は省略します。
感想
以上で完成。
stsを利用することで、より柔軟に組織的な権限設定が可能になりますね。
はじめは概念を理解するのが大変でしたが一度動かしてみて便利さに気づきました。
他にも色々と流用できそうです。
ちなみに、今回はじめてnodejsを使ってlambda書いてみましたが(もともとがnodejsだったため)、非同期処理を意識するのは大変ですね…。
精進します。