AWS CloudWatch: Как масштабировать инфраструктуру протоколирования

Очевидная история, которую вы, возможно, решите рассказать себе: «Вести журнал — это просто». Писать в консоль или распечатывать отладочные сообщения может показаться простым, и при локальном запуске службы это обычно так и есть. Как только вы пересекаете магический барьер, которым является облако, по какой-то причине все становится очень сложным.

Настолько сложным, что появляется множество компаний, которые считают, что могут конкурировать в предоставлении именно такого решения. Но это не статья о том, какую из них использовать, и не маркетинговая уловка для конкретного поставщика. (А я использовал многих из них, и по какой-то причине все они ужасны, единственное, что было более ужасным, чем использование SaaS-провайдера для ведения логов, — это работа с открытым исходным кодом, а ELK — худшая инфраструктура ведения логов из когда-либо созданных. Ваша инфраструктура логирования должна стоить не более 10% от ваших расходов и почти 0% от вашего времени разработки. Однако, когда вы используете любого провайдера, она составляет около 50%.)

Для того, что обычно стоит около 30% от общих расходов на облако, вы ожидаете получить что-то полезное от логирования. Так и есть, ведение журнала критически важно для устойчивости вашего сервиса и вашего бизнеса. В компании Rhosys нам часто требуется знать не только о том, работают ли наши услуги, но и о том, насколько эффективно они работают. Приборные панели, отслеживающие количество вызовов и задержки, бесполезны для бизнеса, нам нужно знать, как выглядят журналы, имеющие отношение к бизнесу. Как и большинство компаний, заботящихся о безопасности (компании, не заботящиеся о безопасности, вероятно, захотят проигнорировать то, что я скажу дальше, иначе это будет похоже на святую воду, сжигающую вашего внутреннего дьявола), у нас есть несколько учетных записей AWS, каждая из которых имеет специальное назначение, закрепленное только за одной командой, и только эта одна команда имеет доступ к этой конкретной учетной записи AWS. Вы не пользуетесь общими учетными записями.

Настройка

То, как мы их настраиваем, не так важно, важно то, что каждый продукт получает свою собственную учетную запись AWS. Это просто имеет смысл, и это необходимо, когда каждый продукт принадлежит разной команде. Поскольку у Rhosys три основных продукта (на момент написания статьи), у нас есть около 40 учетных записей AWS (потому что AWS, конечно):

  • 1 учетная запись AWS для запуска Authress
  • 1 учетная запись AWS для запуска Standup & Prosper
  • 1 учетная запись AWS для запуска Modulemancer
  • 1 учетная запись AWS для открытого кода и кучи наших партнерств с AWS
  • 1 аккаунт для каждого разработчика
  • и еще много чего, потому что почему бы и нет.

Но это история не о безопасности, а об обслуживании, и поскольку каждый из наших продуктов находится в отдельном аккаунте, возникают некоторые сложности с выяснением основной проблемы — как работает наш сервис.

Поскольку мы используем AWS и множество бессерверных технологий, мы активно используем CloudWatch Logs. CW Logs великолепен. Он лучше, чем любой другой SaaS-инструмент для ведения журналов, он также отлично подходит для мониторинга. (Но он ужасен для оповещения.) На данный момент у нас все еще нет отличного решения для «сообщения об этой проблеме команде разработчиков», и это потому, что CW Logs не предлагает способ отправки электронной почты или запуска оповещения, которое на самом деле содержит информацию о том, что не так. Это происходит потому, что решение для мониторинга фактически агрегирует данные, а не аннотирует и индексирует их. И вам придется использовать SNS + CW Insights, чтобы помочь вам.

Журналы

Итак, вернемся к рассмотрению наших трех продуктовых аккаунтов. По большей части, и я упускаю некоторые тонкости, мы ведем логи непосредственно в журналы CloudWatch, и это здорово. Что не очень хорошо, так это если вы хотите видеть все журналы в одном месте (что обычно неправильно, потому что у разных команд могут быть разные решения). Но вы можете захотеть увидеть все — предупреждения, бизнес-проблемы, критические вопросы в удобоваримом формате. Это не обязательно должно быть одно место. Достаточно иметь одну приборную панель CW для каждой учетной записи и легко переключаться между ними.

