{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Python Hadoop MapReduce: Analyzing AWS S3 Bucket Logs with mrjob\n", "\n", "* [Introduction](#Introduction)\n", "* [Setup](#Setup)\n", "* [Processing S3 Logs](#Processing-S3-Logs)\n", "* [Running Amazon Elastic MapReduce Jobs](#Running-Amazon-Elastic-MapReduce-Jobs)\n", "* [Unit Testing S3 Logs](#Unit-Testing-S3-Logs)\n", "* [Running S3 Logs Unit Test](#Running-S3-Logs-Unit-Test)\n", "* [Sample Config File](#Sample-Config-File)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Introduction\n", "\n", "[mrjob](https://pythonhosted.org/mrjob/) lets you write MapReduce jobs in Python 2.5+ and run them on several platforms. You can:\n", "\n", "* Write multi-step MapReduce jobs in pure Python\n", "* Test on your local machine\n", "* Run on a Hadoop cluster\n", "* Run in the cloud using Amazon Elastic MapReduce (EMR)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Setup\n", "\n", "From PyPI:\n", "\n", "``pip install mrjob``\n", "\n", "From source:\n", "\n", "``python setup.py install``\n", "\n", "See [Sample Config File](#Sample-Config-File) section for additional config details." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Processing S3 Logs\n", "\n", "Sample mrjob code that processes log files on Amazon S3 based on the [S3 logging format](http://docs.aws.amazon.com/AmazonS3/latest/dev/LogFormat.html):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%%file mr_s3_log_parser.py\n", "\n", "import time\n", "from mrjob.job import MRJob\n", "from mrjob.protocol import RawValueProtocol, ReprProtocol\n", "import re\n", "\n", "\n", "class MrS3LogParser(MRJob):\n", " \"\"\"Parses the logs from S3 based on the S3 logging format:\n", " http://docs.aws.amazon.com/AmazonS3/latest/dev/LogFormat.html\n", " \n", " Aggregates a user's daily requests by user agent and operation\n", " \n", " Outputs date_time, requester, user_agent, operation, count\n", " \"\"\"\n", "\n", " LOGPATS = r'(\\S+) (\\S+) \\[(.*?)\\] (\\S+) (\\S+) ' \\\n", " r'(\\S+) (\\S+) (\\S+) (\"([^\"]+)\"|-) ' \\\n", " r'(\\S+) (\\S+) (\\S+) (\\S+) (\\S+) (\\S+) ' \\\n", " r'(\"([^\"]+)\"|-) (\"([^\"]+)\"|-)'\n", " NUM_ENTRIES_PER_LINE = 17\n", " logpat = re.compile(LOGPATS)\n", "\n", " (S3_LOG_BUCKET_OWNER, \n", " S3_LOG_BUCKET, \n", " S3_LOG_DATE_TIME,\n", " S3_LOG_IP, \n", " S3_LOG_REQUESTER_ID, \n", " S3_LOG_REQUEST_ID,\n", " S3_LOG_OPERATION, \n", " S3_LOG_KEY, \n", " S3_LOG_HTTP_METHOD,\n", " S3_LOG_HTTP_STATUS, \n", " S3_LOG_S3_ERROR, \n", " S3_LOG_BYTES_SENT,\n", " S3_LOG_OBJECT_SIZE, \n", " S3_LOG_TOTAL_TIME, \n", " S3_LOG_TURN_AROUND_TIME,\n", " S3_LOG_REFERER, \n", " S3_LOG_USER_AGENT) = range(NUM_ENTRIES_PER_LINE)\n", "\n", " DELIMITER = '\\t'\n", "\n", " # We use RawValueProtocol for input to be format agnostic\n", " # and avoid any type of parsing errors\n", " INPUT_PROTOCOL = RawValueProtocol\n", "\n", " # We use RawValueProtocol for output so we can output raw lines\n", " # instead of (k, v) pairs\n", " OUTPUT_PROTOCOL = RawValueProtocol\n", "\n", " # Encode the intermediate records using repr() instead of JSON, so the\n", " # record doesn't get Unicode-encoded\n", " INTERNAL_PROTOCOL = ReprProtocol\n", "\n", " def clean_date_time_zone(self, raw_date_time_zone):\n", " \"\"\"Converts entry 22/Jul/2013:21:04:17 +0000 to the format\n", " 'YYYY-MM-DD HH:MM:SS' which is more suitable for loading into\n", " a database such as Redshift or RDS\n", "\n", " Note: requires the chars \"[ ]\" to be stripped prior to input\n", " Returns the converted datetime annd timezone\n", " or None for both values if failed\n", "\n", " TODO: Needs to combine timezone with date as one field\n", " \"\"\"\n", " date_time = None\n", " time_zone_parsed = None\n", "\n", " # TODO: Probably cleaner to parse this with a regex\n", " date_parsed = raw_date_time_zone[:raw_date_time_zone.find(\":\")]\n", " time_parsed = raw_date_time_zone[raw_date_time_zone.find(\":\") + 1:\n", " raw_date_time_zone.find(\"+\") - 1]\n", " time_zone_parsed = raw_date_time_zone[raw_date_time_zone.find(\"+\"):]\n", "\n", " try:\n", " date_struct = time.strptime(date_parsed, \"%d/%b/%Y\")\n", " converted_date = time.strftime(\"%Y-%m-%d\", date_struct)\n", " date_time = converted_date + \" \" + time_parsed\n", "\n", " # Throws a ValueError exception if the operation fails that is\n", " # caught by the calling function and is handled appropriately\n", " except ValueError as error:\n", " raise ValueError(error)\n", " else:\n", " return converted_date, date_time, time_zone_parsed\n", "\n", " def mapper(self, _, line):\n", " line = line.strip()\n", " match = self.logpat.search(line)\n", "\n", " date_time = None\n", " requester = None\n", " user_agent = None\n", " operation = None\n", "\n", " try:\n", " for n in range(self.NUM_ENTRIES_PER_LINE):\n", " group = match.group(1 + n)\n", "\n", " if n == self.S3_LOG_DATE_TIME:\n", " date, date_time, time_zone_parsed = \\\n", " self.clean_date_time_zone(group)\n", " # Leave the following line of code if \n", " # you want to aggregate by date\n", " date_time = date + \" 00:00:00\"\n", " elif n == self.S3_LOG_REQUESTER_ID:\n", " requester = group\n", " elif n == self.S3_LOG_USER_AGENT:\n", " user_agent = group\n", " elif n == self.S3_LOG_OPERATION:\n", " operation = group\n", " else:\n", " pass\n", "\n", " except Exception:\n", " yield ((\"Error while parsing line: %s\", line), 1)\n", " else:\n", " yield ((date_time, requester, user_agent, operation), 1)\n", "\n", " def reducer(self, key, values):\n", " output = list(key)\n", " output = self.DELIMITER.join(output) + \\\n", " self.DELIMITER + \\\n", " str(sum(values))\n", "\n", " yield None, output\n", "\n", " def steps(self):\n", " return [\n", " self.mr(mapper=self.mapper,\n", " reducer=self.reducer)\n", " ]\n", "\n", "\n", "if __name__ == '__main__':\n", " MrS3LogParser.run()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Running Amazon Elastic MapReduce Jobs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Run an Amazon Elastic MapReduce (EMR) job on the given input (must be a flat file hierarchy), placing the results in the output (output directory must not exist):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!python mr_s3_log_parser.py -r emr s3://bucket-source/ --output-dir=s3://bucket-dest/" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Run a MapReduce job locally on the specified input file, sending the results to the specified output file:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!python mr_s3_log_parser.py input_data.txt > output_data.txt" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Unit Testing S3 Logs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Accompanying unit test:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%%file test_mr_s3_log_parser.py\n", "\n", "from StringIO import StringIO\n", "import unittest2 as unittest\n", "from mr_s3_log_parser import MrS3LogParser\n", "\n", "\n", "class MrTestsUtil:\n", "\n", " def run_mr_sandbox(self, mr_job, stdin):\n", " # inline runs the job in the same process so small jobs tend to\n", " # run faster and stack traces are simpler\n", " # --no-conf prevents options from local mrjob.conf from polluting\n", " # the testing environment\n", " # \"-\" reads from standard in\n", " mr_job.sandbox(stdin=stdin)\n", "\n", " # make_runner ensures job cleanup is performed regardless of\n", " # success or failure\n", " with mr_job.make_runner() as runner:\n", " runner.run()\n", " for line in runner.stream_output():\n", " key, value = mr_job.parse_output_line(line)\n", " yield value\n", "\n", " \n", "class TestMrS3LogParser(unittest.TestCase):\n", "\n", " mr_job = None\n", " mr_tests_util = None\n", "\n", " RAW_LOG_LINE_INVALID = \\\n", " '00000fe9688b6e57f75bd2b7f7c1610689e8f01000000' \\\n", " '00000388225bcc00000 ' \\\n", " 's3-storage [22/Jul/2013:21:03:27 +0000] ' \\\n", " '00.111.222.33 ' \\\n", "\n", " RAW_LOG_LINE_VALID = \\\n", " '00000fe9688b6e57f75bd2b7f7c1610689e8f01000000' \\\n", " '00000388225bcc00000 ' \\\n", " 's3-storage [22/Jul/2013:21:03:27 +0000] ' \\\n", " '00.111.222.33 ' \\\n", " 'arn:aws:sts::000005646931:federated-user/user 00000AB825500000 ' \\\n", " 'REST.HEAD.OBJECT user/file.pdf ' \\\n", " '\"HEAD /user/file.pdf?versionId=00000XMHZJp6DjM9x500000' \\\n", " '00000SDZk ' \\\n", " 'HTTP/1.1\" 200 - - 4000272 18 - \"-\" ' \\\n", " '\"Boto/2.5.1 (darwin) USER-AGENT/1.0.14.0\" ' \\\n", " '00000XMHZJp6DjM9x5JVEAMo8MG00000'\n", "\n", " DATE_TIME_ZONE_INVALID = \"AB/Jul/2013:21:04:17 +0000\"\n", " DATE_TIME_ZONE_VALID = \"22/Jul/2013:21:04:17 +0000\"\n", " DATE_VALID = \"2013-07-22\"\n", " DATE_TIME_VALID = \"2013-07-22 21:04:17\"\n", " TIME_ZONE_VALID = \"+0000\"\n", "\n", " def __init__(self, *args, **kwargs):\n", " super(TestMrS3LogParser, self).__init__(*args, **kwargs)\n", " self.mr_job = MrS3LogParser(['-r', 'inline', '--no-conf', '-'])\n", " self.mr_tests_util = MrTestsUtil()\n", "\n", " def test_invalid_log_lines(self):\n", " stdin = StringIO(self.RAW_LOG_LINE_INVALID)\n", "\n", " for result in self.mr_tests_util.run_mr_sandbox(self.mr_job, stdin):\n", " self.assertEqual(result.find(\"Error\"), 0)\n", "\n", " def test_valid_log_lines(self):\n", " stdin = StringIO(self.RAW_LOG_LINE_VALID)\n", "\n", " for result in self.mr_tests_util.run_mr_sandbox(self.mr_job, stdin):\n", " self.assertEqual(result.find(\"Error\"), -1)\n", "\n", " def test_clean_date_time_zone(self):\n", " date, date_time, time_zone_parsed = \\\n", " self.mr_job.clean_date_time_zone(self.DATE_TIME_ZONE_VALID)\n", " self.assertEqual(date, self.DATE_VALID)\n", " self.assertEqual(date_time, self.DATE_TIME_VALID)\n", " self.assertEqual(time_zone_parsed, self.TIME_ZONE_VALID)\n", "\n", " # Use a lambda to delay the calling of clean_date_time_zone so that\n", " # assertRaises has enough time to handle it properly\n", " self.assertRaises(ValueError,\n", " lambda: self.mr_job.clean_date_time_zone(\n", " self.DATE_TIME_ZONE_INVALID))\n", "\n", "if __name__ == '__main__':\n", " unittest.main()\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Running S3 Logs Unit Test" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Run the mrjob test:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "!python test_mr_s3_log_parser.py -v" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Sample Config File" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "runners:\n", " emr:\n", " aws_access_key_id: __ACCESS_KEY__\n", " aws_secret_access_key: __SECRET_ACCESS_KEY__\n", " aws_region: us-east-1\n", " ec2_key_pair: EMR\n", " ec2_key_pair_file: ~/.ssh/EMR.pem\n", " ssh_tunnel_to_job_tracker: true\n", " ec2_master_instance_type: m3.xlarge\n", " ec2_instance_type: m3.xlarge\n", " num_ec2_instances: 5\n", " s3_scratch_uri: s3://bucket/tmp/\n", " s3_log_uri: s3://bucket/tmp/logs/\n", " enable_emr_debugging: True\n", " bootstrap:\n", " - sudo apt-get install -y python-pip\n", " - sudo pip install --upgrade simplejson" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.6.1" } }, "nbformat": 4, "nbformat_minor": 1 }