diff --git a/plugins/ecs/ecs.go b/plugins/ecs/ecs.go index 7bd023c..ba25b9d 100644 --- a/plugins/ecs/ecs.go +++ b/plugins/ecs/ecs.go @@ -2,9 +2,14 @@ package ecs import ( "bufio" + "context" + "encoding/json" + "net" + "net/http" "os" "runtime" "strings" + "time" "github.com/shogo82148/aws-xray-yasdk-go/xray" "github.com/shogo82148/aws-xray-yasdk-go/xray/schema" @@ -13,7 +18,8 @@ import ( const cgroupPath = "/proc/self/cgroup" type plugin struct { - ECS *schema.ECS + ECS *schema.ECS + logReferences []*schema.LogReference } // Init activates ECS Plugin at runtime. @@ -21,8 +27,19 @@ func Init() { if runtime.GOOS != "linux" { return } - uri := os.Getenv("ECS_CONTAINER_METADATA_URI") - if !strings.HasPrefix(uri, "http://") { + client := newMetadataFetcher() + if client == nil { + // not in ECS Container, skip installing the plugin + return + } + // we don't reuse the client, so release its resources. + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + meta, err := client.Fetch(ctx) + if err != nil { return } hostname, err := os.Hostname() @@ -31,9 +48,11 @@ func Init() { } xray.AddPlugin(&plugin{ ECS: &schema.ECS{ - Container: hostname, - ContainerID: containerID(cgroupPath), + Container: hostname, + ContainerID: containerID(cgroupPath), + ContainerArn: meta.ContainerARN, }, + logReferences: meta.LogReferences(), }) } @@ -43,6 +62,7 @@ func (p *plugin) HandleSegment(seg *xray.Segment, doc *schema.Segment) { doc.AWS = schema.AWS{} } doc.AWS.SetECS(p.ECS) + doc.AWS.AddLogReferences(p.logReferences) } // Origin implements Plugin. @@ -68,3 +88,118 @@ func containerID(cgroup string) string { } return line[len(line)-idLength:] } + +type containerMetadata struct { + ContainerARN string + LogDriver string + LogOptions *logOptions + + // we don't use other fields +} + +type logOptions struct { + AWSLogsGroup string `json:"awslogs-group"` + AWSLogsRegion string `json:"awslogs-region"` +} + +func (meta *containerMetadata) AccountID() string { + arn := meta.ContainerARN + + // trim "arn:aws:ecs:${AWS::Region}:" + for i := 0; i < 4; i++ { + idx := strings.IndexByte(arn, ':') + if idx < 0 { + return "" + } + arn = arn[idx+1:] + } + + idx := strings.IndexByte(arn, ':') + if idx < 0 { + return "" + } + return arn[:idx] +} + +func (meta *containerMetadata) LogReferences() []*schema.LogReference { + opt := meta.LogOptions + if opt == nil || opt.AWSLogsGroup == "" { + return nil + } + + accountID := meta.AccountID() + var arn string + if opt.AWSLogsRegion != "" && accountID != "" { + arn = "arn:aws:logs:" + opt.AWSLogsRegion + ":" + accountID + ":log-group:" + opt.AWSLogsGroup + } + + return []*schema.LogReference{ + { + LogGroup: opt.AWSLogsGroup, + ARN: arn, + }, + } +} + +type metadataFetcher struct { + client *http.Client + url string +} + +func newMetadataFetcher() *metadataFetcher { + url := os.Getenv("ECS_CONTAINER_METADATA_URI_V4") + if url == "" { + // fallback to v3 endpoint + url = os.Getenv("ECS_CONTAINER_METADATA_URI") + } + if !strings.HasPrefix(url, "http://") { + return nil + } + client := &http.Client{ + Transport: &http.Transport{ + // ignore proxy configure from the environment values + Proxy: nil, + + // metadata endpoint is in same network, + // so timeout can be shorter. + DialContext: (&net.Dialer{ + Timeout: time.Second, + KeepAlive: time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 5, + IdleConnTimeout: time.Second, + TLSHandshakeTimeout: time.Second, + ExpectContinueTimeout: time.Second, + }, + Timeout: time.Second, + } + return &metadataFetcher{ + client: client, + url: url, + } +} + +func (c *metadataFetcher) Fetch(ctx context.Context) (*containerMetadata, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var data containerMetadata + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *metadataFetcher) Close() { + c.client.CloseIdleConnections() +} diff --git a/plugins/ecs/ecs_test.go b/plugins/ecs/ecs_test.go index baaf398..7eb9f22 100644 --- a/plugins/ecs/ecs_test.go +++ b/plugins/ecs/ecs_test.go @@ -1,7 +1,11 @@ package ecs import ( + "context" + "io" "io/ioutil" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -24,3 +28,103 @@ func TestContainerID(t *testing.T) { t.Errorf("want %s, got %s", want, got) } } + +func TestMetadataFetcher(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.WriteString(w, `{ + "DockerId": "7de2c0ca988f4162be5606783ffd0f6c-607325679", + "Name": "example", + "DockerName": "example", + "Image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/example:latest", + "ImageID": "sha256:519d5922ec67f6a999740104362cadad3256011c2a39a59ad628b6727936807a", + "Labels": { + "com.amazonaws.ecs.cluster": "arn:aws:ecs:ap-northeast-1:123456789012:cluster/example", + "com.amazonaws.ecs.container-name": "example", + "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:123456789012:task/example/7de2c0ca988f4162be5606783ffd0f6c", + "com.amazonaws.ecs.task-definition-family": "example", + "com.amazonaws.ecs.task-definition-version": "9" + }, + "DesiredStatus": "RUNNING", + "KnownStatus": "RUNNING", + "Limits": { + "CPU": 2 + }, + "CreatedAt": "2022-02-28T08:36:52.148607764Z", + "StartedAt": "2022-02-28T08:36:52.148607764Z", + "Type": "NORMAL", + "Networks": [ + { + "NetworkMode": "awsvpc", + "IPv4Addresses": [ + "10.0.130.95" + ], + "AttachmentIndex": 0, + "MACAddress": "00:00:00:00:00:00", + "IPv4SubnetCIDRBlock": "10.0.130.0/24", + "DomainNameServers": [ + "10.0.0.2" + ], + "DomainNameSearchList": [ + "ap-northeast-1.compute.internal" + ], + "PrivateDNSName": "ip-10-0-130-95.ap-northeast-1.compute.internal", + "SubnetGatewayIpv4Address": "10.0.130.1/24" + } + ], + "ContainerARN": "arn:aws:ecs:ap-northeast-1:123456789012:container/example/7de2c0ca988f4162be5606783ffd0f6c/72d0588f-609e-4824-b565-b00d034a7f22", + "LogOptions": { + "awslogs-group": "/foobar/example", + "awslogs-region": "ap-northeast-1", + "awslogs-stream": "development/example/7de2c0ca988f4162be5606783ffd0f6c" + }, + "LogDriver": "awslogs" + }`) + if err != nil { + panic(err) + } + })) + defer ts.Close() + + os.Setenv("ECS_CONTAINER_METADATA_URI_V4", ts.URL) + defer os.Unsetenv("ECS_CONTAINER_METADATA_URI_V4") + + c := newMetadataFetcher() + if c == nil { + t.Fatalf("failed to initialize fetcher") + } + meta, err := c.Fetch(context.Background()) + if err != nil { + t.Fatal(err) + } + + const containerARN = "arn:aws:ecs:ap-northeast-1:123456789012:container/example/7de2c0ca988f4162be5606783ffd0f6c/72d0588f-609e-4824-b565-b00d034a7f22" + if meta.ContainerARN != containerARN { + t.Errorf("unexpected container arn: want %q, got %q", containerARN, meta.ContainerARN) + } + + const logGroup = "/foobar/example" + if meta.LogOptions.AWSLogsGroup != logGroup { + t.Errorf("unexpected log group: want %q, got %q", logGroup, meta.LogOptions.AWSLogsGroup) + } + const logRegion = "ap-northeast-1" + if meta.LogOptions.AWSLogsRegion != logRegion { + t.Errorf("unexpected log region: want %q, got %q", logRegion, meta.LogOptions.AWSLogsRegion) + } + + if want, got := "123456789012", meta.AccountID(); got != want { + t.Errorf("unexpected account id: want %s, got %s", want, got) + } + + logs := meta.LogReferences() + if len(logs) != 1 { + t.Errorf("unexpected logs count: want 1, got %d", len(logs)) + } + if logs[0].LogGroup != logGroup { + t.Errorf("unexpected log group: want %q, got %q", logGroup, logs[0].LogGroup) + } + + logArn := "arn:aws:logs:ap-northeast-1:123456789012:log-group:/foobar/example" + if logs[0].ARN != logArn { + t.Errorf("unexpected log group: want %q, got %q", logArn, logs[0].ARN) + } +} diff --git a/plugins/eks/eks.go b/plugins/eks/eks.go index c933917..da7f73d 100644 --- a/plugins/eks/eks.go +++ b/plugins/eks/eks.go @@ -70,7 +70,7 @@ func Init() { }, } // we don't reuse the client, so release its resources. - defer closeIdleConnections(client) + defer client.CloseIdleConnections() hostname, err := os.Hostname() if err != nil { @@ -167,14 +167,3 @@ func clusterName(ctx context.Context, client *http.Client, token string) string xraylog.Debugf(ctx, "cluster name is %s", data.Data.ClusterName) return data.Data.ClusterName } - -// call CloseIdleConnections() if the client have the method. -// for Go 1.11 -func closeIdleConnections(client any) { - type IdleConnectionsCloser interface { - CloseIdleConnections() - } - if c, ok := client.(IdleConnectionsCloser); ok { - c.CloseIdleConnections() - } -} diff --git a/xray/schema/schema.go b/xray/schema/schema.go index d2413fa..10a3597 100644 --- a/xray/schema/schema.go +++ b/xray/schema/schema.go @@ -218,16 +218,25 @@ func (aws AWS) AddLogReferences(logs []*LogReference) { // ECS is information about an Amazon ECS container. type ECS struct { - // The container ID of the container running your application. + // The hostname of your container. Container string `json:"container,omitempty"` + // The full container ID of your container. ContainerID string `json:"container_id,omitempty"` + + // The ARN of your container instance. + ContainerArn string `json:"container_arn,omitempty"` } // EKS is information about an Amazon EKS container. type EKS struct { + // The hostname of your EKS pod. + Pod string `json:"pod,omitempty"` + + // The EKS cluster name. ClusterName string `json:"cluster_name,omitempty"` - Pod string `json:"pod,omitempty"` + + // The full container ID of your container. ContainerID string `json:"container_id,omitempty"` }