のぴぴのメモ

自分用のLinuxとかの技術メモ

AWS CLIのAssumeRoleするプロファイル設定を使って、AssumeRoleのクレデンシャルを取得し環境変数に設定するシェル芸 ワンライナーを作ってみた

見やすいようにバックスラッシュで行を分けてますが、ワンライナーです。
terraformをローカルマシンで実行する時に、AWSプロバイダーの部分にローカルマシン固有のプロファイルとか埋め込みたくないけど、都度クレデンシャルを手で環境変数に設定するのは手間だということで作ってみたものです。

AWS_BASE_PROFILE="default"; \
AWS_ASSUME_PROFILE="<assume先のプロファイル>"; \
aws --profile ${AWS_BASE_PROFILE} \
    sts assume-role \
        --role-arn $(aws configure get ${AWS_ASSUME_PROFILE}.role_arn) \
        --role-session-name ${AWS_ASSUME_PROFILE}-session \
    --query 'Credentials.{
              AWS_ACCESS_KEY_ID:AccessKeyId,
              AWS_SECRET_ACCESS_KEY:SecretAccessKey,
              AWS_SESSION_TOKEN:SessionToken
    }' | \
jq -r 'to_entries | .[] | [.key + " " + .value]|@tsv' |
while read key value; do export $key=$value; done

1行にするとこんな感じ。

AWS_BASE_PROFILE="default"; AWS_ASSUME_PROFILE="<assume先のプロファイル>"; aws --profile ${AWS_BASE_PROFILE} sts assume-role --role-arn $(aws configure get ${AWS_ASSUME_PROFILE}.role_arn) --role-session-name ${AWS_ASSUME_PROFILE}-session --query 'Credentials.{AWS_ACCESS_KEY_ID:AccessKeyId,AWS_SECRET_ACCESS_KEY:SecretAccessKey,AWS_SESSION_TOKEN:SessionToken}' |jq -r 'to_entries | .[] | [.key + " " + .value]|@tsv' |while read key value; do export $key=$value; done

terraformでSlackチャンネルを作成するための手順

概要

terraformからpablovarela/slackプロバイダーを利用し、slackのchannelを作成するための手順です。

slackを外部のアプリケーションから操作するためには、slackでアプリケーション(App)を作成する必要があります。作成したAppに必要な権限を付与し、App操作のためのTOKENを利用しterrafomから実行します。今回利用したpablovarela/slackプロバイダーは、環境変数SLACK_TOKENでTOKENをterraformに渡せますのでその方式を利用します。(セキュリティーの面からコードへのTOKEN埋め込みは避けたいため)

あとはterraformでコーディングして実行して、Slackのチャンネルを作成します。

f:id:nopipi:20210921001409p:plain

手順

SlackのApp作成

terraformで操作を行うため、SlackのApp(ポット)を作成します。

f:id:nopipi:20210920224224p:plain:w400

  • Create an appFrom scratchを選択

f:id:nopipi:20210920224833p:plain:w400

  • Appの名前とワークスペースを選択する
    • App Name : 今回はexecute-terraformという名称を設定
    • Pick a workspace to develop your app in: Appをインストールしたいワークスペースを指定する

f:id:nopipi:20210920225258p:plain:w400

  • Appのボット用TOKENに権限を付与する
    • 左のバーからOAuth & Permissionsを選択する
    • Scopesで、 Bot Token ScopesAdd an OAuth Scopeから必要な権限を追加する
    • 権限は以下の6つを追加する
      • terraformのResourceslack_conversation用権限
        • channels:manage
        • channels:read
        • groups:write
        • groups:read
      • terraformのData sourceslack_user用権限
        • users:read
        • users:read.email

f:id:nopipi:20210920230936p:plain:w400

f:id:nopipi:20210920231856p:plain:w400

f:id:nopipi:20210920231950p:plain:w400

  • ワークスペースへのAppインストール
    • 同じ画面上部のOAuth Tokens for Your Workspaceに移動し、Install to Workspace>を実行
    • 選択先の画面で許可を行う
  • TOKENの取得
    • Bot User OAuth Tokenに表示されているトークンを取得する

f:id:nopipi:20210920233403p:plain

terrafomのコード

terrafomのセットアップは、こちらを参照ください。

SlackのTOKEN設定

terrafomでSlackを操作するSlack Providerには、環境変数を利用しTOKENを渡します。

export SLACK_TOKEN="<Appで生成したtoken>"

terrafomでのSlackチャンネル作成のサンプルコード

Slack チャンネル作成用のサンプルです。
terraformの実用的な実装を考慮しmodule構成にはしていますが、blogでの視認性の観点からファイル分割や環境毎のディレクトリ分割はしていません。

.
├── main.tf
└── modules
    └── slack_channel
        └── module_main.tf
  • ./main.tf: プロバイダー設定 & モジュールの呼び出し
#----------------------------
# slack provider
#----------------------------
terraform {
  required_providers {
    slack = {
      source  = "pablovarela/slack"
      version = "~> 1.0"
    }
  }
  required_version = ">= 0.13"
}

provider "slack" {}

#----------------------------
# main
#----------------------------
module "slack_channelt" {
  source = "./modules/slack_channel"

  name             = "sample-channel"
  topic            = "This is sample channel for terrafom demo."
  slack_users_mail = ["clausvalca813@gmail.com", "n.fujita74@gmail.com"]
}
  • ./modules/slack_channel/module_main.tf: 最小モジュールのサンプル
#---------------------------
# Variable設定
#---------------------------
variable "name" {
  type = string
}

variable "topic" {
  type = string
}

variable "slack_users_mail" {
  type = list(string)
}

#---------------------------
# Provider設定
#---------------------------
terraform {
  required_providers {
    slack = {
      source  = "pablovarela/slack"
      version = "~> 1.0"
    }
  }
  required_version = ">= 0.13"
}

#---------------------------
# channel設定
#---------------------------
# 追加するユーザのメール情報からユーザIDの配列を生成
data "slack_user" "users" {
  for_each = toset(var.slack_users_mail)

  email = each.key
}

locals {
  users_list = [for i in data.slack_user.users : i.id]
}


# Create a Slack channel
resource "slack_conversation" "individual_alert_channel" {
  name              = var.name
  topic             = var.topic
  permanent_members = local.users_list
  is_archived       = false
  is_private        = false
}

ポイントは以下の通りです。

  • provider設定
    • 今回利用したプロバイダーは、 required_providersを設定しないと動作しないので要設定
    • module側でも同じ設定が必要
    • 設定内容は、こちらのドキュメントを参照
  • チャンネルに参加させるユーザ
    • slack_userでメールアドレスからSlackユーザを特定し、localsでユーザIDを配列にする

terraformの実行

terraform init
terraform apply

実行時の注意点

  • terraform destroyなどでチャンネルを削除しても、チャンネルはアーカイブされるだけで削除はされない
    • そのため再度チャンネルを作成しようとすると、エラー(name_taken: A channel cannot be created with the given name)が発生する
    • 再作成する場合は、事前にチャンネル管理者によりアーカイブされた該当チャンネルを削除する必要がある

Red Hat Developer ProgramのサブスクリプションはRed Hat DeveloperからRHELイメージをダウンロードすると自動的に付与される件

Red Hat Developer Programのに参加すると、無料のRed Hat Enterprise Linuxサブスクリプションを取得することができます。RHELのを取得できるのも嬉しいですが、redhat
ナレッジベースを参照できるようになるのが嬉しいです。
ちなみに、この無償プログラムは2016/4から提供されています。
また2020/12には、16 システムまでのプロダクション環境での利用に拡張されています。
www.redhat.com

(1)Red Hat Developer Programに参加してサブスクリプションを取得する

こちらの記事にまとまっていたのでこちらは割愛します。
qiita.com

(2)サブスクリプションの更新

Red Hat Developer Programのサブスクリプション"Red Hat Developer Subscription"は、他のサブスクリプション同様有効期限が1年です。
有効期限切れた場合、Red Hat DeveloperからRHELイメージをダウンロードすると、自動的に新しいRed Hat Developer Subscriptionが付与されました。

更新

  • 2018/9/9 初版
  • 2021/9/19 Red Hat Developerの最新ページに合わせ更新

Lambda(python)で特定のロググループ & ログストリームにログ出力するサンプルコード

Lambdaで特定のロググループ & ログストリームにログ出力するコードのサンプルです。
この例では、eventの内容をLogsに出力しています。

コードは以下の記事の内容をベースに一部最適化してます。

import json
import time
import boto3

logGroupName = "security-alaert"
logStreamName = "development"
  
def lambda_handler(event, context):

    #Get Session
    client = boto3.client('logs')

    #Put Log Event
    put_logs(client, logGroupName, logStreamName, "Received event:{0}".format( json.dumps(event)))

    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }


def put_logs(client, group_name, stream_name_prefix, message):
    try:
        #Set Logs Event Data
        log_event = {
            'timestamp': int(time.time()) * 1000,
            'message': message
        }
        
        #Set Flags
        exist_log_stream = True
        sequence_token = None
        while True:
            break_loop = False
            try:
                if exist_log_stream == False:
                    #Create LogGroup
                    try:
                        client.create_log_group(logGroupName=group_name)
                    except client.exceptions.ResourceAlreadyExistsException:
                        pass
                    #Create LogStream
                    client.create_log_stream(
                        logGroupName = group_name,
                        logStreamName = stream_name_prefix)
                    exist_log_stream = True
                    #Write First event log
                    client.put_log_events(
                        logGroupName = group_name,
                        logStreamName = stream_name_prefix,
                        logEvents = [log_event])
                    break_loop = True
                elif sequence_token is None:
                    client.put_log_events(
                        logGroupName = group_name,
                        logStreamName = stream_name_prefix,
                        logEvents = [log_event])
                else:
                    client.put_log_events(
                        logGroupName = group_name,
                        logStreamName = stream_name_prefix,
                        logEvents = [log_event],
                        sequenceToken = sequence_token)
                    break_loop = True
            except client.exceptions.ResourceNotFoundException as e:
                exist_log_stream = False
            except client.exceptions.DataAlreadyAcceptedException as e:
                sequence_token = e.response.get('expectedSequenceToken')
            except client.exceptions.InvalidSequenceTokenException as e:
                sequence_token = e.response.get('expectedSequenceToken')
            except Exception as e:
                print(e)
            
            if break_loop:
                break
    except Exception as e:
        print(e)

macOS zshのプロンプトにgitブランチをカラー表示してみる

やりたいこと

macOSzshのプロンプトにgitのブランチを表示するようにします。
表示はカラー表示させます。

今回の環境は以下の通りです。

  • ProductName: Mac OS X
  • ProductVersion: 10.15.7
  • BuildVersion: 19H1323

gitセットアップに関する他の関連記事はこちら。

セットアップ手順

モジュールのダウンロード

cd
mkdir .zsh
curl https://raw.githubusercontent.com/git/git/master/contrib/completion/git-completion.zsh -o ~/.zsh/git-completion.zsh
curl https://raw.githubusercontent.com/git/git/master/contrib/completion/git-prompt.sh -o ~/.zsh/git-prompt.sh

git-completion.zshgit-prompt.shの2つのファイルが空でない状態であることを確認します。

ls  -l .zsh
total 56
-rw-r--r--  1 XXXXXX  XXXX   7133  9 12 15:48 git-completion.zsh
-rw-r--r--  1 XXXXXX  XXXX  17781  9 12 15:48 git-prompt.sh

ログインシェルへの追加

ログインプロンプトに表示するための設定を~/.zshrcに追加します。

cat >> ~/.zshrc << EOL

# For git
fpath=(~/.zsh $fpath)
if [ -f ${HOME}/.zsh/git-completion.zsh ]; then
       zstyle ':completion:*:*:git:*' script ~/.zsh/git-completion.zsh
fi
if [ -f ${HOME}/.zsh/git-prompt.sh ]; then
       source ${HOME}/.zsh/git-prompt.sh
fi
GIT_PS1_SHOWDIRTYSTATE=true
GIT_PS1_SHOWUNTRACKEDFILES=true
GIT_PS1_SHOWSTASHSTATE=true
GIT_PS1_SHOWUPSTREAM=auto
setopt PROMPT_SUBST ; PS1='%F{46}mac%f: %F{051}%c%F{198}\$(__git_ps1)%f\\$ '
EOL
  • 最後の行の\$(__git_ps1)と、\\$の冒頭のバックスラッシュ(\)は、catで入力するヒアドキュメントがシェルとして評価されないようにするためのエスケープで追加したも

テスト

別のterminalを立ち上げ、gitのリポジトリディレクトリでbranchが表示されることを確認します。

AWS Organizationsのメンバーアカウントで予算設定するためには、先に管理アカウントでAWS Cost Explorerを有効化しないといけない件

困ったこと

AWS Organizationsのメンバーアカウントで予算設定をしようとしたところ、先にpayer account(AWS Organizationsの管理アカウント)側でbudgetsを有効化するようにというメッセージが出てエラーになりました。

BUDGET_CONFIG_JSON='
{
   "BudgetName": "Example Budget",
   "BudgetType": "COST",
    "BudgetLimit": {
        "Amount": "100",
        "Unit": "USD"
    },
    "TimeUnit": "MONTHLY"
}'

aws budgets create-budget \
    --account-id 999999999999 \
    --budget "${BUDGET_CONFIG_JSON}"    

発生したエラー内容

An error occurred (AccessDeniedException) when calling the CreateBudget operation: Account 999999999999 is a linked account. To enable budgets for your account, ask the payer account to enable budgets first.

解決方法

エラーメッセージには、ask the payer account to enable budgets first.とありましたが、実機で確認したところ正確には管理アカウントでAWS Cost Explorerを有効化する必要がありました。

管理アカウントのAWS Cost Explorerの有効化方法

AWS Organizationsの管理アカウントにアクセスします。
AWS Cost Explorerは、マネコンでAWS Cost Explorerを起動することで有効化されます。CLI(API)によるAWS Cost Explorer有効化はできません。*1

  • AWS Organizationsの管理アカウントの管理者権限のあるユーザで、マネージメントコンソールにサインインして
  • Billing and Cost Management コンソールを開く https://console.aws.amazon.com/billing/
  • 左のナビゲーションペインから[Cost Explorer]を選択し、
  • Cost Explorer を起動
  • 有効化されるまで24時間待つ
  • AWS Organizationsの管理アカウントでCost Explorer が利用可能になったことを確認します

メンバーアカウントでのBudget作成

  • Budgetを作成したメンバーアカウントにアクセスできるようにします(マネコン/CLIどちらでも可)
  • Budgetを作成する
BUDGET_CONFIG_JSON='
{
   "BudgetName": "Example Budget",
   "BudgetType": "COST",
    "BudgetLimit": {
        "Amount": "100",
        "Unit": "USD"
    },
    "TimeUnit": "MONTHLY"
}'

aws budgets create-budget \
    --account-id 999999999999 \
    --budget "${BUDGET_CONFIG_JSON}"    
  • 作成したBudgetを確認する
aws budgets describe-budgets --account-id 999999999999
{
    "Budgets": [
        {
            "BudgetName": "Example Budget",
            "BudgetLimit": {
                "Amount": "100.0",
                "Unit": "USD"
            },
            "CostTypes": {
                "IncludeTax": true,
                "IncludeSubscription": true,
                "UseBlended": false,
                "IncludeRefund": true,
                "IncludeCredit": true,
                "IncludeUpfront": true,
                "IncludeRecurring": true,
                "IncludeOtherSubscription": true,
                "IncludeSupport": true,
                "IncludeDiscount": true,
                "UseAmortized": false
            },
            "TimeUnit": "MONTHLY",
            "TimePeriod": {
                "Start": "2021-09-01T09:00:00+09:00",
                "End": "2087-06-15T09:00:00+09:00"
            },
            "CalculatedSpend": {
                "ActualSpend": {
                    "Amount": "0.048",
                    "Unit": "USD"
                }
            },
            "BudgetType": "COST",
            "LastUpdatedTime": "2021-09-05T15:37:34.817000+09:00"
        }
    ]
}

gitのpull reqやpushで更新があったディレクトリを抽出しJOSNの配列にするシェル芸

やりたいこと

  • system毎にディレクトリが分かれているterraformがあり(下記イメージ)、パイプラインで更新があったプロジェクトのディレクトリを判別してterraformを実行したい。
  • 具体的には、git diffを使って差分があるディレクトリを抽出し、それをJOSNの配列にしたい。
  • 差分は、mainブランチとの差分で、1コミット分の差分抽出でOK
  • シェルで実現したい
.
├── README.md
├── modules
│   ├── network
│   ├── resource
│   └── security
└── projects
    ├── system-a
    │   ├── backend.tf
    │   ├── data.tf
    │   ├── local.tf
    │   ├── main.tf
    │   ├── provider.tf
    │   └── terraform.tf
    ├── system-b
    └── system-c

やりかた

mainへのpull requestの場合のmainコミットとの差分抽出

TARGET_BRANCH=main
git fetch --depth 1 origin ${TARGET_BRANCH}  > /dev/null 2>&1
git diff  ${TARGET_BRANCH} HEAD --name-only -- 'projects/' | \
  sed 's:\(^projects/[^/]*\)*.*$:\1:' | \
  sort | uniq | \
  jq -scR 'split("\n") | .[:-1]';

push時の一つ前のコミットとの差分抽出

TARGET_BRANCH=main
git fetch --depth 2 origin ${TARGET_BRANCH}  > /dev/null 2>&1
git diff HEAD^ HEAD --name-only -- 'projects/' | \
  sed 's:\(^projects/[^/]*\)*.*$:\1:' | \
  sort | uniq | \
  jq -scR 'split("\n") | .[:-1]';

実行例