Еще одно решение — несколько экземпляров сбора журналов. То есть развернуть службы агрегации журналов на каждой учетной записи AWS. Проблема в том, что это означает, что нам придется запускать рабочего в каждой учетной записи для обработки логов. Это неправильно. Развертывание агента для каждой службы или для каждого региона, или даже для каждой учетной записи AWS — это плохой архитектурный дизайн, и он не масштабируется. Это должно быть автоматизировано и требовать практически нулевой нагрузки на учетные записи, которые согласны на это.

Как хорошие архитекторы микросервисов, мы направляем соответствующие журналы, связанные с бизнесом, в защищенный аккаунт регистрации. О наших ожиданиях относительно того, как и что мы регистрируем, есть отдельная статья, где я подробно рассказываю о наших ожиданиях относительно регистрации, их назначении и о том, как извлечь из них максимальную пользу.

Краткое содержание этой статьи заключается в том, что мы должны вести логи, которые выглядят следующим образом:

logger.log({
  title: '[Action Required] Failed to automatically handle plan
upgrade, review and determine why it failed and how to
more gracefully improve this problem in the future.',
  level: 'ERROR',
  details: { 
    accountId, error
  }
});
Вход в полноэкранный режим Выход из полноэкранного режима

В CloudWatch Logs это преобразуется в base64 беспорядок, для распутывания которого нам нужен сложный обработчик. Это и есть мясо нашего агрегатора журналов:

