Getting Started With AWS Lambda
Why
Lately I’ve been doing a bit of learning on AWS. While I’ve got a decent bit of professional experience with Azure, I’ve admittedly not really used AWS much (read: at all.) I recently created an instance for work, though, and I’ve been playing around with some of the various free services that they give me for the first 12 months while I have them available; I figure this is as good of a time as any to learn. I actually — albeit briefly — moved this blog over to AWS Amplify as a static site before realizing that I just really liked Write.as and preferred keeping it here.
While my blog didn’t stick in AWS for long, I was trying to think of other simple-yet-useful things I might be able to do in AWS before an idea dawned on me late last week. One of the things I regularly finding myself needing to do is figure out what my public-facing IP address is. There are many methods for doing this. The simplest that most people probably do is just use a website. For example, typing “what’s my IP” into DuckDuckGo will tell you. My good friend The WiFi Ninja is a fan of Moan My IP, which does exactly what you’re thinking. I personally tend to use I Can Haz IP. While the website is fairly… non-existent, I like it because I can use it via curl
to quickly get my IP:
curl https://icanhazip.com/
And it looks like:
Note that if anyone is feeling particularly nefarious, I connected to a VPN prior to running this just so I don’t have to blur out what I actually have for my current public-facing IP address.
This service works great, but I figured replicating it would be a fun thing to do with Lambda. Hilariously, I think it ultimately ended up being significantly more difficult to do this via Lambda than if I had just spun up a t3.micro instance and handled everything myself, but the purpose was to get familiar with how things work in Lambda, and this was quite the learning experience.
How-ish
This will by no means be a comprehensive how-to. I ended up spending a few hours working through this, with many permutations of throwing out different configurations to see what worked. Ultimately, my notes started to fall apart, so I don’t have the material to adequately document the exact steps. Instead, I figured I’d just give a high-level rundown of what I did.
First off, I needed to create a function in Lambda that would actually execute my code. It’s almost funny to type because there’s virtually no code for this. I opted to create a Python 3.9 project though literally anything would’ve been fine since I’m just parroting a value passed as a header — but more on that later.
The code is literally just:
def lambda_handler(event, context):
return { "ip": event['client_ip'].split(',')[0] }
The lambda_handler
function which takes event
and context
as parameters is automatically populated. As far as the code goes, I just removed what the template had in the return
statement and added the line you see above. This wasn't exactly what I had originally, but more on that later.
For a Lambda function to be available to the outside world, I needed an API Gateway. This was especially important because it’s also where I could add custom headers that would be passed to my Lambda function.
Under API > Resources
, I added a GET
method for /
. For the "Integration Type", I selected "Lambda Function" and then selected my only Lambda function. I also had discovered from this thread and I could go to "Mapping Templates" and add something to the "Request body passthrough." In this case, I passed two pieces of information: the client's IP address and the user agent. The user agent would be made useless later, but I never removed it since it doesn't hurt anything, either:
{ "client_ip" : "$input.params('X-Forwarded-For')", "user_agent" : "$input.params('User-Agent')" }
After much confusion on my part, I figured out that I needed to create a “Stage” that this gateway would represent. I named mine “prod” because:
- That’s what the form showed as an example.
- I’m not going to have any other stages, so that makes as much sense as anything else.
The only thing I really configured in it was throttling. The default of 10,000 requests per second with a burst of 5,000 requests seemed a few orders of magnitude overkill for what I was doing. After saving my stage, I was given a URL that would expose my API. The URL is in the form of:
https://{unique_id}.execute-api.{aws_region}.amazonaws.com/{stage}
Opening this URL in Firefox showed me my IP address just as I expected! Progress!
Oops
Next I wanted to use my own domain for it so that I could use ip.borked.sh. I saw there was a “Custom Domain Names” section in the API Gateway console, so I walked through that process. Prior to adding a domain, I had to generate a certificate which is done through the AWS Certificate Manager console. That was easy enough and just had me add a TXT
record to DNS to prove that I owned the domain I was trying to use. What wasn't apparent to me was that heck I was supposed to do in order to point the actual domain to AWS. After much hunting, I found some sparse documentation telling me:
When you create a custom domain name for a Regional API, API Gateway creates a Regional domain name for the API. You must set up a DNS record to map the custom domain name to the Regional domain name. You must also provide a certificate for the custom domain name.
I guessed that this meant adding a CNAME
that matched the domain above, which mercifully ended up being correct. This all seemed good, but a problem I quickly noticed was that entering something like...
curl ip.borked.sh
… didn’t work. I had to explicitly enter:
curl https://ip.borked.sh
That wasn’t ideal, so I started hunting for how to allow both HTTP and HTTPS access.
The solution that I came across in a Stack Overflow thread, the link to which I lost, is that you don’t add a custom domain to the API Gateway like I did. Instead, you set up CloudFront CDN and add your domain to that since it gives you options for HTTP or HTTPS. Allegedly, this only works if your method is a GET
, which makes sense to me but isn't something I verified since I didn't need any other methods.
In CloudFront, I created a “distribution” which gives me a new URL in the format of:
{unique_id}.cloudfront.net
Under the “Origins” section of my distribution, I set the origin URL to be the URL generated by the API gateway, with the “Origin Path” being /{stage}
as highlighted above, so /prod
in my case. Finally, under the "General" settings of my distribution, I added ip.borked.sh
as an "alternate domain name." I had to generate a new certificate because, for some reason, my cert needed to live in a different region than what I have as the default for my AWS account. I'm not sure if I accidentally misconfigured something in CloudFront or if this was a byproduct of having "Use all edge locations (best performance)" selected in my price class when I created the distribution, but regardless it was simple enough to create a new certificate following the same process as before. I also deleted the old certificate in my original region since I wasn't using it for anything.
It’s also worth mentioning that it’s important in the same UI to set the “Viewer protocol policy” to “HTTP and HTTPS” for this use case:
Initially, I had mindlessly selected “Redirect HTTP to HTTPS”. While I would pretty much always want that for a website, in this case it resulted in a “301 — Moved Permanently”. I don’t care if the request is secure or not; I just want to send the response to either.
This almost worked, but I was running into a new issue. The client_ip
value I got was now a list containing first the origin IP and second the CloudFront IP. That was obviously not ideal, either. I can't imagine ever wanting to know the CloudFront IP. There's where I switched the original return in my Lambda function from:
"ip": event['client_ip']
To what I shared earlier:
"ip": event['client_ip'].split(',')[0]
The origin IP always seemed to be the first one in the list, so that’s all I report back.
It’s Alive!
Now I could finally run:
curl ip.borked.sh
Naturally running both of these will also work fine:
curl http://ip.borked.sh
curl https://ip.borked.sh
The difference between this and what I’ve shown at the start of the post from icanhazip.com is that Lambda is responding with JSON. I actually briefly looked at responding with plaintext, but I quickly decided that I liked JSON better because I could easily then parse it with jq
or any scripting language. For example, I created the following little Groovy script to test it out (also available as a GitLab snippet):
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse
import java.net.http.HttpResponse.BodyHandlers
import java.net.URI
import java.time.Duration
import groovy.json.JsonSlurperdef url = 'https://ip.borked.sh'
JsonSlurper slurper = new JsonSlurper()
def httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()def request = HttpRequest.newBuilder()
.GET()
.uri(URI.create(url))
.build()def response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())if( response.statusCode() == 200 ) {
def responseMap = slurper.parseText(response.body())
println responseMap.ip
} else {
println "ERROR: Status Code: ${response.statusCode()}"
}
Caveats
There were a couple of caveats I ran into while working on this. The first is that CloudFront’s default caching of 1 day caused all sorts of havoc as I was trying to test things. I ended up editing my distribution, going to “Behavior”, flipping from “Cache policy and origin request policy (recommended)” to “Legacy cache settings” and turning them way down. While I could often use the original API Gateway URL to bypass Cloudfront’s caching, this didn’t do me much good when I needed to see how CloudFront impacted my code. For example, when I first changed my return statement, I was still seeing 2 IP addresses for a long time before my updated Lambda function started to kick in.
The other caveat that I’m still a little confused about is that I somehow used $0.09 USD in services while getting this set up, so I did something that fell outside the realm of the free tier. Not being particularly fluent with AWS billing and not wanting to create reports on it, I instead just went to the Cost Explorer console and created a monthly budget for $5 USD. Since then, however, the number hasn’t gone up… it remains at 9 cents, and my budgetary estimate for the month is 10 cents. So I’m not exactly sure what’s going on with it. If there are any significant changes, I’ll update this post. All I can say right now is that I’ve hit the endpoint more after I set the budget than I did before.
My initial thoughts on Lambda after doing this were pretty negative, but that was mostly just frustration stemming from my unfamiliarity. In hindsight, it’s a pretty cool service once I figured out how all of the pieces fit together.
Originally published at https://borked.sh on May 23, 2022.