TARGET_BRANCH=main
git fetch --depth 1 origin ${TARGET_BRANCH}  > /dev/null 2>&1
git diff  ${TARGET_BRANCH} HEAD --name-only -- 'projects/' | \
  sed 's:\(^projects/[^/]*\)*.*$:\1:' | \
  sort | uniq | \
  jq -scR 'split("\n") | .[:-1]';
["projects/system-a","projects/system-b","projects/system-c"]

解説

  • git fetchgit diff
TARGET_BRANCH=main
git fetch --depth 1 origin ${TARGET_BRANCH}  > /dev/null 2>&1
git diff  ${TARGET_BRANCH} HEAD --name-only -- 'projects/' 

projects/system-a/backend.tf
projects/system-a/data.tf
projects/system-a/local.tf
projects/system-a/main.tf
projects/system-a/provider.tf
projects/system-b/Makefile
projects/system-b/backend.tf
projects/system-c/dummy
  • sed 's:\(^projects/[^/]*\)*.*$:\1:' |
    • sedでprojects直下のフォルダまでのパスまでだけ抽出しています。
    • sedの書き方は、こちらを参照ください
    • /projects/xxxxxのパスまで絞り込めますが、この段階では同じディレクトリが重複した状態になります。
projects/system-a
projects/system-a
projects/system-a
projects/system-a
projects/system-a
projects/system-b
projects/system-b
projects/system-c
  • /sort | uniq/
    • uniqコマンドで重複している行を削除します。uniqの前提としてソートされている必要があるため、あらかじめsortコマンドで行をソートします。
    • ここまでの実行結果
projects/system-a
projects/system-b
projects/system-c
  • jq -scR 'split("\n") | .[:-1]'
    • jqコマンドでJSONの配列に変換しています。

GitHubでSAML SSO認証が必要なレポジトリ用にgitコマンドでのアクセスが必要になった時のメモ

とある経緯で新しい案件のレポジトリをgit coneしようとして認証エラーになった時の対処メモです。
発生したgitのエラーはこちらです。