for (let logEvent of awslogsData.logEvents) {
  let parsedLogEvent = {
    logStream: awslogsData.logStream,
    logGroup: awslogsData.logGroup,
    region: config.region,
    requestId: logEvent.extractedFields.request_id,
    extractedTimeStamp: logEvent.extractedFields.timestamp
  };

  let event = logEvent.extractedFields.event;

  // Handle timeouts explicitly
  if (event && event.match('Task timed out after')) {
    parsedLogEvent.data = { title: `${event.trim()} (RequestId: ${parsedLogEvent.requestId})`, level: 'ERROR' };

  // Handle everything else
  } else {

    // We want to pull out the JSON object from our logs
    const eventMatcher = event.match(/^(INFO|TRACE|ERROR|WARN)s+(?:[w+s]*s+)?({.*})s*$/s);
    const fallbackLevel = eventMatcher[1] || 'INFO';
    const loggedMessage = JSON.parse(eventMatcher[2]) || {};

    // If the message is a special error which has the code === 'ForceRetryExecution' then ignore it, we use this for enabling internal retries
    if (typeof loggedMessage.code === 'string' && loggedMessage.code.match(/^(ForceRetryExecution)$/i)) {
      continue;
    }

    // Normalize a bunch of properties depending on exactly where the real message data is
    const stringOrObjectMessage = loggedMessage.message || loggedMessage;
    parsedLogEvent.data = typeof stringOrObjectMessage !== 'object' ? { message: stringOrObjectMessage } : stringOrObjectMessage;
    parsedLogEvent.data.level = parsedLogEvent.data.level || fallbackLevel;
    parsedLogEvent.data.stack = parsedLogEvent.data.stack || loggedMessage.stack;
    parsedLogEvent.data.reason = parsedLogEvent.data.reason || loggedMessage.reason;
    parsedLogEvent.data.promise = parsedLogEvent.data.promise || loggedMessage.promise;

    // Actually do something with the message
    await handle(parsedLogEvent);
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, надеюсь, приведенный выше краткий беспорядок показывает, в чем ценность структурированного протоколирования. Поскольку все наши службы ведут структурированный журнал, нам легко разбирать их и обрабатывать единым образом. Я настоятельно рекомендую использовать последовательный подход к ведению журналов, который выглядит примерно так. Использование структурированных журналов позволяет легко отлаживать любой имеющийся у вас сервис без необходимости заново изучать новый шаблон. Конечно, в разных командах это может быть по-разному, но тогда у них будут свои потребности и свои системы агрегации. А когда вы захотите добавить дополнительную ценность к каждому источнику логов, вы сможете сделать это в одном месте, не обновляя какую-то библиотеку, которую вы заставляете обновлять каждую из ваших служб в каждом аккаунте AWS (как будто это вообще реальная стратегия).

Ошибка

Проблема в том, что, хотя у нас есть отличный способ обработки журналов и отличный способ регистрации данных, у нас нет возможности легко переносить журналы с одного аккаунта AWS на другой. Можно подумать, что в безграничных возможностях системы IAM вы сможете назначить действительную политику ресурсов для лямбда-функции и использовать ее в разных аккаунтах AWS. Увы, это невозможно. Оказывается, создание успешной системы авторизации — это огромная задача, и хотя AWS отлично справилась со своей работой, мы можем подтвердить, что управление такой системой и решение всех возможных проблем — это сизифов труд. (Откуда мы знаем? Мы сделали это с помощью Authress).

Единственный способ перенести журналы с одного аккаунта AWS на другой автоматизированным способом (помните, что нам нужно решение с полным набором услуг, мы не хотим развертывать лямбда-функцию подписки на журналы в каждом регионе для каждого аккаунта) — это использовать AWS Kinesis ИЛИ AWS Kinesis Firehose.

Подождите, вы говорите, что это разные вещи? Да, это разные вещи!

За неимением четкой документации от AWS, Kinesis — это общая база данных, а Kinesis Firehose — это транспортный механизм. Таким образом, вы можете либо поместить данные из журналов CloudWatch в специализированную общую базу данных (Kinesis), либо переложить эту работу на транспортировку данных в другое место (Kinesis Firehose). Поскольку Kinesis Firehose заставляет вас передавать данные в БД. Ваш выбор — База данных или База данных. И эта база данных не может быть журналами CloudWatch, также Kinesis не поддерживает прямой вызов лямбды, потому что эй, ПОЧЕМУ НЕТ!

Поскольку Kinesis всегда включен, он стоит не тех денег. Нам нужна полная масштабируемость, поэтому мы выбрали Firehose. Создайте Firehose в учетной записи регистрации и используйте его с каждой подпиской CloudWatch.

Я могу просто установить политику ресурсов на Firehose, чтобы разрешить всему моему AWS-органу доступ к подпискам из журналов CW, верно? НЕПРАВДА, вам нужно создать так называемые пользовательские места назначения журналов и разрешить другим учетным записям использовать их. Это несколько дополнительных ресурсов AWS, которыми нужно управлять.

О, также Kinesis Firehose не является допустимым источником событий для лямбды. «Что?» — скажете вы. Правильно, вам нужно направить данные в ведро S3, а затем использовать триггер Lambda, чтобы выполнить функцию Lambda для разбора журнала.

(И что интересно, данные, которые поступают в лямбду через S3 из Kinesis, не разделены. Данные напрямую конкатенируются. Почему он по умолчанию не ставит разделители между записями автоматически, уму непостижимо. И нет ничего, что не могло бы исправить простое .replace(/}{/g, ‘}n{‘).split(‘n’)).

Решение

В результате есть несколько движущихся частей, которые позволяют объединить журналы в один аккаунт для целей оповещения. Помните, что вы также хотите развернуть эту систему во всех регионах, а не только в одном. Ваши журналы должны оставаться в том же регионе, в котором они генерируются:


Диаграмма архитектуры AWS Multiaccount

И соответствующий шаблон CloudFormation для генерации этих ресурсов в учетной записи Logging AWS:

  • Создайте ведро, в котором мы будем временно хранить журналы:
CrossAccountLogBucket: {
    Type: 'AWS::S3::Bucket',
    Properties: {
      AccessControl: 'Private',
      BucketName: { 'Fn::Sub': '${AWS::AccountId}-${AWS::Region}-cross-account-logging-sink' },
      NotificationConfiguration: {
        LambdaConfigurations: [{ Event: 's3:ObjectCreated:*', Function: { Ref: 'LambdaFunctionAlias' } }]
      }
    }
  }
Вход в полноэкранный режим Выйти из полноэкранного режима
  • Разрешить прямое обращение к лямбда-функции LambdaFunctionAlias
S3LambdaInvokePermission: {
    Type: 'AWS::Lambda::Permission',
    Properties: {
      FunctionName: { Ref: 'LambdaFunctionAlias' },
      Action: 'lambda:InvokeFunction',
      Principal: 's3.amazonaws.com',
      SourceAccount: { Ref: 'AWS::AccountId' },
      SourceArn: { 'Fn::Sub': 'arn:aws:s3:::${AWS::AccountId}-${AWS::Region}-cross-account-logging-sink' }
    }
  }
Войти в полноэкранный режим Выйти из полноэкранного режима
  • Создайте Kinesis Firehose
LogDeliveryStream: {
    Type: 'AWS::KinesisFirehose::DeliveryStream',
    Properties: {
      DeliveryStreamName: { 'Fn::Sub': '${serviceName}-${AWS::Region}-Log-Sink' },
      ExtendedS3DestinationConfiguration: {
        BucketARN: { 'Fn::Sub': '${CrossAccountLogBucket.Arn}' },
        RoleARN: { 'Fn::GetAtt': ['LogStreamRole', 'Arn'] }
      }
    }
  }
Войти в полноэкранный режим Выйти из полноэкранного режима
  • Разрешите Firehose записывать данные в S3
LogStreamRole: {
    Type: 'AWS::IAM::Role',
    Properties: {
      RoleName: { 'Fn::Sub': '${serviceName}-${AWS::Region}-CrossAccountKinesisLogStream' },
      AssumeRolePolicyDocument: {
        Statement: [{
          Effect: 'Allow',
          Principal: { Service: ['firehose.amazonaws.com'] },
          Action: ['sts:AssumeRole'],
          Condition: { StringEquals: { 'sts:ExternalId': { Ref: 'AWS::AccountId' } } }
        }]
      },
      Policies: [
        {
          PolicyDocument: {
            Statement: [
              {
                Effect: 'Allow', Action: ['s3:PutObject'],
                Resource: [{ 'Fn::Sub': '${CrossAccountLogBucket.Arn}' }, { 'Fn::Sub': '${CrossAccountLogBucket.Arn}/*' }]
              },
              {
                Effect: 'Allow', Action: ['kinesis:GetRecords'],
                Resource: [{ 'Fn::Sub': 'arn:aws:kinesis:${AWS::Region}:${AWS::AccountId}:stream/${serviceName}-${AWS::Region}-Log-Sink*' }]
              }
            ]
          }
        }
      ]
    }
  }
Войти в полноэкранный режим Выйти из полноэкранного режима
  • Создайте назначение CloudWatch, которое может писать в Firehose
AggregateLogEventsSubscriptionDestination: {
    Type: 'AWS::Logs::Destination',
    Properties: {
      DestinationName: { 'Fn::Sub': '${serviceName}-CrossAccountLogStream' },
      RoleArn: { 'Fn::GetAtt': ['CloudWatchDelegatedRole', 'Arn'] },
      TargetArn: { 'Fn::Sub': '${LogDeliveryStream.Arn}' },
      DestinationPolicy: {
        'Fn::Sub': JSON.stringify({
          Statement: [{
            Effect: 'Allow', Principal: { AWS: '*' },
            Action: 'logs:PutSubscriptionFilter',
            Resource: 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:destination:${serviceName}-CrossAccountLogStream',
            Condition: {
              StringEquals: { 'aws:PrincipalOrgID': [AWSOrgID] }
            }
          }]
        })
      }
    }
  }
Войти в полноэкранный режим Выйти из полноэкранного режима
  • И включите его для записи в Firehose
CloudWatchDelegatedRole: {
    Type: 'AWS::IAM::Role',
    Properties: {
      RoleName: { 'Fn::Sub': '${serviceName}-${AWS::Region}-CloudWatchCrossAccountAccess' },
      AssumeRolePolicyDocument: {
        Statement: [{
          Effect: 'Allow',
          Principal: { Service: [{ 'Fn::Sub': 'logs.${AWS::Region}.amazonaws.com' }] },
          Action: ['sts:AssumeRole'],
          Condition: {
            StringEquals: { 'aws:PrincipalOrgID': [AWSOrgID] }
          }
        }]
      },
      Policies: [{
        PolicyName: 'FirehoseAccess',
        PolicyDocument: {
          Statement: [{
            Effect: 'Allow', Action: ['firehose:PutRecord'],
            Resource: [{ 'Fn::Sub': '${LogDeliveryStream.Arn}' }]
          }]
        }
      }]
    }
  }
Войти в полноэкранный режим Выйти из полноэкранного режима

Последним шагом здесь будет создание фильтра подписки в учетных записях источников журналов в существующих группах журналов CloudWatch, и все готово.


Оцените статью
Procodings.ru
Добавить комментарий