pabis.eu

Slim app for getting SSM Parameters in Go

11 September 2023

We often use Alpine Linux for our containers. It's small, it has small footprint on memory so it is perfect for containers. However, if we want to interface with AWS services, we either have to: use SDK, use HTTP API or use AWS CLI. AWS CLI weighs a bit (200MB), HTTP API is complex because you have to authenticate before doing anything and SDK doesn't always fit our project - for example we need only to pull SSM configuration for Nginx. So, today I will guide you through creating a small Go application that will read SSM Parameters using detected credentials (IAM EC2 role/profile) and it will be small (<20MB) - as the AWS SDK for Go is modular.

See the full code here

Getting required modules

Let's start a new folder, Git repository and initialize the Go module.

$ mkdir ssm-get && cd ssm-get
$ git init
$ go mod init github.com/ppabis/ssm-get

Next we need to install necessary Go libraries. We will use aws-sdk-go-v2 with the base modules, ssm and config.`

$ go get github.com/aws/aws-sdk-go-v2
$ go get github.com/aws/aws-sdk-go-v2/config
$ go get github.com/aws/aws-sdk-go-v2/service/ssm

config is important in that matter that it enables for automatic discovery of credentials if we are running on EC2/ECS and IAM role is available. Start a new file main.go and create a boilerplate code that will require two arguments to the application: the parameter name and file where to store the value.

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 3 {
        fmt.Printf("Usage: %s <ParameterPath> <OutputFile>\n", os.Args[0])
        os.Exit(0)
    }
    // ... Will continue here
}

Implementing the functionality

First we need to define the region for which the config should be created. For me, the default would be eu-central-1 but you can change it to whatever you want. I also leave an option to pass AWS_REGION via environment variables.

    // Imports
    "context"
    "github.com/aws/aws-sdk-go-v2/config"
    // Inside main() after args check
    region := "eu-central-1"
    if os.Getenv("AWS_REGION") != "" {
        region = os.Getenv("AWS_REGION")
    }

    cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
    if err != nil {
        fmt.Printf("[ERROR] unable to load SDK config, %v\n", err)
    }

Next we will create a client and getting the parameter value using collected app arguments. It's also useful to set flag WithDecryption to true because it will either way be ignored if we read normal String or StringList. For SecureString it will automatically decrypt if KMS permissions allow for that. AWS managed default KMS key for SSM (alias/aws/ssm) should be accessible to anyone.

    // Imports
    "github.com/aws/aws-sdk-go-v2/service/ssm"
    // Inside main() after config.LoadDefaultConfig()
    client := ssm.NewFromConfig(cfg)

    mTrue := true
    output, err := client.GetParameter(context.TODO(), &ssm.GetParameterInput{
        Name:           &parameterName,
        WithDecryption: &mTrue,
    })

    if err != nil {
        fmt.Printf("[ERROR] getting parameter %s: %s\n", parameterName, err)
        os.Exit(0)
    }

Next we will write the value to a file for persistent use. Simply write output value to a file.

    file, err := os.Create(fileName)
    if err != nil {
        fmt.Printf("[ERROR] creating file %s: %s\n", fileName, err)
        os.Exit(0)
    }

    defer file.Close()

    _, err = file.WriteString(*output.Parameter.Value)
    if err != nil {
        fmt.Printf("[ERROR] writing file %s: %s\n", fileName, err)
        os.Exit(0)
    }

In practice the best place to put such sensitive files is /run as it is mounted as tmpfs and is not persisted on the disk. However, these files will be then accessible by any process.

Dockerfile

Let's build our app with Docker and create a sample container with Nginx and our new application. The ssm-get will be run on entrypoint and download Nginx configuration.

FROM golang:1.20-alpine AS builder

WORKDIR /app
COPY go.mod .
COPY go.sum .
COPY main.go .

RUN go build -o ssm-get

FROM nginx:alpine

COPY --from=builder /app/ssm-get /usr/local/bin/ssm-get
COPY 40-nginx-ssm.sh /docker-entrypoint.d/
RUN chmod +x /docker-entrypoint.d/40-nginx-ssm.sh\
    && chmod +x /usr/local/bin/ssm-get

Entrypoint - pull configuration

Let's write a script 40-nginx-ssm.sh that will pull the configuration from SSM Parameter Store and add it to Nginx configuration.

#!/bin/sh
set -e
ssm-get /nginx/config /etc/nginx/conf.d/default.conf
# For testing purposes, print the file to logs
cat /etc/nginx/conf.d/default.conf

Testing on AWS

Let's spin up a test instance on EC2. We will install Docker on it and build our container. Copy the files with SCP (or paste if you prefer) to the new instance. I will use Amazon Linux 2023 and install Docker from default repositories. This container should work also on ECS.

Building of the following Go project might need around 1GB of RAM, so if you are running on a smaller instance, you might want to create a swap file.

$ ssh ec2-user@<instance-ip>
$ sudo yum install docker -y
$ sudo systemctl enable docker
$ sudo systemctl start docker
$ cd ~/ssm-get
$ sudo docker build -m 1G -t test-nginx-ssm:latest .

We can look how large is the output image and ssm-get itself.

$ sudo docker run --rm -it test-nginx-ssm:latest ls -sh /usr/local/bin/ssm-get
   9.0M /usr/local/bin/ssm-get
$ sudo docker image ls test-nginx-ssm
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
test-nginx-ssm   latest    713ec6b49ee5   41 seconds ago   62.2MB

As you see the new application takes only 9MB and the complete image is 62.2MB. Compared it to pure AWS CLI 400MB.

$ sudo docker image ls amazon/aws-cli:latest
REPOSITORY       TAG       IMAGE ID       CREATED      SIZE
amazon/aws-cli   latest    fb1dd853adc1   4 days ago   406MB

So, now we will put some configuration to SSM Parameter Store and run our container to see if it works. Also open port 80 on the instance to test if the configuration for Nginx is parsed by the server.

server {
    listen 80;
    error_log /dev/stderr;
    access_log /dev/stdout;

    server_name _;

    default_type text/html;

    location / {
        return 200 "OK: Nginx $nginx_version,\n host $hostname,\n remote ip $remote_addr\n";
    }
}

The instance should have role with permissions to read SSM Parameters. It is enough to just apply AmazonSSMManagedInstanceCore policy.

Let's start the container and in our browser go to the instance IP.

$ sudo docker run --rm -it -p 80:80 test-nginx-ssm:latest

Nginx test page

Our configuration was loaded! Now every start of the container, the configuration will be downloaded from SSM Parameter Store.