git clone https://github.com/XXXXXX/XXXXXX.git
Cloning into 'XXXXXX'...
remote: The `XXXXXX' organization has enabled or enforced SAML SSO. To access
remote: this repository, you must re-authorize the OAuth Application `XXXXXX`.
fatal: unable to access 'https://github.com/XXXXXX/XXXXXX.git/': The requested URL returned error: 403

対処方法

  • 端末の既存の認証データを削除する(MACならキーチェーンWindowsなら資格情報マネージャー)
  • GitHubPersonal access tokensを発行する
    • ブラウザでGitHubを開き、Settings -> Developer settingsへ移動し、Personal access tokensを選択
    • Generate New Tokenで新しいトークンを発行する
    • トークンを控えた後に、Enable SSOトークンと関連付させるSSOを選び認証させる
  • 改めてgit cloneを実行し、パスワードに発行したトークンを入力して認証する

Windows ServerでDNSサーバをPowerShellでセットアップする手順

手順

DNSサーバのインストール

  • DNSサーバと管理ツールのインストール
Install-WindowsFeature DNS -IncludeManagementTools 
  • 有効化するためにWindowsを再起動
Restart-Computer -Force

DNSサーバの管理ツールの実行

GUIDNSサーバを管理する

GUIは、DNS Manager(dnsmgmt.msc)を利用します。

  • CLIからDNS Managerを起動する場合
dnsmgmt.msc
  • メニューから起動する場合
    • [Start]->[Windows Administrative Tools] -> [DNS]
CLIDNSサーバを管理する

CLIの場合は、dnscmdコマンドを利用します。dnscmdコマンドの説明はこちらです。

CLIでのDNSサーバ管理

条件付きフォワーダー設定
  • 条件付きフォワーダーを追加する(ADなし)
dnscmd /zoneadd 対象ドメイン /forwarder フォワード先IP [フォワード先IP] ・・・

#実行例
dnscmd /zoneadd test.local /forwarder 8.8.8.8 8.8.4.4
  • 条件付きフォワーダーを追加する(ADあり)
dnscmd /zoneadd 対象ドメイン /dsforwarder フォワード先IP [フォワード先IP] ・・・ [/dp] [/domain | /forest | /regacy]
  1. /dp ・・条件付きフォワーダー設定をレプリケーションする場合のオプション。
  2. [/domain | /forest | /regacy] ・・/dpオプションでレプリケーションをする範囲の指定。通常はforest単位/ドメイン単位のどちらか指定
  • 設定した条件付きフォワーダ設定の確認
dnscmd /zoneinfo 対象ドメイン

#実行例
dnscmd /zoneinfo test.local

AWS BackupのOrganizations連携やクロスアカウントの機能をCLIで設定する方法

はじめに

AWS Backupの権限分掌の設計を行うにあたって、それぞれの機能の設定をどのロールの人が実施できるのか明確化するために確認したものです。
確認結果としては、「バックアップポリシー」と「クロスアカウントモニタリング」はAWS Organizationsの機能、「クロスアカウントでバックアップ」はAWS Backupの機能でした。(マネコンだとAWS Backupの画面にOrganizationsの機能が統合されていて、利用者からすると便利ですが、権限設計する側からするとわかりずらくてつらい。)

設定方法

バックアップポリシーの有効/無効

バックアップポリシーは、Organizationの中でバックアップを一元管理するための機能です。
こちらの機能は、AWS Organizationsの機能のため、AWS OrganizationsのAPIで操作します。(AWS Backupの機能ではない)

#有効化
RootId=$(aws --output text organizations list-roots --query 'Roots[].Id' )
aws organizations enable-policy-type  --root-id ${RootId}  --policy-type "BACKUP_POLICY"

#無効化
RootId=$(aws --output text organizations list-roots --query 'Roots[].Id' )
aws organizations disable-policy-type  --root-id ${RootId}  --policy-type "BACKUP_POLICY"

#状態確認
aws organizations list-roots 

クロスアカウントモニタリングの有効/無効

クロスアカウントモニタリングは、Organizationsの管理アカウントから、組織内のすべてのアカウントのバックアップアクティビティをモニタリングする機能です。こちらもAWS OrganizationsのAPIで操作します。(AWS Backupの機能ではない)

#有効化
aws organizations enable-aws-service-access --service-principal backup.amazonaws.com

#無効化
aws organizations disable-aws-service-access --service-principal backup.amazonaws.com

#状態確認(リストにbackup.amazonaws.comがあればEnable、なければDisable)
aws organizations  list-aws-service-access-for-organization   

クロスアカウントバックアップ有効/無効

クロスアカウントでバックアップ(リカバリーポイント)をコピーする機能です。こちらはAWS Backupの機能ですので、AWS BackupのAPIで操作します。
グロスアカウントの具体的設定方法については、こちらを参照下しさい。

#有効化
aws backup update-global-settings --global-settings '{"isCrossAccountBackupEnabled":"true"}'

#無効化
aws backup update-global-settings --global-settings '{"isCrossAccountBackupEnabled":"false"}'

#状態参照
aws backup describe-global-settings

なお本機能はバックアップを他のアカウントにコピーするだけです。他のアカウントのバックアップデータを利用してリストアすることはできません。

AWS Backup does not support recovering resources from one AWS account to another. However, you can copy a backup from one account to a different account and then restore it in that account. For example, you can't restore a backup from account A to account B, but you can copy a backup from account A to account B, and then restore it in account B.

https://docs.aws.amazon.com/aws-backup/latest/devguide/recov-point-create-a-copy.html#restore-cabより引用

Amazon ECS タスク定義の"タスクサイズのCPU"と”コンテナのCPUユニット”の違いを調べてみた

こちらはAWS Containers Advent Calendar 2020の13日目の記事です。
本記事は個人の意見であり、所属する組織の見解とは関係ありません。

とてもこまか〜い話ですが、Amazon ECSのタスク定義設定ではCPUのリソース設定に関して以下の2つのパラメータがあります。この2つのパラメータがどんな挙動をするのか気になりましたのでドキュメントと実機での挙動確認から調べてみました。最後にOSレベルですが素潜りもしてみました。(時間があればcgroupのコードも確認したかったのですが時間がなく、残念)

  • タスクサイズのCPU: タスクに使用される CPU量。
  • コンテナ定義レベルでのCPUユニット: コンテナ用に予約した cpu ユニットの数

f:id:nopipi:20201212213809p:plain:w500

長い記事になってしまったので調査結果の結論を先に行ってしまうと、以下の通りになりました。

  • コンテナのCPUユニットは、
    • 各コンテナに対するCPU制限で、各コンテナ合計値に対する設定値の割合で利用可能なCPUリソースが決まる
    • ECSはdockerのCPU Sharesを利用し実現しており、dockerはLinuxの場合はcgroupのcpu.sharesで機能を実現している
    • cgroupのcpu.shares仕様で、CPUの競合が発生していない時はコンテナのCPUユニットによる制限は発動しない
  • タスクサイズのCPUは、
    • 単一のタスクが対象となり、設定値はハードリミットになる
    • 実機確認の限りでは、Linuxではcgroupの cpu.cfs_period_uscpu.cfs_quota_usで機能を実現しているように見える

なお本記事に記載している内容は、下記前提で記載していますのでご了承ください。

  • ECS + EC2(Linux)構成で確認しています。Fargate構成やEC2(Windows)構成の場合は本記事の内容と異なる可能性があります。
  • 執筆時点(2020年12月13日)での実機環境での挙動確認の結果に基づいて記事を作成しています。

はじめにAmazon ECSとCPU設定について

Amazon ECSとは

Amazon Elastic Container Service (Amazon ECS) は、フルマネージド型のコンテナオーケストレーションサービスで、多数の仮想マシンを束ねてコンテナを一元管理運用するためのサービスになります。Amazon ECSは一元管理する機能ですので、実際にコンテナを実行する環境は別に必要となります。現時点でコンテナの実行環境としては、EC2インスタンスか、マネージドサービスのAWS Fargateのどちらかが選択できます。(今開催されているAWS re:Invent2020でAmazon ECS Anywhereが発表され、2021年にはどこにでもデプロイすることが可能となりますね)

Amazon ECSについて詳しく知りたいという方は、こちらのBlackBeltが参考になるかと思いますのでご参照ください。
aws.amazon.com

Amazon ECSのタスクとコンテナの関係

おさらいをかねて、Amazon ECSのタスクとコンテナの関係を振り返ります。

  • ECSタスク : Amazon ECSで管理する最小単位。Amazon ECSはタスク単位でコンテナの立ち上げ/立ち下げを行います。タスクには一つ以上のコンテナが含まれています。また単一のタスクは、同一のホストOS上で稼働させます。一つのタスクを2台以上のホストOSにまたいで実行することはできません。ECSタスクの構成は、タスク定義(Task definition)で設定されます。
  • コンテナ: dockerのコンテナです。ECSタスクの中に包含されています。単一のコンテナを起動するには、タスクの定義に1 つのコンテナのみ定義することで実現します。

f:id:nopipi:20201213135457p:plain:w600

まずはドキュメントで確認

まずはAmazon ECSの開発者ガイドでそれぞれのパラメータの仕様を確認してみます。ドキュメントでは、Amazon ECSタスク定義に説明があり、それぞれ下記に設定の記載があります。

上記のドキュメントの内容を表にまとめると以下のようになります。

表.タスクサイズのCPUとコンテナのCPUユニットの違い

設定 適用範囲 制限 空きCPUリソース
がある場合の挙動
備考
タスクサイズのCPU タスク ハード制限 上限を超えて
割り当てられない
コンテナのCPUユニット タスク内のコンテナ 各コンテナ合計値
に対する
割合で評価
設定値を超えて
CPUをコンテナに割り当て
dockerの
--cpu-sharesを利用

また上記以外に下記仕様もあります。

  • 双方の設定とも、1vCPU = 1024として設定を行う
  • タスク内の各コンテナに設定したCPUユニットの合計値が、タスクサイズのCPU以内であること

これらの内容を図にまとめると以下のようになります。

f:id:nopipi:20201213142511p:plain:w300

実機で挙動確認

それではドキュメントの内容を実機で確認して行きたいと思います。
なお今回は下記環境で挙動確認をしています。

  • バージョン
    • ECS Agentバージョン: 1.48.1
    • dockerバージョン: 19.03.13-ce
  • ワーカー構成
    • 起動タイプ: EC2インスタンス
    • AMI: Amazon ECS-optimized Amazon Linux 2 AMI(amzn2-ami-ecs-hvm-2.0.20201209-x86_64-ebs)
    • OS: Amazon Linux2(kernel version = 4.14.209-160.335.amzn2.x86_64)
    • EC2インスタンス構成
      • m5.large or m5.xlargeを利用
      • CPUオプションで1core=1threadに設定(CPUのHyperThreadの影響を排除するため)。従って今回の構成でのOS上のCPU数は以下の通りとなります。
        • m5.large -> OSが認識するCPUは、1個
        • m5.xlarge -> OSが認識するCPUは、2個

コンテナのcpuユニットの挙動を見てみた

では最初に、コンテナのcpuユニットのみ設定した状態での挙動を見てみます。

(1)ホストOSが1CPUの場合
  • ホストOS構成: m5.large(1スレッド/core設定) x 1ノード
  • OSが認識するCPU数: 1個
  • タスク構成
    • container1: CPU Unit = 768
    • container2: CPU Unit = 256

タスク内に2つのコンテナがあり、CPU配分を3/4(75%)と1/4(25%)に設定します。このタスクを1タスクのみ起動します。絵にすると以下の通です。

f:id:nopipi:20201213165350p:plain:w500

この状態で、ホストOSにsshでログインして、下記コマンドでそれぞれのコンテにCPU負荷を与えます。

docker exec -it <コンテナID> /bin/bash -c 'while true;do :; done;'

2つのコンテナにCPU負荷を欠けた状態で、ホストOS上でdocker container statsでコンテナのリソース利用状況を確認します。すると、container1がCPU利用率約 75%container2がCPU利用率約 25%でCPUユニットの設定通りの制限がかかっていることが確認できました。
f:id:nopipi:20201213170025p:plain

次に、この状態からContainer2の負荷をゼロにします(docker exec・・・のコマンドを、CTRL+Cで強制終了します)。すると、container1のCPU利用率が約100%になりました。
f:id:nopipi:20201213170443p:plain
ここまでで、CPUユニット設定の割合でCPU配分が決まることと、CPUに空きがある場合はタスクはCPUユニットの設定以上のCPUリソースを利用可能なことが確認できました。

(1)ホストOSが2CPUの場合

では次に、m5.xlarge(1スレッド/core設定) x 1ノード構成でホストOSが2CPUの場合どのような挙動になるか確認します。タスク定義は、先ほどの物と同じです。

f:id:nopipi:20201213172105p:plain:w500

この状態でまず、container1にのみ負荷をかけてみます。CPUの利用状況は、先ほどと同様container1のみCPU 100%の状態になりました。
f:id:nopipi:20201213172524p:plain

次にcontainer2にも負荷をかけます。この場合普通に想像すると、container1とcontainer2のCPU利用率の割合は3:1になることを予想すると思います。しかし実態は、なぜか2つのコンテナともCPU利用率が約100%になりました。
f:id:nopipi:20201213172816p:plain

この事象を理解するには、まずそれぞれのコンテナ上のプロセスがホストOSのどのCPUで実行されているかを確認する必要があります。

プロセスがどのCPUで実行されているかは、topコマンドで確認することができます。ただしデフォルトではこの情報は表示されないため、設定を変更する必要があります。具体的にはtopコマンドを実行し、fを押したあと、カーソルでP = Last Used Cpu (SMPに移動してdで表示するよう設定変更し、qで終了します。そうすると、右端にPという列が現れます。これはそのプロセスが最後に実行されたCPUのプロセッサ番号を表しています。

先ほどのcontainer1とcontainer2に負荷を掛けた状態で上記手順で、各プロセスが実行されているCPUを確認すると、0番と1番とありそれぞれのコンテナ上のプロセスが異なるCPUで実行されていることが確認できます。
f:id:nopipi:20201213175131p:plain

ではなぜ実行CPUが異なることでCPUユニットの制限が適用されないのでしょうか。結論を言うとLinuxカーネルのcgroupの仕様ということになります。

Amazon ECSのコンテナのCPUユニットは、dockerのCPU Shares機能を利用してコンテナのCPUリソースの制限を実現しています。そしてdockerのCPU Shares機能は、Linuxの場合はlinuxカーネルcgroupcpu.sharesを利用してCPUリソースの制限を実現しています。このcgroupのcpu.shareは、複数のプロセスが CPU リソースを競い合う場合のみに有効な機能となります。*1

ここまでの内容を絵にまとめるとこんな感じです。

f:id:nopipi:20201213181630p:plain:w500


ではCPU競合状態になったら本当にCPUユニットの設定が発動するのか確認してみます。先ほどのタスク数を1個から2個に増やして4つのコンテナ全てにCPU負荷をかけてCPUの競合状態を発生させます。

f:id:nopipi:20201213182610p:plain:w500

すると予想通りCPUユニット設定が効果を発揮しそれぞれのタスクで、container1のCPU利用率は約75%、container2のCPU利用率は約25%になりました。
f:id:nopipi:20201213182741p:plain

タスクサイズのCPU

次に、タスクサイズのCPUの挙動を実機で確認します。先ほどのCPUユニットの検証に利用したタスク定義に、タスクサイズのCPU設定を追加して以下の構成にします。

  • タスク構成
    • タスクサイズのcpu: 1024 (1CPUを割り当て)
    • コンテナのCPUユニット
      • container1: CPU Unit = 768
      • container2: CPU Unit = 256

この構成で、CPUユニット検証で利用したm5.xlargeのEC2インスタンスでタスクを1つだけ起動します。構成を絵にすると以下の通りです。

f:id:nopipi:20201213184457p:plain:w500

この状態で、2つのコンテナに対してCPU負荷を与えます。すると、先ほどのCPUユニット設定のみの場合はそれぞれのコンテナがCPUを100%利用しタスク全体では200%利用していましたが、今回はタスクサイズのcpu: 1024(=1CPUで制限)が入ったことで、それぞれのコンテナのCPU利用率は約50%にキャップされ、タスク全体で100%となりました。
f:id:nopipi:20201213184957p:plain

この状態についてですが、まずCPUユニットは先どのCPUユニット設定のみの場合と同様、ホストOSが認識するCPU数2個に対してCPUを利用するプロセスも2個でCPUの競合が発生していない状態のため、CPUユニット設定によるCPUリソース制限は発動していない状態となります。その上でタスクサイズのCPU制限はハード制限でタスク全体で1CPU分のCPUリソースしか利用できない設定(cpu = 1024)のため、結果としてそれぞれのプロセスのCPU利用率が50%に制限されてしまった、という物になります。

ではCPU競合状態が発生した場合はどうなるのか次に確認します。cgroupの制御がかかっていないホストOS上でwhile true;do :; doneを実行してCPU競合状態を発生させます。状況を絵にすると以下の通りになります。

f:id:nopipi:20201213190240p:plain:w500

すると、予想通りですがCPU競合が発生しCPUユニットの設定が有効化されてcontainer1とcontainer2でCPU利用率が3:1の状態になりました。
f:id:nopipi:20201213190507p:plain

タスクサイズのCPU設定について素潜りしてみる

すでにここまでで満腹感はありますが、もう一つ調べてみます。
コンテナのCPUユニットではdockerのCPU Shareを利用していることはAmazon ECSドキュメントに記載されていますが、タスクサイズのCPU設定については具体的な記載はありません。そこで実機のホストOSの中見てどんな風にタスクサイズの制限を実現しているか覗いてみます。

cgroupの構造を確認する

Linuxで動作するdockerでは、リソース制限をkernelのcgroupで実現しています。コンテナのCPUユニットは、話は端おりますが、dockerのCPU sharesの機能を利用しこの機能はcgroupcpu.sharesで実現しています。ということからタスクサイズのCPUもcgroupの機能で実現しているだろうなというあたりが付きます。そこでまずタスクを起動したときのcgroupの構造を確認してみます。

ということで、タスクサイズのCPUの実機確認をした下記構成を使ってホストOS上でcgroup構成を確認します。

f:id:nopipi:20201213184457p:plain:w500

まずはdocker container inspectコマンドで、コンテナID、内部IDやcgroupの親ディレクトリなどを確認してみます。
ホストOS上でdockerコマンドで起動しているコンテナを確認します。

$ docker ps
CONTAINER ID        IMAGE                                                                        COMMAND                  CREATED              STATUS                        PORTS                   NAMES
d2c1740cfafa        290054392706.dkr.ecr.ap-northeast-1.amazonaws.com/simple-httpserver:latest   "docker-php-entrypoi…"   About a minute ago   Up About a minute (healthy)   0.0.0.0:32783->80/tcp   ecs-cpuunit-and-tasksizecpu-1-container2-8afc9a86cdd6bac92500
cc28ca289411        290054392706.dkr.ecr.ap-northeast-1.amazonaws.com/simple-httpserver:latest   "docker-php-entrypoi…"   About a minute ago   Up About a minute (healthy)   0.0.0.0:32782->80/tcp   ecs-cpuunit-and-tasksizecpu-1-container1-d08d9fb3a4d6dbde8301
8851d081efe9        amazon/amazon-ecs-agent:latest                                               "/agent"                 3 hours ago          Up 3 hours (healthy)                                  ecs-agent

docker container inspect ><コンテナID>で、各コンテナの内部IDやcgroupのディレクトリを確認します。

$ sudo yum -y install jq

$ CONTAINER_ID=cc28ca289411
$ docker container inspect ${CONTAINER_ID} | jq -r '.[] |{ Id:.Id, Name:.Name, CgroupParent:.HostConfig.CgroupParent, CpuShares:.HostConfig.CpuShares}'
{
  "Id": "cc28ca2894114178ee023b549bfd29800f9ea311858d9c19b9bed38b308a1fbd",
  "Name": "/ecs-cpuunit-and-tasksizecpu-1-container1-d08d9fb3a4d6dbde8301",
  "CgroupParent": "/ecs/a411e266bd2649a88deec27182fce603",
  "CpuShares": 768
}
$ CONTAINER_ID=d2c1740cfafa
$ docker container inspect ${CONTAINER_ID} | jq -r '.[] |{ Id:.Id, Name:.Name, CgroupParent:.HostConfig.CgroupParent, CpuShares:.HostConfig.CpuShares}'
{
  "Id": "d2c1740cfafa72bdc7b505dec468ce0bac02210222c3e977cce08a8f12394a3f",
  "Name": "/ecs-cpuunit-and-tasksizecpu-1-container2-8afc9a86cdd6bac92500",
  "CgroupParent": "/ecs/a411e266bd2649a88deec27182fce603",
  "CpuShares": 256
}

内容を表にまとめると以下の通りです。

Container CONTAINER ID ID CgroupParent CpuShares
Container1 cc28ca289411 cc28ca2894114178ee023b549bfd29800f9ea311858d9c19b9bed38b308a1fbd /ecs/a411e266bd2649a88deec27182fce603 768
Container2 d2c1740cfafa d2c1740cfafa72bdc7b505dec468ce0bac02210222c3e977cce08a8f12394a3f /ecs/a411e266bd2649a88deec27182fce603 256

この内容を踏まえてcgroupのCPUに関するリソース制限設定を確認してみます。cgroupのCPU制限設定は/sys/fs/cgroup/cpuディレクトリ配下で確認できます。またコンテナのCgroupParentから各コンテナの設定は、/sys/fs/cgroup/cpu/ecs/a411e266bd2649a88deec27182fce603配下であることがわかります。

$ cd /sys/fs/cgroup/cpu/ecs/a411e266bd2649a88deec27182fce603
$ ls -l
合計 0
drwxr-xr-x 2 root root 0 1213 11:05 cc28ca2894114178ee023b549bfd29800f9ea311858d9c19b9bed38b308a1fbd
-rw-r--r-- 1 root root 0 1213 11:31 cgroup.clone_children
-rw-r--r-- 1 root root 0 1213 11:31 cgroup.procs
-rw-r--r-- 1 root root 0 1213 11:05 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 1213 11:05 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 1213 11:31 cpu.rt_period_us
-rw-r--r-- 1 root root 0 1213 11:31 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 1213 11:31 cpu.shares
-r--r--r-- 1 root root 0 1213 11:31 cpu.stat
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.stat
-rw-r--r-- 1 root root 0 1213 11:31 cpuacct.usage
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_all
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_percpu
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_percpu_sys
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_percpu_user
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_sys
-r--r--r-- 1 root root 0 1213 11:31 cpuacct.usage_user
drwxr-xr-x 2 root root 0 1213 11:05 d2c1740cfafa72bdc7b505dec468ce0bac02210222c3e977cce08a8f12394a3f
-rw-r--r-- 1 root root 0 1213 11:31 notify_on_release
-rw-r--r-- 1 root root 0 1213 11:31 tasks

ディレクトリのファイル一覧を見ると、container1/2のコンテナのID名のディレクトリがあることが確認できます。
では各コンテナのCPUユニット設定があるか確認してみます。

$ cat ./cc28ca2894114178ee023b549bfd29800f9ea311858d9c19b9bed38b308a1fbd/cpu.shares 
768
$ cat ./d2c1740cfafa72bdc7b505dec468ce0bac02210222c3e977cce08a8f12394a3f/cpu.shares 
256

Container1/2のCPUユニット設定が入っていることが確認できました。ここまでの内容を絵にまとめます。

f:id:nopipi:20201213205618p:plain

タスクサイズのCPUはどのように制限を実現しているか

とここまで見ると、ecs/a411e266bd2649a88deec27182fce603にタスクサイズのCPU設定があるっぽいことが見えてきます。ということでこのディレクトリのパラメータ(ファイル)を眺めていくと、間の話は飛ばしますが、結果としてタスクサイズのCPUのリソース制限はcpu.cfs_period_uscpu.cfs_quota_usで実現しているっぽいことがわかりました。

これらの設定は何かというと、以下の通りとなります。要は指定した期間で利用可能なCPU時間の制限で、CPUのハード制限の設定になります。詳しくはこちらのカーネルのドキュメントを参照下さい。

  • cpu.cfs_period_us : CPUリソース制限の期間の長さ(マイクロ秒単位)
  • cpu.cfs_quota_us : 期間内の利用可能な合計実行時間(マイクロ秒単位)

今回の環境のcgroupのCPU関連の設定を表にまとめると以下の通りになりました。

項目 cpu.cfs_period_us cpu.cfs_quota_us cpu.shares
タスク 100000 100000 1024
Container1 100000 -1 768
Container2 100000 -1 256

Container1/2は、cpu.cfs_quota_us = -1とありますが、これはContainer1/2単位ではcpu.cfs_quota_usによるCPUリソースのハード制限は無効化されているということになります。
一方でタスクは、 cpu.cfs_period_us = 100000(1秒)cpu.cfs_quota_us = 100000(1秒)とあり、これはタスクは1秒間にCPUリソースを1秒分利用できる = CPU1個分のリソースが割り当てられているということになります。

ここまでの実機の挙動確認から、タスクサイズのCPU設定によるCPUリソース制限は、cgroupのcpu.cfs_period_us cpu.cfs_quota_usで実現していそうだということまでが整理できました。

タスクサイズのCPUのcgroup設定は誰が行っているのか

これが最後です。ではタスクサイズのCPU設定のcpu.cfs_period_us cpu.cfs_quota_usは誰が設定しているのでしょうか?
ここまで来ればおそらくAmazon ECS Agentが設定しているだろうということはあたりがつきます。そこで、Amazon ECS Agentのプロセスのステムコールトレースを取得して確認してみます。

sudo yum -y install strace

ps -ef|grep -v grep |grep '/sbin/docker-init -- /agent'
root      4261  4232  0 08:12 ?        00:00:00 /sbin/docker-init -- /agent

sudo strace -f -tt -p 4261 -o ecs_agent_strace.log

で、ログを見ると cpu.cfs_period_uscpu.cfs_quota_usのファイルへの書き込みをしていることが見て取れます。

<前略>
#ディレクトリの作成
23434 11:05:13.722263 mkdirat(AT_FDCWD, "/sys/fs/cgroup/systemd/ecs/a411e266bd2649a88deec27182fce603", 0755) = 0
<中略>
#cpu.cfs_period_usへの書き込み
23434 11:05:13.724464 openat(AT_FDCWD, "/sys/fs/cgroup/cpu/ecs/a411e266bd2649a88deec27182fce603/cpu.cfs_period_us", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 000) = 27
23434 11:05:13.724487 epoll_ctl(4, EPOLL_CTL_ADD, 27, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3911208272, u64=140054204796240}}) = 0
23434 11:05:13.724505 fcntl(27, F_GETFL) = 0x8001 (flags O_WRONLY|O_LARGEFILE)
23434 11:05:13.724519 fcntl(27, F_SETFL, O_WRONLY|O_NONBLOCK|O_LARGEFILE) = 0
23434 11:05:13.724535 write(27, "100000", 6) = 6
23434 11:05:13.724562 epoll_ctl(4, EPOLL_CTL_DEL, 27, 0xc00050d7a4) = 0
23434 11:05:13.724577 close(27)     
<中略>
#cpu.cfs_quota_usへの書き込み
23434 11:05:13.724594 openat(AT_FDCWD, "/sys/fs/cgroup/cpu/ecs/a411e266bd2649a88deec27182fce603/cpu.cfs_quota_us", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 000) = 27
23434 11:05:13.724616 epoll_ctl(4, EPOLL_CTL_ADD, 27, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3911208272, u64=140054204796240}}) = 0
23434 11:05:13.724633 fcntl(27, F_GETFL) = 0x8001 (flags O_WRONLY|O_LARGEFILE)
23434 11:05:13.724647 fcntl(27, F_SETFL, O_WRONLY|O_NONBLOCK|O_LARGEFILE) = 0
23434 11:05:13.724662 write(27, "100000", 6) = 6
23434 11:05:13.724683 epoll_ctl(4, EPOLL_CTL_DEL, 27, 0xc00050d7a4) = 0
23434 11:05:13.724698 close(27)   

まとめ

本記事では、Amazon ECS タスク定義の"タスクサイズのCPU"と”コンテナのCPUユニット”の違いをドキュメントと実機の挙動確認から調べてみました。
調査結果としては、以下の通りになります。

  • コンテナのCPUユニットは、
    • 各コンテナに対するCPU制限で、各コンテナ合計値に対する設定値の割合で利用可能なCPUリソースが決まる
    • ECSはdockerのCPU Sharesを利用し実現しており、dockerはLinuxの場合はcgroupのcpu.sharesで機能を実現している
    • cgroupのcpu.shares仕様で、CPUの競合が発生していない時はコンテナのCPUユニットによる制限は発動しない
  • タスクサイズのCPUは、
    • 単一のタスクが対象となり、設定値はハードリミットになる
    • 実機確認の限りでは、Linuxではcgroupの cpu.cfs_period_uscpu.cfs_quota_usで機能を実現しているように見える

最後に

私は普段は、AWS Professional ServicesとしてAWS クラウドを使用して期待するビジネス上の成果を実現するようお客様を支援しています。
Professional Servicesには、私みたいな人以外に、ML、Bigdata、セキュリティ、アジャイル、UI/UXのデザインなど各分野のスペシャリストが揃っているエキサイティングなチームです。
興味がある方はこちらの採用のページをご確認ください。
aws.amazon.com

AWS環境のRHEL7/8にDNSキャッシュ(dnsmasq)を設定してみた

はじめに

AWS環境にRHEL8とRHEL7にdnsmasqを利用したDNSキャッシュを設定した時の手順メモです。ネットワーク設定にDHCPを利用している環境の場合、DHCPとの整合性を合わせる部分が鍵となります。

dnsmasqとは

Dnsmasqは軽量で比較的容易に設定できるDNSサーバのフォワーダとDHCPサーバをもつソフトウェアです(wikipediaより)。
これは私見ですが、個々のサーバでのDNSキャッシュを実現する場合、BIND9では高機能すぎるのでdnsmasqが利用されるケースが多いのではないかと思います。
詳しくは以下を参照してください。

作成する構成

RHEL8/7とも、この記事の手順を実行するとDNS解決は以下の図のような構成になります。 dnsmasqは、127.0.0.1の53番ポートでListenしており、アプリケーションがDNSゾルバ参照するときは、この127.0.0.1にアクセスするようになります。dnsmasqは、この後のRHEL7の手順でdnsmasqを構築した時のファイル名になります。RHEL8ではNetworkManagerからdbus経由で設定がdnsmasqに流し込まれるため、設定ファイルはありません。*1

f:id:nopipi:20201117015314p:plain
dnsmasqによるDNSキャッシュサーバ構成概要

RHEL8とRHEL7の違い

RHEL8とRHEL7の違いですが、RHEL8はNetworkManagerがよろしくやってくれるのでNetworkManagerの設定を一部変更するだけでdnsmasqの設定を行う必要はありませんが、RHEL7ではdnsmasqやdhcp設定を個別に設定する必要がある点が異なる点です。

RHEL8にdnsmasqを設定する

実行環境

今回は以下の環境で手順を検証しました。

  • AWSのRHEL8のインスタンスを利用
  • AMI
    • AMI ID: ami-0dc185deadd3ac449
    • AMI Name: RHEL-8.3.0_HVM-20201031-x86_64-0-Hourly2-GP2
    • Region: Tokyo(ap-northeast-1)

設定手順

dnsmasqインストールと実行ユーザ設定

dnsmasqパッケージをインストールします。

sudo yum -y install dnsmasq
NetworkManagerの設定変更

NetworkManagerの設定ファイル/etc/NetworkManager/NetworkManager.conf[main]セッションに、dns=dnsmasqを追加します。

sudo vi /etc/NetworkManager/NetworkManager.conf
  • /etc/NetworkManager/NetworkManager.conf
中略
[main]
plugins = ifcfg-rh,
#plugins=ifcfg-rh

dns=dnsmasq  #この行を追加

[logging]
以下略
再起動
sudo reboot
動作確認

digを利用してDNS参照して動作確認を行います。

(a) digコマンドのインストール
RHEL8にdigコマンドがインストールされていない場合は、bind-utilsパッケージをインストールします。

sudo yum -y install bind-utils

(b) 動作テスト
ANSWER SECTION:で問い合わせたFQDN名のDNSが解決できていること、および末尾のSERVER:で参照先のDNSゾルバが127.0.0.1であることを確認します。

dig aws.amazon.com

; <<>> DiG 9.11.20-RedHat-9.11.20-5.el8 <<>> aws.amazon.com
<中略>
;; ANSWER SECTION:
aws.amazon.com.		48	IN	CNAME	tp.8e49140c2-frontier.amazon.com.
tp.8e49140c2-frontier.amazon.com. 48 IN	CNAME	dr49lng3n1n2s.cloudfront.net.
dr49lng3n1n2s.cloudfront.net. 14 IN	A	143.204.75.74

;; Query time: 2 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Sun Nov 22 10:42:32 UTC 2020
;; MSG SIZE  rcvd: 137

RHEL7にdnsmasqを設定する

基本的には下記のAWSの記事を元にしていますが、一部手順とdnsmasq設定の見直しをしています。

実行環境

  • AWSのRHEL7のインスタンスを利用
  • AMI
    • AMI ID: ami-0e876007231767016
    • AMI Name: RHEL-7.8_HVM-20200803-x86_64-0-Hourly2-GP2
    • Region: Tokyo(ap-northeast-1)

設定手順

dnsmasqインストールと実行ユーザ設定

(a)dnsmasqのパッケージインストール
dnsmasqパッケージをインストールします。

sudo yum -y install dnsmasq

(b)dnsmasq実行ユーザ/グループ作成
この後のdnsmasq.confuser=group=で指定するdnsmasqの実行ユーザ&グループを作成します。

sudo groupadd -r dnsmasq
sudo useradd -r -g dnsmasq dnsmasq
dnsmasq設定ファイルの変更

(a) 設定ファイル(/etc/dnsmasq.conf)バックアップ

sudo mv /etc/dnsmasq.conf /tmp/dnsmasq.conf.orig

(b)設定ファイル(/etc/dnsmasq.conf)編集
テキストエディタ(viなど)で、設定ファイルを編集します。

sudo vi /etc/dnsmasq.conf

設定ファイルには以下の設定を行います。

# Server Configuration
listen-address=127.0.0.1   #local loop backでListenする
port=53
bind-interfaces    #listen-addressで指定したIPにbindを制限する
user=dnsmasq     #先ほど作成したdnsmasqの実行の専用ユーザを指定
group=dnsmasq  #先ほど作成したdnsmasq専用グループを指定
pid-file=/var/run/dnsmasq.pid

# Name resolution options
resolv-file=/etc/resolv.dnsmasq  #dnsmasqのDNSクエリー先のDNSサーバ指定
cache-size=500
domain-needed
bogus-priv

neg-ttl=60
#max-ttl=
#max-cache-ttl
#min-cache-ttl
  • dnsmasq設定のポイント
    • Listen先: listen-address=portで指定。かつbind-interfacesで指定したIPにbindを限定
    • 実行ユーザ/グループ: user=groupで指定
    • DNSクエリー先指定: resolv-file=DNSゾルバーを記載したファイルを指定。未設定の場合は/etc/resolv.confを参照。
    • キャッシュ保有期間/TTLに関する設定
      • 否定応答のキャッシュ時間指定: neg-ttl=で秒で指定。
      • 肯定応答のキャッシュ時間: 保持可能な最大時間をmax-cache-ttl=で秒で指定。(後述)
dnsmasqのDNS参照先設定(/etc/resolv.dnsmasq)

dnsmasqからのDNS参照先として、/etc/dnsmasq.confresolv-file=ファイル名で指定したファイルにdnsmasqが利用するDNSゾルバーを指定します。

DNSIP="$(awk -e '/^nameserver/{ print $2}' /etc/resolv.conf)"
echo $DNSIP

sudo bash -c "echo \"nameserver $DNSIP\" > /etc/resolv.dnsmasq"
dnsmasqサービスの有効化

(a)dnsmasqサービスの起動とOS起動時の自動起動設定

sudo systemctl restart dnsmasq.service
sudo systemctl enable dnsmasq.service

(b)dnsmasqの起動確認
プロセスが起動していること、ログにエラーがないことを確認します。

sudo systemctl status dnsmasq

● dnsmasq.service - DNS caching server.
   Loaded: loaded (/usr/lib/systemd/system/dnsmasq.service; enabled; vendor preset: disabled)
   Active: active (running) since Mon 2020-11-16 16:02:09 UTC; 8s ago
 Main PID: 13057 (dnsmasq)
    Tasks: 1 (limit: 100988)
   Memory: 792.0K
   CGroup: /system.slice/dnsmasq.service
           └─13057 /usr/sbin/dnsmasq -k

Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal systemd[1]: Started DNS caching server..
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: listening on lo(#1): 127.0.0.1
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: started, version 2.79 cachesize 500
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: compile time options: IPv6 GNU-getopt DBus no-i18n IDN2 DHCP DHCPv6 no-Lua TFTP no-conntrack ipset auth DNS>
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: reading /etc/resolv.dnsmasq
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: using nameserver 10.1.0.2#53
Nov 16 16:02:09 ip-10-1-64-32.ap-northeast-1.compute.internal dnsmasq[13057]: read /etc/hosts - 2 addresses
DHCPDNSゾルバー設定変更と反映

(a) DHCP設定ファイル更新(/etc/dhcp/dhclient.conf)
DHCPで変更するときに、dnsmasq DNS キャッシュをデフォルトの DNSゾルバーとするようして設定するよう、/etc/dhcp/dhclient.confを編集します。

sudo bash -c "echo 'supersede domain-name-servers 127.0.0.1, $DNSIP;' >> /etc/dhcp/dhclient.conf"

上記を実行すると、最初に127.0.0.1DHCPで取得し設定されていた元々のDNSゾルバーがエントリーされているはずです。

sudo cat /etc/dhcp/dhclient.conf
supersede domain-name-servers 127.0.0.1, 172.31.0.2;  <==2つのIPは環境によって変わります

(b)設定反映
dhclient コマンドを実行し、設定を反映します。(dhclientではなくrebootでも可能)

sudo dhclient
動作確認

digを利用してDNS参照して動作確認を行います。
(a) digコマンドのインストール
RHEL8にdigコマンドがインストールされていない場合は、bind-utilsパッケージをインストールします。

sudo yum -y install bind-utils

(b) 動作テスト
ANSWER SECTION:で問い合わせたFQDN名のDNSが解決できていること、および末尾のSERVER:で参照先のDNSゾルバが127.0.0.1であることを確認します。

dig aws.amazon.com

; <<>> DiG 9.11.20-RedHat-9.11.20-5.el8 <<>> aws.amazon.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 2564
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;aws.amazon.com.			IN	A

;; ANSWER SECTION:
aws.amazon.com.		35	IN	CNAME	tp.8e49140c2-frontier.amazon.com.
tp.8e49140c2-frontier.amazon.com. 35 IN	CNAME	dr49lng3n1n2s.cloudfront.net.
dr49lng3n1n2s.cloudfront.net. 21 IN	A	143.204.75.74

;; Query time: 3 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Mon Nov 16 16:15:02 UTC 2020
;; MSG SIZE  rcvd: 137

dnsmasqのキャッシュ保持期間

dnsmasqのキャッシュ保持期間は、dnsmasqの設定と各DNSレコードのTTL時間を評価して決定します。

  • dnsmasq設定のmax-cache-ttlとレコードのTTLいづれか小さい方の値がキャッシュ保持期間となる
  • ただし非推奨設定であるが、dnsmasq設定のmin-cache-ttlで0より大きい値を設定している場合、min-cache-ttlの設定値が最短の保持期間となる。(例えばmax-cache-ttlとレコードのTTLの評価でキャッシュ保持期間が5秒とされても、min-cache-ttl10秒であればキャシュ保持期間は10秒となる
  • max-ttl=はクライアントにレコードを渡す際にレコードに設定するTTLの最大値の設定。max-cache-ttlと同じ値を指定する。

*1: NetworkManagerから起動したdnsmasqの引数にenable-dbus=org.freedesktop.NetworkManager.dnsmasqというのがあり、これがdbus経由でdnsmasq設定が投入するためのオプションになります

アカウントに対してS3 block public accessをCLIで設定してみた

AWS CLIを利用してS3のパブリックアクセス ブロックを設定しようとしたら、設定対象によってCLIのコマンドが異なるという面倒な仕様だったのでメモを書いておきます。またAccess Pointは、Access Pointの作成時しかパブリックアクセス ブロックの設定ができない点は留意が必要です。

  • パブリックアクセス ブロックのCLI設定
    • アカウントに対して設定: aws s3control put-public-access-blockで設定
    • バケットに対して設定: aws s3api put-public-access-blockで設定
    • Access Pointに対して設定 aws s3control create-access-pointAccess Point作成時に設定(作成後の変更不可!!)

アカウントに対してパブリックアクセス ブロックを設定するCLI手順

PROFILE=<環境に合わせて設定。デフォルトの場合は"default"を指定>

ACCOUNTID="$(aws --profile ${PROFILE} --output text sts get-caller-identity --query 'Account')"

JSON='{
    "BlockPublicAcls": true,
    "IgnorePublicAcls": true,
    "BlockPublicPolicy": true,
    "RestrictPublicBuckets": true
}'

aws --profile ${PROFILE} s3control put-public-access-block --account-id ${ACCOUNTID} --public-access-block-configuration "${JSON}"

C言語/glibc getaddrinfo()を使ってホスト名からIPアドレスに変換するサンプル

C言語の標準ライブラリで、接続先のホストの名前解決をしてIPアドレス情報を取得するには、従来はgethostbynameの利用が一般的でした。しかし現在はIPv6に対応するため、getaddrinfo()を利用するべきとされています。*1

f:id:nopipi:20201116013457p:plain
getaddrinfo()の処理概要

ここでは、getaddrinfo()による名前解決とIPアドレスに解決した情報の参照方法のサンプルコードを以下に記載します。またおまけで、getaddrinfo()を実行した時の挙動をざっくり説明します。

サンプルコード

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>

#define  BUFF_SIZE 1024

int main(int argc, char **argv)
{
    char *hostname;
    char *service;
    struct addrinfo hints, *res0, *res;
    int ret;
    char ipandport[BUFF_SIZE], buf[BUFF_SIZE];
    int port;

    /* set hostname and service from arguments */
    if( argc < 2 ){
        fprintf(stderr, "error: illigal arguments.\nsample_getaddrinfo HOSTNAME [SERVICE]\n");
        return(EXIT_FAILURE);
    }else{
        /* set hostname */
        hostname = (char *)malloc(sizeof(char) * BUFF_SIZE);
        strncpy(hostname, *(argv+1), BUFF_SIZE);

        /* set port, if service is specified */
        if( argc > 2){
            service = (char *)malloc(sizeof(char) * BUFF_SIZE);
            strncpy(service, *(argv+2), BUFF_SIZE);
        }else{
            service = NULL;
        }
    }
    printf("hostname=%s, service=%s\n", hostname, service);

    /* main dish!! */
    memset(&hints, 0, sizeof(hints));
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_family = PF_UNSPEC;

    if ((ret = getaddrinfo(hostname, service, &hints, &res0)) != 0) {
        perror("error at getaddrinfo():");
        return(EXIT_FAILURE);
    }

    /* convert ai_addr from binary to string */
    switch(res0->ai_family){
        case AF_INET:
            inet_ntop(res0->ai_family, &((struct sockaddr_in *)res0->ai_addr)->sin_addr, buf, BUFF_SIZE);
            port = ntohs( ((struct sockaddr_in *)res0->ai_addr)->sin_port );
            break;
        case AF_INET6:
            inet_ntop(res0->ai_family, &((struct sockaddr_in6 *)res0->ai_addr)->sin6_addr, buf, BUFF_SIZE);
            port = ntohs( ((struct sockaddr_in6 *)res0->ai_addr)->sin6_port );
            break;
        default:
            fprintf(stderr, "ai_family is neither AF_INET nor AF_INET6\n");
            break;
    }
    sprintf(ipandport, "ip=%s, port=%d", buf, port);

    /* print addrinfo */
    printf("addrinfo {\n");
    printf("    int              ai_flags    = %2d \n", res0->ai_flags);
    printf("    int              ai_family   = %2d (AF_INET=2, AF_INET6=10, AF_UNSPEC=0)\n", res0->ai_family);
    printf("    int              ai_socktype = %2d (SOCK_STREAM=1, SOCK_DGRAM=2)\n",         res0->ai_socktype);
    printf("    int              ai_protocol = %2d\n", res0->ai_protocol);
    printf("    socklen_t        ai_addrlen  = %2d\n", res0->ai_addrlen);
    printf("    struct sockaddr *ai_addr     = %s\n",  ipandport );
    printf("    char            *ai_canonname= %s\n",  res0->ai_canonname);
    printf("    struct addrinfo *ai_next     = %p\n",  res0->ai_next);
    printf("}\n");

    return(EXIT_SUCCESS);
}

使い方

ビルド

上記サンプルコードのファイル名を、sample_getaddrinfo.cとした場合の実行手順です。

gcc -o sample_getaddrinfo sample_getaddrinfo.c

実行例

./sample_getaddrinfo google.com
hostname=google.com, service=(null)
addrinfo {
    int              ai_flags    =  0 
    int              ai_family   =  2 (AF_INET=2, AF_INET6=10, AF_UNSPEC=0)
    int              ai_socktype =  1 (SOCK_STREAM=1, SOCK_DGRAM=2)
    int              ai_protocol =  6
    socklen_t        ai_addrlen  = 16
    struct sockaddr *ai_addr     = ip=172.217.25.78, port=0
    char            *ai_canonname= (null)
    struct addrinfo *ai_next     = 0x1b3faf0
}

補足

getaddrinfo()の挙動

大まかな挙動ですが、アプリケーションからcライブラリのgetaddrinfo()を呼び出すと、/etc/nsswitch.confに設定された順序にしたがってhostsファイルやDNSクエリー発行でホスト名のIPアドレス変換をします。この時DNSクエリーの発行先は、/etc/resolve.confの設定に従います。

f:id:nopipi:20201116013457p:plain
getaddrinfo()の処理概要

処理概要

  1. アプリーケーションから、ホスト名の名前解決でCライブラリのgetaddrinfo()を呼び出します
  2. getaddrinfo()は、/etc/nsswitch.confを参照し解決順序を取得します
  3. nsswitch.confは一般にhostsファイルから参照する設定のため、最初に/etc/hostsの内容を参照し、該当するホスト名がないか確認します
  4. hotsファイルにない場合はDNSクエリーを発行してホスト名の解決を図ります
    1. DNSクエリーの発行先のDNSサーバ情報を/etc/resolve.confから取得します
    2. DNSでUPD処理をするためsocketを準備し、クエリーを発行し応答を受信します

getaddrinfo()実行時のstrace

getaddrinfo()挙動の参考としてstraceによるサンプルコード実行時のシステムコールのトレースを貼り付けます。

$ strace -F -tt ./sample_getaddrinfo google.com
strace: option -F is deprecated, please use -f instead
15:08:38.895790 execve("./sample_getaddrinfo", ["./sample_getaddrinfo", "google.com"], 0x7fff89a8f6b8 /* 22 vars */) = 0
<中略>
<-----------ここからgetaddrinfo()を呼び出した中の処理になります
<中略>
15:08:38.901343 open("/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3<<<nsswitch.confで解決優先順位を確認
15:08:38.901539 fstat(3, {st_mode=S_IFREG|0644, st_size=1713, ...}) = 0
15:08:38.901680 read(3, "#\n# /etc/nsswitch.conf\n#\n# An ex"..., 4096) = 1713
<中略><<< /etc/hostsファイルを読み込み確認
15:08:38.902267 open("/etc/host.conf", O_RDONLY|O_CLOEXEC) = 3 
15:08:38.906723 fstat(3, {st_mode=S_IFREG|0644, st_size=126, ...}) = 0
15:08:38.907003 read(3, "127.0.0.1   localhost localhost."..., 4096) = 126
<中略><<<resolv.confを読み込みでDNSクエリー発行先を取得
15:08:38.902946 open("/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 3
15:08:38.903064 fstat(3, {st_mode=S_IFREG|0644, st_size=129, ...}) = 0
15:08:38.903266 read(3, "options timeout:2 attempts:5\n; g"..., 4096) = 129
<中略><<< UDPによるDNSサーバへの問い合わせ(問い合わせ先 = 10.1.0.2)
15:08:38.911102 socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3  <== ここから、DNS(UDP 53port)でDNSにクエリーを発行し応答を受信する処理になります
15:08:38.911335 connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.1.0.2")}, 16) = 0
15:08:38.911535 poll([{fd=3, events=POLLOUT}], 1, 0) = 1 ([{fd=3, revents=POLLOUT}])
15:08:38.911694 sendmmsg(3, [{msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="8\276\1\0\0\1\0\0\0\0\0\0\6google\3com\0\0\1\0\1", iov_len=28}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=28}, {msg_hdr={msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\276\316\1\0\0\1\0\0\0\0\0\0\6google\3com\0\0\34\0\1", iov_len=28}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, msg_len=28}], 2, MSG_NOSIGNAL) = 2
15:08:38.912113 poll([{fd=3, events=POLLIN}], 1, 2000) = 1 ([{fd=3, revents=POLLIN}])
15:08:38.912375 ioctl(3, FIONREAD, [44]) = 0
15:08:38.912558 recvfrom(3, "8\276\201\200\0\1\0\1\0\0\0\0\6google\3com\0\0\1\0\1\300\f\0\1"..., 2048, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.1.0.2")}, [28->16]) = 44
15:08:38.912858 poll([{fd=3, events=POLLIN}], 1, 1998) = 1 ([{fd=3, revents=POLLIN}])
15:08:38.913024 ioctl(3, FIONREAD, [56]) = 0
15:08:38.916333 recvfrom(3, "\276\316\201\200\0\1\0\1\0\0\0\0\6google\3com\0\0\34\0\1\300\f\0\34"..., 65536, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.1.0.2")}, [28->16]) = 56
15:08:38.916608 close(3)                = 0
<中略>
<-----------ここまでgetaddrinfo()の処理
<中略>
15:08:38.924689 write(1, "addrinfo {\n", 11addrinfo {    <== 結果出力
) = 11
15:08:38.924772 write(1, "    int              ai_flags   "..., 39    int              ai_flags    =  0 
) = 39
15:

AWSで動くRHEL 7のレポジトリ名称がいつの間にか変わっていた件について(RHUI2->RHUI3)

RHEL7にdockerをインストールしようとして、二年前に確認した手順でセットアップしようとしたらうまくいかず、2時間ぐらい唸っていたら、いつの間にかRHUIがバージョンアップしてレポジトリ名称がごっそり変わっていたらしい。
access.redhat.com

もう無駄なところでハマるから、メジャーバージョンの中でこう言う変更するのやめてほしい。